diff --git a/.github/scripts/Test-WorkflowSecurity.ps1 b/.github/scripts/Test-WorkflowSecurity.ps1 new file mode 100644 index 00000000..0bd3d266 --- /dev/null +++ b/.github/scripts/Test-WorkflowSecurity.ps1 @@ -0,0 +1,248 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$workflowDirectory = Join-Path $repositoryRoot '.github\workflows' +$workflowFiles = @(Get-ChildItem -LiteralPath $workflowDirectory -File -Filter '*.yml') + +foreach ($workflow in $workflowFiles) { + $source = Get-Content -LiteralPath $workflow.FullName -Raw + foreach ($match in [regex]::Matches($source, '(?m)^\s*uses:\s*(?[^\s#]+)')) { + $reference = $match.Groups['reference'].Value + if ($reference.StartsWith('./', [StringComparison]::Ordinal)) { + continue + } + if ($reference -notmatch '^[^@\s]+@[0-9a-f]{40}$') { + throw "$($workflow.Name) uses mutable or malformed action reference '$reference'. External actions must use a full commit SHA." + } + } + if ($source -match '(?m)^\s*go-version\s*:') { + throw "$($workflow.Name) selects a floating Go toolchain. Use the exact version declared by go.mod." + } + if ($source -match '(?m)^\s*runs-on:\s*(?:ubuntu|windows|macos)-latest\s*$') { + throw "$($workflow.Name) selects a floating hosted-runner generation. Pin the OS generation." + } + if ($source -match '(?mi)vswhere\.exe[^\r\n]*\s-latest(?:\s|$)') { + throw "$($workflow.Name) selects a floating Visual Studio toolchain with vswhere -latest." + } + foreach ($pattern in @( + '(?mi)^\s*(?:python|node|dotnet|cmake|nuget|just)-version:\s*["'']?(?:latest|stable|\d+(?:\.\d+)*\.x)["'']?\s*$', + '(?mi)^\s*toolchain:\s*["'']?(?:stable|beta|nightly)["'']?\s*$')) { + if ($source -match $pattern) { + throw "$($workflow.Name) selects a floating release toolchain: '$($Matches[0].Trim())'." + } + } + $justSetups = [regex]::Matches($source, 'extractions/setup-just@[0-9a-f]{40}').Count + $justPins = [regex]::Matches($source, '(?m)^\s*just-version:\s*"1\.58\.0"\s*$').Count + if ($justSetups -ne $justPins) { + throw "$($workflow.Name) must pin just 1.58.0 for every setup-just action." + } + $msbuildSetups = [regex]::Matches($source, 'microsoft/setup-msbuild@[0-9a-f]{40}').Count + $msbuildPins = [regex]::Matches($source, '(?m)^\s*vs-version:\s*"\[18\.0,19\.0\)"\s*$').Count + if ($msbuildSetups -ne $msbuildPins) { + throw "$($workflow.Name) must constrain every MSBuild setup to the Visual Studio 2026 generation." + } +} + +$releaseSource = Get-Content -LiteralPath (Join-Path $workflowDirectory 'release.yml') -Raw +foreach ($required in @( + 'native-validation', + 'native-package-transaction', + 'native-production-provenance', + 'native-user-mode-signing', + 'Protect-ViiperWindowsReleaseBinaries.ps1', + 'Test-ViiperUdeReleaseBundle.ps1', + '-RequireAuthenticode', + 'viiper-native-udecx-windows-amd64.zip', + 'Test-WorkflowSecurity.ps1', + 'actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a')) { + if (-not $releaseSource.Contains($required)) { + throw "The release workflow is missing required gate '$required'." + } +} +if ($releaseSource -notmatch '(?ms)^\s{4}create-release:\s.*?^\s{8}needs:\s*\[[^\]]*native-validation[^\]]*native-package-transaction[^\]]*\]') { + throw 'create-release must depend on both native validation and package-transaction gates.' +} +if ($releaseSource -notmatch '(?ms)^\s{4}create-release:\s.*?^\s{8}needs:\s*\[[^\]]*native-production-provenance[^\]]*\]') { + throw 'create-release must depend on an accepted Microsoft production-package artifact.' +} +if ($releaseSource -notmatch '(?ms)^\s{4}create-release:\s.*?^\s{8}needs:\s*\[[^\]]*native-user-mode-signing[^\]]*\]') { + throw 'create-release must depend on the fail-closed broker/helper Authenticode signing gate.' +} +if ($releaseSource -notmatch '(?ms)^\s{4}release-policy:\s.*?current origin/main tip') { + throw 'Release tags must be constrained to the workflow-protected current main tip.' +} +if ($releaseSource.Contains('ViiperUde-x64-test-signed')) { + throw 'The production release workflow must never consume the native test-signed artifact.' +} +if ([regex]::Matches($releaseSource, 'pattern:\s*"\*-Release"').Count -ne 2) { + throw 'Release artifact downloads must use the explicit *-Release artifact allowlist.' +} +foreach ($requiredProductionBinding in @( + '.github/workflows/native-production-package.yml', + '.head_branch == "main"', + '.head_sha == $sha', + 'artifact-ids: ${{ needs.native-production-provenance.outputs.artifact_id }}', + 'ViiperUdeCtl-windows-amd64-${{ github.sha }}')) { + if (-not $releaseSource.Contains($requiredProductionBinding)) { + throw "The release workflow is missing production provenance binding '$requiredProductionBinding'." + } +} +if ($releaseSource -notmatch "(?ms)\`$expectedProduction\s*=\s*@\(\s*'submission-manifest\.json',\s*'ViiperUde/ViiperUde\.cat',\s*'ViiperUde/ViiperUde\.inf',\s*'ViiperUde/ViiperUde\.pdb',\s*'ViiperUde/ViiperUde\.sys'\)") { + throw 'Release composition must allowlist the exact validated Microsoft-returned package.' +} +if ($releaseSource -notmatch '(?ms)expected_runtime=\(\s*ViiperUde\.cat\s*ViiperUde\.inf\s*ViiperUde\.sys\s*ViiperUdeCtl\.exe\s*submission-manifest\.json\s*viiper\.exe\s*\)') { + throw 'The public native runtime archive must contain exactly broker, helper, INF, SYS, CAT, and manifest.' +} + +$signingJob = [regex]::Match( + $releaseSource, + '(?ms)^\s{4}native-user-mode-signing:\s.*?(?=^\s{4}build:)').Value +if ([string]::IsNullOrWhiteSpace($signingJob)) { + throw 'The release workflow is missing the mandatory native user-mode signing job.' +} +foreach ($requiredSigningGate in @( + 'WINDOWS_SIGNING_PFX_BASE64', + 'WINDOWS_SIGNING_PFX_PASSWORD', + 'WINDOWS_SIGNING_CERTIFICATE_SHA256', + 'Protect-ViiperWindowsReleaseBinaries.ps1', + 'ViiperUdeCtl.exe verify', + 'VIIPER-windows-amd64-authenticode-${{ github.sha }}', + 'VIIPER-windows-arm64-authenticode-${{ github.sha }}', + 'VIIPER-native-udecx-authenticode-${{ github.sha }}')) { + if (-not $signingJob.Contains($requiredSigningGate)) { + throw "The native signing job is missing fail-closed contract '$requiredSigningGate'." + } +} +if ([regex]::Matches($signingJob, '-RequireAuthenticode').Count -lt 2 -or + [regex]::Matches($signingJob, '-ExpectedSignerCertificateSHA256').Count -lt 2) { + throw 'The native signing job must Authenticode-validate both composition and archive roundtrip with the pinned signer fingerprint.' +} + +$createReleaseJob = [regex]::Match( + $releaseSource, + '(?ms)^\s{4}create-release:\s.*?(?=^\s{4}publish-client-registries:)').Value +foreach ($signedArtifact in @( + 'VIIPER-windows-amd64-authenticode-${{ github.sha }}', + 'VIIPER-windows-arm64-authenticode-${{ github.sha }}', + 'VIIPER-native-udecx-authenticode-${{ github.sha }}')) { + if (-not $createReleaseJob.Contains($signedArtifact)) { + throw "create-release must consume the exact signed artifact '$signedArtifact'." + } +} +if ($createReleaseJob.Contains('path: native-helper') -or + $createReleaseJob.Contains('path: native-production')) { + throw 'create-release must not reconstruct the public package from unsigned helper or production-intake inputs.' +} + +$nativeWorkflow = Get-Content -LiteralPath (Join-Path $workflowDirectory 'native-ude.yml') -Raw +if ($nativeWorkflow -notmatch '(?m)^\s*if:\s*\$\{\{\s*inputs\.upload_artifacts\s*==\s*true\s*\}\}\s*$') { + throw 'Native test-signed artifacts may upload only through the explicit Boolean test-artifact input.' +} +foreach ($requiredNativeGate in @( + 'branches: [main, "feature/**"]', + 'tags: ["v*.*.*"]', + 'VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}', + 'Get-ViiperUdeBuildIdentity.ps1', + '180003f7b141c8015c29e7b3dcb6d252601ca6e82e6cc43b4480db31e167a660', + 'Test-ViiperUdeVersionMonotonicity.ps1', + 'x64/Release/ViiperUde/ViiperUde.inf', + 'inputs.upload_release_helper == true', + 'New-ViiperUdeLocalTestPackage.ps1', + 'ViiperUde-x64-local-test-${{ github.sha }}', + 'native/udecx/x64/Release/ViiperUdeLocalTest/**', + 'retention-days: 7', + 'internal/transport/udecx.nativeSourceRevision=$env:GITHUB_SHA')) { + if (-not $nativeWorkflow.Contains($requiredNativeGate)) { + throw "The native build workflow is missing gate '$requiredNativeGate'." + } +} +if ($nativeWorkflow.Contains('native/udecx/x64/Release/**') -or + $nativeWorkflow.Contains('native/udecx/driver/x64/Release/**') -or + $nativeWorkflow.Contains('native/udecx/package/x64/Release/**')) { + throw 'The local-test artifact must not upload broad compiler output trees.' +} + +$baseBuildWorkflow = Get-Content -LiteralPath (Join-Path $workflowDirectory 'build_base.yml') -Raw +if (-not $baseBuildWorkflow.Contains('VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}')) { + throw 'Production broker builds must inject the exact workflow source SHA.' +} +$justfile = Get-Content -LiteralPath (Join-Path $repositoryRoot 'justfile') -Raw +foreach ($requiredBuildIdentityGate in @( + 'Release builds require explicit VIIPER_NATIVE_SOURCE_REVISION.', + 'internal/transport/udecx.nativeSourceRevision=')) { + if (-not $justfile.Contains($requiredBuildIdentityGate)) { + throw "The release broker build is missing identity gate '$requiredBuildIdentityGate'." + } +} + +$transactionWorkflow = Get-Content -LiteralPath (Join-Path $workflowDirectory 'native-package-transaction.yml') -Raw +foreach ($requiredTransactionTrigger in @( + 'branches: [main, "feature/**"]', + 'tags: ["v*.*.*"]', + 'pull_request:')) { + if (-not $transactionWorkflow.Contains($requiredTransactionTrigger)) { + throw "The native transaction workflow is missing trigger '$requiredTransactionTrigger'." + } +} +if ($transactionWorkflow.Contains('paths:')) { + throw 'Native transaction simulations must not be bypassable through a path filter.' +} + +$productionWorkflow = Get-Content -LiteralPath (Join-Path $workflowDirectory 'native-production-package.yml') -Raw +foreach ($required in @( + 'Test-ViiperUdeSignedPackage.ps1', + '-ValidationMode Production', + 'Microsoft-signed', + 'signingRoute', + "POLICY_REF -cne 'refs/heads/main'", + 'Test-ViiperUdeTargetCompatibility.ps1')) { + if (-not $productionWorkflow.Contains($required)) { + throw "The production-native workflow is missing required validation contract '$required'." + } +} + +$justfileSource = Get-Content -LiteralPath (Join-Path $repositoryRoot 'justfile') -Raw +if ($justfileSource.Contains('@latest') -or + $justfileSource -notmatch 'goversioninfo/cmd/goversioninfo@v1\.7\.0' -or + $justfileSource -notmatch 'go-licenses/v2@v2\.0\.1') { + throw 'Release build helper dependencies in justfile must remain exactly pinned.' +} +if ($productionWorkflow -match '(?m)^\s{2}(?:push|pull_request):') { + throw 'Production Microsoft-signed package acceptance must remain an explicit manual intake path.' +} + +$goDirective = Get-Content -LiteralPath (Join-Path $repositoryRoot 'go.mod') -TotalCount 3 | + Where-Object { $_ -match '^go\s+' } | + Select-Object -First 1 +if ($goDirective -notmatch '^go\s+\d+\.\d+\.\d+$') { + throw "go.mod must pin a complete Go toolchain version; found '$goDirective'." +} + +$packagesPath = Join-Path $repositoryRoot 'native\udecx\driver\packages.config' +[xml]$packages = Get-Content -LiteralPath $packagesPath -Raw +$expectedWdkVersion = '10.0.28000.1839' +$expectedPackages = @( + 'Microsoft.Windows.SDK.CPP', + 'Microsoft.Windows.SDK.CPP.x64', + 'Microsoft.Windows.WDK.x64' +) +foreach ($packageId in $expectedPackages) { + $matches = @($packages.packages.package | Where-Object { $_.id -ceq $packageId }) + if ($matches.Count -ne 1 -or $matches[0].version -cne $expectedWdkVersion) { + throw "Native package '$packageId' must be pinned exactly to $expectedWdkVersion." + } +} + +$projectSource = Get-Content -LiteralPath (Join-Path $repositoryRoot 'native\udecx\driver\ViiperUde.vcxproj') -Raw +foreach ($packageId in $expectedPackages) { + $escapedPath = [regex]::Escape("$packageId.$expectedWdkVersion") + if ($projectSource -notmatch $escapedPath) { + throw "The native project does not import exact package '$packageId.$expectedWdkVersion'." + } +} + +Write-Host 'Workflow action pins, release gates, provenance, and native toolchain contracts are deterministic.' diff --git a/.github/workflows/build_base.yml b/.github/workflows/build_base.yml index ba9a92e7..a86306f8 100644 --- a/.github/workflows/build_base.yml +++ b/.github/workflows/build_base.yml @@ -14,32 +14,33 @@ on: default: false description: "Whether to upload build artifacts" +env: + GOFLAGS: -mod=readonly + GOTOOLCHAIN: local + VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }} + jobs: test: name: Test - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6.4.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: stable + go-version-file: go.mod cache: true cache-dependency-path: | go.sum - name: Setup just - uses: extractions/setup-just@v3 - - - name: Install goversioninfo (Windows) - if: ${{ matrix.target.goos == 'windows' }} - shell: pwsh - run: | - go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@v1.7.0 + uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3 + with: + just-version: "1.58.0" - name: Show Go version run: go version @@ -75,16 +76,16 @@ jobs: } - name: Lint - uses: golangci/golangci-lint-action@v9.2.0 + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: - version: latest + version: v2.12.2 install-mode: goinstall - name: Run tests run: just test-coverage - name: Upload coverage to Codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6 with: token: ${{ secrets.CODECOV_TOKEN }} directory: . @@ -98,13 +99,13 @@ jobs: fail-fast: false matrix: target: - - { goos: linux, goarch: amd64, ext: "", runner: ubuntu-latest } - - { goos: linux, goarch: arm64, ext: "", runner: ubuntu-latest } - - { goos: windows, goarch: amd64, ext: ".exe", runner: windows-latest } - - { goos: windows, goarch: arm64, ext: ".exe", runner: windows-latest } + - { goos: linux, goarch: amd64, ext: "", runner: ubuntu-24.04 } + - { goos: linux, goarch: arm64, ext: "", runner: ubuntu-24.04 } + - { goos: windows, goarch: amd64, ext: ".exe", runner: windows-2025 } + - { goos: windows, goarch: arm64, ext: ".exe", runner: windows-2025 } steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 @@ -114,15 +115,17 @@ jobs: run: ./scripts/test-install-first-run.ps1 - name: Set up Go - uses: actions/setup-go@v6.4.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: 1.26 + go-version-file: go.mod cache: true cache-dependency-path: | go.sum - name: Setup just - uses: extractions/setup-just@v3 + uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3 + with: + just-version: "1.58.0" - name: Install goversioninfo shell: pwsh @@ -162,7 +165,7 @@ jobs: - name: Upload artifact (Linux) if: ${{ inputs.upload_artifacts && matrix.target.goos == 'linux' }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: VIIPER-${{ matrix.target.goos }}-${{ matrix.target.goarch }}${{ inputs.artifact_suffix }} path: dist/viiper-${{ matrix.target.goos }}-${{ matrix.target.goarch }}.tar.gz @@ -170,7 +173,7 @@ jobs: - name: Upload artifact (Windows) if: ${{ inputs.upload_artifacts && matrix.target.goos == 'windows' }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: VIIPER-${{ matrix.target.goos }}-${{ matrix.target.goarch }}${{ inputs.artifact_suffix }} path: dist/viiper-${{ matrix.target.goos }}-${{ matrix.target.goarch }}.zip @@ -178,24 +181,26 @@ jobs: libviiper-linux: name: Build libVIIPER (linux/amd64) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: test steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6.4.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: 1.26 + go-version-file: go.mod cache: true cache-dependency-path: | go.sum - name: Setup just - uses: extractions/setup-just@v3 + uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3 + with: + just-version: "1.58.0" - name: Build run: | @@ -206,7 +211,7 @@ jobs: - name: Upload artifact if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: libVIIPER-linux-amd64${{ inputs.artifact_suffix }} path: dist/libVIIPER/libVIIPER-linux-amd64.zip @@ -214,24 +219,26 @@ jobs: libviiper-windows: name: Build libVIIPER (windows/amd64) - runs-on: windows-latest + runs-on: windows-2025 needs: test steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6.4.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: 1.26 + go-version-file: go.mod cache: true cache-dependency-path: | go.sum - name: Setup just - uses: extractions/setup-just@v3 + uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3 + with: + just-version: "1.58.0" - name: Install build tools shell: pwsh @@ -255,7 +262,7 @@ jobs: - name: Upload artifact if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: libVIIPER-windows-amd64${{ inputs.artifact_suffix }} path: dist/libVIIPER/libVIIPER-windows-amd64.zip diff --git a/.github/workflows/clients_ci.yml b/.github/workflows/clients_ci.yml index b216424b..6edba934 100644 --- a/.github/workflows/clients_ci.yml +++ b/.github/workflows/clients_ci.yml @@ -21,21 +21,25 @@ on: default: "" description: "Override version injected via ldflags (e.g. tag v1.2.3)" -permissions: - contents: read +permissions: + contents: read + +env: + GOFLAGS: -mod=readonly + GOTOOLCHAIN: local jobs: codegen: name: Code generation - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: stable + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod cache: true cache-dependency-path: go.sum @@ -52,7 +56,7 @@ jobs: fi - name: Upload generated clients - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: generated-clients path: clients/ @@ -61,21 +65,21 @@ jobs: typescript: name: TypeScript Client Library needs: codegen - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Download generated clients - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: generated-clients path: clients/ - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: "24" + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24.11.1" cache: "npm" cache-dependency-path: | clients/typescript/package-lock.json @@ -106,7 +110,7 @@ jobs: - name: Upload TypeScript Client Library tarball if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: typescript-client-library${{ inputs.artifact_suffix }} path: clients/typescript/viiperclient-typescript-client-library${{ inputs.artifact_suffix }}.tgz @@ -115,21 +119,21 @@ jobs: csharp: name: C# Client Library needs: codegen - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Download generated clients - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: generated-clients path: clients/ - name: Set up .NET SDK - uses: actions/setup-dotnet@v5 - with: - dotnet-version: "8.0.x" + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + with: + dotnet-version: "8.0.419" - name: Pack C# Client Library run: dotnet pack clients/csharp/Viiper.Client/Viiper.Client.csproj -c Release -o artifacts/nuget @@ -142,7 +146,7 @@ jobs: - name: Upload C# Client Library nupkg if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: csharp-client-library-nupkg${{ inputs.artifact_suffix }} path: artifacts/nuget/*.nupkg @@ -151,21 +155,21 @@ jobs: cpp-sdk: name: C++ Client Library needs: codegen - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Download generated clients - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: generated-clients path: clients/ - name: Set up CMake - uses: jwlawson/actions-setup-cmake@v2.2.0 - with: - cmake-version: "3.26.x" + uses: jwlawson/actions-setup-cmake@0d6a7d60b009d01c9e7523be22153ff8f19460d3 # v2.2.0 + with: + cmake-version: "3.26.6" - name: Install OpenSSL (libssl-dev) run: | @@ -185,7 +189,7 @@ jobs: - name: Upload C++ Client Library headers if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: cpp-client-library-headers${{ inputs.artifact_suffix }} path: cpp-client-library-headers${{ inputs.artifact_suffix }}.zip @@ -194,19 +198,21 @@ jobs: rust: name: Rust Client Library needs: codegen - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Download generated clients - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: generated-clients path: clients/ - name: Set up Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: "1.97.1" - name: Build Rust Client Library working-directory: clients/rust @@ -234,7 +240,7 @@ jobs: - name: Upload Rust Client Library crate if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: rust-client-library${{ inputs.artifact_suffix }} path: clients/rust/target/package/viiper-client-rust-client-library${{ inputs.artifact_suffix }}.crate diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index c5f539cd..7212c6c6 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -20,10 +20,10 @@ concurrency: jobs: deploy-docs: name: Deploy Documentation - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 @@ -33,9 +33,9 @@ jobs: git config user.email github-actions[bot]@users.noreply.github.com - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: - python-version: 3.x + python-version: "3.14.6" - name: Install dependencies run: | diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index 64884d7e..eee564b8 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -18,12 +18,12 @@ on: jobs: generate: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: changelog: ${{ steps.generate_changelog.outputs.changelog }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 diff --git a/.github/workflows/native-package-transaction.yml b/.github/workflows/native-package-transaction.yml new file mode 100644 index 00000000..a217ab04 --- /dev/null +++ b/.github/workflows/native-package-transaction.yml @@ -0,0 +1,55 @@ +name: Native package transaction + +on: + push: + branches: [main, "feature/**"] + tags: ["v*.*.*"] + pull_request: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +env: + GOFLAGS: -mod=readonly + GOTOOLCHAIN: local + +jobs: + fail-closed-simulation: + runs-on: windows-2025-vs2026 + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go.mod + cache: true + - name: Verify exact Go dependency graph + shell: pwsh + run: | + $expected = ((Get-Content go.mod | Where-Object { $_ -match '^go\s+' } | Select-Object -First 1) -split '\s+')[1] + $actual = (go env GOVERSION).TrimStart('g', 'o') + if ($actual -cne $expected) { throw "Expected Go $expected; runner selected $actual." } + go mod verify + - name: Run deterministic package transaction simulations + shell: pwsh + run: go test -count=1 -run '^TestNativePackage' ./internal/cmd + - name: Enforce package helper source contract + shell: pwsh + run: ./native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 + - name: Compile and self-test package helper + shell: pwsh + run: | + $vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -version '[18.0,19.0)' -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $vs) { throw "Visual C++ toolchain was not found" } + $devCmd = Join-Path $vs "Common7\Tools\VsDevCmd.bat" + $source = (Resolve-Path "native\udecx\tools\ViiperUdeCtl.cpp").Path + $output = Join-Path $env:RUNNER_TEMP "ViiperUdeCtl.exe" + $command = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /MT /DUNICODE /D_UNICODE `"$source`" /Fe:`"$output`" /link Setupapi.lib Newdev.lib Cfgmgr32.lib Advapi32.lib Crypt32.lib Wintrust.lib" + cmd.exe /d /s /c $command + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $output)) { + throw "ViiperUdeCtl build failed" + } + & $output self-test + if ($LASTEXITCODE -ne 0) { throw "ViiperUdeCtl self-test failed" } diff --git a/.github/workflows/native-production-package.yml b/.github/workflows/native-production-package.yml new file mode 100644 index 00000000..9f1daf1e --- /dev/null +++ b/.github/workflows/native-production-package.yml @@ -0,0 +1,221 @@ +name: Validate production native package + +on: + workflow_dispatch: + inputs: + source_revision: + description: Full reviewed source commit represented by the Microsoft-signed package. + required: true + type: string + artifact_run_id: + description: Workflow run containing the immutable Microsoft-returned artifact. + required: true + type: string + artifact_id: + description: Immutable GitHub artifact ID; names and broad downloads are not accepted. + required: true + type: string + artifact_digest: + description: GitHub artifact SHA-256 digest, without the sha256 prefix. + required: true + type: string + package_directory: + description: Relative path to the four-file driver package inside the artifact. + required: true + type: string + submission_manifest_path: + description: Relative path to the source-bound submission manifest inside the artifact. + required: true + type: string + +permissions: + actions: read + attestations: write + contents: read + id-token: write + +jobs: + validate-production-package: + name: Accept Microsoft HLK/WHCP-signed native package + runs-on: windows-2025-vs2026 + timeout-minutes: 20 + steps: + - name: Checkout trusted validation policy + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + path: gate-policy + persist-credentials: false + + - name: Validate explicit artifact provenance + shell: pwsh + env: + ARTIFACT_DIGEST: ${{ inputs.artifact_digest }} + ARTIFACT_ID: ${{ inputs.artifact_id }} + ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }} + POLICY_REF: ${{ github.ref }} + POLICY_REVISION: ${{ github.sha }} + SOURCE_REVISION: ${{ inputs.source_revision }} + GH_TOKEN: ${{ github.token }} + run: | + if ($env:SOURCE_REVISION -cnotmatch '^[0-9a-f]{40}$') { + throw 'source_revision must be a lowercase, full 40-character Git commit.' + } + if ($env:ARTIFACT_ID -notmatch '^\d+$' -or $env:ARTIFACT_RUN_ID -notmatch '^\d+$') { + throw 'artifact_id and artifact_run_id must be numeric GitHub identifiers.' + } + if ($env:ARTIFACT_DIGEST -cnotmatch '^[0-9a-f]{64}$') { + throw 'artifact_digest must be a lowercase SHA-256 digest.' + } + if ($env:POLICY_REF -cne 'refs/heads/main' -or + $env:POLICY_REVISION -cne $env:SOURCE_REVISION) { + throw 'Production acceptance must run from main at the exact reviewed source revision.' + } + $metadata = gh api "/repos/$env:GITHUB_REPOSITORY/actions/artifacts/$env:ARTIFACT_ID" | ConvertFrom-Json + if ([long]$metadata.id -ne [long]$env:ARTIFACT_ID -or [bool]$metadata.expired) { + throw 'The selected artifact is missing, expired, or does not match artifact_id.' + } + if ([long]$metadata.workflow_run.id -ne [long]$env:ARTIFACT_RUN_ID -or + [string]$metadata.workflow_run.head_sha -cne $env:SOURCE_REVISION) { + throw 'The selected artifact run is not bound to source_revision.' + } + if ([string]$metadata.digest -cne "sha256:$env:ARTIFACT_DIGEST") { + throw 'The selected artifact digest does not match the explicit SHA-256 input.' + } + + - name: Checkout exact reviewed source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ inputs.source_revision }} + fetch-depth: 0 + path: reviewed-source + persist-credentials: false + + - name: Require the exact current reviewed main source + shell: pwsh + env: + SOURCE_REVISION: ${{ inputs.source_revision }} + working-directory: reviewed-source + run: | + git fetch --no-tags origin main + $mainRevision = (git rev-parse origin/main).Trim() + $checkedOutRevision = (git rev-parse HEAD).Trim() + if ($mainRevision -cne $env:SOURCE_REVISION -or + $checkedOutRevision -cne $env:SOURCE_REVISION) { + throw 'Production native packages must represent the exact current origin/main tip.' + } + + - name: Download only the explicit immutable artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ inputs.artifact_id }} + run-id: ${{ inputs.artifact_run_id }} + github-token: ${{ github.token }} + path: signed-input + + - uses: NuGet/setup-nuget@d105a947828025cd7a980103c35ba2bfae586d0f # v2 + with: + nuget-version: "6.11.1" + - name: Restore exact source-bound WDK tools + run: >- + nuget restore reviewed-source/native/udecx/ViiperUde.sln + -PackagesDirectory reviewed-source/native/udecx/packages + -NonInteractive + - name: Expose source-bound WDK validation tools + shell: pwsh + run: | + $tools = Get-ChildItem reviewed-source/native/udecx/packages -Recurse -File -Filter *.exe + foreach ($required in @('signtool.exe', 'infverif.exe')) { + if (-not ($tools | Where-Object Name -ieq $required | Select-Object -First 1)) { + throw "Restored WDK packages did not contain $required." + } + } + $tools.DirectoryName | Sort-Object -Unique | Out-File $env:GITHUB_PATH -Append -Encoding utf8 + + - name: Validate Microsoft-signed production package + id: validate + shell: pwsh + env: + PACKAGE_DIRECTORY: ${{ inputs.package_directory }} + SOURCE_REVISION: ${{ inputs.source_revision }} + SUBMISSION_MANIFEST: ${{ inputs.submission_manifest_path }} + run: | + $artifactRoot = (Resolve-Path -LiteralPath signed-input).Path + function Resolve-ContainedPath([string]$relativePath, [bool]$requireDirectory) { + if ([string]::IsNullOrWhiteSpace($relativePath) -or [IO.Path]::IsPathRooted($relativePath)) { + throw 'Production package inputs must be non-empty relative paths.' + } + $resolved = (Resolve-Path -LiteralPath (Join-Path $artifactRoot $relativePath)).Path + $prefix = $artifactRoot.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar + if (-not $resolved.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Input path '$relativePath' escapes the downloaded artifact." + } + if ((Get-Item -LiteralPath $resolved).PSIsContainer -ne $requireDirectory) { + throw "Input path '$relativePath' has the wrong file type." + } + return $resolved + } + $packagePath = Resolve-ContainedPath $env:PACKAGE_DIRECTORY $true + $manifestPath = Resolve-ContainedPath $env:SUBMISSION_MANIFEST $false + # Production is literal: the validator requires releaseEligible=true, + # signingRoute=HLK/WHCP, Microsoft kernel policy, and rejects attestation EKU. + & ./gate-policy/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 ` + -PackageDirectory $packagePath ` + -SubmissionManifestPath $manifestPath ` + -ExpectedSourceRevision $env:SOURCE_REVISION ` + -ValidationMode Production + "package_path=$packagePath" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + "manifest_path=$manifestPath" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Validate the actual Microsoft-returned stamped INF contract + shell: pwsh + env: + PACKAGE_PATH: ${{ steps.validate.outputs.package_path }} + run: | + & ./reviewed-source/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 ` + -ProjectPath ./reviewed-source/native/udecx/driver/ViiperUde.vcxproj ` + -InfPath (Join-Path $env:PACKAGE_PATH 'ViiperUde.inf') ` + -RequireStampedInf + + - name: Package only validated production bytes + id: package + shell: pwsh + env: + PACKAGE_PATH: ${{ steps.validate.outputs.package_path }} + MANIFEST_PATH: ${{ steps.validate.outputs.manifest_path }} + SOURCE_REVISION: ${{ inputs.source_revision }} + run: | + $staging = Join-Path $env:RUNNER_TEMP 'viiper-production-package' + Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $staging | Out-Null + Copy-Item -LiteralPath $env:PACKAGE_PATH -Destination (Join-Path $staging 'ViiperUde') -Recurse + Copy-Item -LiteralPath $env:MANIFEST_PATH -Destination (Join-Path $staging 'submission-manifest.json') + $archive = Join-Path $env:RUNNER_TEMP "ViiperUde-x64-production-$env:SOURCE_REVISION.zip" + Compress-Archive -Path (Join-Path $staging '*') -DestinationPath $archive -CompressionLevel Optimal + "archive=$archive" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Attest validated production package provenance + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 + with: + subject-path: ${{ steps.package.outputs.archive }} + + - name: Upload validated production package + id: upload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ViiperUde-x64-production-microsoft-signed-${{ inputs.source_revision }} + path: ${{ steps.package.outputs.archive }} + if-no-files-found: error + retention-days: 30 + + - name: Record accepted artifact identity + shell: pwsh + run: | + @" + ### Microsoft-signed native production package accepted + + - Source: `${{ inputs.source_revision }}` + - Artifact ID: `${{ steps.upload.outputs.artifact-id }}` + - Artifact SHA-256: `${{ steps.upload.outputs.artifact-digest }}` + - Validation: literal `Production` (HLK/WHCP; attestation rejected) + "@ | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml new file mode 100644 index 00000000..c8e82943 --- /dev/null +++ b/.github/workflows/native-ude.yml @@ -0,0 +1,682 @@ +name: Native UdeCx bus + +on: + push: + branches: [main, "feature/**"] + tags: ["v*.*.*"] + paths: + - "go.mod" + - "go.sum" + - "justfile" + - "native/udecx/**" + - "internal/transport/udecx/**" + - "internal/server/usb/**" + - "internal/server/api/**" + - "internal/cmd/**" + - "internal/configpaths/**" + - "viipertypes/**" + - "device/**" + - "usb/**" + - "scripts/**" + - "_testing/e2e/**" + - "docs/testing/e2e_latency.md" + - ".github/workflows/build_base.yml" + - ".github/workflows/**" + - ".github/scripts/Test-WorkflowSecurity.ps1" + - ".github/workflows/native-ude.yml" + - ".github/workflows/native-package-transaction.yml" + - ".github/workflows/release.yml" + pull_request: + paths: + - "go.mod" + - "go.sum" + - "justfile" + - "native/udecx/**" + - "internal/transport/udecx/**" + - "internal/server/usb/**" + - "internal/server/api/**" + - "internal/cmd/**" + - "internal/configpaths/**" + - "viipertypes/**" + - "device/**" + - "usb/**" + - "scripts/**" + - "_testing/e2e/**" + - "docs/testing/e2e_latency.md" + - ".github/workflows/build_base.yml" + - ".github/workflows/**" + - ".github/scripts/Test-WorkflowSecurity.ps1" + - ".github/workflows/native-ude.yml" + - ".github/workflows/native-package-transaction.yml" + - ".github/workflows/release.yml" + workflow_call: + inputs: + upload_artifacts: + description: Upload the test-signed native package for controlled testing. + required: false + type: boolean + default: false + upload_release_helper: + description: Upload only the source-built runtime helper for an HLK/WHCP release composition. + required: false + type: boolean + default: false + workflow_dispatch: + inputs: + upload_artifacts: + description: Upload the compact source-bound local test package. + required: false + type: boolean + default: true + upload_release_helper: + description: Upload source-bound helpers and live probes separately. + required: false + type: boolean + default: false + +permissions: + actions: read + contents: read + security-events: write + +env: + GOFLAGS: -mod=readonly + GOTOOLCHAIN: local + VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }} + +# A driver artifact is meaningful only for the exact current branch head. +# Cancel superseded WDK/CodeQL work instead of letting several incompatible +# ABI revisions finish and present equally downloadable test packages. +concurrency: + group: native-ude-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + protocol: + runs-on: windows-2025 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go.mod + cache: true + - name: Verify workflow and native toolchain policy + shell: pwsh + run: | + ./.github/scripts/Test-WorkflowSecurity.ps1 + $identity = ./native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 ` + -SourceRevision 0123456789abcdef0123456789abcdef01234567 ` + -DriverPackageVersion 0.1.0.17 -ABIMajor 1 -ABIMinor 10 -Capabilities 13 + if ($identity -cne '180003f7b141c8015c29e7b3dcb6d252601ca6e82e6cc43b4480db31e167a660') { + throw "Native build-identity generator drifted: $identity" + } + $expected = ((Get-Content go.mod | Where-Object { $_ -match '^go\s+' } | Select-Object -First 1) -split '\s+')[1] + $actual = (go env GOVERSION).TrimStart('g', 'o') + if ($actual -cne $expected) { throw "Expected Go $expected; runner selected $actual." } + go mod verify + - name: Gate native DriverVer and package-content monotonicity + shell: pwsh + env: + BASE_REVISION: ${{ github.event.pull_request.base.sha || github.event.before }} + run: >- + ./native/udecx/tools/Test-ViiperUdeVersionMonotonicity.ps1 + -BaseRevision $env:BASE_REVISION + -HeadRevision $env:GITHUB_SHA + - name: Test complete VIIPER tree + run: go test ./... + - name: Stress native Windows client cancellation, close, pump failure, and reconnect + run: >- + go test -count=10 -timeout=5m + -run=^TestWindowsClientIOCPStress$ + ./internal/transport/udecx + - name: Vet complete VIIPER tree + run: go vet ./... + - name: Type-check cross-transport end-to-end benchmark + env: + CGO_ENABLED: "0" + run: go test -run=^$ ./_testing/e2e + - name: Fuzz native protocol decoders + run: go test -run=^$ -fuzz=FuzzProtocolDecoders -fuzztime=1000000x ./internal/transport/udecx + + race: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go.mod + cache: true + - name: Race-test native host, USB processor, and realtime controller encoders + run: >- + go test -race -count=5 + ./internal/transport/udecx + ./internal/server/usb + ./device/dualsense + ./device/dualshock4 + ./device/xbox360 + ./device/ns2pro + ./device/keyboard + ./device/mouse + + driver: + runs-on: windows-2025-vs2026 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go.mod + cache: true + - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + with: + languages: c-cpp + build-mode: manual + queries: security-extended + - uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2 + with: + msbuild-architecture: x64 + vs-version: "[18.0,19.0)" + - uses: NuGet/setup-nuget@d105a947828025cd7a980103c35ba2bfae586d0f # v2 + with: + nuget-version: "6.11.1" + - name: Restore WDK packages + run: nuget restore native/udecx/ViiperUde.sln -PackagesDirectory native/udecx/packages -NonInteractive + - name: Validate Windows and KMDF target contract + shell: pwsh + run: ./native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 + - name: Parse native PowerShell tooling + shell: pwsh + run: | + $failed = $false + Get-ChildItem native/udecx/tools -File -Filter *.ps1 | ForEach-Object { + $tokens = $null + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$tokens, [ref]$errors) + if ($errors.Count -gt 0) { + $failed = $true + Write-Error "$($_.Name): $($errors -join [Environment]::NewLine)" + } + } + if ($failed) { throw "Native PowerShell parser gate failed" } + - name: Parse native PowerShell tooling with Windows PowerShell 5.1 + shell: powershell + run: | + if ($PSVersionTable.PSEdition -cne 'Desktop' -or + $PSVersionTable.PSVersion.Major -ne 5) { + throw "Expected Windows PowerShell 5.1, got $($PSVersionTable.PSVersion)." + } + $failed = $false + Get-ChildItem native/udecx/tools -File -Filter *.ps1 | ForEach-Object { + $tokens = $null + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile( + $_.FullName, [ref]$tokens, [ref]$errors) + if ($errors.Count -gt 0) { + $failed = $true + Write-Error "$($_.Name): $($errors -join [Environment]::NewLine)" + } + } + if ($failed) { throw "Windows PowerShell 5.1 parser gate failed" } + - name: Expose WDK tools + shell: pwsh + run: | + $tools = Get-ChildItem native/udecx/packages -Recurse -File -Filter *.exe + $stampInf = $tools | Where-Object Name -ieq stampinf.exe | Select-Object -First 1 + if (-not $stampInf) { throw "Restored WDK package did not contain stampinf.exe" } + $tools.DirectoryName | Sort-Object -Unique | Out-File $env:GITHUB_PATH -Append -Encoding utf8 + - name: Build x64 driver + run: msbuild native/udecx/ViiperUde.sln /m /p:Configuration=Release /p:Platform=x64 /p:SignMode=TestSign + - name: Enforce WHQL-aligned and Universal INF rules + shell: pwsh + run: | + $inf = (Resolve-Path ./native/udecx/x64/Release/ViiperUde/ViiperUde.inf).Path + foreach ($mode in @('/h', '/u')) { + & infverif.exe $mode $inf + if ($LASTEXITCODE -ne 0) { + throw "InfVerif $mode rejected the stamped native INF (exit $LASTEXITCODE)." + } + } + - name: Verify stamped KMDF and DriverVer contract + shell: pwsh + run: >- + ./native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 + -ProjectPath ./native/udecx/driver/ViiperUde.vcxproj + -InfPath ./native/udecx/x64/Release/ViiperUde/ViiperUde.inf + -RequireStampedInf + - name: Verify matching private line and type debug artifacts + shell: pwsh + run: >- + ./native/udecx/tools/Test-ViiperUdeDebugArtifacts.ps1 + -SysPath ./native/udecx/x64/Release/ViiperUde.sys + -PdbPath ./native/udecx/x64/Release/ViiperUde.pdb + -MapPath ./native/udecx/x64/Release/ViiperUde.map + - name: Build transactional root-devnode and live-media helpers + shell: pwsh + run: | + $vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -version '[18.0,19.0)' -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $vs) { throw "Visual C++ toolchain was not found" } + $devCmd = Join-Path $vs "Common7\Tools\VsDevCmd.bat" + $source = (Resolve-Path "native\udecx\tools\ViiperUdeCtl.cpp").Path + $outputDir = Join-Path $PWD "native\udecx\x64\Release" + $intermediateDir = Join-Path $env:RUNNER_TEMP "viiper-native-symbol-objects" + New-Item -ItemType Directory -Force $outputDir | Out-Null + New-Item -ItemType Directory -Force $intermediateDir | Out-Null + $output = Join-Path $outputDir "ViiperUdeCtl.exe" + $helperPdb = Join-Path $outputDir "ViiperUdeCtl.pdb" + $helperObj = Join-Path $intermediateDir "ViiperUdeCtl.obj" + $command = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /Z7 /MT /DUNICODE /D_UNICODE `"$source`" /Fo:`"$helperObj`" /Fe:`"$output`" /link Setupapi.lib Newdev.lib Cfgmgr32.lib Advapi32.lib /DEBUG:FULL /PDB:`"$helperPdb`" /PDBALTPATH:ViiperUdeCtl.pdb /INCREMENTAL:NO /OPT:REF /OPT:ICF" + cmd.exe /d /s /c $command + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $output) -or -not (Test-Path $helperPdb)) { throw "ViiperUdeCtl full-symbol build failed" } + & $output self-test + if ($LASTEXITCODE -ne 0) { throw "ViiperUdeCtl self-test failed" } + $mediaSource = (Resolve-Path "native\udecx\tools\ViiperUdeMediaProbe.cpp").Path + $mediaOutput = Join-Path $outputDir "ViiperUdeMediaProbe.exe" + $mediaPdb = Join-Path $outputDir "ViiperUdeMediaProbe.pdb" + $mediaObj = Join-Path $intermediateDir "ViiperUdeMediaProbe.obj" + $mediaCommand = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /Z7 /MT /D_WIN32_WINNT=0x0A00 `"$mediaSource`" /Fo:`"$mediaObj`" /Fe:`"$mediaOutput`" /link Ole32.lib Ksuser.lib /DEBUG:FULL /PDB:`"$mediaPdb`" /PDBALTPATH:ViiperUdeMediaProbe.pdb /INCREMENTAL:NO /OPT:REF /OPT:ICF" + cmd.exe /d /s /c $mediaCommand + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $mediaOutput) -or -not (Test-Path $mediaPdb)) { throw "ViiperUdeMediaProbe full-symbol build failed" } + $mediaSnapshot = Join-Path $env:RUNNER_TEMP "viiper-ude-media-smoke.snapshot" + & $mediaOutput snapshot $mediaSnapshot + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $mediaSnapshot)) { throw "ViiperUdeMediaProbe endpoint snapshot smoke test failed" } + Remove-Item -LiteralPath $mediaSnapshot -Force + $inputSource = (Resolve-Path "native\udecx\tools\ViiperUdeInputProbe.cpp").Path + $inputOutput = Join-Path $outputDir "ViiperUdeInputProbe.exe" + $inputPdb = Join-Path $outputDir "ViiperUdeInputProbe.pdb" + $inputObj = Join-Path $intermediateDir "ViiperUdeInputProbe.obj" + $inputCommand = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /Z7 /MT /D_WIN32_WINNT=0x0A00 `"$inputSource`" /Fo:`"$inputObj`" /Fe:`"$inputOutput`" /link Setupapi.lib Hid.lib /DEBUG:FULL /PDB:`"$inputPdb`" /PDBALTPATH:ViiperUdeInputProbe.pdb /INCREMENTAL:NO /OPT:REF /OPT:ICF" + cmd.exe /d /s /c $inputCommand + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $inputOutput) -or -not (Test-Path $inputPdb)) { throw "ViiperUdeInputProbe full-symbol build failed" } + $inputSnapshot = Join-Path $env:RUNNER_TEMP "viiper-ude-input-smoke.snapshot" + & $inputOutput snapshot $inputSnapshot + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $inputSnapshot)) { throw "ViiperUdeInputProbe HID snapshot smoke test failed" } + Remove-Item -LiteralPath $inputSnapshot -Force + $probeManifest = [ordered]@{ + schemaVersion = 1 + sourceRevision = $env:GITHUB_SHA.ToLowerInvariant() + probes = [ordered]@{ + 'ViiperUdeMediaProbe.exe' = (Get-FileHash -LiteralPath $mediaOutput -Algorithm SHA256).Hash.ToLowerInvariant() + 'ViiperUdeInputProbe.exe' = (Get-FileHash -LiteralPath $inputOutput -Algorithm SHA256).Hash.ToLowerInvariant() + } + } + $probeManifestPath = Join-Path $outputDir 'ViiperUdeLiveProbes.manifest.json' + $probeManifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $probeManifestPath -Encoding utf8NoBOM + if (-not (Test-Path -LiteralPath $probeManifestPath -PathType Leaf)) { throw "Live-probe manifest was not created" } + ./native/udecx/tools/Test-ViiperUdeDebugArtifacts.ps1 ` + -SysPath native/udecx/x64/Release/ViiperUde.sys ` + -PdbPath native/udecx/x64/Release/ViiperUde.pdb ` + -MapPath native/udecx/x64/Release/ViiperUde.map ` + -HelperPath $output -HelperPdbPath $helperPdb ` + -MediaProbePath $mediaOutput -MediaProbePdbPath $mediaPdb ` + -InputProbePath $inputOutput -InputProbePdbPath $inputPdb + - name: Build source-bound native broker + shell: pwsh + run: | + $output = 'native/udecx/x64/Release/viiper.exe' + $env:CGO_ENABLED = '0' + $buildDate = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') + go build -tags release -trimpath ` + -ldflags "-X main.Version=0.1.0-local-test -X main.Commit=$env:GITHUB_SHA -X main.Date=$buildDate -X github.com/Alia5/VIIPER/internal/codegen/common.Version=0.1.0-local-test -X github.com/Alia5/VIIPER/internal/transport/udecx.nativeSourceRevision=$env:GITHUB_SHA" ` + -o $output ./cmd/viiper + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $output -PathType Leaf)) { + throw 'Source-bound native broker build failed.' + } + $nmPatterns = @( + ' main\.main(?:\.abi0)?$', + ' github\.com/Alia5/VIIPER/internal/transport/udecx\.\(\*Host\)\.runInputPublisher(?:\.abi0)?$' + ) + $nmMatches = @(& go tool nm $output 2>&1 | Select-String -Pattern $nmPatterns) + $nmExitCode = $LASTEXITCODE + $nmLines = @($nmMatches | ForEach-Object { $_.Line }) + if ($nmExitCode -ne 0 -or + @($nmPatterns | Where-Object { @($nmLines -match $_).Count -eq 0 }).Count -ne 0) { + throw 'Source-bound native broker is missing required Go/DWARF hot-path symbols.' + } + $brokerAscii = [Text.Encoding]::ASCII.GetString([IO.File]::ReadAllBytes($output)) + $dwarfSectionPatterns = @( + '\.(?:z)?debug_info(?:\x00|$)', + '\.(?:z)?debug_line(?:\x00|$)', + '\.(?:z)?debug_abbrev(?:\x00|$)' + ) + if (@($dwarfSectionPatterns | Where-Object { $brokerAscii -notmatch $_ }).Count -ne 0) { + throw 'Source-bound native broker is missing embedded Go DWARF sections.' + } + $helpOutput = (& $output --help 2>&1 | Out-String) + if ($LASTEXITCODE -ne 0 -or + $helpOutput -notmatch [regex]::Escape("Version: 0.1.0-local-test ($env:GITHUB_SHA)") -or + $helpOutput -notmatch [regex]::Escape($buildDate)) { + throw 'Retaining Go DWARF changed or removed the source-bound broker version metadata.' + } + $buildInfoPath = "$output.buildinfo.txt" + $buildInfo = (& go version -m $output 2>&1 | Out-String) + if ($LASTEXITCODE -ne 0 -or + $buildInfo -notmatch ('(?m)^\s*build\s+vcs\.revision=' + [regex]::Escape($env:GITHUB_SHA) + '\s*$')) { + throw 'The broker Go build information is not bound to the workflow source revision.' + } + [IO.File]::WriteAllText($buildInfoPath, $buildInfo, [Text.UTF8Encoding]::new($false)) + $brokerItem = Get-Item -LiteralPath $output + $buildManifest = [ordered]@{ + schema = 1 + sourceRevision = $env:GITHUB_SHA.ToLowerInvariant() + version = '0.1.0-local-test' + commit = $env:GITHUB_SHA.ToLowerInvariant() + buildDate = $buildDate + goVersion = (go env GOVERSION) + trimpath = $true + embeddedDwarf = $true + embeddedDwarfSections = @('debug_info', 'debug_line', 'debug_abbrev') + binary = [ordered]@{ + name = $brokerItem.Name + length = $brokerItem.Length + sha256 = (Get-FileHash -LiteralPath $output -Algorithm SHA256).Hash.ToLowerInvariant() + } + buildInfoSha256 = (Get-FileHash -LiteralPath $buildInfoPath -Algorithm SHA256).Hash.ToLowerInvariant() + } + $buildManifestPath = "$output.build.json" + [IO.File]::WriteAllText($buildManifestPath, + ($buildManifest | ConvertTo-Json -Depth 5), [Text.UTF8Encoding]::new($false)) + - name: Compose exact source-bound debug bundle + if: ${{ inputs.upload_release_helper == true || inputs.upload_artifacts == true }} + shell: pwsh + run: >- + ./native/udecx/tools/New-ViiperUdeDebugBundle.ps1 + -RepositoryRoot . + -SourceRevision $env:GITHUB_SHA + -DriverImagePath native/udecx/x64/Release/ViiperUde.sys + -DriverPdbPath native/udecx/x64/Release/ViiperUde.pdb + -DriverMapPath native/udecx/x64/Release/ViiperUde.map + -BrokerPath native/udecx/x64/Release/viiper.exe + -BrokerBuildInfoPath native/udecx/x64/Release/viiper.exe.buildinfo.txt + -BrokerBuildManifestPath native/udecx/x64/Release/viiper.exe.build.json + -HelperPath native/udecx/x64/Release/ViiperUdeCtl.exe + -HelperPdbPath native/udecx/x64/Release/ViiperUdeCtl.pdb + -MediaProbePath native/udecx/x64/Release/ViiperUdeMediaProbe.exe + -MediaProbePdbPath native/udecx/x64/Release/ViiperUdeMediaProbe.pdb + -InputProbePath native/udecx/x64/Release/ViiperUdeInputProbe.exe + -InputProbePdbPath native/udecx/x64/Release/ViiperUdeInputProbe.pdb + -OutputDirectory native/udecx/x64/Release/ViiperUdeDebug + - name: Upload source-bound native live probes + if: ${{ inputs.upload_release_helper == true }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ViiperUdeLiveProbes-windows-amd64-${{ github.sha }} + path: | + native/udecx/x64/Release/ViiperUdeMediaProbe.exe + native/udecx/x64/Release/ViiperUdeMediaProbe.pdb + native/udecx/x64/Release/ViiperUdeInputProbe.exe + native/udecx/x64/Release/ViiperUdeInputProbe.pdb + native/udecx/x64/Release/ViiperUdeLiveProbes.manifest.json + if-no-files-found: error + retention-days: 30 + - name: Upload source-bound native runtime helper + if: ${{ inputs.upload_release_helper == true }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ViiperUdeCtl-windows-amd64-${{ github.sha }} + path: | + native/udecx/x64/Release/ViiperUdeCtl.exe + native/udecx/x64/Release/ViiperUdeCtl.pdb + if-no-files-found: error + retention-days: 30 + - name: Upload exact source-bound debug bundle + if: ${{ inputs.upload_release_helper == true || inputs.upload_artifacts == true }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ViiperUdeDebug-windows-amd64-${{ github.sha }} + path: native/udecx/x64/Release/ViiperUdeDebug/** + if-no-files-found: error + retention-days: 30 + - name: Validate testing-only Hardware Dev Center CAB structure + shell: pwsh + run: | + ./native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 ` + -InfPath native/udecx/x64/Release/ViiperUde/ViiperUde.inf ` + -SysPath native/udecx/x64/Release/ViiperUde/ViiperUde.sys ` + -PdbPath native/udecx/x64/Release/ViiperUde.pdb ` + -CatalogPath native/udecx/x64/Release/ViiperUde/viiperude.cat ` + -OutputPath native/udecx/x64/Release/ViiperUdeAttestationStructure.cab ` + -SourceRevision $env:GITHUB_SHA ` + -AcknowledgeTestingOnly + - name: Compose compact source-bound local test package + if: ${{ inputs.upload_artifacts == true }} + shell: pwsh + run: | + $certificatePath = (Resolve-Path 'native/udecx/x64/Release/ViiperUde.cer').Path + $certificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( + $certificatePath) + $certificateSha256 = $certificate.GetCertHashString( + [Security.Cryptography.HashAlgorithmName]::SHA256) + $addedTrust = @() + if (-not ('ViiperNativeCertificateStore' -as [type])) { + Add-Type -Language CSharp -TypeDefinition @' + using System; + using System.ComponentModel; + using System.Runtime.InteropServices; + + public static class ViiperNativeCertificateStore + { + private const int CERT_STORE_PROV_SYSTEM_W = 10; + private const uint CERT_SYSTEM_STORE_LOCAL_MACHINE = 0x00020000; + private const uint CERT_STORE_OPEN_EXISTING_FLAG = 0x00004000; + private const uint CERT_STORE_MAXIMUM_ALLOWED_FLAG = 0x00001000; + private const uint CERT_ENCODING = 0x00010001; + private const uint CERT_STORE_ADD_NEW = 1; + private const uint CERT_FIND_EXISTING = 0x000d0000; + + [DllImport("crypt32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CertOpenStore( + IntPtr provider, uint encoding, IntPtr cryptProvider, + uint flags, string storeName); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern bool CertAddEncodedCertificateToStore( + IntPtr store, uint encoding, byte[] certificate, uint length, + uint disposition, out IntPtr context); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern IntPtr CertCreateCertificateContext( + uint encoding, byte[] certificate, uint length); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern IntPtr CertFindCertificateInStore( + IntPtr store, uint encoding, uint findFlags, uint findType, + IntPtr findParameter, IntPtr previousContext); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern bool CertDeleteCertificateFromStore(IntPtr context); + + [DllImport("crypt32.dll")] + private static extern bool CertFreeCertificateContext(IntPtr context); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern bool CertCloseStore(IntPtr store, uint flags); + + private static IntPtr Open(string storeName) + { + IntPtr store = CertOpenStore( + new IntPtr(CERT_STORE_PROV_SYSTEM_W), 0, IntPtr.Zero, + CERT_SYSTEM_STORE_LOCAL_MACHINE | CERT_STORE_OPEN_EXISTING_FLAG | + CERT_STORE_MAXIMUM_ALLOWED_FLAG, + storeName); + if (store == IntPtr.Zero) + throw new Win32Exception(Marshal.GetLastWin32Error(), "CertOpenStore"); + return store; + } + + public static void Add(string storeName, byte[] certificate) + { + IntPtr store = Open(storeName); + IntPtr context = IntPtr.Zero; + try + { + if (!CertAddEncodedCertificateToStore( + store, CERT_ENCODING, certificate, (uint)certificate.Length, + CERT_STORE_ADD_NEW, out context)) + throw new Win32Exception( + Marshal.GetLastWin32Error(), "CertAddEncodedCertificateToStore"); + } + finally + { + if (context != IntPtr.Zero) CertFreeCertificateContext(context); + CertCloseStore(store, 0); + } + } + + public static bool Remove(string storeName, byte[] certificate) + { + IntPtr store = Open(storeName); + IntPtr search = IntPtr.Zero; + try + { + search = CertCreateCertificateContext( + CERT_ENCODING, certificate, (uint)certificate.Length); + if (search == IntPtr.Zero) + throw new Win32Exception( + Marshal.GetLastWin32Error(), "CertCreateCertificateContext"); + IntPtr found = CertFindCertificateInStore( + store, CERT_ENCODING, 0, CERT_FIND_EXISTING, search, IntPtr.Zero); + if (found == IntPtr.Zero) return false; + if (!CertDeleteCertificateFromStore(found)) + throw new Win32Exception( + Marshal.GetLastWin32Error(), "CertDeleteCertificateFromStore"); + return true; + } + finally + { + if (search != IntPtr.Zero) CertFreeCertificateContext(search); + CertCloseStore(store, 0); + } + } + } + '@ + } + $operationError = $null + $cleanupErrors = [Collections.Generic.List[string]]::new() + try { + foreach ($storeName in @( + [Security.Cryptography.X509Certificates.StoreName]::Root, + [Security.Cryptography.X509Certificates.StoreName]::TrustedPublisher)) { + $store = [Security.Cryptography.X509Certificates.X509Store]::new( + $storeName, + [Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine) + try { + $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) + $matches = $store.Certificates.Find( + [Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, + $certificate.Thumbprint, $false) + $exactMatch = @($matches | Where-Object { + $_.GetCertHashString( + [Security.Cryptography.HashAlgorithmName]::SHA256) -ceq + $certificateSha256 + }) + if ($matches.Count -ne $exactMatch.Count) { + throw "Certificate thumbprint collision in LocalMachine\\$storeName." + } + if ($exactMatch.Count -eq 0) { + [ViiperNativeCertificateStore]::Add( + $storeName.ToString(), $certificate.RawData) + $addedTrust += $storeName + $store.Close() + $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) + $installed = $store.Certificates.Find( + [Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, + $certificate.Thumbprint, $false) + $installedExact = @($installed | Where-Object { + $_.GetCertHashString( + [Security.Cryptography.HashAlgorithmName]::SHA256) -ceq + $certificateSha256 + }) + if ($installed.Count -ne 1 -or $installedExact.Count -ne 1) { + throw "Exact temporary certificate was not installed in LocalMachine\\$storeName." + } + } + } + finally { + $store.Close() + } + } + ./native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 ` + -InfPath native/udecx/x64/Release/ViiperUde/ViiperUde.inf ` + -SysPath native/udecx/x64/Release/ViiperUde/ViiperUde.sys ` + -PdbPath native/udecx/x64/Release/ViiperUde.pdb ` + -CatalogPath native/udecx/x64/Release/ViiperUde/ViiperUde.cat ` + -TestCertificatePath $certificatePath ` + -BrokerPath native/udecx/x64/Release/viiper.exe ` + -HelperPath native/udecx/x64/Release/ViiperUdeCtl.exe ` + -MediaProbePath native/udecx/x64/Release/ViiperUdeMediaProbe.exe ` + -InputProbePath native/udecx/x64/Release/ViiperUdeInputProbe.exe ` + -ProbeManifestPath native/udecx/x64/Release/ViiperUdeLiveProbes.manifest.json ` + -OutputDirectory native/udecx/x64/Release/ViiperUdeLocalTest ` + -SourceRevision $env:GITHUB_SHA + } + catch { + $operationError = $_ + } + finally { + try { + foreach ($storeName in $addedTrust) { + $store = $null + try { + $store = [Security.Cryptography.X509Certificates.X509Store]::new( + $storeName, + [Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine) + $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) + $matches = $store.Certificates.Find( + [Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, + $certificate.Thumbprint, $false) + $exactMatch = @($matches | Where-Object { + $_.GetCertHashString( + [Security.Cryptography.HashAlgorithmName]::SHA256) -ceq + $certificateSha256 + }) + if ($exactMatch.Count -ne 1) { + if ($exactMatch.Count -eq 0) { continue } + throw "Temporary certificate collision in LocalMachine\\$storeName." + } + $store.Close() + if (-not [ViiperNativeCertificateStore]::Remove( + $storeName.ToString(), $certificate.RawData)) { + throw "Temporary certificate disappeared from LocalMachine\\$storeName." + } + $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) + $remaining = $store.Certificates.Find( + [Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, + $certificate.Thumbprint, $false) + if ($remaining.Count -ne 0) { + throw "Temporary certificate remained in LocalMachine\\$storeName after cleanup." + } + } + catch { + [void]$cleanupErrors.Add("LocalMachine\\$storeName cleanup failed: $($_.Exception.Message)") + } + finally { + if ($null -ne $store) { $store.Close() } + } + } + } + finally { + $certificate.Dispose() + } + } + if ($cleanupErrors.Count -ne 0) { + $message = $cleanupErrors -join '; ' + if ($null -ne $operationError) { + $message = "$($operationError.Exception.Message); $message" + } + throw $message + } + if ($null -ne $operationError) { + throw $operationError + } + - name: Analyze native driver and setup helper + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + with: + category: /language:c-cpp + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: ${{ inputs.upload_artifacts == true }} + with: + name: ViiperUde-x64-local-test-${{ github.sha }} + path: native/udecx/x64/Release/ViiperUdeLocalTest/** + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 605b1162..166aa8b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,13 +5,376 @@ on: tags: - "v*.*.*" -permissions: - contents: write - id-token: write +permissions: + actions: read + contents: read -jobs: - build: - uses: ./.github/workflows/build_base.yml +jobs: + release-policy: + name: Validate release source and workflow policy + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Checkout exact release source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + + - name: Require an exact SemVer tag on reviewed main history + shell: bash + env: + TAG_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Release tag must be exact vMAJOR.MINOR.PATCH SemVer." + exit 1 + fi + test "$(git rev-parse "refs/tags/${TAG_NAME}^{commit}")" = "$GITHUB_SHA" + git fetch --no-tags origin main + if [[ "$(git rev-parse origin/main)" != "$GITHUB_SHA" ]]; then + echo "::error::Release tags must point to the current origin/main tip." + exit 1 + fi + + - name: Enforce immutable workflow dependency policy + shell: pwsh + run: ./.github/scripts/Test-WorkflowSecurity.ps1 + + - name: Require native DriverVer monotonicity from the previous release + shell: pwsh + run: | + $tags = @(git tag --merged "$env:GITHUB_SHA^" --list 'v*.*.*' | + Where-Object { $_ -match '^v\d+\.\d+\.\d+$' } | + Sort-Object { [Version]$_.Substring(1) } -Descending) + $baseline = if ($tags.Count -gt 0) { $tags[0] } else { '' } + ./native/udecx/tools/Test-ViiperUdeVersionMonotonicity.ps1 ` + -BaseRevision $baseline ` + -HeadRevision $env:GITHUB_SHA + + native-validation: + name: Native UdeCx release gate + needs: release-policy + permissions: + contents: read + security-events: write + uses: ./.github/workflows/native-ude.yml + with: + upload_artifacts: false + upload_release_helper: true + + native-package-transaction: + name: Native package transaction release gate + needs: release-policy + permissions: + contents: read + uses: ./.github/workflows/native-package-transaction.yml + + native-production-provenance: + name: Require accepted Microsoft HLK/WHCP package + needs: release-policy + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: read + outputs: + artifact_digest: ${{ steps.locate.outputs.artifact_digest }} + artifact_id: ${{ steps.locate.outputs.artifact_id }} + run_id: ${{ steps.locate.outputs.run_id }} + steps: + - name: Locate the exact trusted production-package acceptance + id: locate + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + artifact_name="ViiperUde-x64-production-microsoft-signed-${GITHUB_SHA}" + response="$(gh api --paginate --slurp \ + "/repos/${GITHUB_REPOSITORY}/actions/artifacts?per_page=100&name=${artifact_name}")" + mapfile -t candidates < <(jq -r --arg name "$artifact_name" ' + [.[].artifacts[] | + select(.name == $name and (.expired | not))] | + sort_by(.id) | reverse | .[] | @base64' <<<"$response") + + selected='' + for encoded in "${candidates[@]}"; do + artifact="$(base64 --decode <<<"$encoded")" + run_id="$(jq -r '.workflow_run.id' <<<"$artifact")" + run="$(gh api "/repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}")" + if jq -e \ + --arg repo "$GITHUB_REPOSITORY" \ + --arg sha "$GITHUB_SHA" ' + .path == ".github/workflows/native-production-package.yml" and + .event == "workflow_dispatch" and + .status == "completed" and + .conclusion == "success" and + .head_branch == "main" and + .head_sha == $sha and + .repository.full_name == $repo' <<<"$run" >/dev/null; then + selected="$artifact" + break + fi + done + if [[ -z "$selected" ]]; then + echo "::error::No successful main-branch Microsoft HLK/WHCP acceptance artifact exists for ${GITHUB_SHA}." + exit 1 + fi + + artifact_id="$(jq -r '.id' <<<"$selected")" + run_id="$(jq -r '.workflow_run.id' <<<"$selected")" + digest="$(jq -r '.digest' <<<"$selected")" + if [[ ! "$artifact_id" =~ ^[0-9]+$ || ! "$run_id" =~ ^[0-9]+$ || + ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::Accepted production artifact metadata is malformed." + exit 1 + fi + echo "artifact_id=${artifact_id}" >> "$GITHUB_OUTPUT" + echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" + echo "artifact_digest=${digest#sha256:}" >> "$GITHUB_OUTPUT" + { + echo '### Required Microsoft production package' + echo + echo "- Source: \`${GITHUB_SHA}\`" + echo "- Acceptance run: \`${run_id}\`" + echo "- Artifact ID: \`${artifact_id}\`" + echo "- Artifact digest: \`${digest}\`" + } >> "$GITHUB_STEP_SUMMARY" + + native-user-mode-signing: + name: Sign and validate native runtime package + needs: [release-policy, native-validation, native-production-provenance, build] + runs-on: windows-2025-vs2026 + permissions: + actions: read + contents: read + steps: + - name: Checkout exact release source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Download unsigned x64 broker input + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: VIIPER-windows-amd64-Release + path: unsigned-broker-amd64 + + - name: Download unsigned ARM64 broker input + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: VIIPER-windows-arm64-Release + path: unsigned-broker-arm64 + + - name: Download source-built native helper input + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: ViiperUdeCtl-windows-amd64-${{ github.sha }} + path: unsigned-helper + + - name: Download accepted Microsoft production package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ needs.native-production-provenance.outputs.artifact_id }} + run-id: ${{ needs.native-production-provenance.outputs.run_id }} + github-token: ${{ github.token }} + path: accepted-production + + - uses: NuGet/setup-nuget@d105a947828025cd7a980103c35ba2bfae586d0f # v2 + with: + nuget-version: "6.11.1" + + - name: Restore exact source-bound WDK signing tools + run: >- + nuget restore native/udecx/ViiperUde.sln + -PackagesDirectory native/udecx/packages + -NonInteractive + + - name: Select exact restored SignTool and InfVerif + id: wdk_tools + shell: pwsh + run: | + $tools = @(Get-ChildItem native/udecx/packages -Recurse -File -Filter *.exe) + foreach ($required in @('signtool.exe', 'infverif.exe')) { + if (-not ($tools | Where-Object Name -ieq $required | Select-Object -First 1)) { + throw "Restored WDK packages did not contain $required." + } + } + $signTools = @($tools | Where-Object { + $_.Name -ieq 'signtool.exe' -and $_.FullName -match '[\\/]x64[\\/]signtool\.exe$' + }) + if ($signTools.Count -eq 0) { + throw 'Restored WDK packages did not contain an x64 SignTool.' + } + $hashes = @($signTools | ForEach-Object { + (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash + } | Sort-Object -Unique) + if ($hashes.Count -ne 1) { + throw 'Restored WDK packages contained non-identical x64 SignTool binaries.' + } + $signTool = ($signTools | Sort-Object FullName | Select-Object -First 1).FullName + "sign_tool=$signTool" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + $tools.DirectoryName | Sort-Object -Unique | + Out-File $env:GITHUB_PATH -Append -Encoding utf8 + + - name: Extract and allowlist unsigned release inputs + shell: pwsh + run: | + New-Item -ItemType Directory -Force signed/amd64, signed/arm64, signed/helper | Out-Null + Expand-Archive -LiteralPath unsigned-broker-amd64/viiper-windows-amd64.zip -DestinationPath signed/amd64 + Expand-Archive -LiteralPath unsigned-broker-arm64/viiper-windows-arm64.zip -DestinationPath signed/arm64 + foreach ($architecture in @('amd64', 'arm64')) { + $root = (Resolve-Path "signed/$architecture").Path + $relative = @(Get-ChildItem $root -Recurse -File | ForEach-Object { + [IO.Path]::GetRelativePath($root, $_.FullName).Replace('\', '/') + } | Sort-Object) + if ($relative.Count -ne 2 -or + $relative[0] -cne 'licenses.txt' -or $relative[1] -cne 'viiper.exe') { + throw "The $architecture broker archive is not the exact viiper.exe/licenses.txt input." + } + } + $helpers = @(Get-ChildItem unsigned-helper -Recurse -File) + if ($helpers.Count -ne 1 -or $helpers[0].Name -cne 'ViiperUdeCtl.exe') { + throw 'The helper artifact is not exactly one case-correct ViiperUdeCtl.exe.' + } + Copy-Item -LiteralPath $helpers[0].FullName -Destination signed/helper/ViiperUdeCtl.exe + + - name: Authenticode-sign and verify broker/helper release binaries + shell: pwsh + env: + CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_PFX_PASSWORD }} + CERTIFICATE_SHA256: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_SHA256 }} + PFX_BASE64: ${{ secrets.WINDOWS_SIGNING_PFX_BASE64 }} + SIGN_TOOL: ${{ steps.wdk_tools.outputs.sign_tool }} + run: | + foreach ($required in @( + $env:PFX_BASE64, $env:CERTIFICATE_PASSWORD, $env:CERTIFICATE_SHA256, $env:SIGN_TOOL)) { + if ([string]::IsNullOrWhiteSpace($required)) { + throw 'Production release signing secrets and the exact SignTool are required.' + } + } + ./native/udecx/tools/Protect-ViiperWindowsReleaseBinaries.ps1 ` + -Paths @( + 'signed/amd64/viiper.exe', + 'signed/arm64/viiper.exe', + 'signed/helper/ViiperUdeCtl.exe') ` + -CertificateBase64 $env:PFX_BASE64 ` + -CertificatePassword $env:CERTIFICATE_PASSWORD ` + -ExpectedCertificateSHA256 $env:CERTIFICATE_SHA256 ` + -SignToolPath $env:SIGN_TOOL + + - name: Revalidate Microsoft driver and compose exact runtime bundle + shell: pwsh + env: + CERTIFICATE_SHA256: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_SHA256 }} + run: | + $acceptedArchives = @(Get-ChildItem accepted-production -Recurse -File) + $expectedArchive = "ViiperUde-x64-production-$env:GITHUB_SHA.zip" + if ($acceptedArchives.Count -ne 1 -or $acceptedArchives[0].Name -cne $expectedArchive) { + throw "Accepted production artifact must contain only $expectedArchive." + } + Expand-Archive -LiteralPath $acceptedArchives[0].FullName -DestinationPath signed/production + $expectedProduction = @( + 'submission-manifest.json', + 'ViiperUde/ViiperUde.cat', + 'ViiperUde/ViiperUde.inf', + 'ViiperUde/ViiperUde.pdb', + 'ViiperUde/ViiperUde.sys') + $productionRoot = (Resolve-Path signed/production).Path + $actualProduction = @(Get-ChildItem $productionRoot -Recurse -File | ForEach-Object { + [IO.Path]::GetRelativePath($productionRoot, $_.FullName).Replace('\', '/') + } | Sort-Object) + if (Compare-Object ($expectedProduction | Sort-Object) $actualProduction) { + throw 'Accepted Microsoft production artifact has missing or unexpected files.' + } + ./native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 ` + -PackageDirectory signed/production/ViiperUde ` + -SubmissionManifestPath signed/production/submission-manifest.json ` + -ExpectedSourceRevision $env:GITHUB_SHA ` + -ValidationMode Production + ./native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 ` + -ProjectPath native/udecx/driver/ViiperUde.vcxproj ` + -InfPath signed/production/ViiperUde/ViiperUde.inf ` + -RequireStampedInf + $manifestHash = (Get-FileHash ` + -LiteralPath signed/production/submission-manifest.json ` + -Algorithm SHA256).Hash.ToLowerInvariant() + $deadline = [DateTimeOffset]::UtcNow.AddMinutes(5).ToUnixTimeMilliseconds() + & signed/helper/ViiperUdeCtl.exe verify ` + signed/production/ViiperUde/ViiperUde.inf ` + --manifest signed/production/submission-manifest.json ` + --manifest-sha256 $manifestHash ` + --source-revision $env:GITHUB_SHA ` + --validation-mode production ` + --transaction-deadline-unix-ms $deadline + if ($LASTEXITCODE -ne 0) { + throw "The signed package helper rejected the production driver package (exit $LASTEXITCODE)." + } + + New-Item -ItemType Directory -Force signed/runtime | Out-Null + Copy-Item signed/amd64/viiper.exe signed/runtime/viiper.exe + Copy-Item signed/helper/ViiperUdeCtl.exe signed/runtime/ViiperUdeCtl.exe + Copy-Item signed/production/ViiperUde/ViiperUde.inf signed/runtime/ViiperUde.inf + Copy-Item signed/production/ViiperUde/ViiperUde.sys signed/runtime/ViiperUde.sys + Copy-Item signed/production/ViiperUde/ViiperUde.cat signed/runtime/ViiperUde.cat + Copy-Item signed/production/submission-manifest.json signed/runtime/submission-manifest.json + ./native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 ` + -BundleDirectory signed/runtime ` + -ExpectedSourceRevision $env:GITHUB_SHA ` + -ProjectPath native/udecx/driver/ViiperUde.vcxproj ` + -RequireAuthenticode ` + -ExpectedSignerCertificateSHA256 $env:CERTIFICATE_SHA256 + + - name: Archive signed release outputs + shell: pwsh + env: + CERTIFICATE_SHA256: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_SHA256 }} + run: | + New-Item -ItemType Directory -Force signed/output | Out-Null + Compress-Archive -LiteralPath signed/amd64/viiper.exe, signed/amd64/licenses.txt ` + -DestinationPath signed/output/viiper-windows-amd64.zip -CompressionLevel Optimal + Compress-Archive -LiteralPath signed/arm64/viiper.exe, signed/arm64/licenses.txt ` + -DestinationPath signed/output/viiper-windows-arm64.zip -CompressionLevel Optimal + Compress-Archive -Path signed/runtime/* ` + -DestinationPath signed/output/viiper-native-udecx-windows-amd64.zip -CompressionLevel Optimal + Expand-Archive -LiteralPath signed/output/viiper-native-udecx-windows-amd64.zip ` + -DestinationPath signed/runtime-roundtrip + ./native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 ` + -BundleDirectory signed/runtime-roundtrip ` + -ExpectedSourceRevision $env:GITHUB_SHA ` + -ProjectPath native/udecx/driver/ViiperUde.vcxproj ` + -RequireAuthenticode ` + -ExpectedSignerCertificateSHA256 $env:CERTIFICATE_SHA256 + + - name: Upload signed x64 broker release input + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: VIIPER-windows-amd64-authenticode-${{ github.sha }} + path: signed/output/viiper-windows-amd64.zip + if-no-files-found: error + retention-days: 30 + + - name: Upload signed ARM64 broker release input + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: VIIPER-windows-arm64-authenticode-${{ github.sha }} + path: signed/output/viiper-windows-arm64.zip + if-no-files-found: error + retention-days: 30 + + - name: Upload validated signed native runtime bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: VIIPER-native-udecx-authenticode-${{ github.sha }} + path: signed/output/viiper-native-udecx-windows-amd64.zip + if-no-files-found: error + retention-days: 30 + + build: + needs: release-policy + uses: ./.github/workflows/build_base.yml secrets: inherit with: artifact_suffix: "-Release" @@ -25,38 +388,67 @@ jobs: mode: release tag_name: ${{ github.ref_name }} - client-libraries: - name: Client library smoke builds and pack - uses: ./.github/workflows/clients_ci.yml + client-libraries: + name: Client library smoke builds and pack + needs: release-policy + uses: ./.github/workflows/clients_ci.yml with: artifact_suffix: "-Release" upload_artifacts: true version: ${{ github.ref_name }} - create-release: - name: Create Release - needs: [build, generate-changelog, client-libraries] - runs-on: ubuntu-latest + create-release: + name: Create Release + needs: [release-policy, native-validation, native-package-transaction, native-production-provenance, native-user-mode-signing, build, generate-changelog, client-libraries] + permissions: + actions: read + attestations: write + contents: write + id-token: write + runs-on: ubuntu-24.04 steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 - - name: Download all artifacts - uses: actions/download-artifact@v8 - with: - path: artifacts - - - name: Organize and rename artifacts + - name: Download release build artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + path: artifacts + pattern: "*-Release" + + - name: Download Authenticode-signed x64 broker release input + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: VIIPER-windows-amd64-authenticode-${{ github.sha }} + path: signed-windows-amd64 + + - name: Download Authenticode-signed ARM64 broker release input + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: VIIPER-windows-arm64-authenticode-${{ github.sha }} + path: signed-windows-arm64 + + - name: Download validated Authenticode native runtime bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: VIIPER-native-udecx-authenticode-${{ github.sha }} + path: signed-native-runtime + + - name: Organize and rename artifacts shell: bash run: | set -euo pipefail - mkdir -p release_files - for dir in artifacts/*; do - if [ -d "$dir" ]; then - base="$(basename "$dir")" - # Strip optional suffix from artifact name for filenames + mkdir -p release_files + for dir in artifacts/*; do + if [ -d "$dir" ]; then + base="$(basename "$dir")" + if [[ "$base" == 'VIIPER-windows-amd64-Release' || + "$base" == 'VIIPER-windows-arm64-Release' ]]; then + continue + fi + # Strip optional suffix from artifact name for filenames name_no_suffix="${base%-Release}" for file in "$dir"/*; do if [ -f "$file" ]; then @@ -74,11 +466,61 @@ jobs: cp "$file" "release_files/${fname}" fi fi - done - fi - done - ls -la release_files/ - + done + fi + done + require_exact_artifact() { + local root="$1" + local filename="$2" + local destination="$3" + mapfile -t actual < <(find "$root" -type f -printf '%P\n' | sort) + if [[ "${#actual[@]}" -ne 1 || "${actual[0]}" != "$filename" ]]; then + echo "::error::${root} must contain exactly ${filename}." + exit 1 + fi + cp "$root/$filename" "release_files/$destination" + } + require_exact_artifact \ + signed-windows-amd64 viiper-windows-amd64.zip viiper-windows-amd64.zip + require_exact_artifact \ + signed-windows-arm64 viiper-windows-arm64.zip viiper-windows-arm64.zip + require_exact_artifact \ + signed-native-runtime viiper-native-udecx-windows-amd64.zip \ + viiper-native-udecx-windows-amd64.zip + ls -la release_files/ + + - name: Recheck signed archive allowlists before publication + shell: bash + run: | + set -euo pipefail + expected_broker=(licenses.txt viiper.exe) + for architecture in amd64 arm64; do + archive="release_files/viiper-windows-${architecture}.zip" + mapfile -t actual < <(unzip -Z1 "$archive" | sort) + if ! diff -u \ + <(printf '%s\n' "${expected_broker[@]}") \ + <(printf '%s\n' "${actual[@]}"); then + echo "::error::The signed ${architecture} broker archive has unexpected files." + exit 1 + fi + done + expected_runtime=( + ViiperUde.cat + ViiperUde.inf + ViiperUde.sys + ViiperUdeCtl.exe + submission-manifest.json + viiper.exe + ) + mapfile -t archived < <( + unzip -Z1 release_files/viiper-native-udecx-windows-amd64.zip | sort) + if ! diff -u \ + <(printf '%s\n' "${expected_runtime[@]}") \ + <(printf '%s\n' "${archived[@]}"); then + echo '::error::The validated native runtime archive has unexpected files.' + exit 1 + fi + - name: Extract build info id: build_info shell: bash @@ -88,17 +530,48 @@ jobs: echo "sha=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT TAG_NAME=${GITHUB_REF#refs/tags/} echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT - GIT_VERSION=$(git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always || echo "") - if [[ ! $GIT_VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then - COMMIT_COUNT=$(git rev-list --count HEAD) - GIT_VERSION="v0.0.0-${COMMIT_COUNT}-${GITHUB_SHA:0:7}" - fi - echo "version=$GIT_VERSION" >> $GITHUB_OUTPUT - echo "Version from git: $GIT_VERSION" + GIT_VERSION="$TAG_NAME" + echo "version=$GIT_VERSION" >> $GITHUB_OUTPUT + echo "Version from git: $GIT_VERSION" + + - name: Create deterministic release checksums + shell: bash + run: | + set -euo pipefail + expected=( + viiper-cpp-client-library-headers.zip + viiper-csharp-client-library-nupkg.nupkg + viiper-libVIIPER-linux-amd64.zip + viiper-libVIIPER-windows-amd64.zip + viiper-linux-amd64.tar.gz + viiper-linux-arm64.tar.gz + viiper-native-udecx-windows-amd64.zip + viiper-rust-client-library.crate + viiper-typescript-client-library.tgz + viiper-windows-amd64.zip + viiper-windows-arm64.zip + ) + mapfile -t actual < <(find release_files -maxdepth 1 -type f -printf '%f\n' | sort) + if ! diff -u <(printf '%s\n' "${expected[@]}") <(printf '%s\n' "${actual[@]}"); then + echo "::error::Release staging contained a missing or unexpected artifact." + exit 1 + fi + ( + cd release_files + find . -maxdepth 1 -type f ! -name SHA256SUMS -print0 | + sort -z | + xargs -0 sha256sum | + sed 's# \./# #' + ) > release_files/SHA256SUMS + + - name: Attest release artifact provenance + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 + with: + subject-path: release_files/* - name: Create Release id: create_release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 with: tag_name: ${{ steps.build_info.outputs.tag_name }} name: "VIIPER ${{ steps.build_info.outputs.version }}" @@ -135,16 +608,18 @@ jobs: publish-client-registries: name: Publish client libraries (best effort) needs: create-release - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 continue-on-error: true permissions: + actions: read contents: read id-token: write steps: - name: Download all artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: artifacts + pattern: "*-Release" - name: Organize client library artifacts shell: bash @@ -178,9 +653,9 @@ jobs: - name: Set up Node.js (for npm publish) id: setup_node continue-on-error: true - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: - node-version: "24" + node-version: "24.11.1" registry-url: "https://registry.npmjs.org/" - name: Publish TypeScript client library to npm @@ -231,15 +706,15 @@ jobs: id: setup_dotnet if: ${{ steps.nuget_config.outputs.enabled == 'true' }} continue-on-error: true - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 with: - dotnet-version: "8.0.x" + dotnet-version: "8.0.419" - name: NuGet login (OIDC to temporary API key) id: nuget_login if: ${{ steps.nuget_config.outputs.enabled == 'true' && steps.setup_dotnet.outcome == 'success' }} continue-on-error: true - uses: NuGet/login@v1.2.0 + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 with: user: ${{ secrets.NUGET_USER }} @@ -261,13 +736,15 @@ jobs: - name: Set up Rust (for crates.io publish) id: setup_rust continue-on-error: true - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: "1.97.1" - name: Authenticate with crates.io (OIDC) id: crates_io_auth if: ${{ steps.setup_rust.outcome == 'success' }} continue-on-error: true - uses: rust-lang/crates-io-auth-action@v1.0.4 + uses: rust-lang/crates-io-auth-action@bbd81622f20ce9e2dd9622e3218b975523e45bbe # v1.0.4 - name: Publish Rust client library to crates.io id: publish_crates diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml index 0869c765..14683d3f 100644 --- a/.github/workflows/snapshots.yml +++ b/.github/workflows/snapshots.yml @@ -10,12 +10,12 @@ permissions: jobs: calculate-version: name: Calculate Dev Version - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: version: ${{ steps.version.outputs.version }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 @@ -58,16 +58,16 @@ jobs: create-pre-release: name: Create Pre-Release needs: [build, generate-changelog, client-libraries] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/main') steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 - name: Download all artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: artifacts @@ -104,7 +104,7 @@ jobs: echo "Version from git: $GIT_VERSION" - name: Update Dev Snapshot Release - uses: andelf/nightly-release@v1 + uses: andelf/nightly-release@c5ed4bdb7c1da04a4fa1e40bc5e67306f682563b # v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/_testing/e2e/bench_test.go b/_testing/e2e/bench_test.go index 83a2b66c..e2b65a75 100644 --- a/_testing/e2e/bench_test.go +++ b/_testing/e2e/bench_test.go @@ -2,9 +2,12 @@ package e2e_bench_test import ( "context" + "fmt" "log/slog" "os" "os/signal" + "path/filepath" + "strings" "syscall" "testing" "time" @@ -30,7 +33,43 @@ const ( TimeWhat_WaitRelease ) +const e2eTransportEnvironment = "VIIPER_E2E_TRANSPORT" +const e2eBenchmarkPassword = "testpassword1234" + +func selectedE2ETransport() (string, error) { + transport := strings.ToLower(strings.TrimSpace(os.Getenv(e2eTransportEnvironment))) + if transport == "" { + return "usbip", nil + } + if transport != "usbip" && transport != "native-ude" { + return "", fmt.Errorf("%s must be usbip or native-ude, got %q", + e2eTransportEnvironment, transport) + } + return transport, nil +} + +func benchmarkAuthModeSupported(transport string, encrypted bool) bool { + return transport != "native-ude" || encrypted +} + +func TestNativeE2EBenchmarkRequiresProductionAuthentication(t *testing.T) { + if benchmarkAuthModeSupported("native-ude", false) { + t.Fatal("native UDE benchmark accepted an unauthenticated stream") + } + if !benchmarkAuthModeSupported("native-ude", true) { + t.Fatal("native UDE benchmark rejected an authenticated stream") + } + if !benchmarkAuthModeSupported("usbip", false) { + t.Fatal("legacy USB/IP benchmark unexpectedly rejected its plaintext baseline") + } +} + func Benchmark_Xbox360_Delay(b *testing.B) { + transport, err := selectedE2ETransport() + if err != nil { + b.Fatal(err) + } + b.Logf("VIIPER end-to-end transport: %s", transport) type bench struct { name string @@ -147,11 +186,23 @@ func Benchmark_Xbox360_Delay(b *testing.B) { useEncryption: true, }, } + if transport == "native-ude" { + // The native broker owns local kernel topology and therefore requires + // authenticated localhost streams. Do not silently benchmark an + // unsupported plaintext path and label its failure as transport latency. + authenticated := benches[:0] + for _, candidate := range benches { + if benchmarkAuthModeSupported(transport, candidate.useEncryption) { + authenticated = append(authenticated, candidate) + } + } + benches = authenticated + } b.SetParallelism(1) defer sdl.Quit() - if err := sdl.Init(sdl.InitFlagGamepad); err != nil { + if err := sdl.Init(sdl.InitFlagGamepad | sdl.InitFlagEvents); err != nil { b.Fatalf("SDL init failed: %v", err) } @@ -162,6 +213,10 @@ func Benchmark_Xbox360_Delay(b *testing.B) { existingGamepadSet[id] = true } + credentialPath := filepath.Join(b.TempDir(), "viiper.key.txt") + if err := os.WriteFile(credentialPath, []byte(e2eBenchmarkPassword), 0o600); err != nil { + b.Fatalf("write benchmark credential: %v", err) + } s := cmd.Server{ USBServerConfig: usb.ServerConfig{ Addr: ":3244", @@ -171,51 +226,49 @@ func Benchmark_Xbox360_Delay(b *testing.B) { Addr: ":3245", AutoAttachLocalClient: true, DeviceHandlerConnectTimeout: time.Second * 5, - Password: "testpassword1234", + Password: e2eBenchmarkPassword, PlatformOpts: api.PlatformOpts{ AutoAttachWindowsNative: true, }, }, ConnectionTimeout: 5 * time.Second, + Transport: transport, + KeyFile: credentialPath, } logger := slog.Default() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + serverDone := make(chan error, 1) go func() { - if err := s.StartServer(ctx, logger, nil); err != nil { - panic(err) - } + serverDone <- s.StartServer(ctx, logger, nil) }() - var c *viiperclient.Client - - c = viiperclient.New("localhost:3245") + client := viiperclient.NewWithPassword("localhost:3245", e2eBenchmarkPassword) var busResp *viipertypes.BusCreateResponse - var err error + var createErr error for range 10 { - busResp, err = c.BusCreate(1) - if err == nil { + select { + case serverErr := <-serverDone: + b.Fatalf("VIIPER %s server stopped during startup: %v", transport, serverErr) + default: + } + busResp, createErr = client.BusCreate(1) + if createErr == nil { break } time.Sleep(time.Second * 1) } if busResp == nil { - b.Fatalf("BusCreate failed: %v", err) + b.Fatalf("BusCreate over %s failed: %v", transport, createErr) } busID := busResp.BusID - defer c.BusRemove(busID) + defer client.BusRemove(busID) //nolint:errcheck - devInfo, err := c.DeviceAdd(busID, "xbox360", nil) + devInfo, err := client.DeviceAdd(busID, "xbox360", nil) if err != nil { b.Fatalf("DeviceAdd failed: %v", err) } - devStream, err := c.OpenStream(ctx, busID, devInfo.DevID) - if err != nil { - b.Fatalf("OpenStream failed: %v", err) - } - defer devStream.Close() //nolint:errcheck - var gamepad *sdl.Gamepad for range 10 { sdl.UpdateGamepads() @@ -238,28 +291,14 @@ func Benchmark_Xbox360_Delay(b *testing.B) { if gamepad == nil { b.Fatalf("No new gamepad found for testing (expected VIIPER virtual device)") } - padChann := make(chan bool) - prevPadPressed := false - go func() { - defer close(padChann) - for { - select { - case <-ctx.Done(): - return - default: - } - sdl.UpdateGamepads() - pressed := gamepad.GetButton(sdl.GamepadButtonSouth) - if pressed != prevPadPressed { - padChann <- pressed - prevPadPressed = pressed - } - } - }() - for _, bench := range benches { + benchClient := viiperclient.New("localhost:3245") if bench.useEncryption { - c = viiperclient.NewWithPassword("localhost:3245", "testpassword1234") + benchClient = viiperclient.NewWithPassword("localhost:3245", e2eBenchmarkPassword) + } + devStream, openErr := benchClient.OpenStream(ctx, busID, devInfo.DevID) + if openErr != nil { + b.Fatalf("OpenStream for %s failed: %v", bench.name, openErr) } b.Run(bench.name, func(b *testing.B) { for b.Loop() { @@ -272,10 +311,10 @@ func Benchmark_Xbox360_Delay(b *testing.B) { if err != nil { b.Fatalf("WriteBinary failed: %v", err) } - timeout := time.After(1 * time.Second) - bench.timeOn(TimeWhat_WaitInput, b) - waitForInput(ctx, timeout, padChann, true) + if err = waitForInput(ctx, gamepad, true); err != nil { + b.Fatalf("wait for pressed input over %s: %v", transport, err) + } b.StopTimer() bench.timeOn(TimeWhat_ClientWriteRelease, b) @@ -284,30 +323,29 @@ func Benchmark_Xbox360_Delay(b *testing.B) { if err != nil { b.Fatalf("WriteBinary failed: %v", err) } - timeout = time.After(10000 * time.Second) bench.timeOn(TimeWhat_WaitRelease, b) - waitForInput(ctx, timeout, padChann, false) + if err = waitForInput(ctx, gamepad, false); err != nil { + b.Fatalf("wait for released input over %s: %v", transport, err) + } b.StartTimer() } }) + if closeErr := devStream.Close(); closeErr != nil { + b.Fatalf("Close stream for %s: %v", bench.name, closeErr) + } } } -func waitForInput(ctx context.Context, timeout <-chan time.Time, padChann <-chan bool, wantPressed bool) error { - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-timeout: - return context.DeadlineExceeded - case pressed, ok := <-padChann: - if !ok { - return context.Canceled - } - if pressed == wantPressed { - return nil - } +func waitForInput(ctx context.Context, gamepad *sdl.Gamepad, wantPressed bool) error { + if err := ctx.Err(); err != nil { + return err + } + if !gamepad.WaitButtonEvent(sdl.GamepadButtonSouth, wantPressed, 1000) { + if err := ctx.Err(); err != nil { + return err } + return context.DeadlineExceeded } + return nil } diff --git a/_testing/e2e/cmd/verifylatency/main.go b/_testing/e2e/cmd/verifylatency/main.go new file mode 100644 index 00000000..7c2a916c --- /dev/null +++ b/_testing/e2e/cmd/verifylatency/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "strings" + + "github.com/Alia5/VIIPER/_testing/e2e/latency" +) + +func main() { + var input, markersPath, source, sdlRevision, sdlHash, manifestHash, driverHash, driverBuildIdentity, profileHash string + var samples int + flag.StringVar(&input, "input", "", "latency suite JSON") + flag.StringVar(&markersPath, "markers", "", "decoded ETL TraceLogging marker JSON") + flag.StringVar(&source, "source", "", "expected repository revision") + flag.StringVar(&sdlRevision, "sdl-revision", "", "expected SDL revision") + flag.StringVar(&sdlHash, "sdl-sha256", "", "expected loaded SDL SHA-256") + flag.StringVar(&manifestHash, "manifest-sha256", "", "expected package manifest SHA-256") + flag.StringVar(&driverHash, "driver-sha256", "", "expected installed driver SHA-256") + flag.StringVar(&driverBuildIdentity, "driver-build-identity", "", "expected negotiated loaded-driver identity") + flag.StringVar(&profileHash, "trace-profile-sha256", "", "expected WPRP SHA-256") + flag.IntVar(&samples, "samples", 0, "expected sample pairs per controller/transport") + flag.Parse() + if input == "" || markersPath == "" || source == "" || sdlRevision == "" || sdlHash == "" || + manifestHash == "" || driverHash == "" || driverBuildIdentity == "" || profileHash == "" || samples == 0 { + fail(errors.New("all verifier flags are required")) + } + file, err := os.Open(input) + if err != nil { + fail(err) + } + defer file.Close() + suite, err := latency.ParseSuiteReport(file) + if err != nil { + fail(err) + } + if err = latency.RequireSuitePass(suite); err != nil { + fail(err) + } + p := suite.Provenance + if p.SourceRevision != strings.ToLower(source) || + p.SDLSourceRevision != strings.ToLower(sdlRevision) || + p.SDLBinarySHA256 != strings.ToLower(sdlHash) || + p.NativePackageManifestSHA256 != strings.ToLower(manifestHash) || + p.NativeDriverSHA256 != strings.ToLower(driverHash) || + p.NativeDriverBuildIdentity != strings.ToLower(driverBuildIdentity) || + p.TraceProfileSHA256 != strings.ToLower(profileHash) || + p.TraceProviderName != latency.TraceProviderName || + p.TraceProviderGUID != latency.TraceProviderGUID || + p.USBIPBaselineMode != latency.USBIPBaselineMode || + p.USBIPBaselineVersion != latency.USBIPBaselineVersion { + fail(errors.New("suite provenance does not match the production invocation")) + } + for _, controllerCase := range suite.Cases { + if controllerCase.Workload.SamplePairs != samples { + fail(fmt.Errorf("%s has %d sample pairs, want %d", + controllerCase.Workload.ControllerType, controllerCase.Workload.SamplePairs, samples)) + } + } + markersFile, err := os.Open(markersPath) + if err != nil { + fail(err) + } + defer markersFile.Close() + markers, err := latency.ParseTraceMarkers(markersFile) + if err != nil { + fail(err) + } + if err = latency.VerifyTraceMarkers(suite, markers); err != nil { + fail(err) + } + fmt.Printf("strictly verified %d controller cases\n", len(suite.Cases)) +} + +func fail(err error) { + fmt.Fprintln(os.Stderr, "latency evidence rejected:", err) + os.Exit(1) +} diff --git a/_testing/e2e/latency/ViiperLatency.wprp b/_testing/e2e/latency/ViiperLatency.wprp new file mode 100644 index 00000000..bba70efa --- /dev/null +++ b/_testing/e2e/latency/ViiperLatency.wprp @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/_testing/e2e/latency/edge_fence.go b/_testing/e2e/latency/edge_fence.go new file mode 100644 index 00000000..fcf18868 --- /dev/null +++ b/_testing/e2e/latency/edge_fence.go @@ -0,0 +1,35 @@ +package latency + +import "errors" + +// RejectPreWriteEdge accounts for an exact SDL button edge observed during +// dwell or final queue drain and always rejects it as non-causal to the next +// input write. +func RejectPreWriteEdge(lastTimestamp, eventTimestamp uint64, down bool, counters *Counters) (uint64, error) { + if eventTimestamp == 0 || (lastTimestamp != 0 && eventTimestamp < lastTimestamp) { + return lastTimestamp, errors.New("SDL pre-write event timestamp was absent or regressed") + } + if counters != nil { + if down { + counters.Press++ + } else { + counters.Release++ + } + } + return eventTimestamp, errors.New("SDL button edge preceded the input write") +} + +// ValidatePostWriteTimestamp proves an observed SDL event was generated no +// earlier than the SDL clock fence captured before WriteBinary. +func ValidatePostWriteTimestamp(lastTimestamp, fenceTimestamp, eventTimestamp uint64) error { + if fenceTimestamp == 0 || eventTimestamp == 0 { + return errors.New("SDL event or pre-write fence timestamp is absent") + } + if lastTimestamp != 0 && eventTimestamp < lastTimestamp { + return errors.New("SDL event clock regressed") + } + if eventTimestamp <= fenceTimestamp { + return errors.New("SDL event did not follow the input write fence") + } + return nil +} diff --git a/_testing/e2e/latency/edge_fence_test.go b/_testing/e2e/latency/edge_fence_test.go new file mode 100644 index 00000000..c51c01ac --- /dev/null +++ b/_testing/e2e/latency/edge_fence_test.go @@ -0,0 +1,32 @@ +package latency + +import "testing" + +func TestCausalEdgeFenceRejectsDwellDrainAndPreFenceEvents(t *testing.T) { + counters := Counters{} + last, err := RejectPreWriteEdge(100, 101, true, &counters) + if err == nil || last != 101 || counters.Press != 1 { + t.Fatalf("queued press was not rejected/accounted: last=%d counters=%+v error=%v", last, counters, err) + } + last, err = RejectPreWriteEdge(last, 102, false, &counters) + if err == nil || last != 102 || counters.Release != 1 { + t.Fatalf("dwell release was not rejected/accounted: last=%d counters=%+v error=%v", last, counters, err) + } + if err = ValidatePostWriteTimestamp(last, 200, 199); err == nil { + t.Fatal("an event older than the SDL pre-write fence was accepted") + } + if err = ValidatePostWriteTimestamp(last, 200, 200); err == nil { + t.Fatal("an event sharing the pre-write fence tick was accepted") + } + if err = ValidatePostWriteTimestamp(last, 200, 201); err != nil { + t.Fatalf("an event after the causal admission fence was rejected: %v", err) + } +} + +func TestCausalEdgeFenceDoesNotCountInvalidTimestamp(t *testing.T) { + counters := Counters{} + last, err := RejectPreWriteEdge(100, 99, true, &counters) + if err == nil || last != 100 || counters.Total() != 0 { + t.Fatalf("regressed event corrupted counters: last=%d counters=%+v error=%v", last, counters, err) + } +} diff --git a/_testing/e2e/latency/profile_contract_test.go b/_testing/e2e/latency/profile_contract_test.go new file mode 100644 index 00000000..672ce4b2 --- /dev/null +++ b/_testing/e2e/latency/profile_contract_test.go @@ -0,0 +1,98 @@ +package latency + +import ( + "os" + "strings" + "testing" +) + +func TestProductionTraceAndWrapperFailClosedContract(t *testing.T) { + profile, err := os.ReadFile("ViiperLatency.wprp") + if err != nil { + t.Fatal(err) + } + profileText := string(profile) + for _, want := range []string{ + `LoggingMode="File"`, TraceProviderGUID[1 : len(TraceProviderGUID)-1], + `Value="CSwitch"`, `Value="ReadyThread"`, `Value="SampledProfile"`, + `Value="DPC"`, `Value="Interrupt"`, `Value="WDFDPC"`, `Value="WDFInterrupt"`, + } { + if !strings.Contains(profileText, want) { + t.Fatalf("source-controlled WPRP is missing %q", want) + } + } + if strings.Contains(profileText, `LoggingMode="Memory"`) { + t.Fatal("production WPRP regressed to circular memory logging") + } + + wrapper, err := os.ReadFile("../scripts/Invoke-ViiperE2ELatencyGate.ps1") + if err != nil { + t.Fatal(err) + } + wrapperText := string(wrapper) + for _, want := range []string{ + "-filemode", "verifylatency", + "-C $repository test", "-C $repository run", + "$env:GOWORK = 'off'", "$env:GOENV = 'off'", "$env:GOTOOLCHAIN = 'local'", + "-ldflags $nativeRevisionLDFlag", + "github.com/Alia5/VIIPER/internal/transport/udecx.nativeSourceRevision=$headRevision", + "Get-WinEvent -FilterHashtable", "ProviderName = 'VIIPER-LatencyGate'", + "trace_marker_id", "start_qpc_ticks", "trace_marker_qpc_ticks", + "Win32_PnPEntity", "@($_.HardwareID) -contains 'ROOT\\VIIPER\\UDE'", + "$ownedRootDevices[0].PNPDeviceID", + "Dropped\\s+Event", "Buffers?\\s+Lost", + "Resolve-ExactExecutablePath", "[Environment]::SystemDirectory", + "VIIPER_E2E_EXPECTED_PRIORITY_CLASS", "git_executable_sha256", + "-buildvcs=false", + } { + if !strings.Contains(wrapperText, want) { + t.Fatalf("production wrapper is missing fail-closed contract %q", want) + } + } + if strings.Contains(wrapperText, "GeneralProfile.Verbose") { + t.Fatal("production wrapper regressed to an inbox circular profile") + } + if strings.Contains(wrapperText, "DeviceID -like 'ROOT\\VIIPER\\UDE*'") { + t.Fatal("production wrapper confuses the INF hardware ID with the generated PnP instance ID") + } + liveHarness, err := os.ReadFile("../latency_gate_windows_test.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(liveHarness), "sdl.EnableWindowsRawInput()") { + t.Fatal("production harness no longer enables the SDL backend that supplies exact Xbox PnP paths") + } + for _, want := range []string{ + "P90NS", "P999NS", "collectMachineProvenance", "GetPriorityClass", + "ProcessorNameString", "RtlGetVersion", "ProcessElevated", + "exec.Command(executable", "runGit(config.gitPath", + } { + if !strings.Contains(string(liveHarness), want) { + t.Fatalf("production harness is missing latency provenance contract %q", want) + } + } + if strings.Contains(string(liveHarness), `exec.Command("git"`) { + t.Fatal("production harness regressed to PATH-resolved Git after verifying a pinned image") + } + matrix, err := os.ReadFile("../scripts/Invoke-ViiperE2ELatencyMatrix.ps1") + if err != nil { + t.Fatal(err) + } + matrixText := string(matrix) + for _, want := range []string{ + "priority = 'Normal'", "priority = 'High'", "Get-ExactEvidenceFile", + "latency-priority-matrix/v1", "process_priority_class", "Flush($true)", + } { + if !strings.Contains(matrixText, want) { + t.Fatalf("priority-matrix wrapper is missing fail-closed contract %q", want) + } + } + verifier, err := os.ReadFile("../cmd/verifylatency/main.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(verifier), "latency.ParseSuiteReport") || + !strings.Contains(string(verifier), "latency.RequireSuitePass") { + t.Fatal("production verifier no longer invokes strict parsing and pass enforcement") + } +} diff --git a/_testing/e2e/latency/report.go b/_testing/e2e/latency/report.go new file mode 100644 index 00000000..7ee0729b --- /dev/null +++ b/_testing/e2e/latency/report.go @@ -0,0 +1,1173 @@ +package latency + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "reflect" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +var productionPhaseSweepOffsetsNS = [...]int64{ + 0, + 125 * int64(time.Microsecond), + 250 * int64(time.Microsecond), + 375 * int64(time.Microsecond), + 500 * int64(time.Microsecond), + 625 * int64(time.Microsecond), + 750 * int64(time.Microsecond), + 875 * int64(time.Microsecond), +} + +const ( + SchemaV2 = "viiper.controller-to-game.latency/v2" + SuiteSchemaV2 = "viiper.controller-to-game.latency-suite/v2" + TransportUSBIP = "usbip" + TransportNativeUDE = "native-ude" + AuthenticationMode = "password-authenticated-encrypted-stream" + TraceProviderName = "VIIPER-LatencyGate" + TraceProviderGUID = "{e1726ef8-c2e6-4dad-bbf7-2d871b953ab1}" + USBIPBaselineMode = "version-probed-functional-baseline-not-source-bound" + USBIPBaselineVersion = "0.9.7.7" + MinimumProductionSamplePairs = 256 + MaximumProductionSamplePairs = 10_000 + ProductionWarmupPairs = 16 + ProductionTransportBlocks = 2 + ProductionTransitionTimeoutNS int64 = int64(time.Second) + ProductionInterTransitionDelayNS int64 = 2 * int64(time.Millisecond) + DefaultNativeMaxP95NS int64 = 4 * int64(time.Millisecond) + DefaultNativeMaxP99NS int64 = 8 * int64(time.Millisecond) + DefaultNativeMaxNS int64 = 20 * int64(time.Millisecond) + DefaultNativeMaxP95OverUSBIPNS int64 = 1 * int64(time.Millisecond) + DefaultNativeMaxP99OverUSBIPNS int64 = 2 * int64(time.Millisecond) + DefaultNativeMaxOverUSBIPNS int64 = 5 * int64(time.Millisecond) +) + +type Transition string + +const ( + TransitionPress Transition = "press" + TransitionRelease Transition = "release" +) + +// BlockSpec is one block in the counterbalanced ABBA transport schedule. +// Sample sequence numbers are contiguous within each transport, even though +// the two transports are interleaved in wall-clock order. +type BlockSpec struct { + Order int + Transport string + TransportBlock int + FirstSequence int + SamplePairs int +} + +// ProductionBlockSchedule splits the declared samples as evenly as possible +// across USB/IP, native UDE, native UDE, then USB/IP. The ABBA order controls +// first/last-run drift without discarding the identity proof for either block. +func ProductionBlockSchedule(samplePairs int) []BlockSpec { + firstBlockPairs := samplePairs / ProductionTransportBlocks + secondBlockPairs := samplePairs - firstBlockPairs + secondFirstSequence := firstBlockPairs + 1 + return []BlockSpec{ + {Order: 1, Transport: TransportUSBIP, TransportBlock: 1, + FirstSequence: 1, SamplePairs: firstBlockPairs}, + {Order: 2, Transport: TransportNativeUDE, TransportBlock: 1, + FirstSequence: 1, SamplePairs: firstBlockPairs}, + {Order: 3, Transport: TransportNativeUDE, TransportBlock: 2, + FirstSequence: secondFirstSequence, SamplePairs: secondBlockPairs}, + {Order: 4, Transport: TransportUSBIP, TransportBlock: 2, + FirstSequence: secondFirstSequence, SamplePairs: secondBlockPairs}, + } +} + +// ProductionPhaseSweepOffsetsNS returns a copy of the deterministic dwell +// offsets. Added to the 2 ms base dwell, the offsets span one 1 ms HID service +// interval without randomizing otherwise reproducible runs. +func ProductionPhaseSweepOffsetsNS() []int64 { + return append([]int64(nil), productionPhaseSweepOffsetsNS[:]...) +} + +// PhaseSweepScheduleSHA256 hashes the comma-separated base-10 nanosecond +// offsets. That canonical representation is recorded in every workload. +func PhaseSweepScheduleSHA256(offsets []int64) string { + var canonical strings.Builder + for index, offset := range offsets { + if index != 0 { + canonical.WriteByte(',') + } + canonical.WriteString(strconv.FormatInt(offset, 10)) + } + digest := sha256.Sum256([]byte(canonical.String())) + return fmt.Sprintf("%x", digest) +} + +// ProductionPhaseOffsetNS returns the source-bound offset for an exact +// press/release sequence. Each transport resumes the same schedule in block 2. +func ProductionPhaseOffsetNS(sequence int, transition Transition) int64 { + edgeIndex := 2 * (sequence - 1) + if transition == TransitionRelease { + edgeIndex++ + } + if edgeIndex < 0 { + return 0 + } + return productionPhaseSweepOffsetsNS[edgeIndex%len(productionPhaseSweepOffsetsNS)] +} + +type Sample struct { + Sequence int `json:"sequence"` + Transition Transition `json:"transition"` + LatencyNS int64 `json:"latency_ns"` + EventTimestampNS uint64 `json:"sdl_event_timestamp_ns"` + SDLFenceTimestampNS uint64 `json:"sdl_prewrite_fence_timestamp_ns"` + StartQPCTicks int64 `json:"start_qpc_ticks"` + EndQPCTicks int64 `json:"end_qpc_ticks"` + MarkerQPCTicks int64 `json:"trace_marker_qpc_ticks"` + MarkerID string `json:"trace_marker_id"` +} + +type Counters struct { + Press int `json:"press"` + Release int `json:"release"` +} + +func (c Counters) Total() int { return c.Press + c.Release } + +type Distribution struct { + Count int `json:"count"` + P50NS int64 `json:"p50_ns"` + P90NS int64 `json:"p90_ns"` + P95NS int64 `json:"p95_ns"` + P99NS int64 `json:"p99_ns"` + P999NS int64 `json:"p99_9_ns"` + MaxNS int64 `json:"max_ns"` + JitterNS float64 `json:"jitter_ns"` +} + +type DistributionSet struct { + Press Distribution `json:"press"` + Release Distribution `json:"release"` + Combined Distribution `json:"combined"` +} + +type NativeServerProof struct { + ABIMajor uint16 `json:"abi_major"` + ABIMinor uint16 `json:"abi_minor"` + Capabilities uint32 `json:"capabilities"` + ExpectedDriverPackageVersion string `json:"expected_driver_package_version"` + LoadedDriverBuildIdentity string `json:"loaded_driver_build_identity"` +} + +type ServerProof struct { + Server string `json:"server"` + Version string `json:"version"` + Transport string `json:"transport"` + Ready bool `json:"ready"` + NativeUDE *NativeServerProof `json:"native_ude,omitempty"` +} + +type DeviceProof struct { + BusID uint32 `json:"bus_id"` + DeviceID string `json:"device_id"` + Type string `json:"type"` + VendorID uint16 `json:"vendor_id"` + ProductID uint16 `json:"product_id"` + USBIPPort int32 `json:"usbip_port,omitempty"` +} + +type ControllerProof struct { + BaselineGamepadIDs []int32 `json:"baseline_gamepad_ids"` + NewGamepadIDs []int32 `json:"new_gamepad_ids"` + SDLInstanceID int32 `json:"sdl_instance_id"` + SDLPath string `json:"sdl_path"` + SDLGUID string `json:"sdl_guid"` + SDLName string `json:"sdl_name"` + SDLType string `json:"sdl_type"` + SDLReportedType int32 `json:"sdl_reported_type"` + SDLRealType int32 `json:"sdl_real_type"` + VendorID uint16 `json:"vendor_id"` + ProductID uint16 `json:"product_id"` + PNPInstanceID string `json:"pnp_instance_id"` + PNPContainerID string `json:"pnp_container_id"` + PNPAncestorIDs []string `json:"pnp_ancestor_ids"` + PNPAncestorContainerIDs []string `json:"pnp_ancestor_container_ids"` + PNPAncestorServices []string `json:"pnp_ancestor_services"` + PNPAncestorHardwareIDs [][]string `json:"pnp_ancestor_hardware_ids"` + PNPAncestorLocationInfo []string `json:"pnp_ancestor_location_info"` + PNPAncestorLocationPaths [][]string `json:"pnp_ancestor_location_paths"` + TransportAnchorInstanceID string `json:"transport_anchor_instance_id"` + TransportAnchorService string `json:"transport_anchor_service"` +} + +type Run struct { + Order int `json:"order"` + TransportBlock int `json:"transport_block"` + FirstSequence int `json:"first_sequence"` + SamplePairs int `json:"sample_pairs"` + Transport string `json:"transport"` + Authentication string `json:"authentication"` + UnauthenticatedRejected bool `json:"unauthenticated_rejected"` + Server ServerProof `json:"server"` + Device DeviceProof `json:"device"` + Controller ControllerProof `json:"controller"` + Samples []Sample `json:"samples"` + Misses Counters `json:"misses"` + Duplicates Counters `json:"duplicates"` + Statistics DistributionSet `json:"statistics"` + Failure string `json:"failure,omitempty"` +} + +type Workload struct { + APIAddress string `json:"api_address"` + USBIPAddress string `json:"usbip_address"` + ControllerType string `json:"controller_type"` + ExpectedVendorID uint16 `json:"expected_vendor_id"` + ExpectedProductID uint16 `json:"expected_product_id"` + ExpectedSDLType string `json:"expected_sdl_type"` + Button string `json:"button"` + WarmupPairs int `json:"warmup_pairs"` + SamplePairs int `json:"sample_pairs"` + PerTransitionTimeoutNS int64 `json:"per_transition_timeout_ns"` + InterTransitionDelayNS int64 `json:"inter_transition_delay_ns"` + PhaseSweepOffsetsNS []int64 `json:"phase_sweep_offsets_ns"` + PhaseSweepSHA256 string `json:"phase_sweep_sha256"` + Authentication string `json:"authentication"` +} + +type MachineProvenance struct { + Hostname string `json:"hostname"` + OSProductName string `json:"os_product_name"` + OSDisplayVersion string `json:"os_display_version"` + OSVersion string `json:"os_version"` + CPUModel string `json:"cpu_model"` + LogicalProcessors int `json:"logical_processors"` + ProcessPriorityClass string `json:"process_priority_class"` + ProcessElevated bool `json:"process_elevated"` +} + +type Provenance struct { + SourceRevision string `json:"source_revision"` + SDLSourceRevision string `json:"sdl_source_revision"` + SDLBinaryPath string `json:"sdl_binary_path"` + SDLBinarySHA256 string `json:"sdl_binary_sha256"` + NativePackageManifestSHA256 string `json:"native_package_manifest_sha256"` + NativeDriverSHA256 string `json:"native_driver_sha256"` + NativeDriverBuildIdentity string `json:"native_driver_build_identity"` + QPCFrequency int64 `json:"qpc_frequency"` + TraceProviderName string `json:"trace_provider_name"` + TraceProviderGUID string `json:"trace_provider_guid"` + TraceProfileSHA256 string `json:"trace_profile_sha256"` + USBIPBaselineMode string `json:"usbip_baseline_mode"` + USBIPBaselineVersion string `json:"usbip_baseline_version"` + GoVersion string `json:"go_version"` + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` + GitExecutablePath string `json:"git_executable_path"` + GitExecutableSHA256 string `json:"git_executable_sha256"` + GoExecutablePath string `json:"go_executable_path"` + GoExecutableSHA256 string `json:"go_executable_sha256"` + WPRExecutablePath string `json:"wpr_executable_path"` + WPRExecutableSHA256 string `json:"wpr_executable_sha256"` + Machine MachineProvenance `json:"machine"` +} + +// SampleMarkerID is the canonical cross-artifact identity shared by JSON and +// the TraceLogging marker emitted after the SDL edge is observed. +func SampleMarkerID(controller, transport string, block, sequence int, transition Transition) string { + return fmt.Sprintf("%s:%s:%d:%d:%s", controller, transport, block, sequence, transition) +} + +// QPCIntervalNS converts a bounded raw QueryPerformanceCounter interval to +// nanoseconds. Production samples are shorter than the per-transition timeout, +// so rejecting rather than saturating on multiplication overflow is safe and +// prevents a forged or corrupted interval from becoming a plausible latency. +func QPCIntervalNS(start, end, frequency int64) (int64, error) { + if start <= 0 || end <= start || frequency <= 0 { + return 0, errors.New("QPC interval or frequency is invalid") + } + delta := end - start + if delta > math.MaxInt64/int64(time.Second) { + return 0, errors.New("QPC interval overflows nanosecond conversion") + } + nanoseconds := delta * int64(time.Second) / frequency + if nanoseconds <= 0 { + return 0, errors.New("QPC interval has sub-nanosecond or non-positive duration") + } + return nanoseconds, nil +} + +type Policy struct { + MinimumSamplePairs int `json:"minimum_sample_pairs"` + NativeMaxP95NS int64 `json:"native_max_p95_ns"` + NativeMaxP99NS int64 `json:"native_max_p99_ns"` + NativeMaxNS int64 `json:"native_max_ns"` + NativeMaxP95OverUSBIPNS int64 `json:"native_max_p95_over_usbip_ns"` + NativeMaxP99OverUSBIPNS int64 `json:"native_max_p99_over_usbip_ns"` + NativeMaxOverUSBIPNS int64 `json:"native_max_over_usbip_ns"` +} + +type TransportAggregate struct { + Transport string `json:"transport"` + BlockCount int `json:"block_count"` + Misses Counters `json:"misses"` + Duplicates Counters `json:"duplicates"` + Statistics DistributionSet `json:"statistics"` +} + +type MetricComparison struct { + USBIP float64 `json:"usbip"` + NativeUDE float64 `json:"native_ude"` + NativeMinusUSBIP float64 `json:"native_minus_usbip"` + NativeToUSBIPRatio *float64 `json:"native_to_usbip_ratio,omitempty"` +} + +type DistributionComparison struct { + P50 MetricComparison `json:"p50_ns"` + P90 MetricComparison `json:"p90_ns"` + P95 MetricComparison `json:"p95_ns"` + P99 MetricComparison `json:"p99_ns"` + P999 MetricComparison `json:"p99_9_ns"` + Max MetricComparison `json:"max_ns"` + Jitter MetricComparison `json:"jitter_ns"` +} + +type ComparisonSet struct { + Press DistributionComparison `json:"press"` + Release DistributionComparison `json:"release"` + Combined DistributionComparison `json:"combined"` +} + +type Report struct { + Schema string `json:"schema"` + GeneratedAt time.Time `json:"generated_at"` + Provenance Provenance `json:"provenance"` + Workload Workload `json:"workload"` + Policy Policy `json:"policy"` + Runs []Run `json:"runs"` + Transports []TransportAggregate `json:"transports"` + Comparison ComparisonSet `json:"comparison"` + Verdict string `json:"verdict"` + Failures []string `json:"failures"` +} + +type SuiteReport struct { + Schema string `json:"schema"` + GeneratedAt time.Time `json:"generated_at"` + Provenance Provenance `json:"provenance"` + Cases []Report `json:"cases"` + Verdict string `json:"verdict"` + Failures []string `json:"failures"` +} + +var ( + revisionPattern = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{64})$`) + hashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + containerPattern = regexp.MustCompile( + `(?i)^\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}$`) +) + +// Calculate returns nearest-rank percentiles and population standard deviation. +// The input order is retained so callers can keep the original per-sample record. +func Calculate(values []int64) (Distribution, error) { + if len(values) == 0 { + return Distribution{}, errors.New("cannot summarize zero latency samples") + } + ordered := append([]int64(nil), values...) + for index, value := range ordered { + if value <= 0 { + return Distribution{}, fmt.Errorf("latency sample %d must be positive, got %d", index, value) + } + } + sort.Slice(ordered, func(i, j int) bool { return ordered[i] < ordered[j] }) + + mean := 0.0 + m2 := 0.0 + for index, value := range values { + x := float64(value) + delta := x - mean + mean += delta / float64(index+1) + m2 += delta * (x - mean) + } + + return Distribution{ + Count: len(ordered), + P50NS: nearestRank(ordered, 0.50), + P90NS: nearestRank(ordered, 0.90), + P95NS: nearestRank(ordered, 0.95), + P99NS: nearestRank(ordered, 0.99), + P999NS: nearestRank(ordered, 0.999), + MaxNS: ordered[len(ordered)-1], + JitterNS: math.Sqrt(m2 / float64(len(ordered))), + }, nil +} + +func nearestRank(ordered []int64, percentile float64) int64 { + rank := int(math.Ceil(percentile*float64(len(ordered)))) - 1 + if rank < 0 { + rank = 0 + } + if rank >= len(ordered) { + rank = len(ordered) - 1 + } + return ordered[rank] +} + +// Finalize recomputes every derived field and evaluates the fail-closed policy. +func Finalize(report *Report) error { + if report == nil { + return errors.New("nil latency report") + } + if err := validateBase(report); err != nil { + return err + } + + report.Failures = nil + report.Comparison = ComparisonSet{} + report.Transports = nil + for index := range report.Runs { + run := &report.Runs[index] + run.Statistics = summarizeSamples(run.Samples) + if run.Failure != "" { + report.Failures = append(report.Failures, + fmt.Sprintf("%s block %d failed: %s", run.Transport, run.TransportBlock, run.Failure)) + } + if run.Misses.Total() != 0 { + report.Failures = append(report.Failures, + fmt.Sprintf("%s block %d observed %d missed transitions (press=%d release=%d)", + run.Transport, run.TransportBlock, run.Misses.Total(), + run.Misses.Press, run.Misses.Release)) + } + if run.Duplicates.Total() != 0 { + report.Failures = append(report.Failures, + fmt.Sprintf("%s block %d observed %d duplicate transitions (press=%d release=%d)", + run.Transport, run.TransportBlock, run.Duplicates.Total(), + run.Duplicates.Press, run.Duplicates.Release)) + } + } + + for _, transport := range []string{TransportUSBIP, TransportNativeUDE} { + aggregate := aggregateTransport(report.Runs, transport) + report.Transports = append(report.Transports, aggregate) + if aggregate.Statistics.Press.Count < report.Policy.MinimumSamplePairs { + report.Failures = append(report.Failures, + fmt.Sprintf("%s has %d/%d required press samples across its counterbalanced blocks", + transport, aggregate.Statistics.Press.Count, report.Policy.MinimumSamplePairs)) + } + if aggregate.Statistics.Release.Count < report.Policy.MinimumSamplePairs { + report.Failures = append(report.Failures, + fmt.Sprintf("%s has %d/%d required release samples across its counterbalanced blocks", + transport, aggregate.Statistics.Release.Count, report.Policy.MinimumSamplePairs)) + } + } + + usbip := aggregateForTransport(report.Transports, TransportUSBIP) + native := aggregateForTransport(report.Transports, TransportNativeUDE) + if usbip != nil && native != nil && + usbip.Statistics.Press.Count != 0 && native.Statistics.Press.Count != 0 && + usbip.Statistics.Release.Count != 0 && native.Statistics.Release.Count != 0 { + report.Comparison = compareSets(usbip.Statistics, native.Statistics) + checkNativeLimits(report, "press", native.Statistics.Press) + checkNativeLimits(report, "release", native.Statistics.Release) + checkNativeLimits(report, "combined", native.Statistics.Combined) + checkNativeNonRegression(report, "press", usbip.Statistics.Press, native.Statistics.Press) + checkNativeNonRegression(report, "release", usbip.Statistics.Release, native.Statistics.Release) + checkNativeNonRegression(report, "combined", usbip.Statistics.Combined, native.Statistics.Combined) + } + + if len(report.Failures) == 0 { + report.Verdict = "pass" + } else { + report.Verdict = "fail" + } + return nil +} + +func aggregateTransport(runs []Run, transport string) TransportAggregate { + aggregate := TransportAggregate{Transport: transport} + var samples []Sample + for index := range runs { + run := &runs[index] + if run.Transport != transport { + continue + } + aggregate.BlockCount++ + aggregate.Misses.Press += run.Misses.Press + aggregate.Misses.Release += run.Misses.Release + aggregate.Duplicates.Press += run.Duplicates.Press + aggregate.Duplicates.Release += run.Duplicates.Release + samples = append(samples, run.Samples...) + } + aggregate.Statistics = summarizeSamples(samples) + return aggregate +} + +func aggregateForTransport(aggregates []TransportAggregate, transport string) *TransportAggregate { + for index := range aggregates { + if aggregates[index].Transport == transport { + return &aggregates[index] + } + } + return nil +} + +func summarizeSamples(samples []Sample) DistributionSet { + press := make([]int64, 0, len(samples)/2) + release := make([]int64, 0, len(samples)/2) + combined := make([]int64, 0, len(samples)) + for _, sample := range samples { + combined = append(combined, sample.LatencyNS) + switch sample.Transition { + case TransitionPress: + press = append(press, sample.LatencyNS) + case TransitionRelease: + release = append(release, sample.LatencyNS) + } + } + var result DistributionSet + if len(press) != 0 { + result.Press, _ = Calculate(press) + } + if len(release) != 0 { + result.Release, _ = Calculate(release) + } + if len(combined) != 0 { + result.Combined, _ = Calculate(combined) + } + return result +} + +func checkNativeLimits(report *Report, name string, distribution Distribution) { + if distribution.Count == 0 { + return + } + if distribution.P95NS > report.Policy.NativeMaxP95NS { + report.Failures = append(report.Failures, fmt.Sprintf( + "native-ude %s p95 %dns exceeds %dns", name, + distribution.P95NS, report.Policy.NativeMaxP95NS)) + } + if distribution.P99NS > report.Policy.NativeMaxP99NS { + report.Failures = append(report.Failures, fmt.Sprintf( + "native-ude %s p99 %dns exceeds %dns", name, + distribution.P99NS, report.Policy.NativeMaxP99NS)) + } + if distribution.MaxNS > report.Policy.NativeMaxNS { + report.Failures = append(report.Failures, fmt.Sprintf( + "native-ude %s max %dns exceeds %dns", name, + distribution.MaxNS, report.Policy.NativeMaxNS)) + } +} + +func checkNativeNonRegression(report *Report, name string, usbip, native Distribution) { + if native.P95NS-usbip.P95NS > report.Policy.NativeMaxP95OverUSBIPNS { + report.Failures = append(report.Failures, fmt.Sprintf( + "native-ude %s p95 exceeds same-machine USB/IP by %dns (allowed %dns)", + name, native.P95NS-usbip.P95NS, report.Policy.NativeMaxP95OverUSBIPNS)) + } + if native.P99NS-usbip.P99NS > report.Policy.NativeMaxP99OverUSBIPNS { + report.Failures = append(report.Failures, fmt.Sprintf( + "native-ude %s p99 exceeds same-machine USB/IP by %dns (allowed %dns)", + name, native.P99NS-usbip.P99NS, report.Policy.NativeMaxP99OverUSBIPNS)) + } + if native.MaxNS-usbip.MaxNS > report.Policy.NativeMaxOverUSBIPNS { + report.Failures = append(report.Failures, fmt.Sprintf( + "native-ude %s max exceeds same-machine USB/IP by %dns (allowed %dns)", + name, native.MaxNS-usbip.MaxNS, report.Policy.NativeMaxOverUSBIPNS)) + } +} + +func compareSets(usbip, native DistributionSet) ComparisonSet { + return ComparisonSet{ + Press: compareDistribution(usbip.Press, native.Press), + Release: compareDistribution(usbip.Release, native.Release), + Combined: compareDistribution(usbip.Combined, native.Combined), + } +} + +func compareDistribution(usbip, native Distribution) DistributionComparison { + return DistributionComparison{ + P50: compareMetric(float64(usbip.P50NS), float64(native.P50NS)), + P90: compareMetric(float64(usbip.P90NS), float64(native.P90NS)), + P95: compareMetric(float64(usbip.P95NS), float64(native.P95NS)), + P99: compareMetric(float64(usbip.P99NS), float64(native.P99NS)), + P999: compareMetric(float64(usbip.P999NS), float64(native.P999NS)), + Max: compareMetric(float64(usbip.MaxNS), float64(native.MaxNS)), + Jitter: compareMetric(usbip.JitterNS, native.JitterNS), + } +} + +func compareMetric(usbip, native float64) MetricComparison { + comparison := MetricComparison{ + USBIP: usbip, + NativeUDE: native, + NativeMinusUSBIP: native - usbip, + } + if usbip != 0 { + ratio := native / usbip + comparison.NativeToUSBIPRatio = &ratio + } + return comparison +} + +func validateBase(report *Report) error { + if report.Schema != SchemaV2 { + return fmt.Errorf("unsupported report schema %q", report.Schema) + } + if report.GeneratedAt.IsZero() { + return errors.New("generated_at is required") + } + if !revisionPattern.MatchString(report.Provenance.SourceRevision) { + return errors.New("source_revision must be a lowercase 40- or 64-digit Git revision") + } + if !revisionPattern.MatchString(report.Provenance.SDLSourceRevision) { + return errors.New("sdl_source_revision must be a lowercase 40- or 64-digit Git revision") + } + if report.Provenance.SDLBinaryPath == "" || + !hashPattern.MatchString(report.Provenance.SDLBinarySHA256) { + return errors.New("the loaded SDL binary path and SHA-256 are required") + } + if !hashPattern.MatchString(report.Provenance.NativePackageManifestSHA256) || + !hashPattern.MatchString(report.Provenance.NativeDriverSHA256) || + !hashPattern.MatchString(report.Provenance.NativeDriverBuildIdentity) { + return errors.New("source-bound native package manifest and installed driver hashes are required") + } + if report.Provenance.QPCFrequency <= 0 || + report.Provenance.TraceProviderName != TraceProviderName || + report.Provenance.TraceProviderGUID != TraceProviderGUID || + !hashPattern.MatchString(report.Provenance.TraceProfileSHA256) { + return errors.New("QPC and source-controlled TraceLogging provenance are incomplete") + } + if report.Provenance.USBIPBaselineMode != USBIPBaselineMode || + report.Provenance.USBIPBaselineVersion != USBIPBaselineVersion { + return errors.New("USB/IP comparison must be explicitly labeled as the exact version-probed, non-source-bound baseline") + } + if report.Provenance.GoVersion == "" || report.Provenance.GOOS != "windows" || + report.Provenance.GOARCH == "" { + return errors.New("Windows Go toolchain provenance is incomplete") + } + if report.Provenance.GitExecutablePath == "" || + !hashPattern.MatchString(report.Provenance.GitExecutableSHA256) || + report.Provenance.GoExecutablePath == "" || + !hashPattern.MatchString(report.Provenance.GoExecutableSHA256) || + report.Provenance.WPRExecutablePath == "" || + !hashPattern.MatchString(report.Provenance.WPRExecutableSHA256) { + return errors.New("Git, Go, and WPR executable provenance is incomplete") + } + machine := report.Provenance.Machine + if machine.Hostname == "" || machine.OSProductName == "" || + machine.OSDisplayVersion == "" || machine.OSVersion == "" || + machine.CPUModel == "" || machine.LogicalProcessors <= 0 || + (machine.ProcessPriorityClass != "normal" && + machine.ProcessPriorityClass != "high") || !machine.ProcessElevated { + return errors.New("machine, OS, CPU, elevation, and process-priority provenance are incomplete") + } + if report.Workload.APIAddress == "" || report.Workload.USBIPAddress == "" || + report.Workload.Button != "south/A" || + report.Workload.Authentication != AuthenticationMode { + return errors.New("workload identity is incomplete or unsupported") + } + if err := validateControllerWorkload(report.Workload); err != nil { + return err + } + if report.Workload.WarmupPairs != ProductionWarmupPairs || + report.Workload.SamplePairs < MinimumProductionSamplePairs || + report.Workload.SamplePairs > MaximumProductionSamplePairs || + report.Workload.PerTransitionTimeoutNS != ProductionTransitionTimeoutNS || + report.Workload.InterTransitionDelayNS != ProductionInterTransitionDelayNS { + return errors.New("workload warmup, sample count, timeout, or transition delay is invalid") + } + productionOffsets := ProductionPhaseSweepOffsetsNS() + if !reflect.DeepEqual(report.Workload.PhaseSweepOffsetsNS, productionOffsets) || + report.Workload.PhaseSweepSHA256 != PhaseSweepScheduleSHA256(report.Workload.PhaseSweepOffsetsNS) { + return errors.New("workload phase-sweep schedule or SHA-256 is not the reviewed production schedule") + } + if report.Policy.MinimumSamplePairs < MinimumProductionSamplePairs || + report.Policy.MinimumSamplePairs > report.Workload.SamplePairs { + return errors.New("minimum sample policy is weaker than the production floor or exceeds the workload") + } + if report.Policy.NativeMaxP95NS <= 0 || + report.Policy.NativeMaxP95NS > DefaultNativeMaxP95NS || + report.Policy.NativeMaxP99NS <= 0 || + report.Policy.NativeMaxP99NS > DefaultNativeMaxP99NS || + report.Policy.NativeMaxNS <= 0 || + report.Policy.NativeMaxNS > DefaultNativeMaxNS || + report.Policy.NativeMaxP95OverUSBIPNS <= 0 || + report.Policy.NativeMaxP95OverUSBIPNS > DefaultNativeMaxP95OverUSBIPNS || + report.Policy.NativeMaxP99OverUSBIPNS <= 0 || + report.Policy.NativeMaxP99OverUSBIPNS > DefaultNativeMaxP99OverUSBIPNS || + report.Policy.NativeMaxOverUSBIPNS <= 0 || + report.Policy.NativeMaxOverUSBIPNS > DefaultNativeMaxOverUSBIPNS { + return errors.New("native latency policy is absent or weaker than the reviewed release limits") + } + schedule := ProductionBlockSchedule(report.Workload.SamplePairs) + if len(report.Runs) != len(schedule) { + return errors.New("report must contain exactly the four ABBA transport blocks") + } + + for index := range report.Runs { + if err := validateRun(&report.Runs[index], report.Workload, report.Provenance, schedule[index]); err != nil { + return fmt.Errorf("order %d %s block %d: %w", index+1, + report.Runs[index].Transport, report.Runs[index].TransportBlock, err) + } + } + serverVersion := "" + var priorEventTimestamp uint64 + for index := range report.Runs { + run := &report.Runs[index] + if len(run.Samples) != 0 { + firstTimestamp := run.Samples[0].EventTimestampNS + if priorEventTimestamp != 0 && firstTimestamp < priorEventTimestamp { + return errors.New("SDL event clock regressed between ABBA transport blocks") + } + priorEventTimestamp = run.Samples[len(run.Samples)-1].EventTimestampNS + } + if run.Failure != "" { + continue + } + if serverVersion == "" { + serverVersion = run.Server.Version + } else if run.Server.Version != serverVersion { + return errors.New("transport blocks came from different VIIPER server versions") + } + } + return nil +} + +func validateRun(run *Run, workload Workload, provenance Provenance, block BlockSpec) error { + if run.Order != block.Order || run.Transport != block.Transport || + run.TransportBlock != block.TransportBlock || + run.FirstSequence != block.FirstSequence || run.SamplePairs != block.SamplePairs { + return fmt.Errorf("block metadata does not match the production ABBA schedule: %+v", block) + } + if run.Authentication != AuthenticationMode { + return errors.New("API/controller stream is not authenticated identically") + } + if run.Misses.Press < 0 || run.Misses.Release < 0 || + run.Duplicates.Press < 0 || run.Duplicates.Release < 0 { + return errors.New("negative integrity counter") + } + if len(run.Samples) > 2*run.SamplePairs { + return errors.New("more samples than the declared transport block") + } + var priorTimestamp uint64 + var priorMarkerQPC int64 + for index, sample := range run.Samples { + wantSequence := run.FirstSequence + index/2 + wantTransition := TransitionPress + if index%2 != 0 { + wantTransition = TransitionRelease + } + if sample.Sequence != wantSequence || sample.Transition != wantTransition { + return fmt.Errorf("sample %d is %d/%s, want %d/%s", index, + sample.Sequence, sample.Transition, wantSequence, wantTransition) + } + wantMarkerID := SampleMarkerID(workload.ControllerType, run.Transport, + run.TransportBlock, sample.Sequence, sample.Transition) + qpcLatencyNS, qpcErr := QPCIntervalNS(sample.StartQPCTicks, + sample.EndQPCTicks, provenance.QPCFrequency) + if qpcErr != nil || sample.LatencyNS != qpcLatencyNS || sample.EventTimestampNS == 0 || + sample.SDLFenceTimestampNS == 0 || + sample.EventTimestampNS <= sample.SDLFenceTimestampNS || + (priorMarkerQPC != 0 && sample.StartQPCTicks < priorMarkerQPC) || + sample.MarkerQPCTicks < sample.EndQPCTicks || + sample.MarkerID != wantMarkerID { + return fmt.Errorf("sample %d has invalid or inconsistent latency, causal fence, QPC interval, or trace marker", index) + } + if priorTimestamp != 0 && sample.EventTimestampNS < priorTimestamp { + return fmt.Errorf("sample %d regressed the SDL event clock", index) + } + priorTimestamp = sample.EventTimestampNS + priorMarkerQPC = sample.MarkerQPCTicks + } + if run.Failure == "" && len(run.Samples) != 2*run.SamplePairs { + return errors.New("successful run does not contain every press/release sample") + } + if run.Failure != "" { + return nil + } + if !run.UnauthenticatedRejected { + return errors.New("unauthenticated API probe was not rejected") + } + if run.Server.Server != "VIIPER" || run.Server.Transport != run.Transport || + !run.Server.Ready || run.Server.Version == "" { + return errors.New("authenticated ping does not prove the requested live transport") + } + if run.Device.BusID != 1 || run.Device.DeviceID != "1" || + run.Device.Type != workload.ControllerType || + run.Device.VendorID != workload.ExpectedVendorID || + run.Device.ProductID != workload.ExpectedProductID { + return errors.New("API device proof does not identify the exact controller workload") + } + if run.Transport == TransportNativeUDE { + if run.Server.NativeUDE == nil || run.Server.NativeUDE.ABIMajor == 0 || + run.Server.NativeUDE.ExpectedDriverPackageVersion == "" || + run.Server.NativeUDE.LoadedDriverBuildIdentity == "" || run.Device.USBIPPort != 0 { + return errors.New("native transport proof is absent or contradictory") + } + if run.Server.NativeUDE.LoadedDriverBuildIdentity != provenance.NativeDriverBuildIdentity { + return errors.New("loaded native driver build identity does not match the signed package manifest") + } + } else if run.Transport == TransportUSBIP { + if run.Server.NativeUDE != nil || run.Device.USBIPPort <= 0 { + return errors.New("USB/IP transport proof is absent or contradictory") + } + } else { + return fmt.Errorf("unsupported transport %q", run.Transport) + } + if len(run.Controller.NewGamepadIDs) != 1 || + run.Controller.NewGamepadIDs[0] != run.Controller.SDLInstanceID { + return errors.New("SDL observer is not bound to exactly one newly enumerated gamepad") + } + for _, baselineID := range run.Controller.BaselineGamepadIDs { + if baselineID == run.Controller.SDLInstanceID { + return errors.New("SDL observer selected a gamepad that existed before DeviceAdd") + } + } + if run.Controller.SDLInstanceID == 0 || run.Controller.SDLPath == "" || + run.Controller.SDLGUID == "" || run.Controller.SDLName == "" || + run.Controller.SDLType != workload.ExpectedSDLType || + run.Controller.SDLRealType != expectedSDLRealType(workload.ControllerType) || + run.Controller.VendorID != workload.ExpectedVendorID || + run.Controller.ProductID != workload.ExpectedProductID { + return errors.New("new SDL gamepad identity does not match the API-created controller") + } + if err := ValidateTransportAncestry(run.Transport, run.Device.USBIPPort, run.Controller); err != nil { + return err + } + return nil +} + +// ValidateTransportAncestry rejects VID/PID-only substitutions and requires +// exactly one transport-specific root anchor in the SDL interface's PnP chain. +func ValidateTransportAncestry(transport string, usbipPort int32, proof ControllerProof) error { + if proof.PNPInstanceID == "" || !containerPattern.MatchString(proof.PNPContainerID) || + len(proof.PNPAncestorIDs) == 0 || + len(proof.PNPAncestorIDs) != len(proof.PNPAncestorServices) || + len(proof.PNPAncestorIDs) != len(proof.PNPAncestorContainerIDs) || + len(proof.PNPAncestorIDs) != len(proof.PNPAncestorHardwareIDs) || + len(proof.PNPAncestorIDs) != len(proof.PNPAncestorLocationInfo) || + len(proof.PNPAncestorIDs) != len(proof.PNPAncestorLocationPaths) || + !strings.EqualFold(proof.PNPAncestorIDs[0], proof.PNPInstanceID) || + !strings.EqualFold(proof.PNPAncestorContainerIDs[0], proof.PNPContainerID) { + return errors.New("SDL observer lacks an exact, internally consistent Windows PnP ancestry proof") + } + anchorCount := 0 + for index, instanceID := range proof.PNPAncestorIDs { + containerID := proof.PNPAncestorContainerIDs[index] + if containerID != "" && !containerPattern.MatchString(containerID) { + return fmt.Errorf("PnP ancestor %q has malformed container identity %q", instanceID, containerID) + } + service := proof.PNPAncestorServices[index] + hardwareIDs := proof.PNPAncestorHardwareIDs[index] + isAnchor := false + switch transport { + case TransportNativeUDE: + isAnchor = strings.EqualFold(service, "ViiperUde") && + containsFold(hardwareIDs, `ROOT\VIIPER\UDE`) + case TransportUSBIP: + // Root-enumerated devnode instance IDs are OS-assigned (for example + // ROOT\USB\0002). The stable INF identity is the exact hardware ID. + isAnchor = strings.EqualFold(service, "usbip2_ude") && + containsFold(hardwareIDs, `ROOT\USBIP_WIN2\UDE`) + default: + return fmt.Errorf("unsupported transport %q", transport) + } + if isAnchor { + anchorCount++ + if !strings.EqualFold(proof.TransportAnchorInstanceID, instanceID) || + !strings.EqualFold(proof.TransportAnchorService, service) { + return errors.New("reported transport anchor does not match the exact PnP ancestor") + } + } + } + if anchorCount != 1 { + return fmt.Errorf("PnP ancestry contains %d exact %s transport anchors, want 1", anchorCount, transport) + } + if transport == TransportUSBIP { + if usbipPort <= 0 { + return errors.New("USB/IP transport has no positive import-port identity") + } + portSegment := fmt.Sprintf("USB(%d)", usbipPort) + portMatched := false + for index, instanceID := range proof.PNPAncestorIDs { + if strings.EqualFold(instanceID, proof.TransportAnchorInstanceID) { + break + } + for _, locationPath := range proof.PNPAncestorLocationPaths[index] { + for _, segment := range strings.Split(locationPath, "#") { + if strings.EqualFold(segment, portSegment) { + portMatched = true + } + } + } + } + if !portMatched { + return fmt.Errorf("USB/IP PnP descendants do not prove returned root-hub port %d", usbipPort) + } + } + return nil +} + +func containsFold(values []string, want string) bool { + for _, value := range values { + if strings.EqualFold(value, want) { + return true + } + } + return false +} + +func expectedSDLRealType(controllerType string) int32 { + switch controllerType { + case "xbox360": + return 2 // SDL_GAMEPAD_TYPE_XBOX360 + case "dualshock4": + return 5 // SDL_GAMEPAD_TYPE_PS4 + case "dualsensegamepadv5": + return 6 // SDL_GAMEPAD_TYPE_PS5 + default: + return 0 // Rejected by validateControllerWorkload. + } +} + +func validateControllerWorkload(workload Workload) error { + type identity struct { + vendorID, productID uint16 + sdlType string + } + supported := map[string]identity{ + "xbox360": {vendorID: 0x045e, productID: 0x028e, sdlType: "xbox360"}, + "dualshock4": {vendorID: 0x054c, productID: 0x09cc, sdlType: "ps4"}, + "dualsensegamepadv5": {vendorID: 0x054c, productID: 0x0ce6, sdlType: "ps5"}, + } + want, ok := supported[workload.ControllerType] + if !ok || workload.ExpectedVendorID != want.vendorID || + workload.ExpectedProductID != want.productID || workload.ExpectedSDLType != want.sdlType { + return fmt.Errorf("unsupported or contradictory controller workload %q vid=%#04x pid=%#04x SDL=%q", + workload.ControllerType, workload.ExpectedVendorID, + workload.ExpectedProductID, workload.ExpectedSDLType) + } + return nil +} + +// ParseReport strictly parses a finalized report and rejects stale or forged +// derived fields, unknown JSON fields, and trailing input. +func ParseReport(reader io.Reader) (*Report, error) { + decoder := json.NewDecoder(reader) + decoder.DisallowUnknownFields() + var report Report + if err := decoder.Decode(&report); err != nil { + return nil, fmt.Errorf("decode latency report: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return nil, errors.New("latency report contains trailing JSON") + } + return nil, fmt.Errorf("decode trailing latency report data: %w", err) + } + + reportedStatistics := make([]DistributionSet, len(report.Runs)) + for index := range report.Runs { + reportedStatistics[index] = report.Runs[index].Statistics + } + reportedComparison := report.Comparison + reportedTransports := append([]TransportAggregate(nil), report.Transports...) + reportedFailures := append([]string(nil), report.Failures...) + reportedVerdict := report.Verdict + if err := Finalize(&report); err != nil { + return nil, err + } + for index := range report.Runs { + if !reflect.DeepEqual(reportedStatistics[index], report.Runs[index].Statistics) { + return nil, fmt.Errorf("%s statistics do not match the individual samples", + report.Runs[index].Transport) + } + } + if !reflect.DeepEqual(reportedTransports, report.Transports) || + !reflect.DeepEqual(reportedComparison, report.Comparison) || + !reflect.DeepEqual(reportedFailures, report.Failures) || reportedVerdict != report.Verdict { + return nil, errors.New("latency report aggregates, comparison, or verdict do not match its source samples") + } + return &report, nil +} + +func RequirePass(report *Report) error { + if report == nil { + return errors.New("nil latency report") + } + if report.Verdict == "pass" && len(report.Failures) == 0 { + return nil + } + if len(report.Failures) == 0 { + return fmt.Errorf("latency gate verdict is %q", report.Verdict) + } + return errors.New(strings.Join(report.Failures, "; ")) +} + +// FinalizeSuite validates workload parity across the complete production +// controller set and recomputes each controller report from individual samples. +func FinalizeSuite(suite *SuiteReport) error { + if suite == nil { + return errors.New("nil latency suite") + } + if suite.Schema != SuiteSchemaV2 { + return fmt.Errorf("unsupported latency suite schema %q", suite.Schema) + } + if suite.GeneratedAt.IsZero() { + return errors.New("suite generated_at is required") + } + requiredControllers := []string{"xbox360", "dualshock4", "dualsensegamepadv5"} + if len(suite.Cases) != len(requiredControllers) { + return fmt.Errorf("latency suite must contain exactly %d controller cases", len(requiredControllers)) + } + + suite.Failures = nil + var reference *Report + serverVersion := "" + for index := range suite.Cases { + controllerReport := &suite.Cases[index] + if controllerReport.Workload.ControllerType != requiredControllers[index] { + return fmt.Errorf("controller case %d is %q, want %q", index, + controllerReport.Workload.ControllerType, requiredControllers[index]) + } + if controllerReport.GeneratedAt != suite.GeneratedAt || + !reflect.DeepEqual(controllerReport.Provenance, suite.Provenance) { + return fmt.Errorf("%s case provenance differs from the suite", + controllerReport.Workload.ControllerType) + } + if reference == nil { + reference = controllerReport + } else if !sameWorkloadPolicy(reference, controllerReport) { + return fmt.Errorf("%s does not use the identical authenticated timing workload", + controllerReport.Workload.ControllerType) + } + if err := Finalize(controllerReport); err != nil { + return fmt.Errorf("%s case: %w", controllerReport.Workload.ControllerType, err) + } + for _, failure := range controllerReport.Failures { + suite.Failures = append(suite.Failures, + controllerReport.Workload.ControllerType+": "+failure) + } + for _, run := range controllerReport.Runs { + if run.Failure != "" { + continue + } + if serverVersion == "" { + serverVersion = run.Server.Version + } else if run.Server.Version != serverVersion { + return fmt.Errorf("%s/%s used VIIPER version %q, want %q", + controllerReport.Workload.ControllerType, run.Transport, + run.Server.Version, serverVersion) + } + } + } + if len(suite.Failures) == 0 { + suite.Verdict = "pass" + } else { + suite.Verdict = "fail" + } + return nil +} + +func sameWorkloadPolicy(left, right *Report) bool { + return left.Workload.APIAddress == right.Workload.APIAddress && + left.Workload.USBIPAddress == right.Workload.USBIPAddress && + left.Workload.Button == right.Workload.Button && + left.Workload.WarmupPairs == right.Workload.WarmupPairs && + left.Workload.SamplePairs == right.Workload.SamplePairs && + left.Workload.PerTransitionTimeoutNS == right.Workload.PerTransitionTimeoutNS && + left.Workload.InterTransitionDelayNS == right.Workload.InterTransitionDelayNS && + reflect.DeepEqual(left.Workload.PhaseSweepOffsetsNS, right.Workload.PhaseSweepOffsetsNS) && + left.Workload.PhaseSweepSHA256 == right.Workload.PhaseSweepSHA256 && + left.Workload.Authentication == right.Workload.Authentication && + reflect.DeepEqual(left.Policy, right.Policy) +} + +type suiteCaseDerived struct { + statistics []DistributionSet + transports []TransportAggregate + comparison ComparisonSet + verdict string + failures []string +} + +// ParseSuiteReport is the strict artifact parser used for release evidence. +func ParseSuiteReport(reader io.Reader) (*SuiteReport, error) { + decoder := json.NewDecoder(reader) + decoder.DisallowUnknownFields() + var suite SuiteReport + if err := decoder.Decode(&suite); err != nil { + return nil, fmt.Errorf("decode latency suite: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return nil, errors.New("latency suite contains trailing JSON") + } + return nil, fmt.Errorf("decode trailing latency suite data: %w", err) + } + + reportedCases := make([]suiteCaseDerived, len(suite.Cases)) + for caseIndex := range suite.Cases { + controllerReport := &suite.Cases[caseIndex] + derived := suiteCaseDerived{ + statistics: make([]DistributionSet, len(controllerReport.Runs)), + transports: append([]TransportAggregate(nil), controllerReport.Transports...), + comparison: controllerReport.Comparison, + verdict: controllerReport.Verdict, + failures: append([]string(nil), controllerReport.Failures...), + } + for runIndex := range controllerReport.Runs { + derived.statistics[runIndex] = controllerReport.Runs[runIndex].Statistics + } + reportedCases[caseIndex] = derived + } + reportedVerdict := suite.Verdict + reportedFailures := append([]string(nil), suite.Failures...) + if err := FinalizeSuite(&suite); err != nil { + return nil, err + } + for caseIndex := range suite.Cases { + controllerReport := &suite.Cases[caseIndex] + derived := reportedCases[caseIndex] + for runIndex := range controllerReport.Runs { + if !reflect.DeepEqual(derived.statistics[runIndex], + controllerReport.Runs[runIndex].Statistics) { + return nil, fmt.Errorf("%s/%s statistics do not match individual samples", + controllerReport.Workload.ControllerType, + controllerReport.Runs[runIndex].Transport) + } + } + if !reflect.DeepEqual(derived.comparison, controllerReport.Comparison) || + !reflect.DeepEqual(derived.transports, controllerReport.Transports) || + derived.verdict != controllerReport.Verdict || + !reflect.DeepEqual(derived.failures, controllerReport.Failures) { + return nil, fmt.Errorf("%s derived case verdict does not match its samples", + controllerReport.Workload.ControllerType) + } + } + if reportedVerdict != suite.Verdict || !reflect.DeepEqual(reportedFailures, suite.Failures) { + return nil, errors.New("latency suite verdict does not match its controller cases") + } + return &suite, nil +} + +func RequireSuitePass(suite *SuiteReport) error { + if suite == nil { + return errors.New("nil latency suite") + } + if suite.Verdict == "pass" && len(suite.Failures) == 0 { + return nil + } + if len(suite.Failures) == 0 { + return fmt.Errorf("latency suite verdict is %q", suite.Verdict) + } + return errors.New(strings.Join(suite.Failures, "; ")) +} diff --git a/_testing/e2e/latency/report_test.go b/_testing/e2e/latency/report_test.go new file mode 100644 index 00000000..1ec3979c --- /dev/null +++ b/_testing/e2e/latency/report_test.go @@ -0,0 +1,785 @@ +package latency + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "reflect" + "strings" + "testing" + "time" +) + +func TestCalculateNearestRankDistributionAndJitter(t *testing.T) { + values := make([]int64, 100) + for index := range values { + values[index] = int64(100 - index) + } + original := append([]int64(nil), values...) + + got, err := Calculate(values) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(values, original) { + t.Fatal("Calculate reordered the caller's individual samples") + } + if got.Count != 100 || got.P50NS != 50 || got.P90NS != 90 || + got.P95NS != 95 || got.P99NS != 99 || got.P999NS != 100 || + got.MaxNS != 100 { + t.Fatalf("unexpected distribution: %+v", got) + } + wantJitter := math.Sqrt(833.25) + if math.Abs(got.JitterNS-wantJitter) > 1e-12 { + t.Fatalf("jitter=%0.15f want %0.15f", got.JitterNS, wantJitter) + } +} + +func TestQPCIntervalNSFailsClosed(t *testing.T) { + got, err := QPCIntervalNS(10, 410, 1_000_000) + if err != nil || got != 400_000 { + t.Fatalf("QPCIntervalNS()=(%d, %v), want (400000, nil)", got, err) + } + for _, test := range []struct { + name string + start, end, frequency int64 + }{ + {name: "zero start", start: 0, end: 2, frequency: 1}, + {name: "reversed", start: 2, end: 1, frequency: 1}, + {name: "zero frequency", start: 1, end: 2, frequency: 0}, + {name: "sub nanosecond", start: 1, end: 2, frequency: 2_000_000_000}, + {name: "overflow", start: 1, end: math.MaxInt64, frequency: 1}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := QPCIntervalNS(test.start, test.end, test.frequency); err == nil { + t.Fatal("invalid QPC interval was accepted") + } + }) + } +} + +func TestCalculateRejectsMissingAndNonPositiveSamples(t *testing.T) { + if _, err := Calculate(nil); err == nil { + t.Fatal("zero samples were accepted") + } + if _, err := Calculate([]int64{1, 0, 2}); err == nil { + t.Fatal("a zero latency sample was accepted") + } +} + +func TestFinalizeRequiresExactMachineAndPriorityProvenance(t *testing.T) { + high := validReport(t) + high.Provenance.Machine.ProcessPriorityClass = "high" + if err := Finalize(high); err != nil { + t.Fatalf("high-priority production run was rejected: %v", err) + } + + for _, test := range []struct { + name string + mutate func(*MachineProvenance) + }{ + {name: "missing host", mutate: func(machine *MachineProvenance) { machine.Hostname = "" }}, + {name: "missing OS", mutate: func(machine *MachineProvenance) { machine.OSVersion = "" }}, + {name: "missing CPU", mutate: func(machine *MachineProvenance) { machine.CPUModel = "" }}, + {name: "zero logical processors", mutate: func(machine *MachineProvenance) { machine.LogicalProcessors = 0 }}, + {name: "unsupported priority", mutate: func(machine *MachineProvenance) { machine.ProcessPriorityClass = "realtime" }}, + {name: "unelevated", mutate: func(machine *MachineProvenance) { machine.ProcessElevated = false }}, + } { + t.Run(test.name, func(t *testing.T) { + report := validReport(t) + test.mutate(&report.Provenance.Machine) + if err := Finalize(report); err == nil { + t.Fatal("incomplete or unsupported machine provenance was accepted") + } + }) + } +} + +func TestFinalizeRequiresExactToolExecutableProvenance(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*Provenance) + }{ + {name: "missing Git path", mutate: func(p *Provenance) { p.GitExecutablePath = "" }}, + {name: "invalid Git hash", mutate: func(p *Provenance) { p.GitExecutableSHA256 = "bad" }}, + {name: "missing Go path", mutate: func(p *Provenance) { p.GoExecutablePath = "" }}, + {name: "invalid Go hash", mutate: func(p *Provenance) { p.GoExecutableSHA256 = "bad" }}, + {name: "missing WPR path", mutate: func(p *Provenance) { p.WPRExecutablePath = "" }}, + {name: "invalid WPR hash", mutate: func(p *Provenance) { p.WPRExecutableSHA256 = "bad" }}, + } { + t.Run(test.name, func(t *testing.T) { + report := validReport(t) + test.mutate(&report.Provenance) + if err := Finalize(report); err == nil { + t.Fatal("incomplete tool executable provenance was accepted") + } + }) + } +} + +func TestUSBIPAnchorUsesINFHardwareIDAndOSAssignedInstance(t *testing.T) { + // usbip-win2's INF binds ROOT\USBIP_WIN2\UDE to usbip2_ude, while live + // SetupAPI/pnputil evidence exposes the present OS-assigned instance as + // ROOT\USB\####. Preserve all three identities; none substitutes for another. + proof := ControllerProof{ + PNPInstanceID: `HID\VID_045E&PID_028E\1`, + PNPContainerID: `{11111111-2222-3333-4444-555555555555}`, + PNPAncestorIDs: []string{`HID\VID_045E&PID_028E\1`, `USB\VID_045E&PID_028E\1`, `ROOT\USB\0002`}, + PNPAncestorContainerIDs: []string{`{11111111-2222-3333-4444-555555555555}`, `{11111111-2222-3333-4444-555555555555}`, ""}, + PNPAncestorServices: []string{"HidUsb", "usbccgp", "usbip2_ude"}, + PNPAncestorHardwareIDs: [][]string{{`HID_DEVICE_SYSTEM_GAME`}, {`USB\VID_045E&PID_028E`}, {`ROOT\USBIP_WIN2\UDE`}}, + PNPAncestorLocationInfo: []string{"", "Port_#0007.Hub_#0001", ""}, + PNPAncestorLocationPaths: [][]string{{}, {`USBROOT(0)#USB(7)`}, {}}, + TransportAnchorInstanceID: `ROOT\USB\0002`, + TransportAnchorService: "usbip2_ude", + } + if err := ValidateTransportAncestry(TransportUSBIP, 7, proof); err != nil { + t.Fatalf("exact USB/IP INF anchor rejected: %v", err) + } + if err := ValidateTransportAncestry(TransportUSBIP, 8, proof); err == nil || + !strings.Contains(err.Error(), "root-hub port 8") { + t.Fatalf("wrong USB/IP import port was not rejected: %v", err) + } + proof.PNPAncestorHardwareIDs[2] = []string{`ROOT\USB\0002`} + if err := ValidateTransportAncestry(TransportUSBIP, 7, proof); err == nil { + t.Fatal("OS-assigned instance ID was accepted as a substitute for the USB/IP INF hardware ID") + } +} + +func TestProductionBlockScheduleIsCounterbalancedAndComplete(t *testing.T) { + want := []BlockSpec{ + {Order: 1, Transport: TransportUSBIP, TransportBlock: 1, FirstSequence: 1, SamplePairs: 128}, + {Order: 2, Transport: TransportNativeUDE, TransportBlock: 1, FirstSequence: 1, SamplePairs: 128}, + {Order: 3, Transport: TransportNativeUDE, TransportBlock: 2, FirstSequence: 129, SamplePairs: 129}, + {Order: 4, Transport: TransportUSBIP, TransportBlock: 2, FirstSequence: 129, SamplePairs: 129}, + } + if got := ProductionBlockSchedule(257); !reflect.DeepEqual(got, want) { + t.Fatalf("schedule=%+v want %+v", got, want) + } + offsets := ProductionPhaseSweepOffsetsNS() + wantOffsets := []int64{0, 125_000, 250_000, 375_000, 500_000, 625_000, 750_000, 875_000} + if !reflect.DeepEqual(offsets, wantOffsets) { + t.Fatalf("phase sweep=%v want %v", offsets, wantOffsets) + } + if got, wantHash := PhaseSweepScheduleSHA256(offsets), "21eee9ea71984343ebd21221df8272553d6ab369a5740a1c796380cd468abcd9"; got != wantHash { + t.Fatalf("phase sweep SHA-256=%s want %s", got, wantHash) + } + for sequence, wantPressOffset := range []int64{0, 250_000, 500_000, 750_000} { + if got := ProductionPhaseOffsetNS(sequence+1, TransitionPress); got != wantPressOffset { + t.Fatalf("sequence %d press offset=%d want %d", sequence+1, got, wantPressOffset) + } + } +} + +func TestParseReportRecomputesSamplesStatisticsAndComparison(t *testing.T) { + report := validReport(t) + encoded := encodeReport(t, report) + parsed, err := ParseReport(bytes.NewReader(encoded)) + if err != nil { + t.Fatal(err) + } + if err := RequirePass(parsed); err != nil { + t.Fatal(err) + } + if parsed.Transports[0].Statistics.Press.Count != MinimumProductionSamplePairs || + parsed.Transports[1].Statistics.Release.Count != MinimumProductionSamplePairs { + t.Fatalf("individual press/release samples were not retained: %+v", parsed.Transports) + } + if parsed.Comparison.Combined.P99.NativeToUSBIPRatio == nil { + t.Fatal("comparison ratio was not derived") + } +} + +func TestParseReportRejectsUnknownTrailingAndForgedData(t *testing.T) { + report := validReport(t) + + t.Run("unknown field", func(t *testing.T) { + var object map[string]any + if err := json.Unmarshal(encodeReport(t, report), &object); err != nil { + t.Fatal(err) + } + object["not_in_schema"] = true + data, _ := json.Marshal(object) + if _, err := ParseReport(bytes.NewReader(data)); err == nil || + !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("unknown field error=%v", err) + } + }) + + t.Run("trailing JSON", func(t *testing.T) { + data := append(encodeReport(t, report), []byte(` {"extra":true}`)...) + if _, err := ParseReport(bytes.NewReader(data)); err == nil || + !strings.Contains(err.Error(), "trailing JSON") { + t.Fatalf("trailing data error=%v", err) + } + }) + + t.Run("forged statistic", func(t *testing.T) { + forged := validReport(t) + forged.Runs[1].Statistics.Combined.P99NS++ + if _, err := ParseReport(bytes.NewReader(encodeReport(t, forged))); err == nil || + !strings.Contains(err.Error(), "statistics do not match") { + t.Fatalf("forged statistic error=%v", err) + } + }) + + t.Run("forged transport aggregate", func(t *testing.T) { + forged := validReport(t) + forged.Transports[1].Statistics.Press.P95NS++ + if _, err := ParseReport(bytes.NewReader(encodeReport(t, forged))); err == nil || + !strings.Contains(err.Error(), "aggregates") { + t.Fatalf("forged aggregate error=%v", err) + } + }) + + t.Run("mixed source", func(t *testing.T) { + mixed := validReport(t) + mixed.Runs[1].Server.Transport = TransportUSBIP + if err := Finalize(mixed); err == nil || !strings.Contains(err.Error(), "requested live transport") { + t.Fatalf("mixed source error=%v", err) + } + }) + + t.Run("unauthenticated workload", func(t *testing.T) { + unauthenticated := validReport(t) + unauthenticated.Runs[0].UnauthenticatedRejected = false + if err := Finalize(unauthenticated); err == nil || !strings.Contains(err.Error(), "unauthenticated") { + t.Fatalf("unauthenticated source error=%v", err) + } + }) + + t.Run("noncanonical source revision length", func(t *testing.T) { + report := validReport(t) + report.Provenance.SourceRevision = strings.Repeat("a", 41) + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "40- or 64") { + t.Fatalf("noncanonical revision error=%v", err) + } + }) +} + +func TestParseSuiteRequiresPlayStationCasesAndWorkloadParity(t *testing.T) { + suite := validSuite(t) + encoded := encodeSuite(t, suite) + parsed, err := ParseSuiteReport(bytes.NewReader(encoded)) + if err != nil { + t.Fatal(err) + } + if err := RequireSuitePass(parsed); err != nil { + t.Fatal(err) + } + if len(parsed.Cases) != 3 || parsed.Cases[2].Workload.ControllerType != "dualsensegamepadv5" { + t.Fatalf("suite does not contain required production controller cases: %+v", parsed.Cases) + } + + t.Run("Xbox cannot substitute for DualSense", func(t *testing.T) { + missing := validSuite(t) + missing.Cases[2] = cloneReport(t, missing.Cases[0]) + if err := FinalizeSuite(missing); err == nil || !strings.Contains(err.Error(), "dualsensegamepadv5") { + t.Fatalf("missing DualSense error=%v", err) + } + }) + + t.Run("controller workload drift", func(t *testing.T) { + drift := validSuite(t) + drift.Cases[1].Workload.InterTransitionDelayNS++ + if err := FinalizeSuite(drift); err == nil || !strings.Contains(err.Error(), "identical authenticated") { + t.Fatalf("workload drift error=%v", err) + } + }) + + t.Run("DualSense cannot bind as Xbox", func(t *testing.T) { + mismatch := validSuite(t) + mismatch.Cases[2].Runs[1].Controller.SDLRealType = 2 + if err := FinalizeSuite(mismatch); err == nil || !strings.Contains(err.Error(), "SDL gamepad identity") { + t.Fatalf("DualSense SDL substitution error=%v", err) + } + }) + + t.Run("forged controller statistic", func(t *testing.T) { + forged := validSuite(t) + forged.Cases[2].Runs[1].Statistics.Press.P95NS++ + if _, err := ParseSuiteReport(bytes.NewReader(encodeSuite(t, forged))); err == nil || + !strings.Contains(err.Error(), "statistics do not match") { + t.Fatalf("forged suite statistic error=%v", err) + } + }) +} + +func TestFinalizeRejectsTimeoutInsufficientSamplesAndDuplicates(t *testing.T) { + report := validReport(t) + native := &report.Runs[2] + native.Samples = native.Samples[:len(native.Samples)-1] + native.Misses.Release = 1 + native.Duplicates.Press = 2 + native.Failure = "release sample 256 timed out after 1s" + + if err := Finalize(report); err != nil { + t.Fatal(err) + } + if report.Verdict != "fail" { + t.Fatalf("verdict=%q want fail", report.Verdict) + } + joined := strings.Join(report.Failures, "\n") + for _, want := range []string{ + "timed out", "1 missed transitions", "2 duplicate transitions", "255/256 required release samples", + } { + if !strings.Contains(joined, want) { + t.Fatalf("failures did not contain %q:\n%s", want, joined) + } + } + if err := RequirePass(report); err == nil { + t.Fatal("failed report was accepted by RequirePass") + } + + encoded := encodeReport(t, report) + parsed, err := ParseReport(bytes.NewReader(encoded)) + if err != nil { + t.Fatalf("a self-consistent failure artifact must remain parseable: %v", err) + } + if parsed.Verdict != "fail" { + t.Fatalf("parsed verdict=%q", parsed.Verdict) + } +} + +func TestFinalizeRejectsWeakenedPolicyAndOutOfOrderSamples(t *testing.T) { + t.Run("weakened policy", func(t *testing.T) { + report := validReport(t) + report.Policy.NativeMaxP95NS = DefaultNativeMaxP95NS + 1 + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "weaker") { + t.Fatalf("weakened policy error=%v", err) + } + }) + + t.Run("weakened same-machine policy", func(t *testing.T) { + report := validReport(t) + report.Policy.NativeMaxP95OverUSBIPNS = DefaultNativeMaxP95OverUSBIPNS + 1 + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "weaker") { + t.Fatalf("weakened comparison policy error=%v", err) + } + }) + + t.Run("non-ABBA block order", func(t *testing.T) { + report := validReport(t) + report.Runs[2].Transport = TransportUSBIP + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "ABBA") { + t.Fatalf("block order error=%v", err) + } + }) + + t.Run("phase sweep drift", func(t *testing.T) { + report := validReport(t) + report.Workload.PhaseSweepOffsetsNS[1]++ + report.Workload.PhaseSweepSHA256 = PhaseSweepScheduleSHA256(report.Workload.PhaseSweepOffsetsNS) + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "phase-sweep") { + t.Fatalf("phase sweep drift error=%v", err) + } + }) + + t.Run("duplicate sequence", func(t *testing.T) { + report := validReport(t) + report.Runs[0].Samples[1].Transition = TransitionPress + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "want 1/release") { + t.Fatalf("duplicate sequence error=%v", err) + } + }) + + t.Run("event clock regression", func(t *testing.T) { + report := validReport(t) + report.Runs[0].Samples[1].EventTimestampNS = report.Runs[0].Samples[0].EventTimestampNS - 1 + report.Runs[0].Samples[1].SDLFenceTimestampNS = report.Runs[0].Samples[1].EventTimestampNS - 1 + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "event clock") { + t.Fatalf("event clock error=%v", err) + } + }) + + t.Run("pre-write SDL edge", func(t *testing.T) { + report := validReport(t) + report.Runs[0].Samples[0].SDLFenceTimestampNS = + report.Runs[0].Samples[0].EventTimestampNS + 1 + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "causal fence") { + t.Fatalf("pre-write SDL edge error=%v", err) + } + }) + + t.Run("forged trace marker", func(t *testing.T) { + report := validReport(t) + report.Runs[0].Samples[0].MarkerID = "another-sample" + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "trace marker") { + t.Fatalf("forged trace marker error=%v", err) + } + }) + + t.Run("trace marker inside measured interval", func(t *testing.T) { + report := validReport(t) + report.Runs[0].Samples[0].MarkerQPCTicks = + report.Runs[0].Samples[0].EndQPCTicks - 1 + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "QPC interval") { + t.Fatalf("in-interval marker error=%v", err) + } + }) + + t.Run("latency disagrees with raw QPC", func(t *testing.T) { + report := validReport(t) + report.Runs[0].Samples[0].LatencyNS++ + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "inconsistent latency") { + t.Fatalf("forged QPC latency error=%v", err) + } + }) + + t.Run("wrong native ancestry", func(t *testing.T) { + report := validReport(t) + run := &report.Runs[1] + run.Controller.PNPAncestorIDs[len(run.Controller.PNPAncestorIDs)-1] = `ROOT\USB\0002` + run.Controller.PNPAncestorServices[len(run.Controller.PNPAncestorServices)-1] = "usbip2_ude" + run.Controller.PNPAncestorHardwareIDs[len(run.Controller.PNPAncestorHardwareIDs)-1] = []string{`ROOT\USBIP_WIN2\UDE`} + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "anchor") { + t.Fatalf("wrong native ancestry error=%v", err) + } + }) + + t.Run("missing controller container", func(t *testing.T) { + report := validReport(t) + report.Runs[1].Controller.PNPContainerID = "" + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "ancestry proof") { + t.Fatalf("missing controller container error=%v", err) + } + }) + + t.Run("mismatched controller container", func(t *testing.T) { + report := validReport(t) + report.Runs[1].Controller.PNPAncestorContainerIDs[0] = + `{99999999-8888-7777-6666-555555555555}` + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "ancestry proof") { + t.Fatalf("mismatched controller container error=%v", err) + } + }) + + t.Run("wrong loaded native build", func(t *testing.T) { + report := validReport(t) + report.Runs[1].Server.NativeUDE.LoadedDriverBuildIdentity = strings.Repeat("2", 64) + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "signed package manifest") { + t.Fatalf("wrong loaded driver identity error=%v", err) + } + }) + + t.Run("ambiguous USBIP ancestry", func(t *testing.T) { + report := validReport(t) + run := &report.Runs[0] + run.Controller.PNPAncestorIDs = append(run.Controller.PNPAncestorIDs, `ROOT\USB\0003`) + run.Controller.PNPAncestorContainerIDs = append(run.Controller.PNPAncestorContainerIDs, "") + run.Controller.PNPAncestorServices = append(run.Controller.PNPAncestorServices, "usbip2_ude") + run.Controller.PNPAncestorHardwareIDs = append(run.Controller.PNPAncestorHardwareIDs, []string{`ROOT\USBIP_WIN2\UDE`}) + run.Controller.PNPAncestorLocationInfo = append(run.Controller.PNPAncestorLocationInfo, "") + run.Controller.PNPAncestorLocationPaths = append(run.Controller.PNPAncestorLocationPaths, []string{}) + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "anchor") { + t.Fatalf("ambiguous USB/IP ancestry error=%v", err) + } + }) +} + +func TestFinalizeRejectsSameMachineNativeTailRegression(t *testing.T) { + tests := []struct { + name string + metric string + mutate func(*Report) + }{ + { + name: "p95", metric: "p95", + mutate: func(report *Report) { + for runIndex := range report.Runs { + run := &report.Runs[runIndex] + if run.Transport != TransportNativeUDE { + continue + } + for sampleIndex := range run.Samples { + setSampleLatency(&run.Samples[sampleIndex], 1_500_000, report.Provenance.QPCFrequency) + } + } + }, + }, + { + name: "p99", metric: "p99", + mutate: func(report *Report) { + nativeSecond := &report.Runs[2] + for index := len(nativeSecond.Samples) - 6; index < len(nativeSecond.Samples); index++ { + setSampleLatency(&nativeSecond.Samples[index], 2_600_000, report.Provenance.QPCFrequency) + } + }, + }, + { + name: "max", metric: "max", + mutate: func(report *Report) { + nativeSecond := &report.Runs[2] + setSampleLatency(&nativeSecond.Samples[len(nativeSecond.Samples)-1], 5_600_000, + report.Provenance.QPCFrequency) + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + report := validReport(t) + test.mutate(report) + if err := Finalize(report); err != nil { + t.Fatal(err) + } + failures := strings.Join(report.Failures, "\n") + if report.Verdict != "fail" || + !strings.Contains(failures, "same-machine USB/IP") || + !strings.Contains(failures, test.metric) { + t.Fatalf("same-machine %s regression was not rejected: verdict=%q failures=%v", + test.metric, report.Verdict, report.Failures) + } + }) + } +} + +func validReport(t *testing.T) *Report { + t.Helper() + report := &Report{ + Schema: SchemaV2, + GeneratedAt: time.Date(2026, 8, 11, 12, 0, 0, 0, time.UTC), + Provenance: Provenance{ + SourceRevision: strings.Repeat("a", 40), + SDLSourceRevision: strings.Repeat("b", 40), + SDLBinaryPath: `C:\source\SDL3.dll`, + SDLBinarySHA256: strings.Repeat("c", 64), + NativePackageManifestSHA256: strings.Repeat("d", 64), + NativeDriverSHA256: strings.Repeat("e", 64), + NativeDriverBuildIdentity: strings.Repeat("1", 64), + QPCFrequency: 1_000_000_000, + TraceProviderName: TraceProviderName, + TraceProviderGUID: TraceProviderGUID, + TraceProfileSHA256: strings.Repeat("f", 64), + USBIPBaselineMode: USBIPBaselineMode, + USBIPBaselineVersion: USBIPBaselineVersion, + GoVersion: "go1.26.2", + GOOS: "windows", + GOARCH: "amd64", + GitExecutablePath: `C:\Program Files\Git\cmd\git.exe`, + GitExecutableSHA256: strings.Repeat("2", 64), + GoExecutablePath: `C:\Go\bin\go.exe`, + GoExecutableSHA256: strings.Repeat("3", 64), + WPRExecutablePath: `C:\Windows\System32\wpr.exe`, + WPRExecutableSHA256: strings.Repeat("4", 64), + Machine: MachineProvenance{ + Hostname: "bench-host", OSProductName: "Windows 11 Pro", + OSDisplayVersion: "24H2", OSVersion: "10.0.26100.9999", + CPUModel: "Test CPU", LogicalProcessors: 16, + ProcessPriorityClass: "normal", ProcessElevated: true, + }, + }, + Workload: Workload{ + APIAddress: "127.0.0.1:33245", + USBIPAddress: "127.0.0.1:33244", + ControllerType: "xbox360", + ExpectedVendorID: 0x045e, + ExpectedProductID: 0x028e, + ExpectedSDLType: "xbox360", + Button: "south/A", + WarmupPairs: ProductionWarmupPairs, + SamplePairs: MinimumProductionSamplePairs, + PerTransitionTimeoutNS: int64(time.Second), + InterTransitionDelayNS: int64(2 * time.Millisecond), + PhaseSweepOffsetsNS: ProductionPhaseSweepOffsetsNS(), + Authentication: AuthenticationMode, + }, + Policy: Policy{ + MinimumSamplePairs: MinimumProductionSamplePairs, + NativeMaxP95NS: DefaultNativeMaxP95NS, + NativeMaxP99NS: DefaultNativeMaxP99NS, + NativeMaxNS: DefaultNativeMaxNS, + NativeMaxP95OverUSBIPNS: DefaultNativeMaxP95OverUSBIPNS, + NativeMaxP99OverUSBIPNS: DefaultNativeMaxP99OverUSBIPNS, + NativeMaxOverUSBIPNS: DefaultNativeMaxOverUSBIPNS, + }, + } + report.Workload.PhaseSweepSHA256 = PhaseSweepScheduleSHA256(report.Workload.PhaseSweepOffsetsNS) + + for runIndex, block := range ProductionBlockSchedule(MinimumProductionSamplePairs) { + run := Run{ + Order: block.Order, + TransportBlock: block.TransportBlock, + FirstSequence: block.FirstSequence, + SamplePairs: block.SamplePairs, + Transport: block.Transport, + Authentication: AuthenticationMode, + UnauthenticatedRejected: true, + Server: ServerProof{ + Server: "VIIPER", Version: "0.1.0", Transport: block.Transport, Ready: true, + }, + Device: DeviceProof{ + BusID: 1, DeviceID: "1", Type: "xbox360", + VendorID: 0x045e, ProductID: 0x028e, + }, + Controller: ControllerProof{ + BaselineGamepadIDs: []int32{10}, + NewGamepadIDs: []int32{int32(20 + runIndex)}, + SDLInstanceID: int32(20 + runIndex), + SDLPath: fmt.Sprintf("source-path-%s-%d", block.Transport, block.TransportBlock), + SDLGUID: "030000005e0400008e02000000000000", + SDLName: "Xbox 360 Controller", + SDLType: "xbox360", + SDLReportedType: 1, + SDLRealType: 2, + VendorID: 0x045e, + ProductID: 0x028e, + }, + } + if block.Transport == TransportUSBIP { + run.Device.USBIPPort = 1 + run.Controller.PNPInstanceID = `HID\VID_045E&PID_028E\1` + run.Controller.PNPContainerID = `{11111111-2222-3333-4444-555555555555}` + run.Controller.PNPAncestorIDs = []string{run.Controller.PNPInstanceID, `USB\VID_045E&PID_028E\1`, `ROOT\USB\0002`} + run.Controller.PNPAncestorContainerIDs = []string{run.Controller.PNPContainerID, run.Controller.PNPContainerID, ""} + run.Controller.PNPAncestorServices = []string{"HidUsb", "usbccgp", "usbip2_ude"} + run.Controller.PNPAncestorHardwareIDs = [][]string{{`HID_DEVICE_SYSTEM_GAME`}, {`USB\VID_045E&PID_028E`}, {`ROOT\USBIP_WIN2\UDE`}} + run.Controller.PNPAncestorLocationInfo = []string{"", "Port_#0001.Hub_#0001", ""} + run.Controller.PNPAncestorLocationPaths = [][]string{{}, {`USBROOT(0)#USB(1)`}, {}} + run.Controller.TransportAnchorInstanceID = `ROOT\USB\0002` + run.Controller.TransportAnchorService = "usbip2_ude" + } else { + run.Server.NativeUDE = &NativeServerProof{ + ABIMajor: 1, ABIMinor: 0, Capabilities: 1, + ExpectedDriverPackageVersion: "0.1.0.3", + LoadedDriverBuildIdentity: report.Provenance.NativeDriverBuildIdentity, + } + run.Controller.PNPInstanceID = `HID\VID_045E&PID_028E\2` + run.Controller.PNPContainerID = `{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}` + run.Controller.PNPAncestorIDs = []string{run.Controller.PNPInstanceID, `USB\VID_045E&PID_028E\2`, `ROOT\VIIPERUDE\0000`} + run.Controller.PNPAncestorContainerIDs = []string{run.Controller.PNPContainerID, run.Controller.PNPContainerID, ""} + run.Controller.PNPAncestorServices = []string{"HidUsb", "WUDFRd", "ViiperUde"} + run.Controller.PNPAncestorHardwareIDs = [][]string{{`HID_DEVICE_SYSTEM_GAME`}, {`USB\VID_045E&PID_028E`}, {`ROOT\VIIPER\UDE`}} + run.Controller.PNPAncestorLocationInfo = []string{"", "", ""} + run.Controller.PNPAncestorLocationPaths = [][]string{{}, {}, {}} + run.Controller.TransportAnchorInstanceID = `ROOT\VIIPERUDE\0000` + run.Controller.TransportAnchorService = "ViiperUde" + } + transportOffset := 0 + if block.Transport == TransportNativeUDE { + transportOffset = 50_000 + } + lastSequence := block.FirstSequence + block.SamplePairs - 1 + qpcCursor := int64(runIndex+1) * 1_000_000_000_000 + for sequence := block.FirstSequence; sequence <= lastSequence; sequence++ { + base := int64(400_000 + transportOffset + sequence*10) + timestamp := uint64(runIndex+1)*1_000_000_000 + uint64(sequence*2) + pressStartQPC := qpcCursor + pressEndQPC := pressStartQPC + base + releaseStartQPC := pressStartQPC + 10_000_000 + releaseEndQPC := releaseStartQPC + base + 5 + run.Samples = append(run.Samples, + Sample{Sequence: sequence, Transition: TransitionPress, + LatencyNS: base, EventTimestampNS: timestamp, SDLFenceTimestampNS: timestamp - 1, + StartQPCTicks: pressStartQPC, EndQPCTicks: pressEndQPC, + MarkerQPCTicks: pressEndQPC + 1, + MarkerID: SampleMarkerID("xbox360", run.Transport, run.TransportBlock, sequence, TransitionPress)}, + Sample{Sequence: sequence, Transition: TransitionRelease, + LatencyNS: base + 5, EventTimestampNS: timestamp + 1, SDLFenceTimestampNS: timestamp, + StartQPCTicks: releaseStartQPC, EndQPCTicks: releaseEndQPC, + MarkerQPCTicks: releaseEndQPC + 1, + MarkerID: SampleMarkerID("xbox360", run.Transport, run.TransportBlock, sequence, TransitionRelease)}) + qpcCursor = pressStartQPC + 20_000_000 + } + report.Runs = append(report.Runs, run) + } + if err := Finalize(report); err != nil { + t.Fatal(err) + } + if report.Verdict != "pass" { + t.Fatalf("fixture failed: %v", report.Failures) + } + return report +} + +func setSampleLatency(sample *Sample, latencyNS, qpcFrequency int64) { + sample.LatencyNS = latencyNS + sample.EndQPCTicks = sample.StartQPCTicks + latencyNS*qpcFrequency/int64(time.Second) + sample.MarkerQPCTicks = sample.EndQPCTicks + 1 +} + +func validSuite(t *testing.T) *SuiteReport { + t.Helper() + xbox := validReport(t) + suite := &SuiteReport{ + Schema: SuiteSchemaV2, GeneratedAt: xbox.GeneratedAt, Provenance: xbox.Provenance, + } + identities := []struct { + controller string + vendorID uint16 + productID uint16 + sdlType string + realType int32 + }{ + {controller: "xbox360", vendorID: 0x045e, productID: 0x028e, sdlType: "xbox360", realType: 2}, + {controller: "dualshock4", vendorID: 0x054c, productID: 0x09cc, sdlType: "ps4", realType: 5}, + {controller: "dualsensegamepadv5", vendorID: 0x054c, productID: 0x0ce6, sdlType: "ps5", realType: 6}, + } + for caseIndex, identity := range identities { + report := cloneReport(t, *xbox) + report.Workload.ControllerType = identity.controller + report.Workload.ExpectedVendorID = identity.vendorID + report.Workload.ExpectedProductID = identity.productID + report.Workload.ExpectedSDLType = identity.sdlType + for runIndex := range report.Runs { + run := &report.Runs[runIndex] + run.Device.Type = identity.controller + run.Device.VendorID = identity.vendorID + run.Device.ProductID = identity.productID + run.Controller.SDLType = identity.sdlType + run.Controller.SDLRealType = identity.realType + run.Controller.VendorID = identity.vendorID + run.Controller.ProductID = identity.productID + run.Controller.SDLInstanceID = int32(100 + caseIndex*10 + runIndex) + run.Controller.NewGamepadIDs = []int32{run.Controller.SDLInstanceID} + run.Controller.SDLPath = identity.controller + "-" + run.Transport + for sampleIndex := range run.Samples { + sample := &run.Samples[sampleIndex] + sample.MarkerID = SampleMarkerID(identity.controller, run.Transport, + run.TransportBlock, sample.Sequence, sample.Transition) + } + } + if err := Finalize(&report); err != nil { + t.Fatal(err) + } + suite.Cases = append(suite.Cases, report) + } + if err := FinalizeSuite(suite); err != nil { + t.Fatal(err) + } + if suite.Verdict != "pass" { + t.Fatalf("suite fixture failed: %v", suite.Failures) + } + return suite +} + +func cloneReport(t *testing.T, report Report) Report { + t.Helper() + data, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + var clone Report + if err = json.Unmarshal(data, &clone); err != nil { + t.Fatal(err) + } + return clone +} + +func encodeSuite(t *testing.T, suite *SuiteReport) []byte { + t.Helper() + data, err := json.Marshal(suite) + if err != nil { + t.Fatal(err) + } + return data +} + +func encodeReport(t *testing.T, report *Report) []byte { + t.Helper() + data, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + return data +} diff --git a/_testing/e2e/latency/trace_markers.go b/_testing/e2e/latency/trace_markers.go new file mode 100644 index 00000000..0faad644 --- /dev/null +++ b/_testing/e2e/latency/trace_markers.go @@ -0,0 +1,100 @@ +package latency + +import ( + "encoding/json" + "errors" + "fmt" + "io" +) + +type TraceMarker struct { + MarkerID string `json:"trace_marker_id"` + Controller string `json:"controller"` + Transport string `json:"transport"` + TransportBlock int `json:"transport_block"` + Sequence int `json:"sequence"` + Transition string `json:"transition"` + StartQPCTicks int64 `json:"start_qpc_ticks"` + EndQPCTicks int64 `json:"end_qpc_ticks"` + MarkerQPCTicks int64 `json:"trace_marker_qpc_ticks"` + LatencyNS int64 `json:"latency_ns"` + EventTimestampNS uint64 `json:"sdl_event_timestamp_ns"` + SDLFenceTimestampNS uint64 `json:"sdl_prewrite_fence_timestamp_ns"` +} + +func ParseTraceMarkers(reader io.Reader) ([]TraceMarker, error) { + decoder := json.NewDecoder(reader) + decoder.DisallowUnknownFields() + var markers []TraceMarker + if err := decoder.Decode(&markers); err != nil { + return nil, fmt.Errorf("decode ETL marker evidence: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return nil, errors.New("ETL marker evidence contains trailing JSON") + } + return nil, fmt.Errorf("decode trailing ETL marker evidence: %w", err) + } + return markers, nil +} + +// VerifyTraceMarkers requires an exact, chronological, one-to-one copy of every +// finalized JSON sample in the decoded sequential ETL marker stream. The +// decoder supplies events in oldest-first ETL order; accepting set equality +// would hide event reordering and weaken the scheduling evidence. +func VerifyTraceMarkers(suite *SuiteReport, observed []TraceMarker) error { + if suite == nil { + return errors.New("nil latency suite") + } + var expected []TraceMarker + expectedByID := make(map[string]TraceMarker) + for _, controllerCase := range suite.Cases { + for _, run := range controllerCase.Runs { + for _, sample := range run.Samples { + marker := TraceMarker{ + MarkerID: sample.MarkerID, Controller: controllerCase.Workload.ControllerType, + Transport: run.Transport, TransportBlock: run.TransportBlock, + Sequence: sample.Sequence, Transition: string(sample.Transition), + StartQPCTicks: sample.StartQPCTicks, + EndQPCTicks: sample.EndQPCTicks, MarkerQPCTicks: sample.MarkerQPCTicks, + LatencyNS: sample.LatencyNS, + EventTimestampNS: sample.EventTimestampNS, + SDLFenceTimestampNS: sample.SDLFenceTimestampNS, + } + if marker.MarkerID == "" { + return errors.New("latency JSON contains an absent trace marker identity") + } + if _, duplicate := expectedByID[marker.MarkerID]; duplicate { + return fmt.Errorf("latency JSON contains duplicate marker %q", marker.MarkerID) + } + expectedByID[marker.MarkerID] = marker + expected = append(expected, marker) + } + } + } + seen := make(map[string]struct{}, len(observed)) + for index, marker := range observed { + _, exists := expectedByID[marker.MarkerID] + if !exists { + return fmt.Errorf("ETL marker %d has unknown or absent identity %q", index, marker.MarkerID) + } + if _, duplicate := seen[marker.MarkerID]; duplicate { + return fmt.Errorf("ETL contains duplicate marker %q", marker.MarkerID) + } + seen[marker.MarkerID] = struct{}{} + } + if len(seen) != len(expected) { + return fmt.Errorf("ETL contains %d exact markers for %d JSON samples", len(seen), len(expected)) + } + for index, marker := range observed { + want := expected[index] + if marker.MarkerID != want.MarkerID { + return fmt.Errorf("ETL marker order differs at index %d: got %q, want %q", index, marker.MarkerID, want.MarkerID) + } + if marker != want { + return fmt.Errorf("ETL marker %q payload does not match its JSON sample", marker.MarkerID) + } + } + return nil +} diff --git a/_testing/e2e/latency/trace_markers_test.go b/_testing/e2e/latency/trace_markers_test.go new file mode 100644 index 00000000..14f6dcaa --- /dev/null +++ b/_testing/e2e/latency/trace_markers_test.go @@ -0,0 +1,69 @@ +package latency + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestTraceMarkerEvidenceRejectsMissingDuplicateTruncatedAndForged(t *testing.T) { + suite := validSuite(t) + markers := traceMarkersFromSuite(suite) + if err := VerifyTraceMarkers(suite, markers); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + mutate func([]TraceMarker) []TraceMarker + want string + }{ + {"missing", func(in []TraceMarker) []TraceMarker { return in[:len(in)-1] }, "JSON samples"}, + {"duplicate", func(in []TraceMarker) []TraceMarker { return append(in, in[0]) }, "duplicate marker"}, + {"reordered", func(in []TraceMarker) []TraceMarker { in[0], in[1] = in[1], in[0]; return in }, "order"}, + {"forged payload", func(in []TraceMarker) []TraceMarker { in[0].EndQPCTicks++; return in }, "payload"}, + {"unknown", func(in []TraceMarker) []TraceMarker { in[0].MarkerID = "unknown"; return in }, "unknown"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mutated := test.mutate(append([]TraceMarker(nil), markers...)) + if err := VerifyTraceMarkers(suite, mutated); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("marker error=%v", err) + } + }) + } + + encoded, err := json.Marshal(markers) + if err != nil { + t.Fatal(err) + } + if _, err = ParseTraceMarkers(bytes.NewReader(encoded[:len(encoded)-1])); err == nil { + t.Fatal("truncated marker JSON was accepted") + } + if _, err = ParseTraceMarkers(bytes.NewReader(append(encoded, []byte(` {}`)...))); err == nil || + !strings.Contains(err.Error(), "trailing JSON") { + t.Fatalf("trailing marker JSON error=%v", err) + } +} + +func traceMarkersFromSuite(suite *SuiteReport) []TraceMarker { + var markers []TraceMarker + for _, controllerCase := range suite.Cases { + for _, run := range controllerCase.Runs { + for _, sample := range run.Samples { + markers = append(markers, TraceMarker{ + MarkerID: sample.MarkerID, Controller: controllerCase.Workload.ControllerType, + Transport: run.Transport, TransportBlock: run.TransportBlock, + Sequence: sample.Sequence, Transition: string(sample.Transition), + StartQPCTicks: sample.StartQPCTicks, + EndQPCTicks: sample.EndQPCTicks, MarkerQPCTicks: sample.MarkerQPCTicks, + LatencyNS: sample.LatencyNS, + EventTimestampNS: sample.EventTimestampNS, + SDLFenceTimestampNS: sample.SDLFenceTimestampNS, + }) + } + } + } + return markers +} diff --git a/_testing/e2e/latency_gate_unsupported_test.go b/_testing/e2e/latency_gate_unsupported_test.go new file mode 100644 index 00000000..35e59436 --- /dev/null +++ b/_testing/e2e/latency_gate_unsupported_test.go @@ -0,0 +1,17 @@ +//go:build !windows + +package e2e_bench_test + +import ( + "os" + "runtime" + "testing" +) + +func TestLiveControllerToGameLatencyGate(t *testing.T) { + if os.Getenv("VIIPER_E2E_LIVE_LATENCY") == "1" { + t.Fatalf("live controller-to-game latency requires Windows with CGO/SDL3; got %s CGO-disabled build", + runtime.GOOS) + } + t.Skip("live controller-to-game latency is an explicit Windows+CGO production gate") +} diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go new file mode 100644 index 00000000..85ef0550 --- /dev/null +++ b/_testing/e2e/latency_gate_windows_test.go @@ -0,0 +1,1472 @@ +//go:build windows + +package e2e_bench_test + +import ( + "context" + "crypto/sha256" + "encoding" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "math" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/Alia5/VIIPER/_testing/e2e/latency" + "github.com/Alia5/VIIPER/_testing/e2e/sdl" + "github.com/Alia5/VIIPER/device/dualsense" + "github.com/Alia5/VIIPER/device/dualshock4" + "github.com/Alia5/VIIPER/device/xbox360" + "github.com/Alia5/VIIPER/internal/cmd" + "github.com/Alia5/VIIPER/internal/server/api" + serverusb "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/testsupport/latencytrace" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/viipertypes" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +const ( + liveLatencyEnvironment = "VIIPER_E2E_LIVE_LATENCY" + liveLatencyPreflight = "VIIPER_E2E_PRODUCTION_PREFLIGHT" + liveLatencyOutput = "VIIPER_E2E_LATENCY_OUTPUT" + liveLatencySamples = "VIIPER_E2E_LATENCY_SAMPLES" + liveLatencyExpectedRevision = "VIIPER_E2E_EXPECTED_SOURCE_REVISION" + liveLatencySDLRevision = "VIIPER_E2E_SDL_SOURCE_REVISION" + liveLatencySDLDLL = "VIIPER_E2E_SDL_DLL_PATH" + liveLatencySDLSHA256 = "VIIPER_E2E_SDL_DLL_SHA256" + liveLatencyPackageManifest = "VIIPER_E2E_PACKAGE_MANIFEST_SHA256" + liveLatencyDriverSHA256 = "VIIPER_E2E_NATIVE_DRIVER_SHA256" + liveLatencyTraceProfileSHA = "VIIPER_E2E_TRACE_PROFILE_SHA256" + liveLatencyDriverBuildID = "VIIPER_E2E_NATIVE_DRIVER_BUILD_IDENTITY" + liveLatencyExpectedPriority = "VIIPER_E2E_EXPECTED_PRIORITY_CLASS" + liveLatencyGitPath = "VIIPER_E2E_GIT_EXECUTABLE_PATH" + liveLatencyGitSHA256 = "VIIPER_E2E_GIT_EXECUTABLE_SHA256" + liveLatencyGoPath = "VIIPER_E2E_GO_EXECUTABLE_PATH" + liveLatencyGoSHA256 = "VIIPER_E2E_GO_EXECUTABLE_SHA256" + liveLatencyWPRPath = "VIIPER_E2E_WPR_EXECUTABLE_PATH" + liveLatencyWPRSHA256 = "VIIPER_E2E_WPR_EXECUTABLE_SHA256" + liveLatencyAPIAddress = "127.0.0.1:33245" + liveLatencyUSBIPAddress = "127.0.0.1:33244" + liveLatencyPassword = "testpassword1234" + liveLatencyTransitionTimeout = time.Second + liveLatencyTransitionDelay = 2 * time.Millisecond + liveLatencyDiscoveryTimeout = 15 * time.Second + liveLatencyDuplicateQuiet = 25 * time.Millisecond + liveLatencySourceQuiet = 75 * time.Millisecond +) + +type liveLatencyConfig struct { + outputPath string + samplePairs int + expectedRevision string + sdlRevision string + sdlDLLPath string + sdlDLLSHA256 string + packageManifestSHA string + driverSHA256 string + traceProfileSHA256 string + driverBuildIdentity string + expectedPriority string + gitPath string + gitSHA256 string + goPath string + goSHA256 string + wprPath string + wprSHA256 string +} + +type liveControllerWorkload struct { + apiType string + vendorID uint16 + productID uint16 + sdlType string + sdlRealType sdl.GamepadType + state func(down bool) encoding.BinaryMarshaler +} + +func liveControllerWorkloads() []liveControllerWorkload { + return []liveControllerWorkload{ + { + apiType: "xbox360", vendorID: 0x045e, productID: 0x028e, + sdlType: "xbox360", sdlRealType: sdl.GamepadTypeXbox360, + state: func(down bool) encoding.BinaryMarshaler { + state := &xbox360.InputState{} + if down { + state.Buttons = xbox360.ButtonA + } + return state + }, + }, + { + apiType: "dualshock4", vendorID: dualshock4.DefaultVID, + productID: dualshock4.DefaultPID, + sdlType: "ps4", sdlRealType: sdl.GamepadTypePS4, + state: func(down bool) encoding.BinaryMarshaler { + state := dualshock4.NewInputState() + if down { + state.Buttons = dualshock4.ButtonCross + } + return state + }, + }, + { + apiType: dualsense.DeviceTypeGamepadOnlyV5, vendorID: dualsense.DefaultVID, + productID: dualsense.DefaultPIDDS, + sdlType: "ps5", sdlRealType: sdl.GamepadTypePS5, + state: func(down bool) encoding.BinaryMarshaler { + state := dualsense.NewInputState() + if down { + state.Buttons = dualsense.ButtonCross + } + return state + }, + }, + } +} + +type latencyServerSession struct { + cancel context.CancelFunc + done <-chan error + client *viiperclient.Client + ping *viipertypes.PingResponse +} + +// TestLiveControllerToGameLatencyGate is deliberately inert in ordinary CI. +// The production wrapper performs read-only source, package, loaded-driver, +// and SDL provenance checks before opting in. The test then measures exactly +// the same authenticated API/south-button workload over USB/IP and native UDE +// for Xbox360, DualShock4, and DualSense, then writes every SDL-observed edge. +func TestLiveControllerToGameLatencyGate(t *testing.T) { + if os.Getenv(liveLatencyEnvironment) != "1" { + t.Skipf("run _testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1; direct opt-in requires %s=1", + liveLatencyEnvironment) + } + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + config, err := loadLiveLatencyConfig() + if err != nil { + t.Fatal(err) + } + if err = validateLiveLatencySource(config); err != nil { + t.Fatal(err) + } + if err = sdl.EnableWindowsRawInput(); err != nil { + t.Fatalf("enable SDL RawInput for exact Windows source identity: %v", err) + } + if err = sdl.Init(sdl.InitFlagGamepad | sdl.InitFlagEvents); err != nil { + t.Fatalf("initialize source-bound SDL event observer: %v", err) + } + defer sdl.Quit() + traceProvider, err := latencytrace.NewProvider() + if err != nil { + t.Fatalf("initialize source-controlled latency TraceLogging provider: %v", err) + } + defer traceProvider.Close() + traceEnableDeadline := time.Now().Add(time.Second) + for !traceProvider.Enabled() && time.Now().Before(traceEnableDeadline) { + time.Sleep(10 * time.Millisecond) + } + if !traceProvider.Enabled() { + t.Fatal("source-controlled latency TraceLogging provider was not enabled by WPR") + } + qpcFrequency, err := latencytrace.Frequency() + if err != nil { + t.Fatalf("query QPC frequency: %v", err) + } + loadedSDL, err := loadedModulePath("SDL3.dll") + if err != nil { + t.Fatal(err) + } + loadedSDL, err = canonicalPath(loadedSDL) + if err != nil { + t.Fatalf("resolve loaded SDL module: %v", err) + } + if !strings.EqualFold(loadedSDL, config.sdlDLLPath) { + t.Fatalf("loaded SDL module %q does not match source-bound module %q", + loadedSDL, config.sdlDLLPath) + } + loadedHash, err := fileSHA256(loadedSDL) + if err != nil { + t.Fatal(err) + } + if loadedHash != config.sdlDLLSHA256 { + t.Fatalf("loaded SDL SHA-256 %s does not match source-bound SHA-256 %s", + loadedHash, config.sdlDLLSHA256) + } + machine, err := collectMachineProvenance(config.expectedPriority) + if err != nil { + t.Fatalf("collect exact machine and scheduler provenance: %v", err) + } + + generatedAt := time.Now().UTC() + provenance := latency.Provenance{ + SourceRevision: config.expectedRevision, + SDLSourceRevision: config.sdlRevision, + SDLBinaryPath: loadedSDL, + SDLBinarySHA256: loadedHash, + NativePackageManifestSHA256: config.packageManifestSHA, + NativeDriverSHA256: config.driverSHA256, + NativeDriverBuildIdentity: config.driverBuildIdentity, + QPCFrequency: qpcFrequency, + TraceProviderName: latency.TraceProviderName, + TraceProviderGUID: latency.TraceProviderGUID, + TraceProfileSHA256: config.traceProfileSHA256, + USBIPBaselineMode: latency.USBIPBaselineMode, + USBIPBaselineVersion: latency.USBIPBaselineVersion, + GoVersion: runtime.Version(), + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + GitExecutablePath: config.gitPath, + GitExecutableSHA256: config.gitSHA256, + GoExecutablePath: config.goPath, + GoExecutableSHA256: config.goSHA256, + WPRExecutablePath: config.wprPath, + WPRExecutableSHA256: config.wprSHA256, + Machine: machine, + } + suite := &latency.SuiteReport{ + Schema: latency.SuiteSchemaV2, GeneratedAt: generatedAt, Provenance: provenance, + } + + gateCtx, cancelGate := context.WithTimeout(context.Background(), 18*time.Minute) + defer cancelGate() + for _, controller := range liveControllerWorkloads() { + phaseSweepOffsets := latency.ProductionPhaseSweepOffsetsNS() + report := latency.Report{ + Schema: latency.SchemaV2, GeneratedAt: generatedAt, Provenance: provenance, + Workload: latency.Workload{ + APIAddress: liveLatencyAPIAddress, USBIPAddress: liveLatencyUSBIPAddress, + ControllerType: controller.apiType, + ExpectedVendorID: controller.vendorID, + ExpectedProductID: controller.productID, + ExpectedSDLType: controller.sdlType, + Button: "south/A", WarmupPairs: latency.ProductionWarmupPairs, + SamplePairs: config.samplePairs, + PerTransitionTimeoutNS: int64(liveLatencyTransitionTimeout), + InterTransitionDelayNS: int64(liveLatencyTransitionDelay), + PhaseSweepOffsetsNS: phaseSweepOffsets, + PhaseSweepSHA256: latency.PhaseSweepScheduleSHA256(phaseSweepOffsets), + Authentication: latency.AuthenticationMode, + }, + Policy: latency.Policy{ + MinimumSamplePairs: latency.MinimumProductionSamplePairs, + NativeMaxP95NS: latency.DefaultNativeMaxP95NS, + NativeMaxP99NS: latency.DefaultNativeMaxP99NS, + NativeMaxNS: latency.DefaultNativeMaxNS, + NativeMaxP95OverUSBIPNS: latency.DefaultNativeMaxP95OverUSBIPNS, + NativeMaxP99OverUSBIPNS: latency.DefaultNativeMaxP99OverUSBIPNS, + NativeMaxOverUSBIPNS: latency.DefaultNativeMaxOverUSBIPNS, + }, + } + for _, block := range latency.ProductionBlockSchedule(config.samplePairs) { + report.Runs = append(report.Runs, + runLiveLatencyTransport(gateCtx, block, controller, traceProvider, + qpcFrequency, config.driverBuildIdentity)) + } + if err = latency.Finalize(&report); err != nil { + t.Fatalf("finalize %s source-bound latency report: %v", controller.apiType, err) + } + suite.Cases = append(suite.Cases, report) + } + if err = latency.FinalizeSuite(suite); err != nil { + t.Fatalf("finalize source-bound latency suite: %v", err) + } + if err = writeLatencyReportExclusive(config.outputPath, suite); err != nil { + t.Fatalf("write latency report: %v", err) + } + for _, controllerReport := range suite.Cases { + for _, transport := range controllerReport.Transports { + t.Logf("%s/%s controller-to-SDL: press n=%d p50=%s p90=%s p95=%s p99=%s p99.9=%s max=%s jitter=%s; "+ + "release n=%d p50=%s p90=%s p95=%s p99=%s p99.9=%s max=%s jitter=%s; misses=%d duplicates=%d", + controllerReport.Workload.ControllerType, transport.Transport, + transport.Statistics.Press.Count, + time.Duration(transport.Statistics.Press.P50NS), + time.Duration(transport.Statistics.Press.P90NS), + time.Duration(transport.Statistics.Press.P95NS), + time.Duration(transport.Statistics.Press.P99NS), + time.Duration(transport.Statistics.Press.P999NS), + time.Duration(transport.Statistics.Press.MaxNS), + time.Duration(transport.Statistics.Press.JitterNS), + transport.Statistics.Release.Count, + time.Duration(transport.Statistics.Release.P50NS), + time.Duration(transport.Statistics.Release.P90NS), + time.Duration(transport.Statistics.Release.P95NS), + time.Duration(transport.Statistics.Release.P99NS), + time.Duration(transport.Statistics.Release.P999NS), + time.Duration(transport.Statistics.Release.MaxNS), + time.Duration(transport.Statistics.Release.JitterNS), + transport.Misses.Total(), transport.Duplicates.Total()) + } + } + t.Logf("source-bound latency artifact: %s", config.outputPath) + if err = latency.RequireSuitePass(suite); err != nil { + t.Errorf("controller-to-game latency gate failed: %v", err) + } +} + +func loadLiveLatencyConfig() (liveLatencyConfig, error) { + if os.Getenv(liveLatencyPreflight) != "1" { + return liveLatencyConfig{}, fmt.Errorf( + "%s=1 is required; use the production preflight wrapper", liveLatencyPreflight) + } + config := liveLatencyConfig{ + outputPath: strings.TrimSpace(os.Getenv(liveLatencyOutput)), + expectedRevision: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyExpectedRevision))), + sdlRevision: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencySDLRevision))), + sdlDLLPath: strings.TrimSpace(os.Getenv(liveLatencySDLDLL)), + sdlDLLSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencySDLSHA256))), + packageManifestSHA: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyPackageManifest))), + driverSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyDriverSHA256))), + traceProfileSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyTraceProfileSHA))), + driverBuildIdentity: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyDriverBuildID))), + expectedPriority: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyExpectedPriority))), + gitPath: strings.TrimSpace(os.Getenv(liveLatencyGitPath)), + gitSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyGitSHA256))), + goPath: strings.TrimSpace(os.Getenv(liveLatencyGoPath)), + goSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyGoSHA256))), + wprPath: strings.TrimSpace(os.Getenv(liveLatencyWPRPath)), + wprSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyWPRSHA256))), + } + if config.outputPath == "" || config.expectedRevision == "" || config.sdlRevision == "" || + config.sdlDLLPath == "" || config.sdlDLLSHA256 == "" || + config.packageManifestSHA == "" || config.driverSHA256 == "" || + config.traceProfileSHA256 == "" || config.driverBuildIdentity == "" || + (config.expectedPriority != "normal" && config.expectedPriority != "high") || + config.gitPath == "" || config.gitSHA256 == "" || + config.goPath == "" || config.goSHA256 == "" || + config.wprPath == "" || config.wprSHA256 == "" { + return liveLatencyConfig{}, errors.New("production latency provenance environment is incomplete") + } + if !filepath.IsAbs(config.outputPath) || !filepath.IsAbs(config.sdlDLLPath) { + return liveLatencyConfig{}, errors.New("latency output and SDL DLL paths must be absolute") + } + if _, err := os.Stat(config.outputPath); !errors.Is(err, os.ErrNotExist) { + if err == nil { + return liveLatencyConfig{}, fmt.Errorf("latency output already exists: %s", config.outputPath) + } + return liveLatencyConfig{}, fmt.Errorf("inspect latency output: %w", err) + } + if info, err := os.Stat(filepath.Dir(config.outputPath)); err != nil || !info.IsDir() { + return liveLatencyConfig{}, fmt.Errorf("latency output parent must already exist: %s", filepath.Dir(config.outputPath)) + } + samples, err := strconv.Atoi(strings.TrimSpace(os.Getenv(liveLatencySamples))) + if err != nil || samples < latency.MinimumProductionSamplePairs || + samples > latency.MaximumProductionSamplePairs { + return liveLatencyConfig{}, fmt.Errorf("%s must be an integer in [%d, %d]", + liveLatencySamples, latency.MinimumProductionSamplePairs, + latency.MaximumProductionSamplePairs) + } + config.samplePairs = samples + canonicalSDL, err := canonicalPath(config.sdlDLLPath) + if err != nil { + return liveLatencyConfig{}, fmt.Errorf("resolve source-bound SDL DLL: %w", err) + } + config.sdlDLLPath = canonicalSDL + for _, executable := range []struct { + name, path, hash string + }{ + {name: "Git", path: config.gitPath, hash: config.gitSHA256}, + {name: "Go", path: config.goPath, hash: config.goSHA256}, + {name: "WPR", path: config.wprPath, hash: config.wprSHA256}, + } { + canonical, resolveErr := canonicalPath(executable.path) + if resolveErr != nil { + return liveLatencyConfig{}, fmt.Errorf("resolve %s executable: %w", executable.name, resolveErr) + } + actualHash, hashErr := fileSHA256(canonical) + if hashErr != nil || actualHash != executable.hash { + return liveLatencyConfig{}, fmt.Errorf( + "%s executable hash changed: path=%s actual=%s expected=%s error=%v", + executable.name, canonical, actualHash, executable.hash, hashErr) + } + switch executable.name { + case "Git": + config.gitPath = canonical + case "Go": + config.goPath = canonical + case "WPR": + config.wprPath = canonical + } + } + return config, nil +} + +func collectMachineProvenance(expectedPriority string) (latency.MachineProvenance, error) { + hostname, err := os.Hostname() + if err != nil { + return latency.MachineProvenance{}, fmt.Errorf("resolve host name: %w", err) + } + if strings.TrimSpace(hostname) == "" { + return latency.MachineProvenance{}, errors.New("resolved host name is empty") + } + version := windows.RtlGetVersion() + if version == nil || version.BuildNumber == 0 { + return latency.MachineProvenance{}, errors.New("RtlGetVersion returned an invalid OS version") + } + + osKey, err := registry.OpenKey(registry.LOCAL_MACHINE, + `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE) + if err != nil { + return latency.MachineProvenance{}, fmt.Errorf("open Windows version registry key: %w", err) + } + defer osKey.Close() + productName, err := requiredRegistryString(osKey, "ProductName") + if err != nil { + return latency.MachineProvenance{}, err + } + displayVersion, err := firstRequiredRegistryString(osKey, "DisplayVersion", "ReleaseId") + if err != nil { + return latency.MachineProvenance{}, err + } + ubr, _, err := osKey.GetIntegerValue("UBR") + if err != nil { + return latency.MachineProvenance{}, fmt.Errorf("read Windows UBR: %w", err) + } + + cpuKey, err := registry.OpenKey(registry.LOCAL_MACHINE, + `HARDWARE\DESCRIPTION\System\CentralProcessor\0`, registry.QUERY_VALUE) + if err != nil { + return latency.MachineProvenance{}, fmt.Errorf("open CPU registry key: %w", err) + } + defer cpuKey.Close() + cpuModel, err := requiredRegistryString(cpuKey, "ProcessorNameString") + if err != nil { + return latency.MachineProvenance{}, err + } + + priority, err := windows.GetPriorityClass(windows.CurrentProcess()) + if err != nil { + return latency.MachineProvenance{}, fmt.Errorf("query process priority class: %w", err) + } + priorityName := "" + switch priority { + case windows.NORMAL_PRIORITY_CLASS: + priorityName = "normal" + case windows.HIGH_PRIORITY_CLASS: + priorityName = "high" + default: + return latency.MachineProvenance{}, fmt.Errorf( + "unsupported process priority class %#x; expected normal or high", priority) + } + if priorityName != expectedPriority { + return latency.MachineProvenance{}, fmt.Errorf( + "process priority class is %s, wrapper required %s", priorityName, expectedPriority) + } + if !windows.GetCurrentProcessToken().IsElevated() { + return latency.MachineProvenance{}, errors.New("latency process token is not elevated") + } + + return latency.MachineProvenance{ + Hostname: strings.TrimSpace(hostname), + OSProductName: productName, + OSDisplayVersion: displayVersion, + OSVersion: fmt.Sprintf("%d.%d.%d.%d", version.MajorVersion, version.MinorVersion, version.BuildNumber, ubr), + CPUModel: cpuModel, + LogicalProcessors: runtime.NumCPU(), + ProcessPriorityClass: priorityName, + ProcessElevated: true, + }, nil +} + +func requiredRegistryString(key registry.Key, name string) (string, error) { + value, _, err := key.GetStringValue(name) + if err != nil { + return "", fmt.Errorf("read non-empty registry value %s: %w", name, err) + } + value = strings.TrimSpace(value) + if value == "" { + return "", fmt.Errorf("registry value %s is empty", name) + } + return value, nil +} + +func firstRequiredRegistryString(key registry.Key, names ...string) (string, error) { + var lastErr error + for _, name := range names { + value, err := requiredRegistryString(key, name) + if err == nil { + return value, nil + } + lastErr = err + } + return "", lastErr +} + +func validateLiveLatencySource(config liveLatencyConfig) error { + workingDirectory, err := os.Getwd() + if err != nil { + return err + } + repositoryRoot, err := runGit(config.gitPath, workingDirectory, "rev-parse", "--show-toplevel") + if err != nil { + return fmt.Errorf("latency harness is not an exact Git checkout: %w", err) + } + repositoryRoot, err = canonicalPath(strings.TrimSpace(repositoryRoot)) + if err != nil { + return err + } + head, err := runGit(config.gitPath, repositoryRoot, "rev-parse", "--verify", "HEAD") + if err != nil { + return err + } + if strings.ToLower(strings.TrimSpace(head)) != config.expectedRevision { + return fmt.Errorf("latency harness source is %s, expected %s", strings.TrimSpace(head), config.expectedRevision) + } + status, err := runGit(config.gitPath, repositoryRoot, "status", "--porcelain=v1", "--untracked-files=all") + if err != nil { + return err + } + if strings.TrimSpace(status) != "" { + return fmt.Errorf("latency source tree is not clean; refusing unreviewed code or data:\n%s", status) + } + submodules, err := runGit(config.gitPath, repositoryRoot, "submodule", "status", "--recursive") + if err != nil { + return err + } + for _, line := range strings.Split(strings.TrimSpace(submodules), "\n") { + if line != "" && strings.ContainsRune("-+U", rune(line[0])) { + return fmt.Errorf("latency source has an unbound submodule: %s", line) + } + } + sdlRoot := filepath.Join(repositoryRoot, "_testing", "e2e", "deps", "SDL") + sdlRevision, err := runGit(config.gitPath, sdlRoot, "rev-parse", "--verify", "HEAD") + if err != nil { + return err + } + if strings.ToLower(strings.TrimSpace(sdlRevision)) != config.sdlRevision { + return fmt.Errorf("SDL source is %s, expected %s", strings.TrimSpace(sdlRevision), config.sdlRevision) + } + wantSDLPath, err := canonicalPath(filepath.Join(sdlRoot, "build", "Debug", "SDL3.dll")) + if err != nil { + return err + } + if !strings.EqualFold(config.sdlDLLPath, wantSDLPath) { + return fmt.Errorf("SDL DLL must be the wrapper-linked submodule build %s, got %s", + wantSDLPath, config.sdlDLLPath) + } + return nil +} + +func runLiveLatencyTransport( + ctx context.Context, + block latency.BlockSpec, + controller liveControllerWorkload, + traceProvider *latencytrace.Provider, + qpcFrequency int64, + expectedDriverBuildIdentity string, +) (result latency.Run) { + transport := block.Transport + result.Order = block.Order + result.TransportBlock = block.TransportBlock + result.FirstSequence = block.FirstSequence + result.SamplePairs = block.SamplePairs + result.Transport = transport + result.Authentication = latency.AuthenticationMode + tempDir, err := os.MkdirTemp("", "viiper-e2e-latency-"+controller.apiType+"-"+transport+"-") + if err != nil { + result.Failure = err.Error() + return result + } + defer os.RemoveAll(tempDir) + + baseline, err := snapshotGamepadIDs() + if err != nil { + result.Failure = fmt.Sprintf("snapshot baseline SDL gamepads: %v", err) + return result + } + result.Controller.BaselineGamepadIDs = gamepadIDsAsInt32(baseline) + + server, err := startLatencyServer(ctx, transport, tempDir, expectedDriverBuildIdentity) + if err != nil { + result.Failure = err.Error() + return result + } + var ( + busCreated bool + deviceID string + deviceRegistration *viipertypes.Device + gamepadID sdl.GamepadID + gamepad *sdl.Gamepad + stream *viiperclient.DeviceStream + ) + defer func() { + if stream != nil { + if closeErr := stream.Close(); closeErr != nil { + appendLatencyFailure(&result, "close authenticated stream: %v", closeErr) + } + } + if gamepad != nil { + gamepad.Close() + } + if deviceRegistration != nil { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + _, removeErr := server.client.DeviceRemoveRegisteredCtx(cleanupCtx, deviceRegistration) + cancel() + if removeErr != nil { + appendLatencyFailure(&result, "remove API device: %v", removeErr) + } + } + if busCreated && (deviceRegistration == nil || + deviceRegistration.Transport != "native-ude") { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + _, removeErr := server.client.BusRemoveCtx(cleanupCtx, 1) + cancel() + if removeErr != nil { + appendLatencyFailure(&result, "remove API bus: %v", removeErr) + } + } + if gamepadID != 0 { + if removeErr := waitForGamepadRemoval(gamepadID, 10*time.Second); removeErr != nil { + appendLatencyFailure(&result, "wait for exact SDL gamepad removal: %v", removeErr) + } + } + if closeErr := server.close(); closeErr != nil { + appendLatencyFailure(&result, "stop %s server: %v", transport, closeErr) + } + }() + + result.Server = serverProof(server.ping) + unauthenticated := viiperclient.NewWithConfig(liveLatencyAPIAddress, &viiperclient.Config{ + DialTimeout: 500 * time.Millisecond, ReadTimeout: time.Second, WriteTimeout: time.Second, + }) + probeCtx, cancelProbe := context.WithTimeout(ctx, 2*time.Second) + _, unauthenticatedErr := unauthenticated.PingCtx(probeCtx) + cancelProbe() + var unauthenticatedAPIError *viipertypes.APIError + if !errors.As(unauthenticatedErr, &unauthenticatedAPIError) || + unauthenticatedAPIError.Status != 401 || + unauthenticatedAPIError.Title != "Unauthorized" || + unauthenticatedAPIError.Detail != "authentication required" { + result.Failure = fmt.Sprintf( + "unauthenticated API ping was not explicitly rejected by the live server: %v", + unauthenticatedErr) + return result + } + reprobeCtx, cancelReprobe := context.WithTimeout(ctx, 2*time.Second) + reprobe, reprobeErr := server.client.PingCtx(reprobeCtx) + cancelReprobe() + if reprobeErr != nil { + result.Failure = fmt.Sprintf("authenticated API failed immediately after rejection probe: %v", reprobeErr) + return result + } + if err = validatePing(transport, reprobe, expectedDriverBuildIdentity); err != nil || + reprobe.Version != server.ping.Version { + result.Failure = fmt.Sprintf( + "authenticated API identity changed after rejection probe: response=%+v error=%v", + reprobe, err) + return result + } + result.UnauthenticatedRejected = true + + requestCtx, cancelRequest := context.WithTimeout(ctx, 10*time.Second) + bus, err := server.client.BusCreateCtx(requestCtx, 1) + cancelRequest() + if err != nil || bus == nil || bus.BusID != 1 { + result.Failure = fmt.Sprintf("authenticated BusCreate did not return bus 1: response=%v error=%v", bus, err) + return result + } + busCreated = true + requestCtx, cancelRequest = context.WithTimeout(ctx, 30*time.Second) + device, err := server.client.DeviceAddCtx(requestCtx, 1, controller.apiType, nil) + cancelRequest() + if err != nil { + result.Failure = fmt.Sprintf("authenticated DeviceAdd over %s: %v", transport, err) + return result + } + if device == nil || device.BusID != 1 || device.DevID != "1" || + device.Type != controller.apiType || + !strings.EqualFold(device.Vid, fmt.Sprintf("0x%04x", controller.vendorID)) || + !strings.EqualFold(device.Pid, fmt.Sprintf("0x%04x", controller.productID)) { + result.Failure = fmt.Sprintf("DeviceAdd source proof is not the exact %s workload: %+v", + controller.apiType, device) + return result + } + if transport == latency.TransportUSBIP && device.USBIPPort <= 0 { + result.Failure = "USB/IP DeviceAdd did not return the exact auto-attached import port" + return result + } + if transport == latency.TransportNativeUDE && device.USBIPPort != 0 { + result.Failure = fmt.Sprintf("native DeviceAdd returned contradictory USB/IP port %d", device.USBIPPort) + return result + } + deviceID = device.DevID + deviceRegistration = device + result.Device = latency.DeviceProof{ + BusID: 1, DeviceID: device.DevID, Type: device.Type, + VendorID: controller.vendorID, ProductID: controller.productID, USBIPPort: device.USBIPPort, + } + + gamepadID, err = discoverExactNewGamepad(ctx, baseline, liveLatencyDiscoveryTimeout) + if err != nil { + result.Failure = err.Error() + return result + } + result.Controller.NewGamepadIDs = []int32{int32(gamepadID)} + gamepad, err = sdl.OpenGamepad(gamepadID) + if err != nil { + result.Failure = fmt.Sprintf("open exact newly enumerated SDL gamepad %d: %v", gamepadID, err) + return result + } + result.Controller = controllerProof(result.Controller.BaselineGamepadIDs, gamepad) + if err = validateControllerProof(result.Controller, controller); err != nil { + result.Failure = err.Error() + return result + } + if err = bindControllerPnP(&result.Controller, transport, device.USBIPPort); err != nil { + result.Failure = err.Error() + return result + } + + streamCtx, cancelStream := context.WithTimeout(ctx, 10*time.Second) + stream, err = server.client.OpenStream(streamCtx, 1, device.DevID) + cancelStream() + if err != nil { + result.Failure = fmt.Sprintf("open authenticated %s stream over %s: %v", + controller.apiType, transport, err) + return result + } + lastEventTimestamp, err := settleNeutralController(gamepad, stream, controller.state(false)) + if err != nil { + result.Failure = fmt.Sprintf("settle exact SDL source before measurement: %v", err) + return result + } + lastEventTimestamp, err = warmControllerPath(gamepad, stream, controller, lastEventTimestamp, qpcFrequency) + if err != nil { + result.Failure = fmt.Sprintf("warm exact controller-to-SDL path: %v", err) + return result + } + observedDown := false + lastSequence := block.FirstSequence + block.SamplePairs - 1 + for sequence := block.FirstSequence; sequence <= lastSequence; sequence++ { + lastEventTimestamp, err = waitForCausalDwell(gamepad, sequence, + latency.TransitionPress, &observedDown, lastEventTimestamp, &result) + if err != nil { + result.Failure = err.Error() + return result + } + lastEventTimestamp, err = measureTransition( + gamepad, stream, sequence, latency.TransitionPress, true, + controller.state(true), &observedDown, lastEventTimestamp, &result, + controller.apiType, transport, block.TransportBlock, traceProvider, qpcFrequency) + if err != nil { + result.Failure = err.Error() + return result + } + lastEventTimestamp, err = waitForCausalDwell(gamepad, sequence, + latency.TransitionRelease, &observedDown, lastEventTimestamp, &result) + if err != nil { + result.Failure = err.Error() + return result + } + lastEventTimestamp, err = measureTransition( + gamepad, stream, sequence, latency.TransitionRelease, false, + controller.state(false), &observedDown, lastEventTimestamp, &result, + controller.apiType, transport, block.TransportBlock, traceProvider, qpcFrequency) + if err != nil { + result.Failure = err.Error() + return result + } + } + if err = observeDuplicateQuietWindow(gamepad, lastEventTimestamp, &observedDown, &result); err != nil { + result.Failure = err.Error() + return result + } + if observedDown || gamepad.GetButton(sdl.GamepadButtonSouth) { + result.Failure = "exact SDL source did not finish in the commanded released state" + } + return result +} + +func startLatencyServer(ctx context.Context, transport, tempDir, + expectedDriverBuildIdentity string, +) (*latencyServerSession, error) { + for _, address := range []string{liveLatencyAPIAddress, liveLatencyUSBIPAddress} { + listener, err := net.Listen("tcp", address) + if err != nil { + return nil, fmt.Errorf("latency endpoint %s is already occupied: %w", address, err) + } + _ = listener.Close() + } + credentialPath := filepath.Join(tempDir, "viiper.key.txt") + if err := os.WriteFile(credentialPath, []byte(liveLatencyPassword), 0o600); err != nil { + return nil, err + } + serverCtx, cancelServer := context.WithCancel(ctx) + serverDone := make(chan error, 1) + server := cmd.Server{ + USBServerConfig: serverusb.ServerConfig{ + Addr: liveLatencyUSBIPAddress, BusCleanupTimeout: 30 * time.Second, + }, + APIServerConfig: api.ServerConfig{ + Addr: liveLatencyAPIAddress, AutoAttachLocalClient: true, + RequireLocalHostAuth: true, DeviceHandlerConnectTimeout: 30 * time.Second, + Password: liveLatencyPassword, + PlatformOpts: api.PlatformOpts{AutoAttachWindowsNative: true}, + }, + ConnectionTimeout: 5 * time.Second, + Transport: transport, + KeyFile: credentialPath, + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + go func() { serverDone <- server.StartServer(serverCtx, logger, nil) }() + client := viiperclient.NewWithConfig(liveLatencyAPIAddress, &viiperclient.Config{ + DialTimeout: time.Second, ReadTimeout: 2 * time.Second, WriteTimeout: 2 * time.Second, + Password: liveLatencyPassword, + }) + startupDeadline := time.Now().Add(20 * time.Second) + var lastError error + for time.Now().Before(startupDeadline) { + select { + case serverErr := <-serverDone: + cancelServer() + return nil, fmt.Errorf("%s server stopped during startup: %w", transport, serverErr) + default: + } + pingCtx, cancelPing := context.WithTimeout(ctx, time.Second) + ping, pingErr := client.PingCtx(pingCtx) + cancelPing() + if pingErr == nil { + if err := validatePing(transport, ping, expectedDriverBuildIdentity); err != nil { + cancelServer() + <-serverDone + return nil, err + } + return &latencyServerSession{cancel: cancelServer, done: serverDone, client: client, ping: ping}, nil + } + lastError = pingErr + timer := time.NewTimer(100 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + cancelServer() + <-serverDone + return nil, ctx.Err() + case <-timer.C: + } + } + cancelServer() + <-serverDone + return nil, fmt.Errorf("authenticated %s API did not become ready: %v", transport, lastError) +} + +func (session *latencyServerSession) close() error { + session.cancel() + select { + case err := <-session.done: + if err == nil || errors.Is(err, context.Canceled) { + return nil + } + return err + case <-time.After(15 * time.Second): + return errors.New("server shutdown timed out") + } +} + +func validatePing(transport string, ping *viipertypes.PingResponse, + expectedDriverBuildIdentity string, +) error { + if ping == nil || ping.Server != "VIIPER" || ping.Transport != transport || + ping.Version == "" || ping.Ready == nil || !*ping.Ready { + return fmt.Errorf("authenticated ping does not prove live %s transport: %+v", transport, ping) + } + if transport == latency.TransportNativeUDE { + if ping.NativeUDE == nil || ping.NativeUDE.ABIMajor == 0 || + ping.NativeUDE.ExpectedDriverPackageVersion == "" || + ping.NativeUDE.LoadedDriverBuildIdentity == "" || + ping.NativeUDE.LoadedDriverBuildIdentity != expectedDriverBuildIdentity { + return fmt.Errorf("authenticated ping lacks native ABI/package proof: %+v", ping) + } + } else if ping.NativeUDE != nil { + return fmt.Errorf("USB/IP ping returned contradictory native proof: %+v", ping.NativeUDE) + } + return nil +} + +func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { + ready := true + expected := strings.Repeat("a", 64) + ping := &viipertypes.PingResponse{ + Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, + Ready: &ready, + NativeUDE: &viipertypes.NativeUDEInfo{ + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.25", + LoadedDriverBuildIdentity: expected, + }, + } + if err := validatePing(latency.TransportNativeUDE, ping, expected); err != nil { + t.Fatalf("matching negotiated identity was rejected: %v", err) + } + if err := validatePing(latency.TransportNativeUDE, ping, strings.Repeat("b", 64)); err == nil { + t.Fatal("mismatched negotiated identity was accepted before the native workload") + } + ping.NativeUDE.LoadedDriverBuildIdentity = "" + if err := validatePing(latency.TransportNativeUDE, ping, expected); err == nil { + t.Fatal("absent negotiated identity was accepted before the native workload") + } +} + +func serverProof(ping *viipertypes.PingResponse) latency.ServerProof { + proof := latency.ServerProof{ + Server: ping.Server, Version: ping.Version, Transport: ping.Transport, + Ready: ping.Ready != nil && *ping.Ready, + } + if ping.NativeUDE != nil { + proof.NativeUDE = &latency.NativeServerProof{ + ABIMajor: ping.NativeUDE.ABIMajor, ABIMinor: ping.NativeUDE.ABIMinor, + Capabilities: ping.NativeUDE.Capabilities, + ExpectedDriverPackageVersion: ping.NativeUDE.ExpectedDriverPackageVersion, + LoadedDriverBuildIdentity: ping.NativeUDE.LoadedDriverBuildIdentity, + } + } + return proof +} + +func snapshotGamepadIDs() ([]sdl.GamepadID, error) { + sdl.UpdateGamepads() + ids, err := sdl.GetGamepads() + if err != nil { + return nil, err + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids, nil +} + +func discoverExactNewGamepad(ctx context.Context, baseline []sdl.GamepadID, timeout time.Duration) (sdl.GamepadID, error) { + baselineSet := make(map[sdl.GamepadID]struct{}, len(baseline)) + for _, id := range baseline { + baselineSet[id] = struct{}{} + } + deadline := time.Now().Add(timeout) + var candidate sdl.GamepadID + var stableSince time.Time + for time.Now().Before(deadline) { + current, err := snapshotGamepadIDs() + if err != nil { + return 0, err + } + currentSet := make(map[sdl.GamepadID]struct{}, len(current)) + for _, id := range current { + currentSet[id] = struct{}{} + } + for id := range baselineSet { + if _, ok := currentSet[id]; !ok { + return 0, fmt.Errorf("baseline SDL gamepad %d disappeared during source discovery", id) + } + } + added := make([]sdl.GamepadID, 0, 2) + for _, id := range current { + if _, exists := baselineSet[id]; !exists { + added = append(added, id) + } + } + if len(added) > 1 { + return 0, fmt.Errorf("source discovery is ambiguous: new SDL gamepads=%v", added) + } + if len(added) == 1 { + if candidate != added[0] { + candidate = added[0] + stableSince = time.Now() + } else if time.Since(stableSince) >= 250*time.Millisecond { + return candidate, nil + } + } else { + candidate = 0 + stableSince = time.Time{} + } + timer := time.NewTimer(25 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return 0, ctx.Err() + case <-timer.C: + } + } + return 0, errors.New("timed out waiting for exactly one stable newly enumerated SDL gamepad") +} + +func controllerProof( + baseline []int32, + gamepad *sdl.Gamepad, +) latency.ControllerProof { + return latency.ControllerProof{ + BaselineGamepadIDs: baseline, + NewGamepadIDs: []int32{int32(gamepad.ID())}, + SDLInstanceID: int32(gamepad.ID()), + SDLPath: gamepad.Path(), + SDLGUID: sdl.GetGamepadGUIDForID(gamepad.ID()).String(), + SDLName: gamepad.Name(), + SDLType: sdlGamepadTypeName(gamepad.RealType()), + SDLReportedType: int32(gamepad.Type()), + SDLRealType: int32(gamepad.RealType()), + VendorID: gamepad.Vendor(), + ProductID: gamepad.Product(), + } +} + +func validateControllerProof(proof latency.ControllerProof, controller liveControllerWorkload) error { + if proof.SDLInstanceID == 0 || proof.SDLPath == "" || proof.SDLGUID == "" || proof.SDLName == "" { + return fmt.Errorf("new SDL gamepad identity is incomplete: %+v", proof) + } + if proof.SDLType != controller.sdlType || + proof.VendorID != controller.vendorID || proof.ProductID != controller.productID || + sdl.GamepadType(proof.SDLRealType) != controller.sdlRealType { + return fmt.Errorf("new SDL gamepad is not the API-created %s: %+v", controller.apiType, proof) + } + return nil +} + +func sdlGamepadTypeName(gamepadType sdl.GamepadType) string { + switch gamepadType { + case sdl.GamepadTypeXbox360: + return "xbox360" + case sdl.GamepadTypePS4: + return "ps4" + case sdl.GamepadTypePS5: + return "ps5" + default: + return fmt.Sprintf("unknown(%d)", gamepadType) + } +} + +func settleNeutralController( + gamepad *sdl.Gamepad, + stream *viiperclient.DeviceStream, + neutral encoding.BinaryMarshaler, +) (uint64, error) { + if err := stream.SetWriteDeadline(time.Now().Add(liveLatencyTransitionTimeout)); err != nil { + return 0, err + } + if err := stream.WriteBinary(neutral); err != nil { + return 0, err + } + observedDown := gamepad.GetButton(sdl.GamepadButtonSouth) + quietDeadline := time.Now().Add(liveLatencySourceQuiet) + overallDeadline := time.Now().Add(2 * time.Second) + var lastTimestamp uint64 + for time.Now().Before(overallDeadline) { + remaining := time.Until(quietDeadline) + if remaining <= 0 { + if observedDown || gamepad.GetButton(sdl.GamepadButtonSouth) { + return 0, errors.New("controller remained pressed after neutral synchronization") + } + return lastTimestamp, nil + } + event, received, err := gamepad.WaitButtonTransition( + sdl.GamepadButtonSouth, durationMillisecondsCeiling(remaining)) + if err != nil { + return 0, err + } + if !received { + continue + } + if event.TimestampNS == 0 || (lastTimestamp != 0 && event.TimestampNS < lastTimestamp) { + return 0, errors.New("SDL event timestamp was absent or regressed during source synchronization") + } + lastTimestamp = event.TimestampNS + observedDown = event.Down + quietDeadline = time.Now().Add(liveLatencySourceQuiet) + } + return 0, errors.New("SDL source did not reach a quiet released state") +} + +func warmControllerPath( + gamepad *sdl.Gamepad, + stream *viiperclient.DeviceStream, + controller liveControllerWorkload, + lastTimestamp uint64, + qpcFrequency int64, +) (uint64, error) { + observedDown := false + warmup := latency.Run{} + var err error + for sequence := 1; sequence <= latency.ProductionWarmupPairs; sequence++ { + lastTimestamp, err = waitForCausalDwell(gamepad, sequence, latency.TransitionPress, + &observedDown, lastTimestamp, &warmup) + if err != nil { + return lastTimestamp, err + } + lastTimestamp, err = measureTransition( + gamepad, stream, sequence, latency.TransitionPress, true, + controller.state(true), &observedDown, lastTimestamp, &warmup, + "", "", 0, nil, qpcFrequency) + if err != nil { + return lastTimestamp, err + } + lastTimestamp, err = waitForCausalDwell(gamepad, sequence, latency.TransitionRelease, + &observedDown, lastTimestamp, &warmup) + if err != nil { + return lastTimestamp, err + } + lastTimestamp, err = measureTransition( + gamepad, stream, sequence, latency.TransitionRelease, false, + controller.state(false), &observedDown, lastTimestamp, &warmup, + "", "", 0, nil, qpcFrequency) + if err != nil { + return lastTimestamp, err + } + } + if err = observeDuplicateQuietWindow(gamepad, lastTimestamp, &observedDown, &warmup); err != nil { + return lastTimestamp, err + } + if warmup.Misses.Total() != 0 || warmup.Duplicates.Total() != 0 { + return lastTimestamp, fmt.Errorf( + "warmup observed misses=%d duplicates=%d", warmup.Misses.Total(), warmup.Duplicates.Total()) + } + if observedDown || gamepad.GetButton(sdl.GamepadButtonSouth) { + return lastTimestamp, errors.New("warmup did not finish in the commanded released state") + } + return lastTimestamp, nil +} + +func waitForCausalDwell( + gamepad *sdl.Gamepad, + sequence int, + transition latency.Transition, + observedDown *bool, + lastTimestamp uint64, + result *latency.Run, +) (uint64, error) { + delay := liveLatencyTransitionDelay + + time.Duration(latency.ProductionPhaseOffsetNS(sequence, transition)) + deadline := time.Now().Add(delay) + for { + remaining := time.Until(deadline) + if remaining <= 0 { + break + } + if remaining < time.Millisecond { + time.Sleep(remaining) + continue + } + event, received, err := gamepad.WaitButtonTransition( + sdl.GamepadButtonSouth, int32(remaining/time.Millisecond)) + if err != nil { + return lastTimestamp, fmt.Errorf("%s sample %d SDL causal dwell: %w", transition, sequence, err) + } + if !received { + continue + } + updatedTimestamp, rejection := latency.RejectPreWriteEdge( + lastTimestamp, event.TimestampNS, event.Down, &result.Duplicates) + lastTimestamp = updatedTimestamp + *observedDown = event.Down + return lastTimestamp, fmt.Errorf("%s sample %d pre-write dwell: %w", transition, sequence, rejection) + } + if gamepad.GetButton(sdl.GamepadButtonSouth) != *observedDown { + return lastTimestamp, fmt.Errorf("%s sample %d SDL state changed without an observed edge during pre-write dwell", transition, sequence) + } + return lastTimestamp, nil +} + +func measureTransition( + gamepad *sdl.Gamepad, + stream *viiperclient.DeviceStream, + sequence int, + transition latency.Transition, + wantDown bool, + inputState encoding.BinaryMarshaler, + observedDown *bool, + lastTimestamp uint64, + result *latency.Run, + controllerType string, + transport string, + transportBlock int, + traceProvider *latencytrace.Provider, + qpcFrequency int64, +) (uint64, error) { + if *observedDown == wantDown { + return lastTimestamp, fmt.Errorf("%s sample %d started from the wrong observed state", transition, sequence) + } + if err := stream.SetWriteDeadline(time.Now().Add(liveLatencyTransitionTimeout)); err != nil { + return lastTimestamp, err + } + started := time.Now() + for { + event, received, err := gamepad.PollButtonTransition(sdl.GamepadButtonSouth) + if err != nil { + return lastTimestamp, fmt.Errorf("%s sample %d pre-write SDL drain: %w", transition, sequence, err) + } + if !received { + break + } + updatedTimestamp, rejection := latency.RejectPreWriteEdge( + lastTimestamp, event.TimestampNS, event.Down, &result.Duplicates) + lastTimestamp = updatedTimestamp + *observedDown = event.Down + return lastTimestamp, fmt.Errorf("%s sample %d final pre-write queue drain: %w", transition, sequence, rejection) + } + if gamepad.GetButton(sdl.GamepadButtonSouth) != *observedDown { + return lastTimestamp, fmt.Errorf("%s sample %d SDL state changed before its input write", transition, sequence) + } + startQPC, err := latencytrace.Counter() + if err != nil { + return lastTimestamp, fmt.Errorf("%s sample %d query pre-write QPC: %w", transition, sequence, err) + } + // Keep the SDL clock admission fence adjacent to WriteBinary. There is no + // cross-process primitive that can make these two calls atomic; requiring + // the observed event timestamp to be strictly newer closes same-tick stale + // edges and leaves only this irreducible function-call boundary. + sdlFenceTimestamp := sdl.TicksNS() + if sdlFenceTimestamp == 0 { + return lastTimestamp, fmt.Errorf("%s sample %d could not establish its SDL pre-write timestamp fence", transition, sequence) + } + if err := stream.WriteBinary(inputState); err != nil { + return lastTimestamp, fmt.Errorf("%s sample %d authenticated WriteBinary: %w", transition, sequence, err) + } + deadline := started.Add(liveLatencyTransitionTimeout) + for { + remaining := time.Until(deadline) + if remaining <= 0 { + incrementTransitionCounter(&result.Misses, transition) + return lastTimestamp, fmt.Errorf("%s sample %d timed out after %s", transition, sequence, + liveLatencyTransitionTimeout) + } + event, received, err := gamepad.WaitButtonTransition( + sdl.GamepadButtonSouth, durationMillisecondsCeiling(remaining)) + if err != nil { + return lastTimestamp, fmt.Errorf("%s sample %d SDL event wait: %w", transition, sequence, err) + } + if !received { + incrementTransitionCounter(&result.Misses, transition) + return lastTimestamp, fmt.Errorf("%s sample %d timed out after %s", transition, sequence, + liveLatencyTransitionTimeout) + } + if timestampErr := latency.ValidatePostWriteTimestamp( + lastTimestamp, sdlFenceTimestamp, event.TimestampNS); timestampErr != nil { + if event.TimestampNS != 0 && (lastTimestamp == 0 || event.TimestampNS >= lastTimestamp) && + event.TimestampNS <= sdlFenceTimestamp { + incrementEdgeCounter(&result.Duplicates, event.Down) + } + return lastTimestamp, fmt.Errorf("%s sample %d SDL timestamp fence: %w", + transition, sequence, timestampErr) + } + lastTimestamp = event.TimestampNS + if event.Down == *observedDown { + incrementEdgeCounter(&result.Duplicates, event.Down) + continue + } + if event.Down != wantDown { + incrementEdgeCounter(&result.Duplicates, event.Down) + *observedDown = event.Down + continue + } + *observedDown = event.Down + endQPC, qpcErr := latencytrace.Counter() + if qpcErr != nil { + return lastTimestamp, fmt.Errorf("%s sample %d query observed-edge QPC: %w", transition, sequence, qpcErr) + } + latencyNS, qpcErr := latency.QPCIntervalNS(startQPC, endQPC, qpcFrequency) + if qpcErr != nil { + return lastTimestamp, fmt.Errorf("%s sample %d convert observed QPC interval: %w", transition, sequence, qpcErr) + } + sample := latency.Sample{ + Sequence: sequence, Transition: transition, LatencyNS: latencyNS, + EventTimestampNS: event.TimestampNS, SDLFenceTimestampNS: sdlFenceTimestamp, + StartQPCTicks: startQPC, EndQPCTicks: endQPC, + } + if traceProvider != nil { + sample.MarkerID = latency.SampleMarkerID(controllerType, transport, transportBlock, sequence, transition) + sample.MarkerQPCTicks, err = latencytrace.Counter() + if err != nil { + return lastTimestamp, fmt.Errorf("%s sample %d query pre-marker QPC: %w", transition, sequence, err) + } + if err = traceProvider.WriteSample(controllerType, transport, transportBlock, sample); err != nil { + return lastTimestamp, fmt.Errorf("%s sample %d TraceLogging marker: %w", transition, sequence, err) + } + } + result.Samples = append(result.Samples, sample) + return lastTimestamp, nil + } +} + +func observeDuplicateQuietWindow( + gamepad *sdl.Gamepad, + lastTimestamp uint64, + observedDown *bool, + result *latency.Run, +) error { + deadline := time.Now().Add(liveLatencyDuplicateQuiet) + for time.Now().Before(deadline) { + event, received, err := gamepad.WaitButtonTransition( + sdl.GamepadButtonSouth, durationMillisecondsCeiling(time.Until(deadline))) + if err != nil { + return err + } + if !received { + return nil + } + if event.TimestampNS == 0 || event.TimestampNS < lastTimestamp { + return errors.New("SDL event timestamp was absent or regressed in duplicate quiet window") + } + lastTimestamp = event.TimestampNS + incrementEdgeCounter(&result.Duplicates, event.Down) + *observedDown = event.Down + } + return nil +} + +func incrementTransitionCounter(counters *latency.Counters, transition latency.Transition) { + if transition == latency.TransitionPress { + counters.Press++ + } else { + counters.Release++ + } +} + +func incrementEdgeCounter(counters *latency.Counters, down bool) { + if down { + counters.Press++ + } else { + counters.Release++ + } +} + +func durationMillisecondsCeiling(duration time.Duration) int32 { + if duration <= 0 { + return 1 + } + milliseconds := (duration + time.Millisecond - 1) / time.Millisecond + if milliseconds > math.MaxInt32 { + return math.MaxInt32 + } + return int32(milliseconds) +} + +func waitForGamepadRemoval(id sdl.GamepadID, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + ids, err := snapshotGamepadIDs() + if err != nil { + return err + } + present := false + for _, candidate := range ids { + if candidate == id { + present = true + break + } + } + if !present { + return nil + } + time.Sleep(25 * time.Millisecond) + } + return fmt.Errorf("gamepad %d still present after %s", id, timeout) +} + +func gamepadIDsAsInt32(ids []sdl.GamepadID) []int32 { + result := make([]int32, len(ids)) + for index, id := range ids { + result[index] = int32(id) + } + return result +} + +func appendLatencyFailure(run *latency.Run, format string, arguments ...any) { + message := fmt.Sprintf(format, arguments...) + if run.Failure == "" { + run.Failure = message + } else { + run.Failure += "; " + message + } +} + +func runGit(executable, directory string, arguments ...string) (string, error) { + command := exec.Command(executable, append([]string{"-C", directory}, arguments...)...) + output, err := command.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git %s: %w: %s", strings.Join(arguments, " "), err, + strings.TrimSpace(string(output))) + } + return strings.TrimRight(string(output), "\r\n"), nil +} + +func canonicalPath(path string) (string, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(absolute) + if err != nil { + return "", err + } + return filepath.Clean(resolved), nil +} + +func loadedModulePath(name string) (string, error) { + moduleName, err := windows.UTF16PtrFromString(name) + if err != nil { + return "", err + } + var module windows.Handle + if err = windows.GetModuleHandleEx( + windows.GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + moduleName, + &module, + ); err != nil { + return "", fmt.Errorf("GetModuleHandleEx(%s): %w", name, err) + } + buffer := make([]uint16, 32768) + length, err := windows.GetModuleFileName(module, &buffer[0], uint32(len(buffer))) + if err != nil { + return "", fmt.Errorf("GetModuleFileName(%s): %w", name, err) + } + if length == 0 || int(length) >= len(buffer) { + return "", fmt.Errorf("GetModuleFileName(%s) returned invalid length %d", name, length) + } + return windows.UTF16ToString(buffer[:length]), nil +} + +func fileSHA256(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + digest := sha256.New() + if _, err = io.Copy(digest, file); err != nil { + return "", err + } + return hex.EncodeToString(digest.Sum(nil)), nil +} + +func writeLatencyReportExclusive(path string, report *latency.SuiteReport) (err error) { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + complete := false + defer func() { + if closeErr := file.Close(); err == nil && closeErr != nil { + err = closeErr + } + if !complete { + _ = os.Remove(path) + } + }() + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err = encoder.Encode(report); err != nil { + return err + } + if err = file.Sync(); err != nil { + return err + } + complete = true + return nil +} diff --git a/_testing/e2e/pnp_path_windows_test.go b/_testing/e2e/pnp_path_windows_test.go new file mode 100644 index 00000000..8d41fb60 --- /dev/null +++ b/_testing/e2e/pnp_path_windows_test.go @@ -0,0 +1,119 @@ +//go:build windows + +package e2e_bench_test + +import ( + "os" + "strings" + "testing" + + "github.com/Alia5/VIIPER/_testing/e2e/latency" +) + +func TestPnPInstanceIDFromSDLPathFailsClosed(t *testing.T) { + want := `HID\VID_045E&PID_028E&IG_00\7&ABC&0&0000` + got, err := pnpInstanceIDFromSDLPath( + `\\?\hid#vid_045e&pid_028e&ig_00#7&abc&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}`) + if err != nil || got != want { + t.Fatalf("instance=%q error=%v, want %q", got, err, want) + } + for _, invalid := range []string{"", `HID\VID_045E`, `XInput#0`, `\\?\USB#VID_045E#1#{guid}`} { + if got, err = pnpInstanceIDFromSDLPath(invalid); err == nil { + t.Fatalf("invalid SDL path %q returned %q", invalid, got) + } + } +} + +func TestPinnedSDLXboxPathRequiresRawInputForPnPIdentity(t *testing.T) { + rawInputSource, err := os.ReadFile("deps/SDL/src/joystick/windows/SDL_rawinputjoystick.c") + if err != nil { + t.Fatal(err) + } + xinputSource, err := os.ReadFile("deps/SDL/src/joystick/windows/SDL_xinputjoystick.c") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(rawInputSource), + "SDL_GetHintBoolean(SDL_HINT_JOYSTICK_RAWINPUT, false)") { + t.Fatal("pinned SDL RawInput default changed; re-audit the exact Xbox PnP binding") + } + if !strings.Contains(string(xinputSource), `"XInput#%u"`) { + t.Fatal("pinned SDL XInput path contract changed; re-audit the exact Xbox PnP binding") + } +} + +func TestAppendPnPAncestryRequiresCompleteUnambiguousRootChain(t *testing.T) { + const ( + hidID = `HID\VID_045E&PID_028E\1` + usbID = `USB\VID_045E&PID_028E\1` + anchorID = `ROOT\USB\0002` + rootID = `HTREE\ROOT\0` + containerID = `{11111111-2222-3333-4444-555555555555}` + ) + valid := map[string]presentDeviceNode{ + hidID: {instanceID: hidID, parentID: usbID, service: "HidUsb", containerID: containerID}, + usbID: {instanceID: usbID, parentID: anchorID, service: "usbccgp", containerID: containerID, locationPaths: []string{`USBROOT(0)#USB(7)`}}, + anchorID: {instanceID: anchorID, parentID: rootID, service: "usbip2_ude", hardwareIDs: []string{`ROOT\USBIP_WIN2\UDE`}}, + rootID: {instanceID: rootID}, + } + proof := latency.ControllerProof{PNPInstanceID: hidID, PNPContainerID: containerID} + if err := appendPnPAncestry(valid, hidID, latency.TransportUSBIP, &proof); err != nil { + t.Fatal(err) + } + if err := latency.ValidateTransportAncestry(latency.TransportUSBIP, 7, proof); err != nil { + t.Fatalf("valid full USB/IP ancestry was rejected: %v", err) + } + if got := proof.PNPAncestorIDs[len(proof.PNPAncestorIDs)-1]; got != rootID { + t.Fatalf("ancestry ended at %q, want %q", got, rootID) + } + + t.Run("truncated", func(t *testing.T) { + nodes := clonePresentNodes(valid) + delete(nodes, rootID) + if err := appendPnPAncestry(nodes, hidID, latency.TransportUSBIP, + &latency.ControllerProof{PNPInstanceID: hidID}); err == nil { + t.Fatal("truncated PnP ancestry was accepted") + } + }) + + t.Run("cycle", func(t *testing.T) { + nodes := clonePresentNodes(valid) + root := nodes[rootID] + root.parentID = usbID + nodes[rootID] = root + if err := appendPnPAncestry(nodes, hidID, latency.TransportUSBIP, + &latency.ControllerProof{PNPInstanceID: hidID}); err == nil || + !strings.Contains(err.Error(), "cycle") { + t.Fatalf("cyclic PnP ancestry error=%v", err) + } + }) + + t.Run("nested spoof anchor", func(t *testing.T) { + const spoofID = `ROOT\USB\0001` + nodes := clonePresentNodes(valid) + usb := nodes[usbID] + usb.parentID = spoofID + nodes[usbID] = usb + nodes[spoofID] = presentDeviceNode{ + instanceID: spoofID, parentID: anchorID, service: "usbip2_ude", + hardwareIDs: []string{`ROOT\USBIP_WIN2\UDE`}, + } + candidate := latency.ControllerProof{PNPInstanceID: hidID, PNPContainerID: containerID} + if err := appendPnPAncestry(nodes, hidID, latency.TransportUSBIP, &candidate); err != nil { + t.Fatal(err) + } + if err := latency.ValidateTransportAncestry(latency.TransportUSBIP, 7, candidate); err == nil { + t.Fatal("nested spoof transport anchor was accepted") + } + }) +} + +func clonePresentNodes(source map[string]presentDeviceNode) map[string]presentDeviceNode { + result := make(map[string]presentDeviceNode, len(source)) + for key, node := range source { + node.hardwareIDs = append([]string(nil), node.hardwareIDs...) + node.locationPaths = append([]string(nil), node.locationPaths...) + result[key] = node + } + return result +} diff --git a/_testing/e2e/pnp_windows_test.go b/_testing/e2e/pnp_windows_test.go new file mode 100644 index 00000000..0022ed62 --- /dev/null +++ b/_testing/e2e/pnp_windows_test.go @@ -0,0 +1,190 @@ +//go:build windows + +package e2e_bench_test + +import ( + "errors" + "fmt" + "strings" + + "github.com/Alia5/VIIPER/_testing/e2e/latency" + "golang.org/x/sys/windows" +) + +var devPropKeyDeviceParent = windows.DEVPROPKEY{ + FmtID: windows.DEVPROPGUID(windows.GUID{ + Data1: 0x4340a6c5, Data2: 0x93fa, Data3: 0x4706, + Data4: [8]byte{0x97, 0x2c, 0x7b, 0x64, 0x80, 0x08, 0xa5, 0xa7}, + }), + PID: 8, +} + +type presentDeviceNode struct { + instanceID string + parentID string + containerID string + service string + hardwareIDs []string + locationInfo string + locationPaths []string +} + +func pnpInstanceIDFromSDLPath(path string) (string, error) { + trimmed := strings.TrimPrefix(path, `\\?\`) + parts := strings.Split(trimmed, "#") + if len(parts) < 4 || !strings.EqualFold(parts[0], "HID") || + parts[1] == "" || parts[2] == "" || !strings.HasPrefix(parts[len(parts)-1], "{") { + return "", fmt.Errorf("SDL HID interface path has no exact PnP instance identity: %q", path) + } + return strings.ToUpper(strings.Join(parts[:3], `\`)), nil +} + +func bindControllerPnP(proof *latency.ControllerProof, transport string, usbipPort int32) error { + if proof == nil { + return errors.New("nil controller proof") + } + instanceID, err := pnpInstanceIDFromSDLPath(proof.SDLPath) + if err != nil { + return err + } + deviceSet, err := windows.SetupDiGetClassDevsEx(nil, "", 0, + windows.DIGCF_PRESENT|windows.DIGCF_ALLCLASSES, 0, "") + if err != nil { + return fmt.Errorf("enumerate present Windows PnP devices: %w", err) + } + defer deviceSet.Close() + + nodes := make(map[string]presentDeviceNode) + for index := 0; ; index++ { + info, enumErr := windows.SetupDiEnumDeviceInfo(deviceSet, index) + if errors.Is(enumErr, windows.ERROR_NO_MORE_ITEMS) { + break + } + if enumErr != nil { + return fmt.Errorf("enumerate present PnP device %d: %w", index, enumErr) + } + id, idErr := windows.SetupDiGetDeviceInstanceId(deviceSet, info) + if idErr != nil { + return fmt.Errorf("read PnP instance ID %d: %w", index, idErr) + } + node := presentDeviceNode{instanceID: strings.ToUpper(id)} + if value, propertyErr := windows.SetupDiGetDeviceProperty(deviceSet, info, + &devPropKeyDeviceParent); propertyErr == nil { + parentID, valid := value.(string) + if !valid { + return fmt.Errorf("PnP parent property for %q is not a string", node.instanceID) + } + node.parentID = strings.ToUpper(parentID) + } + if value, propertyErr := windows.SetupDiGetDeviceRegistryProperty(deviceSet, info, windows.SPDRP_SERVICE); propertyErr == nil { + node.service, _ = value.(string) + } + if value, propertyErr := windows.SetupDiGetDeviceRegistryProperty( + deviceSet, info, windows.SPDRP_BASE_CONTAINERID); propertyErr == nil { + containerID, valid := value.(string) + if !valid { + return fmt.Errorf("PnP container property for %q is not a string", node.instanceID) + } + containerGUID, guidErr := windows.GUIDFromString(containerID) + if guidErr != nil { + return fmt.Errorf("PnP container property for %q is malformed: %w", node.instanceID, guidErr) + } + node.containerID = containerGUID.String() + } else if !errors.Is(propertyErr, windows.ERROR_INVALID_DATA) && + !errors.Is(propertyErr, windows.ERROR_NOT_FOUND) { + return fmt.Errorf("read PnP container property for %q: %w", node.instanceID, propertyErr) + } + if value, propertyErr := windows.SetupDiGetDeviceRegistryProperty(deviceSet, info, windows.SPDRP_HARDWAREID); propertyErr == nil { + switch typed := value.(type) { + case []string: + node.hardwareIDs = append([]string(nil), typed...) + case string: + node.hardwareIDs = []string{typed} + } + } + if value, propertyErr := windows.SetupDiGetDeviceRegistryProperty(deviceSet, info, windows.SPDRP_LOCATION_INFORMATION); propertyErr == nil { + node.locationInfo, _ = value.(string) + } + if value, propertyErr := windows.SetupDiGetDeviceRegistryProperty(deviceSet, info, windows.SPDRP_LOCATION_PATHS); propertyErr == nil { + switch typed := value.(type) { + case []string: + node.locationPaths = append([]string(nil), typed...) + case string: + node.locationPaths = []string{typed} + } + } + nodes[node.instanceID] = node + } + controllerNode, present := nodes[instanceID] + if !present { + return fmt.Errorf("SDL interface instance %q is not a present Windows PnP devnode", instanceID) + } + if controllerNode.containerID == "" { + return fmt.Errorf("SDL interface instance %q has no exact PnP container identity", instanceID) + } + + proof.PNPInstanceID = instanceID + proof.PNPContainerID = controllerNode.containerID + if err := appendPnPAncestry(nodes, instanceID, transport, proof); err != nil { + return err + } + if err := latency.ValidateTransportAncestry(transport, usbipPort, *proof); err != nil { + return fmt.Errorf("bind SDL path %q to %s transport: %w", proof.SDLPath, transport, err) + } + return nil +} + +func appendPnPAncestry(nodes map[string]presentDeviceNode, startID, transport string, + proof *latency.ControllerProof, +) error { + seen := make(map[string]struct{}) + current := strings.ToUpper(startID) + for depth := 0; depth < 64; depth++ { + if _, duplicate := seen[current]; duplicate { + return errors.New("Windows PnP ancestry contains a cycle") + } + seen[current] = struct{}{} + node, exists := nodes[current] + if !exists { + return fmt.Errorf("PnP ancestor %q is absent from the present-device snapshot", current) + } + proof.PNPAncestorIDs = append(proof.PNPAncestorIDs, node.instanceID) + proof.PNPAncestorContainerIDs = append(proof.PNPAncestorContainerIDs, node.containerID) + proof.PNPAncestorServices = append(proof.PNPAncestorServices, node.service) + proof.PNPAncestorHardwareIDs = append(proof.PNPAncestorHardwareIDs, + append([]string(nil), node.hardwareIDs...)) + proof.PNPAncestorLocationInfo = append(proof.PNPAncestorLocationInfo, node.locationInfo) + proof.PNPAncestorLocationPaths = append(proof.PNPAncestorLocationPaths, + append([]string(nil), node.locationPaths...)) + + if transport == latency.TransportNativeUDE && + strings.EqualFold(node.service, "ViiperUde") && + containsFoldE2E(node.hardwareIDs, `ROOT\VIIPER\UDE`) { + proof.TransportAnchorInstanceID = node.instanceID + proof.TransportAnchorService = node.service + } + if transport == latency.TransportUSBIP && + strings.EqualFold(node.service, "usbip2_ude") && + containsFoldE2E(node.hardwareIDs, `ROOT\USBIP_WIN2\UDE`) { + proof.TransportAnchorInstanceID = node.instanceID + proof.TransportAnchorService = node.service + } + if node.parentID == "" { + if !strings.EqualFold(node.instanceID, `HTREE\ROOT\0`) { + return fmt.Errorf("PnP ancestry ended at %q instead of HTREE\\ROOT\\0", node.instanceID) + } + return nil + } + current = strings.ToUpper(node.parentID) + } + return errors.New("Windows PnP ancestry exceeded the 64-node safety bound") +} + +func containsFoldE2E(values []string, want string) bool { + for _, value := range values { + if strings.EqualFold(value, want) { + return true + } + } + return false +} diff --git a/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 b/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 new file mode 100644 index 00000000..d27d96ec --- /dev/null +++ b/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 @@ -0,0 +1,567 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$SignedPackageDirectory, + + [Parameter(Mandatory = $true)] + [string]$SubmissionManifestPath, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] + [string]$ExpectedSourceRevision, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{64}$')] + [string]$SDLBinarySHA256, + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [Parameter(Mandatory = $true)] + [string]$WprTracePath, + + [ValidateRange(256, 10000)] + [int]$Samples = 256, + + [ValidateSet('Normal', 'High')] + [string]$PriorityClass = 'Normal', + + [string]$RepositoryRoot, + + [Parameter(Mandatory = $true)] + [string]$GitExecutable, + + [Parameter(Mandatory = $true)] + [string]$GoExecutable +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Test-IsAdministrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Resolve-CanonicalPath { + param([Parameter(Mandatory = $true)][string]$Path) + + return (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path +} + +function Resolve-ExactExecutablePath { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Label + ) + + if (-not [IO.Path]::IsPathFullyQualified($Path)) { + throw "$Label must be supplied as an absolute path; PATH lookup is forbidden." + } + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -le 0) { + throw "$Label is not a non-empty regular executable: '$Path'." + } + return $item.FullName +} + +function Resolve-NewEvidencePath { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Repository, + [Parameter(Mandatory = $true)][string]$Label + ) + + $full = [IO.Path]::GetFullPath($Path) + if (Test-Path -LiteralPath $full) { + throw "$Label already exists; refusing to overwrite source-bound evidence: '$full'." + } + $parent = Split-Path -Parent $full + if (-not (Test-Path -LiteralPath $parent -PathType Container)) { + throw "$Label parent directory must already exist: '$parent'." + } + $repoPrefix = $Repository.TrimEnd('\') + '\' + if ($full.StartsWith($repoPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "$Label must be outside the source checkout so the measured tree remains clean: '$full'." + } + return $full +} + +function Resolve-DriverImagePath { + param([Parameter(Mandatory = $true)][string]$ImagePath) + + $path = [Environment]::ExpandEnvironmentVariables($ImagePath.Trim().Trim('"')) + if ($path.StartsWith('\??\', [StringComparison]::Ordinal)) { + $path = $path.Substring(4) + } + if ($path.StartsWith('\SystemRoot\', [StringComparison]::OrdinalIgnoreCase)) { + $path = Join-Path $env:SystemRoot $path.Substring('\SystemRoot\'.Length) + } + elseif ($path.StartsWith('System32\', [StringComparison]::OrdinalIgnoreCase)) { + $path = Join-Path $env:SystemRoot $path + } + if (-not [IO.Path]::IsPathRooted($path)) { + throw "VIIPER UDE has an unsupported relative service image path: '$ImagePath'." + } + return Resolve-CanonicalPath -Path $path +} + +if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) { + $RepositoryRoot = Join-Path $PSScriptRoot '..\..\..' +} +if (-not (Test-IsAdministrator)) { + throw 'The source-bound latency gate and WPR capture require an elevated PowerShell session.' +} +$repository = Resolve-CanonicalPath -Path $RepositoryRoot +$gitPath = Resolve-ExactExecutablePath -Path $GitExecutable -Label 'Git executable' +$git = [pscustomobject]@{ Source = $gitPath } +$gitHash = (Get-FileHash -LiteralPath $gitPath -Algorithm SHA256).Hash.ToLowerInvariant() +$headOutput = @(& $git.Source -C $repository rev-parse --verify HEAD 2>&1) +if ($LASTEXITCODE -ne 0 -or $headOutput.Count -eq 0) { + throw "The production latency harness is not an exact Git checkout.`n$($headOutput -join [Environment]::NewLine)" +} +$headRevision = ([string]$headOutput[0]).Trim().ToLowerInvariant() +if (-not [string]::Equals($headRevision, $ExpectedSourceRevision, + [StringComparison]::OrdinalIgnoreCase)) { + throw "The production latency harness is source '$headRevision', not '$ExpectedSourceRevision'." +} +$treeStatus = @(& $git.Source -C $repository status --porcelain=v1 --untracked-files=all 2>&1) +if ($LASTEXITCODE -ne 0) { + throw "Could not verify the production latency source tree.`n$($treeStatus -join [Environment]::NewLine)" +} +if ($treeStatus.Count -ne 0) { + throw ("The production latency source tree is not clean; refusing unreviewed test code or data:`n" + + ($treeStatus -join [Environment]::NewLine)) +} +$submoduleStatus = @(& $git.Source -C $repository submodule status --recursive 2>&1) +if ($LASTEXITCODE -ne 0 -or @($submoduleStatus | Where-Object { $_ -match '^[\-+U]' }).Count -ne 0) { + throw "The production latency source tree has an unbound submodule state.`n$($submoduleStatus -join [Environment]::NewLine)" +} +$sdlRoot = Resolve-CanonicalPath -Path (Join-Path $repository '_testing\e2e\deps\SDL') +$sdlRevisionOutput = @(& $git.Source -C $sdlRoot rev-parse --verify HEAD 2>&1) +if ($LASTEXITCODE -ne 0 -or $sdlRevisionOutput.Count -eq 0) { + throw "Could not bind the SDL source revision.`n$($sdlRevisionOutput -join [Environment]::NewLine)" +} +$sdlRevision = ([string]$sdlRevisionOutput[0]).Trim().ToLowerInvariant() +$sdlDLL = Resolve-CanonicalPath -Path (Join-Path $sdlRoot 'build\Debug\SDL3.dll') +$actualSDLHash = (Get-FileHash -LiteralPath $sdlDLL -Algorithm SHA256).Hash.ToLowerInvariant() +if (-not [string]::Equals($actualSDLHash, $SDLBinarySHA256, + [StringComparison]::OrdinalIgnoreCase)) { + throw "The SDL binary hash is '$actualSDLHash', not the source-build hash '$SDLBinarySHA256'." +} + +$signatureGate = Join-Path $repository 'native\udecx\tools\Test-ViiperUdeSignedPackage.ps1' +$manifest = Resolve-CanonicalPath -Path $SubmissionManifestPath +$manifestHashBeforeGate = (Get-FileHash -LiteralPath $manifest -Algorithm SHA256).Hash.ToLowerInvariant() +& $signatureGate ` + -PackageDirectory $SignedPackageDirectory ` + -SubmissionManifestPath $manifest ` + -ExpectedSourceRevision $ExpectedSourceRevision ` + -ValidationMode Production + +$packageRoot = Resolve-CanonicalPath -Path $SignedPackageDirectory +$packageDriver = Resolve-CanonicalPath -Path (Join-Path $packageRoot 'ViiperUde.sys') +$service = Get-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Services\ViiperUde' -ErrorAction Stop +if ([string]::IsNullOrWhiteSpace([string]$service.ImagePath)) { + throw 'The installed VIIPER UDE service has no ImagePath.' +} +$installedDriver = Resolve-DriverImagePath -ImagePath ([string]$service.ImagePath) +$packageDriverHash = (Get-FileHash -LiteralPath $packageDriver -Algorithm SHA256).Hash.ToLowerInvariant() +$installedDriverHash = (Get-FileHash -LiteralPath $installedDriver -Algorithm SHA256).Hash.ToLowerInvariant() +if ($packageDriverHash -ne $installedDriverHash) { + throw "The installed VIIPER UDE service image does not match the verified package. Installed='$installedDriver'." +} +$ownedRootDevices = @(Get-CimInstance -ClassName Win32_PnPEntity | Where-Object { + @($_.HardwareID) -contains 'ROOT\VIIPER\UDE' +}) +if ($ownedRootDevices.Count -ne 1) { + throw "Expected exactly one VIIPER UDE hardware-ID owner; found $($ownedRootDevices.Count)." +} +$ownedRootInstance = [string]$ownedRootDevices[0].PNPDeviceID +$devnodes = @(Get-CimInstance -ClassName Win32_PnPSignedDriver | Where-Object { + [string]$_.DeviceID -ieq $ownedRootInstance +}) +if ($devnodes.Count -ne 1) { + throw "Expected exactly one VIIPER UDE root devnode; found $($devnodes.Count)." +} +if (-not [bool]$devnodes[0].IsSigned -or [string]$devnodes[0].Signer -notmatch '(?i)Microsoft') { + throw "The installed VIIPER UDE devnode is not backed by a Microsoft-signed driver (Signer='$($devnodes[0].Signer)')." +} +$manifestHash = (Get-FileHash -LiteralPath $manifest -Algorithm SHA256).Hash.ToLowerInvariant() +if ($manifestHash -ne $manifestHashBeforeGate) { + throw 'The native submission manifest changed while its signature/package gate was running.' +} +$manifestDocument = Get-Content -LiteralPath $manifest -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop +$driverBuildIdentity = ([string]$manifestDocument.driverBuildIdentity).Trim().ToLowerInvariant() +if ($driverBuildIdentity -notmatch '^[0-9a-f]{64}$') { + throw 'The verified submission manifest has no canonical native driver build identity.' +} + +$output = Resolve-NewEvidencePath -Path $OutputPath -Repository $repository -Label 'Latency JSON output' +$trace = Resolve-NewEvidencePath -Path $WprTracePath -Repository $repository -Label 'WPR trace output' +$markers = Resolve-NewEvidencePath -Path "$output.etl-markers.json" -Repository $repository -Label 'Decoded ETL marker output' +if ([string]::Equals($output, $trace, [StringComparison]::OrdinalIgnoreCase) -or + [string]::Equals($output, $markers, [StringComparison]::OrdinalIgnoreCase) -or + [string]::Equals($trace, $markers, [StringComparison]::OrdinalIgnoreCase)) { + throw 'The latency JSON, WPR trace, and decoded marker evidence must use three different paths.' +} +$goPath = Resolve-ExactExecutablePath -Path $GoExecutable -Label 'Go executable' +$go = [pscustomobject]@{ Source = $goPath } +$goHash = (Get-FileHash -LiteralPath $goPath -Algorithm SHA256).Hash.ToLowerInvariant() +$wprPath = Resolve-ExactExecutablePath ` + -Path (Join-Path ([Environment]::SystemDirectory) 'wpr.exe') ` + -Label 'System WPR executable' +$wpr = [pscustomobject]@{ Source = $wprPath } +$wprHash = (Get-FileHash -LiteralPath $wprPath -Algorithm SHA256).Hash.ToLowerInvariant() +$wprProfilePath = Resolve-CanonicalPath -Path (Join-Path $repository '_testing\e2e\latency\ViiperLatency.wprp') +$wprProfileHash = (Get-FileHash -LiteralPath $wprProfilePath -Algorithm SHA256).Hash.ToLowerInvariant() +$wprProfile = "$wprProfilePath!ViiperLatency" +$profileDetailsOutput = @(& $wpr.Source -profiledetails $wprProfile -filemode 2>&1) +if ($LASTEXITCODE -ne 0) { + throw "WPR could not describe '$wprProfile'.`n$($profileDetailsOutput -join [Environment]::NewLine)" +} +$profileDetails = $profileDetailsOutput | Out-String +if ($profileDetails -notmatch '(?im)^Profile\s*:\s*ViiperLatency\.Verbose\.File\s*$') { + throw "WPR '$wprProfile' is not the required source-controlled sequential-file profile.`n$profileDetails" +} +foreach ($eventName in @('DPC', 'Interrupt', 'WDFDPC', 'WDFInterrupt')) { + if ([regex]::Matches($profileDetails, "(?im)^\s*$eventName\s*$").Count -lt 1) { + throw "WPR '$wprProfile' does not capture the required $eventName evidence." + } +} +foreach ($stackName in @('CSwitch', 'ReadyThread', 'SampledProfile')) { + if ([regex]::Matches($profileDetails, "(?im)^\s*$stackName\s*$").Count -lt 2) { + throw "WPR '$wprProfile' does not capture the required $stackName events and stacks." + } +} + +$environmentNames = @( + 'CGO_ENABLED', 'GOENV', 'GOFLAGS', 'GOTOOLCHAIN', 'GOWORK', 'PATH', + 'VIIPER_E2E_LIVE_LATENCY', 'VIIPER_E2E_PRODUCTION_PREFLIGHT', + 'VIIPER_E2E_LATENCY_OUTPUT', 'VIIPER_E2E_LATENCY_SAMPLES', + 'VIIPER_E2E_EXPECTED_SOURCE_REVISION', 'VIIPER_E2E_SDL_SOURCE_REVISION', + 'VIIPER_E2E_SDL_DLL_PATH', 'VIIPER_E2E_SDL_DLL_SHA256', + 'VIIPER_E2E_PACKAGE_MANIFEST_SHA256', 'VIIPER_E2E_NATIVE_DRIVER_SHA256', + 'VIIPER_E2E_TRACE_PROFILE_SHA256', 'VIIPER_E2E_NATIVE_DRIVER_BUILD_IDENTITY', + 'VIIPER_E2E_EXPECTED_PRIORITY_CLASS', + 'VIIPER_E2E_GIT_EXECUTABLE_PATH', 'VIIPER_E2E_GIT_EXECUTABLE_SHA256', + 'VIIPER_E2E_GO_EXECUTABLE_PATH', 'VIIPER_E2E_GO_EXECUTABLE_SHA256', + 'VIIPER_E2E_WPR_EXECUTABLE_PATH', 'VIIPER_E2E_WPR_EXECUTABLE_SHA256' +) +$savedEnvironment = @{} +foreach ($name in $environmentNames) { + $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') +} + +$wprInstance = "ViiperE2ELatency-$PID-$([guid]::NewGuid().ToString('N'))" +$nativeRevisionLDFlag = "-X github.com/Alia5/VIIPER/internal/transport/udecx.nativeSourceRevision=$headRevision" +$wprStarted = $false +$wprFailure = $null +$testExitCode = -1 +$wrapperProcess = [Diagnostics.Process]::GetCurrentProcess() +$originalPriorityClass = $wrapperProcess.PriorityClass +try { + $env:CGO_ENABLED = '1' + $env:GOENV = 'off' + $env:GOFLAGS = '-mod=readonly' + $env:GOTOOLCHAIN = 'local' + $env:GOWORK = 'off' + $env:PATH = "$(Split-Path -Parent $sdlDLL);$($savedEnvironment['PATH'])" + $env:VIIPER_E2E_LIVE_LATENCY = '1' + $env:VIIPER_E2E_PRODUCTION_PREFLIGHT = '1' + $env:VIIPER_E2E_LATENCY_OUTPUT = $output + $env:VIIPER_E2E_LATENCY_SAMPLES = [string]$Samples + $env:VIIPER_E2E_EXPECTED_SOURCE_REVISION = $headRevision + $env:VIIPER_E2E_SDL_SOURCE_REVISION = $sdlRevision + $env:VIIPER_E2E_SDL_DLL_PATH = $sdlDLL + $env:VIIPER_E2E_SDL_DLL_SHA256 = $actualSDLHash + $env:VIIPER_E2E_PACKAGE_MANIFEST_SHA256 = $manifestHash + $env:VIIPER_E2E_NATIVE_DRIVER_SHA256 = $installedDriverHash + $env:VIIPER_E2E_TRACE_PROFILE_SHA256 = $wprProfileHash + $env:VIIPER_E2E_NATIVE_DRIVER_BUILD_IDENTITY = $driverBuildIdentity + $env:VIIPER_E2E_EXPECTED_PRIORITY_CLASS = $PriorityClass.ToLowerInvariant() + $env:VIIPER_E2E_GIT_EXECUTABLE_PATH = $gitPath + $env:VIIPER_E2E_GIT_EXECUTABLE_SHA256 = $gitHash + $env:VIIPER_E2E_GO_EXECUTABLE_PATH = $goPath + $env:VIIPER_E2E_GO_EXECUTABLE_SHA256 = $goHash + $env:VIIPER_E2E_WPR_EXECUTABLE_PATH = $wprPath + $env:VIIPER_E2E_WPR_EXECUTABLE_SHA256 = $wprHash + + $startOutput = @(& $wpr.Source -start $wprProfile -filemode -instancename $wprInstance 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "Could not start the sequential-file WPR capture (exit $LASTEXITCODE).`n$($startOutput -join [Environment]::NewLine)" + } + $wprStarted = $true + + $wrapperProcess.PriorityClass = [Diagnostics.ProcessPriorityClass]::$PriorityClass + & $go.Source -C $repository test -buildvcs=false -mod=readonly -count=1 -timeout=20m ` + -ldflags $nativeRevisionLDFlag ` + -run '^TestLiveControllerToGameLatencyGate$' -v ./_testing/e2e + $testExitCode = $LASTEXITCODE +} +finally { + try { + $wrapperProcess.PriorityClass = $originalPriorityClass + } + catch { + $priorityFailure = "Could not restore wrapper process priority to '$originalPriorityClass': $($_.Exception.Message)" + if ($null -eq $wprFailure) { + $wprFailure = $priorityFailure + } + else { + $wprFailure = "$wprFailure $priorityFailure" + } + } + if ($wprStarted) { + $statusOutput = @(& $wpr.Source -status collectors -details -instancename $wprInstance 2>&1) + $statusExitCode = $LASTEXITCODE + $statusText = $statusOutput | Out-String + if ($statusExitCode -ne 0) { + $wprFailure = "WPR status failed with exit $statusExitCode. $($statusOutput -join ' ')" + } + else { + $lossMatches = [regex]::Matches($statusText, + '(?im)^\s*(?(?:Dropped\s+Events?|Events?\s+Lost|Buffers?\s+Lost))\s*:\s*(?\d+)\s*$') + if ($lossMatches.Count -eq 0) { + $wprFailure = "WPR did not report any event/buffer loss counters. $($statusOutput -join ' ')" + } + else { + $nonZeroLoss = @($lossMatches | Where-Object { [uint64]$_.Groups['count'].Value -ne 0 }) + if ($nonZeroLoss.Count -ne 0) { + $wprFailure = "WPR reported event/buffer loss: $($nonZeroLoss.Value -join '; ')." + } + } + } + + $stopOutput = @(& $wpr.Source -stop $trace -instancename $wprInstance 2>&1) + $stopExitCode = $LASTEXITCODE + if ($stopExitCode -ne 0) { + $stopFailure = "WPR stop failed with exit $stopExitCode. $($stopOutput -join ' ')" + if ($null -eq $wprFailure) { + $wprFailure = $stopFailure + } + else { + $wprFailure = "$wprFailure $stopFailure" + } + } + elseif (-not (Test-Path -LiteralPath $trace -PathType Leaf) -or + (Get-Item -LiteralPath $trace).Length -le 0) { + $traceFailure = "WPR reported success without a non-empty trace '$trace'." + if ($null -eq $wprFailure) { + $wprFailure = $traceFailure + } + else { + $wprFailure = "$wprFailure $traceFailure" + } + } + } + foreach ($name in $environmentNames) { + [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], 'Process') + } +} + +if ($testExitCode -ne 0) { + $wprSuffix = if ($null -eq $wprFailure) { '' } else { " WPR integrity also failed: $wprFailure" } + throw "The live controller-to-game latency gate failed with exit code $testExitCode. Failure evidence, if emitted, is '$output'; WPR evidence is '$trace'.$wprSuffix" +} +if ($null -ne $wprFailure) { + throw "The live workload passed, but WPR evidence failed closed: $wprFailure" +} +if (-not (Test-Path -LiteralPath $output -PathType Leaf)) { + throw "The latency gate exited successfully without the required JSON artifact '$output'." +} +$report = Get-Content -LiteralPath $output -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop +if ([string]$report.schema -cne 'viiper.controller-to-game.latency-suite/v2' -or + [string]$report.provenance.source_revision -cne $headRevision -or + [string]$report.provenance.sdl_source_revision -cne $sdlRevision -or + [string]$report.provenance.sdl_binary_sha256 -cne $actualSDLHash -or + [string]$report.provenance.native_package_manifest_sha256 -cne $manifestHash -or + [string]$report.provenance.native_driver_sha256 -cne $installedDriverHash -or + [string]$report.provenance.native_driver_build_identity -cne $driverBuildIdentity -or + [string]$report.provenance.git_executable_path -cne $gitPath -or + [string]$report.provenance.git_executable_sha256 -cne $gitHash -or + [string]$report.provenance.go_executable_path -cne $goPath -or + [string]$report.provenance.go_executable_sha256 -cne $goHash -or + [string]$report.provenance.wpr_executable_path -cne $wprPath -or + [string]$report.provenance.wpr_executable_sha256 -cne $wprHash -or + [string]$report.provenance.machine.process_priority_class -cne $PriorityClass.ToLowerInvariant() -or + [string]::IsNullOrWhiteSpace([string]$report.provenance.machine.hostname) -or + [string]::IsNullOrWhiteSpace([string]$report.provenance.machine.os_version) -or + [string]::IsNullOrWhiteSpace([string]$report.provenance.machine.cpu_model) -or + [int]$report.provenance.machine.logical_processors -le 0 -or + -not [bool]$report.provenance.machine.process_elevated -or + [string]$report.verdict -cne 'pass' -or + @($report.cases).Count -ne 3) { + throw "The latency JSON artifact is not a passing source-bound production-controller suite." +} + +$expectedMarkers = @{} +foreach ($case in @($report.cases)) { + foreach ($run in @($case.runs)) { + foreach ($sample in @($run.samples)) { + $markerID = [string]$sample.trace_marker_id + if ([string]::IsNullOrWhiteSpace($markerID) -or $expectedMarkers.ContainsKey($markerID)) { + throw "The strictly parsed JSON contains an absent or duplicate trace marker '$markerID'." + } + $expectedMarkers[$markerID] = @{ + Controller = [string]$case.workload.controller_type + Transport = [string]$run.transport + TransportBlock = [string]$run.transport_block + Sequence = [string]$sample.sequence + Transition = [string]$sample.transition + StartQPCTicks = [string]$sample.start_qpc_ticks + EndQPCTicks = [string]$sample.end_qpc_ticks + MarkerQPCTicks = [string]$sample.trace_marker_qpc_ticks + LatencyNS = [string]$sample.latency_ns + SDLEventTimestampNS = [string]$sample.sdl_event_timestamp_ns + SDLFenceTimestampNS = [string]$sample.sdl_prewrite_fence_timestamp_ns + } + } + } +} +$traceMarkers = @{} +$decodedMarkers = [Collections.Generic.List[object]]::new() +$requiredTraceFields = @( + 'MarkerID', 'Controller', 'Transport', 'TransportBlock', 'Sequence', 'Transition', + 'StartQPCTicks', 'EndQPCTicks', 'MarkerQPCTicks', 'LatencyNS', + 'SDLEventTimestampNS', 'SDLFenceTimestampNS' +) +try { + $traceEvents = @(Get-WinEvent -FilterHashtable @{ + Path = $trace + ProviderName = 'VIIPER-LatencyGate' + } -Oldest -ErrorAction Stop) +} +catch { + throw "The ETL could not be decoded for exact TraceLogging attribution: $($_.Exception.Message)" +} +foreach ($event in $traceEvents) { + [xml]$xml = $event.ToXml() + if (-not [string]::Equals([string]$xml.Event.System.Provider.Name, + 'VIIPER-LatencyGate', [StringComparison]::Ordinal) -or + -not [string]::Equals([string]$xml.Event.System.Provider.Guid, + '{e1726ef8-c2e6-4dad-bbf7-2d871b953ab1}', [StringComparison]::OrdinalIgnoreCase)) { + throw 'A decoded latency event does not have the exact source-controlled provider name and GUID.' + } + $fields = @{} + foreach ($data in @($xml.Event.EventData.Data)) { + $name = [string]$data.Name + if ([string]::IsNullOrWhiteSpace($name) -or $fields.ContainsKey($name)) { + throw 'A latency ETL marker contains absent or duplicate named payload fields.' + } + $fields[$name] = [string]$data.InnerText + } + if ($fields.Count -ne $requiredTraceFields.Count -or + @($requiredTraceFields | Where-Object { -not $fields.ContainsKey($_) }).Count -ne 0) { + throw 'A latency ETL marker does not contain the exact source-controlled payload schema.' + } + $markerID = [string]$fields['MarkerID'] + if ([string]::IsNullOrWhiteSpace($markerID) -or $traceMarkers.ContainsKey($markerID)) { + throw "The ETL contains an absent or duplicate latency marker '$markerID'." + } + if (-not $expectedMarkers.ContainsKey($markerID)) { + throw "The ETL contains an unreported latency marker '$markerID'." + } + $expected = $expectedMarkers[$markerID] + foreach ($fieldName in @('Controller', 'Transport', 'TransportBlock', 'Sequence', 'Transition', + 'StartQPCTicks', 'EndQPCTicks', 'MarkerQPCTicks', 'LatencyNS', + 'SDLEventTimestampNS', 'SDLFenceTimestampNS')) { + if ([string]$fields[$fieldName] -cne [string]$expected[$fieldName]) { + throw "ETL marker '$markerID' field '$fieldName' does not match its JSON sample." + } + } + $traceMarkers[$markerID] = $true + $decodedMarkers.Add([pscustomobject]@{ + trace_marker_id = $markerID + controller = [string]$fields['Controller'] + transport = [string]$fields['Transport'] + transport_block = [int]$fields['TransportBlock'] + sequence = [int]$fields['Sequence'] + transition = [string]$fields['Transition'] + start_qpc_ticks = [long]$fields['StartQPCTicks'] + end_qpc_ticks = [long]$fields['EndQPCTicks'] + trace_marker_qpc_ticks = [long]$fields['MarkerQPCTicks'] + latency_ns = [long]$fields['LatencyNS'] + sdl_event_timestamp_ns = [uint64]$fields['SDLEventTimestampNS'] + sdl_prewrite_fence_timestamp_ns = [uint64]$fields['SDLFenceTimestampNS'] + }) +} +if ($traceMarkers.Count -ne $expectedMarkers.Count) { + $missingMarkers = @($expectedMarkers.Keys | Where-Object { -not $traceMarkers.ContainsKey($_) }) + throw "The ETL has $($traceMarkers.Count) exact sample markers for $($expectedMarkers.Count) JSON samples; missing: $($missingMarkers -join ', ')." +} +$markerJSON = ConvertTo-Json -InputObject @($decodedMarkers) -Depth 3 -Compress +$markerBytes = [Text.UTF8Encoding]::new($false).GetBytes($markerJSON) +$markerStream = [IO.File]::Open($markers, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) +try { + $markerStream.Write($markerBytes, 0, $markerBytes.Length) + $markerStream.Flush($true) +} +finally { + $markerStream.Dispose() +} +$verifyExitCode = -1 +try { + $env:CGO_ENABLED = '0' + $env:GOENV = 'off' + $env:GOFLAGS = '' + $env:GOTOOLCHAIN = 'local' + $env:GOWORK = 'off' + $verifyOutput = @(& $go.Source -C $repository run -buildvcs=false -mod=readonly ` + ./_testing/e2e/cmd/verifylatency ` + -input $output ` + -markers $markers ` + -source $headRevision ` + -sdl-revision $sdlRevision ` + -sdl-sha256 $actualSDLHash ` + -manifest-sha256 $manifestHash ` + -driver-sha256 $installedDriverHash ` + -driver-build-identity $driverBuildIdentity ` + -trace-profile-sha256 $wprProfileHash ` + -samples $Samples 2>&1) + $verifyExitCode = $LASTEXITCODE +} +finally { + foreach ($name in @('CGO_ENABLED', 'GOENV', 'GOFLAGS', 'GOTOOLCHAIN', 'GOWORK')) { + [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], 'Process') + } +} +if ($verifyExitCode -ne 0) { + throw "The strict Go evidence verifier rejected the JSON/ETL evidence pair.`n$($verifyOutput -join [Environment]::NewLine)" +} +$requiredControllers = @('xbox360', 'dualshock4', 'dualsensegamepadv5') +for ($index = 0; $index -lt $requiredControllers.Count; $index++) { + $case = $report.cases[$index] + if ([string]$case.workload.controller_type -cne $requiredControllers[$index] -or + [int]$case.workload.warmup_pairs -ne 16 -or + [int]$case.workload.sample_pairs -ne $Samples -or + [long]$case.workload.inter_transition_delay_ns -ne 2000000 -or + [string]$case.workload.phase_sweep_sha256 -cne '21eee9ea71984343ebd21221df8272553d6ab369a5740a1c796380cd468abcd9' -or + @($case.runs).Count -ne 4 -or + [string]$case.runs[0].transport -cne 'usbip' -or + [string]$case.runs[1].transport -cne 'native-ude' -or + [string]$case.runs[2].transport -cne 'native-ude' -or + [string]$case.runs[3].transport -cne 'usbip' -or + [int]$case.runs[0].order -ne 1 -or [int]$case.runs[0].transport_block -ne 1 -or + [int]$case.runs[1].order -ne 2 -or [int]$case.runs[1].transport_block -ne 1 -or + [int]$case.runs[2].order -ne 3 -or [int]$case.runs[2].transport_block -ne 2 -or + [int]$case.runs[3].order -ne 4 -or [int]$case.runs[3].transport_block -ne 2 -or + @($case.transports).Count -ne 2 -or + [int]$case.transports[0].statistics.press.count -ne $Samples -or + [int]$case.transports[0].statistics.release.count -ne $Samples -or + [int]$case.transports[1].statistics.press.count -ne $Samples -or + [int]$case.transports[1].statistics.release.count -ne $Samples) { + throw "The latency JSON artifact is missing the counterbalanced '$($requiredControllers[$index])' workload." + } +} +$postStatus = @(& $git.Source -C $repository status --porcelain=v1 --untracked-files=all 2>&1) +if ($LASTEXITCODE -ne 0 -or $postStatus.Count -ne 0) { + throw ("The production latency run changed its source checkout:`n" + + ($postStatus -join [Environment]::NewLine)) +} + +Write-Host "Validated source-bound controller-to-game latency evidence: '$output'." +Write-Host "Captured source-controlled sequential-file WPR evidence: '$trace'." +Write-Host "Retained the exactly decoded ETL marker evidence: '$markers'." diff --git a/_testing/e2e/scripts/Invoke-ViiperE2ELatencyMatrix.ps1 b/_testing/e2e/scripts/Invoke-ViiperE2ELatencyMatrix.ps1 new file mode 100644 index 00000000..6346f413 --- /dev/null +++ b/_testing/e2e/scripts/Invoke-ViiperE2ELatencyMatrix.ps1 @@ -0,0 +1,193 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$SignedPackageDirectory, + + [Parameter(Mandatory = $true)] + [string]$SubmissionManifestPath, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] + [string]$ExpectedSourceRevision, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{64}$')] + [string]$SDLBinarySHA256, + + [Parameter(Mandatory = $true)] + [string]$EvidenceDirectory, + + [ValidateRange(256, 10000)] + [int]$Samples = 10000, + + [string]$RepositoryRoot, + + [Parameter(Mandatory = $true)] + [string]$GitExecutable, + + [Parameter(Mandatory = $true)] + [string]$GoExecutable +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-ExactEvidenceFile { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Label + ) + + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -le 0) { + throw "$Label is not a non-empty regular file: '$Path'." + } + return [pscustomobject]@{ + path = $item.FullName + length = [long]$item.Length + sha256 = (Get-FileHash -LiteralPath $item.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } +} + +$matrixRoot = [IO.Path]::GetFullPath($EvidenceDirectory) +$matrixRootItem = Get-Item -LiteralPath $matrixRoot -Force -ErrorAction Stop +if (-not $matrixRootItem.PSIsContainer -or + ($matrixRootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "EvidenceDirectory must be an existing non-reparse directory: '$matrixRoot'." +} + +$gate = Join-Path $PSScriptRoot 'Invoke-ViiperE2ELatencyGate.ps1' +$gate = (Resolve-Path -LiteralPath $gate -ErrorAction Stop).Path +$matrixPath = Join-Path $matrixRoot 'viiper-latency-priority-matrix.json' +$runs = @( + [pscustomobject]@{ + priority = 'Normal' + report = (Join-Path $matrixRoot 'viiper-latency-normal.json') + trace = (Join-Path $matrixRoot 'viiper-latency-normal.etl') + }, + [pscustomobject]@{ + priority = 'High' + report = (Join-Path $matrixRoot 'viiper-latency-high.json') + trace = (Join-Path $matrixRoot 'viiper-latency-high.etl') + } +) + +$allOutputs = [Collections.Generic.List[string]]::new() +$allOutputs.Add($matrixPath) +foreach ($run in $runs) { + $allOutputs.Add([string]$run.report) + $allOutputs.Add([string]$run.trace) + $allOutputs.Add("$($run.report).etl-markers.json") +} +foreach ($path in $allOutputs) { + if (Test-Path -LiteralPath $path) { + throw "Refusing to overwrite latency-matrix evidence '$path'." + } +} + +$common = @{ + SignedPackageDirectory = $SignedPackageDirectory + SubmissionManifestPath = $SubmissionManifestPath + ExpectedSourceRevision = $ExpectedSourceRevision + SDLBinarySHA256 = $SDLBinarySHA256 + Samples = $Samples + GitExecutable = $GitExecutable + GoExecutable = $GoExecutable +} +if (-not [string]::IsNullOrWhiteSpace($RepositoryRoot)) { + $common.RepositoryRoot = $RepositoryRoot +} + +foreach ($run in $runs) { + & $gate @common ` + -OutputPath $run.report ` + -WprTracePath $run.trace ` + -PriorityClass $run.priority +} + +$matrixRuns = [Collections.Generic.List[object]]::new() +$referenceProvenance = $null +foreach ($run in $runs) { + $reportFile = Get-ExactEvidenceFile -Path $run.report -Label "$($run.priority) report" + $traceFile = Get-ExactEvidenceFile -Path $run.trace -Label "$($run.priority) trace" + $markerFile = Get-ExactEvidenceFile -Path "$($run.report).etl-markers.json" -Label "$($run.priority) markers" + $report = Get-Content -LiteralPath $run.report -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + $expectedPriority = ([string]$run.priority).ToLowerInvariant() + if ([string]$report.schema -cne 'viiper.controller-to-game.latency-suite/v2' -or + [string]$report.verdict -cne 'pass' -or + [string]$report.provenance.source_revision -cne $ExpectedSourceRevision.ToLowerInvariant() -or + [string]$report.provenance.machine.process_priority_class -cne $expectedPriority -or + @($report.cases).Count -ne 3) { + throw "$($run.priority) report is not an exact passing priority-bound suite." + } + + $machine = $report.provenance.machine + $provenanceIdentity = @( + [string]$report.provenance.source_revision, + [string]$report.provenance.sdl_source_revision, + [string]$report.provenance.sdl_binary_path, + [string]$report.provenance.sdl_binary_sha256, + [string]$report.provenance.native_package_manifest_sha256, + [string]$report.provenance.native_driver_sha256, + [string]$report.provenance.native_driver_build_identity, + [string]$report.provenance.qpc_frequency, + [string]$report.provenance.trace_provider_name, + [string]$report.provenance.trace_provider_guid, + [string]$report.provenance.trace_profile_sha256, + [string]$report.provenance.usbip_baseline_mode, + [string]$report.provenance.usbip_baseline_version, + [string]$report.provenance.go_version, + [string]$report.provenance.goos, + [string]$report.provenance.goarch, + [string]$report.provenance.git_executable_path, + [string]$report.provenance.git_executable_sha256, + [string]$report.provenance.go_executable_path, + [string]$report.provenance.go_executable_sha256, + [string]$report.provenance.wpr_executable_path, + [string]$report.provenance.wpr_executable_sha256, + [string]$machine.hostname, + [string]$machine.os_product_name, + [string]$machine.os_display_version, + [string]$machine.os_version, + [string]$machine.cpu_model, + [string]$machine.logical_processors, + [string]$machine.process_elevated + ) -join "`n" + if ($null -eq $referenceProvenance) { + $referenceProvenance = $provenanceIdentity + } + elseif (-not [string]::Equals($referenceProvenance, $provenanceIdentity, + [StringComparison]::Ordinal)) { + throw 'Normal and high-priority suites do not have identical source/package/toolchain/machine provenance.' + } + + $matrixRuns.Add([ordered]@{ + priority_class = $expectedPriority + report = $reportFile + trace = $traceFile + decoded_markers = $markerFile + }) +} + +$matrix = [ordered]@{ + schema = 'viiper.controller-to-game.latency-priority-matrix/v1' + generated_at = [DateTime]::UtcNow.ToString('o') + source_revision = $ExpectedSourceRevision.ToLowerInvariant() + sample_pairs_per_transition = $Samples + runs = @($matrixRuns) +} +$matrixJSON = ConvertTo-Json -InputObject $matrix -Depth 8 -Compress +$matrixBytes = [Text.UTF8Encoding]::new($false).GetBytes($matrixJSON) +$stream = [IO.File]::Open($matrixPath, [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, [IO.FileShare]::None) +try { + $stream.Write($matrixBytes, 0, $matrixBytes.Length) + $stream.Flush($true) +} +finally { + $stream.Dispose() +} + +$matrixFile = Get-ExactEvidenceFile -Path $matrixPath -Label 'priority matrix manifest' +Write-Host "Validated normal/high-priority latency matrix: '$($matrixFile.path)' (SHA-256 $($matrixFile.sha256))." diff --git a/_testing/e2e/sdl/gamepad.go b/_testing/e2e/sdl/gamepad.go index 893412da..9ab31efa 100644 --- a/_testing/e2e/sdl/gamepad.go +++ b/_testing/e2e/sdl/gamepad.go @@ -5,9 +5,103 @@ package sdl #include +#include +#include #include #include +static inline int wait_gamepad_button_event( + SDL_JoystickID which, + SDL_GamepadButton button, + bool down, + Sint32 timeout_ms) +{ + Uint64 deadline = timeout_ms < 0 ? 0 : SDL_GetTicks() + (Uint64)timeout_ms; + + for (;;) { + Sint32 remaining = timeout_ms; + if (timeout_ms >= 0) { + Uint64 now = SDL_GetTicks(); + if (now >= deadline) { + return 0; + } + Uint64 delta = deadline - now; + remaining = delta > 0x7fffffffULL ? 0x7fffffff : (Sint32)delta; + } + + SDL_Event event; + if (!SDL_WaitEventTimeout(&event, remaining)) { + return 0; + } + if ((event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN || + event.type == SDL_EVENT_GAMEPAD_BUTTON_UP) && + event.gbutton.which == which && + event.gbutton.button == (Uint8)button && + event.gbutton.down == down) { + return 1; + } + } +} + +static inline int wait_gamepad_button_transition( + SDL_JoystickID which, + SDL_GamepadButton button, + Sint32 timeout_ms, + bool *down, + Uint64 *timestamp_ns) +{ + Uint64 deadline = timeout_ms < 0 ? 0 : SDL_GetTicks() + (Uint64)timeout_ms; + + for (;;) { + Sint32 remaining = timeout_ms; + if (timeout_ms >= 0) { + Uint64 now = SDL_GetTicks(); + if (now >= deadline) { + return 0; + } + Uint64 delta = deadline - now; + remaining = delta > 0x7fffffffULL ? 0x7fffffff : (Sint32)delta; + } + + SDL_ClearError(); + SDL_Event event; + if (!SDL_WaitEventTimeout(&event, remaining)) { + const char *error = SDL_GetError(); + return error != NULL && error[0] != '\0' ? -1 : 0; + } + if ((event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN || + event.type == SDL_EVENT_GAMEPAD_BUTTON_UP) && + event.gbutton.which == which && + event.gbutton.button == (Uint8)button) { + *down = event.gbutton.down; + *timestamp_ns = event.gbutton.timestamp; + return 1; + } + } +} + +static inline int poll_gamepad_button_transition( + SDL_JoystickID which, + SDL_GamepadButton button, + bool *down, + Uint64 *timestamp_ns) +{ + SDL_ClearError(); + SDL_Event event; + while (SDL_PollEvent(&event)) { + if ((event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN || + event.type == SDL_EVENT_GAMEPAD_BUTTON_UP) && + event.gbutton.which == which && + event.gbutton.button == (Uint8)button) { + *down = event.gbutton.down; + *timestamp_ns = event.gbutton.timestamp; + return 1; + } + } + const char *error = SDL_GetError(); + return error != NULL && error[0] != '\0' ? -1 : 0; +} + static inline int gamepad_binding_input_button(const SDL_GamepadBinding *b) { return b->input.button; @@ -76,6 +170,29 @@ type GamepadButton int32 // GamepadButtonLabel the set of gamepad button labels. type GamepadButtonLabel int32 +// GamepadButtonEvent is a single SDL transition from an exact opened gamepad. +// TimestampNS is SDL's monotonic SDL_GetTicksNS timestamp from the event. +type GamepadButtonEvent struct { + Down bool + TimestampNS uint64 +} + +// EnableWindowsRawInput makes SDL expose an actual Windows device-interface +// path for XInput-capable controllers. SDL's default XInput backend reports a +// logical "XInput#N" path, which cannot be causally bound to a PnP devnode. +// The production latency gate calls this before SDL_Init and then still fails +// closed unless the resulting path resolves to the selected transport anchor. +func EnableWindowsRawInput() error { + name := C.CString("SDL_JOYSTICK_RAWINPUT") + defer C.free(unsafe.Pointer(name)) + value := C.CString("1") + defer C.free(unsafe.Pointer(value)) + if !bool(C.SDL_SetHint(name, value)) { + return &SDLError{eStr: "SDL rejected the source-identity RawInput hint"} + } + return nil +} + // GamepadBindingType describes the type of a gamepad control binding. type GamepadBindingType int32 @@ -341,6 +458,80 @@ func (g *Gamepad) GetButton(button GamepadButton) bool { return bool(C.SDL_GetGamepadButton(g.cGamepad, C.SDL_GamepadButton(button))) } +// WaitButtonEvent waits for an exact transition from this gamepad without +// polling SDL in a busy loop. SDL's event wait pumps the gamepad event queue +// and returns the transition generated by Windows, so benchmark CPU and +// scheduler-tail measurements are not contaminated by a synthetic observer +// consuming an entire core. +// +// SDL requires event waits to run on the thread which initialized the event +// subsystem. The e2e benchmark calls this method directly from its locked main +// test thread. +func (g *Gamepad) WaitButtonEvent(button GamepadButton, down bool, timeoutMS int32) bool { + if g == nil || g.cGamepad == nil { + return false + } + return C.wait_gamepad_button_event( + C.SDL_GetGamepadID(g.cGamepad), + C.SDL_GamepadButton(button), + C.bool(down), + C.Sint32(timeoutMS), + ) != 0 +} + +// WaitButtonTransition blocks on SDL's event queue until this exact gamepad +// and button produces either edge. It returns (event, false, nil) on timeout. +// Unlike WaitButtonEvent, it exposes unexpected same-state edges so a live +// latency gate can count duplicates instead of silently discarding them. +func (g *Gamepad) WaitButtonTransition( + button GamepadButton, + timeoutMS int32, +) (GamepadButtonEvent, bool, error) { + if g == nil || g.cGamepad == nil { + return GamepadButtonEvent{}, false, &SDLError{eStr: "invalid gamepad handle"} + } + var down C.bool + var timestampNS C.Uint64 + result := C.wait_gamepad_button_transition( + C.SDL_GetGamepadID(g.cGamepad), + C.SDL_GamepadButton(button), + C.Sint32(timeoutMS), + &down, + ×tampNS, + ) + if result < 0 { + return GamepadButtonEvent{}, false, GetError() + } + if result == 0 { + return GamepadButtonEvent{}, false, nil + } + return GamepadButtonEvent{Down: bool(down), TimestampNS: uint64(timestampNS)}, true, nil +} + +// PollButtonTransition drains SDL's already-queued events and returns an exact +// edge for this gamepad/button without waiting. The latency gate uses it as a +// final causal fence immediately before issuing the input write. +func (g *Gamepad) PollButtonTransition(button GamepadButton) (GamepadButtonEvent, bool, error) { + if g == nil || g.cGamepad == nil { + return GamepadButtonEvent{}, false, &SDLError{eStr: "invalid gamepad handle"} + } + var down C.bool + var timestampNS C.Uint64 + result := C.poll_gamepad_button_transition( + C.SDL_GetGamepadID(g.cGamepad), C.SDL_GamepadButton(button), &down, ×tampNS) + if result < 0 { + return GamepadButtonEvent{}, false, GetError() + } + if result == 0 { + return GamepadButtonEvent{}, false, nil + } + return GamepadButtonEvent{Down: bool(down), TimestampNS: uint64(timestampNS)}, true, nil +} + +// TicksNS returns SDL's monotonically increasing nanosecond clock used by +// SDL_GamepadButtonEvent.timestamp. +func TicksNS() uint64 { return uint64(C.SDL_GetTicksNS()) } + // GetButtonLabel gets the label of a button on a gamepad. func (g *Gamepad) GetButtonLabel(button GamepadButton) GamepadButtonLabel { if g == nil || g.cGamepad == nil { diff --git a/_testing/e2e/sdl/sdl_nocgo.go b/_testing/e2e/sdl/sdl_nocgo.go new file mode 100644 index 00000000..29bcbc16 --- /dev/null +++ b/_testing/e2e/sdl/sdl_nocgo.go @@ -0,0 +1,96 @@ +//go:build !cgo + +package sdl + +import "errors" + +// This stub keeps the end-to-end benchmark type-checked in ordinary +// CGO-disabled builds. The real benchmark still requires the vendored SDL3 +// development files and CGO; it fails explicitly instead of disappearing from +// compilation and allowing benchmark-only regressions to go unnoticed. + +type InitFlags uint32 + +const ( + InitFlagGamepad InitFlags = 0x00002000 + InitFlagEvents InitFlags = 0x00004000 +) + +type GamepadID int32 + +type GamepadType int32 + +const ( + GamepadTypeUnknown GamepadType = iota + GamepadTypeStandard + GamepadTypeXbox360 + GamepadTypeXboxOne + GamepadTypePS3 + GamepadTypePS4 + GamepadTypePS5 +) + +type GamepadButton int32 + +const GamepadButtonSouth GamepadButton = 0 + +type Gamepad struct{} + +type GUID [16]byte + +func (GUID) String() string { return "" } + +type GamepadButtonEvent struct { + Down bool + TimestampNS uint64 +} + +func Init(InitFlags) error { + return errors.New("SDL3 end-to-end benchmarks require CGO and the vendored SDL3 development files") +} + +func EnableWindowsRawInput() error { + return errors.New("SDL3 end-to-end benchmarks require CGO and the vendored SDL3 development files") +} + +func Quit() {} + +func UpdateGamepads() {} + +func GetGamepads() ([]GamepadID, error) { return nil, nil } + +func OpenGamepad(GamepadID) (*Gamepad, error) { + return nil, errors.New("SDL3 end-to-end benchmarks require CGO") +} + +func (*Gamepad) Close() {} + +func (*Gamepad) ID() GamepadID { return 0 } + +func (*Gamepad) Path() string { return "" } + +func (*Gamepad) Name() string { return "" } + +func (*Gamepad) Type() GamepadType { return GamepadTypeUnknown } + +func (*Gamepad) RealType() GamepadType { return GamepadTypeUnknown } + +func (*Gamepad) Vendor() uint16 { return 0 } + +func (*Gamepad) Product() uint16 { return 0 } + +func GetGamepadGUIDForID(GamepadID) GUID { return GUID{} } + +func (*Gamepad) GetButton(GamepadButton) bool { return false } + +func (*Gamepad) WaitButtonEvent(GamepadButton, bool, int32) bool { return false } + +func (*Gamepad) WaitButtonTransition(GamepadButton, int32) (GamepadButtonEvent, bool, error) { + return GamepadButtonEvent{}, false, errors.New("SDL3 end-to-end benchmarks require CGO") +} + +func (*Gamepad) PollButtonTransition(GamepadButton) (GamepadButtonEvent, bool, error) { + return GamepadButtonEvent{}, false, errors.New("SDL3 end-to-end benchmarks require CGO") +} + +func TicksNS() uint64 { return 0 } diff --git a/cmd/viiper/viiper.go b/cmd/viiper/viiper.go index 7107912e..57590595 100644 --- a/cmd/viiper/viiper.go +++ b/cmd/viiper/viiper.go @@ -56,7 +56,10 @@ func main() { ctx.Bind(logger) ctx.BindTo(rawLogger, (*log.RawLogger)(nil)) - if cli.UpdateNotify != config.UpdateNotifyNone { + // A broker hosted by Service Control Manager has no interactive desktop. + // Update UI belongs to DS4Windows/the package installer, never session 0. + isServiceCommand := strings.HasPrefix(ctx.Command(), "service") + if !isServiceCommand && cli.UpdateNotify != config.UpdateNotifyNone { go func() { time.Sleep(10 * time.Second) updater.CheckUpdate(Version, cli.UpdateNotify) @@ -94,7 +97,7 @@ func findUserConfig(args []string) string { func setupRawLogger(cli *config.CLI, logger *slog.Logger, closeFiles *[]io.Closer) log.RawLogger { if cli.Log.RawFile != "" { // nolint - f, err := os.OpenFile(cli.Log.RawFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) // nolint + f, err := log.OpenBoundedFile(cli.Log.RawFile, 0o644) // nolint if err != nil { logger.Error("failed to open raw log file", "file", cli.Log.RawFile, "error", err) // nolint return log.NewRaw(nil) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index d1ffc259..02ddd556 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -4,8 +4,8 @@ import ( "context" "encoding/binary" "encoding/json" - "errors" "fmt" + "io" "log/slog" "math" "net" @@ -13,9 +13,9 @@ import ( "time" "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/internal/inputstatequeue" "github.com/Alia5/VIIPER/device/internal/microphonebuffer" "github.com/Alia5/VIIPER/usb" - "github.com/Alia5/VIIPER/usbip" ) const ( @@ -51,25 +51,35 @@ const ( outputFlag2LightbarBrightness = 0x01 outputFlag2LightbarSetup = 0x02 - outputRightTriggerOffset = 11 - outputLeftTriggerOffset = 22 - outputTriggerLength = 11 - outputPlayerLedsOffset = 44 - outputLightbarOffset = 45 + outputRightTriggerOffset = 11 + outputLeftTriggerOffset = 22 + outputTriggerLength = 11 + outputPlayerLedsOffset = 44 + outputLightbarOffset = 45 + inputTransitionQueueCapacity = 256 ) type DualSense struct { - deviceType string - inputCh chan InputState - inputState InputState - inputPublishMu sync.Mutex - metaState *MetaState + deviceType string + inputQueue *inputstatequeue.Queue[InputState] + inputState InputState + metaState *MetaState + + // Output and media publication use independent gates. HID state remains + // valid across an audio-pipe reset, while speaker/haptics data does not. + // Keeping the gates separate prevents an audio reconfiguration from + // discarding the game's final lightbar/trigger/rumble update. + outputPublishMu sync.RWMutex + mediaPublishMu sync.RWMutex atomicAudioHapticsFunc func(OutputState, []byte) realtimeHapticsFunc func(OutputState) speakerResetFunc func() outputFunc func(OutputState) outputState OutputState + latestOutputState OutputState + outputSeen bool + mediaRevision uint64 descriptor usb.Descriptor subcommand [2]byte @@ -96,7 +106,8 @@ type DualSense struct { hapticsPCMStartedAt time.Time timestampBase time.Time - mtx sync.Mutex + inputReportMu sync.Mutex + mtx sync.Mutex } func New(o *device.CreateOptions) (*DualSense, error) { @@ -187,8 +198,9 @@ func new(o *device.CreateOptions, edge bool) (*DualSense, error) { "interfaces", len(d.descriptor.Interfaces)) d.inputState = *NewInputState() - d.inputCh = make(chan InputState, 1) - d.inputCh <- d.inputState + d.inputQueue = inputstatequeue.New( + d.inputState, dualSenseInputEdgeSignature(d.inputState), + inputTransitionQueueCapacity) d.timestampBase = time.Now() return d, nil @@ -208,9 +220,25 @@ func (d *DualSense) SetMetaState(meta MetaState) { } func (d *DualSense) SetOutputCallback(f func(OutputState)) { + d.outputPublishMu.Lock() + defer d.outputPublishMu.Unlock() + + var latest OutputState + var replay bool d.mtx.Lock() d.outputFunc = f + if f != nil && d.outputSeen { + latest = d.latestOutputState + replay = true + } d.mtx.Unlock() + + // A newly attached stream must observe the last explicit game update even + // if it arrived just before callback registration. The publication gate + // orders this replay against live SET_REPORT callbacks. + if replay { + f(latest) + } } // SetAtomicAudioHapticsCallback installs the V5 transport consumer. Each @@ -219,29 +247,80 @@ func (d *DualSense) SetOutputCallback(f func(OutputState)) { // raw 48 kHz speaker frames and consumes one independently completed rear // haptics sample, or silence when that 512-frame lane has not completed yet. func (d *DualSense) SetAtomicAudioHapticsCallback(f func(OutputState, []byte)) { - d.mtx.Lock() - d.atomicAudioHapticsFunc = f - d.mtx.Unlock() + d.replaceMediaCallbacks(func() { + d.atomicAudioHapticsFunc = f + }) } // SetRealtimeHapticsCallback installs the V5 rear-channel consumer. A // callback is issued as soon as one complete 512-frame haptics interval is // available, independently of the 480-frame speaker clock. func (d *DualSense) SetRealtimeHapticsCallback(f func(OutputState)) { - d.mtx.Lock() - d.realtimeHapticsFunc = f - d.mtx.Unlock() + d.replaceMediaCallbacks(func() { + d.realtimeHapticsFunc = f + }) } // SetSpeakerResetCallback installs the transport-side queue reset paired with // SetAtomicAudioHapticsCallback. USB interface close/reopen and endpoint reset // must discard queued speaker PCM from the previous presentation generation. func (d *DualSense) SetSpeakerResetCallback(f func()) { + d.replaceMediaCallbacks(func() { + d.speakerResetFunc = f + }) +} + +// setV5MediaCallbacks replaces the three coupled V5 media callbacks as one +// transport generation. The stream handler uses this instead of exposing a +// partially installed callback set between three independent setter calls. +func (d *DualSense) setV5MediaCallbacks( + atomic func(OutputState, []byte), + realtime func(OutputState), + reset func(), +) { + d.replaceMediaCallbacks(func() { + d.atomicAudioHapticsFunc = atomic + d.realtimeHapticsFunc = realtime + d.speakerResetFunc = reset + }) +} + +// detachV5MediaStreamCallbacks is a terminal detach, not an operational audio +// reset. It advances the device media generation, clears its assembled audio, +// and fences/removes every producer without waiting on the old transport's +// reset callback. The stream handler immediately follows with writer Stop. +func (d *DualSense) detachV5MediaStreamCallbacks() { + d.mediaPublishMu.Lock() + defer d.mediaPublishMu.Unlock() + d.mtx.Lock() - d.speakerResetFunc = f + d.mediaRevision++ + d.resetSpeakerAudioLocked() + d.atomicAudioHapticsFunc = nil + d.realtimeHapticsFunc = nil + d.speakerResetFunc = nil d.mtx.Unlock() } +// replaceMediaCallbacks is a hard lifecycle boundary. A callback already in +// progress finishes before the old transport is flushed; a callback assembled +// before this revision can never publish into the replacement transport. +func (d *DualSense) replaceMediaCallbacks(update func()) { + d.mediaPublishMu.Lock() + defer d.mediaPublishMu.Unlock() + + d.mtx.Lock() + resetSpeaker := d.speakerResetFunc + d.mediaRevision++ + d.resetSpeakerAudioLocked() + update() + d.mtx.Unlock() + + if resetSpeaker != nil { + resetSpeaker() + } +} + // beginSpeakerStream gives each stream generation independent telemetry. An // older writer can therefore finish a callback without changing the state // exposed for a replacement connection. @@ -253,26 +332,35 @@ func (d *DualSense) beginSpeakerStream() *dualSenseSpeakerStreamTelemetry { return telemetry } -func (d *DualSense) UpdateInputState(state *InputState) { - d.inputPublishMu.Lock() - defer d.inputPublishMu.Unlock() +func (d *DualSense) UpdateInputState(state *InputState) error { + return d.UpdateInputStateUntil(nil, state) +} +func (d *DualSense) UpdateInputStateUntil(done <-chan struct{}, state *InputState) error { next := *NewInputState() if state != nil { next = *state } - + if err := d.inputQueue.PublishUntil( + done, next, dualSenseInputEdgeSignature(next)); err != nil { + return err + } d.mtx.Lock() d.inputState = next d.mtx.Unlock() + return nil +} - select { - case <-d.inputCh: - default: - } - select { - case d.inputCh <- next: - default: +func dualSenseInputEdgeSignature(state InputState) uint64 { + return uint64(state.Buttons) | + uint64(state.DPad)<<32 | + uint64(encodeTouchStatus(state.Touch1Active, state.Touch1Tracking))<<40 | + uint64(encodeTouchStatus(state.Touch2Active, state.Touch2Tracking))<<48 +} + +func (d *DualSense) InvalidateInterruptInput(endpoint uint8) { + if endpoint == 0 || endpoint&0x0f == EndpointIn&0x0f { + d.inputQueue.Invalidate() } } @@ -296,19 +384,46 @@ func (d *DualSense) GetDeviceSpecificArgs() map[string]any { res["speakerInterfaceActive"] = d.speakerInterfaceActive speakerState := d.speakerStreamTelemetry.snapshot() res["speakerStreamActive"] = speakerState.Active + res["speakerOrderedFramesReceived"] = speakerState.OrderedReceived + res["speakerOrderedFramesEnqueued"] = speakerState.OrderedEnqueued + res["speakerOrderedFramesRejected"] = speakerState.OrderedRejected + res["speakerOrderedFramesWritten"] = speakerState.OrderedWritten + res["speakerOrderedSaturations"] = speakerState.OrderedSaturations + res["speakerOrderedQueueDepth"] = speakerState.OrderedQueueDepth + res["speakerOrderedQueueHighWater"] = speakerState.OrderedQueueHighWater + res["speakerOrderedLifecycleDiscardedFrames"] = + speakerState.OrderedLifecycleDiscardedFrames + res["speakerOrderedLifecycleDiscardedBytes"] = + speakerState.OrderedLifecycleDiscardedBytes res["speakerPayloadsReceived"] = speakerState.ReceivedPayloads res["speakerBytesReceived"] = speakerState.ReceivedBytes res["speakerPayloadsEnqueued"] = speakerState.EnqueuedPayloads res["speakerBytesEnqueued"] = speakerState.EnqueuedBytes + res["speakerPayloadsRejectedAfterFault"] = speakerState.RejectedPayloads + res["speakerBytesRejectedAfterFault"] = speakerState.RejectedBytes res["speakerPayloadsDropped"] = speakerState.DroppedPayloads res["speakerBytesDropped"] = speakerState.DroppedBytes + res["speakerQueueOverruns"] = speakerState.Overruns + res["speakerQueueUnderruns"] = speakerState.Underruns + res["speakerLateGaps"] = speakerState.LateGaps + res["speakerStalePayloads"] = speakerState.StalePayloads + res["speakerStaleBytes"] = speakerState.StaleBytes + res["speakerLifecycleDiscardedPayloads"] = + speakerState.LifecycleDiscardedPayloads + res["speakerLifecycleDiscardedBytes"] = + speakerState.LifecycleDiscardedBytes res["speakerPayloadsWritten"] = speakerState.WrittenPayloads res["speakerBytesWritten"] = speakerState.WrittenBytes res["speakerWriteFailures"] = speakerState.WriteFailures + res["speakerOrderedWriteFailures"] = speakerState.OrderedWriteFailures res["speakerQueueDepth"] = speakerState.QueueDepth res["speakerQueueHighWater"] = speakerState.QueueHighWater + res["speakerQueueDurationUS"] = speakerState.QueueDurationUS + res["speakerQueueDurationHighWaterUS"] = speakerState.QueueDurationHighUS res["speakerMaxEnqueueGapUS"] = speakerState.MaxEnqueueGapUS res["speakerMaxWriteGapUS"] = speakerState.MaxWriteGapUS + res["speakerTeardownFailures"] = speakerState.TeardownFailures + res["speakerTeardownPending"] = speakerState.TeardownPending res["microphoneInterfaceActive"] = d.microphoneInterfaceActive microphoneState := d.microphoneBuffer.State() res["queuedMicrophoneBytes"] = microphoneState.QueuedBytes @@ -340,38 +455,54 @@ func (d *DualSense) GetDeviceSpecificArgs() map[string]any { } func (d *DualSense) SetInterfaceAltSetting(iface, alt uint8) { + if iface == InterfaceHapticsAudio { + d.resetSpeakerPresentation(func() { + d.speakerInterfaceActive = alt != 0 + }) + return + } + d.mtx.Lock() - var resetSpeaker func() switch iface { - case InterfaceHapticsAudio: - d.speakerInterfaceActive = alt != 0 - d.resetSpeakerAudioLocked() - resetSpeaker = d.speakerResetFunc case InterfaceMicrophone: d.microphoneInterfaceActive = alt != 0 d.resetMicrophoneAudioLocked() } d.mtx.Unlock() - - if resetSpeaker != nil { - resetSpeaker() - } } // ResetEndpoint implements usb.EndpointResetDevice. A standard endpoint pipe // reset preserves the selected alternate setting and feature controls while // discarding all transport data from the previous endpoint generation. func (d *DualSense) ResetEndpoint(endpoint uint8) { + if endpoint == EndpointHapticsAudioOut { + d.resetSpeakerPresentation(nil) + return + } + d.mtx.Lock() - var resetSpeaker func() switch endpoint { - case EndpointHapticsAudioOut: - d.resetSpeakerAudioLocked() - resetSpeaker = d.speakerResetFunc case EndpointMicrophoneIn: d.resetMicrophoneAudioLocked() } d.mtx.Unlock() +} + +// resetSpeakerPresentation advances the device-owned revision and the framed +// writer generation under one publication barrier. The optional state update +// applies before the revision is visible to new media callbacks. +func (d *DualSense) resetSpeakerPresentation(update func()) { + d.mediaPublishMu.Lock() + defer d.mediaPublishMu.Unlock() + + d.mtx.Lock() + d.mediaRevision++ + if update != nil { + update() + } + d.resetSpeakerAudioLocked() + resetSpeaker := d.speakerResetFunc + d.mtx.Unlock() if resetSpeaker != nil { resetSpeaker() @@ -393,28 +524,20 @@ func (d *DualSense) resetMicrophoneAudioLocked() { } func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { - // USB/IP carries the endpoint number separately from transfer direction, - // so an IN descriptor address such as 0x82 arrives here as endpoint 2. + // The transport-neutral device contract carries the endpoint number + // separately from direction, so 0x82 arrives here as endpoint 2 plus IN. epNumber := ep & 0x0F - if dir == usbip.DirIn { + if dir == usb.DirectionIn { switch epNumber { case EndpointIn & 0x0F: - select { - case <-ctx.Done(): - if errors.Is(ctx.Err(), context.DeadlineExceeded) { - d.mtx.Lock() - is := d.inputState - ms := *d.metaState - d.mtx.Unlock() - return d.buildUSBInputReport(&is, &ms) - } + is, _, err := d.inputQueue.Wait(ctx, nil) + if err != nil { return nil - case is := <-d.inputCh: - d.mtx.Lock() - ms := *d.metaState - d.mtx.Unlock() - return d.buildUSBInputReport(&is, &ms) } + d.mtx.Lock() + ms := *d.metaState + d.mtx.Unlock() + return d.buildUSBInputReport(&is, &ms) case EndpointMicrophoneIn & 0x0F: return d.handleMicrophoneIn(ctx) default: @@ -422,12 +545,12 @@ func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, o } } - if dir == usbip.DirOut && epNumber == EndpointOut&0x0F { + if dir == usb.DirectionOut && epNumber == EndpointOut&0x0F { if d.handleOutputReport(out) { return nil } } - if dir == usbip.DirOut && epNumber == EndpointHapticsAudioOut&0x0F { + if dir == usb.DirectionOut && epNumber == EndpointHapticsAudioOut&0x0F { d.handleHapticsAudioOut(out) return nil } @@ -435,6 +558,51 @@ func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, o return nil } +// ReadInterruptInput implements usb.InterruptInputDevice for the native UDE +// fast path. The caller owns dst and reuses it only after SubmitInputReport has +// completed, so encoding here removes the per-sample report allocation without +// changing USB/IP behavior. +func (d *DualSense) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + written, _, err := d.readInterruptInput(ctx, nil, ep, dst) + return written, err +} + +// ReadScheduledInterruptInput preserves the DualSense report encoder and its +// packet-counter/sensor-timestamp cadence while letting native UDE reuse one +// endpoint timer instead of allocating a context timer for every idle sample. +func (d *DualSense) ReadScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { + written, _, err := d.readInterruptInput(ctx, deadline, ep, dst) + return written, err +} + +func (d *DualSense) ReadClassifiedScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, bool, error) { + return d.readInterruptInput(ctx, deadline, ep, dst) +} + +func (d *DualSense) readInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, bool, error) { + if ep&0x0f != EndpointIn&0x0f { + return 0, false, fmt.Errorf("DualSense interrupt-IN endpoint %d is unsupported", ep) + } + if deadline != nil && ctx.Err() != nil { + return 0, false, ctx.Err() + } + is, transition, err := d.inputQueue.Wait(ctx, deadline) + if err != nil { + return 0, false, err + } + d.mtx.Lock() + ms := *d.metaState + d.mtx.Unlock() + written, err := d.buildUSBInputReportInto(&is, &ms, dst) + return written, transition, err +} + func (d *DualSense) QueueMicrophonePCMFrame(frame []byte) { if len(frame) != USBMicrophoneClientFrameSize { return @@ -499,6 +667,35 @@ func (d *DualSense) handleMicrophoneIn(ctx context.Context) []byte { } } +// ReadIsochronousInput implements usb.IsochronousInputDevice. Native UDE owns +// the packet service deadline and destination, so this path neither allocates a +// packet nor creates a timer per USB packet. +func (d *DualSense) ReadIsochronousInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + if ep&0x0f != EndpointMicrophoneIn&0x0f { + return 0, fmt.Errorf("DualSense isochronous-IN endpoint %d is unsupported", ep) + } + if len(dst) < USBMicrophonePacketSize { + return 0, io.ErrShortBuffer + } + if err := ctx.Err(); err != nil { + return 0, err + } + packet := dst[:min(len(dst), USBMicrophoneMaxPacketSize)] + clear(packet) + d.mtx.Lock() + defer d.mtx.Unlock() + if d.microphoneInterfaceActive { + if actualLength, ok := d.microphoneBuffer.ReadPacket(packet); ok { + d.microphoneAudioFeature.applyPCMInPlace( + packet[:actualLength], USBMicrophoneChannels, + ) + return actualLength, nil + } + } + d.microphoneBuffer.RecordZeroPacket() + return USBMicrophonePacketSize, nil +} + func (d *DualSense) drainMicrophoneSignal() { for { select { @@ -522,46 +719,51 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) { } processed, release := d.speakerAudioFeature.applyPCM(out, USBHapticsAudioChannels) + revision := d.mediaRevision reports := d.consumeDualSenseV5AudioLocked(processed, receivedAt) - // The callback is deliberately completed under the device lock. This makes - // an alternate-setting or endpoint reset a hard generation barrier: once the - // reset acquires the lock, no pre-reset callback can enqueue stale PCM after - // the transport queue has been flushed. + for index := range reports { + reports[index].revision = revision + } d.mtx.Unlock() if release != nil { release() } for _, pending := range reports { - report := pending.feedback.BluetoothCombinedOutputReport[:] - if len(report) == 0 { - continue - } + d.publishV5Media(pending) + } +} - d.mtx.Lock() - outputFunc := d.outputFunc - atomicAudioHapticsFunc := d.atomicAudioHapticsFunc - realtimeHapticsFunc := d.realtimeHapticsFunc - if pending.hapticsOnly { - feedback := pending.feedback - d.mtx.Unlock() - if realtimeHapticsFunc != nil { - realtimeHapticsFunc(feedback) - } - continue - } - if outputFunc != nil || atomicAudioHapticsFunc != nil { - feedback := pending.feedback - d.mtx.Unlock() - if atomicAudioHapticsFunc != nil { - atomicAudioHapticsFunc(feedback, pending.speakerPCM) - } else { - outputFunc(feedback) - } - } else { - d.mtx.Unlock() +func (d *DualSense) publishV5Media(pending pendingBluetoothHapticsReport) bool { + d.mediaPublishMu.RLock() + defer d.mediaPublishMu.RUnlock() + + d.mtx.Lock() + if pending.revision != d.mediaRevision || !d.speakerInterfaceActive { + d.mtx.Unlock() + return false + } + outputFunc := d.outputFunc + atomicAudioHapticsFunc := d.atomicAudioHapticsFunc + realtimeHapticsFunc := d.realtimeHapticsFunc + d.mtx.Unlock() + + if pending.hapticsOnly { + if realtimeHapticsFunc == nil { + return false } + realtimeHapticsFunc(pending.feedback) + return true + } + if atomicAudioHapticsFunc != nil { + atomicAudioHapticsFunc(pending.feedback, pending.speakerPCM) + return true + } + if outputFunc != nil { + outputFunc(pending.feedback) + return true } + return false } type pendingBluetoothHapticsReport struct { @@ -569,6 +771,7 @@ type pendingBluetoothHapticsReport struct { assemblyDelay time.Duration feedback OutputState hapticsOnly bool + revision uint64 } type dualSenseV5HapticsGeneration struct { @@ -800,14 +1003,17 @@ func (d *DualSense) handleOutputReport(out []byte) bool { if !ok { return false } + d.outputPublishMu.RLock() + defer d.outputPublishMu.RUnlock() + d.mtx.Lock() + feedback := d.mergeOutputReport(report) + d.latestOutputState = feedback + d.outputSeen = true outputFunc := d.outputFunc + d.mtx.Unlock() if outputFunc != nil { - feedback := d.mergeOutputReport(report) - d.mtx.Unlock() outputFunc(feedback) - } else { - d.mtx.Unlock() } return true } @@ -1096,6 +1302,23 @@ func (d *DualSense) featureReportCommandResponse() []byte { func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { b := make([]byte, InputReportSize) + _, _ = d.buildUSBInputReportInto(s, m, b) + return b +} + +func (d *DualSense) buildUSBInputReportInto(s *InputState, m *MetaState, dst []byte) (int, error) { + if len(dst) < InputReportSize { + return 0, io.ErrShortBuffer + } + b := dst[:InputReportSize] + clear(b) + + // HID GET_REPORT and the native interrupt publisher can encode + // concurrently. Sequence and corruption telemetry are one ordered report + // stream, so serialize only encoding rather than the controller state or + // media paths. + d.inputReportMu.Lock() + defer d.inputReportMu.Unlock() b[0] = ReportIDInput b[1] = uint8(int16(s.LX) + 128) @@ -1173,7 +1396,7 @@ func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { resetUSBInputReportToNeutral(b, d.seqCounter, ts, battery) } - return b + return InputReportSize, nil } func inputStateControlsInvalid(s *InputState) bool { diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 4fbcc0e3..90203ae1 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/binary" "encoding/hex" + "io" "testing" "github.com/Alia5/VIIPER/usbip" @@ -108,6 +109,35 @@ func TestMicrophoneInUsesUSBIPEndpointNumber(t *testing.T) { } } +func TestNativeMicrophoneInWritesCallerBuffer(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + frame := make([]byte, USBMicrophoneClientFrameSize) + for index := range frame { + frame[index] = byte(index*17 + 5) + } + for range microphoneTargetClientFrames { + dev.QueueMicrophonePCMFrame(frame) + } + + packet := make([]byte, USBMicrophonePacketSize) + actual, err := dev.ReadIsochronousInput( + context.Background(), uint32(EndpointMicrophoneIn), packet) + if err != nil || actual != len(packet) { + t.Fatalf("native microphone read len=%d err=%v", actual, err) + } + if !bytes.Equal(packet, frame[:len(packet)]) { + t.Fatal("native microphone read changed caller-buffer PCM") + } + if _, err = dev.ReadIsochronousInput(context.Background(), + uint32(EndpointMicrophoneIn), packet[:len(packet)-1]); err != io.ErrShortBuffer { + t.Fatalf("short native microphone buffer error=%v", err) + } +} + func TestDualSenseDescriptorDoesNotAdvertiseEdgeFeatureReports(t *testing.T) { dev, err := New(nil) if err != nil { @@ -594,6 +624,43 @@ func TestDualSenseOutputSnapshotKeepsIndependentGameFieldsAtomic(t *testing.T) { } } +func TestDualSenseLateOutputConsumerReceivesFinalExplicitState(t *testing.T) { + device, err := New(nil) + if err != nil { + t.Fatal(err) + } + report := make([]byte, OutputReportSize) + report[0] = ReportIDOutput + report[1] = outputFlag0RumbleMask + report[3] = 0 + report[4] = 0 + report[2] = outputFlag1Lightbar | outputFlag1PlayerLeds + report[outputPlayerLedsOffset] = 0x04 + report[outputLightbarOffset] = 0x12 + report[outputLightbarOffset+1] = 0x34 + report[outputLightbarOffset+2] = 0x56 + if !device.handleOutputReport(report) { + t.Fatal("final output report was rejected") + } + + var replayed []OutputState + device.SetOutputCallback(func(state OutputState) { + replayed = append(replayed, state) + }) + if len(replayed) != 1 { + t.Fatalf("late consumer replay count=%d want=1", len(replayed)) + } + state := replayed[0] + if state.RumbleSmall != 0 || state.RumbleLarge != 0 || + state.PlayerLeds != 0x04 || state.LedRed != 0x12 || + state.LedGreen != 0x34 || state.LedBlue != 0x56 { + t.Fatalf("late consumer received wrong final state: %+v", state) + } + if !bytes.Equal(state.RawOutputReport[:], report) { + t.Fatal("late consumer did not receive the exact final report") + } +} + func TestDualSenseTouchTrackingBytes(t *testing.T) { state := &InputState{} data, err := state.MarshalBinary() diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index f97089d7..5a2e2e5f 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -3,6 +3,7 @@ package dualsense import ( "encoding/binary" "encoding/json" + "errors" "fmt" "hash/crc32" "io" @@ -144,32 +145,30 @@ func dualSenseV5StreamHandler(deviceName string) api.StreamHandlerFunc { } writer.EnqueueControl(StreamFrameOutputState, data) }) - dse.SetAtomicAudioHapticsCallback(func(feedback OutputState, speakerPCM []byte) { + atomicAudioHapticsCallback := func(feedback OutputState, speakerPCM []byte) { data, err := marshalFeedback(feedback) if err != nil { logger.Error("failed to marshal V5 atomic audio/haptics feedback", "error", err) return } writer.EnqueueAtomicAudioHaptics(data, speakerPCM) - }) - dse.SetRealtimeHapticsCallback(func(feedback OutputState) { + } + realtimeHapticsCallback := func(feedback OutputState) { data, err := marshalFeedback(feedback) if err != nil { logger.Error("failed to marshal V5 realtime haptics feedback", "error", err) return } writer.EnqueueRealtimeHaptics(data) - }) - dse.SetSpeakerResetCallback(writer.ResetSpeaker) - defer func() { - dse.SetOutputCallback(nil) - dse.SetAtomicAudioHapticsCallback(nil) - dse.SetRealtimeHapticsCallback(nil) - dse.SetSpeakerResetCallback(nil) - writer.Stop() - }() - - return readDualSenseV5InputStream(conn, dse, logger) + } + dse.setV5MediaCallbacks(atomicAudioHapticsCallback, + realtimeHapticsCallback, writer.ResetSpeaker) + streamErr := readDualSenseV5InputStream(conn, dse, logger) + // Remove every producer before writer rundown. A join timeout is joined + // into the owning handler result rather than reported as clean shutdown. + dse.detachV5MediaStreamCallbacks() + dse.SetOutputCallback(nil) + return errors.Join(streamErr, writer.Stop()) } } @@ -194,6 +193,7 @@ func releaseDualSenseIdentity(devPtr *usb.Device, deviceName string) { } func readDualSenseV5InputStream(conn net.Conn, dse *DualSense, logger *slog.Logger) error { + streamDone := api.StreamDone(conn) header := make([]byte, StreamFrameHeaderSize) input := make([]byte, InputStateSize) microphonePCM := make([]byte, USBMicrophoneClientFrameSize) @@ -266,7 +266,9 @@ func readDualSenseV5InputStream(conn net.Conn, dse *DualSense, logger *slog.Logg if err := state.UnmarshalBinary(input); err != nil { return fmt.Errorf("unmarshal framed input state: %w", err) } - dse.UpdateInputState(&state) + if err := dse.UpdateInputStateUntil(streamDone, &state); err != nil { + return fmt.Errorf("queue framed DualSense input state: %w", err) + } case StreamFrameMicrophonePCM: dse.QueueMicrophonePCMFrame(microphonePCM) } diff --git a/device/dualsense/native_audio_v5_test.go b/device/dualsense/native_audio_v5_test.go index 6d9f7ef2..98642f63 100644 --- a/device/dualsense/native_audio_v5_test.go +++ b/device/dualsense/native_audio_v5_test.go @@ -6,6 +6,7 @@ import ( "encoding/binary" "net" "testing" + "time" "github.com/Alia5/VIIPER/usbip" ) @@ -26,10 +27,12 @@ func TestAppendDualSenseV5SpeakerPreservesRawFrontPair(t *testing.T) { } destination := make([]byte, 0, dualSenseV5SpeakerPayloadSize) - if allocations := testing.AllocsPerRun(1000, func() { - destination = appendDualSenseV5Speaker(destination[:0], source) - }); allocations != 0 { - t.Fatalf("V5 front-channel assembler allocated %.2f objects per generation", allocations) + if !raceDetectorEnabled { + if allocations := testing.AllocsPerRun(1000, func() { + destination = appendDualSenseV5Speaker(destination[:0], source) + }); allocations != 0 { + t.Fatalf("V5 front-channel assembler allocated %.2f objects per generation", allocations) + } } destination = appendDualSenseV5Speaker(destination[:0], source) if len(destination) != dualSenseV5SpeakerPayloadSize { @@ -291,6 +294,93 @@ func TestDualSenseV5EndpointResetIsHardBoundaryForBothMediaClocks(t *testing.T) } } +func TestDualSenseV5RejectsPublicationFromPreResetRevision(t *testing.T) { + device, err := New(nil) + if err != nil { + t.Fatal(err) + } + device.SetInterfaceAltSetting(InterfaceHapticsAudio, 1) + published := 0 + device.setV5MediaCallbacks(func(OutputState, []byte) { + published++ + }, nil, nil) + + device.mtx.Lock() + revision := device.mediaRevision + device.mtx.Unlock() + pending := pendingBluetoothHapticsReport{ + revision: revision, + feedback: OutputState{BluetoothCombinedOutputReport: [BluetoothCombinedHapticsReportSize]byte{BluetoothCombinedHapticsReportID}}, + speakerPCM: make([]byte, dualSenseV5SpeakerPayloadSize), + } + + device.ResetEndpoint(EndpointHapticsAudioOut) + if device.publishV5Media(pending) { + t.Fatal("pre-reset media revision was published") + } + if published != 0 { + t.Fatalf("pre-reset media callback count=%d", published) + } +} + +func TestDualSenseV5ResetWaitsForInFlightDevicePublication(t *testing.T) { + device, err := New(nil) + if err != nil { + t.Fatal(err) + } + device.SetInterfaceAltSetting(InterfaceHapticsAudio, 1) + entered := make(chan struct{}) + release := make(chan struct{}) + callbackDone := make(chan struct{}) + resetCalls := 0 + device.setV5MediaCallbacks(func(OutputState, []byte) { + close(entered) + <-release + close(callbackDone) + }, nil, func() { + resetCalls++ + }) + + pcm := makeV5USBPCM(0, dualSenseV5SpeakerFrames, 12000) + transferDone := make(chan struct{}) + go func() { + device.HandleTransfer(context.Background(), EndpointHapticsAudioOut, + usbip.DirOut, pcm) + close(transferDone) + }() + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("media callback did not start") + } + + resetDone := make(chan struct{}) + go func() { + device.ResetEndpoint(EndpointHapticsAudioOut) + close(resetDone) + }() + select { + case <-resetDone: + t.Fatal("endpoint reset crossed an in-flight device publication") + case <-time.After(20 * time.Millisecond): + } + close(release) + select { + case <-callbackDone: + case <-time.After(time.Second): + t.Fatal("media callback did not finish") + } + select { + case <-resetDone: + case <-time.After(time.Second): + t.Fatal("endpoint reset did not finish after publication") + } + <-transferDone + if resetCalls != 1 { + t.Fatalf("transport reset calls=%d want=1", resetCalls) + } +} + func TestDualSenseV5SpeakerCombinesFreshStateWithCompletedRearSample(t *testing.T) { device, captured := newV5CaptureDevice(t) setV5TestLightbar(t, device, 0x11) @@ -366,7 +456,9 @@ func TestDualSenseV5WriterPublishesExactAtomicContract(t *testing.T) { } _ = client.Close() - writer.Stop() + if err := writer.Stop(); err != nil { + t.Fatal(err) + } } func TestDualSenseV5WriterRetainsNewestGenerationWhenBounded(t *testing.T) { @@ -383,8 +475,8 @@ func TestDualSenseV5WriterRetainsNewestGenerationWhenBounded(t *testing.T) { len(writer.audio), dualSenseOutputAudioQueueCapacity) } state := writer.telemetry.snapshot() - if state.ReceivedPayloads != dualSenseOutputAudioQueueCapacity+1 || - state.EnqueuedPayloads != dualSenseOutputAudioQueueCapacity+1 || + if state.ReceivedPayloads != uint64(dualSenseOutputAudioQueueCapacity+1) || + state.EnqueuedPayloads != uint64(dualSenseOutputAudioQueueCapacity+1) || state.DroppedPayloads != 1 || state.DroppedBytes != dualSenseV5SpeakerPayloadSize { t.Fatalf("unexpected V5 bounded telemetry: %+v", state) @@ -392,6 +484,7 @@ func TestDualSenseV5WriterRetainsNewestGenerationWhenBounded(t *testing.T) { for expected := 1; expected <= dualSenseOutputAudioQueueCapacity; expected++ { frame := <-writer.audio + writer.recordMediaDequeued(frame) feedbackLength := int(binary.LittleEndian.Uint16(frame.payload[:2])) feedback := frame.payload[2 : 2+feedbackLength] speaker := frame.payload[2+feedbackLength:] @@ -402,7 +495,7 @@ func TestDualSenseV5WriterRetainsNewestGenerationWhenBounded(t *testing.T) { } writer.release(frame) } - if len(writer.audioFree) != dualSenseOutputAudioQueueCapacity { + if len(writer.audioFree) != dualSenseOutputAudioPoolCapacity { t.Fatalf("V5 bounded queue leaked buffers: free=%d", len(writer.audioFree)) } } diff --git a/device/dualsense/native_input_test.go b/device/dualsense/native_input_test.go new file mode 100644 index 00000000..b470b02c --- /dev/null +++ b/device/dualsense/native_input_test.go @@ -0,0 +1,29 @@ +//go:build !race + +package dualsense + +import ( + "io" + "testing" +) + +func TestNativeInputEncodingUsesCallerBufferWithoutAllocating(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + state := NewInputState() + meta := &MetaState{BatteryStatus: BatteryFullyCharged} + buffer := make([]byte, InputReportSize) + if allocations := testing.AllocsPerRun(1000, func() { + written, encodeErr := dev.buildUSBInputReportInto(state, meta, buffer) + if encodeErr != nil || written != InputReportSize { + panic("DualSense native input encoding failed") + } + }); allocations != 0 { + t.Fatalf("native input allocations=%v want 0", allocations) + } + if _, err = dev.buildUSBInputReportInto(state, meta, buffer[:InputReportSize-1]); err != io.ErrShortBuffer { + t.Fatalf("short-buffer error=%v want %v", err, io.ErrShortBuffer) + } +} diff --git a/device/dualsense/native_microphone_alloc_test.go b/device/dualsense/native_microphone_alloc_test.go new file mode 100644 index 00000000..c31bc6c0 --- /dev/null +++ b/device/dualsense/native_microphone_alloc_test.go @@ -0,0 +1,32 @@ +//go:build !race + +package dualsense + +import ( + "context" + "testing" +) + +func TestNativeMicrophonePacketEncodingDoesNotAllocate(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + frame := make([]byte, USBMicrophoneClientFrameSize) + for range microphoneMaximumClientFrames { + dev.QueueMicrophonePCMFrame(frame) + } + packet := make([]byte, USBMicrophoneMaxPacketSize) + ctx := context.Background() + allocations := testing.AllocsPerRun(100, func() { + if _, readErr := dev.ReadIsochronousInput( + ctx, uint32(EndpointMicrophoneIn), packet, + ); readErr != nil { + panic(readErr) + } + }) + if allocations != 0 { + t.Fatalf("native microphone packet encoding allocated %.2f objects", allocations) + } +} diff --git a/device/dualsense/output_backpressure_test.go b/device/dualsense/output_backpressure_test.go new file mode 100644 index 00000000..31b2a0c8 --- /dev/null +++ b/device/dualsense/output_backpressure_test.go @@ -0,0 +1,413 @@ +package dualsense + +import ( + "context" + "errors" + "io" + "log/slog" + "net" + "sync" + "testing" + "time" + + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" +) + +func TestDualSenseOrderedPublicationIsFIFOWithConcurrentProducers(t *testing.T) { + writer := newDualSenseOutputWriter(nil, nil, nil) + const producers = 24 + start := make(chan struct{}) + var wait sync.WaitGroup + wait.Add(producers) + for marker := 0; marker < producers; marker++ { + marker := byte(marker) + go func() { + defer wait.Done() + <-start + writer.EnqueueControl(StreamFrameOutputState, []byte{marker}) + }() + } + close(start) + wait.Wait() + + seen := make(map[byte]bool, producers) + for publication := uint64(1); publication <= producers; publication++ { + frame := <-writer.control + decrementUint64(&writer.telemetry.orderedQueueDepth) + if frame.publication != publication { + t.Fatalf("publication=%d want=%d", frame.publication, publication) + } + if len(frame.payload) != 1 || seen[frame.payload[0]] { + t.Fatalf("invalid or duplicate payload: % x", frame.payload) + } + seen[frame.payload[0]] = true + } + state := writer.telemetry.snapshot() + if state.OrderedReceived != producers || state.OrderedEnqueued != producers || + state.OrderedRejected != 0 || state.OrderedSaturations != 0 { + t.Fatalf("unexpected concurrent publication state: %+v", state) + } +} + +func TestDualSenseMixedMediaWindowUsesExactDurations(t *testing.T) { + writer := newDualSenseOutputWriter(nil, nil, nil) + feedback, speaker := testV5Media(0x31) + for marker := 0; marker < dualSenseOutputAudioQueueCapacity; marker++ { + if marker%2 == 0 { + writer.EnqueueAtomicAudioHaptics(feedback, speaker) + } else { + writer.EnqueueRealtimeHaptics([]byte{byte(marker)}) + } + } + state := writer.telemetry.snapshot() + if state.QueueDurationUS > dualSenseMediaMaximumBufferTime.Microseconds() || + state.QueueDurationHighUS > dualSenseMediaMaximumBufferTime.Microseconds() { + t.Fatalf("media time bound exceeded: %+v", state) + } + if state.Overruns == 0 { + t.Fatal("mixed 10 ms/10.667 ms media did not evict the oldest frame") + } + var lastType byte + for len(writer.audio) != 0 { + frame := <-writer.audio + writer.recordMediaDequeued(frame) + // Input alternated, so retained FIFO order must continue alternating. + if lastType != 0 && frame.frameType == lastType { + t.Fatalf("mixed media FIFO reordered frame type 0x%02x", frame.frameType) + } + lastType = frame.frameType + writer.release(frame) + } + if writer.telemetry.queueDurationNS.Load() != 0 { + t.Fatalf("media duration accounting leaked %d ns", + writer.telemetry.queueDurationNS.Load()) + } +} + +func TestDualSenseResetCountsBothMediaClocksAsStale(t *testing.T) { + writer := newDualSenseOutputWriter(nil, nil, nil) + feedback, speaker := testV5Media(0x44) + writer.EnqueueAtomicAudioHaptics(feedback, speaker) + writer.EnqueueRealtimeHaptics([]byte{1, 2, 3}) + writer.ResetSpeaker() + state := writer.telemetry.snapshot() + if state.StalePayloads != 2 || + state.StaleBytes != dualSenseV5SpeakerPayloadSize+3 || + state.QueueDepth != 0 || state.QueueDurationUS != 0 || + len(writer.audio) != 0 { + t.Fatalf("reset did not retire both media clocks: %+v", state) + } + if len(writer.audioFree) != dualSenseOutputAudioPoolCapacity { + t.Fatalf("reset leaked media pool: free=%d", len(writer.audioFree)) + } +} + +func TestDualSenseWriterRecordsOnlyObservedGenerationGap(t *testing.T) { + writer := newDualSenseOutputWriter(nil, nil, nil) + writer.telemetry.lastRealtimeEnqueueNS.Store( + time.Now().Add(-35 * time.Millisecond).UnixNano()) + writer.EnqueueRealtimeHaptics([]byte{1}) + state := writer.telemetry.snapshot() + if state.LateGaps != 1 || state.Underruns < 2 { + t.Fatalf("unexpected observed cadence accounting: %+v", state) + } +} + +func TestDualSenseOutputBackpressureTelemetryIsExposed(t *testing.T) { + controller, err := New(nil) + if err != nil { + t.Fatal(err) + } + writer := newDualSenseOutputWriter(nil, controller.beginSpeakerStream(), nil) + writer.EnqueueControl(StreamFrameOutputState, []byte{1}) + writer.EnqueueRealtimeHaptics([]byte{2, 3}) + state := controller.GetDeviceSpecificArgs() + if state["speakerOrderedFramesEnqueued"] != uint64(1) || + state["speakerPayloadsEnqueued"] != uint64(1) || + state["speakerQueueDurationUS"] != + dualSenseRealtimeHapticsCadence.Microseconds() { + t.Fatalf("transport telemetry was not exposed: %+v", state) + } +} + +func TestDualSenseOrderedFaultWakesOwningReadLoop(t *testing.T) { + server, client := net.Pipe() + writer := newDualSenseOutputWriter(server, nil, nil) + readDone := make(chan error, 1) + go func() { + buffer := make([]byte, 1) + _, err := server.Read(buffer) + readDone <- err + }() + for marker := 0; marker <= dualSenseOutputControlQueueCapacity; marker++ { + writer.EnqueueControl(StreamFrameOutputState, []byte{byte(marker)}) + } + writer.EnqueueRealtimeHaptics([]byte{1, 2, 3}) + select { + case err := <-readDone: + if err == nil { + t.Fatal("owning read loop returned without stream fault") + } + case <-time.After(time.Second): + t.Fatal("ordered saturation did not wake the owning read loop") + } + state := writer.telemetry.snapshot() + if state.ReceivedPayloads != 1 || state.RejectedPayloads != 1 || + state.RejectedBytes != 3 || state.EnqueuedPayloads != 0 { + t.Fatalf("media rejection after stream fault was not accounted: %+v", state) + } + _ = client.Close() +} + +func TestDualSenseLifecycleDrainAccountsEveryAcceptedQueuedFrame(t *testing.T) { + writer := newDualSenseOutputWriter(nil, nil, nil) + writer.EnqueueControl(StreamFrameOutputState, []byte{1}) + writer.EnqueueControl(StreamFrameOutputState, []byte{2, 3}) + writer.EnqueueRealtimeHaptics([]byte{1, 2, 3}) + writer.EnqueueRealtimeHaptics([]byte{4, 5}) + writer.requestStop() + go writer.Run() + if err := writer.Stop(); err != nil { + t.Fatal(err) + } + select { + case <-writer.done: + default: + t.Fatal("Stop returned before writer rundown completed") + } + state := writer.telemetry.snapshot() + if state.OrderedLifecycleDiscardedFrames != 2 || + state.OrderedLifecycleDiscardedBytes != 3 || + state.LifecycleDiscardedPayloads != 2 || + state.LifecycleDiscardedBytes != 5 || + state.OrderedQueueDepth != 0 || state.QueueDepth != 0 || + state.QueueDurationUS != 0 { + t.Fatalf("lifecycle drain was not fully accounted: %+v", state) + } +} + +type dualSenseWriteGateConn struct { + net.Conn + started chan struct{} + release chan struct{} + once sync.Once +} + +func (c *dualSenseWriteGateConn) Write(payload []byte) (int, error) { + c.once.Do(func() { close(c.started) }) + <-c.release + return len(payload), nil +} + +func TestDualSenseStopLatchesTimeoutAndContinuesAuthoritativeJoin(t *testing.T) { + server, client := net.Pipe() + gate := &dualSenseWriteGateConn{ + Conn: server, started: make(chan struct{}), release: make(chan struct{}), + } + writer := newDualSenseOutputWriter(gate, nil, nil) + writer.EnqueueRealtimeHaptics([]byte{1, 2, 3}) + go writer.Run() + select { + case <-gate.started: + case <-time.After(time.Second): + t.Fatal("media write did not become in-flight") + } + + err := writer.Stop() + if !errors.Is(err, errDualSenseOutputJoinTimeout) { + t.Fatalf("Stop error=%v want=%v", err, errDualSenseOutputJoinTimeout) + } + select { + case <-writer.done: + t.Fatal("timeout was treated as completed rundown") + default: + } + state := writer.telemetry.snapshot() + if state.TeardownFailures != 1 || !state.TeardownPending { + t.Fatalf("teardown timeout was not latched: %+v", state) + } + + close(gate.release) + select { + case <-writer.done: + case <-time.After(time.Second): + t.Fatal("writer did not finish after in-flight write was released") + } + deadline := time.Now().Add(time.Second) + for writer.telemetry.snapshot().TeardownPending && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if writer.telemetry.snapshot().TeardownPending { + t.Fatal("continued teardown join did not clear pending state") + } + if err := writer.Stop(); !errors.Is(err, errDualSenseOutputJoinTimeout) { + t.Fatalf("latched Stop error=%v want=%v", err, + errDualSenseOutputJoinTimeout) + } + _ = client.Close() +} + +type dualSenseUninterruptibleStreamConn struct { + readRelease chan struct{} + writeStarted chan struct{} + writeRelease chan struct{} + writeOnce sync.Once +} + +func newDualSenseUninterruptibleStreamConn() *dualSenseUninterruptibleStreamConn { + return &dualSenseUninterruptibleStreamConn{ + readRelease: make(chan struct{}), + writeStarted: make(chan struct{}), + writeRelease: make(chan struct{}), + } +} + +func (c *dualSenseUninterruptibleStreamConn) Read([]byte) (int, error) { + <-c.readRelease + return 0, io.EOF +} + +func (c *dualSenseUninterruptibleStreamConn) Write(payload []byte) (int, error) { + c.writeOnce.Do(func() { close(c.writeStarted) }) + <-c.writeRelease + return len(payload), nil +} + +func (*dualSenseUninterruptibleStreamConn) Close() error { return nil } + +func (*dualSenseUninterruptibleStreamConn) LocalAddr() net.Addr { + return &net.TCPAddr{} +} + +func (*dualSenseUninterruptibleStreamConn) RemoteAddr() net.Addr { + return &net.TCPAddr{} +} + +func (*dualSenseUninterruptibleStreamConn) SetDeadline(time.Time) error { + return nil +} + +func (*dualSenseUninterruptibleStreamConn) SetReadDeadline(time.Time) error { + return nil +} + +func (*dualSenseUninterruptibleStreamConn) SetWriteDeadline(time.Time) error { + return nil +} + +func TestDualSenseHandlerDetachesBeforeAuthoritativeStop(t *testing.T) { + controller, err := New(nil) + if err != nil { + t.Fatal(err) + } + var device usb.Device = controller + conn := newDualSenseUninterruptibleStreamConn() + streamHandler := dualSenseV5StreamHandler("DualSense") + errCh := make(chan error, 1) + go func() { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + errCh <- streamHandler(conn, &device, logger) + }() + deadline := time.Now().Add(time.Second) + for { + controller.mtx.Lock() + callbacksReady := controller.atomicAudioHapticsFunc != nil && + controller.speakerResetFunc != nil + controller.mtx.Unlock() + if callbacksReady { + break + } + if time.Now().After(deadline) { + t.Fatal("handler did not install media callbacks") + } + time.Sleep(time.Millisecond) + } + + controller.SetInterfaceAltSetting(InterfaceHapticsAudio, 1) + pcm := make([]byte, + dualSenseV5SpeakerFrames*USBHapticsAudioFrameSize) + controller.HandleTransfer(context.Background(), EndpointHapticsAudioOut, + usbip.DirOut, pcm) + select { + case <-conn.writeStarted: + case <-time.After(time.Second): + t.Fatal("handler writer did not enter the uninterruptible write") + } + close(conn.readRelease) + + select { + case err := <-errCh: + if !errors.Is(err, errDualSenseOutputJoinTimeout) { + t.Fatalf("handler error=%v want=%v", err, + errDualSenseOutputJoinTimeout) + } + case <-time.After(time.Second): + t.Fatal("handler cleanup blocked in reset before Stop could report failure") + } + controller.mtx.Lock() + callbacksDetached := controller.outputFunc == nil && + controller.atomicAudioHapticsFunc == nil && + controller.realtimeHapticsFunc == nil && + controller.speakerResetFunc == nil + controller.mtx.Unlock() + if !callbacksDetached { + t.Fatal("handler retained callbacks after teardown failure") + } + state := controller.GetDeviceSpecificArgs() + if state["speakerTeardownFailures"] != uint64(1) || + state["speakerTeardownPending"] != true { + t.Fatalf("handler did not expose pending teardown: %+v", state) + } + + close(conn.writeRelease) + deadline = time.Now().Add(time.Second) + for { + state = controller.GetDeviceSpecificArgs() + if state["speakerTeardownPending"] == false && + state["speakerStreamActive"] == false { + break + } + if time.Now().After(deadline) { + t.Fatalf("continued writer join did not complete: %+v", state) + } + time.Sleep(time.Millisecond) + } +} + +func TestDualSenseResetCloseAndInFlightWriteCannotDeadlock(t *testing.T) { + server, client := net.Pipe() + conn := newDeadlineTrackingConn(server) + writer := newDualSenseOutputWriter(conn, nil, nil) + feedback, speaker := testV5Media(0x71) + writer.EnqueueAtomicAudioHaptics(feedback, speaker) + go writer.Run() + select { + case <-conn.started: + case <-time.After(time.Second): + t.Fatal("media write did not become in-flight") + } + resetDone := make(chan struct{}) + stopDone := make(chan error, 1) + go func() { writer.ResetSpeaker(); close(resetDone) }() + go func() { stopDone <- writer.Stop() }() + select { + case <-resetDone: + case <-time.After(time.Second): + t.Fatal("reset deadlocked with in-flight write") + } + select { + case err := <-stopDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("stop deadlocked with in-flight write") + } + select { + case <-writer.done: + default: + t.Fatal("Stop returned before writer rundown completed") + } + _ = client.Close() +} diff --git a/device/dualsense/output_writer.go b/device/dualsense/output_writer.go index 308baa57..55048a7d 100644 --- a/device/dualsense/output_writer.go +++ b/device/dualsense/output_writer.go @@ -2,6 +2,7 @@ package dualsense import ( "encoding/binary" + "errors" "log/slog" "net" "sync" @@ -11,50 +12,115 @@ import ( const ( dualSenseOutputControlQueueCapacity = 32 - dualSenseOutputAudioQueueCapacity = 64 + dualSenseMediaMaximumBufferTime = 200 * time.Millisecond + dualSenseSpeakerGenerationCadence = time.Second * dualSenseV5SpeakerFrames / + USBHapticsAudioSampleRate + dualSenseRealtimeHapticsCadence = time.Second * + (BluetoothHapticsSampleSize / 2) / BluetoothHapticsSampleRate + // Twenty 10 ms speaker generations are the largest possible frame count. + // Enqueue also accounts exact per-frame duration, so the 10.667 ms realtime + // lane and mixed media remain within the same 200 ms ceiling. + dualSenseOutputAudioQueueCapacity = int( + dualSenseMediaMaximumBufferTime / dualSenseSpeakerGenerationCadence) + dualSenseRealtimeMediaQueueCapacity = int( + dualSenseMediaMaximumBufferTime / dualSenseRealtimeHapticsCadence) + // One additional buffer belongs to the sole in-flight socket write while + // the full 200 ms queue remains available to producers. + dualSenseOutputAudioPoolCapacity = dualSenseOutputAudioQueueCapacity + 1 // V5 carries one 480-frame V5 generation: the combined feedback and // its matching front-channel stereo PCM. dualSenseSpeakerPayloadCapacity = dualSenseAtomicFeedbackPrefix + OutputStateV5Size + dualSenseV5SpeakerPayloadSize dualSenseSpeakerTraceInterval = 10 * time.Second dualSenseSpeakerResetTimeout = 250 * time.Millisecond + dualSenseOutputJoinTimeout = 300 * time.Millisecond dualSenseAtomicFeedbackPrefix = 2 ) +var errDualSenseOutputJoinTimeout = errors.New( + "DualSense output writer did not stop before the join deadline") + type dualSenseSpeakerStreamTelemetry struct { - receivedPayloads atomic.Uint64 - receivedBytes atomic.Uint64 - enqueuedPayloads atomic.Uint64 - enqueuedBytes atomic.Uint64 - droppedPayloads atomic.Uint64 - droppedBytes atomic.Uint64 - writtenPayloads atomic.Uint64 - writtenBytes atomic.Uint64 - writeFailures atomic.Uint64 - queueDepth atomic.Uint64 - queueHighWater atomic.Uint64 - lastEnqueueNS atomic.Int64 - maxEnqueueGapNS atomic.Int64 - lastWriteNS atomic.Int64 - maxWriteGapNS atomic.Int64 - active atomic.Bool + orderedReceived atomic.Uint64 + orderedEnqueued atomic.Uint64 + orderedRejected atomic.Uint64 + orderedWritten atomic.Uint64 + orderedSaturations atomic.Uint64 + orderedQueueDepth atomic.Uint64 + orderedQueueHighWater atomic.Uint64 + orderedLifecycleDiscardedFrames atomic.Uint64 + orderedLifecycleDiscardedBytes atomic.Uint64 + receivedPayloads atomic.Uint64 + receivedBytes atomic.Uint64 + enqueuedPayloads atomic.Uint64 + enqueuedBytes atomic.Uint64 + rejectedPayloads atomic.Uint64 + rejectedBytes atomic.Uint64 + droppedPayloads atomic.Uint64 + droppedBytes atomic.Uint64 + overruns atomic.Uint64 + underruns atomic.Uint64 + lateGaps atomic.Uint64 + stalePayloads atomic.Uint64 + staleBytes atomic.Uint64 + lifecycleDiscardedPayloads atomic.Uint64 + lifecycleDiscardedBytes atomic.Uint64 + writtenPayloads atomic.Uint64 + writtenBytes atomic.Uint64 + writeFailures atomic.Uint64 + orderedWriteFailures atomic.Uint64 + queueDepth atomic.Uint64 + queueHighWater atomic.Uint64 + queueDurationNS atomic.Int64 + queueDurationHighNS atomic.Int64 + lastEnqueueNS atomic.Int64 + lastRealtimeEnqueueNS atomic.Int64 + maxEnqueueGapNS atomic.Int64 + lastWriteNS atomic.Int64 + maxWriteGapNS atomic.Int64 + active atomic.Bool + teardownFailures atomic.Uint64 + teardownPending atomic.Bool } type dualSenseSpeakerStreamSnapshot struct { - ReceivedPayloads uint64 - ReceivedBytes uint64 - EnqueuedPayloads uint64 - EnqueuedBytes uint64 - DroppedPayloads uint64 - DroppedBytes uint64 - WrittenPayloads uint64 - WrittenBytes uint64 - WriteFailures uint64 - QueueDepth uint64 - QueueHighWater uint64 - MaxEnqueueGapUS int64 - MaxWriteGapUS int64 - Active bool + OrderedReceived uint64 + OrderedEnqueued uint64 + OrderedRejected uint64 + OrderedWritten uint64 + OrderedSaturations uint64 + OrderedQueueDepth uint64 + OrderedQueueHighWater uint64 + OrderedLifecycleDiscardedFrames uint64 + OrderedLifecycleDiscardedBytes uint64 + ReceivedPayloads uint64 + ReceivedBytes uint64 + EnqueuedPayloads uint64 + EnqueuedBytes uint64 + RejectedPayloads uint64 + RejectedBytes uint64 + DroppedPayloads uint64 + DroppedBytes uint64 + Overruns uint64 + Underruns uint64 + LateGaps uint64 + StalePayloads uint64 + StaleBytes uint64 + LifecycleDiscardedPayloads uint64 + LifecycleDiscardedBytes uint64 + WrittenPayloads uint64 + WrittenBytes uint64 + WriteFailures uint64 + OrderedWriteFailures uint64 + QueueDepth uint64 + QueueHighWater uint64 + QueueDurationUS int64 + QueueDurationHighUS int64 + MaxEnqueueGapUS int64 + MaxWriteGapUS int64 + Active bool + TeardownFailures uint64 + TeardownPending bool } func (s *dualSenseSpeakerStreamTelemetry) snapshot() dualSenseSpeakerStreamSnapshot { @@ -62,20 +128,43 @@ func (s *dualSenseSpeakerStreamTelemetry) snapshot() dualSenseSpeakerStreamSnaps return dualSenseSpeakerStreamSnapshot{} } return dualSenseSpeakerStreamSnapshot{ - ReceivedPayloads: s.receivedPayloads.Load(), - ReceivedBytes: s.receivedBytes.Load(), - EnqueuedPayloads: s.enqueuedPayloads.Load(), - EnqueuedBytes: s.enqueuedBytes.Load(), - DroppedPayloads: s.droppedPayloads.Load(), - DroppedBytes: s.droppedBytes.Load(), - WrittenPayloads: s.writtenPayloads.Load(), - WrittenBytes: s.writtenBytes.Load(), - WriteFailures: s.writeFailures.Load(), - QueueDepth: s.queueDepth.Load(), - QueueHighWater: s.queueHighWater.Load(), - MaxEnqueueGapUS: s.maxEnqueueGapNS.Load() / int64(time.Microsecond), - MaxWriteGapUS: s.maxWriteGapNS.Load() / int64(time.Microsecond), - Active: s.active.Load(), + OrderedReceived: s.orderedReceived.Load(), + OrderedEnqueued: s.orderedEnqueued.Load(), + OrderedRejected: s.orderedRejected.Load(), + OrderedWritten: s.orderedWritten.Load(), + OrderedSaturations: s.orderedSaturations.Load(), + OrderedQueueDepth: s.orderedQueueDepth.Load(), + OrderedQueueHighWater: s.orderedQueueHighWater.Load(), + OrderedLifecycleDiscardedFrames: s.orderedLifecycleDiscardedFrames.Load(), + OrderedLifecycleDiscardedBytes: s.orderedLifecycleDiscardedBytes.Load(), + ReceivedPayloads: s.receivedPayloads.Load(), + ReceivedBytes: s.receivedBytes.Load(), + EnqueuedPayloads: s.enqueuedPayloads.Load(), + EnqueuedBytes: s.enqueuedBytes.Load(), + RejectedPayloads: s.rejectedPayloads.Load(), + RejectedBytes: s.rejectedBytes.Load(), + DroppedPayloads: s.droppedPayloads.Load(), + DroppedBytes: s.droppedBytes.Load(), + Overruns: s.overruns.Load(), + Underruns: s.underruns.Load(), + LateGaps: s.lateGaps.Load(), + StalePayloads: s.stalePayloads.Load(), + StaleBytes: s.staleBytes.Load(), + LifecycleDiscardedPayloads: s.lifecycleDiscardedPayloads.Load(), + LifecycleDiscardedBytes: s.lifecycleDiscardedBytes.Load(), + WrittenPayloads: s.writtenPayloads.Load(), + WrittenBytes: s.writtenBytes.Load(), + WriteFailures: s.writeFailures.Load(), + OrderedWriteFailures: s.orderedWriteFailures.Load(), + QueueDepth: s.queueDepth.Load(), + QueueHighWater: s.queueHighWater.Load(), + QueueDurationUS: s.queueDurationNS.Load() / int64(time.Microsecond), + QueueDurationHighUS: s.queueDurationHighNS.Load() / int64(time.Microsecond), + MaxEnqueueGapUS: s.maxEnqueueGapNS.Load() / int64(time.Microsecond), + MaxWriteGapUS: s.maxWriteGapNS.Load() / int64(time.Microsecond), + Active: s.active.Load(), + TeardownFailures: s.teardownFailures.Load(), + TeardownPending: s.teardownPending.Load(), } } @@ -98,35 +187,49 @@ func recordMaximumUint64(target *atomic.Uint64, value uint64) { } type dualSenseOutputFrame struct { - frameType byte - payload []byte - audio bool - generation uint64 + frameType byte + payload []byte + // media participates in the reset generation and bounded-time queue. + // audio marks a preallocated atomic speaker buffer that must be returned. + media bool + audio bool + mediaBytes int + mediaDuration time.Duration + generation uint64 + publication uint64 } // dualSenseOutputWriter serializes controller feedback and virtual speaker // PCM on one framed stream. USB isochronous completion must never wait for TCP // backpressure, so speaker extraction uses a fixed pool and a bounded queue. type dualSenseOutputWriter struct { - conn net.Conn - logger *slog.Logger - telemetry *dualSenseSpeakerStreamTelemetry - control chan dualSenseOutputFrame - realtimeHaptics chan dualSenseOutputFrame - audio chan dualSenseOutputFrame - audioFree chan []byte - stop chan struct{} - done chan struct{} - stopOnce sync.Once - enqueueLock sync.RWMutex - audioEnqueue sync.Mutex - audioWrite sync.Mutex - stopped bool - streamViable atomic.Bool - audioGeneration atomic.Uint64 - sequence uint32 - packet []byte - lastTrace time.Time + conn net.Conn + logger *slog.Logger + telemetry *dualSenseSpeakerStreamTelemetry + control chan dualSenseOutputFrame + // audio is the single media FIFO for atomic speaker/haptics and realtime + // haptics. One FIFO preserves callback publication order across both clocks. + audio chan dualSenseOutputFrame + audioFree chan []byte + stop chan struct{} + done chan struct{} + stopOnce sync.Once + enqueueLock sync.RWMutex + controlEnqueue sync.Mutex + mediaEnqueue sync.Mutex + audioWrite sync.Mutex + stopped bool + streamViable atomic.Bool + accepting atomic.Bool + audioGeneration atomic.Uint64 + orderedPublication uint64 + sequence uint32 + packet []byte + lastTrace time.Time + teardownMu sync.Mutex + teardownErr error + teardownFailureOnce sync.Once + teardownJoinOnce sync.Once } func newDualSenseOutputWriter(conn net.Conn, @@ -135,7 +238,10 @@ func newDualSenseOutputWriter(conn net.Conn, telemetry = &dualSenseSpeakerStreamTelemetry{} } telemetry.queueDepth.Store(0) + telemetry.queueDurationNS.Store(0) + telemetry.orderedQueueDepth.Store(0) telemetry.lastEnqueueNS.Store(0) + telemetry.lastRealtimeEnqueueNS.Store(0) telemetry.lastWriteNS.Store(0) telemetry.active.Store(true) w := &dualSenseOutputWriter{ @@ -143,17 +249,16 @@ func newDualSenseOutputWriter(conn net.Conn, logger: logger, telemetry: telemetry, control: make(chan dualSenseOutputFrame, dualSenseOutputControlQueueCapacity), - realtimeHaptics: make(chan dualSenseOutputFrame, - dualSenseOutputControlQueueCapacity), audio: make(chan dualSenseOutputFrame, dualSenseOutputAudioQueueCapacity), - audioFree: make(chan []byte, dualSenseOutputAudioQueueCapacity), + audioFree: make(chan []byte, dualSenseOutputAudioPoolCapacity), stop: make(chan struct{}), done: make(chan struct{}), packet: make([]byte, 0, StreamFrameHeaderSize+dualSenseSpeakerPayloadCapacity), lastTrace: time.Now(), } w.streamViable.Store(conn != nil) - for range dualSenseOutputAudioQueueCapacity { + w.accepting.Store(true) + for range dualSenseOutputAudioPoolCapacity { w.audioFree <- make([]byte, dualSenseSpeakerPayloadCapacity) } return w @@ -166,14 +271,26 @@ func (w *dualSenseOutputWriter) EnqueueRealtimeHaptics(payload []byte) { if len(payload) == 0 { return } + w.mediaEnqueue.Lock() + defer w.mediaEnqueue.Unlock() + w.recordSpeakerReceive(len(payload), StreamFrameRealtimeHaptics) + if !w.accepting.Load() { + w.recordSpeakerRejected(len(payload)) + return + } w.enqueueLock.RLock() defer w.enqueueLock.RUnlock() - if w.stopped { + if w.stopped || !w.accepting.Load() { + w.recordSpeakerRejected(len(payload)) return } - w.enqueueFrameLocked(w.realtimeHaptics, dualSenseOutputFrame{ - frameType: StreamFrameRealtimeHaptics, - payload: append([]byte(nil), payload...), + w.enqueueMediaDropOldestLocked(dualSenseOutputFrame{ + frameType: StreamFrameRealtimeHaptics, + payload: append([]byte(nil), payload...), + media: true, + mediaBytes: len(payload), + mediaDuration: dualSenseRealtimeHapticsCadence, + generation: w.audioGeneration.Load(), }) } @@ -181,15 +298,43 @@ func (w *dualSenseOutputWriter) EnqueueControl(frameType byte, payload []byte) { if len(payload) == 0 { return } - w.enqueueLock.RLock() - defer w.enqueueLock.RUnlock() - if w.stopped { + w.controlEnqueue.Lock() + defer w.controlEnqueue.Unlock() + w.telemetry.orderedReceived.Add(1) + if !w.accepting.Load() { + w.telemetry.orderedRejected.Add(1) return } - w.enqueueFrameLocked(w.control, dualSenseOutputFrame{ + frame := dualSenseOutputFrame{ frameType: frameType, payload: append([]byte(nil), payload...), - }) + } + w.enqueueLock.RLock() + if w.stopped || !w.accepting.Load() { + w.telemetry.orderedRejected.Add(1) + w.enqueueLock.RUnlock() + return + } + w.orderedPublication++ + frame.publication = w.orderedPublication + depth := w.telemetry.orderedQueueDepth.Add(1) + select { + case w.control <- frame: + w.telemetry.orderedEnqueued.Add(1) + recordMaximumUint64(&w.telemetry.orderedQueueHighWater, depth) + w.enqueueLock.RUnlock() + return + default: + decrementUint64(&w.telemetry.orderedQueueDepth) + } + w.telemetry.orderedRejected.Add(1) + if !w.accepting.CompareAndSwap(true, false) { + w.enqueueLock.RUnlock() + return + } + w.telemetry.orderedSaturations.Add(1) + w.enqueueLock.RUnlock() + w.faultStream("ordered output queue saturated") } // EnqueueAtomicAudioHaptics publishes one V5 generation. A little-endian @@ -204,26 +349,29 @@ func (w *dualSenseOutputWriter) EnqueueAtomicAudioHaptics(feedback, speakerPCM [ return } + w.mediaEnqueue.Lock() + defer w.mediaEnqueue.Unlock() + w.recordSpeakerReceive(len(speakerPCM), StreamFrameAtomicAudioHaptics) + if !w.accepting.Load() { + w.recordSpeakerRejected(len(speakerPCM)) + return + } w.enqueueLock.RLock() defer w.enqueueLock.RUnlock() - if w.stopped { + if w.stopped || !w.accepting.Load() { + w.recordSpeakerRejected(len(speakerPCM)) return } - w.audioEnqueue.Lock() - defer w.audioEnqueue.Unlock() - - w.telemetry.receivedPayloads.Add(1) - w.telemetry.receivedBytes.Add(uint64(len(speakerPCM))) buffer := w.acquireAtomicAudioBuffer() if buffer == nil { - w.recordSpeakerDrop(len(speakerPCM)) + w.recordSpeakerOverrun(len(speakerPCM)) return } length := dualSenseAtomicFeedbackPrefix + len(feedback) + len(speakerPCM) if length > cap(buffer) { w.audioFree <- buffer[:cap(buffer)] - w.recordSpeakerDrop(len(speakerPCM)) + w.recordSpeakerOverrun(len(speakerPCM)) return } buffer = buffer[:length] @@ -232,17 +380,15 @@ func (w *dualSenseOutputWriter) EnqueueAtomicAudioHaptics(feedback, speakerPCM [ copy(buffer[dualSenseAtomicFeedbackPrefix:], feedback) copy(buffer[dualSenseAtomicFeedbackPrefix+len(feedback):], speakerPCM) frame := dualSenseOutputFrame{ - frameType: StreamFrameAtomicAudioHaptics, - payload: buffer, - audio: true, - generation: w.audioGeneration.Load(), - } - if !w.enqueueFrameLocked(w.audio, frame) { - w.audioFree <- buffer[:cap(buffer)] - w.recordSpeakerDrop(len(speakerPCM)) - return - } - w.recordSpeakerEnqueue(len(speakerPCM)) + frameType: StreamFrameAtomicAudioHaptics, + payload: buffer, + media: true, + audio: true, + mediaBytes: len(speakerPCM), + mediaDuration: dualSenseSpeakerGenerationCadence, + generation: w.audioGeneration.Load(), + } + w.enqueueMediaDropOldestLocked(frame) } // acquireAtomicAudioBuffer keeps V5 realtime: when TCP momentarily falls @@ -251,57 +397,85 @@ func (w *dualSenseOutputWriter) EnqueueAtomicAudioHaptics(feedback, speakerPCM [ // the newest native USB generation can still be published without growing an // unbounded stale-audio reserve. func (w *dualSenseOutputWriter) acquireAtomicAudioBuffer() []byte { - select { - case buffer := <-w.audioFree: - return buffer - default: - } + for { + select { + case buffer := <-w.audioFree: + return buffer + default: + } - select { - case oldest := <-w.audio: - w.recordSpeakerDrop(atomicSpeakerPCMBytes(oldest.payload)) - w.telemetry.queueDepth.Store(uint64(len(w.audio))) - return oldest.payload[:cap(oldest.payload)] - default: - // The sole remaining pool buffer can be owned by an in-flight write. - return nil + select { + case oldest := <-w.audio: + w.recordMediaDequeued(oldest) + w.recordSpeakerOverrun(oldest.mediaBytes) + w.release(oldest) + default: + // Every preallocated buffer is either queued or in-flight. Only a + // queued frame may be reclaimed; never steal the in-flight buffer. + return nil + } } } -func atomicSpeakerPCMBytes(payload []byte) int { - if len(payload) < dualSenseAtomicFeedbackPrefix { - return len(payload) +func (w *dualSenseOutputWriter) recordSpeakerReceive(length int, frameType byte) { + w.telemetry.receivedPayloads.Add(1) + w.telemetry.receivedBytes.Add(uint64(length)) + now := time.Now().UnixNano() + last := &w.telemetry.lastEnqueueNS + cadence := dualSenseSpeakerGenerationCadence + if frameType == StreamFrameRealtimeHaptics { + last = &w.telemetry.lastRealtimeEnqueueNS + cadence = dualSenseRealtimeHapticsCadence + } + previous := last.Swap(now) + if previous <= 0 || now <= previous { + return } - feedbackLength := int(binary.LittleEndian.Uint16( - payload[:dualSenseAtomicFeedbackPrefix])) - speakerOffset := dualSenseAtomicFeedbackPrefix + feedbackLength - if speakerOffset > len(payload) { - return len(payload) + gap := now - previous + recordMaximumInt64(&w.telemetry.maxEnqueueGapNS, gap) + expected := int64(cadence) + // This is an observed producer-generation gap, not a claim about remote + // playback. A 50% tolerance avoids classifying scheduler jitter as loss. + if gap > expected+expected/2 { + w.telemetry.lateGaps.Add(1) + missing := uint64(gap/expected) - 1 + if missing == 0 { + missing = 1 + } + w.telemetry.underruns.Add(missing) } - return len(payload) - speakerOffset } -func (w *dualSenseOutputWriter) recordSpeakerEnqueue(length int) { +func (w *dualSenseOutputWriter) recordSpeakerEnqueue(length int, + depth uint64, duration int64) { w.telemetry.enqueuedPayloads.Add(1) w.telemetry.enqueuedBytes.Add(uint64(length)) - now := time.Now().UnixNano() - previous := w.telemetry.lastEnqueueNS.Swap(now) - if previous > 0 && now > previous { - recordMaximumInt64(&w.telemetry.maxEnqueueGapNS, now-previous) - } - depth := uint64(len(w.audio)) - w.telemetry.queueDepth.Store(depth) recordMaximumUint64(&w.telemetry.queueHighWater, depth) + recordMaximumInt64(&w.telemetry.queueDurationHighNS, duration) } -func (w *dualSenseOutputWriter) recordSpeakerDrop(length int) { +func (w *dualSenseOutputWriter) recordSpeakerRejected(length int) { + w.telemetry.rejectedPayloads.Add(1) + w.telemetry.rejectedBytes.Add(uint64(length)) +} + +func (w *dualSenseOutputWriter) recordSpeakerOverrun(length int) { if length <= 0 { return } + w.telemetry.overruns.Add(1) w.telemetry.droppedPayloads.Add(1) w.telemetry.droppedBytes.Add(uint64(length)) } +func (w *dualSenseOutputWriter) recordSpeakerStale(length int) { + if length <= 0 { + return + } + w.telemetry.stalePayloads.Add(1) + w.telemetry.staleBytes.Add(uint64(length)) +} + func (w *dualSenseOutputWriter) recordSpeakerWrite(length int) { w.telemetry.writtenPayloads.Add(1) w.telemetry.writtenBytes.Add(uint64(length)) @@ -312,47 +486,74 @@ func (w *dualSenseOutputWriter) recordSpeakerWrite(length int) { } } -// enqueueFrameLocked requires enqueueLock to be held for reading. Shutdown -// takes the write side before draining, so no producer can publish a frame -// after the final drain has observed an empty queue. -func (w *dualSenseOutputWriter) enqueueFrameLocked(queue chan dualSenseOutputFrame, - frame dualSenseOutputFrame) bool { +// enqueueMediaDropOldestLocked retains the newest bounded media horizon. The +// frame removed from the channel is necessarily queued, never in-flight. +func (w *dualSenseOutputWriter) enqueueMediaDropOldestLocked( + frame dualSenseOutputFrame) { + duration := int64(frame.mediaDuration) + for w.telemetry.queueDurationNS.Load()+duration > int64(dualSenseMediaMaximumBufferTime) || + w.telemetry.queueDepth.Load() >= uint64(cap(w.audio)) { + select { + case oldest := <-w.audio: + w.recordMediaDequeued(oldest) + w.recordSpeakerOverrun(oldest.mediaBytes) + w.release(oldest) + default: + w.recordSpeakerOverrun(frame.mediaBytes) + w.release(frame) + return + } + } + reservedDuration := w.telemetry.queueDurationNS.Add(duration) + depth := w.telemetry.queueDepth.Add(1) select { - case queue <- frame: - return true + case w.audio <- frame: + w.recordSpeakerEnqueue(frame.mediaBytes, depth, reservedDuration) default: - // Do not let TCP backpressure delay a USB/IP isochronous completion. - return false + w.telemetry.queueDurationNS.Add(-duration) + decrementUint64(&w.telemetry.queueDepth) + w.recordSpeakerOverrun(frame.mediaBytes) + w.release(frame) + } +} + +func (w *dualSenseOutputWriter) recordMediaDequeued(frame dualSenseOutputFrame) { + decrementUint64(&w.telemetry.queueDepth) + if frame.mediaDuration > 0 { + w.telemetry.queueDurationNS.Add(-int64(frame.mediaDuration)) } } +func (w *dualSenseOutputWriter) recordSpeakerLifecycleDiscard(length int) { + if length <= 0 { + return + } + w.telemetry.lifecycleDiscardedPayloads.Add(1) + w.telemetry.lifecycleDiscardedBytes.Add(uint64(length)) +} + func (w *dualSenseOutputWriter) Run() { defer func() { w.requestStop() - w.drainAudioQueue() - w.telemetry.queueDepth.Store(0) + w.drainControlQueue() + w.drainAudioQueue(dualSenseMediaDiscardLifecycle) w.telemetry.active.Store(false) w.traceSpeakerState(true) close(w.done) }() preferAudio := false for { - // A complete rear-channel generation has a hard media deadline. It is - // small and arrives slightly less often than speaker media, so servicing - // it first cannot starve the 100 Hz speaker lane. select { - case frame := <-w.realtimeHaptics: - if !w.writeAndRelease(frame) { - return - } - continue + case <-w.stop: + return default: } - // Alternate when both lanes are continuously ready. If the preferred + // Alternate when both traffic classes are continuously ready. If the preferred // lane is empty, immediately service whichever frame arrives next. if preferAudio { select { case frame := <-w.audio: + w.recordMediaDequeued(frame) if !w.writeAndRelease(frame) { return } @@ -363,6 +564,7 @@ func (w *dualSenseOutputWriter) Run() { } else { select { case frame := <-w.control: + decrementUint64(&w.telemetry.orderedQueueDepth) if !w.writeAndRelease(frame) { return } @@ -375,16 +577,14 @@ func (w *dualSenseOutputWriter) Run() { select { case <-w.stop: return - case frame := <-w.realtimeHaptics: - if !w.writeAndRelease(frame) { - return - } case frame := <-w.control: + decrementUint64(&w.telemetry.orderedQueueDepth) if !w.writeAndRelease(frame) { return } preferAudio = true case frame := <-w.audio: + w.recordMediaDequeued(frame) if !w.writeAndRelease(frame) { return } @@ -394,28 +594,30 @@ func (w *dualSenseOutputWriter) Run() { } func (w *dualSenseOutputWriter) writeAndRelease(frame dualSenseOutputFrame) bool { - if frame.audio { + if frame.media { w.audioWrite.Lock() defer w.audioWrite.Unlock() if frame.generation != w.audioGeneration.Load() { - w.recordSpeakerDrop(len(frame.payload)) + w.recordSpeakerStale(frame.mediaBytes) w.release(frame) - w.telemetry.queueDepth.Store(uint64(len(w.audio))) return true } } ok := w.write(frame) - if frame.audio { - w.telemetry.queueDepth.Store(uint64(len(w.audio))) + if frame.media { if ok { - w.recordSpeakerWrite(len(frame.payload)) + w.recordSpeakerWrite(frame.mediaBytes) } else { w.telemetry.writeFailures.Add(1) } + } else if !ok { + w.telemetry.orderedWriteFailures.Add(1) + } else { + w.telemetry.orderedWritten.Add(1) } w.release(frame) - if frame.audio { + if frame.media { w.traceSpeakerState(false) } return ok @@ -427,7 +629,10 @@ func (w *dualSenseOutputWriter) writeAndRelease(frame dualSenseOutputFrame) bool func (w *dualSenseOutputWriter) ResetSpeaker() { w.enqueueLock.Lock() w.audioGeneration.Add(1) - w.drainAudioQueue() + w.drainAudioQueue(dualSenseMediaDiscardStale) + w.telemetry.lastEnqueueNS.Store(0) + w.telemetry.lastRealtimeEnqueueNS.Store(0) + w.telemetry.lastWriteNS.Store(0) w.enqueueLock.Unlock() // A peer that has stopped reading can otherwise hold audioWrite forever. @@ -435,23 +640,52 @@ func (w *dualSenseOutputWriter) ResetSpeaker() { // stream so the owning handler can return and accept a replacement. if w.conn != nil { if err := w.conn.SetWriteDeadline(time.Now().Add(dualSenseSpeakerResetTimeout)); err != nil { - w.invalidateStream() + w.faultStream("speaker reset deadline failed") } } w.audioWrite.Lock() if w.conn != nil && w.streamViable.Load() { if err := w.conn.SetWriteDeadline(time.Time{}); err != nil { - w.invalidateStream() + w.faultStream("speaker reset deadline clear failed") } } w.audioWrite.Unlock() - w.telemetry.queueDepth.Store(0) } -func (w *dualSenseOutputWriter) drainAudioQueue() { +type dualSenseMediaDiscardReason uint8 + +const ( + dualSenseMediaDiscardStale dualSenseMediaDiscardReason = iota + 1 + dualSenseMediaDiscardLifecycle +) + +func (w *dualSenseOutputWriter) drainControlQueue() { + for { + select { + case frame := <-w.control: + decrementUint64(&w.telemetry.orderedQueueDepth) + w.telemetry.orderedLifecycleDiscardedFrames.Add(1) + w.telemetry.orderedLifecycleDiscardedBytes.Add( + uint64(len(frame.payload))) + w.release(frame) + default: + return + } + } +} + +func (w *dualSenseOutputWriter) drainAudioQueue( + reason dualSenseMediaDiscardReason) { for { select { case frame := <-w.audio: + w.recordMediaDequeued(frame) + switch reason { + case dualSenseMediaDiscardStale: + w.recordSpeakerStale(frame.mediaBytes) + case dualSenseMediaDiscardLifecycle: + w.recordSpeakerLifecycleDiscard(frame.mediaBytes) + } w.release(frame) default: return @@ -480,15 +714,37 @@ func (w *dualSenseOutputWriter) traceSpeakerState(final bool) { "receivedBytes", state.ReceivedBytes, "enqueuedPayloads", state.EnqueuedPayloads, "enqueuedBytes", state.EnqueuedBytes, + "rejectedPayloads", state.RejectedPayloads, + "rejectedBytes", state.RejectedBytes, "droppedPayloads", state.DroppedPayloads, "droppedBytes", state.DroppedBytes, + "overruns", state.Overruns, + "underruns", state.Underruns, + "lateGaps", state.LateGaps, + "stalePayloads", state.StalePayloads, + "staleBytes", state.StaleBytes, + "lifecycleDiscardedPayloads", state.LifecycleDiscardedPayloads, + "lifecycleDiscardedBytes", state.LifecycleDiscardedBytes, "writtenPayloads", state.WrittenPayloads, "writtenBytes", state.WrittenBytes, "writeFailures", state.WriteFailures, + "orderedReceived", state.OrderedReceived, + "orderedEnqueued", state.OrderedEnqueued, + "orderedRejected", state.OrderedRejected, + "orderedWritten", state.OrderedWritten, + "orderedSaturations", state.OrderedSaturations, + "orderedWriteFailures", state.OrderedWriteFailures, + "orderedLifecycleDiscardedFrames", + state.OrderedLifecycleDiscardedFrames, + "orderedLifecycleDiscardedBytes", state.OrderedLifecycleDiscardedBytes, "queueDepth", state.QueueDepth, "queueHighWater", state.QueueHighWater, + "queueDurationUS", state.QueueDurationUS, + "queueDurationHighUS", state.QueueDurationHighUS, "maxEnqueueGapUS", state.MaxEnqueueGapUS, - "maxWriteGapUS", state.MaxWriteGapUS) + "maxWriteGapUS", state.MaxWriteGapUS, + "teardownFailures", state.TeardownFailures, + "teardownPending", state.TeardownPending) } func (w *dualSenseOutputWriter) write(frame dualSenseOutputFrame) bool { @@ -519,7 +775,7 @@ func (w *dualSenseOutputWriter) write(frame dualSenseOutputFrame) bool { for len(remaining) > 0 { n, err := w.conn.Write(remaining) if err != nil || n <= 0 { - w.invalidateStream() + w.faultStream("socket write failed") return false } remaining = remaining[n:] @@ -533,19 +789,48 @@ func (w *dualSenseOutputWriter) release(frame dualSenseOutputFrame) { } } -func (w *dualSenseOutputWriter) Stop() { +func (w *dualSenseOutputWriter) Stop() error { w.requestStop() if w.conn != nil { _ = w.conn.SetWriteDeadline(time.Now().Add(dualSenseSpeakerResetTimeout)) _ = w.conn.Close() } + timer := time.NewTimer(dualSenseOutputJoinTimeout) + defer timer.Stop() select { case <-w.done: - case <-time.After(300 * time.Millisecond): + w.telemetry.teardownPending.Store(false) + return w.latchedTeardownError() + case <-timer.C: + w.latchTeardownFailure(errDualSenseOutputJoinTimeout) + w.telemetry.teardownPending.Store(true) + w.teardownJoinOnce.Do(func() { + go func() { + <-w.done + w.telemetry.teardownPending.Store(false) + }() + }) + return w.latchedTeardownError() } } +func (w *dualSenseOutputWriter) latchTeardownFailure(err error) { + w.teardownFailureOnce.Do(func() { + w.teardownMu.Lock() + w.teardownErr = err + w.teardownMu.Unlock() + w.telemetry.teardownFailures.Add(1) + }) +} + +func (w *dualSenseOutputWriter) latchedTeardownError() error { + w.teardownMu.Lock() + defer w.teardownMu.Unlock() + return w.teardownErr +} + func (w *dualSenseOutputWriter) requestStop() { + w.accepting.Store(false) w.stopOnce.Do(func() { w.streamViable.Store(false) w.enqueueLock.Lock() @@ -555,9 +840,27 @@ func (w *dualSenseOutputWriter) requestStop() { }) } -func (w *dualSenseOutputWriter) invalidateStream() { +func (w *dualSenseOutputWriter) faultStream(reason string) { + w.accepting.Store(false) w.streamViable.Store(false) + w.requestStop() + w.telemetry.active.Store(false) + if w.logger != nil { + w.logger.Error("DualSense output stream faulted", "reason", reason) + } if w.conn != nil { _ = w.conn.Close() } } + +func decrementUint64(target *atomic.Uint64) uint64 { + for { + current := target.Load() + if current == 0 { + return 0 + } + if target.CompareAndSwap(current, current-1) { + return current - 1 + } + } +} diff --git a/device/dualsense/output_writer_test.go b/device/dualsense/output_writer_test.go index 6d1226cb..2d37e226 100644 --- a/device/dualsense/output_writer_test.go +++ b/device/dualsense/output_writer_test.go @@ -58,7 +58,9 @@ func TestDualSenseV5WriterPublishesOnlyV5AtomicFrames(t *testing.T) { } _ = client.Close() - writer.Stop() + if err := writer.Stop(); err != nil { + t.Fatal(err) + } state := writer.telemetry.snapshot() if state.ReceivedPayloads != 1 || state.WrittenPayloads != 1 || state.DroppedPayloads != 0 || state.WriteFailures != 0 || state.Active { @@ -86,7 +88,9 @@ func TestDualSenseV5WriterPublishesRealtimeHapticsFrame(t *testing.T) { } _ = client.Close() - writer.Stop() + if err := writer.Stop(); err != nil { + t.Fatal(err) + } } func TestDualSenseV5WriterAlternatesControlAndMedia(t *testing.T) { @@ -109,10 +113,76 @@ func TestDualSenseV5WriterAlternatesControlAndMedia(t *testing.T) { t.Fatalf("frame %d type=0x%02X want=0x%02X", index, header[5], want) } } - writer.Stop() + if err := writer.Stop(); err != nil { + t.Fatal(err) + } + _ = client.Close() +} + +func TestDualSenseV5WriterFaultsOnOrderedSaturationWithoutEviction(t *testing.T) { + server, client := net.Pipe() + writer := newDualSenseOutputWriter(server, nil, nil) + for marker := 0; marker < dualSenseOutputControlQueueCapacity; marker++ { + writer.EnqueueControl(StreamFrameOutputState, []byte{byte(marker)}) + } + writer.EnqueueControl(StreamFrameOutputState, []byte{0xFF}) + if len(writer.control) != dualSenseOutputControlQueueCapacity { + t.Fatalf("control depth=%d want=%d", len(writer.control), + dualSenseOutputControlQueueCapacity) + } + for index := 0; index < dualSenseOutputControlQueueCapacity; index++ { + frame := <-writer.control + decrementUint64(&writer.telemetry.orderedQueueDepth) + want := byte(index) + if len(frame.payload) != 1 || frame.payload[0] != want { + t.Fatalf("control[%d]=% x want=%02x", index, frame.payload, want) + } + } + state := writer.telemetry.snapshot() + if state.OrderedReceived != uint64(dualSenseOutputControlQueueCapacity+1) || + state.OrderedEnqueued != uint64(dualSenseOutputControlQueueCapacity) || + state.OrderedRejected != 1 || state.OrderedSaturations != 1 || + state.Active || writer.accepting.Load() { + t.Fatalf("unexpected saturation state: %+v", state) + } + buffer := make([]byte, 1) + if count, err := client.Read(buffer); count != 0 || err == nil { + t.Fatalf("saturation did not close owning stream: count=%d err=%v", count, err) + } _ = client.Close() } +func TestDualSenseV5WriterBoundsRealtimeMediaAndDropsOldest(t *testing.T) { + writer := newDualSenseOutputWriter(nil, nil, nil) + for marker := 0; marker < dualSenseRealtimeMediaQueueCapacity; marker++ { + writer.EnqueueRealtimeHaptics([]byte{byte(marker)}) + } + writer.EnqueueRealtimeHaptics([]byte{0xFF}) + if len(writer.audio) != dualSenseRealtimeMediaQueueCapacity { + t.Fatalf("media depth=%d want=%d", len(writer.audio), + dualSenseRealtimeMediaQueueCapacity) + } + for index := 0; index < dualSenseRealtimeMediaQueueCapacity; index++ { + frame := <-writer.audio + writer.recordMediaDequeued(frame) + want := byte(index + 1) + if index == dualSenseRealtimeMediaQueueCapacity-1 { + want = 0xFF + } + if len(frame.payload) != 1 || frame.payload[0] != want { + t.Fatalf("realtime[%d]=% x want=%02x", index, frame.payload, want) + } + } + state := writer.telemetry.snapshot() + if state.Overruns != 1 || state.DroppedPayloads != 1 || + state.DroppedBytes != 1 || + state.QueueHighWater != uint64(dualSenseRealtimeMediaQueueCapacity) || + state.QueueDurationHighUS > + dualSenseMediaMaximumBufferTime.Microseconds() { + t.Fatalf("unexpected media overrun telemetry: %+v", state) + } +} + func TestDualSenseV5WriterShutdownReturnsEveryMediaBuffer(t *testing.T) { server, client := net.Pipe() writer := newDualSenseOutputWriter(server, nil, nil) @@ -121,12 +191,14 @@ func TestDualSenseV5WriterShutdownReturnsEveryMediaBuffer(t *testing.T) { writer.EnqueueAtomicAudioHaptics(feedback, speaker) } go writer.Run() - writer.Stop() + if err := writer.Stop(); err != nil { + t.Fatal(err) + } _ = client.Close() state := writer.telemetry.snapshot() if state.Active || state.QueueDepth != 0 || len(writer.audio) != 0 || - len(writer.audioFree) != dualSenseOutputAudioQueueCapacity { + len(writer.audioFree) != dualSenseOutputAudioPoolCapacity { t.Fatalf("shutdown retained buffers: state=%+v queued=%d free=%d", state, len(writer.audio), len(writer.audioFree)) } @@ -188,9 +260,10 @@ func TestDualSenseV5WriterWriteFailureCannotRaceFinalDrain(t *testing.T) { writer.enqueueLock.RLock() buffer := <-writer.audioFree buffer[0] = 0x55 + writer.telemetry.queueDepth.Add(1) writer.audio <- dualSenseOutputFrame{ frameType: StreamFrameAtomicAudioHaptics, - payload: buffer[:4], audio: true, + payload: buffer[:4], media: true, audio: true, mediaBytes: 4, } _ = client.Close() writer.enqueueLock.RUnlock() @@ -201,10 +274,35 @@ func TestDualSenseV5WriterWriteFailureCannotRaceFinalDrain(t *testing.T) { t.Fatal("writer did not finish after socket failure") } if len(writer.audio) != 0 || - len(writer.audioFree) != dualSenseOutputAudioQueueCapacity { + len(writer.audioFree) != dualSenseOutputAudioPoolCapacity { t.Fatalf("shutdown retained a pooled buffer: queued=%d free=%d", len(writer.audio), len(writer.audioFree)) } + state := writer.telemetry.snapshot() + if state.OrderedWriteFailures != 1 || state.OrderedWritten != 0 { + t.Fatalf("ordered write failure was not accounted: %+v", state) + } +} + +func TestDualSenseV5WriterAccountsMediaWriteFailure(t *testing.T) { + server, client := net.Pipe() + writer := newDualSenseOutputWriter(server, nil, nil) + feedback, speaker := testV5Media(0x61) + writer.EnqueueAtomicAudioHaptics(feedback, speaker) + _ = client.Close() + go writer.Run() + select { + case <-writer.done: + case <-time.After(time.Second): + t.Fatal("media write failure did not stop writer") + } + state := writer.telemetry.snapshot() + if state.WriteFailures != 1 || state.WrittenPayloads != 0 || state.Active { + t.Fatalf("media write failure was not accounted: %+v", state) + } + if len(writer.audioFree) != dualSenseOutputAudioPoolCapacity { + t.Fatalf("media write failure leaked pool: free=%d", len(writer.audioFree)) + } } func TestDualSenseV5WriterResetIsHardGenerationBarrier(t *testing.T) { @@ -245,7 +343,9 @@ func TestDualSenseV5WriterResetIsHardGenerationBarrier(t *testing.T) { if newPayload[2] != 0x22 { t.Fatalf("post-reset frame is stale: % x", newPayload[:4]) } - writer.Stop() + if err := writer.Stop(); err != nil { + t.Fatal(err) + } _ = client.Close() } @@ -271,14 +371,16 @@ func TestDualSenseV5WriterResetBoundsBlockedWrite(t *testing.T) { t.Fatal("timed-out write did not stop the stream") } if writer.streamViable.Load() || len(writer.audio) != 0 || - len(writer.audioFree) != dualSenseOutputAudioQueueCapacity { + len(writer.audioFree) != dualSenseOutputAudioPoolCapacity { t.Fatal("failed stream retained V5 transport state") } buffer := make([]byte, StreamFrameHeaderSize+4) if count, err := client.Read(buffer); count != 0 || err == nil { t.Fatalf("failed stream replayed stale media: bytes=%d err=%v", count, err) } - writer.Stop() + if err := writer.Stop(); err != nil { + t.Fatal(err) + } _ = client.Close() } diff --git a/device/dualsense/race_disabled_test.go b/device/dualsense/race_disabled_test.go new file mode 100644 index 00000000..a6561ad5 --- /dev/null +++ b/device/dualsense/race_disabled_test.go @@ -0,0 +1,5 @@ +//go:build !race + +package dualsense + +const raceDetectorEnabled = false diff --git a/device/dualsense/race_enabled_test.go b/device/dualsense/race_enabled_test.go new file mode 100644 index 00000000..44ef928e --- /dev/null +++ b/device/dualsense/race_enabled_test.go @@ -0,0 +1,5 @@ +//go:build race + +package dualsense + +const raceDetectorEnabled = true diff --git a/device/dualsense/scheduled_input_test.go b/device/dualsense/scheduled_input_test.go new file mode 100644 index 00000000..e7367cb6 --- /dev/null +++ b/device/dualsense/scheduled_input_test.go @@ -0,0 +1,101 @@ +package dualsense + +import ( + "context" + "encoding/binary" + "testing" + "time" +) + +func TestScheduledInterruptInputPreservesDualSenseStateAndCadence(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + state := NewInputState() + state.LX, state.R2, state.Buttons = 23, 177, ButtonCross + dev.UpdateInputState(state) + buffer := make([]byte, InputReportSize) + never := make(chan time.Time) + written, err := dev.ReadScheduledInterruptInput(context.Background(), never, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize { + t.Fatalf("event read=(%d, %v)", written, err) + } + if buffer[1] != uint8(int16(state.LX)+128) || buffer[6] != state.R2 || buffer[7] != 1 { + t.Fatalf("event state/counter=%x", buffer[:11]) + } + firstTimestamp := binary.LittleEndian.Uint32(buffer[28:32]) + + deadline := make(chan time.Time, 1) + deadline <- time.Now() + written, err = dev.ReadScheduledInterruptInput(context.Background(), deadline, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize { + t.Fatalf("deadline read=(%d, %v)", written, err) + } + if buffer[1] != uint8(int16(state.LX)+128) || buffer[6] != state.R2 || buffer[7] != 2 { + t.Fatalf("deadline state/counter=%x", buffer[:11]) + } + secondTimestamp := binary.LittleEndian.Uint32(buffer[28:32]) + if secondTimestamp < firstTimestamp || binary.LittleEndian.Uint32(buffer[49:53]) != secondTimestamp { + t.Fatalf("deadline timestamps first=%d second=%d mirror=%d", firstTimestamp, + secondTimestamp, binary.LittleEndian.Uint32(buffer[49:53])) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + state.LX = 44 + dev.UpdateInputState(state) + readyDeadline := make(chan time.Time, 1) + readyDeadline <- time.Now() + if _, err = dev.ReadScheduledInterruptInput(ctx, readyDeadline, EndpointIn&0x0f, buffer); err != context.Canceled { + t.Fatalf("lifecycle cancellation=%v want %v", err, context.Canceled) + } + if written, err = dev.ReadScheduledInterruptInput(context.Background(), never, EndpointIn&0x0f, buffer); err != nil || written != InputReportSize { + t.Fatalf("post-cancel event read=(%d, %v)", written, err) + } + if buffer[1] != uint8(int16(state.LX)+128) || buffer[7] != 3 { + t.Fatalf("post-cancel state/counter=%x", buffer[:11]) + } + if thirdTimestamp := binary.LittleEndian.Uint32(buffer[28:32]); thirdTimestamp < secondTimestamp { + t.Fatalf("post-cancel timestamp=%d before previous=%d", thirdTimestamp, secondTimestamp) + } +} + +func TestClassifiedNativeInputPreservesQueuedDualSenseTransitions(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + press := NewInputState() + press.LX, press.Buttons = -43, ButtonCross + release := NewInputState() + release.LX = 59 + dev.UpdateInputState(press) + dev.UpdateInputState(release) + + buffer := make([]byte, InputReportSize) + never := make(chan time.Time) + written, transition, err := dev.ReadClassifiedScheduledInterruptInput( + context.Background(), never, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize || !transition || + buffer[1] != uint8(int16(press.LX)+128) { + t.Fatalf("press read=(%d, %t, %v) state=%x", written, transition, err, buffer[:11]) + } + written, transition, err = dev.ReadClassifiedScheduledInterruptInput( + context.Background(), never, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize || !transition || + buffer[1] != uint8(int16(release.LX)+128) { + t.Fatalf("release read=(%d, %t, %v) state=%x", written, transition, err, buffer[:11]) + } + analog := *release + analog.LX = 21 + if err = dev.UpdateInputState(&analog); err != nil { + t.Fatal(err) + } + written, transition, err = dev.ReadClassifiedScheduledInterruptInput( + context.Background(), never, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize || transition || + buffer[1] != uint8(int16(analog.LX)+128) { + t.Fatalf("analog read=(%d, %t, %v) state=%x", written, transition, err, buffer[:11]) + } +} diff --git a/device/dualshock4/audio_test.go b/device/dualshock4/audio_test.go index 1996d573..c99507d6 100644 --- a/device/dualshock4/audio_test.go +++ b/device/dualshock4/audio_test.go @@ -118,6 +118,29 @@ func TestAudioInterfacesTrackAlternateSettings(t *testing.T) { assert.Equal(t, make([]byte, USBMicrophonePacketSize), microphone) } +func TestNativeMicrophoneInWritesCallerBuffer(t *testing.T) { + dev, err := New(nil) + require.NoError(t, err) + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + frame := make([]byte, USBMicrophoneClientFrameSize) + for index := range frame { + frame[index] = byte(index*13 + 3) + } + for range microphoneTargetClientFrames { + dev.QueueMicrophonePCMFrame(frame) + } + + packet := make([]byte, USBMicrophonePacketSize) + actual, err := dev.ReadIsochronousInput( + context.Background(), uint32(EndpointMicrophoneIn), packet) + require.NoError(t, err) + require.Equal(t, len(packet), actual) + assert.Equal(t, frame[:len(packet)], packet) + _, err = dev.ReadIsochronousInput(context.Background(), + uint32(EndpointMicrophoneIn), packet[:len(packet)-1]) + assert.ErrorIs(t, err, io.ErrShortBuffer) +} + func TestSpeakerTransferIsForwardedWithoutLoopbackCapture(t *testing.T) { dev, err := New(nil) require.NoError(t, err) @@ -165,7 +188,7 @@ func TestDuplexWriterFramesSpeakerPCM(t *testing.T) { assert.Equal(t, pcm, payload) require.NoError(t, client.Close()) - writer.Stop() + require.NoError(t, writer.Stop()) } func TestAudioInterfaceTransitionsDropPreviousGeneration(t *testing.T) { @@ -213,7 +236,7 @@ func TestAudioInterfaceTransitionsDropPreviousGeneration(t *testing.T) { dev.SetSpeakerCallback(nil) dev.SetSpeakerResetCallback(nil) require.NoError(t, client.Close()) - writer.Stop() + require.NoError(t, writer.Stop()) } func TestEndpointResetDropsSpeakerAndMicrophoneWithoutChangingAlt(t *testing.T) { @@ -260,7 +283,94 @@ func TestEndpointResetDropsSpeakerAndMicrophoneWithoutChangingAlt(t *testing.T) dev.SetSpeakerCallback(nil) dev.SetSpeakerResetCallback(nil) require.NoError(t, client.Close()) - writer.Stop() + require.NoError(t, writer.Stop()) +} + +func TestSpeakerRejectsPublicationFromPreResetRevision(t *testing.T) { + device, err := New(nil) + require.NoError(t, err) + device.SetInterfaceAltSetting(InterfaceSpeaker, 1) + published := 0 + device.SetSpeakerCallback(func([]byte) { published++ }) + + device.mtx.Lock() + revision := device.speakerRevision + device.mtx.Unlock() + device.ResetEndpoint(EndpointAudioOut) + assert.False(t, device.publishSpeakerPCM(revision, []byte{1, 2, 3, 4})) + assert.Equal(t, 0, published) +} + +func TestSpeakerResetWaitsForInFlightDevicePublication(t *testing.T) { + device, err := New(nil) + require.NoError(t, err) + device.SetInterfaceAltSetting(InterfaceSpeaker, 1) + entered := make(chan struct{}) + release := make(chan struct{}) + device.SetSpeakerCallback(func([]byte) { + close(entered) + <-release + }) + resetCalls := 0 + device.SetSpeakerResetCallback(func() { resetCalls++ }) + + transferDone := make(chan struct{}) + go func() { + device.HandleTransfer(context.Background(), EndpointAudioOut, + usbip.DirOut, []byte{1, 2, 3, 4}) + close(transferDone) + }() + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("speaker callback did not start") + } + resetDone := make(chan struct{}) + go func() { + device.ResetEndpoint(EndpointAudioOut) + close(resetDone) + }() + select { + case <-resetDone: + t.Fatal("endpoint reset crossed an in-flight speaker publication") + case <-time.After(20 * time.Millisecond): + } + close(release) + select { + case <-resetDone: + case <-time.After(time.Second): + t.Fatal("endpoint reset did not finish after speaker publication") + } + <-transferDone + assert.Equal(t, 1, resetCalls) +} + +func TestDualShock4WriterFaultsOnOrderedSaturationWithoutEviction(t *testing.T) { + server, client := net.Pipe() + writer := newDualShock4OutputWriter(server, StreamFrameVersionV3) + for marker := 0; marker < cap(writer.control); marker++ { + writer.EnqueueControl(StreamFrameOutputState, []byte{byte(marker)}) + } + writer.EnqueueControl(StreamFrameOutputState, []byte{0xFF}) + depth := cap(writer.control) + require.Len(t, writer.control, depth) + for index := 0; index < depth; index++ { + frame := <-writer.control + decrementDualShock4Uint64(&writer.telemetry.orderedQueueDepth) + require.Equal(t, []byte{byte(index)}, frame.payload) + } + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(depth+1), state.OrderedReceived) + assert.Equal(t, uint64(depth), state.OrderedEnqueued) + assert.Equal(t, uint64(1), state.OrderedRejected) + assert.Equal(t, uint64(1), state.OrderedSaturations) + assert.False(t, state.Active) + assert.False(t, writer.accepting.Load()) + buffer := make([]byte, 1) + count, err := client.Read(buffer) + assert.Zero(t, count) + assert.Error(t, err, "saturation must close the owning stream") + require.NoError(t, client.Close()) } type dualShock4WriteGateConn struct { @@ -309,7 +419,7 @@ func TestSpeakerResetWaitsForInFlightWrite(t *testing.T) { } require.NoError(t, client.Close()) - writer.Stop() + require.NoError(t, writer.Stop()) } type dualShock4DeadlineBlockConn struct { @@ -367,14 +477,14 @@ func TestSpeakerResetBoundsBlockedWriteAndDropsQueuedGeneration(t *testing.T) { Conn: server, started: make(chan struct{}), unblock: make(chan struct{}), } writer := newDualShock4OutputWriter(conn, StreamFrameVersionV3) - writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{0x11}) + writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{0x11, 0x11, 0x11, 0x11}) go writer.Run() select { case <-conn.started: case <-time.After(time.Second): t.Fatal("speaker writer did not enter the blocked write") } - writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{0x22}) + writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{0x22, 0x22, 0x22, 0x22}) resetStarted := time.Now() resetDone := make(chan struct{}) @@ -409,7 +519,9 @@ func TestSpeakerResetBoundsBlockedWriteAndDropsQueuedGeneration(t *testing.T) { assert.GreaterOrEqual(t, closeCount, 1, "timed-out stream was not closed for reconnect") assert.Empty(t, writer.audio) - writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{0x33}) + assert.Equal(t, uint64(1), + writer.telemetry.snapshot().MediaWriteFailures) + writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{0x33, 0x33, 0x33, 0x33}) assert.Empty(t, writer.audio, "failed writer accepted audio instead of waiting for reconnect") diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index abe79a54..a42e65a0 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -4,35 +4,39 @@ import ( "context" "encoding/binary" "encoding/json" - "errors" "fmt" + "io" "log/slog" "sync" "sync/atomic" "time" "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/internal/inputstatequeue" "github.com/Alia5/VIIPER/device/internal/microphonebuffer" "github.com/Alia5/VIIPER/usb" - "github.com/Alia5/VIIPER/usbip" ) const ( microphoneTargetClientFrames = 6 // 60 ms absorbs the DS4's 8/8/8/16 ms framing and host scheduling jitter. microphoneMaximumClientFrames = 20 // 200 ms emergency ceiling for full-duplex BT bursts; steady state remains about 55 ms. + inputTransitionQueueCapacity = 256 ) type DualShock4 struct { - inputCh chan *InputState - inputState *InputState - inputPublishMu sync.Mutex - metaState *MetaState + inputQueue *inputstatequeue.Queue[InputState] + inputState *InputState + metaState *MetaState + + outputPublishMu sync.RWMutex + speakerPublishMu sync.RWMutex outputFunc func(OutputState) speakerFunc func([]byte) speakerResetFunc func() outputState OutputState outputSeen bool + speakerRevision uint64 descriptor usb.Descriptor probeSelector [3]byte @@ -48,6 +52,7 @@ type DualShock4 struct { streamFrameVersion byte microphoneBuffer microphonebuffer.Buffer microphoneSignal chan struct{} + speakerStreamTelemetry *dualShock4OutputStreamTelemetry mtx sync.Mutex } @@ -117,8 +122,9 @@ func New(o *device.CreateOptions) (*DualShock4, error) { "interfaces", len(d.descriptor.Interfaces)) d.inputState = NewInputState() - d.inputCh = make(chan *InputState, 1) - d.inputCh <- d.inputState + d.inputQueue = inputstatequeue.New( + *d.inputState, dualShock4InputEdgeSignature(*d.inputState), + inputTransitionQueueCapacity) d.timestampBase = time.Now() return d, nil @@ -131,6 +137,9 @@ func (d *DualShock4) SetMetaState(meta MetaState) { } func (d *DualShock4) SetOutputCallback(f func(OutputState)) { + d.outputPublishMu.Lock() + defer d.outputPublishMu.Unlock() + var latest OutputState var replay bool @@ -148,38 +157,87 @@ func (d *DualShock4) SetOutputCallback(f func(OutputState)) { } func (d *DualShock4) SetSpeakerCallback(f func([]byte)) { - d.mtx.Lock() - d.speakerFunc = f - d.mtx.Unlock() + d.replaceSpeakerCallbacks(func() { d.speakerFunc = f }) } // SetSpeakerResetCallback installs the transport-side queue reset paired with // SetSpeakerCallback. Interface transitions and endpoint pipe resets must drop // speaker PCM from the previous USB presentation generation. func (d *DualShock4) SetSpeakerResetCallback(f func()) { + d.replaceSpeakerCallbacks(func() { d.speakerResetFunc = f }) +} + +func (d *DualShock4) setSpeakerCallbacks(speaker func([]byte), reset func()) { + d.replaceSpeakerCallbacks(func() { + d.speakerFunc = speaker + d.speakerResetFunc = reset + }) +} + +// detachSpeakerStreamCallbacks is the terminal transport boundary. It fences +// every callback already publishing and removes future producers without +// synchronously invoking the old writer's reset callback. The owning handler +// then performs the authoritative writer rundown and reports any join error. +func (d *DualShock4) detachSpeakerStreamCallbacks() { + d.speakerPublishMu.Lock() + defer d.speakerPublishMu.Unlock() + d.mtx.Lock() - d.speakerResetFunc = f + d.speakerRevision++ + d.speakerFunc = nil + d.speakerResetFunc = nil d.mtx.Unlock() } -func (d *DualShock4) UpdateInputState(state *InputState) { - d.inputPublishMu.Lock() - defer d.inputPublishMu.Unlock() +func (d *DualShock4) replaceSpeakerCallbacks(update func()) { + d.speakerPublishMu.Lock() + defer d.speakerPublishMu.Unlock() + + d.mtx.Lock() + resetSpeaker := d.speakerResetFunc + d.speakerRevision++ + update() + d.mtx.Unlock() + if resetSpeaker != nil { + resetSpeaker() + } +} + +func (d *DualShock4) UpdateInputState(state *InputState) error { + return d.UpdateInputStateUntil(nil, state) +} + +func (d *DualShock4) UpdateInputStateUntil(done <-chan struct{}, state *InputState) error { next := *NewInputState() if state != nil { next = *state } - nextPtr := &next - + if err := d.inputQueue.PublishUntil( + done, next, dualShock4InputEdgeSignature(next)); err != nil { + return err + } d.mtx.Lock() - d.inputState = nextPtr + d.inputState = &next d.mtx.Unlock() - select { - case <-d.inputCh: - default: + return nil +} + +func dualShock4InputEdgeSignature(state InputState) uint64 { + signature := uint64(state.Buttons) | uint64(state.DPad)<<16 + if state.Touch1Active { + signature |= 1 << 24 + } + if state.Touch2Active { + signature |= 1 << 32 + } + return signature +} + +func (d *DualShock4) InvalidateInterruptInput(endpoint uint8) { + if endpoint == 0 || endpoint&0x0f == EndpointIn&0x0f { + d.inputQueue.Invalidate() } - d.inputCh <- nextPtr } func (d *DualShock4) GetDescriptor() *usb.Descriptor { @@ -200,6 +258,52 @@ func (d *DualShock4) GetDeviceSpecificArgs() map[string]any { return map[string]any{} } res["speakerInterfaceActive"] = d.speakerInterfaceActive + speakerState := d.speakerStreamTelemetry.snapshot() + res["speakerStreamActive"] = speakerState.Active + res["speakerOrderedFramesReceived"] = speakerState.OrderedReceived + res["speakerOrderedFramesEnqueued"] = speakerState.OrderedEnqueued + res["speakerOrderedFramesRejected"] = speakerState.OrderedRejected + res["speakerOrderedFramesWritten"] = speakerState.OrderedWritten + res["speakerOrderedSaturations"] = speakerState.OrderedSaturations + res["speakerOrderedQueueDepth"] = speakerState.OrderedQueueDepth + res["speakerOrderedQueueHighWater"] = speakerState.OrderedQueueHighWater + res["speakerOrderedLifecycleDiscardedFrames"] = + speakerState.OrderedLifecycleDiscardedFrames + res["speakerOrderedLifecycleDiscardedBytes"] = + speakerState.OrderedLifecycleDiscardedBytes + res["speakerPayloadsReceived"] = speakerState.MediaReceivedPayloads + res["speakerBytesReceived"] = speakerState.MediaReceivedBytes + res["speakerPayloadsEnqueued"] = speakerState.MediaEnqueuedPayloads + res["speakerBytesEnqueued"] = speakerState.MediaEnqueuedBytes + res["speakerPayloadsRejectedAfterFault"] = speakerState.MediaRejectedPayloads + res["speakerBytesRejectedAfterFault"] = speakerState.MediaRejectedBytes + res["speakerMalformedPayloads"] = speakerState.MediaMalformedPayloads + res["speakerMalformedBytes"] = speakerState.MediaMalformedBytes + res["speakerOversizePayloads"] = speakerState.MediaOversizePayloads + res["speakerOversizeBytes"] = speakerState.MediaOversizeBytes + res["speakerPayloadsDropped"] = speakerState.MediaDroppedPayloads + res["speakerBytesDropped"] = speakerState.MediaDroppedBytes + res["speakerQueueOverruns"] = speakerState.MediaOverruns + res["speakerQueueUnderruns"] = speakerState.MediaUnderruns + res["speakerLateGaps"] = speakerState.MediaLateGaps + res["speakerStalePayloads"] = speakerState.MediaStalePayloads + res["speakerStaleBytes"] = speakerState.MediaStaleBytes + res["speakerLifecycleDiscardedPayloads"] = + speakerState.MediaLifecycleDiscardedPayloads + res["speakerLifecycleDiscardedBytes"] = + speakerState.MediaLifecycleDiscardedBytes + res["speakerPayloadsWritten"] = speakerState.MediaWrittenPayloads + res["speakerBytesWritten"] = speakerState.MediaWrittenBytes + res["speakerOrderedWriteFailures"] = speakerState.OrderedWriteFailures + res["speakerWriteFailures"] = speakerState.MediaWriteFailures + res["speakerQueueDepth"] = speakerState.MediaQueueDepth + res["speakerQueueHighWater"] = speakerState.MediaQueueHighWater + res["speakerQueueDurationUS"] = speakerState.MediaQueueDurationUS + res["speakerQueueDurationHighWaterUS"] = speakerState.MediaQueueDurationHighWaterUS + res["speakerMaxEnqueueGapUS"] = speakerState.MaxMediaEnqueueGapUS + res["speakerMaxWriteGapUS"] = speakerState.MaxMediaWriteGapUS + res["speakerTeardownFailures"] = speakerState.TeardownFailures + res["speakerTeardownPending"] = speakerState.TeardownPending res["microphoneInterfaceActive"] = d.microphoneInterfaceActive microphoneState := d.microphoneBuffer.State() res["queuedMicrophoneBytes"] = microphoneState.QueuedBytes @@ -230,16 +334,26 @@ func (d *DualShock4) GetDeviceSpecificArgs() map[string]any { return res } +// beginSpeakerStream gives each transport generation independent counters so +// an older writer cannot overwrite the health state of its replacement. +func (d *DualShock4) beginSpeakerStream() *dualShock4OutputStreamTelemetry { + telemetry := &dualShock4OutputStreamTelemetry{} + d.mtx.Lock() + d.speakerStreamTelemetry = telemetry + d.mtx.Unlock() + return telemetry +} + func (d *DualShock4) SetInterfaceAltSetting(iface, alt uint8) { + if iface == InterfaceSpeaker { + d.resetSpeakerPresentation(func() { + d.speakerInterfaceActive = alt != 0 + }) + return + } + d.mtx.Lock() - var resetSpeaker func() switch iface { - case InterfaceSpeaker: - wasActive := d.speakerInterfaceActive - d.speakerInterfaceActive = alt != 0 - if wasActive != d.speakerInterfaceActive { - resetSpeaker = d.speakerResetFunc - } case InterfaceMicrophone: wasActive := d.microphoneInterfaceActive d.microphoneInterfaceActive = alt != 0 @@ -249,29 +363,41 @@ func (d *DualShock4) SetInterfaceAltSetting(iface, alt uint8) { } } d.mtx.Unlock() - - // The transport reset may wait for an in-flight socket write. Never hold the - // device mutex across that wait: output and USB teardown callbacks also need - // to acquire it before the writer can finish shutting down. - if resetSpeaker != nil { - resetSpeaker() - } } // ResetEndpoint implements usb.EndpointResetDevice. CLEAR_FEATURE(HALT) // preserves the selected alternate setting while establishing a hard data // generation boundary for the affected audio pipe. func (d *DualShock4) ResetEndpoint(endpoint uint8) { + if endpoint == EndpointAudioOut { + d.resetSpeakerPresentation(nil) + return + } + d.mtx.Lock() - var resetSpeaker func() switch endpoint { - case EndpointAudioOut: - resetSpeaker = d.speakerResetFunc case EndpointMicrophoneIn: d.microphoneBuffer.Reset() d.drainMicrophoneSignal() } d.mtx.Unlock() +} + +// resetSpeakerPresentation orders the device revision and the framed-writer +// generation as one hard barrier. A callback already publishing completes +// before the queue is flushed; any older callback that has not entered the +// gate observes the new revision and is rejected. +func (d *DualShock4) resetSpeakerPresentation(update func()) { + d.speakerPublishMu.Lock() + defer d.speakerPublishMu.Unlock() + + d.mtx.Lock() + d.speakerRevision++ + if update != nil { + update() + } + resetSpeaker := d.speakerResetFunc + d.mtx.Unlock() if resetSpeaker != nil { resetSpeaker() @@ -280,25 +406,17 @@ func (d *DualShock4) ResetEndpoint(endpoint uint8) { func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { epNumber := ep & 0x0F - if dir == usbip.DirIn { + if dir == usb.DirectionIn { switch epNumber { case 4: - select { - case <-ctx.Done(): - if errors.Is(ctx.Err(), context.DeadlineExceeded) { - d.mtx.Lock() - is := d.inputState - ms := *d.metaState - d.mtx.Unlock() - return d.buildUSBInputReport(is, &ms) - } + is, _, err := d.inputQueue.Wait(ctx, nil) + if err != nil { return nil - case is := <-d.inputCh: - d.mtx.Lock() - ms := *d.metaState - d.mtx.Unlock() - return d.buildUSBInputReport(is, &ms) } + d.mtx.Lock() + ms := *d.metaState + d.mtx.Unlock() + return d.buildUSBInputReport(&is, &ms) case EndpointMicrophoneIn & 0x0F: return d.handleMicrophoneIn(ctx) default: @@ -306,9 +424,10 @@ func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, } } - if dir == usbip.DirOut && epNumber == EndpointOut&0x0F { + if dir == usb.DirectionOut && epNumber == EndpointOut&0x0F { if len(out) >= 11 && out[0] == ReportIDOutput { feedback := parseOutputReport(out) + d.outputPublishMu.RLock() d.mtx.Lock() d.outputState = feedback d.outputSeen = true @@ -317,18 +436,20 @@ func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, if outputFunc != nil { outputFunc(feedback) } + d.outputPublishMu.RUnlock() } } - if dir == usbip.DirOut && epNumber == EndpointAudioOut&0x0F { + if dir == usb.DirectionOut && epNumber == EndpointAudioOut&0x0F { d.mtx.Lock() if d.speakerInterfaceActive && d.speakerFunc != nil && len(out) > 0 { - // The USB/IP receive buffer is owned by the transfer handler. Give the + // The transport receive buffer is owned by the transfer handler. Give the // device-stream writer an immutable copy; its owned enqueue path then - // forwards this same allocation without making a second copy. Complete - // the synchronous enqueue under the device lock so a subsequent - // interface or endpoint reset cannot flush the queue and then be raced - // by a pre-reset callback publishing stale PCM afterward. - d.speakerFunc(append([]byte(nil), out...)) + // forwards this same allocation without making a second copy. + pcm := append([]byte(nil), out...) + revision := d.speakerRevision + d.mtx.Unlock() + d.publishSpeakerPCM(revision, pcm) + return nil } d.mtx.Unlock() return nil @@ -337,6 +458,70 @@ func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, return nil } +func (d *DualShock4) publishSpeakerPCM(revision uint64, pcm []byte) bool { + if len(pcm) == 0 { + return false + } + d.speakerPublishMu.RLock() + defer d.speakerPublishMu.RUnlock() + + d.mtx.Lock() + if revision != d.speakerRevision || !d.speakerInterfaceActive || + d.speakerFunc == nil { + d.mtx.Unlock() + return false + } + speakerFunc := d.speakerFunc + d.mtx.Unlock() + + speakerFunc(pcm) + return true +} + +// ReadInterruptInput implements usb.InterruptInputDevice for native UDE. It +// writes the controller's next HID sample into caller-owned storage; USB/IP +// continues to use HandleTransfer and its independently owned report slice. +func (d *DualShock4) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + written, _, err := d.readInterruptInput(ctx, nil, ep, dst) + return written, err +} + +// ReadScheduledInterruptInput keeps the exact DualShock 4 packet counter and +// sensor timestamp encoder while native UDE supplies a reusable endpoint +// deadline instead of a fresh timer-backed context for every idle sample. +func (d *DualShock4) ReadScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { + written, _, err := d.readInterruptInput(ctx, deadline, ep, dst) + return written, err +} + +func (d *DualShock4) ReadClassifiedScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, bool, error) { + return d.readInterruptInput(ctx, deadline, ep, dst) +} + +func (d *DualShock4) readInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, bool, error) { + if ep&0x0f != EndpointIn&0x0f { + return 0, false, fmt.Errorf("DualShock 4 interrupt-IN endpoint %d is unsupported", ep) + } + if deadline != nil && ctx.Err() != nil { + return 0, false, ctx.Err() + } + is, transition, err := d.inputQueue.Wait(ctx, deadline) + if err != nil { + return 0, false, err + } + d.mtx.Lock() + ms := *d.metaState + d.mtx.Unlock() + written, err := d.buildUSBInputReportInto(&is, &ms, dst) + return written, transition, err +} + func (d *DualShock4) QueueMicrophonePCMFrame(frame []byte) { if len(frame) != USBMicrophoneClientFrameSize { return @@ -399,6 +584,33 @@ func (d *DualShock4) handleMicrophoneIn(ctx context.Context) []byte { } } +// ReadIsochronousInput implements usb.IsochronousInputDevice without changing +// the USB/IP packet timeout and ownership contract. Native UDE calls at the +// packet service point, so an empty capture queue becomes a legal zero packet +// rather than a second timer in the real-time path. +func (d *DualShock4) ReadIsochronousInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + if ep&0x0f != EndpointMicrophoneIn&0x0f { + return 0, fmt.Errorf("DualShock 4 isochronous-IN endpoint %d is unsupported", ep) + } + if len(dst) < USBMicrophonePacketSize { + return 0, io.ErrShortBuffer + } + if err := ctx.Err(); err != nil { + return 0, err + } + packet := dst[:min(len(dst), USBMicrophoneMaxPacketSize)] + clear(packet) + d.mtx.Lock() + defer d.mtx.Unlock() + if d.microphoneInterfaceActive { + if actualLength, ok := d.microphoneBuffer.ReadPacket(packet); ok { + return actualLength, nil + } + } + d.microphoneBuffer.RecordZeroPacket() + return USBMicrophonePacketSize, nil +} + func (d *DualShock4) drainMicrophoneSignal() { for { select { @@ -734,6 +946,16 @@ func (d *DualShock4) buildCalibrationReport(id byte) []byte { func (d *DualShock4) buildUSBInputReport(s *InputState, m *MetaState) []byte { b := make([]byte, InputReportSize) + _, _ = d.buildUSBInputReportInto(s, m, b) + return b +} + +func (d *DualShock4) buildUSBInputReportInto(s *InputState, m *MetaState, dst []byte) (int, error) { + if len(dst) < InputReportSize { + return 0, io.ErrShortBuffer + } + b := dst[:InputReportSize] + clear(b) b[0] = ReportIDInput @@ -809,7 +1031,7 @@ func (d *DualShock4) buildUSBInputReport(s *InputState, m *MetaState) []byte { b[39] = touch2Counter encodeTouchCoords(b[40:43], s.Touch2X, s.Touch2Y) - return b + return InputReportSize, nil } func (d *DualShock4) nextReportTimestamp() uint32 { diff --git a/device/dualshock4/handler.go b/device/dualshock4/handler.go index 5b7deaa8..8affce0d 100644 --- a/device/dualshock4/handler.go +++ b/device/dualshock4/handler.go @@ -3,6 +3,7 @@ package dualshock4 import ( "encoding/binary" "encoding/json" + "errors" "fmt" "hash/crc32" "io" @@ -131,7 +132,8 @@ func (h *handler) StreamHandler() api.StreamHandlerFunc { var writer *dualShock4OutputWriter if speakerOutput && streamFrameVersion == StreamFrameVersionV3 { - writer = newDualShock4OutputWriter(conn, streamFrameVersion) + writer = newDualShock4OutputWriterForStream(conn, streamFrameVersion, + ds4.beginSpeakerStream(), logger) ds4.SetOutputCallback(func(feedback OutputState) { data, err := feedback.MarshalBinary() if err != nil { @@ -140,101 +142,340 @@ func (h *handler) StreamHandler() api.StreamHandlerFunc { } writer.EnqueueControl(StreamFrameOutputState, data) }) - ds4.SetSpeakerCallback(func(pcm []byte) { + speakerCallback := func(pcm []byte) { writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, pcm) - }) - ds4.SetSpeakerResetCallback(writer.ResetSpeaker) + } + ds4.setSpeakerCallbacks(speakerCallback, writer.ResetSpeaker) go writer.Run() - defer func() { - ds4.SetOutputCallback(nil) - ds4.SetSpeakerCallback(nil) - ds4.SetSpeakerResetCallback(nil) - writer.Stop() - }() - } else { - ds4.SetOutputCallback(func(feedback OutputState) { - data, err := feedback.MarshalBinary() - if err != nil { - logger.Error("failed to marshal feedback", "error", err) - return - } - if _, err := conn.Write(data); err != nil { - logger.Error("failed to send feedback", "error", err) - } - }) - defer ds4.SetOutputCallback(nil) + streamErr := readDualShock4InputStream(conn, ds4, logger, + microphoneInput, streamFrameVersion) + // Detach producers before requesting writer rundown. Stop returns a + // latched failure if the writer cannot authoritatively join. + ds4.SetOutputCallback(nil) + ds4.detachSpeakerStreamCallbacks() + return errors.Join(streamErr, writer.Stop()) } + ds4.SetOutputCallback(func(feedback OutputState) { + data, err := feedback.MarshalBinary() + if err != nil { + logger.Error("failed to marshal feedback", "error", err) + return + } + if _, err := conn.Write(data); err != nil { + logger.Error("failed to send feedback", "error", err) + } + }) + defer ds4.SetOutputCallback(nil) return readDualShock4InputStream(conn, ds4, logger, microphoneInput, streamFrameVersion) } } type dualShock4OutputFrame struct { - frameType byte - payload []byte - pooledBuffer *dualShock4AudioBuffer - audio bool - generation uint64 + frameType byte + payload []byte + pooledBuffer *dualShock4AudioBuffer + audio bool + mediaBytes int + mediaDuration time.Duration + generation uint64 + publication uint64 } type dualShock4AudioBuffer struct { data []byte } -const dualShock4SpeakerResetWriteTimeout = 250 * time.Millisecond +const ( + dualShock4OutputControlQueueCapacity = 32 + dualShock4SpeakerFrameBytes = USBSpeakerChannels * USBSpeakerBytesPerSample + // Cadence is retained solely for observed producer-gap telemetry. Queue + // admission derives time from each callback's actual aligned PCM frames. + dualShock4SpeakerGenerationFrames = USBSpeakerSampleRate / 100 + dualShock4SpeakerGenerationCadence = time.Second * + dualShock4SpeakerGenerationFrames / USBSpeakerSampleRate + dualShock4SpeakerMaximumBufferTime = 200 * time.Millisecond + dualShock4SpeakerMaximumBufferFrames = int( + int64(USBSpeakerSampleRate) * int64(dualShock4SpeakerMaximumBufferTime) / + int64(time.Second)) + // Preserve the cadence-derived item ceiling as an independent allocation + // bound. Exact payload duration below additionally enforces the 200 ms cap. + dualShock4OutputAudioQueueCapacity = int( + dualShock4SpeakerMaximumBufferTime / dualShock4SpeakerGenerationCadence) + dualShock4SpeakerResetWriteTimeout = 250 * time.Millisecond + dualShock4OutputJoinTimeout = 300 * time.Millisecond +) + +var errDualShock4OutputJoinTimeout = errors.New( + "DualShock 4 output writer did not stop before the join deadline") + +type dualShock4OutputStreamTelemetry struct { + orderedReceived atomic.Uint64 + orderedEnqueued atomic.Uint64 + orderedRejected atomic.Uint64 + orderedWritten atomic.Uint64 + orderedSaturations atomic.Uint64 + orderedQueueDepth atomic.Uint64 + orderedQueueHighWater atomic.Uint64 + orderedLifecycleDiscardedFrames atomic.Uint64 + orderedLifecycleDiscardedBytes atomic.Uint64 + mediaReceivedPayloads atomic.Uint64 + mediaReceivedBytes atomic.Uint64 + mediaEnqueuedPayloads atomic.Uint64 + mediaEnqueuedBytes atomic.Uint64 + mediaRejectedPayloads atomic.Uint64 + mediaRejectedBytes atomic.Uint64 + mediaMalformedPayloads atomic.Uint64 + mediaMalformedBytes atomic.Uint64 + mediaOversizePayloads atomic.Uint64 + mediaOversizeBytes atomic.Uint64 + mediaDroppedPayloads atomic.Uint64 + mediaDroppedBytes atomic.Uint64 + mediaOverruns atomic.Uint64 + mediaUnderruns atomic.Uint64 + mediaLateGaps atomic.Uint64 + mediaStalePayloads atomic.Uint64 + mediaStaleBytes atomic.Uint64 + mediaLifecycleDiscardedPayloads atomic.Uint64 + mediaLifecycleDiscardedBytes atomic.Uint64 + mediaWrittenPayloads atomic.Uint64 + mediaWrittenBytes atomic.Uint64 + orderedWriteFailures atomic.Uint64 + mediaWriteFailures atomic.Uint64 + mediaQueueDepth atomic.Uint64 + mediaQueueHighWater atomic.Uint64 + mediaQueueDurationNS atomic.Int64 + mediaQueueDurationHighNS atomic.Int64 + lastMediaEnqueueNS atomic.Int64 + maxMediaEnqueueGapNS atomic.Int64 + lastMediaWriteNS atomic.Int64 + maxMediaWriteGapNS atomic.Int64 + active atomic.Bool + teardownFailures atomic.Uint64 + teardownPending atomic.Bool +} + +type dualShock4OutputStreamSnapshot struct { + OrderedReceived uint64 + OrderedEnqueued uint64 + OrderedRejected uint64 + OrderedWritten uint64 + OrderedSaturations uint64 + OrderedQueueDepth uint64 + OrderedQueueHighWater uint64 + OrderedLifecycleDiscardedFrames uint64 + OrderedLifecycleDiscardedBytes uint64 + MediaReceivedPayloads uint64 + MediaReceivedBytes uint64 + MediaEnqueuedPayloads uint64 + MediaEnqueuedBytes uint64 + MediaRejectedPayloads uint64 + MediaRejectedBytes uint64 + MediaMalformedPayloads uint64 + MediaMalformedBytes uint64 + MediaOversizePayloads uint64 + MediaOversizeBytes uint64 + MediaDroppedPayloads uint64 + MediaDroppedBytes uint64 + MediaOverruns uint64 + MediaUnderruns uint64 + MediaLateGaps uint64 + MediaStalePayloads uint64 + MediaStaleBytes uint64 + MediaLifecycleDiscardedPayloads uint64 + MediaLifecycleDiscardedBytes uint64 + MediaWrittenPayloads uint64 + MediaWrittenBytes uint64 + OrderedWriteFailures uint64 + MediaWriteFailures uint64 + MediaQueueDepth uint64 + MediaQueueHighWater uint64 + MediaQueueDurationUS int64 + MediaQueueDurationHighWaterUS int64 + MaxMediaEnqueueGapUS int64 + MaxMediaWriteGapUS int64 + Active bool + TeardownFailures uint64 + TeardownPending bool +} + +func (s *dualShock4OutputStreamTelemetry) snapshot() dualShock4OutputStreamSnapshot { + if s == nil { + return dualShock4OutputStreamSnapshot{} + } + return dualShock4OutputStreamSnapshot{ + OrderedReceived: s.orderedReceived.Load(), + OrderedEnqueued: s.orderedEnqueued.Load(), + OrderedRejected: s.orderedRejected.Load(), + OrderedWritten: s.orderedWritten.Load(), + OrderedSaturations: s.orderedSaturations.Load(), + OrderedQueueDepth: s.orderedQueueDepth.Load(), + OrderedQueueHighWater: s.orderedQueueHighWater.Load(), + OrderedLifecycleDiscardedFrames: s.orderedLifecycleDiscardedFrames.Load(), + OrderedLifecycleDiscardedBytes: s.orderedLifecycleDiscardedBytes.Load(), + MediaReceivedPayloads: s.mediaReceivedPayloads.Load(), + MediaReceivedBytes: s.mediaReceivedBytes.Load(), + MediaEnqueuedPayloads: s.mediaEnqueuedPayloads.Load(), + MediaEnqueuedBytes: s.mediaEnqueuedBytes.Load(), + MediaRejectedPayloads: s.mediaRejectedPayloads.Load(), + MediaRejectedBytes: s.mediaRejectedBytes.Load(), + MediaMalformedPayloads: s.mediaMalformedPayloads.Load(), + MediaMalformedBytes: s.mediaMalformedBytes.Load(), + MediaOversizePayloads: s.mediaOversizePayloads.Load(), + MediaOversizeBytes: s.mediaOversizeBytes.Load(), + MediaDroppedPayloads: s.mediaDroppedPayloads.Load(), + MediaDroppedBytes: s.mediaDroppedBytes.Load(), + MediaOverruns: s.mediaOverruns.Load(), + MediaUnderruns: s.mediaUnderruns.Load(), + MediaLateGaps: s.mediaLateGaps.Load(), + MediaStalePayloads: s.mediaStalePayloads.Load(), + MediaStaleBytes: s.mediaStaleBytes.Load(), + MediaLifecycleDiscardedPayloads: s.mediaLifecycleDiscardedPayloads.Load(), + MediaLifecycleDiscardedBytes: s.mediaLifecycleDiscardedBytes.Load(), + MediaWrittenPayloads: s.mediaWrittenPayloads.Load(), + MediaWrittenBytes: s.mediaWrittenBytes.Load(), + OrderedWriteFailures: s.orderedWriteFailures.Load(), + MediaWriteFailures: s.mediaWriteFailures.Load(), + MediaQueueDepth: s.mediaQueueDepth.Load(), + MediaQueueHighWater: s.mediaQueueHighWater.Load(), + MediaQueueDurationUS: s.mediaQueueDurationNS.Load() / + int64(time.Microsecond), + MediaQueueDurationHighWaterUS: s.mediaQueueDurationHighNS.Load() / + int64(time.Microsecond), + MaxMediaEnqueueGapUS: s.maxMediaEnqueueGapNS.Load() / + int64(time.Microsecond), + MaxMediaWriteGapUS: s.maxMediaWriteGapNS.Load() / + int64(time.Microsecond), + Active: s.active.Load(), + TeardownFailures: s.teardownFailures.Load(), + TeardownPending: s.teardownPending.Load(), + } +} // dualShock4OutputWriter keeps USB isochronous completion independent from // local TCP backpressure. Control feedback and speaker PCM share one writer so // their framing sequence is strictly monotonic and conn.Write is never raced. type dualShock4OutputWriter struct { - conn net.Conn - version byte - control chan dualShock4OutputFrame - audio chan dualShock4OutputFrame - stop chan struct{} - done chan struct{} - stopOnce sync.Once - enqueueLock sync.RWMutex - audioWrite sync.Mutex - stopped bool - audioGeneration atomic.Uint64 - sequence uint32 - packet []byte - audioPool sync.Pool + conn net.Conn + version byte + logger *slog.Logger + telemetry *dualShock4OutputStreamTelemetry + control chan dualShock4OutputFrame + audio chan dualShock4OutputFrame + stop chan struct{} + done chan struct{} + stopOnce sync.Once + enqueueLock sync.RWMutex + controlEnqueue sync.Mutex + audioEnqueue sync.Mutex + audioWrite sync.Mutex + stopped bool + accepting atomic.Bool + audioGeneration atomic.Uint64 + orderedPublication uint64 + sequence uint32 + packet []byte + audioPool sync.Pool + teardownMu sync.Mutex + teardownErr error + teardownFailureOnce sync.Once + teardownJoinOnce sync.Once } func newDualShock4OutputWriter(conn net.Conn, version byte) *dualShock4OutputWriter { - return &dualShock4OutputWriter{ - conn: conn, version: version, - control: make(chan dualShock4OutputFrame, 32), - audio: make(chan dualShock4OutputFrame, 256), - stop: make(chan struct{}), done: make(chan struct{}), + return newDualShock4OutputWriterForStream(conn, version, nil, nil) +} + +func newDualShock4OutputWriterForStream(conn net.Conn, version byte, + telemetry *dualShock4OutputStreamTelemetry, + logger *slog.Logger) *dualShock4OutputWriter { + if telemetry == nil { + telemetry = &dualShock4OutputStreamTelemetry{} + } + telemetry.orderedQueueDepth.Store(0) + telemetry.mediaQueueDepth.Store(0) + telemetry.mediaQueueDurationNS.Store(0) + telemetry.lastMediaEnqueueNS.Store(0) + telemetry.lastMediaWriteNS.Store(0) + telemetry.active.Store(true) + w := &dualShock4OutputWriter{ + conn: conn, version: version, logger: logger, telemetry: telemetry, + control: make(chan dualShock4OutputFrame, + dualShock4OutputControlQueueCapacity), + audio: make(chan dualShock4OutputFrame, + dualShock4OutputAudioQueueCapacity), + stop: make(chan struct{}), done: make(chan struct{}), } + w.accepting.Store(true) + return w } func (w *dualShock4OutputWriter) EnqueueControl(frameType byte, payload []byte) { if len(payload) == 0 { return } - w.enqueueLock.RLock() - defer w.enqueueLock.RUnlock() - if w.stopped { + w.controlEnqueue.Lock() + defer w.controlEnqueue.Unlock() + w.telemetry.orderedReceived.Add(1) + if !w.accepting.Load() { + w.telemetry.orderedRejected.Add(1) return } - w.enqueueFrameLocked(w.control, dualShock4OutputFrame{ + frame := dualShock4OutputFrame{ frameType: frameType, payload: append([]byte(nil), payload...), - }) + } + w.enqueueLock.RLock() + if w.stopped || !w.accepting.Load() { + w.telemetry.orderedRejected.Add(1) + w.enqueueLock.RUnlock() + return + } + w.orderedPublication++ + frame.publication = w.orderedPublication + depth := w.telemetry.orderedQueueDepth.Add(1) + select { + case w.control <- frame: + w.telemetry.orderedEnqueued.Add(1) + recordDualShock4MaximumUint64(&w.telemetry.orderedQueueHighWater, depth) + w.enqueueLock.RUnlock() + return + default: + decrementDualShock4Uint64(&w.telemetry.orderedQueueDepth) + } + // Ordered feedback is lossless while the stream is viable. Capacity + // exhaustion is therefore a stream failure, never permission to evict an + // earlier rumble/LED/media-configuration update. + w.telemetry.orderedRejected.Add(1) + if !w.accepting.CompareAndSwap(true, false) { + w.enqueueLock.RUnlock() + return + } + w.telemetry.orderedSaturations.Add(1) + w.enqueueLock.RUnlock() + w.failStream("ordered output queue saturated") } func (w *dualShock4OutputWriter) EnqueueAudio(frameType byte, payload []byte) { if len(payload) == 0 { return } + w.audioEnqueue.Lock() + defer w.audioEnqueue.Unlock() + w.recordMediaReceive(len(payload)) + duration, valid := w.validateMediaPayload(payload) + if !valid { + return + } + if !w.accepting.Load() { + w.recordMediaRejected(len(payload)) + return + } w.enqueueLock.RLock() defer w.enqueueLock.RUnlock() - if w.stopped { + if w.stopped || !w.accepting.Load() { + w.recordMediaRejected(len(payload)) return } var buffer *dualShock4AudioBuffer @@ -253,11 +494,10 @@ func (w *dualShock4OutputWriter) EnqueueAudio(frameType byte, payload []byte) { copy(owned, payload) frame := dualShock4OutputFrame{ frameType: frameType, payload: owned, pooledBuffer: buffer, audio: true, + mediaBytes: len(payload), mediaDuration: duration, generation: w.audioGeneration.Load(), } - if !w.enqueueFrameLocked(w.audio, frame) { - w.releaseAudioBuffer(buffer) - } + w.enqueueMediaDropOldestLocked(frame) } // EnqueueAudioOwned accepts the immutable buffer transferred by DualShock4's @@ -266,43 +506,82 @@ func (w *dualShock4OutputWriter) EnqueueAudioOwned(frameType byte, payload []byt if len(payload) == 0 { return } + w.audioEnqueue.Lock() + defer w.audioEnqueue.Unlock() + w.recordMediaReceive(len(payload)) + duration, valid := w.validateMediaPayload(payload) + if !valid { + return + } + if !w.accepting.Load() { + w.recordMediaRejected(len(payload)) + return + } w.enqueueLock.RLock() defer w.enqueueLock.RUnlock() - if w.stopped { + if w.stopped || !w.accepting.Load() { + w.recordMediaRejected(len(payload)) return } - w.enqueueFrameLocked(w.audio, dualShock4OutputFrame{ + w.enqueueMediaDropOldestLocked(dualShock4OutputFrame{ frameType: frameType, payload: payload, audio: true, + mediaBytes: len(payload), mediaDuration: duration, generation: w.audioGeneration.Load(), }) } -// enqueueFrameLocked requires enqueueLock to be held for reading. Reset and -// shutdown take the write side before draining, so a producer cannot publish a -// stale frame after the final empty-queue observation. -func (w *dualShock4OutputWriter) enqueueFrameLocked( - queue chan dualShock4OutputFrame, frame dualShock4OutputFrame) bool { +// enqueueMediaDropOldestLocked keeps at most the derived 200 ms media window. +// The only removable item is read from the queue itself, so an in-flight write +// is never selected as the overrun victim. +func (w *dualShock4OutputWriter) enqueueMediaDropOldestLocked( + frame dualShock4OutputFrame) { + duration := int64(frame.mediaDuration) + for w.telemetry.mediaQueueDurationNS.Load()+duration > + int64(dualShock4SpeakerMaximumBufferTime) || + w.telemetry.mediaQueueDepth.Load() >= uint64(cap(w.audio)) { + select { + case oldest := <-w.audio: + w.recordMediaDequeued(oldest) + w.recordMediaOverrun(oldest) + w.release(oldest) + default: + // The sole consumer may have removed the last queued item between + // observations. Retry admission using the exact atomic totals. + continue + } + } + reservedDuration := w.telemetry.mediaQueueDurationNS.Add(duration) + depth := w.telemetry.mediaQueueDepth.Add(1) select { - case queue <- frame: - return true + case w.audio <- frame: + w.recordMediaEnqueue(frame.mediaBytes, depth, reservedDuration) default: - // Never block the USB/IP isochronous or HID callback. The receiver - // bounds its own latency too, so dropping newest under pathological - // backpressure is preferable to stalling the virtual USB device. - return false + w.telemetry.mediaQueueDurationNS.Add(-duration) + decrementDualShock4Uint64(&w.telemetry.mediaQueueDepth) + w.recordMediaOverrun(frame) + w.release(frame) } } func (w *dualShock4OutputWriter) Run() { defer func() { w.requestStop() - w.drainAudioQueue() + w.drainControlQueue() + w.drainAudioQueue(dualShock4MediaDiscardLifecycle) + w.telemetry.active.Store(false) + w.traceOutputState() close(w.done) }() for { + select { + case <-w.stop: + return + default: + } // Give feedback priority without starving speaker packets. select { case frame := <-w.control: + decrementDualShock4Uint64(&w.telemetry.orderedQueueDepth) if !w.writeAndRelease(frame) { return } @@ -314,10 +593,12 @@ func (w *dualShock4OutputWriter) Run() { case <-w.stop: return case frame := <-w.control: + decrementDualShock4Uint64(&w.telemetry.orderedQueueDepth) if !w.writeAndRelease(frame) { return } case frame := <-w.audio: + w.recordMediaDequeued(frame) if !w.writeAndRelease(frame) { return } @@ -330,12 +611,24 @@ func (w *dualShock4OutputWriter) writeAndRelease(frame dualShock4OutputFrame) bo w.audioWrite.Lock() defer w.audioWrite.Unlock() if frame.generation != w.audioGeneration.Load() { + w.recordMediaStale(frame) w.release(frame) return true } } ok := w.write(frame) + if frame.audio { + if ok { + w.recordMediaWrite(len(frame.payload)) + } else { + w.telemetry.mediaWriteFailures.Add(1) + } + } else if !ok { + w.telemetry.orderedWriteFailures.Add(1) + } else { + w.telemetry.orderedWritten.Add(1) + } w.release(frame) return ok } @@ -346,14 +639,16 @@ func (w *dualShock4OutputWriter) writeAndRelease(frame dualShock4OutputFrame) bo func (w *dualShock4OutputWriter) ResetSpeaker() { w.enqueueLock.Lock() w.audioGeneration.Add(1) - w.drainAudioQueue() + w.drainAudioQueue(dualShock4MediaDiscardStale) + w.telemetry.lastMediaEnqueueNS.Store(0) + w.telemetry.lastMediaWriteNS.Store(0) w.enqueueLock.Unlock() deadlineArmed := false if w.conn != nil { if err := w.conn.SetWriteDeadline( time.Now().Add(dualShock4SpeakerResetWriteTimeout)); err != nil { - w.failStream() + w.failStream("speaker reset deadline failed") } else { deadlineArmed = true } @@ -375,14 +670,44 @@ func (w *dualShock4OutputWriter) clearWriteDeadlineIfViable() { err := w.conn.SetWriteDeadline(time.Time{}) w.enqueueLock.RUnlock() if err != nil { - w.failStream() + w.failStream("speaker reset deadline clear failed") } } -func (w *dualShock4OutputWriter) drainAudioQueue() { +type dualShock4MediaDiscardReason uint8 + +const ( + dualShock4MediaDiscardStale dualShock4MediaDiscardReason = iota + 1 + dualShock4MediaDiscardLifecycle +) + +func (w *dualShock4OutputWriter) drainControlQueue() { + for { + select { + case frame := <-w.control: + decrementDualShock4Uint64(&w.telemetry.orderedQueueDepth) + w.telemetry.orderedLifecycleDiscardedFrames.Add(1) + w.telemetry.orderedLifecycleDiscardedBytes.Add( + uint64(len(frame.payload))) + w.release(frame) + default: + return + } + } +} + +func (w *dualShock4OutputWriter) drainAudioQueue( + reason dualShock4MediaDiscardReason) { for { select { case frame := <-w.audio: + w.recordMediaDequeued(frame) + switch reason { + case dualShock4MediaDiscardStale: + w.recordMediaStale(frame) + case dualShock4MediaDiscardLifecycle: + w.recordMediaLifecycleDiscard(frame) + } w.release(frame) default: return @@ -417,7 +742,7 @@ func (w *dualShock4OutputWriter) write(frame dualShock4OutputFrame) bool { for len(remaining) > 0 { n, err := w.conn.Write(remaining) if err != nil || n <= 0 { - w.failStream() + w.failStream("socket write failed") return false } remaining = remaining[n:] @@ -425,8 +750,20 @@ func (w *dualShock4OutputWriter) write(frame dualShock4OutputFrame) bool { return true } -func (w *dualShock4OutputWriter) failStream() { +func (w *dualShock4OutputWriter) failStream(reason string) { + w.accepting.Store(false) w.requestStop() + w.telemetry.active.Store(false) + if w.logger != nil { + state := w.telemetry.snapshot() + w.logger.Error("DualShock 4 output stream faulted", + "reason", reason, + "orderedRejected", state.OrderedRejected, + "orderedSaturations", state.OrderedSaturations, + "orderedWriteFailures", state.OrderedWriteFailures, + "mediaOverruns", state.MediaOverruns, + "mediaWriteFailures", state.MediaWriteFailures) + } if w.conn != nil { _ = w.conn.Close() } @@ -443,16 +780,51 @@ func (w *dualShock4OutputWriter) releaseAudioBuffer(buffer *dualShock4AudioBuffe w.audioPool.Put(buffer) } -func (w *dualShock4OutputWriter) Stop() { +func (w *dualShock4OutputWriter) Stop() error { w.requestStop() - _ = w.conn.SetWriteDeadline(time.Now().Add(250 * time.Millisecond)) + if w.conn != nil { + _ = w.conn.SetWriteDeadline( + time.Now().Add(dualShock4SpeakerResetWriteTimeout)) + _ = w.conn.Close() + } + timer := time.NewTimer(dualShock4OutputJoinTimeout) + defer timer.Stop() select { case <-w.done: - case <-time.After(300 * time.Millisecond): + w.telemetry.teardownPending.Store(false) + return w.latchedTeardownError() + case <-timer.C: + w.latchTeardownFailure(errDualShock4OutputJoinTimeout) + w.telemetry.teardownPending.Store(true) + // Keep the writer and every dependent queue/buffer alive until Run's + // authoritative final drain closes done. + w.teardownJoinOnce.Do(func() { + go func() { + <-w.done + w.telemetry.teardownPending.Store(false) + }() + }) + return w.latchedTeardownError() } } +func (w *dualShock4OutputWriter) latchTeardownFailure(err error) { + w.teardownFailureOnce.Do(func() { + w.teardownMu.Lock() + w.teardownErr = err + w.teardownMu.Unlock() + w.telemetry.teardownFailures.Add(1) + }) +} + +func (w *dualShock4OutputWriter) latchedTeardownError() error { + w.teardownMu.Lock() + defer w.teardownMu.Unlock() + return w.teardownErr +} + func (w *dualShock4OutputWriter) requestStop() { + w.accepting.Store(false) w.stopOnce.Do(func() { w.enqueueLock.Lock() w.stopped = true @@ -461,8 +833,163 @@ func (w *dualShock4OutputWriter) requestStop() { }) } +func (w *dualShock4OutputWriter) traceOutputState() { + if w.logger == nil { + return + } + state := w.telemetry.snapshot() + w.logger.Info("DualShock 4 output stream stopped", + "orderedReceived", state.OrderedReceived, + "orderedEnqueued", state.OrderedEnqueued, + "orderedRejected", state.OrderedRejected, + "orderedWritten", state.OrderedWritten, + "orderedSaturations", state.OrderedSaturations, + "orderedLifecycleDiscardedFrames", state.OrderedLifecycleDiscardedFrames, + "orderedLifecycleDiscardedBytes", state.OrderedLifecycleDiscardedBytes, + "mediaReceivedPayloads", state.MediaReceivedPayloads, + "mediaEnqueuedPayloads", state.MediaEnqueuedPayloads, + "mediaRejectedPayloads", state.MediaRejectedPayloads, + "mediaMalformedPayloads", state.MediaMalformedPayloads, + "mediaOversizePayloads", state.MediaOversizePayloads, + "mediaDroppedPayloads", state.MediaDroppedPayloads, + "mediaOverruns", state.MediaOverruns, + "mediaUnderruns", state.MediaUnderruns, + "mediaLateGaps", state.MediaLateGaps, + "mediaStalePayloads", state.MediaStalePayloads, + "mediaLifecycleDiscardedPayloads", + state.MediaLifecycleDiscardedPayloads, + "mediaWrittenPayloads", state.MediaWrittenPayloads, + "mediaWriteFailures", state.MediaWriteFailures, + "mediaQueueHighWater", state.MediaQueueHighWater, + "mediaQueueDurationHighWaterUS", state.MediaQueueDurationHighWaterUS, + "teardownFailures", state.TeardownFailures, + "teardownPending", state.TeardownPending) +} + +func (w *dualShock4OutputWriter) validateMediaPayload(payload []byte) ( + time.Duration, bool) { + if len(payload)%dualShock4SpeakerFrameBytes != 0 { + w.telemetry.mediaMalformedPayloads.Add(1) + w.telemetry.mediaMalformedBytes.Add(uint64(len(payload))) + return 0, false + } + frames := int64(len(payload) / dualShock4SpeakerFrameBytes) + // Round up fractional nanoseconds so admission is conservative for any + // future sample rate that does not divide one second exactly. + durationNS := (frames*int64(time.Second) + + int64(USBSpeakerSampleRate) - 1) / int64(USBSpeakerSampleRate) + duration := time.Duration(durationNS) + if duration > dualShock4SpeakerMaximumBufferTime { + w.telemetry.mediaOversizePayloads.Add(1) + w.telemetry.mediaOversizeBytes.Add(uint64(len(payload))) + return 0, false + } + return duration, true +} + +func (w *dualShock4OutputWriter) recordMediaReceive(length int) { + w.telemetry.mediaReceivedPayloads.Add(1) + w.telemetry.mediaReceivedBytes.Add(uint64(length)) + now := time.Now().UnixNano() + previous := w.telemetry.lastMediaEnqueueNS.Swap(now) + if previous <= 0 || now <= previous { + return + } + gap := now - previous + recordDualShock4MaximumInt64(&w.telemetry.maxMediaEnqueueGapNS, gap) + cadence := int64(dualShock4SpeakerGenerationCadence) + if gap > cadence+cadence/2 { + w.telemetry.mediaLateGaps.Add(1) + missing := uint64(gap/cadence) - 1 + if missing == 0 { + missing = 1 + } + w.telemetry.mediaUnderruns.Add(missing) + } +} + +func (w *dualShock4OutputWriter) recordMediaRejected(length int) { + w.telemetry.mediaRejectedPayloads.Add(1) + w.telemetry.mediaRejectedBytes.Add(uint64(length)) +} + +func (w *dualShock4OutputWriter) recordMediaEnqueue(length int, depth uint64, + duration int64) { + w.telemetry.mediaEnqueuedPayloads.Add(1) + w.telemetry.mediaEnqueuedBytes.Add(uint64(length)) + recordDualShock4MaximumUint64(&w.telemetry.mediaQueueHighWater, depth) + recordDualShock4MaximumInt64(&w.telemetry.mediaQueueDurationHighNS, duration) +} + +func (w *dualShock4OutputWriter) recordMediaDequeued( + frame dualShock4OutputFrame) { + decrementDualShock4Uint64(&w.telemetry.mediaQueueDepth) + if frame.mediaDuration > 0 { + w.telemetry.mediaQueueDurationNS.Add(-int64(frame.mediaDuration)) + } +} + +func (w *dualShock4OutputWriter) recordMediaOverrun(frame dualShock4OutputFrame) { + w.telemetry.mediaOverruns.Add(1) + w.telemetry.mediaDroppedPayloads.Add(1) + w.telemetry.mediaDroppedBytes.Add(uint64(len(frame.payload))) +} + +func (w *dualShock4OutputWriter) recordMediaStale(frame dualShock4OutputFrame) { + w.telemetry.mediaStalePayloads.Add(1) + w.telemetry.mediaStaleBytes.Add(uint64(len(frame.payload))) +} + +func (w *dualShock4OutputWriter) recordMediaLifecycleDiscard( + frame dualShock4OutputFrame) { + w.telemetry.mediaLifecycleDiscardedPayloads.Add(1) + w.telemetry.mediaLifecycleDiscardedBytes.Add(uint64(frame.mediaBytes)) +} + +func (w *dualShock4OutputWriter) recordMediaWrite(length int) { + w.telemetry.mediaWrittenPayloads.Add(1) + w.telemetry.mediaWrittenBytes.Add(uint64(length)) + now := time.Now().UnixNano() + previous := w.telemetry.lastMediaWriteNS.Swap(now) + if previous > 0 && now > previous { + recordDualShock4MaximumInt64(&w.telemetry.maxMediaWriteGapNS, + now-previous) + } +} + +func recordDualShock4MaximumInt64(target *atomic.Int64, value int64) { + for value > 0 { + current := target.Load() + if value <= current || target.CompareAndSwap(current, value) { + return + } + } +} + +func recordDualShock4MaximumUint64(target *atomic.Uint64, value uint64) { + for value > 0 { + current := target.Load() + if value <= current || target.CompareAndSwap(current, value) { + return + } + } +} + +func decrementDualShock4Uint64(target *atomic.Uint64) uint64 { + for { + current := target.Load() + if current == 0 { + return 0 + } + if target.CompareAndSwap(current, current-1) { + return current - 1 + } + } +} + func readDualShock4InputStream(conn net.Conn, ds4 *DualShock4, logger *slog.Logger, microphoneInput bool, frameVersion byte) error { + streamDone := api.StreamDone(conn) if !microphoneInput { buf := make([]byte, InputStateSize) for { @@ -478,7 +1005,9 @@ func readDualShock4InputStream(conn net.Conn, ds4 *DualShock4, if err := state.UnmarshalBinary(buf); err != nil { return fmt.Errorf("unmarshal input state: %w", err) } - ds4.UpdateInputState(&state) + if err := ds4.UpdateInputStateUntil(streamDone, &state); err != nil { + return fmt.Errorf("queue input state: %w", err) + } } } @@ -558,7 +1087,9 @@ func readDualShock4InputStream(conn net.Conn, ds4 *DualShock4, if err := state.UnmarshalBinary(input); err != nil { return fmt.Errorf("unmarshal framed DualShock 4 input state: %w", err) } - ds4.UpdateInputState(&state) + if err := ds4.UpdateInputStateUntil(streamDone, &state); err != nil { + return fmt.Errorf("queue framed DualShock 4 input state: %w", err) + } case StreamFrameMicrophonePCM: ds4.QueueMicrophonePCMFrame(microphonePCM) } diff --git a/device/dualshock4/native_input_test.go b/device/dualshock4/native_input_test.go new file mode 100644 index 00000000..594964fe --- /dev/null +++ b/device/dualshock4/native_input_test.go @@ -0,0 +1,29 @@ +//go:build !race + +package dualshock4 + +import ( + "io" + "testing" +) + +func TestNativeInputEncodingUsesCallerBufferWithoutAllocating(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + state := NewInputState() + meta := &MetaState{BatteryStatus: DefaultBatteryStatus} + buffer := make([]byte, InputReportSize) + if allocations := testing.AllocsPerRun(1000, func() { + written, encodeErr := dev.buildUSBInputReportInto(state, meta, buffer) + if encodeErr != nil || written != InputReportSize { + panic("DualShock 4 native input encoding failed") + } + }); allocations != 0 { + t.Fatalf("native input allocations=%v want 0", allocations) + } + if _, err = dev.buildUSBInputReportInto(state, meta, buffer[:InputReportSize-1]); err != io.ErrShortBuffer { + t.Fatalf("short-buffer error=%v want %v", err, io.ErrShortBuffer) + } +} diff --git a/device/dualshock4/native_microphone_alloc_test.go b/device/dualshock4/native_microphone_alloc_test.go new file mode 100644 index 00000000..f2e0ea36 --- /dev/null +++ b/device/dualshock4/native_microphone_alloc_test.go @@ -0,0 +1,32 @@ +//go:build !race + +package dualshock4 + +import ( + "context" + "testing" +) + +func TestNativeMicrophonePacketEncodingDoesNotAllocate(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + frame := make([]byte, USBMicrophoneClientFrameSize) + for range microphoneMaximumClientFrames { + dev.QueueMicrophonePCMFrame(frame) + } + packet := make([]byte, USBMicrophoneMaxPacketSize) + ctx := context.Background() + allocations := testing.AllocsPerRun(100, func() { + if _, readErr := dev.ReadIsochronousInput( + ctx, uint32(EndpointMicrophoneIn), packet, + ); readErr != nil { + panic(readErr) + } + }) + if allocations != 0 { + t.Fatalf("native microphone packet encoding allocated %.2f objects", allocations) + } +} diff --git a/device/dualshock4/output_backpressure_test.go b/device/dualshock4/output_backpressure_test.go new file mode 100644 index 00000000..c99ce612 --- /dev/null +++ b/device/dualshock4/output_backpressure_test.go @@ -0,0 +1,423 @@ +package dualshock4 + +import ( + "context" + "io" + "log/slog" + "net" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" +) + +func TestDualShock4OrderedPublicationIsFIFOWithConcurrentProducers(t *testing.T) { + writer := newDualShock4OutputWriter(nil, StreamFrameVersionV3) + const producers = 24 + start := make(chan struct{}) + var wait sync.WaitGroup + wait.Add(producers) + for marker := 0; marker < producers; marker++ { + marker := byte(marker) + go func() { + defer wait.Done() + <-start + writer.EnqueueControl(StreamFrameOutputState, []byte{marker}) + }() + } + close(start) + wait.Wait() + + seen := make(map[byte]bool, producers) + for publication := uint64(1); publication <= producers; publication++ { + frame := <-writer.control + decrementDualShock4Uint64(&writer.telemetry.orderedQueueDepth) + require.Equal(t, publication, frame.publication) + require.Len(t, frame.payload, 1) + assert.False(t, seen[frame.payload[0]], "payload was duplicated") + seen[frame.payload[0]] = true + } + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(producers), state.OrderedReceived) + assert.Equal(t, uint64(producers), state.OrderedEnqueued) + assert.Zero(t, state.OrderedRejected) + assert.Zero(t, state.OrderedSaturations) +} + +func dualShock4PacketPayload(packetCount int, marker byte) []byte { + payload := make([]byte, packetCount*USBSpeakerMaxPacketSize) + for index := range payload { + payload[index] = marker + } + return payload +} + +func TestDualShock4MediaDurationUsesActualPCMFrames(t *testing.T) { + writer := newDualShock4OutputWriter(nil, StreamFrameVersionV3) + var expected time.Duration + for packets := 2; packets <= 4; packets++ { + payload := dualShock4PacketPayload(packets, byte(packets)) + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, payload) + frames := len(payload) / dualShock4SpeakerFrameBytes + expected += time.Duration(frames) * time.Second / USBSpeakerSampleRate + } + require.Equal(t, 9*time.Millisecond+281250*time.Nanosecond, expected) + assert.Equal(t, int64(expected), + writer.telemetry.mediaQueueDurationNS.Load()) + assert.Equal(t, expected.Microseconds(), + writer.telemetry.snapshot().MediaQueueDurationUS) + + for packets := 2; packets <= 4; packets++ { + frame := <-writer.audio + writer.recordMediaDequeued(frame) + require.Equal(t, byte(packets), frame.payload[0]) + } + assert.Zero(t, writer.telemetry.mediaQueueDurationNS.Load()) +} + +func TestDualShock4MediaWindowIsTwoHundredMillisecondsAndDropsOldest(t *testing.T) { + writer := newDualShock4OutputWriter(nil, StreamFrameVersionV3) + require.Equal(t, 20, cap(writer.audio)) + const payloadFrames = USBSpeakerSampleRate / 50 + payloadDuration := time.Duration(payloadFrames) * time.Second / + USBSpeakerSampleRate + frameCount := int(dualShock4SpeakerMaximumBufferTime / payloadDuration) + require.Equal(t, 10, frameCount) + for marker := 0; marker <= frameCount; marker++ { + payload := make([]byte, payloadFrames*dualShock4SpeakerFrameBytes) + for index := range payload { + payload[index] = byte(marker) + } + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, payload) + } + require.Len(t, writer.audio, frameCount) + for index := 0; index < frameCount; index++ { + frame := <-writer.audio + writer.recordMediaDequeued(frame) + require.Equal(t, byte(index+1), frame.payload[0]) + } + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(frameCount+1), + state.MediaReceivedPayloads) + assert.Equal(t, uint64(frameCount+1), + state.MediaEnqueuedPayloads) + assert.Equal(t, uint64(1), state.MediaOverruns) + assert.Equal(t, uint64(1), state.MediaDroppedPayloads) + assert.Equal(t, uint64(payloadFrames*dualShock4SpeakerFrameBytes), + state.MediaDroppedBytes) + assert.Equal(t, uint64(frameCount), + state.MediaQueueHighWater) + assert.LessOrEqual(t, state.MediaQueueDurationHighWaterUS, + dualShock4SpeakerMaximumBufferTime.Microseconds()) + assert.Zero(t, state.MediaQueueDepth) +} + +func TestDualShock4MediaItemBoundDropsOldestBeforeAllocationsGrow(t *testing.T) { + writer := newDualShock4OutputWriter(nil, StreamFrameVersionV3) + for marker := 0; marker <= dualShock4OutputAudioQueueCapacity; marker++ { + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, + dualShock4PacketPayload(2, byte(marker))) + } + require.Len(t, writer.audio, dualShock4OutputAudioQueueCapacity) + for index := 0; index < dualShock4OutputAudioQueueCapacity; index++ { + frame := <-writer.audio + writer.recordMediaDequeued(frame) + require.Equal(t, byte(index+1), frame.payload[0]) + } + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(1), state.MediaOverruns) + assert.Equal(t, uint64(1), state.MediaDroppedPayloads) + assert.Equal(t, uint64(dualShock4OutputAudioQueueCapacity), + state.MediaQueueHighWater) + assert.Less(t, state.MediaQueueDurationHighWaterUS, + dualShock4SpeakerMaximumBufferTime.Microseconds()) + assert.Zero(t, state.MediaQueueDepth) +} + +func TestDualShock4MediaRejectsMalformedAndSinglePayloadOverLimit(t *testing.T) { + writer := newDualShock4OutputWriter(nil, StreamFrameVersionV3) + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, []byte{1, 2, 3}) + tooLarge := make([]byte, + (dualShock4SpeakerMaximumBufferFrames+1)*dualShock4SpeakerFrameBytes) + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, tooLarge) + + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(2), state.MediaReceivedPayloads) + assert.Equal(t, uint64(1), state.MediaMalformedPayloads) + assert.Equal(t, uint64(3), state.MediaMalformedBytes) + assert.Equal(t, uint64(1), state.MediaOversizePayloads) + assert.Equal(t, uint64(len(tooLarge)), state.MediaOversizeBytes) + assert.Zero(t, state.MediaEnqueuedPayloads) + assert.Zero(t, state.MediaQueueDepth) + assert.Empty(t, writer.audio) +} + +func TestDualShock4ResetCountsStaleGenerationAndClearsCadence(t *testing.T) { + writer := newDualShock4OutputWriter(nil, StreamFrameVersionV3) + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, []byte{1, 2, 3, 4}) + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, []byte{5, 6, 7, 8}) + writer.ResetSpeaker() + + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(2), state.MediaStalePayloads) + assert.Equal(t, uint64(8), state.MediaStaleBytes) + assert.Zero(t, state.MediaQueueDepth) + assert.Empty(t, writer.audio) + assert.Zero(t, writer.telemetry.lastMediaEnqueueNS.Load()) +} + +func TestDualShock4WriterRecordsOnlyObservedProducerCadenceGap(t *testing.T) { + writer := newDualShock4OutputWriter(nil, StreamFrameVersionV3) + writer.telemetry.lastMediaEnqueueNS.Store( + time.Now().Add(-35 * time.Millisecond).UnixNano()) + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, []byte{1, 2, 3, 4}) + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(1), state.MediaLateGaps) + assert.GreaterOrEqual(t, state.MediaUnderruns, uint64(2)) +} + +func TestDualShock4OutputBackpressureTelemetryIsExposed(t *testing.T) { + controller, err := New(nil) + require.NoError(t, err) + writer := newDualShock4OutputWriterForStream(nil, StreamFrameVersionV3, + controller.beginSpeakerStream(), nil) + writer.EnqueueControl(StreamFrameOutputState, []byte{1}) + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, []byte{2, 3, 4, 5}) + state := controller.GetDeviceSpecificArgs() + assert.Equal(t, uint64(1), state["speakerOrderedFramesEnqueued"]) + assert.Equal(t, uint64(1), state["speakerPayloadsEnqueued"]) + assert.Equal(t, int64(31), state["speakerQueueDurationUS"]) + assert.Equal(t, int64(31), + state["speakerQueueDurationHighWaterUS"]) +} + +func TestDualShock4OrderedFaultWakesOwningReadLoop(t *testing.T) { + server, client := net.Pipe() + writer := newDualShock4OutputWriter(server, StreamFrameVersionV3) + readDone := make(chan error, 1) + go func() { + buffer := make([]byte, 1) + _, err := server.Read(buffer) + readDone <- err + }() + for marker := 0; marker <= dualShock4OutputControlQueueCapacity; marker++ { + writer.EnqueueControl(StreamFrameOutputState, []byte{byte(marker)}) + } + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, []byte{1, 2, 3, 4}) + select { + case err := <-readDone: + assert.Error(t, err) + case <-time.After(time.Second): + t.Fatal("ordered saturation did not wake the owning read loop") + } + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(1), state.MediaReceivedPayloads) + assert.Equal(t, uint64(1), state.MediaRejectedPayloads) + assert.Equal(t, uint64(4), state.MediaRejectedBytes) + assert.Zero(t, state.MediaEnqueuedPayloads) + require.NoError(t, client.Close()) +} + +func TestDualShock4LifecycleDrainAccountsEveryAcceptedQueuedFrame(t *testing.T) { + writer := newDualShock4OutputWriter(nil, StreamFrameVersionV3) + writer.EnqueueControl(StreamFrameOutputState, []byte{1}) + writer.EnqueueControl(StreamFrameOutputState, []byte{2, 3}) + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, []byte{1, 2, 3, 4}) + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, []byte{5, 6, 7, 8}) + writer.requestStop() + go writer.Run() + require.NoError(t, writer.Stop()) + + select { + case <-writer.done: + default: + t.Fatal("Stop returned before writer rundown completed") + } + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(2), state.OrderedLifecycleDiscardedFrames) + assert.Equal(t, uint64(3), state.OrderedLifecycleDiscardedBytes) + assert.Equal(t, uint64(2), state.MediaLifecycleDiscardedPayloads) + assert.Equal(t, uint64(8), state.MediaLifecycleDiscardedBytes) + assert.Zero(t, state.OrderedQueueDepth) + assert.Zero(t, state.MediaQueueDepth) + assert.Zero(t, state.MediaQueueDurationUS) +} + +func TestDualShock4StopLatchesTimeoutAndContinuesAuthoritativeJoin(t *testing.T) { + server, client := net.Pipe() + gate := &dualShock4WriteGateConn{ + Conn: server, started: make(chan struct{}), release: make(chan struct{}), + } + writer := newDualShock4OutputWriter(gate, StreamFrameVersionV3) + writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{1, 2, 3, 4}) + go writer.Run() + select { + case <-gate.started: + case <-time.After(time.Second): + t.Fatal("media write did not become in-flight") + } + + err := writer.Stop() + require.ErrorIs(t, err, errDualShock4OutputJoinTimeout) + select { + case <-writer.done: + t.Fatal("timeout was treated as completed rundown") + default: + } + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(1), state.TeardownFailures) + assert.True(t, state.TeardownPending) + + close(gate.release) + select { + case <-writer.done: + case <-time.After(time.Second): + t.Fatal("writer did not finish after in-flight write was released") + } + assert.Eventually(t, func() bool { + return !writer.telemetry.snapshot().TeardownPending + }, time.Second, time.Millisecond) + require.ErrorIs(t, writer.Stop(), errDualShock4OutputJoinTimeout) + require.NoError(t, client.Close()) +} + +type dualShock4UninterruptibleStreamConn struct { + readRelease chan struct{} + writeStarted chan struct{} + writeRelease chan struct{} + writeOnce sync.Once +} + +func newDualShock4UninterruptibleStreamConn() *dualShock4UninterruptibleStreamConn { + return &dualShock4UninterruptibleStreamConn{ + readRelease: make(chan struct{}), + writeStarted: make(chan struct{}), + writeRelease: make(chan struct{}), + } +} + +func (c *dualShock4UninterruptibleStreamConn) Read([]byte) (int, error) { + <-c.readRelease + return 0, io.EOF +} + +func (c *dualShock4UninterruptibleStreamConn) Write(payload []byte) (int, error) { + c.writeOnce.Do(func() { close(c.writeStarted) }) + <-c.writeRelease + return len(payload), nil +} + +func (*dualShock4UninterruptibleStreamConn) Close() error { return nil } + +func (*dualShock4UninterruptibleStreamConn) LocalAddr() net.Addr { + return &net.TCPAddr{} +} + +func (*dualShock4UninterruptibleStreamConn) RemoteAddr() net.Addr { + return &net.TCPAddr{} +} + +func (*dualShock4UninterruptibleStreamConn) SetDeadline(time.Time) error { + return nil +} + +func (*dualShock4UninterruptibleStreamConn) SetReadDeadline(time.Time) error { + return nil +} + +func (*dualShock4UninterruptibleStreamConn) SetWriteDeadline(time.Time) error { + return nil +} + +func TestDualShock4HandlerDetachesBeforeAuthoritativeStop(t *testing.T) { + controller, err := New(nil) + require.NoError(t, err) + var device usb.Device = controller + conn := newDualShock4UninterruptibleStreamConn() + streamHandler := (&handler{ + speakerOutput: true, streamFrameVersion: StreamFrameVersionV3, + }).StreamHandler() + errCh := make(chan error, 1) + go func() { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + errCh <- streamHandler(conn, &device, logger) + }() + require.Eventually(t, func() bool { + controller.mtx.Lock() + defer controller.mtx.Unlock() + return controller.speakerFunc != nil && controller.speakerResetFunc != nil + }, time.Second, time.Millisecond) + + controller.SetInterfaceAltSetting(InterfaceSpeaker, 1) + controller.HandleTransfer(context.Background(), uint32(EndpointAudioOut), + usbip.DirOut, dualShock4PacketPayload(2, 0x5A)) + select { + case <-conn.writeStarted: + case <-time.After(time.Second): + t.Fatal("handler writer did not enter the uninterruptible write") + } + close(conn.readRelease) + + select { + case err := <-errCh: + require.ErrorIs(t, err, errDualShock4OutputJoinTimeout) + case <-time.After(time.Second): + t.Fatal("handler cleanup blocked in reset before Stop could report failure") + } + controller.mtx.Lock() + callbacksDetached := controller.outputFunc == nil && + controller.speakerFunc == nil && controller.speakerResetFunc == nil + controller.mtx.Unlock() + assert.True(t, callbacksDetached) + state := controller.GetDeviceSpecificArgs() + assert.Equal(t, uint64(1), state["speakerTeardownFailures"]) + assert.Equal(t, true, state["speakerTeardownPending"]) + + close(conn.writeRelease) + require.Eventually(t, func() bool { + state := controller.GetDeviceSpecificArgs() + return state["speakerTeardownPending"] == false && + state["speakerStreamActive"] == false + }, time.Second, time.Millisecond) +} + +func TestDualShock4ResetCloseAndInFlightWriteCannotDeadlock(t *testing.T) { + server, client := net.Pipe() + conn := &dualShock4DeadlineBlockConn{ + Conn: server, started: make(chan struct{}), unblock: make(chan struct{}), + } + writer := newDualShock4OutputWriter(conn, StreamFrameVersionV3) + writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{1, 2, 3, 4}) + go writer.Run() + select { + case <-conn.started: + case <-time.After(time.Second): + t.Fatal("media write did not become in-flight") + } + resetDone := make(chan struct{}) + stopDone := make(chan error, 1) + go func() { writer.ResetSpeaker(); close(resetDone) }() + go func() { stopDone <- writer.Stop() }() + select { + case <-resetDone: + case <-time.After(time.Second): + t.Fatal("reset deadlocked with in-flight write") + } + select { + case err := <-stopDone: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("stop deadlocked with in-flight write") + } + select { + case <-writer.done: + default: + t.Fatal("Stop returned before writer rundown completed") + } + _ = client.Close() +} diff --git a/device/dualshock4/scheduled_input_test.go b/device/dualshock4/scheduled_input_test.go new file mode 100644 index 00000000..b1bfaa8b --- /dev/null +++ b/device/dualshock4/scheduled_input_test.go @@ -0,0 +1,101 @@ +package dualshock4 + +import ( + "context" + "encoding/binary" + "testing" + "time" +) + +func TestScheduledInterruptInputPreservesDualShock4StateAndCadence(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + state := NewInputState() + state.LX, state.L2, state.Buttons = -31, 166, ButtonCross + dev.UpdateInputState(state) + buffer := make([]byte, InputReportSize) + never := make(chan time.Time) + written, err := dev.ReadScheduledInterruptInput(context.Background(), never, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize { + t.Fatalf("event read=(%d, %v)", written, err) + } + if buffer[1] != uint8(int16(state.LX)+128) || buffer[8] != state.L2 || buffer[7]>>CounterShift != 1 { + t.Fatalf("event state/counter=%x", buffer[:12]) + } + firstTimestamp := binary.LittleEndian.Uint16(buffer[10:12]) + + deadline := make(chan time.Time, 1) + deadline <- time.Now() + written, err = dev.ReadScheduledInterruptInput(context.Background(), deadline, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize { + t.Fatalf("deadline read=(%d, %v)", written, err) + } + if buffer[1] != uint8(int16(state.LX)+128) || buffer[8] != state.L2 || buffer[7]>>CounterShift != 2 { + t.Fatalf("deadline state/counter=%x", buffer[:12]) + } + secondTimestamp := binary.LittleEndian.Uint16(buffer[10:12]) + if secondTimestamp < firstTimestamp { + t.Fatalf("deadline timestamp=%d before event=%d", secondTimestamp, firstTimestamp) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + state.LX = 45 + dev.UpdateInputState(state) + readyDeadline := make(chan time.Time, 1) + readyDeadline <- time.Now() + if _, err = dev.ReadScheduledInterruptInput(ctx, readyDeadline, EndpointIn&0x0f, buffer); err != context.Canceled { + t.Fatalf("lifecycle cancellation=%v want %v", err, context.Canceled) + } + written, err = dev.ReadScheduledInterruptInput(context.Background(), never, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize { + t.Fatalf("post-cancel event read=(%d, %v)", written, err) + } + if buffer[1] != uint8(int16(state.LX)+128) || buffer[7]>>CounterShift != 3 { + t.Fatalf("post-cancel state/counter=%x", buffer[:12]) + } + if thirdTimestamp := binary.LittleEndian.Uint16(buffer[10:12]); thirdTimestamp < secondTimestamp { + t.Fatalf("post-cancel timestamp=%d before previous=%d", thirdTimestamp, secondTimestamp) + } +} + +func TestClassifiedNativeInputPreservesQueuedDualShock4Transitions(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + press := NewInputState() + press.LX, press.Buttons = -51, ButtonCross + release := NewInputState() + release.LX = 67 + dev.UpdateInputState(press) + dev.UpdateInputState(release) + + buffer := make([]byte, InputReportSize) + never := make(chan time.Time) + written, transition, err := dev.ReadClassifiedScheduledInterruptInput( + context.Background(), never, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize || !transition || + buffer[1] != uint8(int16(press.LX)+128) { + t.Fatalf("press read=(%d, %t, %v) state=%x", written, transition, err, buffer[:12]) + } + written, transition, err = dev.ReadClassifiedScheduledInterruptInput( + context.Background(), never, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize || !transition || + buffer[1] != uint8(int16(release.LX)+128) { + t.Fatalf("release read=(%d, %t, %v) state=%x", written, transition, err, buffer[:12]) + } + analog := *release + analog.LX = 19 + if err = dev.UpdateInputState(&analog); err != nil { + t.Fatal(err) + } + written, transition, err = dev.ReadClassifiedScheduledInterruptInput( + context.Background(), never, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize || transition || + buffer[1] != uint8(int16(analog.LX)+128) { + t.Fatalf("analog read=(%d, %t, %v) state=%x", written, transition, err, buffer[:12]) + } +} diff --git a/device/internal/inputstatequeue/queue.go b/device/internal/inputstatequeue/queue.go new file mode 100644 index 00000000..1bdbf4ee --- /dev/null +++ b/device/internal/inputstatequeue/queue.go @@ -0,0 +1,221 @@ +package inputstatequeue + +import ( + "context" + "errors" + "math" + "sync" + "time" +) + +var ( + ErrRevisionExhausted = errors.New("input state revision is exhausted") + ErrGenerationChanged = errors.New("input transition generation changed") + ErrBackpressureTimeout = errors.New("input transition backpressure timed out") +) + +const defaultBackpressureTimeout = 5 * time.Second + +type entry[T any] struct { + state T + revision uint64 +} + +// Queue retains every discrete controller transition in a fixed ring while +// coalescing analog/motion-only updates into one latest-state snapshot. Signal +// is edge-triggered; revision bookkeeping re-arms it until all retained work +// has been observed. +type Queue[T any] struct { + mu sync.Mutex + + transitions []entry[T] + signal chan struct{} + space chan struct{} + head int + count int + + latest T + latestRevision uint64 + deliveredRevision uint64 + edgeSignature uint64 + generation uint64 + backpressureTimeout time.Duration +} + +func New[T any](initial T, edgeSignature uint64, capacity int) *Queue[T] { + if capacity <= 0 { + panic("input transition queue capacity must be positive") + } + return &Queue[T]{ + transitions: make([]entry[T], capacity), + signal: make(chan struct{}, 1), + space: make(chan struct{}, 1), + latest: initial, + latestRevision: 1, + deliveredRevision: 1, + edgeSignature: edgeSignature, + backpressureTimeout: defaultBackpressureTimeout, + } +} + +// Publish accepts one source-ordered state. A changed edge signature is +// retained exactly; an unchanged signature updates only the latest snapshot. +// Capacity pressure is propagated to the producer before any state is +// accepted, so no half-committed controller state can be published. +func (q *Queue[T]) Publish(state T, edgeSignature uint64) error { + return q.PublishUntil(nil, state, edgeSignature) +} + +// PublishUntil applies bounded backpressure for a discrete transition. Closing +// done cancels a publication which has not yet been accepted; nil waits until +// lifecycle invalidation or the consumer frees a slot. +func (q *Queue[T]) PublishUntil( + done <-chan struct{}, state T, edgeSignature uint64, +) error { + var generation uint64 + generationKnown := false + var backpressureTimer *time.Timer + defer func() { + if backpressureTimer != nil { + backpressureTimer.Stop() + } + }() + for { + if done != nil { + select { + case <-done: + return context.Canceled + default: + } + } + q.mu.Lock() + if !generationKnown { + generation = q.generation + generationKnown = true + } else if generation != q.generation { + q.mu.Unlock() + return ErrGenerationChanged + } + transition := edgeSignature != q.edgeSignature + if !transition || q.count < len(q.transitions) { + if q.latestRevision == math.MaxUint64 { + q.mu.Unlock() + return ErrRevisionExhausted + } + q.latestRevision++ + q.latest = state + q.edgeSignature = edgeSignature + if transition { + index := (q.head + q.count) % len(q.transitions) + q.transitions[index] = entry[T]{state: state, revision: q.latestRevision} + q.count++ + } + q.notify() + q.mu.Unlock() + return nil + } + q.mu.Unlock() + + if backpressureTimer == nil { + backpressureTimer = time.NewTimer(q.backpressureTimeout) + } + select { + case <-done: + return context.Canceled + case <-q.space: + case <-backpressureTimer.C: + return ErrBackpressureTimeout + } + } +} + +// Wait returns the oldest retained transition, otherwise the latest snapshot. +// A nil deadline preserves the legacy context-deadline behavior used by the +// USB/IP poller; native callers pass a reusable endpoint timer channel. +func (q *Queue[T]) Wait( + ctx context.Context, deadline <-chan time.Time, +) (state T, transition bool, err error) { + select { + case <-ctx.Done(): + if deadline != nil || !errors.Is(ctx.Err(), context.DeadlineExceeded) { + return state, false, ctx.Err() + } + return q.take(true) + case <-deadline: + if err := ctx.Err(); err != nil { + return state, false, err + } + return q.take(true) + case <-q.signal: + if err := ctx.Err(); err != nil { + if deadline == nil && errors.Is(err, context.DeadlineExceeded) { + return q.take(false) + } + return state, false, err + } + return q.take(false) + } +} + +// Invalidate establishes a lifecycle generation boundary. Retained pre-reset +// transitions are discarded, while the current snapshot remains available to +// the first post-boundary host poll. +func (q *Queue[T]) Invalidate() { + q.mu.Lock() + q.head = 0 + q.count = 0 + q.generation++ + q.deliveredRevision = q.latestRevision + select { + case <-q.signal: + default: + } + q.notifySpace() + q.mu.Unlock() +} + +func (q *Queue[T]) take(drainSignal bool) (state T, transition bool, err error) { + q.mu.Lock() + if drainSignal { + select { + case <-q.signal: + default: + } + } + if q.count > 0 { + item := q.transitions[q.head] + var zero entry[T] + q.transitions[q.head] = zero + q.head = (q.head + 1) % len(q.transitions) + q.count-- + q.deliveredRevision = item.revision + state = item.state + transition = true + } else { + state = q.latest + q.deliveredRevision = q.latestRevision + } + pending := q.count > 0 || q.latestRevision > q.deliveredRevision + if transition { + q.notifySpace() + } + if pending { + q.notify() + } + q.mu.Unlock() + return state, transition, nil +} + +func (q *Queue[T]) notify() { + select { + case q.signal <- struct{}{}: + default: + } +} + +func (q *Queue[T]) notifySpace() { + select { + case q.space <- struct{}{}: + default: + } +} diff --git a/device/internal/inputstatequeue/queue_test.go b/device/internal/inputstatequeue/queue_test.go new file mode 100644 index 00000000..153d0f95 --- /dev/null +++ b/device/internal/inputstatequeue/queue_test.go @@ -0,0 +1,125 @@ +package inputstatequeue + +import ( + "context" + "errors" + "testing" + "time" +) + +type testState struct { + edge uint64 + analog int +} + +func TestQueuePreservesEdgesAndCoalescesLatestSnapshot(t *testing.T) { + q := New(testState{}, 0, 4) + for _, state := range []testState{ + {edge: 1, analog: 10}, + {edge: 1, analog: 20}, + {edge: 0, analog: 30}, + } { + if err := q.Publish(state, state.edge); err != nil { + t.Fatal(err) + } + } + + state, transition, err := q.take(false) + if err != nil || !transition || state.edge != 1 || state.analog != 10 { + t.Fatalf("press=(%+v,%t,%v)", state, transition, err) + } + state, transition, err = q.take(false) + if err != nil || !transition || state.edge != 0 || state.analog != 30 { + t.Fatalf("release=(%+v,%t,%v)", state, transition, err) + } + state, transition, err = q.take(false) + if err != nil || transition || state.edge != 0 || state.analog != 30 { + t.Fatalf("latest=(%+v,%t,%v)", state, transition, err) + } +} + +func TestQueueDeadlineConsumptionDrainsStaleWakeToken(t *testing.T) { + q := New(testState{}, 0, 2) + if err := q.Publish(testState{analog: 7}, 0); err != nil { + t.Fatal(err) + } + state, transition, err := q.take(true) + if err != nil || transition || state.analog != 7 { + t.Fatalf("deadline take=(%+v,%t,%v)", state, transition, err) + } + select { + case <-q.signal: + t.Fatal("deadline consumption left a stale immediate wake token") + default: + } +} + +func TestQueueInvalidationCancelsBlockedGeneration(t *testing.T) { + q := New(testState{}, 0, 1) + if err := q.Publish(testState{edge: 1}, 1); err != nil { + t.Fatal(err) + } + result := make(chan error, 1) + go func() { + result <- q.PublishUntil(nil, testState{edge: 2}, 2) + }() + + select { + case err := <-result: + t.Fatalf("blocked publication returned early: %v", err) + case <-time.After(10 * time.Millisecond): + } + q.Invalidate() + select { + case err := <-result: + if !errors.Is(err, ErrGenerationChanged) { + t.Fatalf("generation result=%v", err) + } + case <-time.After(time.Second): + t.Fatal("generation invalidation did not release producer") + } + + deadline := make(chan time.Time, 1) + deadline <- time.Now() + state, transition, err := q.Wait(context.Background(), deadline) + if err != nil || transition || state.edge != 1 { + t.Fatalf("post-boundary latest=(%+v,%t,%v)", state, transition, err) + } +} + +func TestQueueCloseCancelsBlockedProducer(t *testing.T) { + q := New(testState{}, 0, 1) + if err := q.Publish(testState{edge: 1}, 1); err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + result := make(chan error, 1) + go func() { + result <- q.PublishUntil(done, testState{edge: 2}, 2) + }() + close(done) + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("close result=%v", err) + } + case <-time.After(time.Second): + t.Fatal("stream close did not release producer") + } +} + +func TestQueueBoundsBackpressureWhenPeerCloseIsUnobservable(t *testing.T) { + q := New(testState{}, 0, 1) + q.backpressureTimeout = 10 * time.Millisecond + if err := q.Publish(testState{edge: 1}, 1); err != nil { + t.Fatal(err) + } + started := time.Now() + err := q.PublishUntil(make(chan struct{}), testState{edge: 2}, 2) + if !errors.Is(err, ErrBackpressureTimeout) { + t.Fatalf("backpressure result=%v", err) + } + if time.Since(started) > time.Second { + t.Fatal("bounded backpressure did not release the stream handler") + } +} diff --git a/device/internal/microphonebuffer/buffer.go b/device/internal/microphonebuffer/buffer.go index 1c42f9ef..08dad4a5 100644 --- a/device/internal/microphonebuffer/buffer.go +++ b/device/internal/microphonebuffer/buffer.go @@ -156,9 +156,13 @@ func (b *Buffer) QueueFrame(frame []byte) bool { // byte length. Packets contain exactly one fewer, the nominal number, or one // additional interleaved PCM sample-frame. USB Audio accepts these variable // isochronous packet lengths to reconcile the source and host clocks without -// resampling or dropping waveform samples. dst is never modified on failure. +// resampling or dropping waveform samples. A host is also allowed to reserve +// only the nominal packet capacity in an individual URB. In that case a long +// correction remains owed instead of consuming and truncating a PCM frame. +// dst is never modified on failure. func (b *Buffer) ReadPacket(dst []byte) (int, bool) { - if len(dst) < b.packetSize+b.pcmFrameSize { + shortSize := b.packetSize - b.pcmFrameSize + if len(dst) < shortSize { return 0, false } if !b.primed { @@ -166,15 +170,25 @@ func (b *Buffer) ReadPacket(dst []byte) (int, bool) { } actualSize := b.nextPacketSize() + if actualSize > len(dst) { + // The long correction cannot fit in this URB's packet region. Present + // the largest legal size it can hold and leave the positive servo debt + // untouched so a later max-packet reservation can service it. This is + // materially different from reading a long packet and truncating it. + actualSize = min(b.packetSize, len(dst)) + actualSize -= actualSize % b.pcmFrameSize + if actualSize < shortSize { + return 0, false + } + } if b.size < actualSize { // USB Audio accepts the nominal packet and one fewer PCM sample-frame. // Use the largest legal packet still available instead of turning a // single clock-phase deficit into a capture gap. Packet accounting is // committed only afterward so servo telemetry describes what reached the // host and any unserved long-packet correction remains owed. - shortSize := b.packetSize - b.pcmFrameSize if b.size >= b.packetSize { - actualSize = b.packetSize + actualSize = min(b.packetSize, len(dst)) } else if b.size >= shortSize { actualSize = shortSize } else { diff --git a/device/internal/microphonebuffer/buffer_test.go b/device/internal/microphonebuffer/buffer_test.go index 1d9262c4..37b928cf 100644 --- a/device/internal/microphonebuffer/buffer_test.go +++ b/device/internal/microphonebuffer/buffer_test.go @@ -132,6 +132,40 @@ func TestBufferFallsBackFromLongToNominalAndKeepsServoDebt(t *testing.T) { } } +func TestBufferHonorsNominalHostPacketCapacityWithoutDroppingPCM(t *testing.T) { + buffer := New(8, 2, 16, 3, 4) + for value := byte(1); value <= 3; value++ { + buffer.QueueFrame(bytes.Repeat([]byte{value}, 16)) + } + buffer.servoAccumulator = servoPulseScale + + nominal := make([]byte, 8) + actual, ok := buffer.ReadPacket(nominal) + if !ok || actual != len(nominal) { + t.Fatalf("nominal-capacity URB read len=%d ok=%t", actual, ok) + } + if !bytes.Equal(nominal, bytes.Repeat([]byte{1}, len(nominal))) { + t.Fatalf("nominal-capacity URB changed PCM: % x", nominal) + } + if state := buffer.State(); state.QueuedBytes != 40 || state.LongPackets != 0 { + t.Fatalf("nominal-capacity URB consumed a hidden long sample: %+v", state) + } + if buffer.servoAccumulator < servoPulseScale { + t.Fatalf("nominal-capacity URB discarded correction debt: %d", + buffer.servoAccumulator) + } + + maximum := make([]byte, 10) + actual, ok = buffer.ReadPacket(maximum) + if !ok || actual != len(maximum) { + t.Fatalf("later max-capacity URB did not service correction: len=%d ok=%t", + actual, ok) + } + if state := buffer.State(); state.QueuedBytes != 30 || state.LongPackets != 1 { + t.Fatalf("max-capacity URB did not account for one long packet: %+v", state) + } +} + func TestBufferTrueUnderrunRetainsAlignedTail(t *testing.T) { buffer := New(8, 2, 16, 3, 4) residual := []byte{0xA1, 0xA2, 0xA3, 0xA4} diff --git a/device/keyboard/device.go b/device/keyboard/device.go index 291d546b..741fa183 100644 --- a/device/keyboard/device.go +++ b/device/keyboard/device.go @@ -3,12 +3,13 @@ package keyboard import ( "context" + "fmt" "sync" + "time" "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/usb" "github.com/Alia5/VIIPER/usb/hid" - "github.com/Alia5/VIIPER/usbip" ) // Keyboard implements the Device interface for a full HID keyboard with LED support. @@ -85,7 +86,7 @@ func (k *Keyboard) UpdateInputState(state InputState) { // HandleTransfer implements interrupt IN/OUT for Keyboard. func (k *Keyboard) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { - if dir == usbip.DirIn { + if dir == usb.DirectionIn { switch ep { case 1: // 0x81 - keyboard input reports select { @@ -98,7 +99,7 @@ func (k *Keyboard) HandleTransfer(ctx context.Context, ep uint32, dir uint32, ou return nil } } - if dir == usbip.DirOut && ep == 1 { + if dir == usb.DirectionOut && ep == 1 { // 0x01 - LED state from host if len(out) >= 1 { ledState := ledStateFromMask(out[0]) @@ -117,6 +118,46 @@ func (k *Keyboard) HandleTransfer(ctx context.Context, ep uint32, dir uint32, ou return nil } +// ReadInterruptInput implements usb.InterruptInputDevice for native UDE. +func (k *Keyboard) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + return k.readInterruptInput(ctx, nil, ep, dst) +} + +func (k *Keyboard) ReadScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { + return k.readInterruptInput(ctx, deadline, ep, dst) +} + +func (k *Keyboard) readInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { + if ep != 1 { + return 0, fmt.Errorf("keyboard interrupt-IN endpoint %d is unsupported", ep) + } + if deadline != nil && ctx.Err() != nil { + return 0, ctx.Err() + } + select { + case <-ctx.Done(): + return 0, ctx.Err() + case <-deadline: + if ctx.Err() != nil { + return 0, ctx.Err() + } + return 0, context.DeadlineExceeded + case st := <-k.inputCh: + if deadline != nil && ctx.Err() != nil { + select { + case k.inputCh <- st: + default: + } + return 0, ctx.Err() + } + return st.BuildReportInto(dst) + } +} + func ledStateFromMask(mask uint8) LEDState { return LEDState{ NumLock: mask&LEDNumLock != 0, diff --git a/device/keyboard/inputstate.go b/device/keyboard/inputstate.go index aa6d118e..b942ed5d 100644 --- a/device/keyboard/inputstate.go +++ b/device/keyboard/inputstate.go @@ -49,10 +49,21 @@ func (ls *LEDState) UnmarshalBinary(data []byte) error { // Bytes 2-33: Key bitmap (256 bits, 32 bytes) func (kb *InputState) BuildReport() []byte { b := make([]byte, 34) + _, _ = kb.BuildReportInto(b) + return b +} + +// BuildReportInto encodes the HID report into caller-owned storage. +func (kb *InputState) BuildReportInto(dst []byte) (int, error) { + if len(dst) < 34 { + return 0, io.ErrShortBuffer + } + b := dst[:34] + clear(b) b[0] = kb.Modifiers b[1] = 0x00 // Reserved copy(b[2:34], kb.KeyBitmap[:]) - return b + return 34, nil } // MarshalBinary encodes InputState to variable-length wire format. diff --git a/device/keyboard/native_input_test.go b/device/keyboard/native_input_test.go new file mode 100644 index 00000000..ae124d79 --- /dev/null +++ b/device/keyboard/native_input_test.go @@ -0,0 +1,24 @@ +//go:build !race + +package keyboard + +import ( + "io" + "testing" +) + +func TestNativeInputEncodingUsesCallerBufferWithoutAllocating(t *testing.T) { + state := NewInputState() + buffer := make([]byte, 34) + if allocations := testing.AllocsPerRun(1000, func() { + written, err := state.BuildReportInto(buffer) + if err != nil || written != 34 { + panic("keyboard native input encoding failed") + } + }); allocations != 0 { + t.Fatalf("native input allocations=%v want 0", allocations) + } + if _, err := state.BuildReportInto(buffer[:33]); err != io.ErrShortBuffer { + t.Fatalf("short-buffer error=%v want %v", err, io.ErrShortBuffer) + } +} diff --git a/device/keyboard/scheduled_input_test.go b/device/keyboard/scheduled_input_test.go new file mode 100644 index 00000000..63648b8d --- /dev/null +++ b/device/keyboard/scheduled_input_test.go @@ -0,0 +1,47 @@ +package keyboard + +import ( + "context" + "testing" + "time" +) + +func TestScheduledInterruptInputPreservesKeyboardEventContract(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + state := *NewInputState() + state.Modifiers = 0x5a + state.KeyBitmap[3] = 0x80 + dev.UpdateInputState(state) + buffer := make([]byte, 34) + never := make(chan time.Time) + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), never, 1, buffer); readErr != nil || written != 34 { + t.Fatalf("event read=(%d, %v)", written, readErr) + } + if buffer[0] != state.Modifiers || buffer[5] != state.KeyBitmap[3] { + t.Fatalf("event state=%x", buffer) + } + deadline := make(chan time.Time, 1) + deadline <- time.Now() + if _, err = dev.ReadScheduledInterruptInput(context.Background(), deadline, 1, buffer); err != context.DeadlineExceeded { + t.Fatalf("idle deadline=%v want %v", err, context.DeadlineExceeded) + } + + state.Modifiers = 0xa5 + dev.UpdateInputState(state) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + readyDeadline := make(chan time.Time, 1) + readyDeadline <- time.Now() + if _, err = dev.ReadScheduledInterruptInput(ctx, readyDeadline, 1, buffer); err != context.Canceled { + t.Fatalf("lifecycle cancellation=%v want %v", err, context.Canceled) + } + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), never, 1, buffer); readErr != nil || written != 34 { + t.Fatalf("post-cancel event read=(%d, %v)", written, readErr) + } + if buffer[0] != state.Modifiers { + t.Fatalf("post-cancel state=%x", buffer) + } +} diff --git a/device/mouse/device.go b/device/mouse/device.go index 268f2aba..9439748d 100644 --- a/device/mouse/device.go +++ b/device/mouse/device.go @@ -3,12 +3,13 @@ package mouse import ( "context" + "fmt" "sync" + "time" "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/usb" "github.com/Alia5/VIIPER/usb/hid" - "github.com/Alia5/VIIPER/usbip" ) // Mouse implements the minimal Device interface for a 5-button HID mouse @@ -49,7 +50,7 @@ func (m *Mouse) UpdateInputState(state InputState) { } func (m *Mouse) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { - if dir == usbip.DirIn { + if dir == usb.DirectionIn { switch ep { case 1: // 0x81 - main input reports select { @@ -72,6 +73,53 @@ func (m *Mouse) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out [ return nil } +// ReadInterruptInput implements usb.InterruptInputDevice for native UDE. +func (m *Mouse) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + return m.readInterruptInput(ctx, nil, ep, dst) +} + +func (m *Mouse) ReadScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { + return m.readInterruptInput(ctx, deadline, ep, dst) +} + +func (m *Mouse) readInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { + if ep != 1 { + return 0, fmt.Errorf("mouse interrupt-IN endpoint %d is unsupported", ep) + } + if deadline != nil && ctx.Err() != nil { + return 0, ctx.Err() + } + select { + case <-ctx.Done(): + return 0, ctx.Err() + case <-deadline: + if ctx.Err() != nil { + return 0, ctx.Err() + } + return 0, context.DeadlineExceeded + case st := <-m.inputCh: + if deadline != nil && ctx.Err() != nil { + select { + case m.inputCh <- st: + default: + } + return 0, ctx.Err() + } + if st.DX != 0 || st.DY != 0 || st.Wheel != 0 || st.Pan != 0 { + zeroed := InputState{Buttons: st.Buttons} + select { + case m.inputCh <- zeroed: + default: + } + } + return st.BuildReportInto(dst) + } +} + // HID Report Descriptor for a 5-button mouse with vertical and horizontal wheels. // Boot protocol compatible. var reportDescriptor = hid.ReportDescriptor{ diff --git a/device/mouse/inputstate.go b/device/mouse/inputstate.go index 85c0c7ee..fa091831 100644 --- a/device/mouse/inputstate.go +++ b/device/mouse/inputstate.go @@ -31,6 +31,17 @@ func NewInputState() *InputState { return &InputState{} } // Bytes 7-8: Pan (int16 little-endian) func (m *InputState) BuildReport() []byte { b := make([]byte, 9) + _, _ = m.BuildReportInto(b) + return b +} + +// BuildReportInto encodes the HID report into caller-owned storage. +func (m *InputState) BuildReportInto(dst []byte) (int, error) { + if len(dst) < 9 { + return 0, io.ErrShortBuffer + } + b := dst[:9] + clear(b) b[0] = m.Buttons & 0x1F // 5 buttons, mask upper bits b[1] = byte(m.DX) b[2] = byte(m.DX >> 8) @@ -40,7 +51,7 @@ func (m *InputState) BuildReport() []byte { b[6] = byte(m.Wheel >> 8) b[7] = byte(m.Pan) b[8] = byte(m.Pan >> 8) - return b + return 9, nil } // MarshalBinary encodes InputState to 9 bytes. diff --git a/device/mouse/native_input_test.go b/device/mouse/native_input_test.go new file mode 100644 index 00000000..68f86d01 --- /dev/null +++ b/device/mouse/native_input_test.go @@ -0,0 +1,24 @@ +//go:build !race + +package mouse + +import ( + "io" + "testing" +) + +func TestNativeInputEncodingUsesCallerBufferWithoutAllocating(t *testing.T) { + state := NewInputState() + buffer := make([]byte, 9) + if allocations := testing.AllocsPerRun(1000, func() { + written, err := state.BuildReportInto(buffer) + if err != nil || written != 9 { + panic("mouse native input encoding failed") + } + }); allocations != 0 { + t.Fatalf("native input allocations=%v want 0", allocations) + } + if _, err := state.BuildReportInto(buffer[:8]); err != io.ErrShortBuffer { + t.Fatalf("short-buffer error=%v want %v", err, io.ErrShortBuffer) + } +} diff --git a/device/mouse/scheduled_input_test.go b/device/mouse/scheduled_input_test.go new file mode 100644 index 00000000..dde2f64a --- /dev/null +++ b/device/mouse/scheduled_input_test.go @@ -0,0 +1,55 @@ +package mouse + +import ( + "context" + "testing" + "time" +) + +func TestScheduledInterruptInputPreservesMouseEventAndZeroingContract(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + state := *NewInputState() + state.Buttons, state.DX, state.DY = 3, 120, -45 + dev.UpdateInputState(state) + buffer := make([]byte, 9) + never := make(chan time.Time) + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), never, 1, buffer); readErr != nil || written != 9 { + t.Fatalf("event read=(%d, %v)", written, readErr) + } + if buffer[0] != state.Buttons || buffer[1] != byte(state.DX) { + t.Fatalf("event state=%x", buffer) + } + // Relative movement is emitted once, then the device's queued zero-delta + // state is preserved exactly as on the original context-deadline path. + if _, readErr := dev.ReadScheduledInterruptInput(context.Background(), never, 1, buffer); readErr != nil { + t.Fatal(readErr) + } + if buffer[0] != state.Buttons || buffer[1] != 0 || buffer[3] != 0 { + t.Fatalf("zeroed relative state=%x", buffer) + } + deadline := make(chan time.Time, 1) + deadline <- time.Now() + if _, err = dev.ReadScheduledInterruptInput(context.Background(), deadline, 1, buffer); err != context.DeadlineExceeded { + t.Fatalf("idle deadline=%v want %v", err, context.DeadlineExceeded) + } + + state.DX, state.DY = -321, 123 + dev.UpdateInputState(state) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + readyDeadline := make(chan time.Time, 1) + readyDeadline <- time.Now() + if _, err = dev.ReadScheduledInterruptInput(ctx, readyDeadline, 1, buffer); err != context.Canceled { + t.Fatalf("lifecycle cancellation=%v want %v", err, context.Canceled) + } + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), never, 1, buffer); readErr != nil || written != 9 { + t.Fatalf("post-cancel event read=(%d, %v)", written, readErr) + } + if buffer[1] != byte(state.DX) || buffer[2] != byte(state.DX>>8) || + buffer[3] != byte(state.DY) || buffer[4] != byte(state.DY>>8) { + t.Fatalf("post-cancel movement=%x", buffer) + } +} diff --git a/device/ns2pro/device.go b/device/ns2pro/device.go index 3ad1b5bc..2c243514 100644 --- a/device/ns2pro/device.go +++ b/device/ns2pro/device.go @@ -11,7 +11,6 @@ import ( "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/usb" - "github.com/Alia5/VIIPER/usbip" ) type NS2Pro struct { @@ -131,7 +130,7 @@ func (d *NS2Pro) SetMetaState(meta MetaState) { func (d *NS2Pro) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { switch { - case dir == usbip.DirIn && ep == 1: + case dir == usb.DirectionIn && ep == 1: for { select { case <-ctx.Done(): @@ -145,7 +144,7 @@ func (d *NS2Pro) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out } } } - case dir == usbip.DirIn && ep == 2: + case dir == usb.DirectionIn && ep == 2: for { if resp := d.popBulkIn(); resp != nil { return resp @@ -156,14 +155,65 @@ func (d *NS2Pro) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out case <-d.bulkCh: } } - case dir == usbip.DirOut && ep == 1: + case dir == usb.DirectionOut && ep == 1: d.handleOutputReport(out) - case dir == usbip.DirOut && ep == 2: + case dir == usb.DirectionOut && ep == 2: d.handleBulkOut(out) } return nil } +// ReadInterruptInput implements usb.InterruptInputDevice for the HID input +// endpoint. The bulk response endpoint remains on the ordered transfer broker. +func (d *NS2Pro) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + return d.readInterruptInput(ctx, nil, ep, dst) +} + +func (d *NS2Pro) ReadScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { + return d.readInterruptInput(ctx, deadline, ep, dst) +} + +func (d *NS2Pro) readInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { + if ep != EndpointHIDIn&0x0f { + return 0, fmt.Errorf("Switch 2 Pro interrupt-IN endpoint %d is unsupported", ep) + } + if deadline != nil && ctx.Err() != nil { + return 0, ctx.Err() + } + for { + select { + case <-ctx.Done(): + if deadline == nil && errors.Is(ctx.Err(), context.DeadlineExceeded) && d.reportsEnabled() { + return d.nextInputReportInto(dst) + } + return 0, ctx.Err() + case <-deadline: + if ctx.Err() != nil { + return 0, ctx.Err() + } + if d.reportsEnabled() { + return d.nextInputReportInto(dst) + } + return 0, context.DeadlineExceeded + case <-d.inputCh: + if deadline != nil && ctx.Err() != nil { + select { + case d.inputCh <- struct{}{}: + default: + } + return 0, ctx.Err() + } + if d.reportsEnabled() { + return d.nextInputReportInto(dst) + } + } + } +} + func (d *NS2Pro) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex uint16, wLength uint16, data []byte) ([]byte, bool) { reportType := uint8(wValue >> 8) reportID := uint8(wValue) @@ -231,7 +281,20 @@ func (d *NS2Pro) nextInputReport() []byte { return d.inputReportForID(reportID) } +func (d *NS2Pro) nextInputReportInto(dst []byte) (int, error) { + d.protoMu.Lock() + reportID := d.activeReportID + d.protoMu.Unlock() + return d.inputReportForIDInto(reportID, dst) +} + func (d *NS2Pro) inputReportForID(reportID uint8) []byte { + report := make([]byte, InputReportSize) + _, _ = d.inputReportForIDInto(reportID, report) + return report +} + +func (d *NS2Pro) inputReportForIDInto(reportID uint8, dst []byte) (int, error) { d.stateMu.Lock() st := *d.inputState meta := *d.metaState @@ -242,7 +305,8 @@ func (d *NS2Pro) inputReportForID(reportID uint8) []byte { reportID = d.activeReportID } features := d.featureFlags - var report []byte + var written int + var err error switch reportID { case ReportIDCommon: d.reportCounter32++ @@ -251,13 +315,13 @@ func (d *NS2Pro) inputReportForID(reportID uint8) []byte { motionTS = uint32(time.Since(d.motionStart).Microseconds()) d.lastMotionTS = motionTS } - report = st.buildCommonReport(d.reportCounter32, motionTS, features, meta) + written, err = st.buildCommonReportInto(dst, d.reportCounter32, motionTS, features, meta) default: d.reportCounter8++ - report = st.buildProReport(d.reportCounter8, features, meta) + written, err = st.buildProReportInto(dst, d.reportCounter8, features, meta) } d.protoMu.Unlock() - return report + return written, err } func (d *NS2Pro) serialNumber() string { diff --git a/device/ns2pro/inputstate.go b/device/ns2pro/inputstate.go index 62436134..b4e02386 100644 --- a/device/ns2pro/inputstate.go +++ b/device/ns2pro/inputstate.go @@ -111,6 +111,18 @@ func (o *OutputState) UnmarshalBinary(data []byte) error { func (s InputState) buildCommonReport(counter, motionTimestamp uint32, features uint8, meta MetaState) []byte { b := make([]byte, InputReportSize) + _, _ = s.buildCommonReportInto(b, counter, motionTimestamp, features, meta) + return b +} + +func (s InputState) buildCommonReportInto( + dst []byte, counter, motionTimestamp uint32, features uint8, meta MetaState, +) (int, error) { + if len(dst) < InputReportSize { + return 0, io.ErrShortBuffer + } + b := dst[:InputReportSize] + clear(b) b[0] = ReportIDCommon binary.LittleEndian.PutUint32(b[1:5], counter) @@ -133,11 +145,23 @@ func (s InputState) buildCommonReport(counter, motionTimestamp uint32, features binary.LittleEndian.PutUint16(b[0x3B:0x3D], uint16(s.GyroZ)) } - return b + return InputReportSize, nil } func (s InputState) buildProReport(counter uint8, features uint8, meta MetaState) []byte { b := make([]byte, InputReportSize) + _, _ = s.buildProReportInto(b, counter, features, meta) + return b +} + +func (s InputState) buildProReportInto( + dst []byte, counter uint8, features uint8, meta MetaState, +) (int, error) { + if len(dst) < InputReportSize { + return 0, io.ErrShortBuffer + } + b := dst[:InputReportSize] + clear(b) b[0] = ReportIDPro b[1] = counter b[2] = powerInfo(meta) @@ -155,7 +179,7 @@ func (s InputState) buildProReport(counter uint8, features uint8, meta MetaState b[13] = 0x00 b[14] = 0x00 b[15] = 0x00 - return b + return InputReportSize, nil } func (s InputState) commonButtonBytes() [4]byte { diff --git a/device/ns2pro/native_input_test.go b/device/ns2pro/native_input_test.go new file mode 100644 index 00000000..6af25106 --- /dev/null +++ b/device/ns2pro/native_input_test.go @@ -0,0 +1,27 @@ +//go:build !race + +package ns2pro + +import ( + "io" + "testing" +) + +func TestNativeInputEncodingUsesCallerBufferWithoutAllocating(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + buffer := make([]byte, InputReportSize) + if allocations := testing.AllocsPerRun(1000, func() { + written, encodeErr := dev.inputReportForIDInto(ReportIDPro, buffer) + if encodeErr != nil || written != InputReportSize { + panic("Switch 2 Pro native input encoding failed") + } + }); allocations != 0 { + t.Fatalf("native input allocations=%v want 0", allocations) + } + if _, err = dev.inputReportForIDInto(ReportIDPro, buffer[:InputReportSize-1]); err != io.ErrShortBuffer { + t.Fatalf("short-buffer error=%v want %v", err, io.ErrShortBuffer) + } +} diff --git a/device/ns2pro/scheduled_input_test.go b/device/ns2pro/scheduled_input_test.go new file mode 100644 index 00000000..0f5d4936 --- /dev/null +++ b/device/ns2pro/scheduled_input_test.go @@ -0,0 +1,57 @@ +package ns2pro + +import ( + "context" + "testing" + "time" +) + +func TestScheduledInterruptInputPreservesSwitchStateAndDeadlineReplay(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + dev.protoMu.Lock() + dev.usbReportsEnabled = true + dev.protoMu.Unlock() + state := *NewInputState() + state.Buttons, state.LX = ButtonA|ButtonHome, 0x321 + dev.UpdateInputState(state) + buffer := make([]byte, InputReportSize) + never := make(chan time.Time) + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), never, EndpointHIDIn&0x0f, buffer); readErr != nil || written != InputReportSize { + t.Fatalf("event read=(%d, %v)", written, readErr) + } + if buffer[0] != ReportIDPro { + t.Fatalf("event report ID=%02x", buffer[0]) + } + if buffer[1] != 1 { + t.Fatalf("event counter=%d want=1", buffer[1]) + } + deadline := make(chan time.Time, 1) + deadline <- time.Now() + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), deadline, EndpointHIDIn&0x0f, buffer); readErr != nil || written != InputReportSize { + t.Fatalf("deadline read=(%d, %v)", written, readErr) + } + if buffer[0] != ReportIDPro { + t.Fatalf("deadline report ID=%02x", buffer[0]) + } + if buffer[1] != 2 { + t.Fatalf("deadline counter=%d want=2", buffer[1]) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + state.LX = 0x654 + dev.UpdateInputState(state) + readyDeadline := make(chan time.Time, 1) + readyDeadline <- time.Now() + if _, err = dev.ReadScheduledInterruptInput(ctx, readyDeadline, EndpointHIDIn&0x0f, buffer); err != context.Canceled { + t.Fatalf("lifecycle cancellation=%v want %v", err, context.Canceled) + } + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), never, EndpointHIDIn&0x0f, buffer); readErr != nil || written != InputReportSize { + t.Fatalf("post-cancel event read=(%d, %v)", written, readErr) + } + if buffer[1] != 3 { + t.Fatalf("post-cancel counter=%d want=3", buffer[1]) + } +} diff --git a/device/xbox360/device.go b/device/xbox360/device.go index a12acc43..b3bdab46 100644 --- a/device/xbox360/device.go +++ b/device/xbox360/device.go @@ -6,25 +6,41 @@ import ( "encoding/json" "errors" "fmt" + "io" "sync" + "time" "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/usb" - "github.com/Alia5/VIIPER/usbip" ) type Xbox360 struct { - inputMu sync.RWMutex - inputState InputState - inputSignal chan struct{} - rumbleDispatchMu sync.Mutex - rumbleMu sync.Mutex - rumbleFunc func(XRumbleState) - rumbleState XRumbleState - rumbleSeen bool - descriptor usb.Descriptor + inputMu sync.RWMutex + inputState InputState + inputSignal chan struct{} + nativeInputMu sync.Mutex + nativeDataStage uint8 + nativeControlSent bool + rumbleDispatchMu sync.Mutex + rumbleMu sync.Mutex + rumbleFunc func(XRumbleState) + rumbleState XRumbleState + rumbleSeen bool + descriptor usb.Descriptor } +var nativeDataInitializationReports = [...][]byte{ + {0x01, 0x03, 0x0e}, + {0x02, 0x03, 0x00}, + {0x03, 0x03, 0x03}, + {0x08, 0x03, 0x00}, + {0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0xe4, 0xf2, 0xb3, 0xf8, + 0x49, 0xf3, 0xb0, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0x01, 0x03, 0x03}, +} + +var nativeControlInitializationReport = [...]byte{0x05, 0x03, 0x00} + type Xbox360CreateOptions struct { SubType *uint8 `json:"subType"` } @@ -87,7 +103,7 @@ func (x *Xbox360) UpdateInputState(state InputState) { // HandleTransfer implements interrupt IN/OUT for Xbox360. func (x *Xbox360) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { - if dir == usbip.DirIn { + if dir == usb.DirectionIn { switch ep { case 1: // 0x81 - main input reports select { @@ -110,7 +126,7 @@ func (x *Xbox360) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out return nil } } - if dir == usbip.DirOut && ep == 1 { + if dir == usb.DirectionOut && ep == 1 { // Host->Device output reports used by the wired Xbox 360 controller include // an 8-byte rumble packet: [0]=ReportID(0x00), [1]=Len(0x08), [2]=Reserved/Status(0x00), // [3]=Left (low-frequency/large) motor 0-255, [4]=Right (high-frequency/small) motor 0-255, @@ -126,6 +142,107 @@ func (x *Xbox360) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out return nil } +// ReadInterruptInput implements usb.InterruptInputDevice for the native UDE +// input lane without changing the USB/IP report ownership contract. +func (x *Xbox360) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + written, _, err := x.readInterruptInput(ctx, nil, ep, dst) + return written, err +} + +func (x *Xbox360) ReadScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { + written, _, err := x.readInterruptInput(ctx, deadline, ep, dst) + return written, err +} + +func (x *Xbox360) ReadClassifiedScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, bool, error) { + return x.readInterruptInput(ctx, deadline, ep, dst) +} + +func (x *Xbox360) SupportsInterruptInputEndpoint(ep uint32) bool { + return ep == 1 || ep == 3 +} + +func (x *Xbox360) nativeInitializationReport(ep uint32, dst []byte) (int, bool, error) { + x.nativeInputMu.Lock() + defer x.nativeInputMu.Unlock() + + var report []byte + switch ep { + case 1: + if int(x.nativeDataStage) >= len(nativeDataInitializationReports) { + return 0, false, nil + } + report = nativeDataInitializationReports[x.nativeDataStage] + case 3: + if x.nativeControlSent { + return 0, false, nil + } + report = nativeControlInitializationReport[:] + default: + return 0, false, fmt.Errorf("Xbox 360 interrupt-IN endpoint %d is unsupported", ep) + } + if len(dst) < len(report) { + return 0, false, io.ErrShortBuffer + } + copy(dst, report) + if ep == 1 { + x.nativeDataStage++ + } else { + x.nativeControlSent = true + } + return len(report), true, nil +} + +func (x *Xbox360) readInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, bool, error) { + if !x.SupportsInterruptInputEndpoint(ep) { + return 0, false, fmt.Errorf("Xbox 360 interrupt-IN endpoint %d is unsupported", ep) + } + if deadline != nil && ctx.Err() != nil { + return 0, false, ctx.Err() + } + if written, transition, err := x.nativeInitializationReport(ep, dst); err != nil || transition { + return written, transition, err + } + if ep == 3 { + select { + case <-ctx.Done(): + return 0, false, ctx.Err() + case <-deadline: + return 0, false, context.DeadlineExceeded + } + } + inputReady := false + select { + case <-ctx.Done(): + if deadline != nil || !errors.Is(ctx.Err(), context.DeadlineExceeded) { + return 0, false, ctx.Err() + } + case <-deadline: + case <-x.inputSignal: + inputReady = true + } + if deadline != nil && ctx.Err() != nil { + if inputReady { + select { + case x.inputSignal <- struct{}{}: + default: + } + } + return 0, false, ctx.Err() + } + x.inputMu.RLock() + st := x.inputState + x.inputMu.RUnlock() + written, err := st.BuildReportInto(dst) + return written, inputReady, err +} + func (x *Xbox360) emitRumble(rumble XRumbleState) { x.rumbleDispatchMu.Lock() defer x.rumbleDispatchMu.Unlock() @@ -178,8 +295,8 @@ func MakeDescriptor() usb.Descriptor { }, Endpoints: []usb.EndpointDescriptor{ // Full-speed interrupt bInterval=1 advertises a 1 ms maximum - // input service cadence. The USB/IP scheduler still presents only - // the newest feeder state, so idle pads do not create a busy loop. + // input service cadence. Transport schedulers present only the newest + // feeder state, so idle pads do not create a user-mode busy loop. {BEndpointAddress: 0x81, BMAttributes: 0x03, WMaxPacketSize: 0x0020, BInterval: 0x01}, {BEndpointAddress: 0x01, BMAttributes: 0x03, WMaxPacketSize: 0x0020, BInterval: 0x08}, }, diff --git a/device/xbox360/inputstate.go b/device/xbox360/inputstate.go index 1a233c54..bb34f236 100644 --- a/device/xbox360/inputstate.go +++ b/device/xbox360/inputstate.go @@ -56,6 +56,17 @@ type GuitarHeroDrumsInputState struct { // 14-19: Reserved / zero func (x *InputState) BuildReport() []byte { b := make([]byte, 20) + _, _ = x.BuildReportInto(b) + return b +} + +// BuildReportInto encodes the wired input report into caller-owned storage. +func (x *InputState) BuildReportInto(dst []byte) (int, error) { + if len(dst) < 20 { + return 0, io.ErrShortBuffer + } + b := dst[:20] + clear(b) b[0] = 0x00 b[1] = 0x14 binary.LittleEndian.PutUint16(b[2:4], uint16(x.Buttons&0xffff)) @@ -66,7 +77,7 @@ func (x *InputState) BuildReport() []byte { binary.LittleEndian.PutUint16(b[10:12], uint16(x.RX)) binary.LittleEndian.PutUint16(b[12:14], uint16(x.RY)) copy(b[14:20], x.Reserved[:]) - return b + return 20, nil } // MarshalBinary encodes InputState to 20 bytes. diff --git a/device/xbox360/native_input_test.go b/device/xbox360/native_input_test.go new file mode 100644 index 00000000..5fb147a2 --- /dev/null +++ b/device/xbox360/native_input_test.go @@ -0,0 +1,24 @@ +//go:build !race + +package xbox360 + +import ( + "io" + "testing" +) + +func TestNativeInputEncodingUsesCallerBufferWithoutAllocating(t *testing.T) { + state := NewInputState() + buffer := make([]byte, 20) + if allocations := testing.AllocsPerRun(1000, func() { + written, err := state.BuildReportInto(buffer) + if err != nil || written != 20 { + panic("Xbox 360 native input encoding failed") + } + }); allocations != 0 { + t.Fatalf("native input allocations=%v want 0", allocations) + } + if _, err := state.BuildReportInto(buffer[:19]); err != io.ErrShortBuffer { + t.Fatalf("short-buffer error=%v want %v", err, io.ErrShortBuffer) + } +} diff --git a/device/xbox360/scheduled_input_test.go b/device/xbox360/scheduled_input_test.go new file mode 100644 index 00000000..93910cde --- /dev/null +++ b/device/xbox360/scheduled_input_test.go @@ -0,0 +1,88 @@ +package xbox360 + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestScheduledInterruptInputPreservesXboxStateAndDeadlineReplay(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + state := *NewInputState() + state.Buttons, state.LT, state.RX = 0x1234, 199, -4567 + dev.UpdateInputState(state) + buffer := make([]byte, 32) + never := make(chan time.Time) + for index, want := range nativeDataInitializationReports { + written, transition, readErr := dev.ReadClassifiedScheduledInterruptInput( + context.Background(), never, 1, buffer) + if readErr != nil || !transition || written != len(want) { + t.Fatalf("initialization report %d=(%d, %v, %v)", index, written, transition, readErr) + } + if got := buffer[:written]; string(got) != string(want) { + t.Fatalf("initialization report %d=%x want=%x", index, got, want) + } + } + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), never, 1, buffer); readErr != nil || written != 20 { + t.Fatalf("event read=(%d, %v)", written, readErr) + } + if buffer[4] != state.LT { + t.Fatalf("event state=%x", buffer) + } + deadline := make(chan time.Time, 1) + deadline <- time.Now() + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), deadline, 1, buffer); readErr != nil || written != 20 { + t.Fatalf("deadline read=(%d, %v)", written, readErr) + } + if buffer[4] != state.LT { + t.Fatalf("deadline state=%x", buffer) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + state.LT = 211 + dev.UpdateInputState(state) + readyDeadline := make(chan time.Time, 1) + readyDeadline <- time.Now() + if _, err = dev.ReadScheduledInterruptInput(ctx, readyDeadline, 1, buffer); err != context.Canceled { + t.Fatalf("lifecycle cancellation=%v want %v", err, context.Canceled) + } + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), never, 1, buffer); readErr != nil || written != 20 { + t.Fatalf("post-cancel event read=(%d, %v)", written, readErr) + } + if buffer[4] != state.LT { + t.Fatalf("post-cancel state=%x", buffer) + } +} + +func TestNativeInterruptEndpointSelectionAndControlInitialization(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + for endpoint := uint32(1); endpoint <= 4; endpoint++ { + want := endpoint == 1 || endpoint == 3 + if got := dev.SupportsInterruptInputEndpoint(endpoint); got != want { + t.Fatalf("endpoint %d support=%v want=%v", endpoint, got, want) + } + } + buffer := make([]byte, 32) + never := make(chan time.Time) + written, transition, err := dev.ReadClassifiedScheduledInterruptInput( + context.Background(), never, 3, buffer) + if err != nil || !transition || written != len(nativeControlInitializationReport) { + t.Fatalf("control initialization=(%d, %v, %v)", written, transition, err) + } + if got := buffer[:written]; string(got) != string(nativeControlInitializationReport[:]) { + t.Fatalf("control initialization=%x want=%x", got, nativeControlInitializationReport) + } + deadline := make(chan time.Time, 1) + deadline <- time.Now() + if _, _, err = dev.ReadClassifiedScheduledInterruptInput( + context.Background(), deadline, 3, buffer); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("idle control endpoint error=%v want deadline exceeded", err) + } +} diff --git a/docs/api/overview.md b/docs/api/overview.md index 586960ce..f858a843 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -77,16 +77,31 @@ If you ever worked with HTTP APIs before, you'll feel right at home. The exception to this are the device-control and feedback streams, which are raw binary streams specific to each device type. - **Transport**: TCP with optional encryption (ChaCha20-Poly1305) -- **Default listen address**: `:3242` (configurable via `--api.addr`) -- **Authentication**: Required for remote connections, optional for localhost (password-based with HMAC validation) +- **Default listen address**: `127.0.0.1:3242` (configurable via `--api.addr`) +- **Authentication**: Required by default for localhost and always required for remote connections (password-based with HMAC validation) - **Encryption**: Automatic for authenticated connections (ChaCha20-Poly1305 with unique session keys) - **Request format**: a single ASCII/UTF‑8 line terminated by `\0` - **Routing**: path followed by optional payload separated by whitespace (e.g., `bus/list\0` or `bus/create 5\0`) - **Payload**: optional string that can be a JSON object, numeric value, or plain string depending on the endpoint. The payload may contain newlines (e.g., pretty-printed JSON) as only the null byte terminates the request. -- **Success response**: a single line containing a JSON payload (or an empty line for commands that have no payload), terminated by connection close -- **Error response**: a single line JSON object following RFC 7807 Problem Details format with a `status` field (HTTP-style status code) and other error details, terminated by connection close +- **Success response**: a single line containing a JSON payload (or an empty line for commands that have no payload), terminated by connection close +- **Error response**: a single line JSON object following RFC 7807 Problem Details format with a `status` field (HTTP-style status code) and other error details, terminated by connection close + +Authenticated records use a 96-bit nonce split into a fixed 32-bit direction +domain (`0` for client-to-server and `1` for server-to-client) and a 64-bit +monotonic record counter. The Go server and Go client receivers require the +expected direction and exact next counter, rejecting role inversion, replay, +and reordering. This satisfies the [Go `cipher.AEAD` requirement that a nonce +be unique for a given key](https://pkg.go.dev/crypto/cipher#AEAD). The nonce +remains part of every wire record: existing generated clients continue to send +the client domain and decrypt the server domain directly from the record. + +Upgrade authenticated deployments server-first. Existing clients accept the +new server domain because each record carries its nonce, while the new Go +client intentionally rejects records from an older roleless server that still +sends domain `0`. Packaged service and Go client versions should otherwise be +kept matched. !!! tip "Testing the API" For quick testing, you can use tools like `netcat` (Linux/macOS) or PowerShell scripts (Windows) to send requests and read responses. @@ -94,11 +109,11 @@ The exception to this are the device-control and feedback streams, which are raw !!! warning "Connection timing and auto‑cleanup" After you add a device with `bus/{id}/add`, you must connect to its streaming endpoint within the configured `DeviceHandlerConnectTimeout` (default: 5s). If no stream connection is established in time, the device is automatically removed. Likewise, when a stream disconnects, a reconnection timer with the same timeout starts; if the client doesn’t reconnect before it expires, the device is removed. -!!! warning "Authentication Required for Remote Connections" - **VIIPER requires authentication for all non-localhost connections.** - - - **Localhost clients** (`127.0.0.1`, `::1`, `localhost`): Authentication is **optional** (but supported) by default - - **Remote clients**: Authentication is **required** and enforced +!!! warning "Authentication Required" + **VIIPER requires authentication for API topology and device-stream control by default.** + + - **Localhost clients** (`127.0.0.1`, `::1`, `localhost`): Authentication is **required by default** + - **Remote clients**: Authentication is **always required** and enforced On first start, VIIPER generates a random password and saves it to `/viiper.key.txt`. @@ -106,9 +121,9 @@ The exception to this are the device-control and feedback streams, which are raw Linux (user): `~/.config/github.com/Alia5/viiper/viiper.key.txt` Linux (root/systemd): `/etc/viiper/viiper.key.txt` - Remote clients must provide this password to establish a connection. + Clients must provide this password to establish an authenticated connection. The value is never printed to a VIIPER log or console. - See the [Configuration](../cli/configuration.md) documentation for details on password management and the `--api.require-localhost-auth` option. + See the [Configuration](../cli/configuration.md) documentation for credential management and the legacy USB/IP localhost development opt-out. ## Endpoints @@ -159,7 +174,37 @@ The exception to this are the device-control and feedback streams, which are raw ??? info "ping - Simple identity and version check" **Request:** `ping` - **Response:** `{ "server": "VIIPER", "version": "1.2.3[-dev-abcd]" }` + **Legacy response:** `{ "server": "VIIPER", "version": "1.2.3[-dev-abcd]" }` + + The packaged server also reports its active transport and readiness. Native + UDE mode includes the exact negotiated ABI, capability mask, package + version expected by the service, the source-bound identity returned by the + currently loaded kernel image, and negotiated limits. Clients opting in to + the native backend should fail closed unless these fields match their + required contract: + + ```json + { + "server": "VIIPER", + "version": "1.2.3", + "transport": "native-ude", + "ready": true, + "nativeUde": { + "abiMajor": 1, + "abiMinor": 14, + "capabilities": 61, + "expectedDriverPackageVersion": "0.1.0.38", + "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", + "controllerSessionId": "", + "controllerInstanceId": "ROOT\\VIIPERUDE\\0000", + "maxDevices": 32, + "maxDescriptorBytes": 262144, + "maxTransferBytes": 1048576, + "maxIsoPackets": 1024, + "maxPendingOperations": 4096 + } + } + ``` #### `bus/list` {.toc-anchor} @@ -203,10 +248,19 @@ The exception to this are the device-control and feedback streams, which are raw "devId": "1", "vid": "0x045e", "pid": "0x028e", - "type": "xbox360" - "deviceSpecific": { - "subType": 1 - } + "type": "xbox360", + "deviceSpecific": { + "subType": 1 + }, + "transport": "native-ude", + "nativeUde": { + "deviceId": "4294967297", + "deviceGeneration": 1, + "controllerSessionId": "123456789", + "controllerInstanceId": "ROOT\\VIIPERUDE\\0000", + "usb20PortNumber": 1, + "usb30PortNumber": 0 + } } ] } @@ -239,10 +293,19 @@ The exception to this are the device-control and feedback streams, which are raw "devId": "1", "vid": "0x045e", "pid": "0x028e", - "type": "xbox360", - "deviceSpecific": { - "subType":7 - } + "type": "xbox360", + "deviceSpecific": { + "subType":7 + }, + "transport": "native-ude", + "nativeUde": { + "deviceId": "4294967297", + "deviceGeneration": 1, + "controllerSessionId": "123456789", + "controllerInstanceId": "ROOT\\VIIPERUDE\\0000", + "usb20PortNumber": 1, + "usb30PortNumber": 0 + } } ``` @@ -250,16 +313,43 @@ The exception to this are the device-control and feedback streams, which are raw After add, the server starts a connect timer (default `5s`). You must open a device stream before the timeout expires, otherwise the device is auto-removed. !!! info "Auto-attach" - If [auto-attach](../cli/server.md#api.auto-attach-local-client) is enabled (default), the server automatically attaches the new device to a local USBIP client on the same host (localhost only). Failures are logged but do not affect the API response. + In explicit USB/IP mode, [auto-attach](../cli/server.md#api.auto-attach-local-client) can attach the new device to a local USBIP client. Native UDE mode never performs USB/IP attach/detach; its response instead carries the authenticated `nativeUde` ownership tuple. Exactly one of `usb20PortNumber` and `usb30PortNumber` is nonzero. Treat `deviceId` and `controllerSessionId` as decimal strings rather than JSON numbers, and fail closed if any native ownership field is absent or inconsistent with `ping`. #### `bus/{id}/remove ` {.toc-anchor} -??? info "bus/{id}/remove - Remove a device from a bus" - **Request:** `bus/1/remove 1` - - **Payload:** Numeric device ID (e.g., `1` for device 1-1 on the bus) - - **Response:** `{ "busId": , "devId": "" }` +??? info "bus/{id}/remove - Remove a device from a bus" + **Request:** `bus/1/remove 1` + + **Payload:** Numeric device ID (e.g., `1` for device 1-1 on the bus) + + This legacy ID-only endpoint is available only in explicit USB/IP mode. + Native UDE clients must use `remove-native`; an ID-only native removal is + rejected because IDs can be reused after a controller restart. + + **Response:** `{ "busId": , "devId": "" }` + +#### `bus/{id}/remove-native ` {.toc-anchor} + +??? info "bus/{id}/remove-native - Conditionally remove one exact native UDE device" + **Request:** + + ```text + bus/1/remove-native {"devId":"1","transport":"native-ude","nativeUde":{"deviceId":"4294967297","deviceGeneration":1,"controllerSessionId":"123456789","controllerInstanceId":"ROOT\\VIIPERUDE\\0000","usb20PortNumber":1,"usb30PortNumber":0}} + ``` + + The payload must echo the exact `devId`, `transport`, and complete + `nativeUde` receipt returned by `add` or `list`. Field names, decimal string + encodings, and object shape are canonical; duplicate, missing, unknown, or + trailing JSON fields are rejected. + + VIIPER compares the receipt with the current native registration while + holding the same lifecycle lock used for unregistration. A stale receipt + returns `409 Conflict` and removes nothing. Clients must treat that result as + a retired lifetime and must never retry through the ID-only endpoint. A + successful removal owns empty-bus cleanup; clients must not issue a separate + `bus/remove` operation. + + **Response:** `{ "busId": 1, "devId": "1" }` ### Device Control / Feedback {#device-control--feedback} diff --git a/docs/architecture/native-udecx-official-sources.md b/docs/architecture/native-udecx-official-sources.md new file mode 100644 index 00000000..204d3b13 --- /dev/null +++ b/docs/architecture/native-udecx-official-sources.md @@ -0,0 +1,72 @@ +# Native UDE official-source pins + +This file records the primary Microsoft contracts used by the native UDE +implementation and transaction model. Links are pinned to immutable source +commits so a later documentation edit cannot silently change the reviewed +release basis. The corresponding Microsoft Learn pages remain useful for +navigation, but they are not the immutable evidence references. + +Pins captured on 2026-08-15: + +- `MicrosoftDocs/windows-driver-docs`: + `5bf16a2a190814adbda0826aba1daf74faa1d45c` +- `MicrosoftDocs/windows-driver-docs-ddi`: + `7515063cea4c9e98db6a92986c5b4ddb0463fd16` +- `MicrosoftDocs/sdk-api`: + `4502fff176b3b56beddb6a63c9f980377b11ba9b` + +## UDE ownership, completion, and power + +- [Write a UDE client driver](https://github.com/MicrosoftDocs/windows-driver-docs/blob/5bf16a2a190814adbda0826aba1daf74faa1d45c/windows-driver-docs-pr/usbcon/writing-a-ude-client-driver.md) + is the authority for class-extension ownership of the associated endpoint + queue state, the client's forwarded-I/O purge obligation, START reopening, + and separate-DPC URB completion. It is the basis for treating PURGE as the + upstream admission boundary while joining driver-owned callbacks and + forwarded operations without calling WDF queue-state mutation APIs. +- [Asynchronous link-power exit completion](https://github.com/MicrosoftDocs/windows-driver-docs-ddi/blob/7515063cea4c9e98db6a92986c5b4ddb0463fd16/wdk-ddi-src/content/udecxusbdevice/nf-udecxusbdevice-udecxusbdevicelinkpowerexitcomplete.md) + requires PASSIVE_LEVEL completion after the client has finished its low-power + transition. This is the basis for the preallocated passive D0-exit worker and + its completion-as-final-object-access rule. +- [WdfWorkItemFlush](https://github.com/MicrosoftDocs/windows-driver-docs-ddi/blob/7515063cea4c9e98db6a92986c5b4ddb0463fd16/wdk-ddi-src/content/wdfworkitem/nf-wdfworkitem-wdfworkitemflush.md) + waits for queued and already-running callbacks and is PASSIVE_LEVEL only. +- [WDF object cleanup](https://github.com/MicrosoftDocs/windows-driver-docs-ddi/blob/7515063cea4c9e98db6a92986c5b4ddb0463fd16/wdk-ddi-src/content/wdfobject/nc-wdfobject-evt_wdf_object_context_cleanup.md) + defines child-before-parent cleanup and the work-item callback lifetime fence. + Together, these two contracts require flushing device work before consuming + a UDE device handle and allow an endpoint-parented purge worker to finish its + counted callback before endpoint cleanup. + +## Driver-package transaction + +- [SetupCopyOEMInfW](https://github.com/MicrosoftDocs/sdk-api/blob/4502fff176b3b56beddb6a63c9f980377b11ba9b/sdk-api-src/content/setupapi/nf-setupapi-setupcopyoeminfw.md) + supplies the add-only stage operation and the documented + `SP_COPY_NOOVERWRITE`/`ERROR_FILE_EXISTS` receipt behavior. +- [DiInstallDevice](https://github.com/MicrosoftDocs/sdk-api/blob/4502fff176b3b56beddb6a63c9f980377b11ba9b/sdk-api-src/content/newdev/nf-newdev-diinstalldevice.md) + binds an explicitly selected, already preinstalled driver to the exact + present devnode and returns an authoritative reboot requirement. +- [DiUninstallDevice](https://github.com/MicrosoftDocs/sdk-api/blob/4502fff176b3b56beddb6a63c9f980377b11ba9b/sdk-api-src/content/newdev/nf-newdev-diuninstalldevice.md) + removes the selected devnode and returns a reboot requirement that must remain + durable until a later boot proves the requested removal settled. +- [SetupUninstallOEMInfW](https://github.com/MicrosoftDocs/sdk-api/blob/4502fff176b3b56beddb6a63c9f980377b11ba9b/sdk-api-src/content/setupapi/nf-setupapi-setupuninstalloeminfw.md) + removes one exact published package. The transaction uses flags zero and + never force-deletes a package still used by a device. +- [SetupDiCreateDeviceInfoW](https://github.com/MicrosoftDocs/sdk-api/blob/4502fff176b3b56beddb6a63c9f980377b11ba9b/sdk-api-src/content/setupapi/nf-setupapi-setupdicreatedeviceinfow.md) + and [SetupDiGetDeviceInstanceIdW](https://github.com/MicrosoftDocs/sdk-api/blob/4502fff176b3b56beddb6a63c9f980377b11ba9b/sdk-api-src/content/setupapi/nf-setupapi-setupdigetdeviceinstanceidw.md) + establish that a generated root instance identity is available before device + registration. The install journal therefore persists that exact receipt + before registration can leave a partially created root. + +## Broker rollback material + +- [CryptProtectData](https://github.com/MicrosoftDocs/sdk-api/blob/4502fff176b3b56beddb6a63c9f980377b11ba9b/sdk-api-src/content/dpapi/nf-dpapi-cryptprotectdata.md) + defines machine-scoped protected rollback blobs. The journal additionally + relies on protected directories, exact ACLs, per-transaction entropy, hashes, + and no plaintext secret fields; DPAPI alone is not the access-control layer. +- [ReplaceFileW](https://github.com/MicrosoftDocs/sdk-api/blob/4502fff176b3b56beddb6a63c9f980377b11ba9b/sdk-api-src/content/winbase/nf-winbase-replacefilew.md) + supplies the one-name image replacement primitive used after durable capture + of the prior broker image. Journal records and backup files are separately + flushed and read back before any service, key, image, or ownership mutation. + +These sources define API behavior, not the repository's complete safety proof. +The release proof also requires the checked-in state-machine contracts, injected +cut-point models, immutable package manifest, live lifecycle/Verifier matrix, +and the absence of any unresolved recovery journal. diff --git a/docs/architecture/native-udecx-package-install.md b/docs/architecture/native-udecx-package-install.md new file mode 100644 index 00000000..98466d68 --- /dev/null +++ b/docs/architecture/native-udecx-package-install.md @@ -0,0 +1,367 @@ +# Native UDE package installation and removal transactions + +The native UDE release is installed through one fail-closed composition +transaction. `viiper native-package-install` is a hidden bootstrapper boundary; +users enter it only through a signed DS4Windows/VIIPER installer that embeds the +reviewed SHA-256 values. It cannot turn a CI test-signed package into production +media. The driver must first satisfy the production HLK/WHCP contract in +[`native-udecx-signing.md`](native-udecx-signing.md). + +Production removal enters through `viiper uninstall`. The signed installer must +pass the packaged `ViiperUdeCtl.exe` path, its installer-bound SHA-256, and the +interactive-user SID. A direct Windows uninstall without those immutable helper +inputs fails before mutation; the command does not fall back to deleting only +the broker and leaving the devnode or Driver Store package behind. + +## Trust inputs + +The signed bootstrapper supplies all of the following as immutable build data: + +- the exact VIIPER broker, `ViiperUdeCtl.exe`, production manifest, INF, SYS, + and CAT SHA-256 values; +- the reviewed exact 40- or 64-hexadecimal source revision; +- the runtime driver directory containing only the Microsoft-returned INF, + SYS, and CAT, plus the source-bound HLK/WHCP manifest; and +- the target interactive-user SID whose legacy startup ownership may be + migrated. + +Before its first mutation, the command holds non-write-shared and +non-delete-shared handles to the broker, helper, manifest, INF, SYS, and CAT, +plus every local directory ancestor used to reopen those paths. It rejects +reparse points, hard links, ancestor replacement, extra package files, hash changes, +noncanonical INF contracts, non-production manifests, and packages that do not +pass the helper's read-only signature/catalog verification. The helper proves +that the exact adjacent `ViiperUde.inf` and `ViiperUde.sys` are both members of +the exact adjacent Microsoft catalog under Windows driver policy before any +SetupAPI mutation. +Production checks +the actual catalog signer certificate for Windows Hardware Driver Verification +EKU and explicitly rejects the attestation EKU; the publisher display name is +not sufficient. The helper repeats +the installer-embedded manifest SHA-256 check before both verification and +installation. Paths are passed as arguments, never through a command shell. + +The certification/intake artifact still contains the PDB, and the intake gate +binds that PDB to the same manifest. The public runtime bundle omits it: the +installer pins the validated manifest hash and rechecks the unchanged INF, +while Windows consumes only INF/SYS/CAT. Debug symbols therefore remain part +of the source-provenance evidence without becoming a user-machine dependency. + +## Commit order + +1. Acquire the administrator-only machine package mutex and validate every + immutable input. Then acquire the broker-service mutex, inspect the exact + service/configuration/DACL/recovery/image/run-state, and retain a protected + hash snapshot of any trusted prior broker. This is the global + package-then-service lock order. +2. Create and hold a random one-time token below `%ProgramFiles%\VIIPER` with + an administrator/SYSTEM-only DACL, and pass its installer-bound SHA-256 to + `ViiperUdeCtl install`. The helper independently reopens and verifies the + source manifest and all three runtime driver hashes, acquires its private + driver mutex, and snapshots the exact Driver Store/devnode topology. Before + its first SetupAPI or broker mutation, it creates the protected fixed + `%ProgramData%\VIIPER\UdeCx\Transactions\active-v2` journal, copies and + revalidates immutable prior/candidate recovery material, and publishes the + first write-through record in a bounded canonical SHA-256 chain. Every later + staging, quiescence, binding, rollback, reboot, and broker handoff cut point + is durably appended before the next mutation. Startup admission reconciles + this journal from exact current state; unknown transitions, identities, + package inventories, or partial topology fail closed and retain evidence. +3. Classify the driver under that mutex. Exact package bytes plus an exact + started binding cause no SetupAPI mutation. Same-version INF/SYS/CAT + conflicts and implicit downgrades fail before mutation. A missing candidate + is add-only staged with `SetupCopyOEMInfW`; the helper validates its returned + published name, bytes, catalog, signer, and complete package inventory, then + proves that staging did not alter the captured root. Only after that proof + does it signal the inherited quiescence request. The outer transaction stops + only a trusted broker while retaining the broker-service mutex. Weak service + ownership aborts because it is not a safe rollback source. After quiescence, + the helper re-enumerates global topology, repeats exact package/root-byte and + pristine-runtime proofs, prepares the compatible-driver list, and switches + only the captured devnode in place with `DiInstallDevice`. There is no + forward remove/recreate gap. If the captured topology had no root, the helper + obtains the generated instance ID, durably records that exact receipt before + setting its hardware ID or registering it, and can therefore reconcile a + crash-partial root without adopting a lookalike. The captured snapshot and + exact staging receipt remain authoritative until broker commit; rollback + restores the prior binding before removing only a package proved staged by + this transaction. +4. After the exact binding is verified, the helper signals its inherited broker + handoff event. The outer transaction releases its protected prior-image and + SCM handles, then releases the broker-service mutex on the same pinned OS + thread. Only then does the helper launch the immutable package broker's + hidden `native-package-broker-commit` command while still holding the driver + mutex and snapshot. That command reopens the token, requires its exact + DACL/hash/path, proves that the separate outer process still owns the package + mutex, then acquires the broker-service mutex. An exact driver no-op skips + service quiescence but uses the same handoff before broker health or repair. +5. Before its first broker mutation, the nested command builds all rollback + material below a protected + `%ProgramData%\VIIPER\BrokerTransactions\preparing-` directory. + Its bounded canonical snapshot binds the outer token, candidate and prior + image, exact service state, target SID, and encrypted prior credential and + legacy-registration artifacts. Only after every file is flushed, reopened, + hash-verified, and protected does an atomic no-replace rename publish the + fixed `active-v1` journal. A write-through SHA-256 chain then records intent + and return phases around service stop, atomic image replacement, legacy-owner + stop, credential rotation, SCM configuration, start, authentication, legacy + removal, and reauthentication. +6. The nested command accepts a true no-op only when the protected + service/image/credential state is canonical, no legacy owner is live, the + service PID is stable, and authenticated `ping` proves `Ready=true`, ABI + 1.14, the exact capability mask, package version, controller session and + instance identities, and loaded-kernel build + identity. Otherwise it performs the journaled repair. Exact forward health + ends at durable `nested-ready`; it does not delete rollback material or claim + outer success. A broker failure restores SCM, credential, image, legacy + state, and prior run-state in dependency order before recording an exact + rollback result. Missing, malformed, ambiguous, or corrupt evidence latches + manual reconciliation and never authorizes an independent driver rollback. +7. Driver and broker success settle through a two-phase receipt. The helper + durably records `BrokerOuterSettlementPending` and emits one canonical + binding containing both transaction IDs, both pending journal digests, the + candidate and outer-token identity, a fresh settlement nonce, and the + protected request hash. The Go parent revalidates live forward state, records + `outer-settlement-pending`, atomically publishes the protected request, and + calls the hash-pinned helper's `broker-settlement-ack` command while it still + owns the package mutex. The helper authenticates both journals and the + request, records `BrokerOuterSettled`, atomically retires its active journal + to an exact settled tombstone, and returns the final driver digest. Go binds + that receipt into `outer-settled`, publishes the protected final receipt, + atomically retires its journal, and only then asks the helper to atomically + rename the driver tombstone to an inert discarding name. Recursive deletion + is best-effort after those authoritative renames. +8. Every ordinary process or power cut re-enters the same transaction. A + protected `nested-ready`, pending settlement, active final state, or settled + tombstone is replayed idempotently using the original token and journal + identities; a new broker child is not started while old settlement exists. + The caller retries its requested new transaction only after the old one is + exactly settled. A pre-mutation proof or fully settled child rollback is the + only authority for restoring the captured driver packages/devnode. If the + outer transaction stopped a trusted prior broker and failure occurred before + handoff, it restores the exact snapshot only after settled driver proof. + After handoff, indeterminate proof leaves the service stopped and preserves + both journals for reconciliation. The legacy transport itself is never + directly removed by this transaction. + +The mutating broker process is never hard-terminated. The outer absolute +four-minute deadline is passed through the helper into the nested broker, so it +does not receive a fresh budget after driver mutation. The broker owns a +separately bounded rollback and unwinds cooperatively; the helper retains the +driver snapshot through a three-minute post-deadline ceiling that covers the +45-second inner service rollback plus the outer non-canceled two-minute image +rollback. Even after that ceiling it retains the driver mutex until the child +actually exits, then reports an indeterminate result rather than racing the +child with a second driver rollback. Synchronous SetupAPI work is +checked immediately before and after each mutating boundary; no new phase may +start after expiry, and no process is killed mid-rollback. + +Before calling Go's `Cmd.Wait`, the outer transaction duplicates the exact +helper process handle with `SYNCHRONIZE`. It waits on that retained process +object together with the two child-to-parent coordination events, and passes +only four unnamed, explicitly inherited events to the exact helper process. +The process signal has priority over stale event observations. The retained +process must become signaled before the package mutex or any immutable input +handle can unwind. A non-exit `Cmd.Wait` or event-wait error is therefore still +indeterminate, but it can no longer let a live mutating helper escape the +transaction scope or authorize an unsafe prior-service restart. + +## Exact package removal + +Removal is a separate fail-closed composition transaction; it does not reuse +the historical broker-only uninstall routine. + +1. Acquire the package mutex and then the broker-service mutex. Lock every local + ancestor of the packaged helper, hold its leaf handle without write/delete + sharing, and require the installer-bound SHA-256, one-link identity, + non-reparse identity, and PE header. +2. Inventory only the exact `VIIPERNativeBroker` name. A service is eligible to + stop only when its LocalSystem configuration, arguments, service DACL, + recovery policy, credential path, and managed Program Files executable are + canonical. The executable, credential, and protected directory chains remain + locked and hash-snapshotted. A running broker's optional log is first held by + file identity with sharing compatible with its trusted writer. A same-named + weak, non-LocalSystem, or non-managed service fails preflight and is never + adopted or deleted. +3. Stop the exact trusted service but keep its SCM registration, credential, and + managed files intact. Before launching the helper, upgrade any live-log probe + to a non-write-shared delete handle and require the same volume/file identity, + one-link state, non-reparse identity, and a stable hash. Launch + `ViiperUdeCtl remove` with the outer absolute + deadline. The Go parent never uses a context-killed process or hard + termination after launch, and it applies the same retained-process join as + install before releasing its service/package scope. The helper checks the + cooperative deadline before + and after each SetupAPI boundary and owns a separate two-minute cooperative + rollback ceiling. If the live-log identity cannot be upgraded exactly, the + helper is not launched and the broker stays stopped rather than reopening an + ambiguous LocalSystem-managed path. +4. Accept only one structured helper outcome whose process and reported exit + codes agree. Exit 0 is verified final success. Exit 3010 is verified Windows + reboot-success. A preflight rejection proves no driver mutation; exit 1 with + `rollback=succeeded` proves that the exact captured package/devnode topology + was restored. A no-reboot result in those two failure classes permits the + exact prior broker run-state to be restored after its locked service/files + are revalidated. Rollback that still requires a reboot preserves the files + but leaves the service stopped until Windows can settle the binding. + Exit 3, a crash, a missing/malformed proof, or any ambiguous wait cannot prove + a safe binding, so the broker remains stopped and the command reports that + external reconciliation is required. + Before mutation, the helper creates the fixed protected + `%ProgramData%\VIIPER-UdeCx-RemoveTransactions\active-v2` recovery root and + stores the exact prior devnode plus every INF/SYS/CAT package in immutable + write-through backups. A canonical, bounded, append-only SHA-256 chain binds + those backups, boot/reboot epochs, and device/package entered, returned, and + committed cut points. Every directory and file is non-reparse, single-link, + Administrators/LocalSystem-only, explicitly flushed, reopened, byte-compared, + and held against write/delete sharing. Startup recovery uses exact raw-root, + package-inventory, and cut-point authority to finish removal or restore the + prior state; unknown, mixed, or concurrent topology latches manual + reconciliation without broad mutation. Terminal validation releases evidence + handles, atomically renames `active-v2` to a transaction-bound settled + tombstone, proves active admission absent, and only then makes deletion + best-effort. A retained tombstone is a successful but explicitly surfaced + cleanup warning, never hidden evidence loss. Preservation is armed before + mutation and survives exceptions, process failure, power loss, and + reboot-required SetupAPI returns. + The allocation-free exception outcome separately tracks whether transaction + mutation actually started: pre-mutation exceptions remain exit 4 with + `changed=0`, while post-mutation exceptions require exit 3 reconciliation. +5. Only after exit 0 or 3010 does cleanup revalidate and delete the exact service, + credential, broker log, and installer-owned broker images. Deletion uses the + retained file identities rather than a second untrusted path lookup. It does + not recursively delete either managed directory, and it never enumerates or + changes unrelated devnodes, Driver Store packages, files, scheduled tasks, + Run registrations, processes, or USB/IP state. A repeat after partial cleanup + safely reconciles exact protected leftovers; complete service/driver absence + is idempotent. If the uninstalling process is itself the exact locked broker + image and Windows will not mark the mapped image for immediate deletion, the + transaction proves that identity by volume/file ID, schedules only that + protected path with `MoveFileEx(..., MOVEFILE_DELAY_UNTIL_REBOOT)`, and folds + the result into exit 3010. + A retry that finds the exact service already marked for deletion waits under + the same transaction deadline, then continues from the proven-absent service + state and reconciles only retained exact files. + +This ordering follows Microsoft's separation between +[`DiUninstallDevice`](https://learn.microsoft.com/windows/win32/api/newdev/nf-newdev-diuninstalldevice), +which removes a selected devnode and its child topology, and +[`DiUninstallDriverW`](https://learn.microsoft.com/windows/win32/api/newdev/nf-newdev-diuninstalldriverw), +which removes a specified package from devices and then the Driver Store. Both +APIs return a `NeedReboot` result; the caller must aggregate that result while it +finishes its other required uninstall operations. VIIPER therefore preserves + 3010 only after exact owned cleanup has reconciled. The transaction follows + the generic Windows devnode-before-package lifecycle and root-bus ownership + model; no third-party package, registration, service, or cleanup convention is + treated as VIIPER ownership authority. + +## Reference-backed Windows invariants + +- The machine transaction lock uses a private namespace bounded to the local + Administrators SID and a protected SYSTEM/Administrators DACL. It then waits + on the mutex and owns it until `ReleaseMutex`; object existence is not lock + ownership, and `WAIT_ABANDONED` triggers a fresh inventory before mutation. + This follows Microsoft's [private namespace](https://learn.microsoft.com/windows/win32/sync/object-namespaces) + and [mutex wait](https://learn.microsoft.com/windows/win32/sync/using-mutex-objects) + contracts and prevents an unelevated process from pre-creating the machine + lock name. +- Native ABI health opens the UDE control interface for overlapped I/O. A + pending `DeviceIoControl` is waited only until the absolute transaction + deadline; timeout calls `CancelIoEx` and drains the exact `OVERLAPPED` before + rollback continues. This is the documented [overlapped DeviceIoControl](https://learn.microsoft.com/windows/win32/api/ioapiset/nf-ioapiset-deviceiocontrol) + and [CancelIoEx](https://learn.microsoft.com/windows/win32/fileio/cancelioex-func) + lifetime rule. +- SetupAPI upgrade and rollback preserve the captured root device instance ID. + Upgrade add-only stages the exact candidate before broker quiescence, then + performs an exact selected-driver switch on the captured devnode. Root + creation is needed only when no prior root exists. Per + [`SetupDiCreateDeviceInfoW`](https://learn.microsoft.com/windows/win32/api/setupapi/nf-setupapi-setupdicreatedeviceinfow), + forward creation passes the VIIPER-owned `VIIPERUDE` device name with + `DICD_GENERATE_ID` and verifies the returned `ROOT\VIIPERUDE\####` identity. + Rollback recreation omits `DICD_GENERATE_ID`, making `DeviceName` the complete + captured instance ID. It accepts only that namespace or the exact legacy + `ROOT\USB\####` form produced when older builds incorrectly passed the USB + class name, after the existing service/package ownership proof. The helper + then verifies the restored identity, topology, and signed package hashes rather + than deleting every matching devnode and manufacturing a replacement. +- The root-enumerated bus owns its exact child identities and separates + user-mode submission from PnP mutation. The configured legacy transport + remains untouched until authenticated native health succeeds; package + rollback never treats its service or Driver Store packages as installer-owned. + +Every public native install, repair, or uninstall takes locks in the same +machine-wide order: package mutex, then broker-service mutex. During install, +the outer transaction retains both across any required broker stop and driver +replacement, then performs an explicit service-lock handoff to the nested +broker commit while continuing to own the package mutex. The nested callback +does not reacquire the package mutex (which would deadlock); the protected +one-time token and zero-time ownership check authorize that one service +transaction. Once handoff occurs, an unsettled helper exit closes but preserves +the exact token so journal replay can prove the original outer identity. It is +deleted only after exact rollback or completed two-phase forward settlement and +is inert without the matching package-mutex owner. A later outer package run +may reconcile that old transaction under the same package-to-service lock order +but must return retry instead of beginning a new child in the same admission. +Because Win32 mutexes are thread-owned, each Go acquisition pins its goroutine +to that OS thread until the matching release; scheduler migration cannot strand +either global lock. + +## Restart boundary + +The normal newer-package path add-only stages and verifies the candidate before +quiescence, then switches only the captured root in place. Every SetupAPI return +that requests a restart is durably recorded with the boot identifier that +produced it before control can leave the mutation boundary. On that same boot, +reconciliation returns the pending restart without repeating the mutation, +starting the broker, removing legacy ownership, or retiring recovery evidence. +The production composition attempts exact driver rollback when a forward +activation requests a restart; if restoring the prior binding also needs a +restart, the journal enters `RestoreRebootPending` and the prior broker remains +stopped. A changed forward state that has not completed broker settlement is an +unsettled reconciliation result, never a success-shaped 3010. + +After Windows crosses the recorded boot boundary, the helper treats current +root/package/service state as authority and the protected journal as the narrow +ownership receipt. It revalidates exact bytes, topology, phase history, and +reboot epoch before finishing forward, finishing rollback, or latching manual +reconciliation. A new install cannot start while `active-v2` or a pending +cross-journal broker settlement exists. Only an exact terminal state is +atomically retired; rerunning the signed installer then starts a fresh +transaction if the requested update still remains. + +Removal uses the same rule. Device and package API returns record their fresh +restart bit and generating boot before any subsequent step. Same-boot recovery +does not repeat a pending device removal or mutate packages. After a later boot, +the raw root namespace and exact package inventory must prove either the +expected removed prefix or the exact rollback state before work continues. +Forward 3010 authorizes only the cleanup associated with that exact committed +removal; rollback 3010 preserves the prior managed files and leaves the service +stopped until restoration settles. A crossed restart that still exposes an +indeterminate pending root, any extra package/root, or any mismatched epoch +latches manual reconciliation instead of requesting restart forever. Cleanup +failure is reported separately and never turns a partial uninstall into +terminal success. + +## Deterministic gates + +The normal Go suite and compiled helper self-test run a failpoint matrix for +every driver, broker, cross-journal settlement, rollback, reboot, and retirement +phase. Coverage includes partial protected preparation, every atomic record +publication cut, authenticated-health failure, child exit before parent proof, +both sides of the settlement acknowledgement, final-receipt publication, +active-to-settled and settled-to-discarding renames, caller cancellation, +rollback failure, and retained cleanup. Source contracts require immutable +input locks, read-only helper verification, protected ACLs, exact service +ownership, atomic image publication, exact package/root mutation, nested token +binding, global lock ordering, and both-journal authenticated proof. They reject +hard process termination, context-killed helper processes, in-place recursive +deletion of authoritative evidence, or direct legacy-transport removal in the +outer layer. + +The removal matrix independently covers both mutex acquisitions, immutable +preflight, service inventory, partial stop, helper launch/outcome, exact cleanup, +restore failure, close failure, every 3010 cut and boot epoch, structured +preflight, verified and unverified rollback, malformed proof, concurrent root or +package appearance, idempotent absence, evidence-lock release, tombstone cleanup +warnings, and exact ownership. The targeted matrices are also run repeatedly to +catch state leakage and ordering regressions. diff --git a/docs/architecture/native-udecx-signing.md b/docs/architecture/native-udecx-signing.md new file mode 100644 index 00000000..7ce7573f --- /dev/null +++ b/docs/architecture/native-udecx-signing.md @@ -0,0 +1,198 @@ +# Native UDE driver signing and release contract + +The native VIIPER bus is a kernel driver. Shipping an Authenticode-signed EXE +does not make the driver loadable on modern Secure Boot Windows. A production +package must be signed by Microsoft through Hardware Dev Center. + +## Supported release paths + +### Local development: WDK test signing + +The manually dispatched native workflow can publish one compact, +seven-day-retention `LocalTest` artifact for the exact source SHA. It contains +the WDK test-signed INF/SYS/PDB/CAT evidence, an exact three-file runtime driver +directory, the source-bound broker/helper and live probes, the exported test +certificate, and a closed SHA-256 lock. Installation requires explicit +disposable-machine acknowledgement, elevation, the exact source revision and +interactive-user SID, and a current boot entry reporting `TESTSIGNING Yes`. +It imports only the artifact-bound certificate and then executes the normal +package-to-service transaction through `viiper.exe native-package-install`; +the helper is never invoked as a standalone mutation. Authenticated ABI 1.14, +capability, package-version, and loaded-kernel identity health must succeed +before the transaction commits. + +This route is deliberately non-release (`releaseEligible=false`, +`signingRoute=LocalTest`). It cannot satisfy controlled-attestation or +production validation, is never consumed by release composition, and does not +change the requirement that even test-mode 64-bit drivers carry a valid test +signature. + +### Controlled testing: attestation signing + +Microsoft now documents attestation signing as **testing-only**. An +attestation-signed package is not Windows Certified and is not a supported +retail release path. It can be used only in Microsoft's documented controlled +testing scenarios (for example, CoDev or Test Registry Key / Surface SSRK), and +it does not support Windows Server 2016 or later. It must never be shipped as +the public VIIPER driver. + +1. Build the exact x64 Release driver, INF, PDB, and catalog. +2. Run `native/udecx/tools/New-ViiperUdeAttestationPackage.ps1` with explicit + paths to those four artifacts and `-AcknowledgeTestingOnly`. The script + validates the INF contract, + creates the required non-root `ViiperUde` folder in the CAB, re-extracts the + CAB, verifies every SHA-256 hash, and writes a sidecar hash manifest bound + to the required source revision. +3. Sign the CAB with a SHA-256 code-signing certificate registered to the + organization's Hardware Dev Center account. Establishing that account and + submitting attestation packages requires a currently valid EV certificate. +4. Submit the signed CAB through the applicable Partner Center testing flow. +5. Download Microsoft's returned package and run + `native/udecx/tools/Test-ViiperUdeSignedPackage.ps1`. It requires valid + Microsoft kernel-policy signatures on both the SYS and catalog, proves the + INF and SYS are members of that exact catalog, requires the testing-only + attestation EKU, binds the unchanged INF/PDB to the source-revision sidecar, + and requires both WHQL-aligned and Universal INF verification. +6. Hash-lock only that validated Microsoft-signed package into the installer. + +The structural CAB produced by CI is not installable production media. It has +not been EV-signed, submitted to Microsoft, or returned with Microsoft's +signature. Even a Microsoft attestation-signed result remains a controlled-test +artifact under the current Microsoft contract. CI names it accordingly and +never promotes it as a release driver. + +### Production certification: HLK/WHCP + +HLK/WHCP is the only VIIPER production target. Microsoft recommends HLK-tested, +dashboard-signed drivers for release; WHCP is required for retail Windows +Update publication. Run the controller and child devices through the +applicable Device Fundamentals, USB, HID, audio, power, reliability, and +security playlists, submit the resulting HLKX package, and validate the +dashboard-signed result with the same local validation script in `Production` +mode. That mode rejects the attestation EKU and requires a release-eligible +`HLK/WHCP` evidence manifest bound to the reviewed source revision. + +## Package invariants + +- The CAB has no files at its root. Its only driver folder is `ViiperUde`. +- The package contains exactly one `ViiperUde.inf`, `ViiperUde.sys`, + `ViiperUde.pdb`, and `ViiperUde.cat` selected by explicit path. +- The INF targets only `ROOT\VIIPER\UDE`, copies only `ViiperUde.sys`, and + names only `ViiperUde.cat`. +- The schema-2 submission manifest identifies the exact reviewed bits and the + SHA-256 build identity derived from source revision, four-part DriverVer, + ABI 1.14, and the exact capability mask. That same identity is compiled into + the SYS that the signed catalog seals and is returned by the loaded kernel. +- Returned packages contain only the canonical INF, SYS, PDB, and CAT in one + directory. The unchanged INF/PDB must match the submission manifest, and + SignTool must prove INF/SYS membership in the returned Microsoft catalog. +- PDB is certification evidence, not a runtime dependency. The public native + archive contains exactly the release `viiper.exe` broker, + `ViiperUdeCtl.exe`, INF, SYS, CAT, and the validated submission manifest. + Release composition rejects every missing or additional file. +- Local-test, controlled-test, and production signatures are separate + validation modes; neither a local certificate nor an attestation EKU can + satisfy the production release gate. +- Test certificates, test-signing state, or disabled Secure Boot are never a + release prerequisite. +- The production installer refuses an unsigned, test-signed, mismatched, + downgraded, or non-Microsoft driver package before any driver-store mutation. +- Updating a live kernel package remains a reboot-safe transaction; it is not + overwritten in place. + +## Current release gate + +Feature branches, `main`, release tags, and the public release workflow all run +the native compile, static-analysis, ABI/lifecycle, fuzz, race, stamped-INF, +package-transaction, helper rollback/update/removal, and deterministic package +checks. Driver package source changes must strictly increase the four-part +`DriverVer` without regressing its date; a release also compares against the +previous SemVer tag. The `viiper uninstall` command now hash-locks and invokes +the helper's exact root-devnode/Driver Store removal transaction under the +package-then-service lock order, and its deterministic gates cover structured +success, reboot-success, preflight, verified rollback, indeterminate rollback, +and exact owned cleanup. Live uninstall on the Microsoft-signed package remains +part of the external acceptance matrix below rather than an implementation gap. + +Production driver acceptance is separate and manual because Microsoft signing +is external. The intake workflow must run from the exact current `main` commit, +downloads one artifact by immutable run ID, artifact ID, and SHA-256 digest, +and validates the Microsoft-returned INF/SYS/PDB/CAT package in literal +`Production` mode. It rejects test signatures and the attestation EKU, verifies +catalog membership, validates the actual returned stamped INF against the +reviewed project, and publishes one source-named accepted artifact. + +A tag release must point to the current `main` tip and cannot publish without a +successful production-intake run at that same commit. It downloads only that +accepted artifact plus the current release broker and source-built helper. A +mandatory Windows job signs both broker architectures and the helper with the +configured production Authenticode certificate, requires a trusted timestamp, +the exact certificate SHA-256 fingerprint, and Code Signing EKU, then validates +the exact six-file runtime bundle before and after archiving. Publication +consumes only those signed outputs, discards the certification PDB, and +allowlists every public release asset before checksumming and attesting it. The +test-signed CI artifact is never a release input. + +These automation gates do not manufacture certification evidence. A native +driver is not production-ready until the external HLK/WHCP dashboard-signed +package also passes Driver Verifier, the complete HLK matrix, repeated live +install/update/rollback/uninstall, process crash, sleep/resume, and +multi-controller soak on a disposable test machine. + +The first repeatable signed-driver gate is +`native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1`. It validates the +Microsoft-returned package, requires the installed service image to have the +same SHA-256 hash, requires exactly one Microsoft-signed root devnode, and then +runs the opt-in Windows integration test against Xbox 360, DualShock 4, +DualSense, DualSense Edge, and Switch 2 Pro production descriptors. Every +generation must enumerate, complete direct interrupt-input reports, tear down +to zero active devices and pending operations, and leave all protocol/fault +counters unchanged. The script does not install a driver or enable Driver +Verifier. It also kills a subprocess that owns an enumerated DualSense and +requires kernel file cleanup to remove the child, drain pending operations, +release exclusive ownership, and accept a fresh session. Driver Verifier +remains an explicit disposable-machine operation. The companion +`Enable-ViiperUdeVerifierForNextBoot.ps1` script first repeats the signed +package/service hash binding, refuses to replace verifier settings for another +driver, and stages Microsoft's standard checks for `ViiperUde.sys` in +`oneboot` mode. After the restart, live validation with +`-RequireDriverVerifier` refuses to run unless `verifier /query` proves the +driver is actually being verified. Neither script restarts the computer. +The optional `ViiperUdeMediaProbe.exe` gate snapshots active CoreAudio endpoints +before enumeration, requires exactly one newly-active render/capture pair for +DualShock 4 and DualSense, drives both simultaneously through event-mode WASAPI, +and requires the driver's ISO packet, OUT-byte, and IN-byte counters all to +advance. This distinguishes a visible-but-nonfunctional audio endpoint from a +working full-duplex bus and avoids confusing an already-connected physical pad +with the newly-created virtual one. +The opt-in root-restart gate uses Microsoft's `pnputil /restart-device` only +against the one package-verified VIIPER instance ID and only on an explicitly +acknowledged disposable machine. It keeps a DualSense child and direct-input +publisher active across removal, requires the old owner session to terminate, +then hash-preserving PnP restart must expose a clean controller that accepts a +new exclusive owner, re-enumerates the child, services input, and drains to +zero again. Windows 10 1809 remains a supported runtime target, but this +particular automated gate requires Windows 10 2004 because that is when +Microsoft added `pnputil /restart-device`; 1809 power/PnP coverage belongs in +the HLK/DevFund matrix. + +## Primary Microsoft references + +- [Driver code-signing requirements](https://learn.microsoft.com/windows-hardware/drivers/dashboard/code-signing-reqs) +- [TESTSIGNING boot configuration](https://learn.microsoft.com/windows-hardware/drivers/install/the-testsigning-boot-configuration-option) +- [Install a test-signed driver package](https://learn.microsoft.com/windows-hardware/drivers/install/how-to-install-test-signed-driver-for-setup-and-boot) +- [Verify a test-signed catalog](https://learn.microsoft.com/windows-hardware/drivers/install/verifying-the-signature-of-a-test-signed-catalog-file) +- [Attestation-sign Windows drivers](https://learn.microsoft.com/windows-hardware/drivers/dashboard/code-signing-attestation) +- [Driver-signing options and best practices](https://learn.microsoft.com/windows-hardware/drivers/dashboard/driver-signing-offerings) +- [Components of a driver package](https://learn.microsoft.com/windows-hardware/drivers/install/components-of-a-driver-package) +- [Catalog files](https://learn.microsoft.com/windows-hardware/drivers/install/catalog-files) +- [Release-signing a driver package catalog](https://learn.microsoft.com/windows-hardware/drivers/install/release-signing-a-driver-package-s-catalog-file) +- [INF Version section](https://learn.microsoft.com/windows-hardware/drivers/install/inf-version-section) +- [SignTool command-line reference](https://learn.microsoft.com/windows-hardware/drivers/devtest/signtool) +- [Windows Hardware Lab Kit](https://learn.microsoft.com/windows-hardware/test/hlk/) +- [Add driver and supplemental content to an HLK package](https://learn.microsoft.com/windows-hardware/test/hlk/user/add-driver-and-supplemental-content-to-your-package) +- [INF DriverVer directive](https://learn.microsoft.com/windows-hardware/drivers/install/inf-driverver-directive) +- [InfVerif `/h`](https://learn.microsoft.com/windows-hardware/drivers/devtest/infverif_h) +- [Driver Verifier](https://learn.microsoft.com/windows-hardware/drivers/devtest/driver-verifier) +- [Driver Verifier command syntax](https://learn.microsoft.com/windows-hardware/drivers/devtest/verifier-command-line) +- [PnPUtil command syntax](https://learn.microsoft.com/windows-hardware/drivers/devtest/pnputil-command-syntax) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md new file mode 100644 index 00000000..15bd522d --- /dev/null +++ b/docs/architecture/native-udecx.md @@ -0,0 +1,747 @@ +# Native VIIPER UdeCx bus + +## Objective + +Replace VIIPER's localhost USB/IP attachment path on Windows with a native +KMDF/UdeCx bus while retaining the existing, tested Go controller engines. +The native bus must expose the same HID, audio, microphone, isochronous, state, +and feedback contracts without a TCP loopback or an external USB/IP driver. + +The first correctness target is feature parity. Performance work follows only +after transfer ordering, cancellation, teardown, and recovery are proven. + +## Evidence and reference points + +- Microsoft's UdeCx contract owns USB device creation, endpoint queues, reset, + start, purge, and power lifecycle. Purge is asynchronous: pending work must be + cancelled before `UdecxUsbEndpointPurgeComplete` is called. +- Released UdeCx behavior demonstrates that Windows can expose VIIPER's + bidirectional isochronous PlayStation audio topology. Its normal-response URB + completion behavior also supports using a real WDF DPC for every terminal + path; the Microsoft UDE contract remains the authority for completion IRQL. +- The controller supports chained MDLs plus high-speed and SuperSpeed devices + on separate USB 2 and USB 3 ports. UdeCx owns the mandatory + post-enumeration child reset; configuration replacement enters VIIPER's + generation-owned lifecycle stream only after Windows finishes enumerating + the child. +- The lifecycle model uses explicit protocol negotiation, handle-scoped + ownership, bounded manual queues, cancel-safe requests, generation-aware + target teardown, and per-target synchronization rather than one global lock. + +Reference code is used for architecture and documented protocol behavior. New +VIIPER code is independently named and implemented. See +`native/udecx/THIRD_PARTY_NOTICES.md`. + +## Architecture + +```text +DS4Windows + | existing VIIPER API +VIIPER Go service + | usb.Device controller engines (unchanged) +native UDE broker + | versioned IOCTL ABI, overlapped inverted calls +VIIPER UdeCx KMDF driver + | UDE endpoint queues and lifecycle +Windows USB/HID/Audio stacks +``` + +The Go service remains the controller model. It already owns descriptors, +control requests, HID reports, audio media, alternate settings, endpoint reset, +and the state rules learned while stabilizing DualSense and DualShock 4. +The kernel driver owns only Windows USB presentation and transfer lifecycle. + +## Non-negotiable invariants + +1. Every device is owned by exactly one open broker handle. +2. Every device identity includes a monotonically increasing generation. +3. Every endpoint incarnation includes a monotonically increasing generation; + an address reused within one device generation cannot inherit requests, + publications, workers, cancels, or completions from its predecessor. +4. Every operation token completes exactly once or is cancelled exactly once. +5. A completion from an old device or endpoint generation is rejected without + touching a replacement that reused the numeric identifier or address. +6. Purge stops admission, cancels queued and in-flight work, waits for ownership + to settle, then acknowledges UdeCx. +7. Driver unload and file cleanup leave no UDE device, request, or worker alive. +8. Endpoint queues are bounded. The broker enforces both its controller-wide + ceiling and each child's negotiated pending-operation quota, so one busy + media device cannot starve another controller. Saturation is observable and + never overwrites live media or state silently. +9. Shared report state is snapshotted atomically before encoding. Media and + state never share mutable buffers. +10. No raw user pointer crosses the ABI. +11. The ABI is size- and version-negotiated before any mutating operation. + A revision mismatch has a distinct status that directs the service or + installer to the exact matching native-driver package. The service also + recognizes the parameter/length errors returned by native previews from + before that distinct status existed, so an upgrade cannot strand ABI 1.7. + ABI 1.9+ additionally returns the 32-byte identity compiled into the loaded + kernel image: SHA-256 over the canonical source revision, driver-package + version, ABI, and exact-capability tuple. The broker binary and schema-2 + accepted-package manifest derive the same value from their protected build + inputs. A stale + loaded image is rejected even when its on-disk replacement, ABI, and + capability mask otherwise look correct. +12. Every packed wire structure has a compiler-independent size guard. ABI + 1.13 carries endpoint generation in the 108-byte operation, 72-byte + completion, and 52-byte input-report records; the completion's final + 32-bit word remains explicitly reserved. Sizes never depend on compiler + tail padding, and every field offset is guarded so a same-size reorder + cannot silently desynchronize the C and Go layouts. +13. ABI 1.14 returns a fixed 40-byte receipt only after the exact + `UdecxUsbDevicePlugIn` succeeds. The receipt echoes device identity and + speed and carries the authoritative USB 2 or USB 3 controller port. User + mode binds it to the exact controller instance obtained from the opened + SetupAPI interface and to that file session's nonzero negotiated driver + nonce. Authenticated add/list/ping responses expose these values as an + opaque ownership tuple; consumers must not infer ownership from VID/PID, + enumeration order, or stream generation. + +## Kernel/user transport + +The transport is intentionally split by USB semantics: + +- interrupt-IN input reports use a manual-queue fast path. The Windows poll + stays parked in the endpoint queue; one versioned `SUBMIT_INPUT_REPORT` call + completes a waiting URB without an allocation or broker round trip. ABI 1.10 + classifies newly queued controller states separately from deadline-generated + cadence snapshots. ABI 1.13 additionally binds every report, operation, + cancel, completion, publisher, and worker to the exact endpoint incarnation. + Each endpoint holds a bounded preallocated transition + FIFO and one latest-state snapshot: Windows consumes every accepted edge in + order, while idle 1 ms DS4/DualSense reports update only the snapshot and + cannot crowd edges out. The passive ready callback copies one report directly + and transfers terminal ownership to the required completion DPC. The Go + publisher allocates one descriptor-sized buffer at endpoint start. + Each active publisher also owns one reusable service-deadline timer. The + controller receives that timer's channel separately from the lifecycle + context, so an idle deadline still replays cached state while purge, reset, + D0 exit, and owner shutdown still cancel the read. This preserves the exact + DualSense/DualShock report builders, counters, and sensor timestamps while + removing the timer-backed context allocation from every service interval. + The serial overlapped IOCTL copies the report before that buffer is reused, + eliminating per-sample Go heap work without shared-memory lifetime hazards. + Input timing is therefore host-poll-driven rather than dependent on a second + physical report arriving after the poll; +- control, interrupt-OUT, isochronous speaker/microphone/haptics, feedback, and + every lifecycle transition use the cancel-safe ordered inverted-call broker. + VIIPER posts multiple `DEQUEUE_OPERATION` requests, processes each immutable + operation through the existing `usb.Device` interface, then submits + `COMPLETE_OPERATION`. Native microphone engines encode directly into the + host URB's packet regions at each reserved USB service point. They neither + allocate a packet nor create a per-packet timer; an unavailable source frame + becomes the legal nominal zero packet immediately. The adaptive PCM buffer + also observes the actual capacity reserved for each URB. If Windows reserves + only nominal capacity, a pending long clock-correction packet remains owed + instead of being consumed and silently truncated. The USB/IP microphone path + retains its existing allocation and timeout ownership contract. + +The input counters intentionally measure opposite sides of the fast path: +`InputReportsSubmitted` counts accepted publications, while +`InputReportsCompleted` counts Windows interrupt-IN polls. Idle publications +may coalesce into the latest snapshot, but transition publications do not. + +Microsoft's UDE programming guide and host-controller I/O guide require URB +completion at `DISPATCH_LEVEL` for USB-client compatibility. They additionally +require synchronously handled URBs, `EvtIoCanceledOnQueue`, and request-cancel +paths to complete from a separate DPC. The individual +`UdecxUrbComplete`/`UdecxUrbCompleteWithNtStatus` API pages conflict with that +guidance by listing `PASSIVE_LEVEL`; the current WDK declarations carry no IRQL +SAL annotation that resolves the conflict (verified against the project's +pinned WDK 10.0.28000.1839). VIIPER follows the UDE-specific +compatibility rule because it explicitly covers terminal and cancellation +behavior and agrees with released UdeCx behavior. VIIPER uses a real WDF DPC +and never synthesizes an IRQL raise. + +One preallocated controller WDF DPC is the only function that calls either +UdeCx URB completion API. A request-context intrusive queue holds request and +endpoint references without per-transfer allocation. Broker replies, +cancellation, admission rejection, and direct interrupt-IN all claim exactly +one queue entry. Every endpoint queue registers `EvtIoCanceledOnQueue`, +overriding KMDF's synchronous default so a URB canceled before dispatch follows +the same path. The DPC runs at asserted `DISPATCH_LEVEL`, makes the terminal +call, then retires the broker slot or direct endpoint operation and signals the +drain event. Teardown closes admission, waits the tracked broker count, joins +the completion count, and uses `WdfDpcCancel(..., TRUE)` only after the list is +empty; a canceled pre-dispatch invocation is re-armed rather than abandoned. + +Buffer lookup, validation, copies, and user-mode publication remain on the +explicitly passive queues and work items. A late cached poll still crosses the +preallocated endpoint work-item boundary because `WdfIoQueueReadyNotify` can +run inline on the UdeCx submitter thread; that work item consumes one cache +token and prepares the buffer, then transfers terminal ownership to the shared +DPC. No payload, endpoint ordering, or isochronous scheduling behavior changes +at this execution-level boundary. + +Input publishers start and stop from UdeCx endpoint lifecycle notifications, +retain their sequence across a purge/start cycle, and are cancelled before +device removal. Removal rejected before UdeCx takes the child restores the +active publishers, so a retry does not strand the current generation. Once +ownership transfers, a terminal UdeCx removal fault restarts the controller +and the removed generation remains closed. + +Dynamic endpoint cleanup leaves an address-scoped retirement tombstone for +the current device generation. This lets the kernel acknowledge and discard +the final admitted input report that can cross asynchronous cleanup before +user mode consumes the ordered purge event, without accepting reports for an +endpoint that was never configured. + +The broker owner session is deliberately one-shot. Stopping the user-mode host +cancels endpoint lanes that may already own dequeued kernel requests; those +requests cannot be reconstructed safely in a restarted goroutine. VIIPER must +close that driver handle and negotiate a fresh `Client`/`Host` session. It never +guesses a new endpoint sequence baseline or risks abandoning a USB request from +the retired file session. + +Session shutdown owns a cancellation context before `Serve` is scheduled, so +even an immediate stop cannot miss host cancellation. The client waits for all +dequeue workers, endpoint lanes, input publishers, and their completions to +finish before cancelling overlapped kernel I/O and closing the exclusive broker +handle. The handle is therefore always the last object released. + +This deliberately removes TCP, WSK, USB/IP framing, and attach bookkeeping. +The direct input lane removes the highest-frequency HID broker path without +mixing report ownership into the proven PlayStation media/state transport. +The authenticated DS4Windows-to-service API which feeds that lane keeps its +wire format but reuses one bounded receive slab per connection and decrypts +records in place before copying into the caller's buffer. Full-duplex access +uses independent read/write locks; concurrent writers are serialized into +whole records. Client and server records use separate 32-bit nonce domains and +monotonic 64-bit counters; receivers enforce both the direction and exact next +counter. A partially emitted record closes the now-unrecoverable stream instead +of permitting a corrupt retry. +Once correctness gates pass, high-rate media payloads may move to a +preallocated ring while keeping the same token/generation lifecycle. Control +and lifecycle operations remain IOCTL based. + +### Operation identity + +Every operation carries: + +- device ID and generation; +- a globally unique token for that generation; +- endpoint address, endpoint-incarnation generation, and transfer direction; +- endpoint attributes, interval, and maximum packet size copied from the + UdeCx endpoint descriptor; +- operation kind and URB function; +- transfer flags, setup packet, and start frame where applicable; +- ordered isochronous packet metadata; +- a bounded payload. + +### Device creation + +VIIPER serializes the exact descriptor set returned by the controller engine: +device, configuration, BOS, language/string records, and device-speed policy. +The driver validates all offsets and lengths before constructing a UDE device. +Descriptor normalization required by UdeCx is a named policy, not an implicit +mutation: high-speed bulk packets are 512 bytes and interval conversion is +covered by descriptor tests. The reserved Microsoft OS 1.0 `0xEE` string is +published explicitly when a controller exposes one, preserving WinUSB binding +for vendor interfaces such as the Switch 2 Pro path. + +## Lifecycle + +```text +Absent -> Creating -> Enumerating -> Active + | | + v v + Failed <- Purging -> Removed +``` + +- **Creating:** validate ABI, descriptors, limits, and owner handle. +- **Enumerating:** create UDE device/endpoints and plug it into UdeCx. +- **Active:** accept transfers and endpoint lifecycle notifications. +- **Purging:** close admission, cancel all tokens, drain queues, acknowledge + endpoint purge, and invalidate the generation. +- **Removed:** delete the UDE object and release all references. + +Power loss, owner process exit, DS4Windows restart, VIIPER restart, and explicit +unplug all converge on the same idempotent purge path. + +### Composite alternate-setting identity + +UdeCx can report unreliable `InterfaceNumber` and `NewInterfaceSetting` values +for composite-device alternate-setting changes. VIIPER does not install a +system-wide root-hub upper filter to compensate. + +Every endpoint callback already supplies the authoritative endpoint descriptor. +The kernel copies its address, attributes, interval, and maximum packet size +into the versioned broker operation. User mode matches that complete signature +against the immutable controller descriptor and derives the owning interface +and alternate setting. Endpoint start activates it; purge returns it to zero +only after the last endpoint belonging to the active alternate is gone. The +first ISO URB is also authoritative activation, closing the cross-worker race +where media reaches user mode before its start notification. Numeric UdeCx +interface fields are only hints for alternates that contain no endpoints. + +### Broker service migration transaction + +Windows native mode is owned by the `VIIPERNativeBroker` Service Control +Manager service, never by an HKCU Run entry or tray process. Installation and +update use one machine-wide mutex and the following fail-closed transaction: + +1. Resolve Program Files and ProgramData through Windows Known Folder APIs. + Resolve one target interactive-user SID before the first mutation and use + that same identity for the credential ACL and every legacy HKU/task/process + operation. An elevated caller may supply the bootstrapper-origin SID; + otherwise VIIPER proves the shell or active-console token and fails closed + when no unambiguous interactive user exists. + Native installation is accepted only from the managed + `Program Files\VIIPER\viiper.exe` or + `Program Files\DS4Windows\VIIPER\viiper.exe` layout. Every component is + opened as a non-reparse point and retained without delete sharing through + authenticated startup. The executable and credential must each have one + hard link, and their retained file handles also deny write sharing. Every + managed directory and the PE executable must already carry the exact + protected, administrator-owned package ACL before the service command will + register the executable as LocalSystem code. The command validates and + retains those objects read-only; it never treats an in-place ACL rewrite as + proof because that cannot revoke handles opened under an older weak ACL. +2. Provision a freshly rotated, nonempty credential under + `%ProgramData%\VIIPER` only after every prior broker owner is stopped. An + existing value is retained solely for rollback and is never trusted as the + new secret, preventing a standard user from pre-seeding a known key. The directory + is held open without delete sharing while the key is staged and published + with `MoveFileExW(REPLACE_EXISTING | WRITE_THROUGH)`. Its protected DACL + grants full control to SYSTEM and built-in administrators and read access + to the installing user's SID; no localized account name is parsed. +3. Snapshot the prior service configuration, stable running state, failure + actions, SCM object owner/DACL, and legacy startup commands. The prior + service executable is parsed from the SCM command line, validated against + the already-protected managed-file ACL contract without mutating it, and + every path component remains locked against replacement until commit or + exact rollback. Before a LocalSystem command is installed, the service object is + required to already have a protected DACL granting control only to SYSTEM + and built-in administrators. The transaction rejects a permissive existing + service rather than relying on a DACL rewrite that cannot revoke previously + opened service handles; rollback restores and verifies the exact prior + descriptor before any prior service restart. Task Scheduler enumeration is + fail-closed and distinguishes a missing root task from provider/access + failure. Stop the old SCM instance and the exact snapshotted scheduled-task + instance before considering residual HKU Run processes. Only processes + whose full executable path and token-user SID match the target registration + are terminated. Process handles remain open from identity verification + through termination, preventing PID-reuse and cross-user mistakes. +4. Negotiate the packaged native driver, then create or update an automatic, + own-process LocalSystem service with explicit `native-ude`, credential, and + log arguments. Arguments are escaped with Windows command-line rules rather + than passed through a shell. The few legacy Task Scheduler operations use + the absolute, non-reparse system PowerShell path rather than process `PATH`. +5. Apply bounded recovery (two restart attempts followed by no action), start + the service, and require an authenticated ping proving `Ready=true`, exact + native transport, ABI, and negotiated capabilities. The broker's current + package-version field is compile-time metadata rather than installed-driver + attestation and is intentionally not treated as verification. +6. Only after that proof, compare-and-remove the legacy HKCU registration. The + exact `HKU\` hive and existing Run key handles stay open for the entire + transaction, preventing logoff/unload from turning rollback into an orphan + registry subtree. Run ownership is compared as data plus `REG_SZ` versus + `REG_EXPAND_SZ`; both originally present and originally absent Run/task + states are CAS-checked immediately before commit. Task names are matched + with Task Scheduler's case-insensitive identity rules. The + exact legacy scheduled task stays registered but disabled: exported task XML + omits its registered ACL and cannot recreate Password-logon credentials, so + delete/re-register would not be an exact transaction. Disable, stop, wait, + and validation occur in one bounded provider operation; rollback re-enables + only the same retained task. Task XML is transported as explicit UTF-8 + through an ASCII base64 envelope. Re-authenticate the broker after legacy + ownership is disabled, closing a restart race. The service command runs + without a tray in session 0. + +Any failure receives a fresh rollback deadline: a newly created service is +stopped and deleted, or the previous configuration, recovery policy, and +running state are restored and verified before the prior service can restart. +The credential rollback uses the same atomic publication path. A legacy process +is restarted only when it was actually running before migration: scheduled-task +processes are restarted by Task Scheduler in their original security context, +and HKCU processes use the interactive shell token rather than the elevated +installer token. Full scheduled-task XML is restored after partial removal. +Uninstall holds the same mutex across service, startup-registration, and process +cleanup. It resolves and snapshots every target before mutation, stops the +service and exact legacy owners, compare-removes HKU Run ownership while +retaining any exact scheduled task disabled, and marks +the service for deletion only as the final fallible operation. If anything +before successful deletion fails, it restores registrations, the exact service +configuration/recovery policy/stable state, and only the legacy owners that +were previously running. Every Task Scheduler subprocess is context-bounded so +a wedged provider cannot retain the installer mutex indefinitely. + +## Synchronization model + +- Separate controller locks protect the device table and broker-owner + registration; the broker spin lock is the operation-admission boundary. +- Broker file callbacks explicitly run at `WdfExecutionLevelPassive`, matching + their wait-lock, synchronous-queue-purge, and pageable cleanup operations. +- Every UdeCx USB-device and endpoint object explicitly requests + `WdfExecutionLevelPassive`. Microsoft permits the device power/reset, + endpoint-configuration, start, purge, and reset callbacks at up to + `DISPATCH_LEVEL`, but VIIPER's callbacks create WDF/UdeCx objects and acquire + the embedded device-table `FAST_MUTEX`, operations whose contract is below + dispatch level. The KMDF controller default is dispatch execution, so relying + on inherited or presently observed callback context is not a valid safety + contract. +- Controller removal closes a single `ShuttingDown` admission gate in + `EvtDeviceSelfManagedIoCleanup`, while the controller's queues, timer, + completion DPC, locks, and broker storage are still valid. + Cleanup first joins any file cleanup that crossed the owner lock before the + gate, then purges user-mode queues, aborts every admitted broker operation, + and uses KMDF's preceding non-power-managed queue purge as its terminal + endpoint fence. While a shared device-index lock still pins every endpoint, + it requires `WdfIoQueueDriverNoRequests` and `ActiveOperations == 0` under + the broker lock. The former closes the callback-delivered/pre-first-driver- + instruction window; the latter joins forwarded work and its terminal DPC. + Queued host polls are deliberately not part of this predicate because UdeCx + owns those requests and issues the endpoint-purge transition while consuming + the child. Only after that proof does cleanup join tracked and untracked + completion counts and the final DPC, then revoke and consume UDE handles. + The final controller `EvtCleanupCallback` performs only invariant checks + because KMDF has already cleaned up child objects by then. +- UdeCx USB-device deletion remains asynchronous. Shutdown snapshots and + revokes each device under the embedded shared/exclusive push lock, invokes + `UdecxUsbDevicePlugOutAndDelete` after dropping the lock, and never waits for + child cleanup from the PnP cleanup callback. Embedding the push lock in the + controller context keeps endpoint/device cleanup independent of sibling WDF + child deletion order. +- Removal atomically revokes the UDE handle from the device table and retires + its logical active count before `UdecxUsbDevicePlugOutAndDelete`. `Devices[]` + is the sole slot-ownership table, so a slot can be reused after the consuming + UdeCx call returns even when KMDF defers the old child's object cleanup. No + path dereferences or restores that invalidated UDE handle. +- File cleanup first closes create/destroy admission, then joins only those + finite UdeCx API calls before removing the owner's remaining logical devices + and releasing the exclusive controller owner. Each retired child keeps its + own reference on the old file object until `EvtCleanupCallback`; that late + physical rundown cannot block a successor owner or clear a reused slot. +- Child teardown aborts every exact-device reset/configuration request before + consuming its UdeCx handle. A reset notification still queued for user mode + is retired in O(1) and emitted as a benign cancel; an already delivered reset + keeps only a `(token, device ID, generation, owner)` tombstone, so its late + acknowledgement cannot reopen or mutate a replacement child. Management-slot + reuse remains closed until that acknowledgement arrives or KMDF invokes the + old owner's `EvtFileClose`, the documented post-I/O boundary. No teardown + performs a global notification-ring scan or blocks unrelated controllers. +- A post-transfer UdeCx removal failure is terminal for the controller, not + retryable for the child. The kernel accepts the broker's removal request and + requests a PnP controller restart; user mode can retry only failures returned + before ownership reached UdeCx. +- Each device has a short-held state lock and independent endpoint queues. +- User mode serializes controller-engine lifecycle mutations as one complete + transaction. Endpoint reset cannot overlap an endpoint start/purge, device + reset, or alternate-setting change, while ordinary HID and media transfers + remain concurrent on their independent endpoint lanes. The serialization + object belongs to one `(device ID, generation)` session; a blocked reset on + one controller cannot stall lifecycle or media activation on another. +- Each endpoint owns a drain event covering both broker-forwarded URBs and the + direct interrupt-IN fast path. UdeCx itself owns and purges the framework + endpoint queue; VIIPER never starts or purges that queue. The purge callback + closes admission and cancels only the requests already forwarded into + VIIPER-owned paths. A passive work item only observes the associated queue: + `WdfIoQueueDriverNoRequests` proves that every WDF-delivered request has + returned to framework ownership, while the broker-lock rundown proves every + forwarded request and terminal DPC has released the endpoint. It does not + wait for UdeCx-owned queued host polls or for the queue's READY bookkeeping + to clear. Only then may the work item call + `UdecxUsbEndpointPurgeComplete`. A pipe can therefore never restart or + disappear across a live or pre-callback-delivery request, and the client + never mutates UdeCx-owned queue state. +- Endpoint reset and endpoint-configuration callbacks are asynchronous UdeCx + management requests, not notifications. ABI 1.10 preserves the + generation-bound management tokens introduced in ABI 1.8 and adds the + source-bound loaded-kernel identity to negotiation. Windows receives the + request completion only after the Go controller engine has applied the reset + or alternate-setting transition. Start, purge, and power notifications + remain unacknowledged and cannot add a media round trip. +- Endpoint reset owns a gate separate from endpoint purge. The UdeCx reset + callback closes both broker and direct-input admission under the broker lock, + cancels forwarded work, and defers its acknowledged lifecycle event until a + read-only queue sample reports `WdfIoQueueDriverNoRequests` and the endpoint + rundown reaches zero. Unlike purge, reset may leave a parked interrupt poll + queued and the queue ready. This weaker queue predicate is stable because the + UdeCx reset is asynchronous: the endpoint cannot process successor transfers + until VIIPER completes the reset request. User mode stops and joins that + endpoint's direct-input publisher before applying recovery. At owner + acknowledgement the kernel repeats the exact `(device ID, generation, + pinned WDF device/endpoint, reset epoch)` proof, clears only that live reset + gate under the admission lock, and immediately completes the UdeCx request. + Generic framework references prevent opaque handle recycling until every + management-slot terminal path has cleared the slot outside the broker lock. + Missing, purged, removed, or reused identities fail closed and receive no + stale reset publication or successor gate change. Reset never calls + purge-complete or waits for a later start callback, matching UdeCx's distinct + reset and purge contracts. +- Device configuration replacement closes direct input admission in the + kernel callback and pauses + every user-mode publisher before controller state is cleared. Every endpoint + first passes the same reset-specific driver-owned-request/rundown proof; if + purge or removal wins, the actual reset request fails without publishing a + dead generation. Successful device-reset admission also advances a private + 64-bit epoch. Endpoint reset admission captures that epoch, so a later device + reset deterministically supersedes every older endpoint reset even when user + mode acknowledges them out of order. Admission and active publishers reopen + only after a second exact-generation/object/epoch proof at acknowledgement, + so no HID snapshot or late terminal callback can cross the reset boundary. + Configuration replacement alone uses this one-child-at-a-time device gate; + concurrent reset transactions are rejected instead of interleaving two + controller resets. The mandatory post-enumeration reset stays entirely in + UdeCx, because an emulated descriptor has no backing physical reset to + coordinate and Windows cannot continue child enumeration while that reset is + waiting on user mode. Device initialization also completes synchronously. +- The controller's default KMDF queue is parallel and completes interrupt-IN + submissions directly. Mutation, broker, and lifecycle IOCTLs alone move to + the serialized control queue. This removes a redundant KMDF forwarding and + dispatch boundary from every fresh report while large media completions still + cannot head-of-line block controller input. +- Each fast interrupt-IN endpoint has its own passive lock. Different + controllers publish concurrently, while accidental concurrent submissions + for one endpoint cannot reorder reports or replay a coalesced sequence. +- If reports arrive before Windows posts its next HID poll, the passive + manual-queue ready callback copies the oldest transition (or latest idle + snapshot when no transition is pending) and hands + terminal ownership to the shared completion DPC. The required asynchronous + DISPATCH_LEVEL completion remains intact without an intervening system-worker + scheduling hop on first poll, resume, or idle recovery. +- A lost ordered lifecycle notification faults both the broker and the direct + interrupt-IN producer lane. Already-published broker completions remain + drainable, but no new controller state is admitted into a generation whose + power/reset history is no longer trustworthy. +- Endpoint start opens the kernel admission gate before publishing the ordered + start notification. The first fresh input snapshot after resume therefore + cannot consume its sequence against a still-purged kernel endpoint; the + callback itself is the single UdeCx restart boundary for both paths. +- Every published operation also carries a per-device publication sequence. + Endpoint lanes remain independent, but device-wide D0 transitions use that + sequence to reject a delayed pre-exit start notification. Multiple overlapped + dequeue workers therefore cannot resurrect an input publisher outside D0 or + consume a physical state snapshot behind a power boundary. +- Parallel media callbacks receive a per-endpoint admission sequence under the + broker lock. An URB cannot publish ahead of an earlier live unpublished + admission; cancellation retires the admission before dispatch resumes, so + the public endpoint sequence remains contiguous without limiting media to + one in-flight URB. +- Media callbacks do not take the controller lock. +- Transfer buffers obey both dimensions of the Windows USB contract: the URB + declares the total transfer length, while a pointer returned by + `UdecxUrbRetrieveBuffer` is used only within its separately reported mapped + span. Chained or short mappings fall through to a bounded MDL-chain walk; + the driver never treats the URB length as permission to overrun one mapping. +- Interrupt-IN queues are manual and completed from a generation-owned, + sequence-checked transition FIFO plus latest-state snapshot. The passive + queue-ready callback copies directly, then the shared DPC performs the only + terminal UdeCx completion. A synchronously replenished Windows poll drains at + most one queued transition and is otherwise left parked for the next + producer. Endpoint purge/reset and device reset/D0 exit invalidate the FIFO, + snapshot, and delivery token after closing admission, so no held button can + cross a lifecycle boundary. + Output and media endpoints retain independent ordered queues. +- A direct input report that was already submitted when D0 exit, device reset, + unplug, or endpoint purge begins is acknowledged and discarded at that exact + lifecycle boundary. The kernel closes admission in the UdeCx callback itself + rather than waiting for the user-mode notification. Stale generations and + replayed sequences remain hard failures, so normal teardown cannot fault the + exclusive broker session. +- UDE callbacks never wait on user mode while holding a WDF lock. +- Blocking work is represented by cancelable WDF requests, not sleeping kernel + threads. +- Every mark-cancelable transition revalidates its prior state under the broker + lock. If KMDF invokes cancellation before that lock is reacquired, the cancel + callback's DPC-completion ownership is final and cannot be overwritten + by admission or publication. +- Broker dequeue validation, wait-count admission, and transfer into the + manual inverted-call queue share the owner lock with file cleanup. No close + can finish purging that queue and then have an already-validated request + appear behind the purge boundary. +- Child creation is protected by an owner-admission barrier. Cleanup closes + admission under the owner lock and waits for every admitted UdeCx create and + PlugIn transaction before enumerating owned children. UdeCx calls run without + the owner lock held, avoiding callback deadlocks while preventing an orphaned + child from being published behind cleanup's enumeration boundary. +- Overlapped cancellation is outcome-based rather than intent-based. After + `CancelIoEx`, the completion packet decides whether the operation completed + normally, was actually aborted, or failed. A successful create/destroy can + therefore never be reported as cancelled merely because the context deadline + raced its completion. +- Completion lookup is keyed by `(device ID, generation, token)`. +- Failed transfers do not need to fabricate a successful ISO packet table. + Successful completions are canonical: OUT replies carry no payload, every + ISO reserved field is zero, packet extents stay inside the transfer buffer, + and the sum of actual packet lengths equals the reported completed bytes. + The host-owned packet offsets are immutable across the broker boundary; + user mode may return only each packet's actual length and status. Sparse IN + payload span is independent of the completed-byte total, and the kernel + derives the URB error count from the returned per-packet statuses. +- Each isochronous endpoint owns a virtual USB frame reservation clock. ASAP + URBs reserve the first frame after the current or previously queued window, + and the driver returns that actual frame in `StartFrame` as required by the + Windows USB contract. Explicit schedules advance the same endpoint clock; + reset, purge, and start clear it so an old pipe lifetime cannot skew a new + media stream. + +This uses per-target ownership and manual request queues while accounting for +UdeCx's endpoint-specific purge contract. +Host-side create/remove gates are keyed by stable device ID: generations of +one controller cannot cross, while a slow PnP transition for one pad cannot +stall an independent pad's registration or removal. +- PnP creation is revalidated after the overlapped kernel transaction. If a + one-shot host session stopped while creation was in flight, that exact child + generation is transactionally destroyed and cannot be published into the + terminal host. + +## Delivery checkpoints + +1. Versioned ABI, independent C/Go layout tests, architecture notes. +2. Installable root-enumerated KMDF/UdeCx controller with negotiation, owner + cleanup, diagnostics, and no virtual child. +3. Dynamic HID-only child, control endpoint, interrupt IN/OUT, reset and purge. +4. VIIPER Go broker implementing the existing controller interface. +5. Xbox, DualShock 4, and DualSense HID/state parity. +6. Bidirectional isochronous audio and microphone parity, alternate settings, + haptics, lightbar, triggers, and reconnect recovery. +7. Fault injection, soak, latency, CPU, install/update/rollback, and signing. + +## Release gates + +- No verifier findings under KMDF/USB/UdeCx stress. +- The completion-execution contract has a two-machine signed-live gate. On a + clean disposable Windows 10 1809 x64 machine, stage + `Enable-ViiperUdeVerifierForNextBoot.ps1` (which selects only + `ViiperUde.sys`, Microsoft's `/standard` checks, and `oneboot`), restart, and + run `Invoke-ViiperUdeLiveValidation.ps1` in `Production` mode with + `-RequireDriverVerifier`, at least three iterations, both media/input probes, + and at least 180 seconds of media. On the current Windows 11 x64 HLK target, + run the same command with `-RestartRootDevice -ReleaseGate`. Both runs must + exercise normal broker replies, mark-cancel races, owner-process death, + endpoint reset/purge, root removal, and concurrent control/interrupt/ISO + traffic; `verifier /query` must show the reviewed image and there may be no + verifier violation, bugcheck, stuck request, nonzero terminal pending count, + or late duplicate completion. +- The HLK gate is the complete Studio-generated applicable playlist, without + manually suppressing tests, for the VIIPER root controller and every + enumerated USB/HID/audio child on Windows 10 1809 x64 and the current Windows + 11 x64 certification target. This includes every applicable Device + Fundamentals I/O, PnP, power, reliability and security test plus USB, HID, + and Audio tests. Every result must pass, or carry a Microsoft-approved + erratum recorded in the source-bound HLKX evidence; a locally filtered or + waived cancellation/IRQL failure is not a pass. +- Repeated create/remove, service kill, process crash, sleep/resume, and device + reconnect leave zero stale children and zero stuck requests. +- The driver retains a bounded, nonpaged lifecycle recorder partitioned by + processor. Each shard can retain the full public 512-record window, and the + query path merges only stable published records into the global latest + suffix. Monotonic per-slot claims prevent a preempted writer from overwriting + a newer wrap when processors collide on a shard; an active-slot collision is + dropped rather than waited on and sets a sticky failure flag. Lifecycle + writers take no locks, allocate no memory, and never wait. Any endpoint, + completion, controller, or owner rundown watchdog also sets a sticky status + flag, so rolling its record out of the public window cannot hide it from the + release gate. Retained records include the active count and queue state needed + to diagnose the stalled ownership boundary. +- Descriptor and protocol fuzzing rejects malformed inputs without a bugcheck. +- HID report ordering has no duplication or regression across generations. +- DualSense and DualShock 4 media survive concurrent state and feedback traffic. +- Native latency and CPU are measured against the current USB/IP path and a + comparable virtual-input baseline under the same workload. +- The overlapped owner handle uses Microsoft's + `FILE_SKIP_COMPLETION_PORT_ON_SUCCESS` contract. A direct input IOCTL which + the kernel completes inline returns on its publisher goroutine without an + otherwise redundant IOCP-pump/channel scheduling hop; operations that return + `ERROR_IO_PENDING` retain the existing cancellation-safe completion path. +- Native completion encoding writes into bounded buffers recycled by the + client. Continuous control and isochronous traffic no longer allocates a new + wire buffer for every URB; an allocation gate protects the caller-buffer + encoder while the existing public marshal API remains available for tooling. +- Allocation microbenchmarks cover both authenticated stream directions and + input-deadline scheduling. On the development Windows x64 host, replacing a + timer-backed context per service interval reduced the isolated scheduling + cost from 4 allocations/272 bytes to zero. Authenticated 512-byte record + writes moved from 3 allocations/592 bytes to zero, and reads from 3 + allocations/1092 bytes to zero. These are GC/jitter controls, not substitutes + for the signed HID latency gate. +- Product changes to scheduling, thread priority, DPC behavior, or queue depth + require a named, bounded-memory WPR capture of the signed live gate. CPU + sampled/precise, ready-thread, context-switch, WDF DPC, interrupt, and ISR + evidence must identify the actual critical path; a polling benchmark or Task + Manager percentage alone is not a valid basis for such a change. +- Signed live input validation discovers the exact newly created HID gamepad, + continuously reads reports through HIDClass, and correlates 256 unique + publication markers with cross-process QPC timestamps. DualShock 4, + DualSense, and DualSense Edge must remain at or below 4 ms p95, 8 ms p99, + and 20 ms maximum publisher-to-HID latency. These gates include user-mode + scheduling and prevent a nominal polling-rate claim from hiding tail stalls. +- That same signed live HID gate writes a full output report through the newly + enumerated HIDClass collection. The exact marker must survive UdeCx and the + native broker into DualShock 4 rumble/lightbar feedback and DualSense rumble, + player/lightbar LED, and both adaptive-trigger blocks; kernel operation, + completion, and host-to-device byte counters must advance. Unit-level + processor tests alone are not accepted as proof of Windows game-feedback + delivery. +- Installation is signed, reversible, version-gated, and never replaces a live + kernel driver across an unsafe reboot boundary. +- The INF's Windows 10 1809 floor and the linked KMDF contract remain aligned: + the driver targets KMDF 1.27, the framework version Microsoft ships in + Windows 10 1809. CI rejects a newer KMDF target unless the INF floor is also + intentionally raised. + +The exact attestation/HLK boundary, CAB construction, and Microsoft-signature +validation contract is documented in +[`native-udecx-signing.md`](native-udecx-signing.md). +The protected broker staging, cross-component driver/service rollback, and +authenticated commit order are documented in +[`native-udecx-package-install.md`](native-udecx-package-install.md). + +## Primary documentation + +- Microsoft, *Write a UDE client driver* + +- Microsoft, *Handling I/O Requests in a USB Host Controller Driver* + +- Microsoft, `WdfRequestRetrieveOutputBuffer` (the buffered negotiation + response remains framework-owned until request completion) + +- Microsoft, `EVT_UDECX_USB_ENDPOINT_PURGE` +- Microsoft, `EVT_UDECX_USB_ENDPOINT_RESET` (asynchronous reset request) + +- Microsoft, `WdfIoQueueGetState`, `WDF_IO_QUEUE_STATE`, and + `WdfIoQueueDriverNoRequests` (no requests are owned by driver callbacks) + + +- Microsoft, `UdecxUrbComplete` and `UdecxUrbCompleteWithNtStatus` + + +- Microsoft, `EvtDeviceSelfManagedIoCleanup` + +- Microsoft, `EVT_WDF_DPC`, `WdfDpcEnqueue`, and `WdfDpcCancel` + + + +- Microsoft, *KMDF Version History* +- Microsoft, *Install the WDK using NuGet* +- Microsoft Windows Driver Samples CI guidance +- Microsoft, *Finding and Opening a HID Collection* + +- Microsoft, *Obtaining HID Reports* + +- Microsoft, *Acquiring high-resolution time stamps* + +- Microsoft, *WPR Command-Line Options* + +- Microsoft, *CPU Analysis* + +- Microsoft, `SetFileCompletionNotificationModes` + +- Microsoft, `CreateService` + +- Microsoft, `ChangeServiceConfig` + +- Microsoft, `ChangeServiceConfig2` + +- Microsoft, `SERVICE_FAILURE_ACTIONS` + +- Microsoft, `DeleteService` + +- Microsoft, `SetSecurityInfo` + +- Microsoft, `MoveFileExW` + +- Microsoft, `CreateProcessAsUserW` + diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 0dd0791a..af5fe5f7 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -21,10 +21,10 @@ All command-line flags have corresponding environment variables for easier deplo | Environment Variable | CLI Flag | Default | Description | |---------------------|----------|---------|-------------| | `VIIPER_USB_ADDR` | `--usb.addr` | `:3241` | USBIP server listen address | -| `VIIPER_API_ADDR` | `--api.addr` | `:3242` | API server listen address | +| `VIIPER_API_ADDR` | `--api.addr` | `127.0.0.1:3242` | API server listen address | | `VIIPER_API_DEVICE_HANDLER_TIMEOUT` | `--api.device-handler-timeout` | `5s` | Device handler auto-cleanup timeout | | `VIIPER_API_AUTO_ATTACH_LOCAL_CLIENT` | `--api.auto-attach-local-client` | `true` | Auto-attach exported devices to local usbip client | -| `VIIPER_API_REQUIRE_LOCALHOST_AUTH` | `--api.require-localhost-auth` | `false` | Require authentication even for localhost connections | +| `VIIPER_API_REQUIRE_LOCALHOST_AUTH` | `--api.require-local-host-auth` | `true` | Require authentication for localhost connections | | `VIIPER_CONNECTION_TIMEOUT` | `--connection-timeout` | `30s` | Connection operation timeout | ### Proxy Configuration @@ -66,8 +66,7 @@ If --config is not provided, VIIPER will search for configuration in this order ## Authentication and Security -VIIPER requires authentication for remote (non-localhost) connections -to prevent unauthorized device creation. +VIIPER requires authentication by default for local and remote API clients to prevent unauthorized device creation and stream takeover. The password file is _intentionally_ separated from the main configuration @@ -77,15 +76,13 @@ The password file is _intentionally_ separated from the main configuration - **Windows:** `%APPDATA%\VIIPER\` - **Linux/macOS (user):** `~/.config/github.com/Alia5/viiper/` - **Linux (root/systemd):** `/etc/viiper/` -- **Auto-generation:** If the file doesn't exist, -VIIPER generates a random 16-character password on first start and displays it in the console +- **Auto-generation:** If the file doesn't exist, VIIPER generates a random 16-character password on first start. The value is stored only in the credential file and is not printed to logs or the console. - **Custom passwords:** You can edit `viiper.key.txt` and replace it with any password of any length - **Encryption:** All authenticated connections use fast ChaCha20-Poly1305 encryption with unique session keys -### Localhost Exemption - -By default, clients connecting from `localhost`, `127.0.0.1`, or `::1` do NOT require authentication (they can optionally provide it). -To require authentication even for localhost connections, use `--api.require-localhost-auth=true`. +### Localhost Authentication + +Clients connecting from `localhost`, `127.0.0.1`, or `::1` authenticate by default. For legacy USB/IP development only, `--api.require-local-host-auth=false` opts out locally. Native UDE transport always requires authentication. ### Remote Connections @@ -100,7 +97,7 @@ All remote clients MUST authenticate using the password from `viiper.key.txt`. ```json { "api": { - "addr": ":3242", + "addr": "127.0.0.1:3242", "device-handler-connect-timeout": "5s", "auto-attach-local-client": true }, diff --git a/docs/cli/server.md b/docs/cli/server.md index bf8f4a99..3625edbb 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -18,8 +18,8 @@ The server exposes two interfaces: 1. **USBIP Server** - Standard USBIP protocol for device attachment 2. **VIIPER API Server** - Management API for device/bus control -!!! warning "Authentication Required for Remote Connections" - VIIPER requires **authentication for all remote (non-localhost) connections** to prevent unauthorized device creation. +!!! warning "Authentication Required" + VIIPER requires authentication by default, including for localhost, to prevent an unrelated local process from creating devices or taking over a live controller stream. On first start, VIIPER generates a random password and saves it to `/viiper.key.txt`. @@ -27,11 +27,11 @@ The server exposes two interfaces: Linux (user): `~/.config/github.com/Alia5/viiper/viiper.key.txt` Linux (root/systemd): `/etc/viiper/viiper.key.txt` - - **Localhost clients** (`127.0.0.1`, `::1`): Authentication is optional by default - - **Remote clients**: Authentication is required and enforced - - All authenticated connections use **ChaCha20-Poly1305 encryption** - - See the `--api.require-localhost-auth` option below to require authentication for localhost connections. + - **Localhost clients** (`127.0.0.1`, `::1`): Authentication is required by default + - **Remote clients**: Authentication is always required and enforced + - All authenticated connections use **ChaCha20-Poly1305 encryption** + + The credential is never printed to the console or log. Clients read it from the protected credential file. Native UDE mode always requires localhost authentication. !!! info "Automatic Local Attachment" By default, VIIPER automatically attaches newly created devices to the local USBIP client (localhost only). @@ -51,7 +51,7 @@ USBIP server listen address. API server listen address. -**Default:** `:3242` +**Default:** `127.0.0.1:3242` **Environment Variable:** `VIIPER_API_ADDR` ### `--api.device-handler-timeout` @@ -76,20 +76,19 @@ Disable example: viiper server --api.auto-attach-local-client=false ``` -### `--api.require-localhost-auth` +### `--api.require-local-host-auth` Require authentication even for clients connecting from localhost (`127.0.0.1`, `::1`, `localhost`). -By default, localhost clients are exempt from authentication for convenience during local development. -Enable this option if you want to enforce authentication for all connections regardless of origin. +Authentication is enabled by default. Legacy USB/IP development can explicitly disable it for localhost only; native UDE mode ignores that opt-out and remains authenticated. -**Default:** `false` +**Default:** `true` **Environment Variable:** `VIIPER_API_REQUIRE_LOCALHOST_AUTH` -Enable example: +Local USB/IP development opt-out: ```bash -viiper server --api.require-localhost-auth=true +viiper server --api.require-local-host-auth=false ``` ### `--connection-timeout` @@ -103,7 +102,7 @@ Connection operation timeout for both USBIP and API servers. ### Basic Server -Start server with default settings (USBIP on :3241, API on :3242): +Start server with default settings (USBIP on :3241, API on 127.0.0.1:3242): ```bash viiper server diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index bfcc7e75..861324f9 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -20,19 +20,19 @@ This starts two services: - **USBIP Server** on port `3241` (standard USBIP protocol) - **VIIPER API Server** on port `3242` (management and device interactions) -!!! warning "Authentication for Remote Connections" +!!! warning "API Authentication" On first start, VIIPER generates a random password and saves it to `/viiper.key.txt`. Windows: `%APPDATA%\VIIPER\viiper.key.txt` Linux (user): `~/.config/github.com/Alia5/viiper/viiper.key.txt` Linux (root/systemd): `/etc/viiper/viiper.key.txt` - - **Localhost clients** (`127.0.0.1`, `::1`): Authentication is **optional** (but supported) - - **Remote clients**: Authentication is **required** - provide the password using your client library + - **Localhost clients** (`127.0.0.1`, `::1`): Authentication is **required by default** + - **Remote clients**: Authentication is **always required** - provide the password using your client library All authenticated connections use **ChaCha20-Poly1305 encryption** to protect against man-in-the-middle attacks. - You can change the password at any time by editing `viiper.key.txt`. + The password is never printed in logs or the console. Read it from `viiper.key.txt`; you can change it by editing that file while VIIPER is stopped. !!! tip "Auto-attach Feature" By default, VIIPER automatically attaches newly created devices to the local machine. You can disable this with `--api.auto-attach-local-client=false`. diff --git a/docs/index.md b/docs/index.md index 6b38f70b..b4a03668 100644 --- a/docs/index.md +++ b/docs/index.md @@ -90,11 +90,11 @@ VIIPER takes care of all USBIP protocol details, so you can focus on implementin On `localhost` VIIPER also automatically attached the USBIP client, so you don't have to worry about USBIP details at all. !!! info "Security: Authentication & Encryption" - VIIPER **requires authentication for remote connections** - to prevent unauthorized device creation. + VIIPER **requires authentication by default**, including on localhost, + to prevent unauthorized device creation and stream takeover. All authenticated connections use fast **ChaCha20-Poly1305 encryption** to protect against man-in-the-middle attacks. - Localhost connections are exempt from authentication by default for convenience. + Native UDE mode never permits unauthenticated topology or stream control. See the [API documentation](api/overview) for details diff --git a/docs/testing/e2e_latency.md b/docs/testing/e2e_latency.md index 5fc090af..0c82a9c2 100644 --- a/docs/testing/e2e_latency.md +++ b/docs/testing/e2e_latency.md @@ -1,70 +1,321 @@ -# E2E Latency Benchmarks - -The script `viiper/_testing/e2e/scripts/lat_bench.go` runs (or parses) end‑to‑end input latency benchmarks and produces enriched output (table, markdown, or JSON). - -It groups repeated cycles when `-count > 1` and uses the single press E2E measurement (`E2E-InputDelay`) as the 100% baseline. - -## Output - -| Column | Meaning | -| --------------- | ----------------------------------------------------------------------------------------- | -| Benchmark | Name of the sub benchmark | -| Count | Iterations performed (from Go bench output; affected by `-benchtime`) | -| ns/op | Nanoseconds per operation (direct Go benchmark figure) | -| % of Full | Relative to `E2E-InputDelay` (single press baseline) | -| Client Share % | Portion attributed to the (go) client write phase (for E2E rows) | -| Latency Share % | Remainder attributed to transport + virtual device/host stack + tight device polling loop | - -`E2E-PressAndRelease` includes both press and release cycles, so it is expected to be ~2× the single press and thus can exceed 100% in `% of Full`. - -## Scope / Methodology - -- All benchmarks included here are executed against a VIIPER server on the same host (localhost). - They therefore measure in-process client emission plus local USBIP stack + emulated device processing only. - Remote/network USBIP attachment will add network RTT and jitter which is intentionally excluded from these baseline figures. -- Benchmarks use a single emulated Xbox360 controller device. - Other devices might produce slightly different results depending on USB report size and VIIPER-InputState size. -- Benchmarks use a single button press, which is enough as clients/VIIPER always produce a full report of the devices state. - -## Benchtime Mode - -Runs use a fixed-iteration benchtime (e.g. `-benchtime=1000x`, `-benchtime=10000x`) rather than time-based (e.g. `2s`). - -## Running - -From repository root: - -```bash -cd testing/e2e -# Single run, 1000 fixed iterations per sub benchmark -go run ./scripts/lat_bench.go -benchtime=1000x -count=1 -format markdown -``` - -Results (Arch Linux / SteamDeck Kernel / Steam Deck LCD / Go 1.25+, 10k iterations): - -| Benchmark | Count | ns/op | % of Full | Client Share % | Latency Share % | -| --------------------------- | ----- | ------ | --------- | -------------- | --------------- | -| 1_Go-Client-Write | 10000 | 10668 | 11.98 | 100.00 | 0.00 | -| 2_InputDelay-Without-Client | 10000 | 74154 | 83.25 | 0.00 | 100.00 | -| 3_E2E-InputDelay | 10000 | 89078 | 100.00 | 11.98 | 88.02 | -| 4_E2E-PressAndRelease | 10000 | 184870 | 207.54 | 11.54 | 88.46 | - -Example output (Windows / AMD Ryzen 9 3900X / Go 1.25+, 10k iterations): - -| Benchmark | Count | ns/op | % of Full | Client Share % | Latency Share % | -| --------------------------- | ----- | ------ | --------- | -------------- | --------------- | -| 1_Go-Client-Write | 10000 | 27933 | 16.60 | 100.00 | 0.00 | -| 2_InputDelay-Without-Client | 10000 | 133724 | 79.45 | 0.00 | 100.00 | -| 3_E2E-InputDelay | 10000 | 168307 | 100.00 | 16.60 | 83.40 | -| 4_E2E-PressAndRelease | 10000 | 331439 | 196.93 | 16.86 | 83.14 | - -Variability across repeated measurement runs has been negligible. -Use a larger `-count` if you want to increase the number of runs. - -## Notes - -- Memory statistics from Go benchmarks are intentionally omitted. -- `% of Full` falls back to the largest ns/op if the baseline row is missing. -- All benchmarking must run with parallelism 1 in underlying benches. -- Benchmarks use a tight polling loop using SDL3 to detect input state changes on the emulated device. -- Benchmarks must be run without an already running VIIPER server instance. +# Controller-to-game latency + +VIIPER has several latency tools. They answer different questions and +must not be presented as interchangeable evidence. + +- `_testing/e2e/scripts/lat_bench.go` formats Go benchmark averages. It is a + useful developer diagnostic, but `ns/op` does not preserve individual tail + samples, transition loss, or duplication. It is not the native release gate. +- `_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1` is the opt-in Windows + production gate. It records every press and release observed through SDL, + compares authenticated USB/IP and native UDE runs, and emits a strict JSON + evidence artifact plus a source-controlled sequential-file WPR trace. +- `_testing/e2e/scripts/Invoke-ViiperE2ELatencyMatrix.ps1` is the release + entry point. It runs the complete gate once at Normal and once at High + process priority, then binds both raw JSON/ETL/decoded-marker sets into one + hash manifest. + +No live latency result is checked into this document. A passing result exists +only when the production command below succeeds on the stated machine and its +source-bound artifacts are retained. + +### Evidence boundary + +This is an exact-source native-path, production-authentic API-to-consumer gate. +The USB/IP comparator is deliberately labeled +`version-probed-functional-baseline-not-source-bound`: the wrapper proves the +supported 0.9.7.7 command and functional port contract, not the source revision +of that third-party installed driver. The Go +test starts `cmd.Server` in process at the clean `HEAD` under test and uses the +repository's Go client over real localhost TCP. Beyond that process boundary it +uses the installed USB/IP or native UDE transport, the actual Windows controller +stack, and the source-bound SDL DLL. Authentication, API framing, controller +serialization, transport delivery, HID consumption, SDL event delivery, and +consumer wake-up are therefore live rather than mocked. + +It is not a packaged-executable, service/task-hosted broker, DS4Windows, physical +controller, display, or game-engine-frame test. The signed-package/broker live +gates and any DS4Windows or physical-input qualification remain separate +evidence. A pass here must not be relabeled as a pass for those boundaries. + +## What the production gate measures + +For each controller below, the gate creates the device through the authenticated +VIIPER API, opens an authenticated controller stream, writes alternating south +button states, and waits for the corresponding game-facing SDL transition: + +| API controller | Expected SDL type | VID:PID | South button | +| --- | --- | --- | --- | +| `xbox360` | Xbox 360 | `045e:028e` | A | +| `dualshock4` | PS4 | `054c:09cc` | Cross | +| `dualsensegamepadv5` | PS5 | `054c:0ce6` | Cross | + +Each controller uses a fresh server, bus, device, stream, and exact SDL binding +for four counterbalanced blocks: USB/IP, native UDE, native UDE, USB/IP (ABBA). +Sixteen unrecorded press/release pairs warm the complete path at the start of +every block. The declared sample count is then split as evenly as possible +between the two blocks for each transport; `-Samples 256` therefore records 128 +pairs in each block and aggregates 256 press plus 256 release samples per +transport. ABBA makes both the first/last positions USB/IP and both middle +positions native, reducing one-way warm-up and monotonic-drift bias without +discarding per-block source identity. + +The v2 JSON retains every raw sample and publishes nearest-rank p50, p90, p95, +p99, p99.9, and max values plus population jitter. Its provenance includes the +host name, Windows product/display/build identity, CPU model, logical processor +count, token elevation, and the measured process priority class. Reports from +different machine identities cannot be combined into the priority matrix. + +All four blocks use the same API address, credential, bus/device position, +input sequence, warm-up count, one-second event timeout, and deterministic +unmeasured dwell schedule. Xbox success cannot certify either PlayStation path. +Missing, ambiguous, or misidentified DualShock 4 or DualSense enumeration—or a +failure in any ABBA block—fails the whole suite. + +A fixed 2 ms dwell could repeatedly land writes at the same phase of a 1 ms HID +service interval. The gate instead retains a 2 ms minimum state dwell and adds +the source-bound offsets `0, 125000, 250000, 375000, 500000, 625000, 750000, +875000` ns. Press and release edges index that vector deterministically by +sequence number, and block 2 resumes the same per-transport sequence. The +cumulative offsets visit all eight 125 us phases of one millisecond; all +controllers and transports receive the identical pattern. Sleeps occur before +the measured `WriteBinary` interval, never inside it, and do not busy-wait. + +The artifact records the vector and SHA-256 +`21eee9ea71984343ebd21221df8272553d6ab369a5740a1c796380cd468abcd9` of its +comma-separated base-10 nanosecond representation. The parser recomputes that +hash and rejects a changed vector or scheduling workload. This is a reproducible +phase-control policy, not a claim that Windows wakes at each requested +nanosecond; WPR retains the scheduler evidence for investigating overshoot. + +The interval starts immediately before `DeviceStream.WriteBinary` and ends when +the exact SDL gamepad/button event returns to the waiting consumer. It therefore +includes authenticated client framing, localhost TCP delivery, VIIPER device +processing, the selected virtual USB transport, Windows controller input, SDL's +event path, and consumer wake-up. It does not claim display, engine-frame, or +network latency. + +Raw `QueryPerformanceCounter` ticks bracket every interval and are converted +with the once-recorded `QueryPerformanceFrequency`; that conversion is the +canonical `latency_ns`. The strict parser recomputes it exactly and rejects an +overflow, clock regression, cross-sample QPC regression, or JSON latency that +does not match its retained ticks. Go's monotonic clock is used only for wait +deadlines. Microsoft recommends QPC for sub-microsecond interval and latency +measurement. SDL's event timestamp is retained independently to reject absent, +stale, or regressing events. The harness never subtracts the SDL clock from the +QPC clock. + +The observer uses `SDL_WaitEventTimeout`, not a tight state loop. It observes +the complete unmeasured dwell, then drains exact queued button events, checks +the current state, captures QPC, and places an `SDL_GetTicksNS` fence directly +beside the input write. An event must be strictly newer than that fence; an +older or same-tick event is rejected rather than misattributed to the write. +SDL and the authenticated TCP write expose no shared atomic operation, so this +is a stale-edge exclusion/admission proof, not a claim of cryptographic causal +identity across the irreducible final function-call boundary. Unexpected +same-state edges from the exact device are counted as duplicates while the wait +continues. A missing expected edge increments the appropriate miss counter and +terminates that transport/controller run. The final quiet window is also an SDL +event wait, so late release duplicates are not hidden and no measurement-side +busy poll consumes a CPU core. + +The source-bound SDL build enables its Windows RawInput backend before +initialization. SDL's default Xbox backend exposes only a logical `XInput#N` +path; RawInput retains the exact HID device-interface path needed to bind the +observed controller to Windows PnP ancestry. This makes the Xbox arm an SDL +RawInput consumer-path measurement, not an XInput API polling measurement. A +logical XInput path or failure to enable RawInput fails closed rather than +falling back to VID/PID-only identity. + +## Source and device binding + +The PowerShell entry point fails closed before measurement unless all of the +following are true: + +- `HEAD` equals the caller-supplied 40- or 64-digit source revision; +- the tracked and untracked source tree is clean and every submodule is at its + recorded revision; +- the native package passes the existing production Microsoft-signature and + submission-manifest gate; +- the installed `ViiperUde.sys` hash matches that verified package and the one + VIIPER root devnode reports a Microsoft signer; +- the live Go harness is linked with the clean `HEAD` as + `nativeSourceRevision`, and the build identity negotiated from the loaded + kernel image exactly matches the verified manifest identity (the installed + file hash alone is not presented as loaded-image proof); +- the SDL DLL hash matches the caller-supplied source-build hash; +- the DLL actually loaded by the Go test is that exact absolute SDL path and + hash; +- the USB/IP prerequisite check accepts the repository's supported runtime; +- both servers reject an unauthenticated ping, while the authenticated ping + reports the requested live transport and a ready backend; +- `DeviceAdd` returns the expected controller type, VID, PID, bus, device ID, + and (for USB/IP) exact auto-attached import port; +- all baseline SDL gamepads remain present and exactly one stable new SDL ID is + created; its path, GUID, real type, VID, and PID must match the API device; +- the SDL HID interface resolves to an exact present Windows PnP instance and + container identity plus a cardinality-consistent ancestor chain. A + native run must terminate at service `ViiperUde`/hardware ID + `ROOT\VIIPER\UDE`; USB/IP must terminate at service `usbip2_ude`/INF hardware + ID `ROOT\USBIP_WIN2\UDE` (the OS-assigned devnode instance is commonly + `ROOT\USB\####` and is recorded separately). The gate follows the unified + `DEVPKEY_Device_Parent` relation through that anchor to `HTREE\ROOT\0`; a + truncated/cyclic chain or a second matching anchor is rejected. + +The gate does not install, update, stop, replace, or remove a driver or service. +Run it on a disposable test machine with the verified production package and +supported USB/IP runtime already installed. No VIIPER process or service may +already own the API ports or native broker handle. + +## Statistics and pass policy + +The artifact retains every sample's sequence, transition, monotonic latency, +SDL event/pre-write-fence timestamps, raw QPC start/end/pre-marker ticks, and canonical +TraceLogging marker ID. It reports press, +release, and combined distributions for each controller/transport: + +- p50, p95, and p99 use the nearest-rank definition (`ceil(p * N)`, one based); +- max is the largest individual interval; +- jitter is the population standard deviation of the individual intervals; +- misses and duplicates are separate press/release counters. + +At least 256 complete press samples and 256 complete release samples, aggregated +from both counterbalanced blocks, are required for every controller and +transport. A timeout, write/event error, +insufficient count, non-monotonic SDL event clock, any miss, or any duplicate +fails the artifact. Native press, release, and combined distributions must each +remain at or below the reviewed native limits: 4 ms p95, 8 ms p99, and 20 ms +maximum. + +The JSON also reports native-minus-USB/IP deltas and native/USB-IP ratios for +p50, p95, p99, max, and jitter. A same-machine non-regression policy additionally +requires native p95, p99, and maximum to be no more than 1 ms, 2 ms, and 5 ms +above the corresponding USB/IP values for press, release, and combined samples. +Those absolute deltas are engineering acceptance limits set at one quarter of +the corresponding 4/8/20 ms native ceilings. They avoid unstable ratios when a +USB/IP baseline is very small; they are policy, not a claim about observed +transport performance. The gate does not say native is lower latency unless the +retained live artifact actually shows negative native-minus-USB/IP deltas. + +The parser rejects unknown fields, trailing JSON, weakened absolute or +same-machine limits, a non-ABBA schedule, mixed transport proof, workload drift +between controllers, reordered or missing press/release samples, and block, +aggregate, comparison, or verdict fields that do not exactly recompute from the +individual records. + +## Running the production gate + +Prerequisites are an elevated Windows PowerShell session, an exact clean +checkout, Go 1.26 or newer, CGO with a working C toolchain, CMake, the +source-built SDL submodule, WPR, USB/IP win2 +0.9.7.7, and an already installed Microsoft-signed VIIPER UDE package matching +its submission manifest. + +The SDL wrapper currently links the multi-configuration Debug output. Build and +record that exact binary before running the gate: + +```powershell +cmake -S .\_testing\e2e\deps\SDL -B .\_testing\e2e\deps\SDL\build -A x64 +cmake --build .\_testing\e2e\deps\SDL\build --config Debug +$sdlHash = (Get-FileHash .\_testing\e2e\deps\SDL\build\Debug\SDL3.dll -Algorithm SHA256).Hash +``` + +Choose an existing evidence directory outside the checkout. Existing files are +never overwritten. `-Samples` is the total pair count per +controller/transport and is bounded to 256–10,000. The release matrix defaults +to 10,000 and produces independent Normal/High JSON, ETL, and decoded-marker +artifacts. + +```powershell +$revision = (git rev-parse HEAD).Trim() +$gitExe = 'C:\Program Files\Git\cmd\git.exe' +$goExe = 'C:\Go\bin\go.exe' + +.\_testing\e2e\scripts\Invoke-ViiperE2ELatencyMatrix.ps1 ` + -SignedPackageDirectory C:\ViiperUde\MicrosoftSigned ` + -SubmissionManifestPath C:\ViiperUde\ViiperUde.cab.sha256.json ` + -ExpectedSourceRevision $revision ` + -SDLBinarySHA256 $sdlHash ` + -EvidenceDirectory C:\ViiperEvidence ` + -GitExecutable $gitExe ` + -GoExecutable $goExe ` + -Samples 10000 +``` + +For a single diagnostic run, call `Invoke-ViiperE2ELatencyGate.ps1` directly +with `-PriorityClass Normal` or `-PriorityClass High` and new `-OutputPath` and +`-WprTracePath` values. Both wrappers require absolute, non-reparse Git and Go +executable paths; the system WPR image is pinned automatically. Their paths and +SHA-256 values are retained in the v2 provenance. A single run is not the +release priority matrix. + +The wrapper verifies and uses the checked-in `ViiperLatency.wprp` in sequential +file mode, names the recording instance, rejects any reported event/buffer +loss, and saves the trace on both pass and test failure. The profile includes +context-switch, ready-thread, sampled-profile, DPC, interrupt, and WDF evidence +needed to investigate a tail. A fixed TraceLogging provider captures another +QPC value after the measured end and then emits each marker. The wrapper decodes +the ETL oldest-first and requires exact chronological, one-to-one marker and +QPC/timestamp/latency payload equality with the strictly parsed JSON; missing, +duplicate, reordered, extra, or undecodable markers fail closed. +The exact decoded marker set is retained beside the JSON as +`.etl-markers.json`, and the production wrapper invokes the same Go +strict parser/recomputation verifier used by deterministic tests on the JSON, +decoded-marker, and ETL evidence pair. +The ETL remains corroborating scheduler evidence, not a substitute for SDL's +consumer timestamp. + +Directly setting the live-test environment variable is intentionally +insufficient. The Go test also requires the preflight marker, expected source +and SDL revisions, loaded SDL path/hash, verified package-manifest hash, +installed-driver hash, sample count, and a new absolute output path. + +## Aggregate developer diagnostic + +For a non-gating average-only diagnostic, run from the repository root. Use the +encrypted rows when comparing transports so the API/controller stream mode is +the same: + +```powershell +$env:VIIPER_E2E_TRANSPORT = 'usbip' +go run .\_testing\e2e\scripts\lat_bench.go ` + -pkg .\_testing\e2e -encryption encrypted -benchtime 1000x -count 5 -format markdown + +$env:VIIPER_E2E_TRANSPORT = 'native-ude' +go run .\_testing\e2e\scripts\lat_bench.go ` + -pkg .\_testing\e2e -encryption encrypted -benchtime 1000x -count 5 -format markdown +``` + +Go benchmark `ns/op` is an aggregate timing result. Do not infer p95/p99, +misses, duplicates, or a live release pass from it. + +## Method references + +- [Go `testing` benchmarks](https://pkg.go.dev/testing) document `B.Loop`, the + benchmark timer, and aggregate metric semantics. +- [Go monotonic time](https://pkg.go.dev/time#hdr-Monotonic_Clocks) documents why + `time.Since(start)` is robust against wall-clock adjustment. +- [SDL gamepad button events](https://wiki.libsdl.org/SDL3/SDL_GamepadButtonEvent) + define the nanosecond event timestamp, device ID, button, and edge. +- [`SDL_WaitEventTimeout`](https://wiki.libsdl.org/SDL3/SDL_WaitEventTimeout) is + the blocking event-consumer primitive used by the observer. +- [`SDL_HINT_JOYSTICK_RAWINPUT`](https://wiki.libsdl.org/SDL3/SDL_HINT_JOYSTICK_RAWINPUT) + documents that RawInput is disabled by default, handles XInput-capable + devices, and must be enabled before SDL initialization. +- [Microsoft high-resolution timestamp guidance](https://learn.microsoft.com/en-us/windows/win32/sysinfo/acquiring-high-resolution-time-stamps) + recommends QPC for interval and latency measurements. +- [Microsoft `CM_Get_Parent` and unified-parent guidance](https://learn.microsoft.com/en-us/windows/win32/api/cfgmgr32/nf-cfgmgr32-cm_get_parent) + identifies `DEVPKEY_Device_Parent` as the Windows Vista-and-later device-tree + parent relation used by the identity proof. +- [Microsoft WPR command-line guidance](https://learn.microsoft.com/en-us/windows-hardware/test/wpt/wpr-command-line-options) + documents named instances, memory/file modes, profiles, start, and stop. +- [Microsoft WPR logging-mode guidance](https://learn.microsoft.com/en-us/windows-hardware/test/wpt/logging-mode) + distinguishes sequential file logging from bounded circular memory logging. +- [Microsoft `Get-WinEvent` guidance](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.diagnostics/get-winevent) + documents ETL `Path`, `ProviderName` filtering, and oldest-first decoding. +- [Microsoft TraceLogging capture guidance](https://learn.microsoft.com/en-us/windows-hardware/drivers/devtest/capture-and-view-tracelogging-data) + documents collecting self-describing providers with WPR/WPA. +- [ViGEmBus](https://github.com/nefarius/ViGEmBus/tree/d986e1d93708ec9b11049542fa6027272cce716c) + is the virtual-controller lifecycle and replay-method reference. Its design + motivates testing through an unmodified game-consumer API; no ViGEm latency + number is copied or claimed here. diff --git a/examples/go/virtual_ds4/main.go b/examples/go/virtual_ds4/main.go index 9e4b8c97..66f50189 100644 --- a/examples/go/virtual_ds4/main.go +++ b/examples/go/virtual_ds4/main.go @@ -68,12 +68,12 @@ func main() { fmt.Printf("Created and connected to DualShock 4 device %s on bus %d\n", addResp.DevID, addResp.BusID) defer func() { - if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + if _, err := api.DeviceRemoveRegisteredCtx(ctx, addResp); err != nil { fmt.Printf("DeviceRemove error: %v\n", err) } else { fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevID) } - if createdBus { + if createdBus && addResp.Transport != "native-ude" { if _, err := api.BusRemoveCtx(ctx, busID); err != nil { fmt.Printf("BusRemove error: %v\n", err) } else { diff --git a/examples/go/virtual_ds4_cli/main.go b/examples/go/virtual_ds4_cli/main.go index 42ad99c9..dde03856 100644 --- a/examples/go/virtual_ds4_cli/main.go +++ b/examples/go/virtual_ds4_cli/main.go @@ -93,10 +93,10 @@ func main() { fmt.Printf("Connected to DualShock 4 device %s on bus %d\n", addResp.DevID, addResp.BusID) defer func() { - if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + if _, err := api.DeviceRemoveRegisteredCtx(ctx, addResp); err != nil { fmt.Printf("DeviceRemove error: %v\n", err) } - if createdBus { + if createdBus && addResp.Transport != "native-ude" { _, _ = api.BusRemoveCtx(ctx, busID) } }() diff --git a/examples/go/virtual_ds_and_edge_cli/main.go b/examples/go/virtual_ds_and_edge_cli/main.go index 6a8aaa65..925bc405 100644 --- a/examples/go/virtual_ds_and_edge_cli/main.go +++ b/examples/go/virtual_ds_and_edge_cli/main.go @@ -102,10 +102,10 @@ func main() { fmt.Printf("Connected to %s device %s on bus %d\n", deviceType, addResp.DevID, addResp.BusID) defer func() { - if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + if _, err := api.DeviceRemoveRegisteredCtx(ctx, addResp); err != nil { fmt.Printf("DeviceRemove error: %v\n", err) } - if createdBus { + if createdBus && addResp.Transport != "native-ude" { _, _ = api.BusRemoveCtx(ctx, busID) } }() diff --git a/examples/go/virtual_keyboard/main.go b/examples/go/virtual_keyboard/main.go index 1399e3b1..2456cf50 100644 --- a/examples/go/virtual_keyboard/main.go +++ b/examples/go/virtual_keyboard/main.go @@ -68,12 +68,12 @@ func main() { // Cleanup on exit defer func() { - if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + if _, err := api.DeviceRemoveRegisteredCtx(ctx, addResp); err != nil { fmt.Printf("DeviceRemove error: %v\n", err) } else { fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevID) } - if createdBus { + if createdBus && addResp.Transport != "native-ude" { if _, err := api.BusRemoveCtx(ctx, busID); err != nil { fmt.Printf("BusRemove error: %v\n", err) } else { diff --git a/examples/go/virtual_mouse/main.go b/examples/go/virtual_mouse/main.go index 05d308ee..c590a497 100644 --- a/examples/go/virtual_mouse/main.go +++ b/examples/go/virtual_mouse/main.go @@ -65,12 +65,12 @@ func main() { // Cleanup on exit defer func() { - if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + if _, err := api.DeviceRemoveRegisteredCtx(ctx, addResp); err != nil { fmt.Printf("DeviceRemove error: %v\n", err) } else { fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevID) } - if createdBus { + if createdBus && addResp.Transport != "native-ude" { if _, err := api.BusRemoveCtx(ctx, busID); err != nil { fmt.Printf("BusRemove error: %v\n", err) } else { diff --git a/examples/go/virtual_ns2pro/main.go b/examples/go/virtual_ns2pro/main.go index 2b65db9c..4de44d21 100644 --- a/examples/go/virtual_ns2pro/main.go +++ b/examples/go/virtual_ns2pro/main.go @@ -46,12 +46,12 @@ func main() { fmt.Printf("Created and connected to Switch 2 Pro device %s on bus %d\n", addResp.DevID, addResp.BusID) defer func() { - if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + if _, err := api.DeviceRemoveRegisteredCtx(ctx, addResp); err != nil { fmt.Printf("DeviceRemove error: %v\n", err) } else { fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevID) } - if createdBus { + if createdBus && addResp.Transport != "native-ude" { if _, err := api.BusRemoveCtx(ctx, busID); err != nil { fmt.Printf("BusRemove error: %v\n", err) } else { diff --git a/examples/go/virtual_x360_pad/main.go b/examples/go/virtual_x360_pad/main.go index 026d43e6..2e1e3e34 100644 --- a/examples/go/virtual_x360_pad/main.go +++ b/examples/go/virtual_x360_pad/main.go @@ -68,12 +68,12 @@ func main() { // Cleanup on exit defer func() { - if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + if _, err := api.DeviceRemoveRegisteredCtx(ctx, addResp); err != nil { fmt.Printf("DeviceRemove error: %v\n", err) } else { fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevID) } - if createdBus { + if createdBus && addResp.Transport != "native-ude" { if _, err := api.BusRemoveCtx(ctx, busID); err != nil { fmt.Printf("BusRemove error: %v\n", err) } else { diff --git a/go.mod b/go.mod index a83535ea..d54d635a 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.2 require ( fyne.io/systray v1.12.1 + github.com/Microsoft/go-winio v0.6.2 github.com/alecthomas/kong v1.15.0 github.com/alecthomas/kong-toml v0.4.0 github.com/alecthomas/kong-yaml v0.2.0 diff --git a/go.sum b/go.sum index 2eb865f8..482e3537 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ= fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= diff --git a/internal/cmd/install.go b/internal/cmd/install.go index f115efb5..a43a70b1 100644 --- a/internal/cmd/install.go +++ b/internal/cmd/install.go @@ -10,12 +10,21 @@ import ( "strings" ) -// Install sets up VIIPER to run automatically. -type Install struct{} +// Install sets up VIIPER to run automatically. On Windows native-ude uses an +// SCM-owned LocalSystem broker; the legacy usbip developer path retains its +// historical per-user startup registration. +type Install struct { + Transport string `help:"Virtual USB transport to register: usbip or native-ude." default:"usbip"` + TargetUserSID string `help:"Interactive Windows user SID that owns DS4Windows startup state." hidden:""` +} -// Uninstall removes VIIPER startup configuration. +// Uninstall removes VIIPER's platform-owned service/startup state. Production +// Windows packages also remove their exact native devnode and Driver Store package. type Uninstall struct { - Yes bool `help:"Confirm removal without prompting." short:"y"` + Yes bool `help:"Confirm removal without prompting." short:"y"` + TargetUserSID string `help:"Interactive Windows user SID that owns VIIPER startup state." hidden:""` + DriverHelper string `help:"Path to the packaged ViiperUdeCtl.exe used for exact native package removal." hidden:""` + ExpectedHelperSHA256 string `help:"Installer-embedded SHA-256 of ViiperUdeCtl.exe used for exact native package removal." hidden:""` } func (c *Install) Run(logger *slog.Logger) error { @@ -28,7 +37,12 @@ func (c *Install) Run(logger *slog.Logger) error { return errors.New("cannot install from 'go run'") } - return install(logger) + transport := strings.ToLower(strings.TrimSpace(c.Transport)) + if transport != "usbip" && transport != "native-ude" { + return fmt.Errorf("unsupported VIIPER transport %q (expected usbip or native-ude)", c.Transport) + } + + return install(logger, transport, strings.TrimSpace(c.TargetUserSID)) } func (c *Uninstall) Run(logger *slog.Logger) error { @@ -42,7 +56,7 @@ func (c *Uninstall) Run(logger *slog.Logger) error { } if !c.Yes { - fmt.Print("Remove VIIPER startup registration and stop its server? [y/N]: ") + fmt.Print("Remove VIIPER's installed service/startup ownership and any exact native device/driver package managed by this installation? [y/N]: ") answer, readErr := bufio.NewReader(os.Stdin).ReadString('\n') if readErr != nil && len(answer) == 0 { return fmt.Errorf("could not read uninstall confirmation: %w", readErr) @@ -54,7 +68,12 @@ func (c *Uninstall) Run(logger *slog.Logger) error { } } - return uninstall(logger) + return uninstall( + logger, + strings.TrimSpace(c.TargetUserSID), + strings.TrimSpace(c.DriverHelper), + strings.ToLower(strings.TrimSpace(c.ExpectedHelperSHA256)), + ) } func currentExecutable() (string, error) { diff --git a/internal/cmd/install_linux.go b/internal/cmd/install_linux.go index c37a5c96..a8d1f397 100644 --- a/internal/cmd/install_linux.go +++ b/internal/cmd/install_linux.go @@ -17,13 +17,19 @@ const ( servicePath = "/etc/systemd/system/viiper.service" ) -func install(logger *slog.Logger) error { +func install(logger *slog.Logger, transport, targetUserSID string) error { + if targetUserSID != "" { + return errors.New("--target-user-sid is supported only by the Windows native broker installer") + } + if transport != "usbip" { + return fmt.Errorf("transport %q is unavailable on Linux", transport) + } exePath, err := currentExecutable() if err != nil { return err } - unit := systemdUnitContent(exePath) + unit := systemdUnitContent(exePath, transport) if err := os.WriteFile(servicePath, []byte(unit), 0o644); err != nil { return err } @@ -40,11 +46,21 @@ func install(logger *slog.Logger) error { } } - logger.Info("VIIPER systemd service installed", "path", servicePath, "exe", exePath) + logger.Info("VIIPER systemd service installed", "path", servicePath, "exe", exePath, + "transport", transport) return nil } -func uninstall(logger *slog.Logger) error { +func uninstall( + logger *slog.Logger, + targetUserSID, driverHelper, expectedHelperSHA256 string, +) error { + if targetUserSID != "" { + return errors.New("--target-user-sid is supported only by the Windows native broker installer") + } + if driverHelper != "" || expectedHelperSHA256 != "" { + return errors.New("native package uninstall helper inputs are supported only on Windows") + } var errs []error if err := runSystemctl("stop", serviceName); err != nil { @@ -70,7 +86,7 @@ func uninstall(logger *slog.Logger) error { return nil } -func systemdUnitContent(exePath string) string { +func systemdUnitContent(exePath, transport string) string { workingDir := filepath.Dir(exePath) return fmt.Sprintf(`[Unit] Description=VIIPER server @@ -79,13 +95,13 @@ Wants=network-online.target [Service] Type=simple -ExecStart=%q server +ExecStart=%q server --transport %s WorkingDirectory=%s Restart=on-failure [Install] WantedBy=multi-user.target -`, exePath, workingDir) +`, exePath, transport, workingDir) } func runSystemctl(args ...string) error { diff --git a/internal/cmd/install_windows.go b/internal/cmd/install_windows.go index 86568546..7b2338be 100644 --- a/internal/cmd/install_windows.go +++ b/internal/cmd/install_windows.go @@ -5,6 +5,7 @@ package cmd import ( "bufio" "bytes" + "context" "errors" "fmt" "log/slog" @@ -13,8 +14,11 @@ import ( "path/filepath" "strconv" "strings" + "time" "github.com/Alia5/VIIPER/internal/configpaths" + "github.com/Alia5/VIIPER/internal/transport/udecx" + "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" ) @@ -24,13 +28,36 @@ const ( runScheduledTask = "RunVIIPER" ) -func install(logger *slog.Logger) error { +func install(logger *slog.Logger, transport, targetUserSID string) error { + if transport == "native-ude" { + if err := requireDeveloperStandaloneNativeInstall(); err != nil { + return err + } + release, err := acquireNamedNativePackageMutex( + nativePackageMutexName, nativePackageTransactionTimeout, + ) + if err != nil { + return err + } + defer release() + return installNativeBroker(logger, targetUserSID) + } + if targetUserSID != "" { + return errors.New("--target-user-sid is valid only with --transport native-ude") + } if os.Getenv("VIIPER_DEVELOPER_STANDALONE") != "1" { return errors.New("standalone VIIPER startup registration is developer-only on Windows; use the signed DS4Windows installer or its built-in VIIPER repair so one verified owner manages VIIPER and USB-IP") } - if err := requireUSBIPRuntime(); err != nil { + release, err := acquireNativeInstallMutex(nativeServiceInstallTimeout) + if err != nil { return err } + defer release() + if transport == "usbip" { + if err := requireUSBIPRuntime(); err != nil { + return err + } + } scheduledExe, err := currentScheduledTaskExe() if err != nil { return fmt.Errorf("failed to inspect legacy %s scheduled task: %w", runScheduledTask, err) @@ -63,7 +90,12 @@ func install(logger *slog.Logger) error { return fmt.Errorf("failed to create log directory %s: %w", cfgDir, err) } - value := fmt.Sprintf("\"%s\" server --log.file \"%s\"", exePath, logFile) + if previousExe != "" { + if err := killProcessesByExe(previousExe, logger); err != nil { + return fmt.Errorf("failed to stop previous autorun instance: %w", err) + } + } + value := windowsAutorunCommand(exePath, transport, logFile) key, _, err := registry.CreateKey(registry.CURRENT_USER, runKeyPath, registry.ALL_ACCESS) if err != nil { return err @@ -74,77 +106,76 @@ func install(logger *slog.Logger) error { return err } - if previousExe != "" { - if err := killProcessesByExe(previousExe, logger); err != nil { - return fmt.Errorf("failed to stop previous autorun instance: %w", err) - } - } - - if err := exec.Command(exePath, "server", "--log.file", logFile).Start(); err != nil { + if err := exec.Command(exePath, serverArguments(transport, logFile)...).Start(); err != nil { return fmt.Errorf("failed to start server: %w", err) } - logger.Info("VIIPER install completed for Windows autorun", "exe", exePath, "logFile", logFile) + logger.Info("VIIPER install completed for Windows autorun", "exe", exePath, + "transport", transport, "logFile", logFile) return nil } -func uninstall(logger *slog.Logger) error { - autorunExe, err := currentAutorunExe() - if err != nil { - return err - } - scheduledExe, err := currentScheduledTaskExe() - if err != nil { - return fmt.Errorf("failed to inspect %s scheduled task: %w", runScheduledTask, err) - } - if err := removeScheduledTask(); err != nil { - return fmt.Errorf("failed to remove %s scheduled task; run uninstall as administrator: %w", runScheduledTask, err) - } - if scheduledExe != "" { - if err := killProcessesByExe(scheduledExe, logger); err != nil { - return fmt.Errorf("failed to stop scheduled VIIPER instance: %w", err) - } +func requireDeveloperStandaloneNativeInstall() error { + if os.Getenv("VIIPER_DEVELOPER_STANDALONE") != "1" { + return errors.New("standalone native UDE installation is developer-only on Windows; use the signed package installer or set VIIPER_DEVELOPER_STANDALONE=1 for an explicitly unsupported test machine") } + return nil +} - key, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE) - if err != nil { - if !errors.Is(err, registry.ErrNotExist) { - return err - } - } else { - defer key.Close() //nolint:errcheck +func serverArguments(transport, logFile string) []string { + return []string{"server", "--transport", transport, "--log.file", logFile} +} - if err := key.DeleteValue(runValueKey); err != nil { - if !errors.Is(err, registry.ErrNotExist) { - return err - } - } - } +func windowsAutorunCommand(exePath, transport, logFile string) string { + return fmt.Sprintf("\"%s\" server --transport %s --log.file \"%s\"", + exePath, transport, logFile) +} - if autorunExe != "" { - if err := killProcessesByExe(autorunExe, logger); err != nil { - return fmt.Errorf("failed to stop autorun instance: %w", err) - } +func requireNativeUDEBroker() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, err := udecx.Open(ctx) + if err != nil { + return fmt.Errorf("native UDE driver preflight failed without changing autorun: %w", err) } - - currentExe, currentErr := currentExecutable() - if currentErr == nil && !strings.EqualFold(currentExe, autorunExe) { - if err := killProcessesByExe(currentExe, logger); err != nil { - return fmt.Errorf("failed to stop installed VIIPER instance: %w", err) - } + if err := client.Close(); err != nil { + return fmt.Errorf("native UDE driver preflight close failed without changing autorun: %w", err) } - - logger.Info("VIIPER startup entries removed and server stopped") return nil } +func uninstall( + logger *slog.Logger, + targetUserSID, driverHelper, expectedHelperSHA256 string, +) error { + request := nativePackageUninstallRequest{ + driverHelper: driverHelper, expectedHelperSHA256: expectedHelperSHA256, + targetUserSID: targetUserSID, + } + if err := request.validate(); err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), nativePackageTransactionTimeout) + defer cancel() + return uninstallNativePackage(ctx, logger, request) +} + func currentScheduledTaskExe() (string, error) { - script := fmt.Sprintf( - "$ErrorActionPreference='Stop';$t=Get-ScheduledTask -TaskName '%s' -ErrorAction SilentlyContinue;if($null -eq $t){exit 0};$a=@($t.Actions);if($a.Count -ne 1){throw 'scheduled task must contain exactly one action'};$a[0].Execute", - runScheduledTask, - ) - output, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script).CombinedOutput() + // Enumerate the exact root task and fail closed on provider errors. A + // targeted Get-ScheduledTask call with SilentlyContinue cannot distinguish + // "not found" from an unavailable or access-denied Task Scheduler provider. + script := `$ErrorActionPreference='Stop';$m=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -ceq '\' -and $_.TaskName -ieq 'RunVIIPER'});if($m.Count -eq 0){exit 0};if($m.Count -ne 1){throw 'expected zero or one root RunVIIPER task'};$a=@($m[0].Actions);if($a.Count -ne 1){throw 'scheduled task must contain exactly one action'};$a[0].Execute` + powershell, err := trustedSystemExecutable("WindowsPowerShell", "v1.0", "powershell.exe") if err != nil { + return "", fmt.Errorf("resolve trusted PowerShell: %w", err) + } + ctx, cancel := context.WithTimeout(context.Background(), nativeServiceInstallTimeout) + defer cancel() + output, err := exec.CommandContext(ctx, powershell, "-NoProfile", "-NonInteractive", "-Command", script).CombinedOutput() + if err != nil { + if ctx.Err() != nil { + return "", fmt.Errorf("scheduled task query timed out: %w", ctx.Err()) + } return "", fmt.Errorf("scheduled task query failed: %w: %s", err, strings.TrimSpace(string(output))) } path := strings.Trim(strings.TrimSpace(string(output)), `"`) @@ -162,14 +193,18 @@ func removeScheduledTask() error { // Get-ScheduledTask makes absence distinguishable from an access-denied // deletion. Never report uninstall success while a highest-privilege task // can silently start VIIPER again at the next logon. - script := fmt.Sprintf( - "$ErrorActionPreference='Stop';$t=Get-ScheduledTask -TaskName '%s' -ErrorAction SilentlyContinue;if($null -eq $t){exit 0};Unregister-ScheduledTask -TaskName '%s' -Confirm:$false -ErrorAction Stop;if(Get-ScheduledTask -TaskName '%s' -ErrorAction SilentlyContinue){throw 'scheduled task still exists'}", - runScheduledTask, - runScheduledTask, - runScheduledTask, - ) - output, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script).CombinedOutput() + script := `$ErrorActionPreference='Stop';$m=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -ceq '\' -and $_.TaskName -ieq 'RunVIIPER'});if($m.Count -eq 0){exit 0};if($m.Count -ne 1){throw 'expected exactly one root RunVIIPER task'};Unregister-ScheduledTask -TaskName $m[0].TaskName -TaskPath '\' -Confirm:$false -ErrorAction Stop;$after=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -ceq '\' -and $_.TaskName -ieq 'RunVIIPER'});if($after.Count -ne 0){throw 'scheduled task still exists'}` + powershell, err := trustedSystemExecutable("WindowsPowerShell", "v1.0", "powershell.exe") if err != nil { + return fmt.Errorf("resolve trusted PowerShell: %w", err) + } + ctx, cancel := context.WithTimeout(context.Background(), nativeServiceInstallTimeout) + defer cancel() + output, err := exec.CommandContext(ctx, powershell, "-NoProfile", "-NonInteractive", "-Command", script).CombinedOutput() + if err != nil { + if ctx.Err() != nil { + return fmt.Errorf("scheduled task removal timed out: %w", ctx.Err()) + } return fmt.Errorf("scheduled task removal failed: %w: %s", err, strings.TrimSpace(string(output))) } return nil @@ -227,7 +262,11 @@ func killProcessesByExe(target string, logger *slog.Logger) error { "$ErrorActionPreference='SilentlyContinue';$t='%s';Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -eq $t } | Select-Object -ExpandProperty ProcessId", strings.ReplaceAll(target, "'", "''"), ) - cmd := exec.Command("powershell", "-NoProfile", "-Command", script) + powershell, err := trustedSystemExecutable("WindowsPowerShell", "v1.0", "powershell.exe") + if err != nil { + return fmt.Errorf("resolve trusted PowerShell: %w", err) + } + cmd := exec.Command(powershell, "-NoProfile", "-Command", script) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("process query failed: %w: %s", err, strings.TrimSpace(string(output))) @@ -259,7 +298,11 @@ func killProcessesByExe(target string, logger *slog.Logger) error { if pid == self { continue } - cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F") + taskkill, pathErr := trustedSystemExecutable("taskkill.exe") + if pathErr != nil { + return fmt.Errorf("resolve trusted taskkill: %w", pathErr) + } + cmd := exec.Command(taskkill, "/PID", strconv.Itoa(pid), "/T", "/F") output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("taskkill pid %d failed: %w: %s", pid, err, strings.TrimSpace(string(output))) @@ -269,3 +312,34 @@ func killProcessesByExe(target string, logger *slog.Logger) error { return nil } + +func trustedSystemExecutable(relativeParts ...string) (string, error) { + if len(relativeParts) == 0 { + return "", errors.New("trusted system executable path is empty") + } + for _, part := range relativeParts { + if part == "" || part == "." || part == ".." || filepath.Base(part) != part { + return "", fmt.Errorf("invalid trusted system path component %q", part) + } + } + systemDirectory, err := windows.GetSystemDirectory() + if err != nil { + return "", err + } + current := filepath.Clean(systemDirectory) + root, err := openNativePathWithoutReparse(current, windows.FILE_READ_ATTRIBUTES, true) + if err != nil { + return "", fmt.Errorf("open Windows system directory: %w", err) + } + windows.CloseHandle(root) //nolint:errcheck + for index, part := range relativeParts { + current = filepath.Join(current, part) + isDirectory := index < len(relativeParts)-1 + handle, err := openNativePathWithoutReparse(current, windows.FILE_READ_ATTRIBUTES, isDirectory) + if err != nil { + return "", fmt.Errorf("open trusted system path %s: %w", current, err) + } + windows.CloseHandle(handle) //nolint:errcheck + } + return current, nil +} diff --git a/internal/cmd/install_windows_test.go b/internal/cmd/install_windows_test.go new file mode 100644 index 00000000..1203f025 --- /dev/null +++ b/internal/cmd/install_windows_test.go @@ -0,0 +1,23 @@ +//go:build windows + +package cmd + +import ( + "reflect" + "testing" +) + +func TestLegacyUSBIPInstallPersistsExplicitTransport(t *testing.T) { + exe := `C:\Program Files\VIIPER\viiper.exe` + logFile := `C:\Users\test user\AppData\Local\VIIPER\viiper.log` + + wantArgs := []string{"server", "--transport", "usbip", "--log.file", logFile} + if got := serverArguments("usbip", logFile); !reflect.DeepEqual(got, wantArgs) { + t.Fatalf("server arguments=%q want=%q", got, wantArgs) + } + + wantCommand := `"C:\Program Files\VIIPER\viiper.exe" server --transport usbip --log.file "C:\Users\test user\AppData\Local\VIIPER\viiper.log"` + if got := windowsAutorunCommand(exe, "usbip", logFile); got != wantCommand { + t.Fatalf("autorun command=%q want=%q", got, wantCommand) + } +} diff --git a/internal/cmd/native_broker_journal_windows.go b/internal/cmd/native_broker_journal_windows.go new file mode 100644 index 00000000..dfe2f03a --- /dev/null +++ b/internal/cmd/native_broker_journal_windows.go @@ -0,0 +1,3909 @@ +//go:build windows + +package cmd + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "slices" + "strings" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +const ( + nativeBrokerJournalSchema = 1 + nativeBrokerJournalMaximumRecords = 96 + nativeBrokerJournalMaximumLine = 16 * 1024 + nativeBrokerJournalMaximumSnapshot = 128 * 1024 + nativeBrokerJournalMaximumSecret = 512 * 1024 + nativeBrokerJournalMaximumImage = 128 * 1024 * 1024 + nativeBrokerJournalMaximumSettlement = 16 * 1024 + nativeBrokerJournalRootName = "BrokerTransactions" + nativeBrokerJournalActiveName = "active-v1" + nativeBrokerJournalPreparingPrefix = "preparing-" + nativeBrokerJournalSettledPrefix = "settled-" + nativeBrokerJournalSnapshotName = "snapshot.json" + nativeBrokerJournalRecordsName = "journal.jsonl" + nativeBrokerJournalCredentialName = "prior-key.dpapi" + nativeBrokerJournalLegacyName = "prior-legacy.dpapi" + nativeBrokerJournalPriorImageName = "prior-image.exe" + nativeBrokerJournalSettlementName = "outer-settlement.json" + nativeBrokerJournalSettledReceiptName = "outer-settled.json" + nativeBrokerJournalSDDL = "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + nativeBrokerJournalFileSDDL = "O:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)" +) + +const ( + nativeBrokerCutRecordPartialWrite = "record-stage-partial-write" + nativeBrokerCutRecordWriteDone = "record-stage-write-complete" + nativeBrokerCutRecordSyncDone = "record-stage-sync-complete" + nativeBrokerCutRecordReadbackDone = "record-stage-readback-complete" + nativeBrokerCutRecordBeforePublish = "record-stage-before-publish" + nativeBrokerCutAfterBindingOutput = "settlement-after-binding-output" + nativeBrokerCutBeforePending = "settlement-before-broker-pending" + nativeBrokerCutAfterPending = "settlement-after-broker-pending" + nativeBrokerCutAfterRequest = "settlement-after-request-published" + nativeBrokerCutBeforeDriverAck = "settlement-before-driver-ack" + nativeBrokerCutAfterDriverAck = "settlement-after-driver-ack" + nativeBrokerCutBeforeBrokerFinal = "settlement-before-broker-final" + nativeBrokerCutAfterBrokerFinal = "settlement-after-broker-final" + nativeBrokerCutBeforeRetirement = "settlement-before-broker-retirement" + nativeBrokerCutAfterRetirement = "settlement-after-broker-retirement" + nativeBrokerCutAfterDiscard = "settlement-after-best-effort-discard" + nativeBrokerCutSettlementPartialWrite = "settlement-request-partial-write" + nativeBrokerCutSettlementWriteDone = "settlement-request-write-complete" + nativeBrokerCutSettlementSyncDone = "settlement-request-sync-complete" + nativeBrokerCutSettlementReadbackDone = "settlement-request-readback-complete" + nativeBrokerCutSettlementBeforePublish = "settlement-request-before-publish" +) + +type nativeBrokerJournalPhase string + +const ( + nativeBrokerPhasePrepared nativeBrokerJournalPhase = "prepared" + nativeBrokerPhaseServiceStopIntent nativeBrokerJournalPhase = "service-stop-intent" + nativeBrokerPhaseServiceStopped nativeBrokerJournalPhase = "service-stopped" + nativeBrokerPhaseImageSwitchIntent nativeBrokerJournalPhase = "image-switch-intent" + nativeBrokerPhaseImageSwitched nativeBrokerJournalPhase = "image-switched" + nativeBrokerPhaseLegacyStopIntent nativeBrokerJournalPhase = "legacy-stop-intent" + nativeBrokerPhaseLegacyStopped nativeBrokerJournalPhase = "legacy-stopped" + nativeBrokerPhaseCredentialWriteIntent nativeBrokerJournalPhase = "credential-write-intent" + nativeBrokerPhaseCredentialWritten nativeBrokerJournalPhase = "credential-written" + nativeBrokerPhaseServiceConfigIntent nativeBrokerJournalPhase = "service-config-intent" + nativeBrokerPhaseServiceConfigured nativeBrokerJournalPhase = "service-configured" + nativeBrokerPhaseServiceStartIntent nativeBrokerJournalPhase = "service-start-intent" + nativeBrokerPhaseServiceStarted nativeBrokerJournalPhase = "service-started" + nativeBrokerPhaseAuthenticated nativeBrokerJournalPhase = "authenticated" + nativeBrokerPhaseLegacyRemoveIntent nativeBrokerJournalPhase = "legacy-remove-intent" + nativeBrokerPhaseLegacyRemoved nativeBrokerJournalPhase = "legacy-removed" + nativeBrokerPhaseReauthenticated nativeBrokerJournalPhase = "reauthenticated" + nativeBrokerPhaseNestedReady nativeBrokerJournalPhase = "nested-ready" + nativeBrokerPhaseOuterSettlementPending nativeBrokerJournalPhase = "outer-settlement-pending" + nativeBrokerPhaseOuterSettled nativeBrokerJournalPhase = "outer-settled" + nativeBrokerPhaseRollbackIntent nativeBrokerJournalPhase = "rollback-intent" + nativeBrokerPhaseRollbackService nativeBrokerJournalPhase = "rollback-service" + nativeBrokerPhaseRollbackImage nativeBrokerJournalPhase = "rollback-image" + nativeBrokerPhaseRollbackCredential nativeBrokerJournalPhase = "rollback-credential" + nativeBrokerPhaseRollbackLegacy nativeBrokerJournalPhase = "rollback-legacy" + nativeBrokerPhaseRollbackSettled nativeBrokerJournalPhase = "rollback-settled" + nativeBrokerPhaseManual nativeBrokerJournalPhase = "manual" +) + +var nativeBrokerForwardPhaseOrder = []nativeBrokerJournalPhase{ + nativeBrokerPhasePrepared, + nativeBrokerPhaseServiceStopIntent, + nativeBrokerPhaseServiceStopped, + nativeBrokerPhaseImageSwitchIntent, + nativeBrokerPhaseImageSwitched, + nativeBrokerPhaseLegacyStopIntent, + nativeBrokerPhaseLegacyStopped, + nativeBrokerPhaseCredentialWriteIntent, + nativeBrokerPhaseCredentialWritten, + nativeBrokerPhaseServiceConfigIntent, + nativeBrokerPhaseServiceConfigured, + nativeBrokerPhaseServiceStartIntent, + nativeBrokerPhaseServiceStarted, + nativeBrokerPhaseAuthenticated, + nativeBrokerPhaseLegacyRemoveIntent, + nativeBrokerPhaseLegacyRemoved, + nativeBrokerPhaseReauthenticated, + nativeBrokerPhaseNestedReady, + nativeBrokerPhaseOuterSettlementPending, + nativeBrokerPhaseOuterSettled, +} + +var nativeBrokerRollbackPhaseOrder = []nativeBrokerJournalPhase{ + nativeBrokerPhaseRollbackIntent, + nativeBrokerPhaseRollbackService, + nativeBrokerPhaseRollbackCredential, + nativeBrokerPhaseRollbackImage, + nativeBrokerPhaseRollbackLegacy, + nativeBrokerPhaseRollbackSettled, +} + +type nativeBrokerJournalService struct { + Exists bool `json:"exists"` + WasRunning bool `json:"wasRunning"` + Config mgr.Config `json:"config"` + SecurityDescriptor string `json:"securityDescriptor"` + RecoveryActions []mgr.RecoveryAction `json:"recoveryActions"` + RecoveryResetSeconds uint32 `json:"recoveryResetSeconds"` + RecoverNonCrash bool `json:"recoverNonCrash"` +} + +type nativeBrokerJournalSnapshot struct { + Schema int `json:"schema"` + TransactionID string `json:"transactionId"` + OuterTransactionID string `json:"outerTransactionId"` + OuterTokenPath string `json:"outerTokenPath"` + TargetUserSID string `json:"targetUserSid"` + CandidatePath string `json:"candidatePath"` + CandidateSHA256 string `json:"candidateSha256"` + PriorImageExists bool `json:"priorImageExists"` + PriorImagePath string `json:"priorImagePath"` + PriorImageSHA256 string `json:"priorImageSha256"` + PriorCredentialSHA256 string `json:"priorCredentialSha256"` + PriorCredentialExists bool `json:"priorCredentialExists"` + PriorCredentialArtifact string `json:"priorCredentialArtifactSha256"` + PriorLegacyArtifact string `json:"priorLegacyArtifactSha256"` + Service nativeBrokerJournalService `json:"service"` +} + +type nativeBrokerJournalSnapshotEnvelope struct { + Schema int `json:"schema"` + PayloadSHA256 string `json:"payloadSha256"` + Payload nativeBrokerJournalSnapshot `json:"payload"` +} + +type nativeBrokerOuterSettlementBinding struct { + Schema int `json:"schema"` + BrokerTransactionID string `json:"brokerTransactionId"` + BrokerOuterTransactionID string `json:"brokerOuterTransactionId"` + BrokerCandidateSHA256 string `json:"brokerCandidateSha256"` + BrokerNestedDigest string `json:"brokerNestedDigest"` + DriverTransactionID string `json:"driverTransactionId"` + DriverPendingDigest string `json:"driverPendingDigest"` + SettlementNonce string `json:"settlementNonce"` +} + +type nativeBrokerOuterSettlementRequest struct { + Schema int `json:"schema"` + BindingSHA256 string `json:"bindingSha256"` + BrokerPendingDigest string `json:"brokerPendingDigest"` + Binding nativeBrokerOuterSettlementBinding `json:"binding"` +} + +type nativeBrokerOuterSettlementEnvelope struct { + Schema int `json:"schema"` + PayloadSHA256 string `json:"payloadSha256"` + Payload nativeBrokerOuterSettlementRequest `json:"payload"` +} + +type nativeBrokerOuterSettlementPrepared struct { + Request nativeBrokerOuterSettlementRequest + RequestPath string + RequestSHA256 string + contents []byte +} + +type nativeBrokerOuterSettlementFinal struct { + Schema int `json:"schema"` + BrokerTransactionID string `json:"brokerTransactionId"` + BrokerPendingDigest string `json:"brokerPendingDigest"` + BrokerSettledDigest string `json:"brokerSettledDigest"` + DriverTransactionID string `json:"driverTransactionId"` + DriverPendingDigest string `json:"driverPendingDigest"` + DriverSettledDigest string `json:"driverSettledDigest"` + SettlementNonce string `json:"settlementNonce"` + RequestSHA256 string `json:"requestSha256"` + State string `json:"state"` +} + +type nativeBrokerOuterSettlementFinalEnvelope struct { + Schema int `json:"schema"` + PayloadSHA256 string `json:"payloadSha256"` + Payload nativeBrokerOuterSettlementFinal `json:"payload"` +} + +type nativeBrokerOuterSettlementFinalPrepared struct { + Receipt nativeBrokerOuterSettlementFinal + ReceiptPath string + ReceiptSHA256 string + contents []byte +} + +func nativeBrokerDriverReceiptFromFinal( + receipt nativeBrokerOuterSettlementFinal, +) nativePackageBrokerSettlementReceipt { + return nativePackageBrokerSettlementReceipt{ + BrokerTransactionID: receipt.BrokerTransactionID, + BrokerPendingDigest: receipt.BrokerPendingDigest, + DriverTransactionID: receipt.DriverTransactionID, + DriverPendingDigest: receipt.DriverPendingDigest, + SettlementNonce: receipt.SettlementNonce, + RequestSHA256: receipt.RequestSHA256, + State: receipt.State, + Digest: receipt.DriverSettledDigest, + } +} + +type nativeBrokerJournalRecordUnsigned struct { + Schema int `json:"schema"` + Sequence uint32 `json:"sequence"` + TransactionID string `json:"transactionId"` + Phase nativeBrokerJournalPhase `json:"phase"` + PreviousSHA256 string `json:"previousSha256"` + SnapshotSHA256 string `json:"snapshotSha256"` + DetailSHA256 string `json:"detailSha256"` +} + +type nativeBrokerJournalRecord struct { + Schema int `json:"schema"` + Sequence uint32 `json:"sequence"` + TransactionID string `json:"transactionId"` + Phase nativeBrokerJournalPhase `json:"phase"` + PreviousSHA256 string `json:"previousSha256"` + SnapshotSHA256 string `json:"snapshotSha256"` + DetailSHA256 string `json:"detailSha256"` + RecordSHA256 string `json:"recordSha256"` +} + +type nativeBrokerJournalCredentialSnapshot struct { + Schema int `json:"schema"` + Exists bool `json:"exists"` + Bytes []byte `json:"bytes"` +} + +type nativeBrokerJournalLegacySnapshot struct { + Schema int `json:"schema"` + UserSID string `json:"userSid"` + RunKeyExisted bool `json:"runKeyExisted"` + RunValue *nativeRunRegistration `json:"-"` + RunValueText *string `json:"runValue,omitempty"` + RunValueType uint32 `json:"runValueType"` + ScheduledXML *string `json:"scheduledXml,omitempty"` + ScheduledActive bool `json:"scheduledActive"` + ScheduledEnabled bool `json:"scheduledEnabled"` + Commands []nativeLegacyCommand `json:"-"` + SerializableCmds []nativeBrokerCommand `json:"commands"` +} + +type nativeBrokerCommand struct { + Executable string `json:"executable"` + Arguments []string `json:"arguments"` + WorkingDirectory string `json:"workingDirectory"` + Source uint8 `json:"source"` + WasRunning bool `json:"wasRunning"` +} + +type nativeBrokerJournal struct { + directory string + snapshot nativeBrokerJournalSnapshot + snapshotDigest string + records []nativeBrokerJournalRecord + priorLegacy *nativeBrokerJournalLegacySnapshot + cutpoint func(string) error + appendRecord func([]byte) error +} + +type nativeBrokerJournalManualError struct { + cause error +} + +type nativeBrokerJournalRetirementOperations struct { + rename func() error + proveActiveAbsent func() error + proveTombstone func() error + discardTombstone func() error + cutpoint func(string) error +} + +type nativeBrokerJournalRecordPublicationOperations struct { + loadCurrent func() ([]byte, error) + discardStaging func() error + stage func([]byte) error + beforePublish func() error + publish func() error +} + +type nativeBrokerJournalPreparationOperations struct { + createDirectory func() error + writeCredential func() error + writeLegacy func() error + writePriorImage func() error + writeSnapshot func() error + createRecordStream func() error + writePrepared func() error + publishActive func() error + cutpoint func(string) error +} + +type nativeBrokerOuterSettlementOperations struct { + recordPending func() error + publishRequest func() error + acknowledgeDriver func() error + recordBrokerSettled func() error + retireBrokerJournal func() error + discardInertState func() error + observeDiscardError func(error) + cutpoint func(string) error +} + +type nativeBrokerSettlementPublicationOperations struct { + loadPublished func() ([]byte, bool, error) + loadStaging func() ([]byte, bool, error) + discardStaging func() error + publishStaging func() error + writeNew func() error + readback func() ([]byte, error) +} + +func executeNativeBrokerJournalPreparation( + operations nativeBrokerJournalPreparationOperations, +) error { + steps := []struct { + name string + run func() error + }{ + {"directory-created", operations.createDirectory}, + {"credential-written", operations.writeCredential}, + {"legacy-written", operations.writeLegacy}, + {"prior-image-written", operations.writePriorImage}, + {"snapshot-written", operations.writeSnapshot}, + {"record-stream-created", operations.createRecordStream}, + {"prepared-written", operations.writePrepared}, + {"active-published", operations.publishActive}, + } + for _, step := range steps { + if step.run == nil { + return fmt.Errorf("native broker journal preparation operation %s is missing", step.name) + } + if err := step.run(); err != nil { + return err + } + if operations.cutpoint != nil { + if err := operations.cutpoint("prepare-" + step.name); err != nil { + return err + } + } + } + return nil +} + +func executeNativeBrokerOuterSettlement( + operations nativeBrokerOuterSettlementOperations, +) error { + if operations.recordPending == nil || operations.publishRequest == nil || + operations.acknowledgeDriver == nil || operations.recordBrokerSettled == nil || + operations.retireBrokerJournal == nil || operations.discardInertState == nil { + return errors.New("native broker outer settlement operations are incomplete") + } + cut := func(name string) error { + if operations.cutpoint == nil { + return nil + } + return operations.cutpoint(name) + } + if err := cut(nativeBrokerCutAfterBindingOutput); err != nil { + return err + } + if err := cut(nativeBrokerCutBeforePending); err != nil { + return err + } + if err := operations.recordPending(); err != nil { + return err + } + if err := cut(nativeBrokerCutAfterPending); err != nil { + return err + } + if err := operations.publishRequest(); err != nil { + return err + } + if err := cut(nativeBrokerCutAfterRequest); err != nil { + return err + } + if err := cut(nativeBrokerCutBeforeDriverAck); err != nil { + return err + } + if err := operations.acknowledgeDriver(); err != nil { + return err + } + if err := cut(nativeBrokerCutAfterDriverAck); err != nil { + return err + } + if err := cut(nativeBrokerCutBeforeBrokerFinal); err != nil { + return err + } + if err := operations.recordBrokerSettled(); err != nil { + return err + } + if err := cut(nativeBrokerCutAfterBrokerFinal); err != nil { + return err + } + // The protected broker-final receipt is now authoritative. The driver + // tombstone may only leave exact settled discovery after validating that + // receipt; any recursive cleanup after its atomic rename is inert. + if err := operations.discardInertState(); err != nil { + if operations.observeDiscardError != nil { + operations.observeDiscardError(err) + } + return err + } + if err := cut(nativeBrokerCutAfterDiscard); err != nil { + return err + } + if err := cut(nativeBrokerCutBeforeRetirement); err != nil { + return err + } + if err := operations.retireBrokerJournal(); err != nil { + return err + } + if err := cut(nativeBrokerCutAfterRetirement); err != nil { + return err + } + return nil +} + +func executeNativeBrokerSettlementPublication( + expected []byte, + operations nativeBrokerSettlementPublicationOperations, +) error { + if len(expected) == 0 || operations.loadPublished == nil || + operations.loadStaging == nil || operations.discardStaging == nil || + operations.publishStaging == nil || operations.writeNew == nil || + operations.readback == nil { + return errors.New("native broker settlement publication operations are incomplete") + } + published, publishedExists, err := operations.loadPublished() + if err != nil { + if publishedExists { + return &nativeBrokerJournalManualError{cause: fmt.Errorf( + "read published broker settlement request: %w", err, + )} + } + return err + } + staged, stagingExists, stagingErr := operations.loadStaging() + if stagingExists { + if !publishedExists && stagingErr == nil && bytes.Equal(staged, expected) { + if err := operations.publishStaging(); err != nil { + return err + } + published, publishedExists = expected, true + } else if err := operations.discardStaging(); err != nil { + return err + } + } else if stagingErr != nil { + return stagingErr + } + if publishedExists { + if !bytes.Equal(published, expected) { + return &nativeBrokerJournalManualError{cause: errors.New( + "published broker settlement request differs from the authoritative binding", + )} + } + } else if err := operations.writeNew(); err != nil { + return err + } + readback, err := operations.readback() + if err != nil { + return err + } + if !bytes.Equal(readback, expected) { + return errors.New("broker settlement request failed write-through readback") + } + return nil +} + +func (e *nativeBrokerJournalManualError) Error() string { + return "native broker recovery requires manual reconciliation: " + e.cause.Error() +} + +func (e *nativeBrokerJournalManualError) Unwrap() error { return e.cause } + +func nativeBrokerJournalHash(data []byte) string { + digest := sha256.Sum256(data) + return hex.EncodeToString(digest[:]) +} + +func isCanonicalNativeBrokerJournalSHA256(value string) bool { + return value == strings.ToLower(value) && nativePackageSHA256.MatchString(value) +} + +func nativeBrokerJournalCanonicalJSON(value any) ([]byte, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + if bytes.IndexByte(data, '\n') >= 0 || len(data) > nativeBrokerJournalMaximumSnapshot { + return nil, errors.New("native broker journal canonical payload exceeds its bound") + } + return data, nil +} + +func decodeCanonicalNativeBrokerJSON(data []byte, value any, maximum int) error { + if len(data) == 0 || len(data) > maximum || bytes.IndexByte(data, '\n') >= 0 { + return errors.New("native broker journal payload has an invalid length or framing") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + return err + } + if decoder.More() { + return errors.New("native broker journal payload has trailing JSON") + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return errors.New("native broker journal payload has trailing data") + } + canonical, err := json.Marshal(value) + if err != nil { + return err + } + if !bytes.Equal(canonical, data) { + return errors.New("native broker journal payload is not in canonical byte form") + } + return nil +} + +func nativeBrokerJournalPhaseIndex(phases []nativeBrokerJournalPhase, phase nativeBrokerJournalPhase) int { + return slices.Index(phases, phase) +} + +func validateNativeBrokerJournalTransition(previous, next nativeBrokerJournalPhase) error { + if next == nativeBrokerPhaseManual { + if previous == nativeBrokerPhaseOuterSettled || previous == nativeBrokerPhaseRollbackSettled { + return errors.New("settled native broker journal cannot become manual") + } + return nil + } + if previous == nativeBrokerPhaseManual || previous == nativeBrokerPhaseOuterSettled || + previous == nativeBrokerPhaseRollbackSettled { + return fmt.Errorf("native broker journal phase %s is terminal", previous) + } + if next == nativeBrokerPhaseOuterSettlementPending && previous != nativeBrokerPhaseNestedReady { + return errors.New("native broker outer settlement must be armed directly from nested-ready") + } + if next == nativeBrokerPhaseOuterSettled && previous != nativeBrokerPhaseOuterSettlementPending { + return errors.New("native broker outer settlement requires the durable pending handshake") + } + previousForward := nativeBrokerJournalPhaseIndex(nativeBrokerForwardPhaseOrder, previous) + nextForward := nativeBrokerJournalPhaseIndex(nativeBrokerForwardPhaseOrder, next) + previousRollback := nativeBrokerJournalPhaseIndex(nativeBrokerRollbackPhaseOrder, previous) + nextRollback := nativeBrokerJournalPhaseIndex(nativeBrokerRollbackPhaseOrder, next) + if next == nativeBrokerPhaseRollbackIntent && previousForward >= 0 { + return nil + } + if previousRollback >= 0 && nextRollback > previousRollback { + return nil + } + if previousForward >= 0 && nextForward > previousForward { + return nil + } + return fmt.Errorf("invalid native broker journal transition %s -> %s", previous, next) +} + +func (j *nativeBrokerJournal) lastPhase() nativeBrokerJournalPhase { + if len(j.records) == 0 { + return "" + } + return j.records[len(j.records)-1].Phase +} + +func (j *nativeBrokerJournal) proof() nativeBrokerJournalProof { + digest := "" + if len(j.records) != 0 { + digest = j.records[len(j.records)-1].RecordSHA256 + } + return nativeBrokerJournalProof{ + TransactionID: j.snapshot.TransactionID, + OuterTransactionID: j.snapshot.OuterTransactionID, + CandidateSHA256: j.snapshot.CandidateSHA256, + State: string(j.lastPhase()), + Digest: digest, + } +} + +func (j *nativeBrokerJournal) appendPhase(phase nativeBrokerJournalPhase, detailSHA256 string) error { + if j == nil { + return nil + } + if detailSHA256 != "" && !isCanonicalNativeBrokerJournalSHA256(detailSHA256) { + return errors.New("native broker journal detail digest is malformed") + } + if (phase == nativeBrokerPhaseOuterSettlementPending || + phase == nativeBrokerPhaseOuterSettled) && detailSHA256 == "" { + return errors.New("native broker two-phase settlement record requires a bound detail digest") + } + if len(j.records) == 0 { + if phase != nativeBrokerPhasePrepared { + return errors.New("native broker journal must begin with prepared") + } + } else { + if j.lastPhase() == phase && j.records[len(j.records)-1].DetailSHA256 == detailSHA256 { + return nil + } + previousRollback := nativeBrokerJournalPhaseIndex( + nativeBrokerRollbackPhaseOrder, j.lastPhase(), + ) + nextRollback := nativeBrokerJournalPhaseIndex(nativeBrokerRollbackPhaseOrder, phase) + if previousRollback >= 0 && nextRollback >= 0 && nextRollback <= previousRollback { + return nil + } + if err := validateNativeBrokerJournalTransition(j.lastPhase(), phase); err != nil { + return err + } + } + if len(j.records) >= nativeBrokerJournalMaximumRecords { + return errors.New("native broker journal record bound exhausted") + } + if j.cutpoint != nil { + if err := j.cutpoint("before-record-" + string(phase)); err != nil { + return err + } + } + previous := strings.Repeat("0", 64) + if len(j.records) != 0 { + previous = j.records[len(j.records)-1].RecordSHA256 + } + unsigned := nativeBrokerJournalRecordUnsigned{ + Schema: nativeBrokerJournalSchema, Sequence: uint32(len(j.records) + 1), + TransactionID: j.snapshot.TransactionID, Phase: phase, + PreviousSHA256: previous, SnapshotSHA256: j.snapshotDigest, + DetailSHA256: detailSHA256, + } + unsignedData, err := nativeBrokerJournalCanonicalJSON(unsigned) + if err != nil { + return err + } + record := nativeBrokerJournalRecord{ + Schema: unsigned.Schema, Sequence: unsigned.Sequence, + TransactionID: unsigned.TransactionID, Phase: unsigned.Phase, + PreviousSHA256: unsigned.PreviousSHA256, SnapshotSHA256: unsigned.SnapshotSHA256, + DetailSHA256: unsigned.DetailSHA256, RecordSHA256: nativeBrokerJournalHash(unsignedData), + } + line, err := nativeBrokerJournalCanonicalJSON(record) + if err != nil { + return err + } + if len(line)+1 > nativeBrokerJournalMaximumLine { + return errors.New("native broker journal record exceeds its bound") + } + appendRecord := j.appendRecord + if appendRecord == nil { + appendRecord = func(record []byte) error { + return appendNativeBrokerJournalRecord( + filepath.Join(j.directory, nativeBrokerJournalRecordsName), record, j.cutpoint, + ) + } + } + if err := appendRecord(line); err != nil { + return err + } + j.records = append(j.records, record) + if j.cutpoint != nil { + if err := j.cutpoint("after-record-" + string(phase)); err != nil { + return err + } + } + return nil +} + +func nativeBrokerJournalPaths(userSID string) (string, string, error) { + if _, err := validateNativeInstallingUserSID(userSID); err != nil { + return "", "", err + } + programData, err := windows.KnownFolderPath(windows.FOLDERID_ProgramData, windows.KF_FLAG_DEFAULT) + if err != nil { + return "", "", fmt.Errorf("resolve ProgramData for native broker journal: %w", err) + } + programData = filepath.Clean(programData) + product := filepath.Join(programData, "VIIPER") + root := filepath.Join(product, nativeBrokerJournalRootName) + active := filepath.Join(root, nativeBrokerJournalActiveName) + if !strings.EqualFold(filepath.Dir(root), product) || !strings.EqualFold(filepath.Dir(active), root) { + return "", "", errors.New("native broker journal path escaped its fixed ProgramData root") + } + return root, active, nil +} + +func nativeBrokerJournalActivePathUnbound() (string, error) { + programData, err := windows.KnownFolderPath(windows.FOLDERID_ProgramData, windows.KF_FLAG_DEFAULT) + if err != nil { + return "", err + } + programData = filepath.Clean(programData) + root := filepath.Join(programData, "VIIPER", nativeBrokerJournalRootName) + active := filepath.Join(root, nativeBrokerJournalActiveName) + if !strings.EqualFold(filepath.Dir(active), root) { + return "", errors.New("native broker active journal escaped its fixed root") + } + return active, nil +} + +func createOrOpenProtectedNativeBrokerJournalDirectory(path string, create bool) (windows.Handle, bool, error) { + security, err := nativeSecurityAttributes(nativeBrokerJournalSDDL) + if err != nil { + return 0, false, err + } + created := false + if create { + pointer, pointerErr := windows.UTF16PtrFromString(path) + if pointerErr != nil { + return 0, false, pointerErr + } + if createErr := windows.CreateDirectory(pointer, security); createErr == nil { + created = true + } else if !errors.Is(createErr, windows.ERROR_ALREADY_EXISTS) { + return 0, false, createErr + } + } + handle, err := openNativePathWithoutReparse( + path, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + ) + if err != nil { + return 0, created, err + } + if err := validateNativeSecurityDescriptor(handle, nativeBrokerJournalSDDL); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return 0, created, err + } + return handle, created, nil +} + +func ensureNativeBrokerJournalRoot(userSID string) (string, error) { + root, _, err := nativeBrokerJournalPaths(userSID) + if err != nil { + return "", err + } + product := filepath.Dir(root) + productHandle, err := secureNativeCredentialDirectory(product, userSID) + if err != nil { + return "", fmt.Errorf("validate native broker journal product root: %w", err) + } + defer windows.CloseHandle(productHandle) //nolint:errcheck + rootHandle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(root, true) + if err != nil { + return "", fmt.Errorf("create or validate native broker journal root: %w", err) + } + windows.CloseHandle(rootHandle) //nolint:errcheck + return root, nil +} + +func isNativeBrokerJournalTransactionID(value string) bool { + if len(value) != 32 || value != strings.ToLower(value) { + return false + } + _, err := hex.DecodeString(value) + return err == nil +} + +func nativeBrokerJournalSiblingPath(root, prefix, transactionID string) (string, error) { + if !isNativeBrokerJournalTransactionID(transactionID) { + return "", errors.New("native broker journal directory transaction identifier is malformed") + } + path := filepath.Join(root, prefix+transactionID) + if !strings.EqualFold(filepath.Dir(path), root) || filepath.Base(path) != prefix+transactionID { + return "", errors.New("native broker journal transaction directory escaped its fixed root") + } + return path, nil +} + +func createNativeBrokerJournalPreparingDirectory(userSID, transactionID string) (string, error) { + root, err := ensureNativeBrokerJournalRoot(userSID) + if err != nil { + return "", err + } + _, active, err := nativeBrokerJournalPaths(userSID) + if err != nil { + return "", err + } + if _, err := nativePathAttributes(active); err == nil { + return "", &nativeBrokerJournalManualError{cause: errors.New( + "an active protected broker journal already exists", + )} + } else if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return "", err + } + preparing, err := nativeBrokerJournalSiblingPath( + root, nativeBrokerJournalPreparingPrefix, transactionID, + ) + if err != nil { + return "", err + } + handle, created, err := createOrOpenProtectedNativeBrokerJournalDirectory(preparing, true) + if err != nil { + return "", fmt.Errorf("create native broker preparing journal: %w", err) + } + windows.CloseHandle(handle) //nolint:errcheck + if !created { + return "", &nativeBrokerJournalManualError{cause: errors.New( + "a transaction-identical protected preparing journal already exists", + )} + } + return preparing, nil +} + +func validateNativeBrokerJournalPublishedArtifacts(j *nativeBrokerJournal) error { + if j == nil || !isNativeBrokerJournalTransactionID(j.snapshot.TransactionID) { + return errors.New("native broker journal publication lacks an exact transaction") + } + expected := map[string]bool{ + nativeBrokerJournalSnapshotName: true, + nativeBrokerJournalRecordsName: true, + nativeBrokerJournalCredentialName: true, + nativeBrokerJournalLegacyName: true, + } + if j.snapshot.PriorImageExists { + expected[nativeBrokerJournalPriorImageName] = true + } + entries, err := os.ReadDir(j.directory) + if err != nil { + return err + } + if len(entries) != len(expected) { + return errors.New("native broker journal publication contains missing or extra artifacts") + } + for _, entry := range entries { + if entry.IsDir() || !expected[entry.Name()] { + return fmt.Errorf("native broker journal publication contains unexpected artifact %q", entry.Name()) + } + handle, err := openNativeBrokerJournalFile( + filepath.Join(j.directory, entry.Name()), windows.GENERIC_READ, windows.OPEN_EXISTING, + ) + if err != nil { + return err + } + windows.CloseHandle(handle) //nolint:errcheck + } + return nil +} + +func publishNativeBrokerJournalActive( + userSID string, + j *nativeBrokerJournal, +) error { + if j == nil || j.lastPhase() != nativeBrokerPhasePrepared { + return errors.New("native broker journal is not durably prepared for publication") + } + root, active, err := nativeBrokerJournalPaths(userSID) + if err != nil { + return err + } + expectedPreparing, err := nativeBrokerJournalSiblingPath( + root, nativeBrokerJournalPreparingPrefix, j.snapshot.TransactionID, + ) + if err != nil || !strings.EqualFold(filepath.Clean(j.directory), filepath.Clean(expectedPreparing)) { + return errors.Join(err, errors.New("native broker preparing directory identity changed before publication")) + } + loaded, err := loadNativeBrokerJournal(j.directory) + if err != nil { + return fmt.Errorf("read back prepared native broker journal: %w", err) + } + if loaded.lastPhase() != nativeBrokerPhasePrepared || + loaded.proof() != j.proof() || loaded.snapshotDigest != j.snapshotDigest { + return errors.New("prepared native broker journal readback changed before publication") + } + if _, _, err := loaded.loadProtectedArtifacts(); err != nil { + return fmt.Errorf("verify protected native broker recovery artifacts before publication: %w", err) + } + if err := validateNativeBrokerJournalPublishedArtifacts(loaded); err != nil { + return err + } + if err := moveNativePackageFile(j.directory, active, false); err != nil { + return fmt.Errorf("atomically publish prepared native broker journal: %w", err) + } + j.directory = active + handle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(active, false) + if err != nil { + return fmt.Errorf("validate published native broker journal directory: %w", err) + } + windows.CloseHandle(handle) //nolint:errcheck + reloaded, err := loadNativeBrokerJournal(active) + if err != nil { + return fmt.Errorf("read back published native broker journal: %w", err) + } + if reloaded.proof() != j.proof() || reloaded.snapshotDigest != j.snapshotDigest { + return errors.New("published native broker journal differs from its prepared receipt") + } + return nil +} + +func openNativeBrokerJournalFile(path string, access uint32, disposition uint32) (windows.Handle, error) { + security, err := nativeSecurityAttributes(nativeBrokerJournalFileSDDL) + if err != nil { + return 0, err + } + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + handle, err := windows.CreateFile( + pointer, access|windows.READ_CONTROL, windows.FILE_SHARE_READ, + security, disposition, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT| + windows.FILE_FLAG_WRITE_THROUGH, + 0, + ) + if err != nil { + return 0, err + } + fail := func(failErr error) (windows.Handle, error) { + windows.CloseHandle(handle) //nolint:errcheck + return 0, failErr + } + attribute := nativeFileAttributeTagInfo{} + if err := windows.GetFileInformationByHandleEx( + handle, windows.FileAttributeTagInfo, (*byte)(unsafe.Pointer(&attribute)), + uint32(unsafe.Sizeof(attribute)), + ); err != nil { + return fail(err) + } + if attribute.FileAttributes&(windows.FILE_ATTRIBUTE_DIRECTORY| + windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + return fail(errors.New("native broker journal artifact is not a regular file")) + } + if err := requireSingleNativeFileLink(handle); err != nil { + return fail(err) + } + if err := validateNativeSecurityDescriptor(handle, nativeBrokerJournalFileSDDL); err != nil { + return fail(err) + } + return handle, nil +} + +func writeNativeBrokerJournalFile(path string, contents []byte, maximum int) error { + if len(contents) == 0 || len(contents) > maximum { + return errors.New("native broker journal artifact has an invalid length") + } + next := path + ".next" + handle, err := openNativeBrokerJournalFile( + next, windows.GENERIC_READ|windows.GENERIC_WRITE, windows.CREATE_NEW, + ) + if err != nil { + return fmt.Errorf("create native broker journal staging artifact: %w", err) + } + file := os.NewFile(uintptr(handle), next) + if file == nil { + windows.CloseHandle(handle) //nolint:errcheck + return errors.New("wrap native broker journal staging artifact") + } + cleanup := true + defer func() { + file.Close() //nolint:errcheck + if cleanup { + os.Remove(next) //nolint:errcheck + } + }() + if _, err := file.Write(contents); err != nil { + return err + } + if err := file.Sync(); err != nil { + return err + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return err + } + readback, err := io.ReadAll(io.LimitReader(file, int64(maximum)+1)) + if err != nil { + return err + } + if !bytes.Equal(readback, contents) { + return errors.New("native broker journal artifact failed write-through readback") + } + if err := file.Close(); err != nil { + return err + } + if err := moveNativePackageFile(next, path, false); err != nil { + return err + } + cleanup = false + return nil +} + +func buildNativeBrokerJournalRecordStream(current, line []byte) ([]byte, error) { + if len(line) == 0 || len(line)+1 > nativeBrokerJournalMaximumLine || bytes.IndexByte(line, '\n') >= 0 { + return nil, errors.New("native broker journal record framing is invalid") + } + if len(current) > nativeBrokerJournalMaximumRecords*nativeBrokerJournalMaximumLine || + (len(current) != 0 && current[len(current)-1] != '\n') { + return nil, errors.New("native broker journal published record stream is not exactly framed") + } + if bytes.Count(current, []byte{'\n'}) >= nativeBrokerJournalMaximumRecords { + return nil, errors.New("native broker journal record stream exceeds its bound") + } + next := make([]byte, 0, len(current)+len(line)+1) + next = append(next, current...) + next = append(next, line...) + next = append(next, '\n') + if len(next) > nativeBrokerJournalMaximumRecords*nativeBrokerJournalMaximumLine { + return nil, errors.New("native broker journal record stream exceeds its bound") + } + return next, nil +} + +func discardUnpublishedNativeBrokerJournalFile(path string) error { + handle, err := openNativeBrokerJournalFile( + path, windows.GENERIC_READ|windows.DELETE, windows.OPEN_EXISTING, + ) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil + } + return err + } + windows.CloseHandle(handle) //nolint:errcheck + if err := deleteNativePackageFile(path); err != nil && + !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return err + } + return nil +} + +func stageNativeBrokerJournalRecordStream( + path string, + contents []byte, + cutpoint func(string) error, +) (resultErr error) { + if len(contents) == 0 || len(contents) > nativeBrokerJournalMaximumRecords*nativeBrokerJournalMaximumLine { + return errors.New("native broker journal staged record stream has an invalid length") + } + handle, err := openNativeBrokerJournalFile( + path, windows.GENERIC_READ|windows.GENERIC_WRITE, windows.CREATE_NEW, + ) + if err != nil { + return err + } + file := os.NewFile(uintptr(handle), path) + if file == nil { + windows.CloseHandle(handle) //nolint:errcheck + return errors.New("wrap native broker journal staged record stream") + } + defer func() { + if closeErr := file.Close(); resultErr == nil && closeErr != nil { + resultErr = closeErr + } + }() + partial := len(contents) / 2 + if partial == 0 { + partial = len(contents) + } + if written, err := file.Write(contents[:partial]); err != nil || written != partial { + if err == nil { + err = io.ErrShortWrite + } + return err + } + if cutpoint != nil { + if err := cutpoint(nativeBrokerCutRecordPartialWrite); err != nil { + return err + } + } + if partial < len(contents) { + if written, err := file.Write(contents[partial:]); err != nil || written != len(contents)-partial { + if err == nil { + err = io.ErrShortWrite + } + return err + } + } + if cutpoint != nil { + if err := cutpoint(nativeBrokerCutRecordWriteDone); err != nil { + return err + } + } + if err := file.Sync(); err != nil { + return err + } + if cutpoint != nil { + if err := cutpoint(nativeBrokerCutRecordSyncDone); err != nil { + return err + } + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return err + } + readback := make([]byte, len(contents)) + if _, err := io.ReadFull(file, readback); err != nil { + return err + } + if !bytes.Equal(readback, contents) { + return errors.New("native broker journal record failed write-through readback") + } + if cutpoint != nil { + if err := cutpoint(nativeBrokerCutRecordReadbackDone); err != nil { + return err + } + } + return nil +} + +func executeNativeBrokerJournalRecordPublication( + line []byte, + operations nativeBrokerJournalRecordPublicationOperations, +) error { + if operations.loadCurrent == nil || operations.discardStaging == nil || + operations.stage == nil || operations.beforePublish == nil || operations.publish == nil { + return errors.New("native broker journal record publication operations are incomplete") + } + current, err := operations.loadCurrent() + if err != nil { + return err + } + next, err := buildNativeBrokerJournalRecordStream(current, line) + if err != nil { + return err + } + if err := operations.discardStaging(); err != nil { + return err + } + if err := operations.stage(next); err != nil { + return err + } + if err := operations.beforePublish(); err != nil { + return err + } + return operations.publish() +} + +func appendNativeBrokerJournalRecord( + path string, + line []byte, + cutpoint func(string) error, +) error { + maximum := nativeBrokerJournalMaximumRecords * nativeBrokerJournalMaximumLine + staging := path + ".next" + published := false + defer func() { + if !published { + discardUnpublishedNativeBrokerJournalFile(staging) //nolint:errcheck + } + }() + return executeNativeBrokerJournalRecordPublication( + line, + nativeBrokerJournalRecordPublicationOperations{ + loadCurrent: func() ([]byte, error) { + handle, err := openNativeBrokerJournalFile( + path, windows.GENERIC_READ, windows.OPEN_EXISTING, + ) + if err != nil { + return nil, fmt.Errorf("open native broker journal record stream: %w", err) + } + file := os.NewFile(uintptr(handle), path) + if file == nil { + windows.CloseHandle(handle) //nolint:errcheck + return nil, errors.New("wrap native broker journal record stream") + } + current, readErr := io.ReadAll(io.LimitReader(file, int64(maximum)+1)) + closeErr := file.Close() + return current, errors.Join(readErr, closeErr) + }, + discardStaging: func() error { + if err := discardUnpublishedNativeBrokerJournalFile(staging); err != nil { + return fmt.Errorf("discard stale unpublished broker journal record stream: %w", err) + } + return nil + }, + stage: func(next []byte) error { + return stageNativeBrokerJournalRecordStream(staging, next, cutpoint) + }, + beforePublish: func() error { + if cutpoint == nil { + return nil + } + return cutpoint(nativeBrokerCutRecordBeforePublish) + }, + publish: func() error { + if err := replaceNativePackageFileAtomically(staging, path, true); err != nil { + return fmt.Errorf("atomically publish native broker journal record stream: %w", err) + } + published = true + return nil + }, + }, + ) +} + +func readNativeBrokerJournalFile(path string, maximum int) ([]byte, error) { + handle, err := openNativeBrokerJournalFile(path, windows.GENERIC_READ, windows.OPEN_EXISTING) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(handle), path) + if file == nil { + windows.CloseHandle(handle) //nolint:errcheck + return nil, errors.New("wrap native broker journal artifact") + } + defer file.Close() //nolint:errcheck + contents, err := io.ReadAll(io.LimitReader(file, int64(maximum)+1)) + if err != nil { + return nil, err + } + if len(contents) == 0 || len(contents) > maximum { + return nil, errors.New("native broker journal artifact exceeds its bound") + } + return contents, nil +} + +func loadNativeBrokerJournal(directory string) (*nativeBrokerJournal, error) { + snapshotBytes, err := readNativeBrokerJournalFile( + filepath.Join(directory, nativeBrokerJournalSnapshotName), nativeBrokerJournalMaximumSnapshot, + ) + if err != nil { + return nil, fmt.Errorf("read native broker journal snapshot: %w", err) + } + var envelope nativeBrokerJournalSnapshotEnvelope + if err := decodeCanonicalNativeBrokerJSON(snapshotBytes, &envelope, nativeBrokerJournalMaximumSnapshot); err != nil { + return nil, fmt.Errorf("decode native broker journal snapshot: %w", err) + } + payloadBytes, err := nativeBrokerJournalCanonicalJSON(envelope.Payload) + if err != nil { + return nil, err + } + if envelope.Schema != nativeBrokerJournalSchema || envelope.Payload.Schema != nativeBrokerJournalSchema || + !isCanonicalNativeBrokerJournalSHA256(envelope.PayloadSHA256) || + envelope.PayloadSHA256 != nativeBrokerJournalHash(payloadBytes) { + return nil, errors.New("native broker journal snapshot digest or schema is invalid") + } + if err := validateNativeBrokerJournalSnapshot(envelope.Payload); err != nil { + return nil, err + } + recordBytes, err := readNativeBrokerJournalFile( + filepath.Join(directory, nativeBrokerJournalRecordsName), + nativeBrokerJournalMaximumRecords*nativeBrokerJournalMaximumLine, + ) + if err != nil { + return nil, fmt.Errorf("read native broker journal records: %w", err) + } + if recordBytes[len(recordBytes)-1] != '\n' { + return nil, errors.New("native broker journal published record stream has a torn trailing record") + } + j := &nativeBrokerJournal{ + directory: directory, snapshot: envelope.Payload, snapshotDigest: envelope.PayloadSHA256, + } + scanner := bufio.NewScanner(bytes.NewReader(recordBytes)) + scanner.Buffer(make([]byte, 1024), nativeBrokerJournalMaximumLine) + for scanner.Scan() { + if len(j.records) >= nativeBrokerJournalMaximumRecords { + return nil, errors.New("native broker journal contains too many records") + } + line := append([]byte(nil), scanner.Bytes()...) + var record nativeBrokerJournalRecord + if err := decodeCanonicalNativeBrokerJSON(line, &record, nativeBrokerJournalMaximumLine); err != nil { + return nil, fmt.Errorf("decode native broker journal record: %w", err) + } + if err := j.validateLoadedRecord(record); err != nil { + return nil, err + } + j.records = append(j.records, record) + } + if err := scanner.Err(); err != nil { + return nil, err + } + if len(j.records) == 0 || j.records[0].Phase != nativeBrokerPhasePrepared { + return nil, errors.New("native broker journal has no durable prepared record") + } + return j, nil +} + +func (j *nativeBrokerJournal) validateLoadedRecord(record nativeBrokerJournalRecord) error { + if record.Schema != nativeBrokerJournalSchema || + record.Sequence != uint32(len(j.records)+1) || + record.TransactionID != j.snapshot.TransactionID || + record.SnapshotSHA256 != j.snapshotDigest { + return errors.New("native broker journal record identity is inconsistent") + } + previous := strings.Repeat("0", 64) + if len(j.records) != 0 { + previous = j.records[len(j.records)-1].RecordSHA256 + if err := validateNativeBrokerJournalTransition(j.records[len(j.records)-1].Phase, record.Phase); err != nil { + return err + } + } + if record.PreviousSHA256 != previous || + (record.DetailSHA256 != "" && !isCanonicalNativeBrokerJournalSHA256(record.DetailSHA256)) { + return errors.New("native broker journal hash chain is inconsistent") + } + if (record.Phase == nativeBrokerPhaseOuterSettlementPending || + record.Phase == nativeBrokerPhaseOuterSettled) && record.DetailSHA256 == "" { + return errors.New("native broker two-phase settlement record is unbound") + } + unsigned := nativeBrokerJournalRecordUnsigned{ + Schema: record.Schema, Sequence: record.Sequence, TransactionID: record.TransactionID, + Phase: record.Phase, PreviousSHA256: record.PreviousSHA256, + SnapshotSHA256: record.SnapshotSHA256, DetailSHA256: record.DetailSHA256, + } + data, err := nativeBrokerJournalCanonicalJSON(unsigned) + if err != nil { + return err + } + if !isCanonicalNativeBrokerJournalSHA256(record.RecordSHA256) || + record.RecordSHA256 != nativeBrokerJournalHash(data) { + return errors.New("native broker journal record digest is invalid") + } + return nil +} + +func validateNativeBrokerJournalOuterTokenPath(snapshot nativeBrokerJournalSnapshot) error { + tokenBase := strings.ToLower(filepath.Base(snapshot.OuterTokenPath)) + if !filepath.IsAbs(snapshot.OuterTokenPath) || strings.IndexByte(snapshot.OuterTokenPath, 0) >= 0 || + !strings.EqualFold(filepath.Dir(snapshot.OuterTokenPath), filepath.Dir(snapshot.CandidatePath)) || + !strings.HasPrefix(tokenBase, ".viiper.transaction.") || + !strings.HasSuffix(tokenBase, ".token") { + return errors.New("native broker journal outer token path is malformed") + } + return nil +} + +func validateNativeBrokerJournalSnapshot(snapshot nativeBrokerJournalSnapshot) error { + if snapshot.Schema != nativeBrokerJournalSchema || + !isNativeBrokerJournalTransactionID(snapshot.TransactionID) || + !isCanonicalNativeBrokerJournalSHA256(snapshot.OuterTransactionID) || + !isCanonicalNativeBrokerJournalSHA256(snapshot.CandidateSHA256) || + !filepath.IsAbs(snapshot.CandidatePath) || strings.IndexByte(snapshot.CandidatePath, 0) >= 0 { + return errors.New("native broker journal snapshot identity is malformed") + } + if err := validateNativeBrokerJournalOuterTokenPath(snapshot); err != nil { + return err + } + if _, err := validateNativeInstallingUserSID(snapshot.TargetUserSID); err != nil { + return fmt.Errorf("validate journal target user SID: %w", err) + } + for _, digest := range []string{ + snapshot.PriorCredentialArtifact, snapshot.PriorLegacyArtifact, + } { + if !isCanonicalNativeBrokerJournalSHA256(digest) { + return errors.New("native broker journal artifact digest is malformed") + } + } + if snapshot.PriorCredentialExists && !isCanonicalNativeBrokerJournalSHA256(snapshot.PriorCredentialSHA256) { + return errors.New("native broker journal prior credential digest is malformed") + } + if !snapshot.PriorCredentialExists && snapshot.PriorCredentialSHA256 != "" { + return errors.New("absent prior credential carried a digest") + } + if snapshot.PriorImageExists { + if !filepath.IsAbs(snapshot.PriorImagePath) || + !isCanonicalNativeBrokerJournalSHA256(snapshot.PriorImageSHA256) { + return errors.New("native broker journal prior image identity is malformed") + } + } else if snapshot.PriorImagePath != "" || snapshot.PriorImageSHA256 != "" { + return errors.New("absent prior image carried identity") + } + if snapshot.Service.Exists { + if strings.TrimSpace(snapshot.Service.Config.BinaryPathName) == "" || + strings.IndexByte(snapshot.Service.Config.BinaryPathName, 0) >= 0 || + strings.TrimSpace(snapshot.Service.SecurityDescriptor) == "" || + snapshot.Service.Config.Password != "" || + len(snapshot.Service.Config.BinaryPathName) > 32767 || + len(snapshot.Service.SecurityDescriptor) > 64*1024 || + len(snapshot.Service.Config.Dependencies) > 64 || + len(snapshot.Service.RecoveryActions) > 16 { + return errors.New("native broker journal prior service snapshot is incomplete") + } + for _, value := range append( + append([]string(nil), snapshot.Service.Config.Dependencies...), + snapshot.Service.Config.LoadOrderGroup, + snapshot.Service.Config.ServiceStartName, + snapshot.Service.Config.DisplayName, + snapshot.Service.Config.Description, + ) { + if strings.IndexByte(value, 0) >= 0 || len(value) > 32767 { + return errors.New("native broker journal prior service string is malformed") + } + } + } else if snapshot.Service.WasRunning || snapshot.Service.Config.BinaryPathName != "" || + snapshot.Service.SecurityDescriptor != "" || len(snapshot.Service.RecoveryActions) != 0 || + snapshot.Service.RecoveryResetSeconds != 0 || snapshot.Service.RecoverNonCrash { + return errors.New("absent prior service carried mutable state") + } + return nil +} + +func protectNativeBrokerJournalData(transactionID, outerID, kind string, plaintext []byte) ([]byte, error) { + if len(plaintext) == 0 || len(plaintext) > nativeBrokerJournalMaximumSecret { + return nil, errors.New("native broker recovery secret has an invalid length") + } + entropy := sha256.Sum256([]byte("VIIPER/native-broker-journal/v1\x00" + transactionID + "\x00" + outerID + "\x00" + kind)) + input := windows.DataBlob{Size: uint32(len(plaintext)), Data: &plaintext[0]} + entropyBlob := windows.DataBlob{Size: uint32(len(entropy)), Data: &entropy[0]} + var output windows.DataBlob + if err := windows.CryptProtectData( + &input, nil, &entropyBlob, 0, nil, + windows.CRYPTPROTECT_LOCAL_MACHINE|windows.CRYPTPROTECT_UI_FORBIDDEN, + &output, + ); err != nil { + return nil, err + } + defer windows.LocalFree(windows.Handle(unsafe.Pointer(output.Data))) //nolint:errcheck + if output.Size == 0 || output.Size > nativeBrokerJournalMaximumSecret || output.Data == nil { + return nil, errors.New("DPAPI returned an invalid native broker recovery artifact") + } + return append([]byte(nil), unsafe.Slice(output.Data, output.Size)...), nil +} + +func unprotectNativeBrokerJournalData(transactionID, outerID, kind string, ciphertext []byte) ([]byte, error) { + if len(ciphertext) == 0 || len(ciphertext) > nativeBrokerJournalMaximumSecret { + return nil, errors.New("native broker recovery ciphertext has an invalid length") + } + entropy := sha256.Sum256([]byte("VIIPER/native-broker-journal/v1\x00" + transactionID + "\x00" + outerID + "\x00" + kind)) + input := windows.DataBlob{Size: uint32(len(ciphertext)), Data: &ciphertext[0]} + entropyBlob := windows.DataBlob{Size: uint32(len(entropy)), Data: &entropy[0]} + var output windows.DataBlob + if err := windows.CryptUnprotectData( + &input, nil, &entropyBlob, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &output, + ); err != nil { + return nil, err + } + defer windows.LocalFree(windows.Handle(unsafe.Pointer(output.Data))) //nolint:errcheck + if output.Size == 0 || output.Size > nativeBrokerJournalMaximumSecret || output.Data == nil { + return nil, errors.New("DPAPI returned invalid native broker recovery plaintext") + } + return append([]byte(nil), unsafe.Slice(output.Data, output.Size)...), nil +} + +func snapshotNativeBrokerCredentialReadOnly(userSID string) ([]byte, bool, error) { + path, err := nativeServiceKeyFilePath() + if err != nil { + return nil, false, err + } + programData, err := windows.KnownFolderPath(windows.FOLDERID_ProgramData, windows.KF_FLAG_DEFAULT) + if err != nil { + return nil, false, err + } + programData = filepath.Clean(programData) + product := filepath.Join(programData, "VIIPER") + if !strings.EqualFold(filepath.Dir(path), product) { + return nil, false, errors.New("native broker credential escaped its fixed ProgramData root") + } + if _, err := nativePathAttributes(product); err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil, false, nil + } + return nil, false, err + } + productHandle, err := openNativePathWithoutReparse( + product, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + ) + if err != nil { + return nil, false, err + } + defer windows.CloseHandle(productHandle) //nolint:errcheck + if err := validateNativeSecurityDescriptor(productHandle, nativeCredentialDirectorySDDL(userSID)); err != nil { + return nil, false, fmt.Errorf("validate credential directory before recovery snapshot: %w", err) + } + if _, err := nativePathAttributes(path); err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil, false, nil + } + return nil, false, err + } + handle, err := openNativePathWithoutReparse(path, windows.GENERIC_READ|windows.READ_CONTROL, false) + if err != nil { + return nil, false, err + } + if err := requireSingleNativeFileLink(handle); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return nil, false, err + } + if err := validateNativeSecurityDescriptor(handle, nativeCredentialFileSDDL(userSID)); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return nil, false, err + } + file := os.NewFile(uintptr(handle), path) + if file == nil { + windows.CloseHandle(handle) //nolint:errcheck + return nil, false, errors.New("wrap prior native broker credential") + } + defer file.Close() //nolint:errcheck + contents, err := io.ReadAll(io.LimitReader(file, 64*1024+1)) + if err != nil { + return nil, false, err + } + if len(contents) == 0 || len(contents) > 64*1024 { + return nil, false, errors.New("prior native broker credential has an invalid length") + } + return contents, true, nil +} + +func captureNativeBrokerLegacySnapshot(ctx context.Context, userSID string) (nativeBrokerJournalLegacySnapshot, error) { + legacy, err := snapshotNativeLegacyStartup(ctx, userSID) + if err != nil { + return nativeBrokerJournalLegacySnapshot{}, err + } + if legacy.release != nil { + defer legacy.release() + } + for index := range legacy.commands { + processes, processErr := openLegacyProcessesByExecutable( + legacy.commands[index].executable, legacy.userSID, + ) + if processErr != nil { + return nativeBrokerJournalLegacySnapshot{}, processErr + } + legacy.commands[index].running = len(processes) != 0 + for _, process := range processes { + windows.CloseHandle(process.handle) //nolint:errcheck + } + } + result := nativeBrokerJournalLegacySnapshot{ + Schema: nativeBrokerJournalSchema, UserSID: legacy.userSID, + RunKeyExisted: legacy.runKeyExisted, ScheduledActive: legacy.scheduledActive, + ScheduledEnabled: legacy.scheduledEnabled, + } + if legacy.runValue != nil { + value := legacy.runValue.value + result.RunValueText = &value + result.RunValueType = legacy.runValue.valueType + } + if legacy.scheduledXML != nil { + value := *legacy.scheduledXML + result.ScheduledXML = &value + } + for _, command := range legacy.commands { + result.SerializableCmds = append(result.SerializableCmds, nativeBrokerCommand{ + Executable: command.executable, Arguments: append([]string(nil), command.arguments...), + WorkingDirectory: command.workingDirectory, Source: uint8(command.source), + WasRunning: command.running, + }) + } + if err := validateNativeBrokerLegacySnapshot(result); err != nil { + return nativeBrokerJournalLegacySnapshot{}, err + } + return result, nil +} + +func validateNativeBrokerLegacySnapshot(snapshot nativeBrokerJournalLegacySnapshot) error { + if snapshot.Schema != nativeBrokerJournalSchema { + return errors.New("native broker legacy snapshot schema is invalid") + } + if _, err := validateNativeInstallingUserSID(snapshot.UserSID); err != nil { + return err + } + if snapshot.RunValueText == nil { + if snapshot.RunValueType != 0 { + return errors.New("absent legacy Run value carried a type") + } + } else if snapshot.RunValueType != registry.SZ && snapshot.RunValueType != registry.EXPAND_SZ { + return errors.New("legacy Run value type is unsupported") + } + if snapshot.ScheduledActive && (!snapshot.ScheduledEnabled || snapshot.ScheduledXML == nil) { + return errors.New("active legacy task snapshot is not restorable") + } + if snapshot.ScheduledXML != nil && strings.TrimSpace(*snapshot.ScheduledXML) == "" { + return errors.New("legacy task snapshot contains empty XML") + } + if len(snapshot.SerializableCmds) > 8 { + return errors.New("legacy command snapshot exceeds its bound") + } + for _, command := range snapshot.SerializableCmds { + if !filepath.IsAbs(command.Executable) || strings.IndexByte(command.Executable, 0) >= 0 || + !strings.EqualFold(filepath.Base(command.Executable), "viiper.exe") || + command.Source != uint8(legacyCommandRun) || len(command.Arguments) > 64 { + return errors.New("legacy command snapshot is malformed") + } + } + return nil +} + +func copyNativeBrokerJournalImage(source windows.Handle, destination, expectedHash string) (resultErr error) { + security, err := nativeSecurityAttributes(nativeBrokerJournalFileSDDL) + if err != nil { + return err + } + pointer, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + target, err := windows.CreateFile( + pointer, windows.GENERIC_READ|windows.GENERIC_WRITE|windows.READ_CONTROL, + windows.FILE_SHARE_READ, security, windows.CREATE_NEW, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT| + windows.FILE_FLAG_WRITE_THROUGH, + 0, + ) + if err != nil { + return err + } + defer func() { + windows.CloseHandle(target) //nolint:errcheck + if resultErr != nil { + deleteNativePackageFile(destination) //nolint:errcheck + } + }() + if _, err := windows.SetFilePointer(source, 0, nil, windows.FILE_BEGIN); err != nil { + return err + } + var total int64 + buffer := make([]byte, 64*1024) + for { + var read uint32 + if err := windows.ReadFile(source, buffer, &read, nil); err != nil { + return err + } + if read == 0 { + break + } + total += int64(read) + if total > nativeBrokerJournalMaximumImage { + return errors.New("prior native broker image exceeds the recovery bound") + } + var written uint32 + if err := windows.WriteFile(target, buffer[:read], &written, nil); err != nil { + return err + } + if written != read { + return io.ErrShortWrite + } + } + if err := windows.FlushFileBuffers(target); err != nil { + return err + } + if err := validateNativeSecurityDescriptor(target, nativeBrokerJournalFileSDDL); err != nil { + return err + } + if err := requireSingleNativeFileLink(target); err != nil { + return err + } + hash, err := hashNativePackageHandle(target) + if err != nil { + return err + } + if !strings.EqualFold(hash, expectedHash) { + return errors.New("prior native broker image changed during durable capture") + } + return nil +} + +func createEmptyNativeBrokerJournalRecords(path string) error { + handle, err := openNativeBrokerJournalFile( + path, windows.GENERIC_READ|windows.GENERIC_WRITE, windows.CREATE_NEW, + ) + if err != nil { + return err + } + if err := windows.FlushFileBuffers(handle); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return err + } + return windows.CloseHandle(handle) +} + +func beginNativeBrokerJournal( + ctx context.Context, + t *windowsNativePackageTransaction, +) (_ *nativeBrokerJournal, resultErr error) { + if t == nil || !t.nestedBrokerCommit || t.tokenSHA256 == "" || + t.boundOuterTokenPath == "" { + return nil, errors.New("native broker journal requires a bound nested package transaction") + } + if t.serviceSnapshot.disposition == nativePackageServiceWeakExactOwned { + return nil, &nativeBrokerJournalManualError{cause: errors.New( + "weak service or image ownership is not a trustworthy recovery source", + )} + } + if t.serviceSnapshot.disposition == nativePackageServiceTrusted && + !strings.EqualFold(filepath.Clean(t.priorServiceExecutable), filepath.Clean(t.destination)) { + return nil, &nativeBrokerJournalManualError{cause: errors.New( + "prior service uses a noncanonical image path that cannot be durably switched", + )} + } + priorCredential, credentialExists, err := snapshotNativeBrokerCredentialReadOnly(t.request.targetUserSID) + if err != nil { + return nil, fmt.Errorf("snapshot prior native broker credential: %w", err) + } + legacy, err := captureNativeBrokerLegacySnapshot(ctx, t.request.targetUserSID) + if err != nil { + return nil, fmt.Errorf("snapshot prior legacy ownership for recovery: %w", err) + } + var transactionBytes [16]byte + if _, err := io.ReadFull(rand.Reader, transactionBytes[:]); err != nil { + return nil, err + } + transactionID := hex.EncodeToString(transactionBytes[:]) + + credentialPlain, err := nativeBrokerJournalCanonicalJSON(nativeBrokerJournalCredentialSnapshot{ + Schema: nativeBrokerJournalSchema, Exists: credentialExists, + Bytes: append([]byte(nil), priorCredential...), + }) + if err != nil { + return nil, err + } + credentialCipher, err := protectNativeBrokerJournalData( + transactionID, t.tokenSHA256, "prior-key", credentialPlain, + ) + if err != nil { + return nil, fmt.Errorf("protect prior native broker credential: %w", err) + } + legacyPlain, err := nativeBrokerJournalCanonicalJSON(legacy) + if err != nil { + return nil, err + } + legacyCipher, err := protectNativeBrokerJournalData( + transactionID, t.tokenSHA256, "prior-legacy", legacyPlain, + ) + if err != nil { + return nil, fmt.Errorf("protect prior legacy recovery state: %w", err) + } + + priorImageExists := false + priorImageHash := "" + var priorImageHandle windows.Handle + if handle, openErr := openNativePathWithoutReparse( + t.destination, windows.GENERIC_READ|windows.READ_CONTROL, false, + ); openErr == nil { + priorImageHandle = handle + defer windows.CloseHandle(priorImageHandle) //nolint:errcheck + if err := requireSingleNativeFileLink(handle); err != nil { + return nil, err + } + if err := validateNativeSecurityDescriptor(handle, nativeBrokerExecutableSDDL); err != nil { + return nil, fmt.Errorf("validate prior broker image before durable capture: %w", err) + } + priorImageHash, err = hashNativePackageHandle(handle) + if err != nil { + return nil, err + } + priorImageExists = true + } else if !errors.Is(openErr, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(openErr, windows.ERROR_PATH_NOT_FOUND) { + return nil, openErr + } + if t.serviceSnapshot.disposition == nativePackageServiceTrusted && + (!priorImageExists || !strings.EqualFold(priorImageHash, t.priorExecutableSHA256)) { + return nil, errors.New("prior service image identity differs from the canonical image snapshot") + } + + priorImagePath := "" + if priorImageExists { + priorImagePath = t.destination + } + serviceSnapshot := nativeBrokerJournalService{} + if t.serviceSnapshot.disposition == nativePackageServiceTrusted { + serviceSnapshot = nativeBrokerJournalService{ + Exists: true, WasRunning: t.serviceSnapshot.wasRunning, + Config: t.priorServiceConfig, SecurityDescriptor: t.priorServiceDACL, + RecoveryActions: append([]mgr.RecoveryAction(nil), t.priorServiceRecovery...), + RecoveryResetSeconds: t.priorServiceReset, + RecoverNonCrash: t.priorServiceNonCrash, + } + } + credentialHash := "" + if credentialExists { + credentialHash = nativeBrokerJournalHash(priorCredential) + } + snapshot := nativeBrokerJournalSnapshot{ + Schema: nativeBrokerJournalSchema, TransactionID: transactionID, + OuterTransactionID: t.tokenSHA256, OuterTokenPath: t.boundOuterTokenPath, + TargetUserSID: t.request.targetUserSID, + CandidatePath: t.destination, CandidateSHA256: t.request.expectedBrokerSHA256, + PriorImageExists: priorImageExists, PriorImagePath: priorImagePath, + PriorImageSHA256: priorImageHash, PriorCredentialExists: credentialExists, + PriorCredentialSHA256: credentialHash, + PriorCredentialArtifact: nativeBrokerJournalHash(credentialCipher), + PriorLegacyArtifact: nativeBrokerJournalHash(legacyCipher), Service: serviceSnapshot, + } + if err := validateNativeBrokerJournalSnapshot(snapshot); err != nil { + return nil, err + } + payload, err := nativeBrokerJournalCanonicalJSON(snapshot) + if err != nil { + return nil, err + } + snapshotDigest := nativeBrokerJournalHash(payload) + envelope, err := nativeBrokerJournalCanonicalJSON(nativeBrokerJournalSnapshotEnvelope{ + Schema: nativeBrokerJournalSchema, PayloadSHA256: snapshotDigest, Payload: snapshot, + }) + if err != nil { + return nil, err + } + + directory := "" + j := &nativeBrokerJournal{ + snapshot: snapshot, snapshotDigest: snapshotDigest, + priorLegacy: &legacy, cutpoint: t.brokerJournalCutpoint, + } + cleanup := true + defer func() { + if cleanup && directory != "" { + if cleanupErr := discardNativeBrokerJournalDirectory(directory); cleanupErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("clean incomplete native broker journal: %w", cleanupErr)) + } + } + }() + if err := executeNativeBrokerJournalPreparation(nativeBrokerJournalPreparationOperations{ + createDirectory: func() error { + var createErr error + directory, createErr = createNativeBrokerJournalPreparingDirectory( + t.request.targetUserSID, transactionID, + ) + j.directory = directory + return createErr + }, + writeCredential: func() error { + return writeNativeBrokerJournalFile( + filepath.Join(directory, nativeBrokerJournalCredentialName), credentialCipher, + nativeBrokerJournalMaximumSecret, + ) + }, + writeLegacy: func() error { + return writeNativeBrokerJournalFile( + filepath.Join(directory, nativeBrokerJournalLegacyName), legacyCipher, + nativeBrokerJournalMaximumSecret, + ) + }, + writePriorImage: func() error { + if !priorImageExists { + return nil + } + return copyNativeBrokerJournalImage( + priorImageHandle, filepath.Join(directory, nativeBrokerJournalPriorImageName), + priorImageHash, + ) + }, + writeSnapshot: func() error { + return writeNativeBrokerJournalFile( + filepath.Join(directory, nativeBrokerJournalSnapshotName), envelope, + nativeBrokerJournalMaximumSnapshot, + ) + }, + createRecordStream: func() error { + return createEmptyNativeBrokerJournalRecords( + filepath.Join(directory, nativeBrokerJournalRecordsName), + ) + }, + writePrepared: func() error { + return j.appendPhase(nativeBrokerPhasePrepared, "") + }, + publishActive: func() error { + return publishNativeBrokerJournalActive(t.request.targetUserSID, j) + }, + cutpoint: t.brokerJournalCutpoint, + }); err != nil { + return nil, err + } + cleanup = false + return j, nil +} + +func (j *nativeBrokerJournal) validatePriorCredential(exists bool, contents []byte) error { + if j == nil { + return nil + } + if exists != j.snapshot.PriorCredentialExists { + return errors.New("native broker credential existence changed after durable snapshot") + } + if exists && !strings.EqualFold( + nativeBrokerJournalHash(contents), j.snapshot.PriorCredentialSHA256, + ) { + return errors.New("native broker credential changed after durable snapshot") + } + return nil +} + +func (j *nativeBrokerJournal) validatePriorOwnership( + service nativeServiceSnapshot, + legacy nativeLegacyState, +) error { + if j == nil { + return nil + } + expected := j.snapshot.Service + if service.exists != expected.Exists { + return errors.New("native broker service existence changed after durable snapshot") + } + if service.exists { + expectedOperational := expected.WasRunning + if nativeBrokerJournalPhaseIndex(nativeBrokerForwardPhaseOrder, j.lastPhase()) >= + nativeBrokerJournalPhaseIndex(nativeBrokerForwardPhaseOrder, nativeBrokerPhaseServiceStopIntent) { + expectedOperational = false + } + if serviceWasOperational(service.status.State) != expectedOperational || + !nativeServiceConfigsEqual(service.config, expected.Config) || + compareNativeSecurityDescriptorStrings( + service.securityDescriptor, expected.SecurityDescriptor, + ) != nil || !slices.Equal(service.recoveryActions, expected.RecoveryActions) || + service.recoveryResetSeconds != expected.RecoveryResetSeconds || + service.recoverNonCrash != expected.RecoverNonCrash { + return errors.New("native broker service changed after durable snapshot") + } + } + if j.priorLegacy == nil { + return errors.New("native broker journal lost its retained prior legacy snapshot") + } + prior := j.priorLegacy + if !strings.EqualFold(legacy.userSID, prior.UserSID) || + legacy.runKeyExisted != prior.RunKeyExisted || + legacy.scheduledActive != prior.ScheduledActive || + legacy.scheduledEnabled != prior.ScheduledEnabled { + return errors.New("legacy startup ownership changed after durable snapshot") + } + if (legacy.runValue == nil) != (prior.RunValueText == nil) { + return errors.New("legacy Run registration changed after durable snapshot") + } + if legacy.runValue != nil && (legacy.runValue.value != *prior.RunValueText || + legacy.runValue.valueType != prior.RunValueType) { + return errors.New("legacy Run registration changed after durable snapshot") + } + if (legacy.scheduledXML == nil) != (prior.ScheduledXML == nil) || + (legacy.scheduledXML != nil && *legacy.scheduledXML != *prior.ScheduledXML) { + return errors.New("legacy scheduled task changed after durable snapshot") + } + if len(legacy.commands) != len(prior.SerializableCmds) { + return errors.New("legacy startup command set changed after durable snapshot") + } + for index := range legacy.commands { + command := prior.SerializableCmds[index] + if !nativeLegacyCommandsEqual(legacy.commands[index], nativeLegacyCommand{ + executable: command.Executable, arguments: command.Arguments, + workingDirectory: command.WorkingDirectory, source: nativeLegacyCommandSource(command.Source), + }) { + return errors.New("legacy startup command changed after durable snapshot") + } + } + return nil +} + +func isNativeBrokerJournalInactiveDirectoryName(name, prefix string) bool { + return strings.HasPrefix(name, prefix) && + isNativeBrokerJournalTransactionID(strings.TrimPrefix(name, prefix)) +} + +func discardNativeBrokerJournalDirectory(directory string) error { + name := filepath.Base(filepath.Clean(directory)) + if name != nativeBrokerJournalActiveName && + !isNativeBrokerJournalInactiveDirectoryName(name, nativeBrokerJournalPreparingPrefix) && + !isNativeBrokerJournalInactiveDirectoryName(name, nativeBrokerJournalSettledPrefix) { + return errors.New("refusing to discard an unrecognized native broker journal directory") + } + handle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(directory, false) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil + } + return err + } + windows.CloseHandle(handle) //nolint:errcheck + entries, err := os.ReadDir(directory) + if err != nil { + return err + } + allowed := map[string]bool{ + nativeBrokerJournalSnapshotName: true, nativeBrokerJournalRecordsName: true, + nativeBrokerJournalCredentialName: true, nativeBrokerJournalLegacyName: true, + nativeBrokerJournalPriorImageName: true, + nativeBrokerJournalSettlementName: true, + nativeBrokerJournalSettledReceiptName: true, + nativeBrokerJournalSnapshotName + ".next": true, + nativeBrokerJournalRecordsName + ".next": true, + nativeBrokerJournalCredentialName + ".next": true, + nativeBrokerJournalLegacyName + ".next": true, + nativeBrokerJournalPriorImageName + ".next": true, + nativeBrokerJournalSettlementName + ".next": true, + nativeBrokerJournalSettledReceiptName + ".next": true, + } + for _, entry := range entries { + if entry.IsDir() || !allowed[entry.Name()] { + return &nativeBrokerJournalManualError{cause: fmt.Errorf( + "protected broker journal contains unexpected artifact %q", entry.Name(), + )} + } + } + var cleanupErrors []error + for _, entry := range entries { + path := filepath.Join(directory, entry.Name()) + file, openErr := openNativeBrokerJournalFile(path, windows.DELETE|windows.READ_CONTROL, windows.OPEN_EXISTING) + if openErr != nil { + cleanupErrors = append(cleanupErrors, openErr) + continue + } + windows.CloseHandle(file) //nolint:errcheck + if deleteErr := deleteNativePackageFile(path); deleteErr != nil && + !errors.Is(deleteErr, windows.ERROR_FILE_NOT_FOUND) { + cleanupErrors = append(cleanupErrors, deleteErr) + } + } + if len(cleanupErrors) != 0 { + return errors.Join(cleanupErrors...) + } + pointer, err := windows.UTF16PtrFromString(directory) + if err != nil { + return err + } + if err := windows.RemoveDirectory(pointer); err != nil && + !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return err + } + return nil +} + +func executeNativeBrokerJournalRetirement(operations nativeBrokerJournalRetirementOperations) error { + if operations.rename == nil || operations.proveActiveAbsent == nil || + operations.proveTombstone == nil || operations.discardTombstone == nil { + return errors.New("native broker journal retirement operations are incomplete") + } + cut := func(name string) error { + if operations.cutpoint == nil { + return nil + } + return operations.cutpoint(name) + } + if err := cut("retire-before-rename"); err != nil { + return err + } + if err := operations.rename(); err != nil { + return err + } + if err := cut("retire-after-rename"); err != nil { + return err + } + if err := operations.proveActiveAbsent(); err != nil { + return err + } + if err := cut("retire-active-absence-proven"); err != nil { + return err + } + if err := operations.proveTombstone(); err != nil { + return err + } + if err := cut("retire-tombstone-proven"); err != nil { + return err + } + // Admission no longer observes this transaction. Deletion is deliberately + // non-authoritative and may be retried by inactive-directory discovery. + _ = operations.discardTombstone() + return nil +} + +func retireNativeBrokerJournal(j *nativeBrokerJournal) error { + if j == nil || (j.lastPhase() != nativeBrokerPhaseOuterSettled && + j.lastPhase() != nativeBrokerPhaseRollbackSettled) { + return errors.New("native broker journal cannot retire before exact terminal settlement") + } + active := filepath.Clean(j.directory) + root := filepath.Dir(active) + if filepath.Base(active) != nativeBrokerJournalActiveName || + filepath.Base(root) != nativeBrokerJournalRootName { + return errors.New("native broker active directory identity changed before retirement") + } + tombstone, err := nativeBrokerJournalSiblingPath( + root, nativeBrokerJournalSettledPrefix, j.snapshot.TransactionID, + ) + if err != nil { + return err + } + return executeNativeBrokerJournalRetirement(nativeBrokerJournalRetirementOperations{ + cutpoint: j.cutpoint, + rename: func() error { + if err := moveNativePackageFile(active, tombstone, false); err != nil { + return fmt.Errorf("atomically retire native broker active journal: %w", err) + } + j.directory = tombstone + return nil + }, + proveActiveAbsent: func() error { + if _, err := nativePathAttributes(active); err == nil { + return errors.New("native broker active journal still exists after terminal retirement") + } else if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return err + } + return nil + }, + proveTombstone: func() error { + handle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(tombstone, false) + if err != nil { + return fmt.Errorf("validate settled native broker journal tombstone: %w", err) + } + return windows.CloseHandle(handle) + }, + discardTombstone: func() error { + return discardNativeBrokerJournalDirectory(tombstone) + }, + }) +} + +func reconcileNativeBrokerJournalInactiveDirectories( + logger *slog.Logger, + userSID string, +) error { + root, _, err := nativeBrokerJournalPaths(userSID) + if err != nil { + return err + } + rootHandle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(root, false) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil + } + return err + } + windows.CloseHandle(rootHandle) //nolint:errcheck + entries, err := os.ReadDir(root) + if err != nil { + return err + } + for _, entry := range entries { + name := entry.Name() + if name == nativeBrokerJournalActiveName { + if !entry.IsDir() { + return &nativeBrokerJournalManualError{cause: errors.New( + "native broker active journal is not a protected directory", + )} + } + continue + } + preparing := isNativeBrokerJournalInactiveDirectoryName( + name, nativeBrokerJournalPreparingPrefix, + ) + settled := isNativeBrokerJournalInactiveDirectoryName( + name, nativeBrokerJournalSettledPrefix, + ) + if (!preparing && !settled) || !entry.IsDir() { + return &nativeBrokerJournalManualError{cause: fmt.Errorf( + "native broker journal root contains unknown transaction artifact %q", name, + )} + } + path := filepath.Join(root, name) + handle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(path, false) + if err != nil { + return &nativeBrokerJournalManualError{cause: err} + } + windows.CloseHandle(handle) //nolint:errcheck + if err := discardNativeBrokerJournalDirectory(path); err != nil { + if preparing { + return &nativeBrokerJournalManualError{cause: fmt.Errorf( + "discard incomplete unpublished broker preparation: %w", err, + )} + } + logger.Warn("Retaining protected settled broker journal tombstone", + "transactionDirectory", name, "error", err) + } + } + return nil +} + +func nativeBrokerJournalPathHash(path string) (string, bool, error) { + handle, err := openNativePathWithoutReparse( + path, windows.GENERIC_READ|windows.READ_CONTROL, false, + ) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return "", false, nil + } + return "", false, err + } + defer windows.CloseHandle(handle) //nolint:errcheck + if err := requireSingleNativeFileLink(handle); err != nil { + return "", false, err + } + if err := validateNativeSecurityDescriptor(handle, nativeBrokerExecutableSDDL); err != nil { + return "", false, err + } + hash, err := hashNativePackageHandle(handle) + return hash, true, err +} + +func restoreNativeBrokerJournalImage(j *nativeBrokerJournal) error { + if j == nil { + return errors.New("native broker image recovery has no journal") + } + snapshot := j.snapshot + transactionStaging := filepath.Join( + filepath.Dir(snapshot.CandidatePath), + ".viiper.staging."+snapshot.TransactionID+".tmp", + ) + if stagingHash, stagingExists, err := nativeBrokerJournalPathHash(transactionStaging); err != nil { + return err + } else if stagingExists { + if !strings.EqualFold(stagingHash, snapshot.CandidateSHA256) { + return &nativeBrokerJournalManualError{cause: errors.New( + "broker staging artifact differs from the transaction candidate identity", + )} + } + if err := deleteNativePackageFile(transactionStaging); err != nil { + return err + } + } + recoveryStaging := filepath.Join( + filepath.Dir(snapshot.CandidatePath), + ".viiper.recovery."+snapshot.TransactionID+".tmp", + ) + if _, stagingExists, err := nativeBrokerJournalPathHash(recoveryStaging); err != nil { + return err + } else if stagingExists { + if err := deleteNativePackageFile(recoveryStaging); err != nil { + return err + } + } + currentHash, currentExists, err := nativeBrokerJournalPathHash(snapshot.CandidatePath) + if err != nil { + return err + } + if snapshot.PriorImageExists && currentExists && + strings.EqualFold(currentHash, snapshot.PriorImageSHA256) { + return nil + } + if currentExists && !strings.EqualFold(currentHash, snapshot.CandidateSHA256) { + return &nativeBrokerJournalManualError{cause: errors.New( + "canonical broker image differs from both durable prior and candidate identities", + )} + } + if !snapshot.PriorImageExists { + if !currentExists { + return nil + } + return deleteNativePackageFile(snapshot.CandidatePath) + } + artifact := filepath.Join(j.directory, nativeBrokerJournalPriorImageName) + artifactHandle, err := openNativeBrokerJournalFile( + artifact, windows.GENERIC_READ, windows.OPEN_EXISTING, + ) + if err != nil { + return err + } + defer windows.CloseHandle(artifactHandle) //nolint:errcheck + artifactHash, err := hashNativePackageHandle(artifactHandle) + if err != nil { + return err + } + if !strings.EqualFold(artifactHash, snapshot.PriorImageSHA256) { + return &nativeBrokerJournalManualError{cause: errors.New( + "protected prior broker image artifact failed identity validation", + )} + } + staging := recoveryStaging + if err := copyNativePackageHandleAtomically(artifactHandle, staging, artifactHash); err != nil { + return err + } + cleanup := true + defer func() { + if cleanup { + deleteNativePackageFile(staging) //nolint:errcheck + } + }() + if err := replaceNativePackageFileAtomically(staging, snapshot.CandidatePath, currentExists); err != nil { + return err + } + cleanup = false + restoredHash, restoredExists, err := nativeBrokerJournalPathHash(snapshot.CandidatePath) + if err != nil { + return err + } + if !restoredExists || !strings.EqualFold(restoredHash, snapshot.PriorImageSHA256) { + return errors.New("restored prior broker image did not verify") + } + return nil +} + +func (j *nativeBrokerJournal) loadProtectedArtifacts() ( + nativeBrokerJournalCredentialSnapshot, + nativeBrokerJournalLegacySnapshot, + error, +) { + credentialCipher, err := readNativeBrokerJournalFile( + filepath.Join(j.directory, nativeBrokerJournalCredentialName), + nativeBrokerJournalMaximumSecret, + ) + if err != nil { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, err + } + if !strings.EqualFold( + nativeBrokerJournalHash(credentialCipher), j.snapshot.PriorCredentialArtifact, + ) { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, + errors.New("protected prior credential artifact digest is invalid") + } + credentialPlain, err := unprotectNativeBrokerJournalData( + j.snapshot.TransactionID, j.snapshot.OuterTransactionID, "prior-key", credentialCipher, + ) + if err != nil { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, err + } + var credential nativeBrokerJournalCredentialSnapshot + if err := decodeCanonicalNativeBrokerJSON( + credentialPlain, &credential, nativeBrokerJournalMaximumSecret, + ); err != nil { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, err + } + if credential.Schema != nativeBrokerJournalSchema || + credential.Exists != j.snapshot.PriorCredentialExists || + (credential.Exists && (!strings.EqualFold( + nativeBrokerJournalHash(credential.Bytes), j.snapshot.PriorCredentialSHA256, + ) || len(credential.Bytes) == 0)) || + (!credential.Exists && len(credential.Bytes) != 0) { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, + errors.New("protected prior credential plaintext does not match the journal snapshot") + } + + legacyCipher, err := readNativeBrokerJournalFile( + filepath.Join(j.directory, nativeBrokerJournalLegacyName), + nativeBrokerJournalMaximumSecret, + ) + if err != nil { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, err + } + if !strings.EqualFold(nativeBrokerJournalHash(legacyCipher), j.snapshot.PriorLegacyArtifact) { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, + errors.New("protected prior legacy artifact digest is invalid") + } + legacyPlain, err := unprotectNativeBrokerJournalData( + j.snapshot.TransactionID, j.snapshot.OuterTransactionID, "prior-legacy", legacyCipher, + ) + if err != nil { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, err + } + var legacy nativeBrokerJournalLegacySnapshot + if err := decodeCanonicalNativeBrokerJSON( + legacyPlain, &legacy, nativeBrokerJournalMaximumSecret, + ); err != nil { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, err + } + if err := validateNativeBrokerLegacySnapshot(legacy); err != nil { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, err + } + if !strings.EqualFold(legacy.UserSID, j.snapshot.TargetUserSID) { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, + errors.New("protected legacy artifact target SID differs from the journal") + } + j.priorLegacy = &legacy + if j.snapshot.PriorImageExists { + artifact, err := openNativeBrokerJournalFile( + filepath.Join(j.directory, nativeBrokerJournalPriorImageName), + windows.GENERIC_READ, windows.OPEN_EXISTING, + ) + if err != nil { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, err + } + artifactHash, hashErr := hashNativePackageHandle(artifact) + closeErr := windows.CloseHandle(artifact) + if hashErr != nil || closeErr != nil || + !strings.EqualFold(artifactHash, j.snapshot.PriorImageSHA256) { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, + errors.Join(hashErr, closeErr, errors.New("protected prior image artifact digest is invalid")) + } + } + return credential, legacy, nil +} + +func currentNativeBrokerJournalCandidateCredentialDigest(j *nativeBrokerJournal) string { + if j == nil { + return "" + } + for index := len(j.records) - 1; index >= 0; index-- { + switch j.records[index].Phase { + case nativeBrokerPhaseCredentialWriteIntent, nativeBrokerPhaseCredentialWritten: + return j.records[index].DetailSHA256 + } + } + return "" +} + +func restoreNativeBrokerJournalCredential( + j *nativeBrokerJournal, + prior nativeBrokerJournalCredentialSnapshot, +) error { + current, exists, err := snapshotNativeBrokerCredentialReadOnly(j.snapshot.TargetUserSID) + if err != nil { + return err + } + currentDigest := "" + if exists { + currentDigest = nativeBrokerJournalHash(current) + } + if exists == prior.Exists && (!exists || strings.EqualFold( + currentDigest, j.snapshot.PriorCredentialSHA256, + )) { + return nil + } + candidateDigest := currentNativeBrokerJournalCandidateCredentialDigest(j) + if candidateDigest == "" || !exists || !strings.EqualFold(currentDigest, candidateDigest) { + return &nativeBrokerJournalManualError{cause: errors.New( + "native broker credential differs from durable prior and candidate identities", + )} + } + path, err := nativeServiceKeyFilePath() + if err != nil { + return err + } + if prior.Exists { + if err := writeNativeCredentialAtomically(path, prior.Bytes, j.snapshot.TargetUserSID); err != nil { + return err + } + } else if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + verified, verifiedExists, err := snapshotNativeBrokerCredentialReadOnly(j.snapshot.TargetUserSID) + if err != nil { + return err + } + if verifiedExists != prior.Exists || (verifiedExists && !strings.EqualFold( + nativeBrokerJournalHash(verified), j.snapshot.PriorCredentialSHA256, + )) { + return errors.New("prior native broker credential did not verify after durable restoration") + } + return nil +} + +func exactNativeBrokerJournalServiceState(service nativeManagedService) ( + mgr.Config, + string, + []mgr.RecoveryAction, + uint32, + bool, + svc.Status, + error, +) { + config, err := service.Config() + if err != nil { + return mgr.Config{}, "", nil, 0, false, svc.Status{}, err + } + dacl, err := service.SecurityDescriptor() + if err != nil { + return mgr.Config{}, "", nil, 0, false, svc.Status{}, err + } + recovery, err := service.RecoveryActions() + if err != nil { + return mgr.Config{}, "", nil, 0, false, svc.Status{}, err + } + reset, err := service.ResetPeriod() + if err != nil { + return mgr.Config{}, "", nil, 0, false, svc.Status{}, err + } + nonCrash, err := service.RecoveryActionsOnNonCrashFailures() + if err != nil { + return mgr.Config{}, "", nil, 0, false, svc.Status{}, err + } + status, err := service.Query() + return config, dacl, recovery, reset, nonCrash, status, err +} + +func restoreNativeBrokerJournalService( + ctx context.Context, + j *nativeBrokerJournal, +) (nativeManagedService, nativeSCM, error) { + managerRaw, err := mgr.Connect() + if err != nil { + return nil, nil, err + } + manager := &windowsNativeSCM{manager: managerRaw} + service, err := manager.OpenService(NativeBrokerServiceName) + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + if j.snapshot.Service.Exists { + manager.Close() //nolint:errcheck + return nil, nil, &nativeBrokerJournalManualError{cause: errors.New( + "prior native broker service disappeared during recovery", + )} + } + return nil, manager, nil + } + if err != nil { + manager.Close() //nolint:errcheck + return nil, nil, err + } + config, dacl, recovery, reset, nonCrash, status, err := + exactNativeBrokerJournalServiceState(service) + if err != nil { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, err + } + executable, err := nativeServiceExecutableFromCommandLine(config.BinaryPathName) + if err != nil || !strings.EqualFold(filepath.Clean(executable), filepath.Clean(j.snapshot.CandidatePath)) || + !isLocalSystemServiceAccount(config.ServiceStartName) || + compareNativeSecurityDescriptorStrings(dacl, nativeBrokerServiceSDDL) != nil { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, &nativeBrokerJournalManualError{cause: errors.New( + "current service is not an exact transaction-owned broker service", + )} + } + if status.State != svc.Stopped { + if err := stopNativeService(ctx, service, waitContext); err != nil { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, err + } + } + prior := j.snapshot.Service + if !prior.Exists { + candidateConfig, _, err := nativeBrokerServiceConfiguration( + j.snapshot.CandidatePath, mustNativeBrokerJournalCredentialPath(), + ) + if err != nil || !nativeServiceConfigsEqual(config, candidateConfig) || + !slices.Equal(recovery, nativeServiceRecoveryActions) || + reset != nativeServiceRecoveryResetSecond || !nonCrash { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, &nativeBrokerJournalManualError{cause: errors.New( + "new broker service is only partially configured; exact deletion is unsafe", + )} + } + if err := service.Delete(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, err + } + service.Close() //nolint:errcheck + if err := waitForNativePackageServiceDeletion(ctx, manager); err != nil { + manager.Close() //nolint:errcheck + return nil, nil, err + } + return nil, manager, nil + } + if !nativeServiceConfigsEqual(config, prior.Config) { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, &nativeBrokerJournalManualError{cause: errors.New( + "existing broker service configuration is outside the durable prior identity", + )} + } + if err := service.UpdateConfig(prior.Config); err != nil { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, err + } + if err := service.SetSecurityDescriptor(prior.SecurityDescriptor); err != nil { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, err + } + if err := service.SetRecoveryActionsExact( + prior.RecoveryActions, prior.RecoveryResetSeconds, + ); err != nil { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, err + } + if err := service.SetRecoveryActionsOnNonCrashFailures(prior.RecoverNonCrash); err != nil { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, err + } + verifiedConfig, verifiedDACL, verifiedRecovery, verifiedReset, verifiedNonCrash, verifiedStatus, err := + exactNativeBrokerJournalServiceState(service) + if err != nil || !nativeServiceConfigsEqual(verifiedConfig, prior.Config) || + compareNativeSecurityDescriptorStrings(verifiedDACL, prior.SecurityDescriptor) != nil || + !slices.Equal(verifiedRecovery, prior.RecoveryActions) || + verifiedReset != prior.RecoveryResetSeconds || verifiedNonCrash != prior.RecoverNonCrash || + verifiedStatus.State != svc.Stopped { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, errors.Join(err, errors.New( + "prior native broker service did not verify after durable restoration", + )) + } + return service, manager, nil +} + +func mustNativeBrokerJournalCredentialPath() string { + path, _ := nativeServiceKeyFilePath() + return path +} + +func restoreNativeBrokerJournalLegacy( + ctx context.Context, + j *nativeBrokerJournal, + prior nativeBrokerJournalLegacySnapshot, +) error { + hive, err := registry.OpenKey(registry.USERS, prior.UserSID, registry.READ) + if err != nil { + return fmt.Errorf("open target user hive for broker recovery: %w", err) + } + defer hive.Close() //nolint:errcheck + runKey, err := registry.OpenKey(hive, runKeyPath, registry.QUERY_VALUE|registry.SET_VALUE) + if errors.Is(err, registry.ErrNotExist) { + if prior.RunValueText != nil || prior.RunKeyExisted { + return &nativeBrokerJournalManualError{cause: errors.New( + "prior target-user Run key disappeared during broker recovery", + )} + } + } else if err != nil { + return err + } + if runKey != 0 { + defer runKey.Close() //nolint:errcheck + current, found, err := readNativeRunRegistration(runKey) + if err != nil { + return err + } + if prior.RunValueText == nil { + if found { + return &nativeBrokerJournalManualError{cause: errors.New( + "legacy Run registration appeared during broker recovery", + )} + } + } else { + expected := nativeRunRegistration{value: *prior.RunValueText, valueType: prior.RunValueType} + if found && !nativeRunRegistrationsEqual(current, expected) { + return &nativeBrokerJournalManualError{cause: errors.New( + "legacy Run registration changed outside the broker transaction", + )} + } + if !found { + if err := setNativeRunRegistration(runKey, expected); err != nil { + return err + } + } + } + } + _, currentXML, currentActive, _, found, err := currentScheduledTaskCommand(ctx) + if err != nil { + return err + } + if prior.ScheduledXML == nil { + if found { + return &nativeBrokerJournalManualError{cause: errors.New( + "legacy scheduled task appeared during broker recovery", + )} + } + } else { + if !found { + return &nativeBrokerJournalManualError{cause: errors.New( + "legacy scheduled task disappeared during broker recovery", + )} + } + if currentXML != *prior.ScheduledXML { + if err := validateNativeTaskDisabledOnly(*prior.ScheduledXML, currentXML); err != nil { + return &nativeBrokerJournalManualError{cause: err} + } + if err := restoreNativeScheduledTask(ctx, *prior.ScheduledXML, currentXML); err != nil { + return err + } + } + if prior.ScheduledActive && !currentActive { + if err := startNativeScheduledTask(ctx, *prior.ScheduledXML); err != nil { + return err + } + } + } + for _, command := range prior.SerializableCmds { + if !command.WasRunning || command.Source != uint8(legacyCommandRun) { + continue + } + processes, err := openLegacyProcessesByExecutable(command.Executable, prior.UserSID) + if err != nil { + return err + } + alreadyRunning := len(processes) != 0 + for _, process := range processes { + windows.CloseHandle(process.handle) //nolint:errcheck + } + if alreadyRunning { + continue + } + verify, release, err := lockNativeLegacyTaskExecutable(command.Executable) + if err != nil { + return err + } + if err := verify(); err != nil { + release() + return err + } + err = startNativeLegacyCommandAsShellUser(nativeLegacyCommand{ + executable: command.Executable, arguments: command.Arguments, + workingDirectory: command.WorkingDirectory, source: legacyCommandRun, + }, prior.UserSID) + release() + if err != nil { + return err + } + } + return nil +} + +func rollbackNativeBrokerJournal(ctx context.Context, j *nativeBrokerJournal) (resultErr error) { + priorCredential, priorLegacy, err := j.loadProtectedArtifacts() + if err != nil { + return &nativeBrokerJournalManualError{cause: err} + } + if nativeBrokerJournalPhaseIndex(nativeBrokerForwardPhaseOrder, j.lastPhase()) >= 0 { + if err := j.appendPhase(nativeBrokerPhaseRollbackIntent, ""); err != nil { + return err + } + } + service, manager, err := restoreNativeBrokerJournalService(ctx, j) + if err != nil { + j.appendPhase(nativeBrokerPhaseManual, "") //nolint:errcheck + return err + } + if manager != nil { + defer manager.Close() //nolint:errcheck + } + if service != nil { + defer service.Close() //nolint:errcheck + } + if err := j.appendPhase(nativeBrokerPhaseRollbackService, ""); err != nil { + return err + } + if err := restoreNativeBrokerJournalCredential(j, priorCredential); err != nil { + j.appendPhase(nativeBrokerPhaseManual, "") //nolint:errcheck + return err + } + if err := j.appendPhase(nativeBrokerPhaseRollbackCredential, ""); err != nil { + return err + } + if err := restoreNativeBrokerJournalImage(j); err != nil { + j.appendPhase(nativeBrokerPhaseManual, "") //nolint:errcheck + return err + } + if err := j.appendPhase(nativeBrokerPhaseRollbackImage, ""); err != nil { + return err + } + if err := restoreNativeBrokerJournalLegacy(ctx, j, priorLegacy); err != nil { + j.appendPhase(nativeBrokerPhaseManual, "") //nolint:errcheck + return err + } + if err := j.appendPhase(nativeBrokerPhaseRollbackLegacy, ""); err != nil { + return err + } + if service != nil && j.snapshot.Service.WasRunning { + if err := service.Start(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) { + j.appendPhase(nativeBrokerPhaseManual, "") //nolint:errcheck + return err + } + if err := waitForNativeServiceState(ctx, service, svc.Running, waitContext); err != nil { + j.appendPhase(nativeBrokerPhaseManual, "") //nolint:errcheck + return err + } + } + if err := j.appendPhase(nativeBrokerPhaseRollbackSettled, ""); err != nil { + return err + } + return retireNativeBrokerJournal(j) +} + +func reconcileNativeBrokerJournalBeforeAdmission( + ctx context.Context, + logger *slog.Logger, + userSID string, +) error { + _, err := reconcileNativeBrokerJournalBeforeAdmissionInternal( + ctx, logger, userSID, false, + ) + return err +} + +func reconcileNativeBrokerJournalBeforeOuterPackage( + ctx context.Context, + logger *slog.Logger, + userSID string, +) (bool, error) { + return reconcileNativeBrokerJournalBeforeAdmissionInternal( + ctx, logger, userSID, true, + ) +} + +func reconcileNativeBrokerJournalBeforeAdmissionInternal( + ctx context.Context, + logger *slog.Logger, + userSID string, + allowNestedReady bool, +) (bool, error) { + if err := reconcileNativeBrokerJournalInactiveDirectories(logger, userSID); err != nil { + return false, err + } + _, active, err := nativeBrokerJournalPaths(userSID) + if err != nil { + return false, err + } + if _, err := nativePathAttributes(active); err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return false, nil + } + return false, err + } + handle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(active, false) + if err != nil { + return false, &nativeBrokerJournalManualError{cause: err} + } + windows.CloseHandle(handle) //nolint:errcheck + j, err := loadNativeBrokerJournal(active) + if err != nil { + return false, &nativeBrokerJournalManualError{cause: err} + } + if !strings.EqualFold(j.snapshot.TargetUserSID, userSID) { + return false, &nativeBrokerJournalManualError{cause: errors.New( + "active broker journal belongs to a different target user SID", + )} + } + switch j.lastPhase() { + case nativeBrokerPhaseRollbackSettled: + return false, retireNativeBrokerJournal(j) + case nativeBrokerPhaseNestedReady, nativeBrokerPhaseOuterSettlementPending, + nativeBrokerPhaseOuterSettled: + if !allowNestedReady { + return false, &nativeBrokerJournalManualError{cause: errors.New( + "broker readiness lacks a completed authoritative outer package settlement", + )} + } + if _, _, err := j.loadProtectedArtifacts(); err != nil { + return false, &nativeBrokerJournalManualError{cause: err} + } + if err := verifyNativeBrokerJournalForwardState(ctx, j); err != nil { + return false, &nativeBrokerJournalManualError{cause: fmt.Errorf( + "pending outer broker settlement failed exact forward verification: %w", err, + )} + } + if j.lastPhase() == nativeBrokerPhaseOuterSettled { + finalPath, err := nativeBrokerOuterSettlementFinalPath(j) + if err != nil { + return false, &nativeBrokerJournalManualError{cause: err} + } + if _, err := nativePathAttributes(finalPath); err == nil { + if _, err := loadNativeBrokerOuterSettlementFinalForReconciliation(j); err != nil { + return false, &nativeBrokerJournalManualError{cause: fmt.Errorf( + "terminal broker settlement has an invalid protected final receipt: %w", err, + )} + } + } else if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return false, &nativeBrokerJournalManualError{cause: fmt.Errorf( + "inspect terminal broker settlement final receipt: %w", err, + )} + } + } + return true, nil + case nativeBrokerPhaseManual: + return false, &nativeBrokerJournalManualError{cause: errors.New( + "prior broker recovery is latched manual", + )} + default: + if _, _, err := j.loadProtectedArtifacts(); err != nil { + return false, &nativeBrokerJournalManualError{cause: err} + } + logger.Warn("Recovering an interrupted native broker transaction", + "transactionId", j.snapshot.TransactionID, + "phase", j.lastPhase()) + return false, rollbackNativeBrokerJournal(ctx, j) + } +} + +func verifyNativeBrokerJournalForwardState( + ctx context.Context, + j *nativeBrokerJournal, +) error { + imageHash, imageExists, err := nativeBrokerJournalPathHash(j.snapshot.CandidatePath) + if err != nil { + return err + } + if !imageExists || !strings.EqualFold(imageHash, j.snapshot.CandidateSHA256) { + return errors.New("outer settlement found a different canonical broker image") + } + credential, exists, err := snapshotNativeBrokerCredentialReadOnly(j.snapshot.TargetUserSID) + if err != nil { + return err + } + candidateCredential := currentNativeBrokerJournalCandidateCredentialDigest(j) + if !exists || candidateCredential == "" || + !strings.EqualFold(nativeBrokerJournalHash(credential), candidateCredential) { + return errors.New("outer settlement found a different native broker credential") + } + managerRaw, err := mgr.Connect() + if err != nil { + return err + } + manager := &windowsNativeSCM{manager: managerRaw} + defer manager.Close() //nolint:errcheck + service, err := manager.OpenService(NativeBrokerServiceName) + if err != nil { + return err + } + defer service.Close() //nolint:errcheck + config, dacl, recovery, reset, nonCrash, status, err := exactNativeBrokerJournalServiceState(service) + if err != nil { + return err + } + credentialPath, err := nativeServiceKeyFilePath() + if err != nil { + return err + } + expectedConfig, _, err := nativeBrokerServiceConfiguration( + j.snapshot.CandidatePath, credentialPath, + ) + if err != nil { + return err + } + if !nativeServiceConfigsEqual(config, expectedConfig) || + compareNativeSecurityDescriptorStrings(dacl, nativeBrokerServiceSDDL) != nil || + !slices.Equal(recovery, nativeServiceRecoveryActions) || + reset != nativeServiceRecoveryResetSecond || !nonCrash || status.State != svc.Running { + return errors.New("outer settlement found a noncanonical native broker service") + } + legacy, err := snapshotNativeLegacyStartup(ctx, j.snapshot.TargetUserSID) + if err != nil { + return err + } + if legacy.release != nil { + defer legacy.release() + } + if nativeLegacyStartupOwnsRuntime(legacy) { + return errors.New("outer settlement found active legacy startup ownership") + } + return nil +} + +func validateNativeBrokerNestedReplayBinding( + j *nativeBrokerJournal, + userSID, outerTokenPath, outerTransactionID, candidateSHA256 string, +) error { + if j == nil || j.lastPhase() != nativeBrokerPhaseNestedReady { + return errors.New("active broker journal is not at nested-ready") + } + if !strings.EqualFold(j.snapshot.TargetUserSID, userSID) || + !strings.EqualFold(filepath.Clean(j.snapshot.OuterTokenPath), filepath.Clean(outerTokenPath)) || + !strings.EqualFold(j.snapshot.OuterTransactionID, outerTransactionID) || + !strings.EqualFold(j.snapshot.CandidateSHA256, candidateSHA256) { + return errors.New("active nested broker journal does not match the exact outer token, candidate, and target user") + } + proof := j.proof() + if proof.TransactionID != j.snapshot.TransactionID || + proof.State != string(nativeBrokerPhaseNestedReady) || + !isCanonicalNativeBrokerJournalSHA256(proof.Digest) { + return errors.New("durable nested-ready proof is not canonical") + } + return nil +} + +func replayNativeBrokerNestedReadyProof( + ctx context.Context, + logger *slog.Logger, + userSID, outerTokenPath, outerTransactionID, candidateSHA256 string, +) (nativeBrokerJournalProof, bool, bool, error) { + if err := reconcileNativeBrokerJournalInactiveDirectories(logger, userSID); err != nil { + return nativeBrokerJournalProof{}, false, false, err + } + _, active, err := nativeBrokerJournalPaths(userSID) + if err != nil { + return nativeBrokerJournalProof{}, false, false, err + } + if _, err := nativePathAttributes(active); err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nativeBrokerJournalProof{}, false, false, nil + } + return nativeBrokerJournalProof{}, false, false, err + } + handle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(active, false) + if err != nil { + return nativeBrokerJournalProof{}, false, true, &nativeBrokerJournalManualError{cause: err} + } + windows.CloseHandle(handle) //nolint:errcheck + j, err := loadNativeBrokerJournal(active) + if err != nil { + return nativeBrokerJournalProof{}, false, true, &nativeBrokerJournalManualError{cause: err} + } + proof := j.proof() + if !strings.EqualFold(j.snapshot.TargetUserSID, userSID) || + !strings.EqualFold(filepath.Clean(j.snapshot.OuterTokenPath), filepath.Clean(outerTokenPath)) || + !strings.EqualFold(j.snapshot.OuterTransactionID, outerTransactionID) || + !strings.EqualFold(j.snapshot.CandidateSHA256, candidateSHA256) { + return proof, false, true, &nativeBrokerJournalManualError{cause: errors.New( + "active broker journal does not match the exact outer token, candidate, and target user", + )} + } + switch j.lastPhase() { + case nativeBrokerPhaseManual, nativeBrokerPhaseOuterSettled: + return proof, false, true, &nativeBrokerJournalManualError{cause: fmt.Errorf( + "active broker journal phase %s cannot be replayed or rolled back by the child", + j.lastPhase(), + )} + case nativeBrokerPhaseRollbackSettled: + if err := retireNativeBrokerJournal(j); err != nil { + return proof, false, true, err + } + return proof, false, true, nil + case nativeBrokerPhaseNestedReady: + // Exact forward replay is verified below without changing the journal. + case nativeBrokerPhaseOuterSettlementPending: + return proof, false, true, errors.New( + "broker child journal is already pending outer acknowledgement; replay the retained outer binding", + ) + default: + if _, _, err := j.loadProtectedArtifacts(); err != nil { + return proof, false, true, &nativeBrokerJournalManualError{cause: err} + } + if err := rollbackNativeBrokerJournal(ctx, j); err != nil { + return j.proof(), false, true, err + } + return j.proof(), false, true, nil + } + if err := validateNativeBrokerNestedReplayBinding( + j, userSID, outerTokenPath, outerTransactionID, candidateSHA256, + ); err != nil { + return proof, false, true, &nativeBrokerJournalManualError{cause: err} + } + if _, _, err := j.loadProtectedArtifacts(); err != nil { + return proof, false, true, &nativeBrokerJournalManualError{cause: err} + } + if err := verifyNativeBrokerJournalForwardState(ctx, j); err != nil { + return proof, false, true, &nativeBrokerJournalManualError{cause: fmt.Errorf( + "durable nested-ready state failed exact replay verification: %w", err, + )} + } + return proof, true, true, nil +} + +func nativeBrokerJournalOuterSettlementIdentity( + outerTransactionID, candidateSHA256 string, + proof nativePackageInstallProof, +) (string, string) { + if proof.success && proof.journal.TransactionID != "" && + (proof.journalRecovery == "fresh" || proof.journalRecovery == "replayed") { + return proof.journal.OuterTransactionID, proof.journal.CandidateSHA256 + } + return outerTransactionID, candidateSHA256 +} + +func discardSettledNativeBrokerOuterToken(j *nativeBrokerJournal) error { + if j == nil { + return errors.New("settled broker token cleanup has no journal") + } + path := j.snapshot.OuterTokenPath + handle, err := openNativePathWithoutReparse( + path, windows.GENERIC_READ|windows.READ_CONTROL|windows.DELETE, false, + ) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil + } + return err + } + closeWith := func(result error) error { + return errors.Join(result, windows.CloseHandle(handle)) + } + if err := requireSingleNativeFileLink(handle); err != nil { + return closeWith(err) + } + if err := validateNativeSecurityDescriptor(handle, nativePackageTokenSDDL); err != nil { + return closeWith(err) + } + hash, err := hashNativePackageHandle(handle) + if err != nil { + return closeWith(err) + } + if hash != j.snapshot.OuterTransactionID { + return closeWith(errors.New("retained outer token no longer matches its settled transaction")) + } + if err := windows.CloseHandle(handle); err != nil { + return err + } + if err := deleteNativePackageFile(path); err != nil && + !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return err + } + return nil +} + +func validateNativeBrokerOuterSettlementBinding( + j *nativeBrokerJournal, + proof nativePackageInstallProof, +) (nativeBrokerOuterSettlementBinding, string, error) { + if j == nil || (j.lastPhase() != nativeBrokerPhaseNestedReady && + j.lastPhase() != nativeBrokerPhaseOuterSettlementPending && + j.lastPhase() != nativeBrokerPhaseOuterSettled) { + return nativeBrokerOuterSettlementBinding{}, "", errors.New( + "active broker journal is not eligible for outer settlement", + ) + } + nestedIndex := len(j.records) - 1 + if j.lastPhase() == nativeBrokerPhaseOuterSettlementPending { + nestedIndex-- + } else if j.lastPhase() == nativeBrokerPhaseOuterSettled { + nestedIndex -= 2 + } + if nestedIndex < 0 || j.records[nestedIndex].Phase != nativeBrokerPhaseNestedReady { + return nativeBrokerOuterSettlementBinding{}, "", errors.New( + "outer settlement lacks the immediately preceding nested-ready record", + ) + } + nestedDigest := j.records[nestedIndex].RecordSHA256 + if !proof.success || !proof.changed || proof.exitCode != 0 || + (proof.journalRecovery != "fresh" && proof.journalRecovery != "replayed") || + proof.journal.TransactionID != j.snapshot.TransactionID || + proof.journal.OuterTransactionID != j.snapshot.OuterTransactionID || + proof.journal.CandidateSHA256 != j.snapshot.CandidateSHA256 || + proof.journal.State != string(nativeBrokerPhaseNestedReady) || + proof.journal.Digest != nestedDigest || + !isCanonicalNativeBrokerJournalSHA256(proof.driverTransactionID) || + !isCanonicalNativeBrokerJournalSHA256(proof.driverPendingDigest) || + !isCanonicalNativeBrokerJournalSHA256(proof.settlementNonce) { + return nativeBrokerOuterSettlementBinding{}, "", errors.New( + "outer driver proof omitted or mismatched the durable two-phase journal binding", + ) + } + binding := nativeBrokerOuterSettlementBinding{ + Schema: nativeBrokerJournalSchema, + BrokerTransactionID: j.snapshot.TransactionID, + BrokerOuterTransactionID: j.snapshot.OuterTransactionID, + BrokerCandidateSHA256: j.snapshot.CandidateSHA256, + BrokerNestedDigest: nestedDigest, + DriverTransactionID: proof.driverTransactionID, + DriverPendingDigest: proof.driverPendingDigest, + SettlementNonce: proof.settlementNonce, + } + bindingBytes, err := nativeBrokerJournalCanonicalJSON(binding) + if err != nil { + return nativeBrokerOuterSettlementBinding{}, "", err + } + return binding, nativeBrokerJournalHash(bindingBytes), nil +} + +func validateNativeBrokerOuterSettlementRequest( + j *nativeBrokerJournal, + request nativeBrokerOuterSettlementRequest, +) error { + if j == nil || (j.lastPhase() != nativeBrokerPhaseOuterSettlementPending && + j.lastPhase() != nativeBrokerPhaseOuterSettled) { + return errors.New("broker settlement request has no exact pending journal state") + } + pendingIndex := len(j.records) - 1 + if j.lastPhase() == nativeBrokerPhaseOuterSettled { + pendingIndex-- + } + nestedIndex := pendingIndex - 1 + if nestedIndex < 0 || + j.records[pendingIndex].Phase != nativeBrokerPhaseOuterSettlementPending || + j.records[nestedIndex].Phase != nativeBrokerPhaseNestedReady { + return errors.New("broker settlement request has no exact pending journal state") + } + binding := request.Binding + if request.Schema != nativeBrokerJournalSchema || binding.Schema != nativeBrokerJournalSchema || + binding.BrokerTransactionID != j.snapshot.TransactionID || + binding.BrokerOuterTransactionID != j.snapshot.OuterTransactionID || + binding.BrokerCandidateSHA256 != j.snapshot.CandidateSHA256 || + binding.BrokerNestedDigest != j.records[nestedIndex].RecordSHA256 || + !isCanonicalNativeBrokerJournalSHA256(binding.DriverTransactionID) || + !isCanonicalNativeBrokerJournalSHA256(binding.DriverPendingDigest) || + !isCanonicalNativeBrokerJournalSHA256(binding.SettlementNonce) || + !isCanonicalNativeBrokerJournalSHA256(request.BindingSHA256) || + !isCanonicalNativeBrokerJournalSHA256(request.BrokerPendingDigest) || + request.BrokerPendingDigest != j.records[pendingIndex].RecordSHA256 || + request.BindingSHA256 != j.records[pendingIndex].DetailSHA256 { + return errors.New("broker settlement request identity does not match its journal chain") + } + bindingBytes, err := nativeBrokerJournalCanonicalJSON(binding) + if err != nil { + return err + } + if request.BindingSHA256 != nativeBrokerJournalHash(bindingBytes) { + return errors.New("broker settlement request binding digest is invalid") + } + return nil +} + +func encodeNativeBrokerOuterSettlementEnvelope( + request nativeBrokerOuterSettlementRequest, +) ([]byte, string, error) { + payload, err := nativeBrokerJournalCanonicalJSON(request) + if err != nil { + return nil, "", err + } + envelope := nativeBrokerOuterSettlementEnvelope{ + Schema: nativeBrokerJournalSchema, + PayloadSHA256: nativeBrokerJournalHash(payload), + Payload: request, + } + contents, err := nativeBrokerJournalCanonicalJSON(envelope) + if err != nil { + return nil, "", err + } + if len(contents) > nativeBrokerJournalMaximumSettlement { + return nil, "", errors.New("broker settlement envelope exceeds its bound") + } + return contents, nativeBrokerJournalHash(contents), nil +} + +func decodeNativeBrokerOuterSettlementEnvelope( + contents []byte, +) (nativeBrokerOuterSettlementEnvelope, error) { + var envelope nativeBrokerOuterSettlementEnvelope + if err := decodeCanonicalNativeBrokerJSON( + contents, &envelope, nativeBrokerJournalMaximumSettlement, + ); err != nil { + return nativeBrokerOuterSettlementEnvelope{}, err + } + payload, err := nativeBrokerJournalCanonicalJSON(envelope.Payload) + if err != nil { + return nativeBrokerOuterSettlementEnvelope{}, err + } + if envelope.Schema != nativeBrokerJournalSchema || + !isCanonicalNativeBrokerJournalSHA256(envelope.PayloadSHA256) || + envelope.PayloadSHA256 != nativeBrokerJournalHash(payload) { + return nativeBrokerOuterSettlementEnvelope{}, errors.New( + "broker settlement envelope schema or payload digest is invalid", + ) + } + return envelope, nil +} + +func nativeBrokerOuterSettlementRequestPath(j *nativeBrokerJournal) (string, error) { + if j == nil { + return "", errors.New("broker settlement request has no journal") + } + _, active, err := nativeBrokerJournalPaths(j.snapshot.TargetUserSID) + if err != nil { + return "", err + } + if !strings.EqualFold(filepath.Clean(j.directory), filepath.Clean(active)) { + return "", errors.New("broker settlement request escaped the exact active journal") + } + path := filepath.Join(active, nativeBrokerJournalSettlementName) + if !strings.EqualFold(filepath.Dir(path), active) || + filepath.Base(path) != nativeBrokerJournalSettlementName { + return "", errors.New("broker settlement request path escaped its active journal") + } + return path, nil +} + +func loadNativeBrokerOuterSettlementRequest( + j *nativeBrokerJournal, +) (nativeBrokerOuterSettlementPrepared, error) { + path, err := nativeBrokerOuterSettlementRequestPath(j) + if err != nil { + return nativeBrokerOuterSettlementPrepared{}, err + } + contents, err := readNativeBrokerJournalFile(path, nativeBrokerJournalMaximumSettlement) + if err != nil { + return nativeBrokerOuterSettlementPrepared{}, err + } + envelope, err := decodeNativeBrokerOuterSettlementEnvelope(contents) + if err != nil { + return nativeBrokerOuterSettlementPrepared{}, err + } + if err := validateNativeBrokerOuterSettlementRequest(j, envelope.Payload); err != nil { + return nativeBrokerOuterSettlementPrepared{}, err + } + return nativeBrokerOuterSettlementPrepared{ + Request: envelope.Payload, RequestPath: path, + RequestSHA256: nativeBrokerJournalHash(contents), contents: contents, + }, nil +} + +func publishNativeBrokerOuterSettlementRequest( + j *nativeBrokerJournal, + prepared nativeBrokerOuterSettlementPrepared, +) error { + expectedPath, err := nativeBrokerOuterSettlementRequestPath(j) + if err != nil { + return err + } + if !strings.EqualFold(filepath.Clean(prepared.RequestPath), filepath.Clean(expectedPath)) || + prepared.RequestSHA256 != nativeBrokerJournalHash(prepared.contents) { + return errors.New("broker settlement publication identity changed") + } + if envelope, err := decodeNativeBrokerOuterSettlementEnvelope(prepared.contents); err != nil { + return err + } else if envelope.Payload != prepared.Request { + return errors.New("broker settlement publication payload changed") + } + if err := validateNativeBrokerOuterSettlementRequest(j, prepared.Request); err != nil { + return err + } + staging := expectedPath + ".next" + loadOptional := func(path string) ([]byte, bool, error) { + if _, err := nativePathAttributes(path); err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil, false, nil + } + return nil, false, err + } + contents, err := readNativeBrokerJournalFile(path, nativeBrokerJournalMaximumSettlement) + return contents, true, err + } + if err := executeNativeBrokerSettlementPublication( + prepared.contents, + nativeBrokerSettlementPublicationOperations{ + loadPublished: func() ([]byte, bool, error) { + return loadOptional(expectedPath) + }, + loadStaging: func() ([]byte, bool, error) { + return loadOptional(staging) + }, + discardStaging: func() error { + return discardUnpublishedNativeBrokerJournalFile(staging) + }, + publishStaging: func() error { + return moveNativePackageFile(staging, expectedPath, false) + }, + writeNew: func() error { + return writeNativeBrokerJournalFile( + expectedPath, prepared.contents, nativeBrokerJournalMaximumSettlement, + ) + }, + readback: func() ([]byte, error) { + return readNativeBrokerJournalFile( + expectedPath, nativeBrokerJournalMaximumSettlement, + ) + }, + }, + ); err != nil { + return fmt.Errorf("publish broker settlement request: %w", err) + } + loaded, err := loadNativeBrokerOuterSettlementRequest(j) + if err != nil { + return fmt.Errorf("read back broker settlement request: %w", err) + } + if loaded.Request != prepared.Request || loaded.RequestSHA256 != prepared.RequestSHA256 || + !bytes.Equal(loaded.contents, prepared.contents) { + return errors.New("published broker settlement request differs from its durable receipt") + } + return nil +} + +func armNativeBrokerOuterSettlement( + ctx context.Context, + userSID, outerTransactionID, candidateSHA256 string, + proof nativePackageInstallProof, +) (*nativeBrokerJournal, nativeBrokerOuterSettlementPrepared, error) { + _, active, err := nativeBrokerJournalPaths(userSID) + if err != nil { + return nil, nativeBrokerOuterSettlementPrepared{}, err + } + if _, err := nativePathAttributes(active); err != nil { + return nil, nativeBrokerOuterSettlementPrepared{}, errors.Join( + err, errors.New("authoritative outer success expected a durable broker journal"), + ) + } + j, err := loadNativeBrokerJournal(active) + if err != nil { + return nil, nativeBrokerOuterSettlementPrepared{}, &nativeBrokerJournalManualError{cause: err} + } + expectedOuterTransactionID, expectedCandidateSHA256 := + nativeBrokerJournalOuterSettlementIdentity(outerTransactionID, candidateSHA256, proof) + if !strings.EqualFold(j.snapshot.OuterTransactionID, expectedOuterTransactionID) || + !strings.EqualFold(j.snapshot.CandidateSHA256, expectedCandidateSHA256) || + !strings.EqualFold(j.snapshot.TargetUserSID, userSID) { + return j, nativeBrokerOuterSettlementPrepared{}, &nativeBrokerJournalManualError{cause: errors.New( + "outer package proof does not match the durable broker journal binding", + )} + } + if _, _, err := j.loadProtectedArtifacts(); err != nil { + return j, nativeBrokerOuterSettlementPrepared{}, &nativeBrokerJournalManualError{cause: err} + } + binding, bindingDigest, err := validateNativeBrokerOuterSettlementBinding(j, proof) + if err != nil { + return j, nativeBrokerOuterSettlementPrepared{}, &nativeBrokerJournalManualError{cause: err} + } + if err := verifyNativeBrokerJournalForwardState(ctx, j); err != nil { + return j, nativeBrokerOuterSettlementPrepared{}, &nativeBrokerJournalManualError{cause: err} + } + if j.lastPhase() == nativeBrokerPhaseNestedReady { + if err := j.appendPhase(nativeBrokerPhaseOuterSettlementPending, bindingDigest); err != nil { + return j, nativeBrokerOuterSettlementPrepared{}, err + } + } else { + pendingIndex := len(j.records) - 1 + if j.lastPhase() == nativeBrokerPhaseOuterSettled { + pendingIndex-- + } + if pendingIndex < 0 || + j.records[pendingIndex].Phase != nativeBrokerPhaseOuterSettlementPending || + j.records[pendingIndex].DetailSHA256 != bindingDigest { + return j, nativeBrokerOuterSettlementPrepared{}, &nativeBrokerJournalManualError{cause: errors.New( + "replayed outer settlement binding differs from the durable pending record", + )} + } + } + pendingIndex := len(j.records) - 1 + if j.lastPhase() == nativeBrokerPhaseOuterSettled { + pendingIndex-- + } + request := nativeBrokerOuterSettlementRequest{ + Schema: nativeBrokerJournalSchema, + BindingSHA256: bindingDigest, + BrokerPendingDigest: j.records[pendingIndex].RecordSHA256, + Binding: binding, + } + if err := validateNativeBrokerOuterSettlementRequest(j, request); err != nil { + return j, nativeBrokerOuterSettlementPrepared{}, err + } + contents, requestDigest, err := encodeNativeBrokerOuterSettlementEnvelope(request) + if err != nil { + return j, nativeBrokerOuterSettlementPrepared{}, err + } + requestPath, err := nativeBrokerOuterSettlementRequestPath(j) + if err != nil { + return j, nativeBrokerOuterSettlementPrepared{}, err + } + return j, nativeBrokerOuterSettlementPrepared{ + Request: request, RequestPath: requestPath, + RequestSHA256: requestDigest, contents: contents, + }, nil +} + +func validateNativeBrokerOuterSettlementReceipt( + prepared nativeBrokerOuterSettlementPrepared, + receipt nativePackageBrokerSettlementReceipt, +) error { + binding := prepared.Request.Binding + if receipt.BrokerTransactionID != binding.BrokerTransactionID || + receipt.BrokerPendingDigest != prepared.Request.BrokerPendingDigest || + receipt.DriverTransactionID != binding.DriverTransactionID || + receipt.DriverPendingDigest != binding.DriverPendingDigest || + receipt.SettlementNonce != binding.SettlementNonce || + receipt.RequestSHA256 != prepared.RequestSHA256 || + receipt.State != string(nativeBrokerPhaseOuterSettled) || + !isCanonicalNativeBrokerJournalSHA256(receipt.Digest) || + receipt.Digest == receipt.DriverPendingDigest { + return errors.New("driver settlement receipt does not match both pending journal identities") + } + return nil +} + +func nativeBrokerOuterSettlementFinalPath(j *nativeBrokerJournal) (string, error) { + if j == nil { + return "", errors.New("broker final receipt has no journal") + } + _, active, err := nativeBrokerJournalPaths(j.snapshot.TargetUserSID) + if err != nil { + return "", err + } + if !strings.EqualFold(filepath.Clean(j.directory), filepath.Clean(active)) { + return "", errors.New("broker final receipt escaped the exact active journal") + } + path := filepath.Join(active, nativeBrokerJournalSettledReceiptName) + if !strings.EqualFold(filepath.Dir(path), active) || + filepath.Base(path) != nativeBrokerJournalSettledReceiptName { + return "", errors.New("broker final receipt path escaped its active journal") + } + return path, nil +} + +func encodeNativeBrokerOuterSettlementFinalEnvelope( + receipt nativeBrokerOuterSettlementFinal, +) ([]byte, string, error) { + payload, err := nativeBrokerJournalCanonicalJSON(receipt) + if err != nil { + return nil, "", err + } + envelope := nativeBrokerOuterSettlementFinalEnvelope{ + Schema: nativeBrokerJournalSchema, + PayloadSHA256: nativeBrokerJournalHash(payload), + Payload: receipt, + } + contents, err := nativeBrokerJournalCanonicalJSON(envelope) + if err != nil { + return nil, "", err + } + if len(contents) > nativeBrokerJournalMaximumSettlement { + return nil, "", errors.New("broker final receipt envelope exceeds its bound") + } + return contents, nativeBrokerJournalHash(contents), nil +} + +func decodeNativeBrokerOuterSettlementFinalEnvelope( + contents []byte, +) (nativeBrokerOuterSettlementFinalEnvelope, error) { + var envelope nativeBrokerOuterSettlementFinalEnvelope + if err := decodeCanonicalNativeBrokerJSON( + contents, &envelope, nativeBrokerJournalMaximumSettlement, + ); err != nil { + return nativeBrokerOuterSettlementFinalEnvelope{}, err + } + payload, err := nativeBrokerJournalCanonicalJSON(envelope.Payload) + if err != nil { + return nativeBrokerOuterSettlementFinalEnvelope{}, err + } + if envelope.Schema != nativeBrokerJournalSchema || + !isCanonicalNativeBrokerJournalSHA256(envelope.PayloadSHA256) || + envelope.PayloadSHA256 != nativeBrokerJournalHash(payload) { + return nativeBrokerOuterSettlementFinalEnvelope{}, errors.New( + "broker final receipt envelope schema or payload digest is invalid", + ) + } + return envelope, nil +} + +func validateNativeBrokerOuterSettlementFinal( + j *nativeBrokerJournal, + prepared nativeBrokerOuterSettlementPrepared, + driverReceipt nativePackageBrokerSettlementReceipt, + receipt nativeBrokerOuterSettlementFinal, +) error { + if j == nil || j.lastPhase() != nativeBrokerPhaseOuterSettled || + len(j.records) < 3 || + j.records[len(j.records)-2].Phase != nativeBrokerPhaseOuterSettlementPending || + j.records[len(j.records)-3].Phase != nativeBrokerPhaseNestedReady { + return errors.New("broker final receipt has no exact terminal journal state") + } + driverReceiptBytes, err := nativeBrokerJournalCanonicalJSON(driverReceipt) + if err != nil { + return err + } + binding := prepared.Request.Binding + if receipt.Schema != nativeBrokerJournalSchema || + receipt.BrokerTransactionID != binding.BrokerTransactionID || + receipt.BrokerPendingDigest != prepared.Request.BrokerPendingDigest || + receipt.BrokerSettledDigest != j.records[len(j.records)-1].RecordSHA256 || + receipt.DriverTransactionID != binding.DriverTransactionID || + receipt.DriverPendingDigest != binding.DriverPendingDigest || + receipt.DriverSettledDigest != driverReceipt.Digest || + receipt.SettlementNonce != binding.SettlementNonce || + receipt.RequestSHA256 != prepared.RequestSHA256 || + receipt.State != string(nativeBrokerPhaseOuterSettled) || + j.records[len(j.records)-1].DetailSHA256 != nativeBrokerJournalHash(driverReceiptBytes) { + return errors.New("broker final receipt does not bind both terminal journal digests") + } + for _, digest := range []string{ + receipt.BrokerPendingDigest, receipt.BrokerSettledDigest, + receipt.DriverTransactionID, receipt.DriverPendingDigest, + receipt.DriverSettledDigest, receipt.SettlementNonce, + receipt.RequestSHA256, + } { + if !isCanonicalNativeBrokerJournalSHA256(digest) { + return errors.New("broker final receipt contains a malformed digest") + } + } + return nil +} + +func loadNativeBrokerOuterSettlementFinal( + j *nativeBrokerJournal, + prepared nativeBrokerOuterSettlementPrepared, + driverReceipt nativePackageBrokerSettlementReceipt, +) (nativeBrokerOuterSettlementFinalPrepared, error) { + path, err := nativeBrokerOuterSettlementFinalPath(j) + if err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + contents, err := readNativeBrokerJournalFile(path, nativeBrokerJournalMaximumSettlement) + if err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + envelope, err := decodeNativeBrokerOuterSettlementFinalEnvelope(contents) + if err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + if err := validateNativeBrokerOuterSettlementFinal( + j, prepared, driverReceipt, envelope.Payload, + ); err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + return nativeBrokerOuterSettlementFinalPrepared{ + Receipt: envelope.Payload, ReceiptPath: path, + ReceiptSHA256: nativeBrokerJournalHash(contents), contents: contents, + }, nil +} + +func loadNativeBrokerOuterSettlementFinalForReconciliation( + j *nativeBrokerJournal, +) (nativeBrokerOuterSettlementFinalPrepared, error) { + prepared, err := loadNativeBrokerOuterSettlementRequest(j) + if err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + path, err := nativeBrokerOuterSettlementFinalPath(j) + if err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + contents, err := readNativeBrokerJournalFile(path, nativeBrokerJournalMaximumSettlement) + if err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + envelope, err := decodeNativeBrokerOuterSettlementFinalEnvelope(contents) + if err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + driverReceipt := nativeBrokerDriverReceiptFromFinal(envelope.Payload) + if err := validateNativeBrokerOuterSettlementReceipt(prepared, driverReceipt); err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + if err := validateNativeBrokerOuterSettlementFinal( + j, prepared, driverReceipt, envelope.Payload, + ); err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + return nativeBrokerOuterSettlementFinalPrepared{ + Receipt: envelope.Payload, ReceiptPath: path, + ReceiptSHA256: nativeBrokerJournalHash(contents), contents: contents, + }, nil +} + +func publishNativeBrokerOuterSettlementFinal( + j *nativeBrokerJournal, + prepared nativeBrokerOuterSettlementFinalPrepared, + driverRequest nativeBrokerOuterSettlementPrepared, + driverReceipt nativePackageBrokerSettlementReceipt, +) error { + expectedPath, err := nativeBrokerOuterSettlementFinalPath(j) + if err != nil { + return err + } + if !strings.EqualFold(filepath.Clean(prepared.ReceiptPath), filepath.Clean(expectedPath)) || + prepared.ReceiptSHA256 != nativeBrokerJournalHash(prepared.contents) { + return errors.New("broker final receipt publication identity changed") + } + if envelope, err := decodeNativeBrokerOuterSettlementFinalEnvelope(prepared.contents); err != nil { + return err + } else if envelope.Payload != prepared.Receipt { + return errors.New("broker final receipt publication payload changed") + } + if err := validateNativeBrokerOuterSettlementFinal( + j, driverRequest, driverReceipt, prepared.Receipt, + ); err != nil { + return err + } + staging := expectedPath + ".next" + loadOptional := func(path string) ([]byte, bool, error) { + if _, err := nativePathAttributes(path); err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil, false, nil + } + return nil, false, err + } + contents, err := readNativeBrokerJournalFile(path, nativeBrokerJournalMaximumSettlement) + return contents, true, err + } + if err := executeNativeBrokerSettlementPublication( + prepared.contents, + nativeBrokerSettlementPublicationOperations{ + loadPublished: func() ([]byte, bool, error) { return loadOptional(expectedPath) }, + loadStaging: func() ([]byte, bool, error) { return loadOptional(staging) }, + discardStaging: func() error { + return discardUnpublishedNativeBrokerJournalFile(staging) + }, + publishStaging: func() error { + return moveNativePackageFile(staging, expectedPath, false) + }, + writeNew: func() error { + return writeNativeBrokerJournalFile( + expectedPath, prepared.contents, nativeBrokerJournalMaximumSettlement, + ) + }, + readback: func() ([]byte, error) { + return readNativeBrokerJournalFile(expectedPath, nativeBrokerJournalMaximumSettlement) + }, + }, + ); err != nil { + return fmt.Errorf("publish broker final receipt: %w", err) + } + loaded, err := loadNativeBrokerOuterSettlementFinal(j, driverRequest, driverReceipt) + if err != nil { + return fmt.Errorf("read back broker final receipt: %w", err) + } + if loaded.Receipt != prepared.Receipt || loaded.ReceiptSHA256 != prepared.ReceiptSHA256 || + !bytes.Equal(loaded.contents, prepared.contents) { + return errors.New("published broker final receipt differs from its durable bytes") + } + return nil +} + +func recordNativeBrokerOuterSettlement( + ctx context.Context, + userSID string, + receipt nativePackageBrokerSettlementReceipt, +) (*nativeBrokerJournal, nativeBrokerOuterSettlementFinalPrepared, error) { + _, active, err := nativeBrokerJournalPaths(userSID) + if err != nil { + return nil, nativeBrokerOuterSettlementFinalPrepared{}, err + } + j, err := loadNativeBrokerJournal(active) + if err != nil { + return nil, nativeBrokerOuterSettlementFinalPrepared{}, &nativeBrokerJournalManualError{cause: err} + } + if j.lastPhase() != nativeBrokerPhaseOuterSettlementPending && + j.lastPhase() != nativeBrokerPhaseOuterSettled { + return j, nativeBrokerOuterSettlementFinalPrepared{}, &nativeBrokerJournalManualError{cause: fmt.Errorf( + "broker settlement acknowledgement observed phase %s", j.lastPhase(), + )} + } + prepared, err := loadNativeBrokerOuterSettlementRequest(j) + if err != nil { + return j, nativeBrokerOuterSettlementFinalPrepared{}, &nativeBrokerJournalManualError{cause: err} + } + if err := validateNativeBrokerOuterSettlementReceipt(prepared, receipt); err != nil { + return j, nativeBrokerOuterSettlementFinalPrepared{}, &nativeBrokerJournalManualError{cause: err} + } + if _, _, err := j.loadProtectedArtifacts(); err != nil { + return j, nativeBrokerOuterSettlementFinalPrepared{}, &nativeBrokerJournalManualError{cause: err} + } + if err := verifyNativeBrokerJournalForwardState(ctx, j); err != nil { + return j, nativeBrokerOuterSettlementFinalPrepared{}, &nativeBrokerJournalManualError{cause: err} + } + receiptBytes, err := nativeBrokerJournalCanonicalJSON(receipt) + if err != nil { + return j, nativeBrokerOuterSettlementFinalPrepared{}, err + } + receiptDigest := nativeBrokerJournalHash(receiptBytes) + if j.lastPhase() == nativeBrokerPhaseOuterSettlementPending { + if err := j.appendPhase(nativeBrokerPhaseOuterSettled, receiptDigest); err != nil { + return j, nativeBrokerOuterSettlementFinalPrepared{}, err + } + } else if j.records[len(j.records)-1].DetailSHA256 != receiptDigest { + return j, nativeBrokerOuterSettlementFinalPrepared{}, &nativeBrokerJournalManualError{cause: errors.New( + "replayed driver settlement receipt differs from the terminal broker record", + )} + } + finalReceipt := nativeBrokerOuterSettlementFinal{ + Schema: nativeBrokerJournalSchema, + BrokerTransactionID: prepared.Request.Binding.BrokerTransactionID, + BrokerPendingDigest: prepared.Request.BrokerPendingDigest, + BrokerSettledDigest: j.records[len(j.records)-1].RecordSHA256, + DriverTransactionID: receipt.DriverTransactionID, + DriverPendingDigest: receipt.DriverPendingDigest, + DriverSettledDigest: receipt.Digest, + SettlementNonce: receipt.SettlementNonce, + RequestSHA256: receipt.RequestSHA256, + State: string(nativeBrokerPhaseOuterSettled), + } + if err := validateNativeBrokerOuterSettlementFinal( + j, prepared, receipt, finalReceipt, + ); err != nil { + return j, nativeBrokerOuterSettlementFinalPrepared{}, err + } + contents, finalDigest, err := encodeNativeBrokerOuterSettlementFinalEnvelope(finalReceipt) + if err != nil { + return j, nativeBrokerOuterSettlementFinalPrepared{}, err + } + finalPath, err := nativeBrokerOuterSettlementFinalPath(j) + if err != nil { + return j, nativeBrokerOuterSettlementFinalPrepared{}, err + } + finalPrepared := nativeBrokerOuterSettlementFinalPrepared{ + Receipt: finalReceipt, ReceiptPath: finalPath, + ReceiptSHA256: finalDigest, contents: contents, + } + if err := publishNativeBrokerOuterSettlementFinal( + j, finalPrepared, prepared, receipt, + ); err != nil { + return j, finalPrepared, err + } + return j, finalPrepared, nil +} + +func nativeBrokerJournalAbsenceIsSettled(proof nativePackageInstallProof) bool { + if proof.success { + // The driver can change while the child proves an exact healthy no-op. + // In that case the child correctly owns no journal; any advertised child + // identity still requires its exact active journal. + return proof.journal.TransactionID == "" + } + return !proof.changed || proof.rollback == "succeeded" +} + +func reconcileNativeBrokerJournalAfterOuterFailure( + ctx context.Context, + userSID, outerTransactionID, candidateSHA256 string, + proof nativePackageInstallProof, +) (nativeBrokerJournalProof, error) { + _, active, err := nativeBrokerJournalPaths(userSID) + if err != nil { + return nativeBrokerJournalProof{}, err + } + if _, err := nativePathAttributes(active); err != nil { + if (errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND)) && + nativeBrokerJournalAbsenceIsSettled(proof) { + return nativeBrokerJournalProof{}, nil + } + return nativeBrokerJournalProof{}, err + } + j, err := loadNativeBrokerJournal(active) + if err != nil { + return nativeBrokerJournalProof{}, &nativeBrokerJournalManualError{cause: err} + } + expectedOuterTransactionID, expectedCandidateSHA256 := + nativeBrokerJournalOuterSettlementIdentity(outerTransactionID, candidateSHA256, proof) + if !strings.EqualFold(j.snapshot.OuterTransactionID, expectedOuterTransactionID) || + !strings.EqualFold(j.snapshot.CandidateSHA256, expectedCandidateSHA256) || + !strings.EqualFold(j.snapshot.TargetUserSID, userSID) { + return j.proof(), &nativeBrokerJournalManualError{cause: errors.New( + "outer failure proof does not match the durable broker journal binding", + )} + } + if _, _, err := j.loadProtectedArtifacts(); err != nil { + return j.proof(), &nativeBrokerJournalManualError{cause: err} + } + if proof.rollback == "succeeded" || (!proof.changed && proof.rollback == "not-needed") { + if j.lastPhase() != nativeBrokerPhaseRollbackSettled { + if err := rollbackNativeBrokerJournal(ctx, j); err != nil { + return j.proof(), err + } + } + return j.proof(), nil + } + return j.proof(), &nativeBrokerJournalManualError{cause: errors.New( + "outer package proof did not authorize broker journal settlement or rollback", + )} +} + +func admitNativeBrokerServiceStartup(executable, credentialPath string) error { + active, err := nativeBrokerJournalActivePathUnbound() + if err != nil { + return err + } + if _, err := nativePathAttributes(active); err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil + } + return err + } + handle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(active, false) + if err != nil { + return &nativeBrokerJournalManualError{cause: err} + } + windows.CloseHandle(handle) //nolint:errcheck + j, err := loadNativeBrokerJournal(active) + if err != nil { + return &nativeBrokerJournalManualError{cause: err} + } + priorCredential, _, err := j.loadProtectedArtifacts() + if err != nil { + return &nativeBrokerJournalManualError{cause: err} + } + executable = filepath.Clean(executable) + credentialPath = filepath.Clean(credentialPath) + expectedCredentialPath, err := nativeServiceKeyFilePath() + if err != nil { + return err + } + if !strings.EqualFold(executable, filepath.Clean(j.snapshot.CandidatePath)) || + !strings.EqualFold(credentialPath, filepath.Clean(expectedCredentialPath)) { + return &nativeBrokerJournalManualError{cause: errors.New( + "service startup identity differs from the active broker journal", + )} + } + imageHash, imageExists, err := nativeBrokerJournalPathHash(executable) + if err != nil { + return err + } + credential, credentialExists, err := snapshotNativeBrokerCredentialReadOnly(j.snapshot.TargetUserSID) + if err != nil { + return err + } + switch j.lastPhase() { + case nativeBrokerPhaseServiceStartIntent: + candidateCredential := currentNativeBrokerJournalCandidateCredentialDigest(j) + if !imageExists || !strings.EqualFold(imageHash, j.snapshot.CandidateSHA256) || + !credentialExists || candidateCredential == "" || + !strings.EqualFold(nativeBrokerJournalHash(credential), candidateCredential) { + return &nativeBrokerJournalManualError{cause: errors.New( + "candidate broker startup did not match its durable image/key identities", + )} + } + return nil + case nativeBrokerPhaseRollbackLegacy, nativeBrokerPhaseRollbackSettled: + if !j.snapshot.Service.Exists || !j.snapshot.PriorImageExists || !imageExists || + !strings.EqualFold(imageHash, j.snapshot.PriorImageSHA256) || + credentialExists != priorCredential.Exists || + (credentialExists && !strings.EqualFold( + nativeBrokerJournalHash(credential), j.snapshot.PriorCredentialSHA256, + )) { + return &nativeBrokerJournalManualError{cause: errors.New( + "rollback broker startup did not match its durable prior image/key identities", + )} + } + return nil + case nativeBrokerPhaseNestedReady, nativeBrokerPhaseOuterSettlementPending, + nativeBrokerPhaseOuterSettled: + candidateCredential := currentNativeBrokerJournalCandidateCredentialDigest(j) + if !imageExists || !strings.EqualFold(imageHash, j.snapshot.CandidateSHA256) || + !credentialExists || candidateCredential == "" || + !strings.EqualFold(nativeBrokerJournalHash(credential), candidateCredential) { + return &nativeBrokerJournalManualError{cause: errors.New( + "settled broker startup did not match its durable image/key identities", + )} + } + return nil + default: + return &nativeBrokerJournalManualError{cause: fmt.Errorf( + "service startup is not admitted while broker journal phase is %s", j.lastPhase(), + )} + } +} diff --git a/internal/cmd/native_broker_journal_windows_test.go b/internal/cmd/native_broker_journal_windows_test.go new file mode 100644 index 00000000..cbbc1388 --- /dev/null +++ b/internal/cmd/native_broker_journal_windows_test.go @@ -0,0 +1,979 @@ +//go:build windows + +package cmd + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +var errNativeBrokerJournalCutpoint = errors.New("simulated process loss") + +func newNativeBrokerJournalModel(t *testing.T) (*nativeBrokerJournal, *[][]byte) { + t.Helper() + snapshot := nativeBrokerJournalSnapshot{ + Schema: nativeBrokerJournalSchema, TransactionID: strings.Repeat("a", 32), + OuterTransactionID: strings.Repeat("b", 64), + OuterTokenPath: `C:\Program Files\VIIPER\.viiper.transaction.test.token`, + TargetUserSID: "S-1-5-21-1-2-3-1001", + CandidatePath: `C:\Program Files\VIIPER\viiper.exe`, + CandidateSHA256: strings.Repeat("c", 64), + PriorCredentialArtifact: strings.Repeat("d", 64), + PriorLegacyArtifact: strings.Repeat("e", 64), + } + payload, err := nativeBrokerJournalCanonicalJSON(snapshot) + if err != nil { + t.Fatal(err) + } + var persisted [][]byte + j := &nativeBrokerJournal{ + snapshot: snapshot, snapshotDigest: nativeBrokerJournalHash(payload), + appendRecord: func(record []byte) error { + persisted = append(persisted, append([]byte(nil), record...)) + return nil + }, + } + return j, &persisted +} + +func TestNativeBrokerJournalCutpointsLeaveCanonicalPrefix(t *testing.T) { + t.Parallel() + phases := append([]nativeBrokerJournalPhase(nil), + nativeBrokerForwardPhaseOrder[:len(nativeBrokerForwardPhaseOrder)-1]..., + ) + phases = append(phases, + nativeBrokerPhaseRollbackIntent, + nativeBrokerPhaseRollbackService, + nativeBrokerPhaseRollbackCredential, + nativeBrokerPhaseRollbackImage, + nativeBrokerPhaseRollbackLegacy, + nativeBrokerPhaseRollbackSettled, + ) + for cutIndex := range phases { + cutIndex := cutIndex + t.Run(string(phases[cutIndex]), func(t *testing.T) { + t.Parallel() + journal, persisted := newNativeBrokerJournalModel(t) + journal.cutpoint = func(name string) error { + if name == "after-record-"+string(phases[cutIndex]) { + return errNativeBrokerJournalCutpoint + } + return nil + } + for index, phase := range phases { + detail := "" + if phase == nativeBrokerPhaseOuterSettlementPending || + phase == nativeBrokerPhaseOuterSettled { + detail = strings.Repeat("f", 64) + } + err := journal.appendPhase(phase, detail) + if index < cutIndex && err != nil { + t.Fatalf("phase %s failed before cutpoint: %v", phase, err) + } + if index == cutIndex { + if !errors.Is(err, errNativeBrokerJournalCutpoint) { + t.Fatalf("phase %s error=%v", phase, err) + } + break + } + } + if len(*persisted) != cutIndex+1 || len(journal.records) != cutIndex+1 { + t.Fatalf("persisted=%d records=%d want=%d", + len(*persisted), len(journal.records), cutIndex+1) + } + reloaded := &nativeBrokerJournal{ + snapshot: journal.snapshot, snapshotDigest: journal.snapshotDigest, + } + for _, line := range *persisted { + var record nativeBrokerJournalRecord + if err := decodeCanonicalNativeBrokerJSON( + line, &record, nativeBrokerJournalMaximumLine, + ); err != nil { + t.Fatalf("decode persisted prefix: %v", err) + } + if err := reloaded.validateLoadedRecord(record); err != nil { + t.Fatalf("validate persisted prefix: %v", err) + } + reloaded.records = append(reloaded.records, record) + } + if reloaded.lastPhase() != phases[cutIndex] { + t.Fatalf("last phase=%s want=%s", reloaded.lastPhase(), phases[cutIndex]) + } + }) + } +} + +func TestNativeBrokerJournalRejectsTamperAndIllegalDirection(t *testing.T) { + t.Parallel() + journal, persisted := newNativeBrokerJournalModel(t) + if err := journal.appendPhase(nativeBrokerPhasePrepared, ""); err != nil { + t.Fatal(err) + } + if err := journal.appendPhase(nativeBrokerPhaseImageSwitchIntent, strings.Repeat("f", 64)); err != nil { + t.Fatal(err) + } + if err := journal.appendPhase(nativeBrokerPhasePrepared, ""); err == nil { + t.Fatal("forward journal accepted a backward transition") + } + tampered := append([]byte(nil), (*persisted)[1]...) + index := bytes.Index(tampered, []byte(strings.Repeat("f", 64))) + if index < 0 { + t.Fatal("detail digest not found in canonical record") + } + tampered[index] = '0' + var record nativeBrokerJournalRecord + if err := decodeCanonicalNativeBrokerJSON(tampered, &record, nativeBrokerJournalMaximumLine); err != nil { + t.Fatalf("tamper should remain canonical JSON: %v", err) + } + reloaded := &nativeBrokerJournal{ + snapshot: journal.snapshot, snapshotDigest: journal.snapshotDigest, + records: []nativeBrokerJournalRecord{journal.records[0]}, + } + if err := reloaded.validateLoadedRecord(record); err == nil { + t.Fatal("hash-chain validation accepted tampered detail digest") + } +} + +func TestNativeBrokerJournalPreparationCutsNeverExposeIncompleteActiveState(t *testing.T) { + t.Parallel() + cutpoints := []string{ + "prepare-directory-created", + "prepare-credential-written", + "prepare-legacy-written", + "prepare-prior-image-written", + "prepare-snapshot-written", + "prepare-record-stream-created", + "prepare-prepared-written", + "prepare-active-published", + } + for cutIndex, cutpoint := range cutpoints { + cutIndex, cutpoint := cutIndex, cutpoint + t.Run(cutpoint, func(t *testing.T) { + t.Parallel() + preparing, active := false, false + var completed []string + step := func(name string) func() error { + return func() error { + if name == "directory-created" { + preparing = true + } else if !preparing || active { + return errors.New("preparation escaped its unpublished directory") + } + completed = append(completed, name) + return nil + } + } + err := executeNativeBrokerJournalPreparation(nativeBrokerJournalPreparationOperations{ + createDirectory: step("directory-created"), + writeCredential: step("credential-written"), + writeLegacy: step("legacy-written"), + writePriorImage: step("prior-image-written"), + writeSnapshot: step("snapshot-written"), + createRecordStream: step("record-stream-created"), + writePrepared: step("prepared-written"), + publishActive: func() error { + if !preparing || len(completed) != len(cutpoints)-1 { + return errors.New("active publication preceded complete preparation") + } + preparing, active = false, true + completed = append(completed, "active-published") + return nil + }, + cutpoint: func(name string) error { + if name == cutpoint { + return errNativeBrokerJournalCutpoint + } + return nil + }, + }) + if !errors.Is(err, errNativeBrokerJournalCutpoint) || len(completed) != cutIndex+1 { + t.Fatalf("cut=%s completed=%v err=%v", cutpoint, completed, err) + } + if cutpoint == "prepare-active-published" { + if !active || preparing { + t.Fatal("post-publication cut lost authoritative active state") + } + } else if active || !preparing { + t.Fatal("pre-publication cut exposed active state or lost disposable preparation") + } + }) + } +} + +func TestNativeBrokerJournalAtomicRecordCutsKeepPublishedPrefix(t *testing.T) { + t.Parallel() + journal, persisted := newNativeBrokerJournalModel(t) + if err := journal.appendPhase(nativeBrokerPhasePrepared, ""); err != nil { + t.Fatal(err) + } + if err := journal.appendPhase(nativeBrokerPhaseServiceStopIntent, ""); err != nil { + t.Fatal(err) + } + current := append(append([]byte(nil), (*persisted)[0]...), '\n') + next, err := buildNativeBrokerJournalRecordStream(current, (*persisted)[1]) + if err != nil { + t.Fatal(err) + } + cutpoints := []string{ + nativeBrokerCutRecordPartialWrite, + nativeBrokerCutRecordWriteDone, + nativeBrokerCutRecordSyncDone, + nativeBrokerCutRecordReadbackDone, + nativeBrokerCutRecordBeforePublish, + } + for _, cutpoint := range cutpoints { + cutpoint := cutpoint + t.Run(cutpoint, func(t *testing.T) { + t.Parallel() + published := append([]byte(nil), current...) + var staged []byte + err := executeNativeBrokerJournalRecordPublication( + (*persisted)[1], + nativeBrokerJournalRecordPublicationOperations{ + loadCurrent: func() ([]byte, error) { + return append([]byte(nil), published...), nil + }, + discardStaging: func() error { staged = nil; return nil }, + stage: func(candidate []byte) error { + staged = append([]byte(nil), candidate...) + if cutpoint == nativeBrokerCutRecordPartialWrite { + staged = staged[:len(staged)/2] + } + if cutpoint != nativeBrokerCutRecordBeforePublish { + return errNativeBrokerJournalCutpoint + } + return nil + }, + beforePublish: func() error { + if cutpoint == nativeBrokerCutRecordBeforePublish { + return errNativeBrokerJournalCutpoint + } + return nil + }, + publish: func() error { + published = append([]byte(nil), staged...) + return nil + }, + }, + ) + if !errors.Is(err, errNativeBrokerJournalCutpoint) || len(staged) == 0 { + t.Fatalf("cutpoint %s was not observed before publication: err=%v", cutpoint, err) + } + if !bytes.Equal(published, current) { + t.Fatalf("cutpoint %s changed the authoritative published prefix", cutpoint) + } + }) + } + published := append([]byte(nil), current...) + var staged []byte + if err := executeNativeBrokerJournalRecordPublication( + (*persisted)[1], + nativeBrokerJournalRecordPublicationOperations{ + loadCurrent: func() ([]byte, error) { return append([]byte(nil), published...), nil }, + discardStaging: func() error { staged = nil; return nil }, + stage: func(candidate []byte) error { staged = append([]byte(nil), candidate...); return nil }, + beforePublish: func() error { return nil }, + publish: func() error { published = append([]byte(nil), staged...); return nil }, + }, + ); err != nil || !bytes.Equal(published, next) { + t.Fatalf("fully read-back record stream was not atomically published: err=%v", err) + } + if _, err := buildNativeBrokerJournalRecordStream( + current[:len(current)-1], (*persisted)[1], + ); err == nil { + t.Fatal("a torn published trailing record was accepted as an append base") + } +} + +func TestNativeBrokerSettlementRequestPublicationRecoversEveryStagingCut(t *testing.T) { + t.Parallel() + expected := []byte(`{"schema":1,"request":"exact"}`) + cutpoints := []string{ + nativeBrokerCutSettlementPartialWrite, + nativeBrokerCutSettlementWriteDone, + nativeBrokerCutSettlementSyncDone, + nativeBrokerCutSettlementReadbackDone, + nativeBrokerCutSettlementBeforePublish, + } + for _, cutpoint := range cutpoints { + cutpoint := cutpoint + t.Run(cutpoint, func(t *testing.T) { + t.Parallel() + var published, staged []byte + publishedExists, stagingExists := false, false + operations := func(cut string) nativeBrokerSettlementPublicationOperations { + return nativeBrokerSettlementPublicationOperations{ + loadPublished: func() ([]byte, bool, error) { + return append([]byte(nil), published...), publishedExists, nil + }, + loadStaging: func() ([]byte, bool, error) { + return append([]byte(nil), staged...), stagingExists, nil + }, + discardStaging: func() error { + staged, stagingExists = nil, false + return nil + }, + publishStaging: func() error { + published = append([]byte(nil), staged...) + publishedExists, stagingExists = true, false + return nil + }, + writeNew: func() error { + stagingExists = true + staged = append([]byte(nil), expected...) + if cut == nativeBrokerCutSettlementPartialWrite { + staged = staged[:len(staged)/2] + } + if cut != "" { + return errNativeBrokerJournalCutpoint + } + published = append([]byte(nil), staged...) + publishedExists, stagingExists = true, false + return nil + }, + readback: func() ([]byte, error) { + if !publishedExists { + return nil, errors.New("no published request") + } + return append([]byte(nil), published...), nil + }, + } + } + err := executeNativeBrokerSettlementPublication(expected, operations(cutpoint)) + if !errors.Is(err, errNativeBrokerJournalCutpoint) || publishedExists || !stagingExists { + t.Fatalf("cut=%s published=%v staging=%v err=%v", + cutpoint, publishedExists, stagingExists, err) + } + if err := executeNativeBrokerSettlementPublication(expected, operations("")); err != nil { + t.Fatalf("resume after %s: %v", cutpoint, err) + } + if !publishedExists || stagingExists || !bytes.Equal(published, expected) { + t.Fatalf("resume after %s did not publish the exact request", cutpoint) + } + }) + } + tampered := []byte(`{"schema":1,"request":"different"}`) + err := executeNativeBrokerSettlementPublication(expected, + nativeBrokerSettlementPublicationOperations{ + loadPublished: func() ([]byte, bool, error) { return tampered, true, nil }, + loadStaging: func() ([]byte, bool, error) { return nil, false, nil }, + discardStaging: func() error { return nil }, + publishStaging: func() error { return nil }, + writeNew: func() error { return nil }, + readback: func() ([]byte, error) { return tampered, nil }, + }) + var manual *nativeBrokerJournalManualError + if !errors.As(err, &manual) { + t.Fatalf("published interior corruption was not latched unsafe: %v", err) + } +} + +func TestNativeBrokerTwoPhaseSettlementCutsAreReplayable(t *testing.T) { + t.Parallel() + cutpoints := []string{ + nativeBrokerCutAfterBindingOutput, + nativeBrokerCutBeforePending, + nativeBrokerCutAfterPending, + nativeBrokerCutAfterRequest, + nativeBrokerCutBeforeDriverAck, + nativeBrokerCutAfterDriverAck, + nativeBrokerCutBeforeBrokerFinal, + nativeBrokerCutAfterBrokerFinal, + nativeBrokerCutBeforeRetirement, + nativeBrokerCutAfterRetirement, + nativeBrokerCutAfterDiscard, + } + for _, cutpoint := range cutpoints { + cutpoint := cutpoint + t.Run(cutpoint, func(t *testing.T) { + t.Parallel() + driverPending := true + brokerPending, requestPublished := false, false + driverSettled, brokerSettled := false, false + brokerRetired, discardAttempted := false, false + operations := func(cut string) nativeBrokerOuterSettlementOperations { + return nativeBrokerOuterSettlementOperations{ + recordPending: func() error { + if !driverPending { + return errors.New("broker pending preceded driver pending") + } + brokerPending = true + return nil + }, + publishRequest: func() error { + if !brokerPending { + return errors.New("request preceded broker pending") + } + requestPublished = true + return nil + }, + acknowledgeDriver: func() error { + if !requestPublished { + return errors.New("driver acknowledgement preceded request") + } + driverSettled = true + return nil + }, + recordBrokerSettled: func() error { + if !driverSettled { + return errors.New("broker final preceded driver final") + } + brokerSettled = true + return nil + }, + retireBrokerJournal: func() error { + if !discardAttempted { + return errors.New("broker retirement preceded authenticated driver discard") + } + brokerRetired = true + return nil + }, + discardInertState: func() error { + if !brokerSettled { + return errors.New("driver discard preceded the protected broker-final receipt") + } + discardAttempted = true + return nil + }, + cutpoint: func(name string) error { + if name == cut { + return errNativeBrokerJournalCutpoint + } + return nil + }, + } + } + err := executeNativeBrokerOuterSettlement(operations(cutpoint)) + if !errors.Is(err, errNativeBrokerJournalCutpoint) { + t.Fatalf("cutpoint %s was not reached: %v", cutpoint, err) + } + if driverSettled && !requestPublished || brokerSettled && !driverSettled || + discardAttempted && !brokerSettled || brokerRetired && !discardAttempted { + t.Fatalf("cutpoint %s violated settlement ordering", cutpoint) + } + if !brokerRetired { + if err := executeNativeBrokerOuterSettlement(operations("")); err != nil { + t.Fatalf("idempotent replay after %s: %v", cutpoint, err) + } + if !driverSettled || !brokerSettled || !brokerRetired || !discardAttempted { + t.Fatalf("replay after %s did not reach complete settlement", cutpoint) + } + } + }) + } + observed := false + err := executeNativeBrokerOuterSettlement(nativeBrokerOuterSettlementOperations{ + recordPending: func() error { return nil }, publishRequest: func() error { return nil }, + acknowledgeDriver: func() error { return nil }, recordBrokerSettled: func() error { return nil }, + retireBrokerJournal: func() error { return nil }, + discardInertState: func() error { return errors.New("inert cleanup retained") }, + observeDiscardError: func(error) { observed = true }, + }) + if err == nil || !observed { + t.Fatalf("unverified driver discard did not retain broker evidence: observed=%v err=%v", + observed, err) + } +} + +func TestNativeBrokerSettlementEnvelopeBindsBothJournalChains(t *testing.T) { + t.Parallel() + journal, _ := newNativeBrokerJournalModel(t) + if err := journal.appendPhase(nativeBrokerPhasePrepared, ""); err != nil { + t.Fatal(err) + } + if err := journal.appendPhase(nativeBrokerPhaseNestedReady, ""); err != nil { + t.Fatal(err) + } + proof := nativePackageInstallProof{ + success: true, changed: true, exitCode: 0, journalRecovery: "fresh", + journal: journal.proof(), driverTransactionID: strings.Repeat("1", 64), + driverPendingDigest: strings.Repeat("2", 64), settlementNonce: strings.Repeat("3", 64), + } + binding, bindingDigest, err := validateNativeBrokerOuterSettlementBinding(journal, proof) + if err != nil { + t.Fatal(err) + } + if err := journal.appendPhase(nativeBrokerPhaseOuterSettlementPending, bindingDigest); err != nil { + t.Fatal(err) + } + request := nativeBrokerOuterSettlementRequest{ + Schema: nativeBrokerJournalSchema, BindingSHA256: bindingDigest, + BrokerPendingDigest: journal.proof().Digest, Binding: binding, + } + if err := validateNativeBrokerOuterSettlementRequest(journal, request); err != nil { + t.Fatal(err) + } + contents, requestDigest, err := encodeNativeBrokerOuterSettlementEnvelope(request) + if err != nil { + t.Fatal(err) + } + envelope, err := decodeNativeBrokerOuterSettlementEnvelope(contents) + if err != nil || envelope.Payload != request { + t.Fatalf("canonical settlement envelope did not round trip: envelope=%+v err=%v", envelope, err) + } + prepared := nativeBrokerOuterSettlementPrepared{ + Request: request, RequestPath: `C:\ProgramData\VIIPER\BrokerTransactions\active-v1\outer-settlement.json`, + RequestSHA256: requestDigest, contents: contents, + } + receipt := nativePackageBrokerSettlementReceipt{ + BrokerTransactionID: binding.BrokerTransactionID, + BrokerPendingDigest: request.BrokerPendingDigest, + DriverTransactionID: binding.DriverTransactionID, + DriverPendingDigest: binding.DriverPendingDigest, + SettlementNonce: binding.SettlementNonce, + RequestSHA256: requestDigest, State: string(nativeBrokerPhaseOuterSettled), + Digest: strings.Repeat("4", 64), + } + if err := validateNativeBrokerOuterSettlementReceipt(prepared, receipt); err != nil { + t.Fatal(err) + } + receiptBytes, err := nativeBrokerJournalCanonicalJSON(receipt) + if err != nil { + t.Fatal(err) + } + if err := journal.appendPhase( + nativeBrokerPhaseOuterSettled, nativeBrokerJournalHash(receiptBytes), + ); err != nil { + t.Fatal(err) + } + if err := validateNativeBrokerOuterSettlementRequest(journal, request); err != nil { + t.Fatalf("terminal broker journal lost its exact pending request: %v", err) + } + finalReceipt := nativeBrokerOuterSettlementFinal{ + Schema: nativeBrokerJournalSchema, + BrokerTransactionID: binding.BrokerTransactionID, + BrokerPendingDigest: request.BrokerPendingDigest, + BrokerSettledDigest: journal.proof().Digest, + DriverTransactionID: binding.DriverTransactionID, + DriverPendingDigest: binding.DriverPendingDigest, + DriverSettledDigest: receipt.Digest, + SettlementNonce: binding.SettlementNonce, + RequestSHA256: requestDigest, + State: string(nativeBrokerPhaseOuterSettled), + } + if err := validateNativeBrokerOuterSettlementFinal( + journal, prepared, receipt, finalReceipt, + ); err != nil { + t.Fatal(err) + } + if replayedReceipt := nativeBrokerDriverReceiptFromFinal(finalReceipt); replayedReceipt != receipt { + t.Fatalf("protected final receipt did not reconstruct the exact driver acknowledgement: got=%+v want=%+v", + replayedReceipt, receipt) + } + finalContents, finalDigest, err := encodeNativeBrokerOuterSettlementFinalEnvelope(finalReceipt) + if err != nil || !isCanonicalNativeBrokerJournalSHA256(finalDigest) { + t.Fatalf("encode protected final receipt: digest=%q err=%v", finalDigest, err) + } + finalEnvelope, err := decodeNativeBrokerOuterSettlementFinalEnvelope(finalContents) + if err != nil || finalEnvelope.Payload != finalReceipt { + t.Fatalf("canonical final receipt did not round trip: envelope=%+v err=%v", finalEnvelope, err) + } + mixedFinal := finalReceipt + mixedFinal.BrokerSettledDigest = strings.Repeat("5", 64) + if err := validateNativeBrokerOuterSettlementFinal( + journal, prepared, receipt, mixedFinal, + ); err == nil { + t.Fatal("unrelated canonical broker-final digest authorized driver retirement") + } + mutations := []func(*nativePackageBrokerSettlementReceipt){ + func(value *nativePackageBrokerSettlementReceipt) { value.BrokerTransactionID = strings.Repeat("5", 32) }, + func(value *nativePackageBrokerSettlementReceipt) { value.BrokerPendingDigest = strings.Repeat("5", 64) }, + func(value *nativePackageBrokerSettlementReceipt) { value.DriverTransactionID = strings.Repeat("5", 64) }, + func(value *nativePackageBrokerSettlementReceipt) { value.DriverPendingDigest = strings.Repeat("5", 64) }, + func(value *nativePackageBrokerSettlementReceipt) { value.RequestSHA256 = strings.Repeat("5", 64) }, + } + for index, mutate := range mutations { + changed := receipt + mutate(&changed) + if err := validateNativeBrokerOuterSettlementReceipt(prepared, changed); err == nil { + t.Fatalf("settlement receipt mix-up mutation %d was accepted", index) + } + } + changedNonce := receipt + changedNonce.SettlementNonce = strings.Repeat("5", 64) + if err := validateNativeBrokerOuterSettlementReceipt(prepared, changedNonce); err == nil { + t.Fatal("settlement nonce mix-up was accepted") + } + changedBinding := request + changedBinding.Binding.DriverPendingDigest = strings.Repeat("5", 64) + if err := validateNativeBrokerOuterSettlementRequest(journal, changedBinding); err == nil { + t.Fatal("request accepted a changed driver chain with the same nonce") + } +} + +func TestNativeBrokerJournalRetirementCutsPreserveAdmissionAuthority(t *testing.T) { + t.Parallel() + cutpoints := []string{ + "retire-before-rename", + "retire-after-rename", + "retire-active-absence-proven", + "retire-tombstone-proven", + } + for _, cutpoint := range cutpoints { + cutpoint := cutpoint + t.Run(cutpoint, func(t *testing.T) { + t.Parallel() + active, tombstone := true, false + err := executeNativeBrokerJournalRetirement(nativeBrokerJournalRetirementOperations{ + rename: func() error { + if !active || tombstone { + return errors.New("invalid model rename") + } + active, tombstone = false, true + return nil + }, + proveActiveAbsent: func() error { + if active { + return errors.New("active still present") + } + return nil + }, + proveTombstone: func() error { + if !tombstone { + return errors.New("tombstone absent") + } + return nil + }, + discardTombstone: func() error { + tombstone = false + return nil + }, + cutpoint: func(name string) error { + if name == cutpoint { + return errNativeBrokerJournalCutpoint + } + return nil + }, + }) + if !errors.Is(err, errNativeBrokerJournalCutpoint) { + t.Fatalf("cutpoint error=%v", err) + } + if cutpoint == "retire-before-rename" { + if !active || tombstone { + t.Fatal("pre-rename cut lost the still-authoritative terminal active journal") + } + } else if active || !tombstone { + t.Fatal("post-rename cut republished active admission or lost its protected tombstone") + } + }) + } + active, tombstone := true, false + if err := executeNativeBrokerJournalRetirement(nativeBrokerJournalRetirementOperations{ + rename: func() error { active, tombstone = false, true; return nil }, + proveActiveAbsent: func() error { + if active { + return errors.New("active still present") + } + return nil + }, + proveTombstone: func() error { + if !tombstone { + return errors.New("tombstone absent") + } + return nil + }, + discardTombstone: func() error { return errors.New("simulated cleanup failure") }, + }); err != nil || active || !tombstone { + t.Fatalf("non-authoritative tombstone cleanup blocked settlement: active=%v tombstone=%v err=%v", + active, tombstone, err) + } +} + +func TestNativeBrokerJournalNestedReadyProofReplaysAfterParentCut(t *testing.T) { + t.Parallel() + journal, _ := newNativeBrokerJournalModel(t) + if err := journal.appendPhase(nativeBrokerPhasePrepared, ""); err != nil { + t.Fatal(err) + } + if err := journal.appendPhase(nativeBrokerPhaseNestedReady, ""); err != nil { + t.Fatal(err) + } + beforeParentRecord := journal.proof() + if err := validateNativeBrokerNestedReplayBinding( + journal, journal.snapshot.TargetUserSID, journal.snapshot.OuterTokenPath, + journal.snapshot.OuterTransactionID, + journal.snapshot.CandidateSHA256, + ); err != nil { + t.Fatal(err) + } + afterReplay := journal.proof() + if beforeParentRecord != afterReplay || afterReplay.State != string(nativeBrokerPhaseNestedReady) { + t.Fatalf("replayed proof changed across parent cut: before=%+v after=%+v", + beforeParentRecord, afterReplay) + } + if err := validateNativeBrokerNestedReplayBinding( + journal, journal.snapshot.TargetUserSID, journal.snapshot.OuterTokenPath, + strings.Repeat("0", 64), + journal.snapshot.CandidateSHA256, + ); err == nil { + t.Fatal("nested-ready replay accepted a different outer transaction identity") + } + if err := validateNativeBrokerNestedReplayBinding( + journal, journal.snapshot.TargetUserSID, + `C:\Program Files\VIIPER\.viiper.transaction.other.token`, + journal.snapshot.OuterTransactionID, journal.snapshot.CandidateSHA256, + ); err == nil { + t.Fatal("nested-ready replay accepted a different outer token path") + } +} + +func TestNativeBrokerJournalReplayedOuterBindingSelectsOldTransactionIdentity(t *testing.T) { + t.Parallel() + currentOuter := strings.Repeat("1", 64) + currentCandidate := strings.Repeat("2", 64) + oldOuter := strings.Repeat("3", 64) + oldCandidate := strings.Repeat("4", 64) + proof := nativePackageInstallProof{ + success: true, changed: true, exitCode: 0, journalRecovery: "replayed", + journal: nativeBrokerJournalProof{ + TransactionID: strings.Repeat("5", 32), OuterTransactionID: oldOuter, + CandidateSHA256: oldCandidate, State: string(nativeBrokerPhaseNestedReady), + Digest: strings.Repeat("6", 64), + }, + } + outer, candidate := nativeBrokerJournalOuterSettlementIdentity( + currentOuter, currentCandidate, proof, + ) + if outer != oldOuter || candidate != oldCandidate { + t.Fatalf("replayed identity=(%s,%s) want old=(%s,%s)", + outer, candidate, oldOuter, oldCandidate) + } + proof.journalRecovery = "" + outer, candidate = nativeBrokerJournalOuterSettlementIdentity( + currentOuter, currentCandidate, proof, + ) + if outer != currentOuter || candidate != currentCandidate { + t.Fatal("unbound proof replaced the current outer transaction identity") + } +} + +func TestNativeBrokerJournalTransactionDirectoryNamesAreCanonical(t *testing.T) { + t.Parallel() + valid := strings.Repeat("a", 32) + if !isNativeBrokerJournalInactiveDirectoryName(nativeBrokerJournalPreparingPrefix+valid, + nativeBrokerJournalPreparingPrefix) || + !isNativeBrokerJournalInactiveDirectoryName(nativeBrokerJournalSettledPrefix+valid, + nativeBrokerJournalSettledPrefix) { + t.Fatal("canonical transaction directory name was rejected") + } + for _, invalid := range []string{ + strings.ToUpper(valid), valid[:31], valid + "0", strings.Repeat("z", 32), + } { + if isNativeBrokerJournalTransactionID(invalid) { + t.Fatalf("noncanonical transaction directory identity was accepted: %q", invalid) + } + } +} + +func TestNativeBrokerJournalSnapshotBindsExactOuterTokenPath(t *testing.T) { + t.Parallel() + journal, _ := newNativeBrokerJournalModel(t) + if err := validateNativeBrokerJournalOuterTokenPath(journal.snapshot); err != nil { + t.Fatalf("canonical snapshot was rejected: %v", err) + } + journal.snapshot.OuterTokenPath = + `C:\Program Files\Other\.viiper.transaction.test.token` + if err := validateNativeBrokerJournalOuterTokenPath(journal.snapshot); err == nil { + t.Fatal("snapshot accepted an outer token outside the candidate image directory") + } +} + +func TestStandaloneNativeBrokerInstallIsFailClosedByDefault(t *testing.T) { + t.Setenv("VIIPER_DEVELOPER_STANDALONE", "") + err := requireDeveloperStandaloneNativeInstall() + if err == nil || !strings.Contains(err.Error(), "developer-only") { + t.Fatalf("default standalone native install did not fail before mutation: %v", err) + } +} + +func TestStandaloneNativeBrokerInstallRequiresExactDeveloperOptIn(t *testing.T) { + for _, value := range []string{"true", "01", " 1", "1 "} { + t.Run(value, func(t *testing.T) { + t.Setenv("VIIPER_DEVELOPER_STANDALONE", value) + if err := requireDeveloperStandaloneNativeInstall(); err == nil { + t.Fatalf("noncanonical developer opt-in %q was accepted", value) + } + }) + } + t.Setenv("VIIPER_DEVELOPER_STANDALONE", "1") + if err := requireDeveloperStandaloneNativeInstall(); err != nil { + t.Fatalf("exact developer opt-in was rejected: %v", err) + } +} + +func TestRecoveredPriorPackageRequiresExplicitRetry(t *testing.T) { + t.Parallel() + err := error(&nativePackageRecoveryRetryError{}) + if !strings.Contains(err.Error(), "retry") || !strings.Contains(err.Error(), "settled") { + t.Fatalf("recovery retry error is not explicit: %v", err) + } +} + +func TestNativeBrokerJournalAbsenceRequiresSettledChildOutcome(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + proof nativePackageInstallProof + want bool + }{ + { + name: "successful healthy child no-op after driver mutation", + proof: nativePackageInstallProof{ + success: true, + changed: true, + }, + want: true, + }, + { + name: "successful child advertises durable identity", + proof: nativePackageInstallProof{ + success: true, + journal: nativeBrokerJournalProof{ + TransactionID: strings.Repeat("a", 32), + }, + }, + want: false, + }, + { + name: "failed changed child completed rollback", + proof: nativePackageInstallProof{ + changed: true, + rollback: "succeeded", + }, + want: true, + }, + { + name: "failed child performed no mutation", + proof: nativePackageInstallProof{ + rollback: "not-needed", + }, + want: true, + }, + { + name: "failed changed child has unsettled rollback", + proof: nativePackageInstallProof{ + changed: true, + rollback: "failed", + }, + want: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := nativeBrokerJournalAbsenceIsSettled(test.proof); got != test.want { + t.Fatalf("nativeBrokerJournalAbsenceIsSettled()=%t want %t", got, test.want) + } + }) + } +} + +func TestNativeBrokerJournalProofContainsNoProtectedPayload(t *testing.T) { + t.Parallel() + result := nativePackageBrokerCommitResult{ + success: true, changed: true, rollback: "not-needed", exitCode: 0, + journal: nativeBrokerJournalProof{ + TransactionID: strings.Repeat("a", 32), OuterTransactionID: strings.Repeat("c", 64), + CandidateSHA256: strings.Repeat("d", 64), State: string(nativeBrokerPhaseNestedReady), + Digest: strings.Repeat("b", 64), + }, + } + line := result.journalProofLine() + if !strings.Contains(line, "transactionId=") || !strings.Contains(line, "outerTransactionId=") || + !strings.Contains(line, "candidateSha256=") || + !strings.Contains(line, "digest=") || + strings.Contains(strings.ToLower(line), "password") || strings.Contains(line, "scheduledXml") { + t.Fatalf("unsafe journal proof=%q", line) + } +} + +func TestNativePackageInstallProofRequiresCanonicalJournalBinding(t *testing.T) { + t.Parallel() + base := "result=success operation=install changed=1 rebootRequired=0 rollback=not-needed exitCode=0\n" + proof, err := parseNativePackageInstallProof(base, 0) + if err != nil { + t.Fatal(err) + } + if proof.journal.TransactionID != "" { + t.Fatal("install proof invented an absent journal binding") + } + binding := "journal-binding operation=install transactionId=" + strings.Repeat("a", 32) + + " outerTransactionId=" + strings.Repeat("c", 64) + + " candidateSha256=" + strings.Repeat("d", 64) + + " state=nested-ready digest=" + strings.Repeat("b", 64) + + " driverTransactionId=" + strings.Repeat("e", 64) + + " driverDigest=" + strings.Repeat("f", 64) + + " settlementNonce=" + strings.Repeat("0", 64) + " recovery=fresh\n" + proof, err = parseNativePackageInstallProof(base+binding, 0) + if err != nil { + t.Fatal(err) + } + if proof.journal.TransactionID != strings.Repeat("a", 32) || + proof.journal.OuterTransactionID != strings.Repeat("c", 64) || + proof.journal.CandidateSHA256 != strings.Repeat("d", 64) || + proof.journal.State != "nested-ready" || proof.journal.Digest != strings.Repeat("b", 64) || + proof.driverTransactionID != strings.Repeat("e", 64) || + proof.driverPendingDigest != strings.Repeat("f", 64) || + proof.settlementNonce != strings.Repeat("0", 64) || + proof.journalRecovery != "fresh" { + t.Fatalf("journal binding=%+v", proof.journal) + } + replayed := strings.Replace(binding, "recovery=fresh", "recovery=replayed", 1) + proof, err = parseNativePackageInstallProof(base+replayed, 0) + if err != nil || proof.journalRecovery != "replayed" { + t.Fatalf("canonical replayed binding was rejected: proof=%+v err=%v", proof, err) + } + if _, err := parseNativePackageInstallProof(base+binding+binding, 0); err == nil { + t.Fatal("duplicate journal binding was accepted") + } + for _, malformed := range []string{ + strings.TrimSuffix(binding, "\n"), + binding + "journal-binding operation=install transactionId=not-canonical\n", + strings.Replace(binding, "nested-ready", "Nested-Ready", 1), + strings.Replace(binding, "recovery=fresh", "recovery=unknown", 1), + } { + if _, err := parseNativePackageInstallProof(base+malformed, 0); err == nil { + t.Fatalf("noncanonical journal binding was accepted: %q", malformed) + } + } + failure := "result=error operation=install changed=1 rebootRequired=0 rollback=succeeded exitCode=1\n" + if _, err := parseNativePackageInstallProof(failure+binding, 1); err == nil { + t.Fatal("failure outcome carried an unauthorized forward journal binding") + } +} + +func TestNativePackageBrokerSettlementDiscardReceiptIsCanonical(t *testing.T) { + t.Parallel() + line := "journal-discard operation=broker-settlement-discard" + + " brokerTransactionId=" + strings.Repeat("a", 32) + + " brokerDigest=" + strings.Repeat("b", 64) + + " driverTransactionId=" + strings.Repeat("c", 64) + + " driverDigest=" + strings.Repeat("d", 64) + + " settlementNonce=" + strings.Repeat("e", 64) + + " requestSha256=" + strings.Repeat("f", 64) + + " discarded=1 retained=1\n" + receipt, err := parseNativePackageBrokerSettlementDiscardReceipt(line, 0) + if err != nil { + t.Fatal(err) + } + if !receipt.Discarded || !receipt.Retained || + receipt.BrokerTransactionID != strings.Repeat("a", 32) || + receipt.BrokerDigest != strings.Repeat("b", 64) || + receipt.DriverTransactionID != strings.Repeat("c", 64) || + receipt.DriverDigest != strings.Repeat("d", 64) || + receipt.SettlementNonce != strings.Repeat("e", 64) || + receipt.RequestSHA256 != strings.Repeat("f", 64) { + t.Fatalf("discard receipt=%+v", receipt) + } + for _, malformed := range []string{ + strings.Replace(line, " retained=1", "", 1), + strings.Replace(line, "retained=1", "retained=true", 1), + strings.TrimSuffix(line, "\n"), + line + line, + } { + if _, err := parseNativePackageBrokerSettlementDiscardReceipt(malformed, 0); err == nil { + t.Fatalf("noncanonical discard receipt was accepted: %q", malformed) + } + } +} diff --git a/internal/cmd/native_mutex_windows.go b/internal/cmd/native_mutex_windows.go new file mode 100644 index 00000000..52101c2a --- /dev/null +++ b/internal/cmd/native_mutex_windows.go @@ -0,0 +1,313 @@ +//go:build windows + +package cmd + +import ( + "errors" + "fmt" + "runtime" + "strings" + "sync" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + // A private namespace prevents an unprivileged process from pre-creating a + // public Global mutex and making CreateMutex open an attacker-owned object. + // The alias and complete boundary (name plus Administrators SID) identify one + // namespace shared by the package and broker-service transactions. + nativeMutexNamespaceAlias = "VIIPER_NATIVE_INSTALL_NAMESPACE_V1" + nativeMutexBoundaryName = "VIIPER_NATIVE_INSTALL_ADMIN_BOUNDARY_V1" + nativeMutexObjectSDDL = "O:BAG:BAD:P(A;;GA;;;SY)(A;;GA;;;BA)" + + nativeMutexNamespaceRaceRetries = 5000 +) + +var ( + nativeMutexKernel32 = windows.NewLazySystemDLL("kernel32.dll") + nativeCreateBoundaryDescriptorW = nativeMutexKernel32.NewProc("CreateBoundaryDescriptorW") + nativeAddSIDToBoundaryDescriptor = nativeMutexKernel32.NewProc("AddSIDToBoundaryDescriptor") + nativeDeleteBoundaryDescriptor = nativeMutexKernel32.NewProc("DeleteBoundaryDescriptor") + nativeCreatePrivateNamespaceW = nativeMutexKernel32.NewProc("CreatePrivateNamespaceW") + nativeOpenPrivateNamespaceW = nativeMutexKernel32.NewProc("OpenPrivateNamespaceW") + nativeClosePrivateNamespace = nativeMutexKernel32.NewProc("ClosePrivateNamespace") + nativeMutexNamespaceOnce sync.Once + nativeMutexNamespaceProcessScope *nativeMutexNamespace + nativeMutexNamespaceProcessErr error +) + +type nativeMutexNamespace struct { + boundary windows.Handle + namespace windows.Handle +} + +// close is used only while initialization is incomplete. A successfully +// created/opened namespace is retained for the process lifetime: Microsoft +// documents that after the creator closes its namespace handle, existing +// objects continue to work but subsequent OpenPrivateNamespace calls fail. +func (scope *nativeMutexNamespace) close() { + if scope == nil { + return + } + if scope.namespace != 0 { + nativeClosePrivateNamespace.Call(uintptr(scope.namespace), 0) //nolint:errcheck + scope.namespace = 0 + } + if scope.boundary != 0 { + nativeDeleteBoundaryDescriptor.Call(uintptr(scope.boundary)) //nolint:errcheck + scope.boundary = 0 + } +} + +func nativeMutexCallError(err error) error { + if err == nil || errors.Is(err, windows.ERROR_SUCCESS) { + return windows.ERROR_GEN_FAILURE + } + return err +} + +func createNativeMutexBoundary() (windows.Handle, error) { + name, err := windows.UTF16PtrFromString(nativeMutexBoundaryName) + if err != nil { + return 0, err + } + result, _, callErr := nativeCreateBoundaryDescriptorW.Call( + uintptr(unsafe.Pointer(name)), 0, + ) + runtime.KeepAlive(name) + if result == 0 { + return 0, nativeMutexCallError(callErr) + } + boundary := windows.Handle(result) + administrators, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + nativeDeleteBoundaryDescriptor.Call(uintptr(boundary)) //nolint:errcheck + return 0, err + } + result, _, callErr = nativeAddSIDToBoundaryDescriptor.Call( + uintptr(unsafe.Pointer(&boundary)), uintptr(unsafe.Pointer(administrators)), + ) + runtime.KeepAlive(administrators) + if result == 0 { + nativeDeleteBoundaryDescriptor.Call(uintptr(boundary)) //nolint:errcheck + return 0, nativeMutexCallError(callErr) + } + return boundary, nil +} + +// createOrOpenNativeMutexNamespace follows the documented private-namespace +// rendezvous: create with a protected DACL, or open only the namespace with the +// exact alias and Administrators boundary. A creator can disappear between +// ERROR_ALREADY_EXISTS and OpenPrivateNamespace, so retry only that absence +// race; all access and boundary failures remain fail-closed. +func createOrOpenNativeMutexNamespace() (*nativeMutexNamespace, error) { + boundary, err := createNativeMutexBoundary() + if err != nil { + return nil, fmt.Errorf("create native install mutex boundary: %w", err) + } + scope := &nativeMutexNamespace{boundary: boundary} + alias, err := windows.UTF16PtrFromString(nativeMutexNamespaceAlias) + if err != nil { + scope.close() + return nil, err + } + descriptor, err := windows.SecurityDescriptorFromString(nativeMutexObjectSDDL) + if err != nil { + scope.close() + return nil, fmt.Errorf("create native install namespace security descriptor: %w", err) + } + attributes := windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), + SecurityDescriptor: descriptor, + } + + var lastErr error + for attempt := 0; attempt < nativeMutexNamespaceRaceRetries; attempt++ { + result, _, createErr := nativeCreatePrivateNamespaceW.Call( + uintptr(unsafe.Pointer(&attributes)), uintptr(boundary), + uintptr(unsafe.Pointer(alias)), + ) + runtime.KeepAlive(alias) + runtime.KeepAlive(descriptor) + if result != 0 { + scope.namespace = windows.Handle(result) + return scope, nil + } + createErr = nativeMutexCallError(createErr) + if !errors.Is(createErr, windows.ERROR_ALREADY_EXISTS) && + !errors.Is(createErr, windows.ERROR_DUP_NAME) { + scope.close() + return nil, fmt.Errorf("create native install mutex namespace: %w", createErr) + } + + result, _, openErr := nativeOpenPrivateNamespaceW.Call( + uintptr(boundary), uintptr(unsafe.Pointer(alias)), + ) + runtime.KeepAlive(alias) + if result != 0 { + scope.namespace = windows.Handle(result) + return scope, nil + } + openErr = nativeMutexCallError(openErr) + lastErr = openErr + if !errors.Is(openErr, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(openErr, windows.ERROR_DUP_NAME) { + scope.close() + return nil, fmt.Errorf("open native install mutex namespace: %w", openErr) + } + time.Sleep(time.Millisecond) + } + scope.close() + return nil, fmt.Errorf("create or open native install mutex namespace after creator race: %w", lastErr) +} + +func nativeMutexProcessNamespace() (*nativeMutexNamespace, error) { + nativeMutexNamespaceOnce.Do(func() { + nativeMutexNamespaceProcessScope, nativeMutexNamespaceProcessErr = + createOrOpenNativeMutexNamespace() + }) + return nativeMutexNamespaceProcessScope, nativeMutexNamespaceProcessErr +} + +func nativePrivateMutexName(objectName string) (*uint16, error) { + if objectName == "" || strings.ContainsAny(objectName, `\\/`) { + return nil, fmt.Errorf("invalid native private mutex object name %q", objectName) + } + return windows.UTF16PtrFromString(nativeMutexNamespaceAlias + `\` + objectName) +} + +func nativeMutexSecurityAttributes() (*windows.SecurityAttributes, error) { + descriptor, err := windows.SecurityDescriptorFromString(nativeMutexObjectSDDL) + if err != nil { + return nil, err + } + return &windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), + SecurityDescriptor: descriptor, + }, nil +} + +// createNamedNativeMutex normalizes the Win32 CreateMutex contract. A named +// mutex that already exists is a successful open: Windows returns its valid +// handle and ERROR_ALREADY_EXISTS, and WaitForSingleObject decides ownership. +func createNamedNativeMutex( + attributes *windows.SecurityAttributes, + name *uint16, +) (windows.Handle, error) { + handle, err := windows.CreateMutex(attributes, false, name) + if err != nil && !errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + return 0, err + } + if handle == 0 { + if err != nil { + return 0, err + } + return 0, windows.ERROR_INVALID_HANDLE + } + return handle, nil +} + +func nativeMutexWaitMilliseconds(timeout time.Duration) uint32 { + if timeout <= 0 { + return 0 + } + milliseconds := timeout / time.Millisecond + if milliseconds >= time.Duration(windows.INFINITE) { + return windows.INFINITE - 1 + } + return uint32(milliseconds) +} + +// acquireNativeNamedMutex preserves Win32's thread-affine mutex ownership by +// pinning the goroutine through the returned release closure. Mutex ownership +// and its handle, the namespace, the boundary, and the thread pin are released +// in that order. +func acquireNativeNamedMutex( + objectName string, + timeout time.Duration, + busyMessage string, +) (func(), error) { + name, err := nativePrivateMutexName(objectName) + if err != nil { + return nil, err + } + runtime.LockOSThread() + scope, err := nativeMutexProcessNamespace() + if err != nil { + runtime.UnlockOSThread() + return nil, err + } + attributes, err := nativeMutexSecurityAttributes() + if err != nil { + runtime.UnlockOSThread() + return nil, fmt.Errorf("create native mutex security descriptor: %w", err) + } + handle, err := createNamedNativeMutex(attributes, name) + runtime.KeepAlive(attributes.SecurityDescriptor) + if err != nil { + runtime.UnlockOSThread() + return nil, fmt.Errorf("create protected native mutex: %w", err) + } + status, err := windows.WaitForSingleObject(handle, nativeMutexWaitMilliseconds(timeout)) + if err != nil || (status != windows.WAIT_OBJECT_0 && status != windows.WAIT_ABANDONED) { + windows.CloseHandle(handle) //nolint:errcheck + runtime.UnlockOSThread() + if err != nil { + return nil, fmt.Errorf("wait for protected native mutex: %w", err) + } + return nil, errors.New(busyMessage) + } + var once sync.Once + return func() { + once.Do(func() { + windows.ReleaseMutex(handle) //nolint:errcheck + windows.CloseHandle(handle) //nolint:errcheck + runtime.KeepAlive(scope) + runtime.UnlockOSThread() + }) + }, nil +} + +// nativeNamedMutexHeldByAnotherOwner opens only the exact object inside the +// Administrators namespace. WAIT_TIMEOUT proves another thread/process owns +// it; an absent, abandoned, or immediately acquirable mutex proves no live +// owner. This is the cross-process nested package-commit probe. +func nativeNamedMutexHeldByAnotherOwner(objectName string) (bool, error) { + name, err := nativePrivateMutexName(objectName) + if err != nil { + return false, err + } + runtime.LockOSThread() + defer runtime.UnlockOSThread() + scope, err := nativeMutexProcessNamespace() + if err != nil { + return false, err + } + defer runtime.KeepAlive(scope) + handle, err := windows.OpenMutex( + windows.SYNCHRONIZE|windows.MUTEX_MODIFY_STATE, false, name, + ) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { + return false, nil + } + return false, err + } + defer windows.CloseHandle(handle) //nolint:errcheck + status, err := windows.WaitForSingleObject(handle, 0) + if err != nil { + return false, err + } + switch status { + case uint32(windows.WAIT_TIMEOUT): + return true, nil + case uint32(windows.WAIT_OBJECT_0), uint32(windows.WAIT_ABANDONED): + windows.ReleaseMutex(handle) //nolint:errcheck + return false, nil + default: + return false, fmt.Errorf("unexpected protected native mutex wait status: 0x%08x", status) + } +} diff --git a/internal/cmd/native_mutex_windows_test.go b/internal/cmd/native_mutex_windows_test.go new file mode 100644 index 00000000..7723f476 --- /dev/null +++ b/internal/cmd/native_mutex_windows_test.go @@ -0,0 +1,147 @@ +//go:build windows + +package cmd + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +const nativeMutexProbeTestEnvironment = "VIIPER_NATIVE_MUTEX_PROBE_TEST" + +func requireNativeMutexAdministrator(t *testing.T) { + t.Helper() + administrators, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + t.Fatalf("create Administrators SID: %v", err) + } + member, err := windows.GetCurrentProcessToken().IsMember(administrators) + if err != nil { + t.Fatalf("check Administrators token membership: %v", err) + } + if !member { + t.Skip("Administrators-bound private namespace requires an elevated test token") + } +} + +func TestNativeMutexRejectsPublicNamespaceObjectNames(t *testing.T) { + t.Parallel() + for _, name := range []string{"", `Global\VIIPER`, `Local\VIIPER`, `nested/name`} { + if _, err := nativePrivateMutexName(name); err == nil { + t.Errorf("nativePrivateMutexName(%q) accepted a namespace escape", name) + } + } +} + +func TestNativeMutexWaitMillisecondsIsBounded(t *testing.T) { + t.Parallel() + if got := nativeMutexWaitMilliseconds(-time.Second); got != 0 { + t.Fatalf("negative timeout converted to %d, want 0", got) + } + if got := nativeMutexWaitMilliseconds(time.Nanosecond); got != 0 { + t.Fatalf("sub-millisecond timeout converted to %d, want 0", got) + } + if got := nativeMutexWaitMilliseconds(time.Second); got != 1000 { + t.Fatalf("one-second timeout converted to %d, want 1000", got) + } + if got := nativeMutexWaitMilliseconds(time.Duration(windows.INFINITE) * time.Millisecond); got != windows.INFINITE-1 { + t.Fatalf("oversized timeout converted to %d, want %d", got, uint32(windows.INFINITE-1)) + } +} + +func TestNativeMutexNestedPackageProbe(t *testing.T) { + requireNativeMutexAdministrator(t) + name := "VIIPER_NATIVE_PACKAGE_PROBE_TEST_" + filepath.Base(t.TempDir()) + release, err := acquireNamedNativePackageMutex(name, time.Second) + if err != nil { + t.Fatalf("acquire package mutex: %v", err) + } + defer release() + + command := exec.Command(os.Args[0], "-test.run=^TestNativeMutexNestedPackageProbeChild$") + command.Env = append(os.Environ(), nativeMutexProbeTestEnvironment+"="+name) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("run nested mutex probe: %v\n%s", err, output) + } +} + +func TestNativeMutexNestedPackageProbeChild(t *testing.T) { + name := os.Getenv(nativeMutexProbeTestEnvironment) + if name == "" { + return + } + held, err := nativePackageMutexHeldByAnotherOwner(name) + if err != nil { + t.Fatalf("probe parent-owned package mutex: %v", err) + } + if !held { + t.Fatal("parent-owned package mutex was not observed inside the exact private namespace") + } +} + +func TestNativeMutexPrivateNamespaceSourceContract(t *testing.T) { + t.Parallel() + _, current, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve native mutex test source") + } + source, err := os.ReadFile(filepath.Join(filepath.Dir(current), "native_mutex_windows.go")) + if err != nil { + t.Fatal(err) + } + text := string(source) + for _, required := range []string{ + `nativeMutexObjectSDDL = "O:BAG:BAD:P(A;;GA;;;SY)(A;;GA;;;BA)"`, + `windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)`, + `NewProc("CreateBoundaryDescriptorW")`, + `NewProc("AddSIDToBoundaryDescriptor")`, + `NewProc("CreatePrivateNamespaceW")`, + `NewProc("OpenPrivateNamespaceW")`, + `NewProc("ClosePrivateNamespace")`, + `nativeDeleteBoundaryDescriptor.Call`, + `nativeMutexNamespaceOnce.Do(func()`, + `nativeMutexProcessNamespace()`, + `errors.Is(createErr, windows.ERROR_DUP_NAME)`, + `errors.Is(openErr, windows.ERROR_DUP_NAME)`, + `scope.namespace = windows.Handle(result)`, + `createNamedNativeMutex(attributes, name)`, + `runtime.LockOSThread()`, + `windows.WAIT_ABANDONED`, + `windows.OpenMutex(`, + } { + if !strings.Contains(text, required) { + t.Errorf("native mutex namespace lost %q", required) + } + } + for _, forbidden := range []string{`Global\VIIPER.NativePackage`, `Global\VIIPER.NativeBroker`} { + if strings.Contains(text, forbidden) { + t.Errorf("native mutex helper retains squattable public name %q", forbidden) + } + } + if nativePackageMutexName == nativeInstallMutexName { + t.Fatal("package and service transactions unexpectedly share one mutex object") + } + if strings.ContainsAny(nativePackageMutexName+nativeInstallMutexName, `\\/`) { + t.Fatal("native mutex object names escape the private namespace") + } +} + +func TestNativeMutexAbsentProbeIsNotHeld(t *testing.T) { + requireNativeMutexAdministrator(t) + name := "VIIPER_NATIVE_ABSENT_PROBE_TEST_" + filepath.Base(t.TempDir()) + held, err := nativePackageMutexHeldByAnotherOwner(name) + if err != nil { + t.Fatalf("probe absent package mutex: %v", err) + } + if held { + t.Fatal("absent package mutex reported as held") + } +} diff --git a/internal/cmd/native_package.go b/internal/cmd/native_package.go new file mode 100644 index 00000000..5f84f7a8 --- /dev/null +++ b/internal/cmd/native_package.go @@ -0,0 +1,603 @@ +package cmd + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +var nativePackageHexRevision = regexp.MustCompile(`^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$`) +var nativePackageSHA256 = regexp.MustCompile(`^[0-9a-fA-F]{64}$`) +var nativePackageInstallProofPattern = regexp.MustCompile( + `(?m)^result=(success|error) operation=install changed=([01]) rebootRequired=([01]) rollback=(not-needed|succeeded|failed) exitCode=([0-9]+)(?: .*)?\r?$`, +) +var nativePackageInstallJournalBindingPattern = regexp.MustCompile( + `(?m)^journal-binding operation=install transactionId=([0-9a-f]{32}) outerTransactionId=([0-9a-f]{64}) candidateSha256=([0-9a-f]{64}) state=(nested-ready) digest=([0-9a-f]{64}) driverTransactionId=([0-9a-f]{64}) driverDigest=([0-9a-f]{64}) settlementNonce=([0-9a-f]{64}) recovery=(fresh|replayed)\r?$`, +) +var nativePackageBrokerSettlementReceiptPattern = regexp.MustCompile( + `(?m)^journal-settlement operation=broker-settlement-ack brokerTransactionId=([0-9a-f]{32}) brokerPendingDigest=([0-9a-f]{64}) driverTransactionId=([0-9a-f]{64}) driverPendingDigest=([0-9a-f]{64}) settlementNonce=([0-9a-f]{64}) requestSha256=([0-9a-f]{64}) state=(outer-settled) digest=([0-9a-f]{64})\r?$`, +) +var nativePackageBrokerSettlementDiscardPattern = regexp.MustCompile( + `(?m)^journal-discard operation=broker-settlement-discard brokerTransactionId=([0-9a-f]{32}) brokerDigest=([0-9a-f]{64}) driverTransactionId=([0-9a-f]{64}) driverDigest=([0-9a-f]{64}) settlementNonce=([0-9a-f]{64}) requestSha256=([0-9a-f]{64}) discarded=([01]) retained=([01])\r?$`, +) + +const ( + nativePackageTransactionTimeout = 4 * time.Minute + nativePackageRollbackTimeout = 2 * time.Minute + nativePackageRebootRequiredCode = 3010 +) + +type nativePackageRebootRequiredError struct { + cause error +} + +type nativePackageRecoveryRetryError struct{} + +func (*nativePackageRecoveryRetryError) Error() string { + return "a prior native package transaction was recovered and settled; retry the requested package transaction" +} + +func (e *nativePackageRebootRequiredError) Error() string { + return "native package activation requires a restart after safe rollback: " + e.cause.Error() +} + +func (e *nativePackageRebootRequiredError) Unwrap() error { return e.cause } + +// ExitCode lets Kong preserve Windows' ERROR_SUCCESS_REBOOT_REQUIRED contract +// for the signed installer instead of flattening the reconciled state to 1. +func (e *nativePackageRebootRequiredError) ExitCode() int { + return nativePackageRebootRequiredCode +} + +// NativePackageInstall is the narrow bootstrapper boundary for the native UDE +// package. Production is the default and normal users enter through the signed +// DS4Windows installer. The explicit local-test route retains the same hashes, +// rollback, service, and authenticated health transaction for disposable +// TESTSIGNING machines without relaxing the production route. +type NativePackageInstall struct { + PackageDirectory string `help:"Directory containing the exact Microsoft-returned INF, SYS, and CAT runtime files." required:""` + SubmissionManifest string `help:"Source-bound HLK/WHCP submission manifest." required:""` + SourceRevision string `help:"Reviewed 40- or 64-character source revision." required:""` + DriverHelper string `help:"Path to the packaged ViiperUdeCtl.exe." required:""` + ExpectedBrokerSHA256 string `help:"Installer-embedded SHA-256 of this VIIPER executable." required:""` + ExpectedHelperSHA256 string `help:"Installer-embedded SHA-256 of ViiperUdeCtl.exe." required:""` + ExpectedManifestSHA256 string `help:"Installer-embedded SHA-256 of the reviewed HLK/WHCP manifest." required:""` + ExpectedInfSHA256 string `help:"Installer-embedded SHA-256 of the Microsoft-returned ViiperUde.inf." required:""` + ExpectedSysSHA256 string `help:"Installer-embedded SHA-256 of the Microsoft-returned ViiperUde.sys." required:""` + ExpectedCatSHA256 string `help:"Installer-embedded SHA-256 of the Microsoft-returned ViiperUde.cat." required:""` + TargetUserSID string `help:"Interactive Windows user SID that owns legacy startup state." required:""` + DriverValidationMode string `help:"Driver signature route: production or local-test." default:"production" enum:"production,local-test" hidden:""` +} + +// NativePackageBrokerCommit is invoked only by ViiperUdeCtl while the signed +// outer package transaction holds its machine mutex and protected token. +type NativePackageBrokerCommit struct { + TokenFile string `help:"Protected package-transaction token path." required:""` + ExpectedTokenSHA256 string `help:"SHA-256 of the protected transaction token." required:""` + ExpectedBrokerSHA256 string `help:"Installer-bound SHA-256 of the broker being committed." required:""` + TargetUserSID string `help:"Interactive Windows user SID that owns legacy startup state." required:""` + TransactionDeadlineUnixMS string `help:"Outer package transaction deadline as Unix milliseconds." required:""` + RecoveryOnly bool `help:"Replay or reconcile only the exact durable child transaction; never start a new one." hidden:""` +} + +type nativePackageBrokerCommitResult struct { + success bool + changed bool + rollback string + exitCode int + journal nativeBrokerJournalProof +} + +type nativeBrokerJournalProof struct { + TransactionID string + OuterTransactionID string + CandidateSHA256 string + State string + Digest string +} + +type nativePackageInstallProof struct { + success bool + changed bool + rebootRequired bool + rollback string + exitCode int + journal nativeBrokerJournalProof + driverTransactionID string + driverPendingDigest string + settlementNonce string + journalRecovery string +} + +type nativePackageBrokerSettlementReceipt struct { + BrokerTransactionID string `json:"brokerTransactionId"` + BrokerPendingDigest string `json:"brokerPendingDigest"` + DriverTransactionID string `json:"driverTransactionId"` + DriverPendingDigest string `json:"driverPendingDigest"` + SettlementNonce string `json:"settlementNonce"` + RequestSHA256 string `json:"requestSha256"` + State string `json:"state"` + Digest string `json:"digest"` +} + +type nativePackageBrokerSettlementDiscardReceipt struct { + BrokerTransactionID string + BrokerDigest string + DriverTransactionID string + DriverDigest string + SettlementNonce string + RequestSHA256 string + Discarded bool + Retained bool +} + +func parseNativePackageInstallProof(output string, processExitCode int) (nativePackageInstallProof, error) { + matches := nativePackageInstallProofPattern.FindAllStringSubmatch(output, -1) + if len(matches) != 1 { + return nativePackageInstallProof{}, errors.New("driver helper did not emit exactly one structured install outcome") + } + proofExitCode, err := strconv.Atoi(matches[0][5]) + if err != nil { + return nativePackageInstallProof{}, fmt.Errorf("parse driver helper install exit code: %w", err) + } + proof := nativePackageInstallProof{ + success: matches[0][1] == "success", + changed: matches[0][2] == "1", + rebootRequired: matches[0][3] == "1", + rollback: matches[0][4], + exitCode: proofExitCode, + } + journalBindingSeen := false + for cursor := 0; cursor < len(output); { + lineEnd := strings.IndexByte(output[cursor:], '\n') + terminated := lineEnd >= 0 + if terminated { + lineEnd += cursor + } else { + lineEnd = len(output) + } + line := output[cursor:lineEnd] + if strings.HasPrefix(line, "journal-binding") { + journalBinding := nativePackageInstallJournalBindingPattern.FindStringSubmatch(line) + if !terminated || len(journalBinding) != 10 || journalBinding[0] != line { + return nativePackageInstallProof{}, errors.New( + "driver helper emitted a noncanonical broker journal binding", + ) + } + if journalBindingSeen { + return nativePackageInstallProof{}, errors.New("driver helper emitted multiple broker journal bindings") + } + journalBindingSeen = true + proof.journal = nativeBrokerJournalProof{ + TransactionID: journalBinding[1], + OuterTransactionID: journalBinding[2], + CandidateSHA256: journalBinding[3], + State: journalBinding[4], + Digest: journalBinding[5], + } + proof.driverTransactionID = journalBinding[6] + proof.driverPendingDigest = journalBinding[7] + proof.settlementNonce = journalBinding[8] + proof.journalRecovery = journalBinding[9] + } + if !terminated { + break + } + cursor = lineEnd + 1 + } + if proof.exitCode != processExitCode { + return nativePackageInstallProof{}, fmt.Errorf( + "driver helper install process exit %d disagreed with structured exit %d", + processExitCode, proof.exitCode, + ) + } + switch proof.exitCode { + case 0: + if !proof.success || proof.rebootRequired || proof.rollback != "not-needed" { + return nativePackageInstallProof{}, errors.New("driver helper emitted an invalid success install outcome") + } + case nativePackageRebootRequiredCode: + settledBeforeMutation := !proof.changed && proof.rollback == "not-needed" + settledAfterRollback := proof.changed && proof.rollback == "succeeded" + if proof.success || !proof.rebootRequired || + (!settledBeforeMutation && !settledAfterRollback) { + return nativePackageInstallProof{}, errors.New("driver helper emitted an invalid reboot-boundary install outcome") + } + case 4: + if proof.success || proof.changed || proof.rebootRequired || proof.rollback != "not-needed" { + return nativePackageInstallProof{}, errors.New("driver helper emitted an invalid preflight install outcome") + } + case 1: + settledMutation := proof.changed && proof.rollback == "succeeded" + preMutationFailure := !proof.changed && proof.rollback == "not-needed" + if proof.success || (!settledMutation && !preMutationFailure) { + return nativePackageInstallProof{}, errors.New("driver helper emitted an invalid failed install outcome") + } + case 3: + if proof.success || !proof.changed || proof.rollback != "failed" { + return nativePackageInstallProof{}, errors.New("driver helper emitted an invalid indeterminate install outcome") + } + default: + return nativePackageInstallProof{}, fmt.Errorf( + "driver helper returned unsupported structured install exit %d", proof.exitCode, + ) + } + if journalBindingSeen && (!proof.success || !proof.changed || proof.exitCode != 0) { + return nativePackageInstallProof{}, errors.New( + "driver helper emitted a broker journal binding for a non-forward-success outcome", + ) + } + return proof, nil +} + +func parseNativePackageBrokerSettlementReceipt( + output string, + processExitCode int, +) (nativePackageBrokerSettlementReceipt, error) { + if processExitCode != 0 { + return nativePackageBrokerSettlementReceipt{}, fmt.Errorf( + "driver helper settlement acknowledgement exited with %d", processExitCode, + ) + } + var receipt nativePackageBrokerSettlementReceipt + seen := false + for cursor := 0; cursor < len(output); { + lineEnd := strings.IndexByte(output[cursor:], '\n') + terminated := lineEnd >= 0 + if terminated { + lineEnd += cursor + } else { + lineEnd = len(output) + } + line := output[cursor:lineEnd] + if strings.HasPrefix(line, "journal-settlement") { + match := nativePackageBrokerSettlementReceiptPattern.FindStringSubmatch(line) + if !terminated || len(match) != 9 || match[0] != line { + return nativePackageBrokerSettlementReceipt{}, errors.New( + "driver helper emitted a noncanonical broker settlement acknowledgement", + ) + } + if seen { + return nativePackageBrokerSettlementReceipt{}, errors.New( + "driver helper emitted multiple broker settlement acknowledgements", + ) + } + seen = true + receipt = nativePackageBrokerSettlementReceipt{ + BrokerTransactionID: match[1], + BrokerPendingDigest: match[2], + DriverTransactionID: match[3], + DriverPendingDigest: match[4], + SettlementNonce: match[5], + RequestSHA256: match[6], + State: match[7], + Digest: match[8], + } + } + if !terminated { + break + } + cursor = lineEnd + 1 + } + if !seen { + return nativePackageBrokerSettlementReceipt{}, errors.New( + "driver helper emitted no broker settlement acknowledgement", + ) + } + return receipt, nil +} + +func parseNativePackageBrokerSettlementDiscardReceipt( + output string, + processExitCode int, +) (nativePackageBrokerSettlementDiscardReceipt, error) { + if processExitCode != 0 { + return nativePackageBrokerSettlementDiscardReceipt{}, fmt.Errorf( + "driver helper settled-tombstone discard exited with %d", processExitCode, + ) + } + var receipt nativePackageBrokerSettlementDiscardReceipt + seen := false + for cursor := 0; cursor < len(output); { + lineEnd := strings.IndexByte(output[cursor:], '\n') + terminated := lineEnd >= 0 + if terminated { + lineEnd += cursor + } else { + lineEnd = len(output) + } + line := output[cursor:lineEnd] + if strings.HasPrefix(line, "journal-discard") { + match := nativePackageBrokerSettlementDiscardPattern.FindStringSubmatch(line) + if !terminated || len(match) != 9 || match[0] != line { + return nativePackageBrokerSettlementDiscardReceipt{}, errors.New( + "driver helper emitted a noncanonical settled-tombstone discard receipt", + ) + } + if seen { + return nativePackageBrokerSettlementDiscardReceipt{}, errors.New( + "driver helper emitted multiple settled-tombstone discard receipts", + ) + } + seen = true + receipt = nativePackageBrokerSettlementDiscardReceipt{ + BrokerTransactionID: match[1], + BrokerDigest: match[2], + DriverTransactionID: match[3], + DriverDigest: match[4], + SettlementNonce: match[5], + RequestSHA256: match[6], + Discarded: match[7] == "1", + Retained: match[8] == "1", + } + } + if !terminated { + break + } + cursor = lineEnd + 1 + } + if !seen { + return nativePackageBrokerSettlementDiscardReceipt{}, errors.New( + "driver helper emitted no settled-tombstone discard receipt", + ) + } + return receipt, nil +} + +func (r nativePackageBrokerCommitResult) proofLine() string { + status := "error" + if r.success { + status = "success" + } + changed := 0 + if r.changed { + changed = 1 + } + return fmt.Sprintf( + "result=%s operation=native-package-broker-commit changed=%d rollback=%s exitCode=%d\n", + status, changed, r.rollback, r.exitCode, + ) +} + +func (r nativePackageBrokerCommitResult) journalProofLine() string { + if len(r.journal.TransactionID) != 32 || + !nativePackageSHA256.MatchString(r.journal.OuterTransactionID) || + !nativePackageSHA256.MatchString(r.journal.CandidateSHA256) || + !nativePackageSHA256.MatchString(r.journal.Digest) || + r.journal.TransactionID != strings.ToLower(r.journal.TransactionID) || + r.journal.OuterTransactionID != strings.ToLower(r.journal.OuterTransactionID) || + r.journal.CandidateSHA256 != strings.ToLower(r.journal.CandidateSHA256) || + r.journal.Digest != strings.ToLower(r.journal.Digest) || + (r.journal.State != "nested-ready" && r.journal.State != "rollback-settled" && + r.journal.State != "manual") { + return "" + } + if _, err := hex.DecodeString(r.journal.TransactionID); err != nil { + return "" + } + return fmt.Sprintf( + "journal-proof operation=native-package-broker-commit transactionId=%s outerTransactionId=%s candidateSha256=%s state=%s digest=%s\n", + r.journal.TransactionID, r.journal.OuterTransactionID, r.journal.CandidateSHA256, + r.journal.State, r.journal.Digest, + ) +} + +type nativePackageBrokerCommitError struct { + cause error + exitCode int +} + +func (e *nativePackageBrokerCommitError) Error() string { return e.cause.Error() } +func (e *nativePackageBrokerCommitError) Unwrap() error { return e.cause } +func (e *nativePackageBrokerCommitError) ExitCode() int { return e.exitCode } + +func nativePackageBrokerPreflightFailure(err error) (nativePackageBrokerCommitResult, error) { + return nativePackageBrokerCommitResult{rollback: "not-needed", exitCode: 4}, err +} + +func (c *NativePackageBrokerCommit) Run(logger *slog.Logger) error { + var result nativePackageBrokerCommitResult + var err error + if !nativePackageSHA256.MatchString(strings.TrimSpace(c.ExpectedTokenSHA256)) || + !nativePackageSHA256.MatchString(strings.TrimSpace(c.ExpectedBrokerSHA256)) { + result, err = nativePackageBrokerPreflightFailure( + errors.New("native package token and broker SHA-256 values must contain exactly 64 hexadecimal characters"), + ) + } else { + result, err = commitNativePackageBroker(logger, strings.TrimSpace(c.TokenFile), + strings.ToLower(strings.TrimSpace(c.ExpectedTokenSHA256)), + strings.ToLower(strings.TrimSpace(c.ExpectedBrokerSHA256)), strings.TrimSpace(c.TargetUserSID), + strings.TrimSpace(c.TransactionDeadlineUnixMS), c.RecoveryOnly) + } + fmt.Fprint(os.Stdout, result.proofLine()+result.journalProofLine()) + if err != nil { + return &nativePackageBrokerCommitError{cause: err, exitCode: result.exitCode} + } + return nil +} + +func (c *NativePackageInstall) Run(logger *slog.Logger) error { + executable, err := currentExecutable() + if err != nil { + return err + } + if strings.Contains(executable, "go-build") { + return errors.New("cannot provision the native package from 'go run'") + } + request := nativePackageRequest{ + brokerSource: executable, + packageDirectory: strings.TrimSpace(c.PackageDirectory), + submissionManifest: strings.TrimSpace(c.SubmissionManifest), + sourceRevision: strings.ToLower(strings.TrimSpace(c.SourceRevision)), + driverHelper: strings.TrimSpace(c.DriverHelper), + expectedBrokerSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedBrokerSHA256)), + expectedHelperSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedHelperSHA256)), + expectedManifestSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedManifestSHA256)), + expectedInfSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedInfSHA256)), + expectedSysSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedSysSHA256)), + expectedCatSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedCatSHA256)), + targetUserSID: strings.TrimSpace(c.TargetUserSID), + driverValidationMode: strings.ToLower(strings.TrimSpace(c.DriverValidationMode)), + } + if err := request.validate(); err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), nativePackageTransactionTimeout) + defer cancel() + return installNativePackage(ctx, logger, request) +} + +type nativePackageRequest struct { + brokerSource string + packageDirectory string + submissionManifest string + sourceRevision string + driverHelper string + expectedBrokerSHA256 string + expectedHelperSHA256 string + expectedManifestSHA256 string + expectedInfSHA256 string + expectedSysSHA256 string + expectedCatSHA256 string + targetUserSID string + driverValidationMode string +} + +func (r nativePackageRequest) validate() error { + for name, value := range map[string]string{ + "broker source": r.brokerSource, "driver package": r.packageDirectory, + "submission manifest": r.submissionManifest, "driver helper": r.driverHelper, + "target user SID": r.targetUserSID, + } { + if value == "" { + return fmt.Errorf("native package %s is empty", name) + } + if strings.IndexByte(value, 0) >= 0 { + return fmt.Errorf("native package %s contains NUL", name) + } + } + if !nativePackageHexRevision.MatchString(r.sourceRevision) { + return errors.New("native package source revision must contain exactly 40 or 64 hexadecimal characters") + } + if r.driverValidationMode != "production" && r.driverValidationMode != "local-test" { + return errors.New("native package driver validation mode must be production or local-test") + } + if !nativePackageSHA256.MatchString(r.expectedBrokerSHA256) || + !nativePackageSHA256.MatchString(r.expectedHelperSHA256) || + !nativePackageSHA256.MatchString(r.expectedManifestSHA256) || + !nativePackageSHA256.MatchString(r.expectedInfSHA256) || + !nativePackageSHA256.MatchString(r.expectedSysSHA256) || + !nativePackageSHA256.MatchString(r.expectedCatSHA256) { + return errors.New("native package broker, helper, manifest, INF, SYS, and CAT SHA-256 values must contain exactly 64 hexadecimal characters") + } + for name, path := range map[string]string{ + "broker source": r.brokerSource, "driver package": r.packageDirectory, + "submission manifest": r.submissionManifest, "driver helper": r.driverHelper, + } { + if !filepath.IsAbs(path) { + return fmt.Errorf("native package %s must be an absolute path: %s", name, path) + } + } + return nil +} + +type nativePackageServiceDisposition uint8 + +const ( + nativePackageServiceAbsent nativePackageServiceDisposition = iota + nativePackageServiceTrusted + nativePackageServiceWeakExactOwned +) + +type nativePackageServiceSnapshot struct { + disposition nativePackageServiceDisposition + wasRunning bool + opaque any +} + +type nativePackageTransaction interface { + Preflight(context.Context) error + InspectService(context.Context) (nativePackageServiceSnapshot, error) + Prepare(context.Context, nativePackageServiceSnapshot) error + InstallDriverAndBroker(context.Context) error + VerifyAuthenticatedHealth(context.Context) error + Commit(context.Context) error + Rollback(context.Context) error + Close() error +} + +func runNativePackageTransaction( + ctx context.Context, + logger *slog.Logger, + transaction nativePackageTransaction, +) (resultErr error) { + if transaction == nil { + return errors.New("native package transaction is nil") + } + defer func() { + if closeErr := transaction.Close(); closeErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("close native package transaction: %w", closeErr)) + } + }() + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package transaction canceled before preflight: %w", err) + } + if err := transaction.Preflight(ctx); err != nil { + return fmt.Errorf("native package preflight rejected before mutation: %w", err) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package transaction canceled before service inspection: %w", err) + } + service, err := transaction.InspectService(ctx) + if err != nil { + return fmt.Errorf("inspect native broker service before mutation: %w", err) + } + prepared := false + committed := false + defer func() { + if !prepared || committed { + return + } + rollbackCtx, cancelRollback := context.WithTimeout( + context.WithoutCancel(ctx), nativePackageRollbackTimeout, + ) + defer cancelRollback() + if rollbackErr := transaction.Rollback(rollbackCtx); rollbackErr != nil { + resultErr = errors.Join(resultErr, + fmt.Errorf("roll back native package transaction: %w", rollbackErr)) + } + }() + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package transaction canceled before preparation: %w", err) + } + // Preparation can fail after stopping a prior service or publishing one of + // the staged paths. Arm rollback before entering the mutating method. + prepared = true + if err := transaction.Prepare(ctx, service); err != nil { + return fmt.Errorf("prepare protected native package staging: %w", err) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package transaction canceled before driver installation: %w", err) + } + if err := transaction.InstallDriverAndBroker(ctx); err != nil { + return fmt.Errorf("install native driver and broker transaction: %w", err) + } + if err := transaction.VerifyAuthenticatedHealth(ctx); err != nil { + return fmt.Errorf("verify native package authenticated health: %w", err) + } + if err := transaction.Commit(ctx); err != nil { + return fmt.Errorf("commit native package transaction: %w", err) + } + committed = true + logger.Info("VIIPER native UDE package transaction committed", + "transport", "native-ude", "sourceRevision", "verified") + return nil +} diff --git a/internal/cmd/native_package_contract_test.go b/internal/cmd/native_package_contract_test.go new file mode 100644 index 00000000..48bf32dc --- /dev/null +++ b/internal/cmd/native_package_contract_test.go @@ -0,0 +1,1180 @@ +package cmd + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestNativePackageProductionSourceContract(t *testing.T) { + t.Parallel() + _, current, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve test source") + } + root := filepath.Clean(filepath.Join(filepath.Dir(current), "..", "..")) + windowsSource := readNativePackageContractFile(t, + filepath.Join(root, "internal", "cmd", "native_package_windows.go")) + uninstallWindowsSource := readNativePackageContractFile(t, + filepath.Join(root, "internal", "cmd", "native_package_uninstall_windows.go")) + processWaitSource := readNativePackageContractFile(t, + filepath.Join(root, "internal", "cmd", "native_package_process_windows.go")) + mutexSource := readNativePackageContractFile(t, + filepath.Join(root, "internal", "cmd", "native_mutex_windows.go")) + helperSource := readNativePackageContractFile(t, + filepath.Join(root, "native", "udecx", "tools", "ViiperUdeCtl.cpp")) + transactionSource := readNativePackageContractFile(t, + filepath.Join(root, "internal", "cmd", "native_package.go")) + uninstallTransactionSource := readNativePackageContractFile(t, + filepath.Join(root, "internal", "cmd", "native_package_uninstall.go")) + serviceSource := readNativePackageContractFile(t, + filepath.Join(root, "internal", "cmd", "native_service_install_windows.go")) + brokerJournalSource := readNativePackageContractFile(t, + filepath.Join(root, "internal", "cmd", "native_broker_journal_windows.go")) + + requiredWindows := []string{ + "expectedManifestSHA256", + "expectedInfSHA256", "expectedSysSHA256", "expectedCatSHA256", + "--manifest-sha256", + "--expected-inf-sha256", "--expected-sys-sha256", "--expected-cat-sha256", + "nativeBrokerDirectorySDDL", + "nativeBrokerExecutableSDDL", + "nativePackageServiceWeakExactOwned", + "isCanonicalNativePackageService", + "nativeServiceConfigsEqual", + "slices.Equal(recovery, nativeServiceRecoveryActions)", + "service.Delete()", + "lockNativeServiceExecutableReadOnly", + `runDriverHelper(ctx)`, + "nestedBrokerCommit", "nestedBrokerHealthy", "nestedMutationStarted", + "nestedRollbackSucceeded", "verifyExactBrokerHealth(ctx)", + "nested native broker service rollback is unsettled", + "MOVEFILE_WRITE_THROUGH", + "VerifyAuthenticatedHealth", + "nativePackageTokenSDDL", + "nativePackageMutexHeldByAnotherOwner", + "lockNativePackageDirectoryChain", + "--broker-token-sha256", + "--broker-quiesce-request-handle", "--broker-quiesce-ready-handle", + "--broker-quiesce-abort-handle", "--broker-handoff-handle", + "AdditionalInheritedHandles", "coordinateDriverHelper(ctx", + "quiescePriorServiceForDriver", "releaseServiceForBrokerHandoff", + "removeWeakExactOwnedService", + "restoreQuiescedPriorService", "driverHelperSettled", + "nativePackageRebootRequiredError", + "parseNativePackageInstallProof(text, processExitCode)", + } + for _, fragment := range requiredWindows { + if !strings.Contains(windowsSource, fragment) { + t.Errorf("Windows package orchestrator lost %q", fragment) + } + } + stageStart := strings.Index(windowsSource, + "func (t *windowsNativePackageTransaction) stageCoordinationToken() error {") + stageEnd := strings.Index(windowsSource, + "func (t *windowsNativePackageTransaction) ensureManagedPackageDirectory() error {") + if stageStart < 0 || stageEnd <= stageStart { + t.Fatal("native package coordination-token implementation is missing or malformed") + } + stageToken := windowsSource[stageStart:stageEnd] + closeWriter := strings.Index(stageToken, "if err := windows.CloseHandle(handle); err != nil {") + reopenSealed := strings.Index(stageToken, "sealed, err := lockNativePackageInput(path)") + rehashSealed := strings.Index(stageToken, "sealedHash, err := hashNativePackageHandle(sealed)") + publishSealed := strings.Index(stageToken, "t.tokenHandle = sealed") + if closeWriter < 0 || reopenSealed <= closeWriter || rehashSealed <= reopenSealed || + publishSealed <= rehashSealed { + t.Fatal("native package coordination token is published before its write handle is sealed and revalidated") + } + if strings.Contains(stageToken, "t.tokenHandle = handle") { + t.Fatal("native package transaction retains a write-capable token handle across nested broker startup") + } + requiredUninstallWindows := []string{ + "acquireNamedNativePackageMutex(nativePackageMutexName", + "acquireNativeInstallMutex(budget)", + "lockNativePackageDirectoryChain(filepath.Dir(t.request.driverHelper))", + "expectedHelperSHA256", "hashNativePackageHandle(helper)", + "isCanonicalNativePackageService(", "nativeBrokerServiceConfiguration(", + "nativeBrokerExecutableSDDL", "nativeCredentialDirectorySDDL(t.userSID)", + "nativeCredentialFileSDDL(t.userSID)", "lockNativePackageUninstallFile(", + "lockExactBrokerDirectoryChain(path)", + "lockNativePackageUninstallLiveLog(", "promoteNativePackageUninstallLiveLog(ctx)", + "stopNativeService(ctx, t.service", "--transaction-deadline-unix-ms", + "exec.Command(t.request.driverHelper", "parseNativePackageRemoveProof(", + "return result, proofErr", "serviceRestoreVerified", "t.service.Delete()", + "waitForNativePackageServiceDeletion", "deleteNativePackageUninstallFileHandle(", + "errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE)", + "nativePackageUninstallIsCurrentExecutable(file)", + "windows.MOVEFILE_DELAY_UNTIL_REBOOT|windows.MOVEFILE_WRITE_THROUGH", + "renameNativePackageUninstallFileToTombstone(file)", + } + for _, fragment := range requiredUninstallWindows { + if !strings.Contains(uninstallWindowsSource, fragment) { + t.Errorf("Windows package uninstall orchestrator lost %q", fragment) + } + } + for name, source := range map[string]string{ + "package install": windowsSource, + "package uninstall": uninstallWindowsSource, + } { + if strings.Contains(source, "command.Wait()") { + t.Errorf("%s bypasses the retained process-handle join", name) + } + } + if !strings.Contains(windowsSource, "waitNativePackageHelperCoordinated(command") { + t.Error("package install lost the retained coordinated process-handle join") + } + if !strings.Contains(uninstallWindowsSource, "waitNativePackageHelper(command)") { + t.Error("package uninstall lost the retained process-handle join") + } + credentialStart := strings.Index(serviceSource, + "func createProtectedNativeCredentialStagingFile(") + credentialEnd := strings.Index(serviceSource, + "func replaceFileAtomically(") + if credentialStart < 0 || credentialEnd <= credentialStart { + t.Fatal("protected native credential staging implementation is missing or malformed") + } + credentialStaging := serviceSource[credentialStart:credentialEnd] + for _, fragment := range []string{ + "nativeSecurityAttributes(sddl)", + "windows.CREATE_NEW", + "windows.FILE_FLAG_OPEN_REPARSE_POINT", + "windows.FILE_FLAG_WRITE_THROUGH", + "requireSingleNativeFileLink(handle)", + "validateNativeSecurityDescriptor(handle, sddl)", + } { + if !strings.Contains(credentialStaging, fragment) { + t.Errorf("native credential staging lost %q", fragment) + } + } + if strings.Contains(serviceSource, `os.CreateTemp(directory, ".viiper-key-*.tmp")`) || + strings.Contains(serviceSource, + "applyNativeACLToHandle(windows.Handle(temporary.Fd())") { + t.Fatal("native credential staging is created with a weak ACL before post-creation repair") + } + if strings.Contains(uninstallWindowsSource, + "scheduleNativePackageUninstallFileAtReboot(file.path)") { + t.Error("native uninstall schedules a reusable canonical broker path for reboot deletion") + } + for _, fragment := range []string{ + "process.WithHandle(", "windows.DuplicateHandle(", "windows.SYNCHRONIZE", + "windows.WaitForSingleObject", "windows.INFINITE", + "join.complete(command.Wait())", "nativePackageProcessWaitIndeterminateError", + } { + if !strings.Contains(processWaitSource, fragment) { + t.Errorf("native package helper process join lost %q", fragment) + } + } + if strings.Index(uninstallWindowsSource, "acquireNamedNativePackageMutex(nativePackageMutexName") > + strings.Index(uninstallWindowsSource, "acquireNativeInstallMutex(budget)") { + t.Error("native package uninstall no longer acquires package mutex before service mutex") + } + requiredHelper := []string{ + "Outcome Verify(", "ValidateCandidateInputs(", "RunBrokerInstall(", + "--manifest-sha256", "manifest-installer-hash", "--broker-sha256", + "--expected-inf-sha256", "--expected-sys-sha256", "--expected-cat-sha256", + "--broker-token-sha256", "native-package-broker-commit", + "BuildBrokerCommitCommandLine(", "--expected-token-sha-256", + "--expected-broker-sha-256", + "ParseBrokerCommitProof", "driverRollbackAuthorized", "CreatePipe(", + "PROC_THREAD_ATTRIBUTE_HANDLE_LIST", "kMaximumBrokerProofBytes", + "RollbackInstall(", "broker-reboot-boundary", + "--transaction-deadline-unix-ms", "kBrokerRollbackCeilingMs", + "kDriverRollbackCeilingMs", + "CreatePrivateNamespaceW", "WAIT_ABANDONED", "ReleaseMutex", + "FILE_FLAG_OVERLAPPED", "CancelIoEx", "kCancelledIoDrainMs", + "RegisterRootDeviceExact", "rollback-identity-verification", + "CertGetEnhancedKeyUsage", "1.3.6.1.4.1.311.10.3.5.1", + "CERT_FIND_EXT_ONLY_ENHKEY_USAGE_FLAG", + "VerifyDriverCatalogMember", "WinVerifyTrust", + "VerifyDriverCatalogMember(catalogPath, infPath", + "LoadLibraryExW", "LOAD_LIBRARY_SEARCH_SYSTEM32", "GetProcAddress", + "ValidateExactPackageDirectory", "Sha256Handle(manifest.get()", + "RequestBrokerQuiescence", "SignalBrokerHandoff", + "SetupCopyOEMInfW(", "SP_COPY_NOOVERWRITE", "ERROR_FILE_EXISTS", + "SetupUninstallOEMInfW(", "RemoveStagedCandidateExact(", + "VerifyPackageInventory(", "packageStagedHere", "bindingMutationStarted", + "stage-package-inventory-verification", "stage-root-binding-verification", + "stage-concurrent-publication", "post-quiescence-package-inventory-verification", + "post-quiescence-root-verification", "final-pre-bind-root-topology-verification", + "final-pre-bind-package-inventory-verification", + "final-pre-bind-root-verification", "post-bind-package-inventory-verification", + "PreparePreinstalledDriverOnDevice(", "CommitPreparedDriverBinding(", + "requirePristineRuntime", "RequiresDriverMutation(", + "RequiresPristineRuntimeProof(", "RuntimeStatsArePristine(", + "AbiCompatibilityProfile", "{14, 61, 152, true}", + "{13, 29, 152, true}", + "{12, 29, 152, true}", + "{11, 29, 144, false}", "{10, 13, 144, false}", + "AbiCompatibilityProfilesAreValid()", "IsAbiRetryEligible(", + "AbiHealthPurpose::PristineUpgrade", "AbiHealthPurpose::PristineRecheck", + "AbiHealthPurpose::RollbackHealth", "AbiNegotiationResponseMatchesProfile(", + "StatsRecordMatchesProfile(", "offsetof(VIIPER_UDE_STATS, ReservedPorts) == 144", + "rollback-runtime-start-verification", "rollback-runtime-abi-profile", + "rollback-stopped-state-verification", "RollbackLifecycleStateMatches(", + "self-test-rollback-lifecycle", + "transaction-deadline-before-broker-quiescence", + "MarkTransactionMutationStarted();", "recoveredReceipt", + "IOCTL_VIIPER_UDE_QUERY_STATS", + "upgrade-runtime-reboot-boundary", + "self-test-pristine-runtime-decision", "self-test-pristine-runtime-stats", + "--broker-quiesce-request-handle", "--broker-quiesce-ready-handle", + "--broker-quiesce-abort-handle", "--broker-handoff-handle", + "--recovery-only", "journal-binding operation=install", + "OuterPackageMutexWitness", "VerifyHeldByOuterOwner(", + "AcknowledgeBrokerOuterSettlement(", "DiscardBrokerSettlementTombstone(", + "ReconcileSettledBrokerOuterSettlement(", + "ParseBrokerSettlementRequest(", "ParseBrokerSettlementFinal(", + "ReadProtectedBrokerSettlementFinal(", + "ValidateBrokerSettlementFinalBinding(", + "ValidateBrokerSettlementFinalJournal(", + "IsBrokerOuterSettlementContinuationPhase(", + "BrokerOuterSettlementPendingDriverDigest(", + "LockProtectedBrokerImage(", + "kBrokerSettlementRequestFile", "kInstallRecoverySettledPrefix", + "kBrokerSettlementFinalFile", "kInstallRecoveryDiscardPrefix", + `<< " retained=" << (retained ? 1 : 0)`, + } + for _, fragment := range requiredHelper { + if !strings.Contains(helperSource, fragment) { + t.Errorf("driver helper lost %q", fragment) + } + } + sourceRegion := func(name, start, end string, lastStart bool) string { + t.Helper() + startIndex := strings.Index(helperSource, start) + if lastStart { + startIndex = strings.LastIndex(helperSource, start) + } + endIndex := -1 + if startIndex >= 0 { + if relative := strings.Index(helperSource[startIndex+len(start):], end); relative >= 0 { + endIndex = startIndex + len(start) + relative + } + } + if startIndex < 0 || endIndex <= startIndex { + t.Fatalf("driver helper %s source region is missing or malformed", name) + } + return helperSource[startIndex:endIndex] + } + assertOrdered := func(name, source string, fragments ...string) { + t.Helper() + cursor := -1 + for _, fragment := range fragments { + relative := strings.Index(source[cursor+1:], fragment) + if relative < 0 { + t.Fatalf("driver helper violates %s ordering at %q", name, fragment) + } + cursor += relative + 1 + } + } + + journalRequired := []string{ + "SHGetKnownFolderPath(", "FOLDERID_ProgramData", + `kInstallRecoveryProductDirectory[] = L"VIIPER"`, + `kInstallRecoveryComponentDirectory[] = L"UdeCx"`, + `kInstallRecoveryTransactionsDirectory[] = L"Transactions"`, + `kInstallRecoveryActiveDirectory[] = L"active-v2"`, + `kInstallRecoveryJournalPrefix[] = L"journal-"`, + "OpenStableDirectory(", "CreateOrOpenInstallRecoveryDirectory(", + "FILE_FLAG_OPEN_REPARSE_POINT", "FILE_ATTRIBUTE_REPARSE_POINT", + "VerifyProtectedFileSystemSecurity(", "WriteInstallJournalRecord(", + `\"previousSha256\"`, `\"payloadSha256\"`, + "FILE_FLAG_WRITE_THROUGH", "FlushFileBuffers(file.get())", + "MOVEFILE_WRITE_THROUGH", "install-journal-readback", + "Outcome Recover(", `L"recover"`, "ReconcileInstallJournal(", + "SynchronousMutationWatchdog", "InvokeAuthoritativeSynchronousMutation(", + "deadlineOverrun", "ManualReconciliationRequired", + "ForwardRebootPending", "RestoreRebootPending", + `\"direction\"`, `\"rollbackAuthorized\"`, + "ValidateInstallJournalTransition(", "impl_->poisoned = true", + "ValidateAndDiscardInstallJournalTemporaryFile(", + "OpenExistingInstallRecoveryDirectory(", "MapGenericMask(", + "StageReceiptCaptured ownership record", "exactPreRollbackInventory", + "RootSnapshotIsAuthorizedForInstallRollback(", + "InstallJournalNeedsRestoreRebootPending(", + "pendingRebootBootIdentifier", "freshRebootRequired", + "rootRegistrationInstanceId", "RootRegistrationIntentCaptured", + "ObservePriorEmptyInstallRecoveryRoot(", + "ReadInstallRecoveryHardwareIds(", + "DecodeCanonicalInstallRecoveryString(", + "RemoveAuthorizedPriorEmptyRootAfterAdmission(", + "PartialRootRemovalEntered", "PartialRootRemovalReturned", + "PartialRootRemovalRebootPending", "partialRootRemovalBinding", + "partialRootRemovalBootIdentifier", + "InstallRecoveryChainHasActive(", + "BuildInstallRecoveryProductDirectorySecurity(", + "VerifyProtectedProductDirectorySecurity(", + } + for _, fragment := range journalRequired { + if !strings.Contains(helperSource, fragment) { + t.Errorf("driver helper install journal lost %q", fragment) + } + } + journalPhases := []string{ + "Prepared", + "SetupCopyEntered", "SetupCopyReturned", "StageReceiptCaptured", + "QuiesceSignalEntered", "QuiesceSignalReturned", + "RootRegistrationIntentCaptured", + "RootRegistrationEntered", "RootRegistrationReturned", + "DiInstallEntered", "DiInstallReturned", "PriorAbiProfileCaptured", + "DriverValidated", + "BrokerHandoffEntered", "BrokerHandoffReturned", + "BrokerChildEntered", "BrokerChildSettled", + "BrokerOuterSettlementPending", "BrokerOuterSettled", + "RollbackBindingEntered", "PartialRootRemovalEntered", + "PartialRootRemovalReturned", "PartialRootRemovalRebootPending", + "RollbackBindingReturned", + "SetupUninstallEntered", "SetupUninstallReturned", + "ForwardValidated", "ExactPriorRestored", + "ForwardRebootPending", "RestoreRebootPending", + "ManualReconciliationRequired", + } + for _, phase := range journalPhases { + qualified := "InstallJournalPhase::" + phase + if strings.Count(helperSource, qualified) < 2 { + t.Errorf("driver helper install journal does not both define and use phase %q", phase) + } + } + + recoveryPathSource := sourceRegion("fixed recovery path", + "bool ResolveInstallRecoveryPaths(", "bool GetBootIdentifier(", false) + assertOrdered("fixed ProgramData path", recoveryPathSource, + "SHGetKnownFolderPath(", "FOLDERID_ProgramData", + "*product = *programData / kInstallRecoveryProductDirectory;", + "*component = *product / kInstallRecoveryComponentDirectory;", + "*transactions = *component / kInstallRecoveryTransactionsDirectory;", + "*active = *transactions / kInstallRecoveryActiveDirectory;") + for _, fragment := range []string{ + "FILE_FLAG_OPEN_REPARSE_POINT", "FILE_ATTRIBUTE_REPARSE_POINT", + "VerifyProtectedFileSystemSecurity(", + "CreateOrOpenInstallRecoveryDirectory(", + "active, false, true, &activeHandle", + } { + if !strings.Contains(recoveryPathSource, fragment) { + t.Errorf("driver helper fixed recovery path lost %q", fragment) + } + } + + journalWriterSource := sourceRegion("append-only journal writer", + "bool WriteInstallJournalRecord(", "bool GenerateInstallTransactionId(", false) + assertOrdered("durable journal publication", journalWriterSource, + "BuildInstallJournalPayload(", "Sha256Data(payload, &digest", + `\"payloadSha256\"`, "CREATE_NEW", "FILE_FLAG_WRITE_THROUGH", + "FlushFileBuffers(file.get())", "MoveFileExW(", "MOVEFILE_WRITE_THROUGH", + "OPEN_EXISTING", "ReadFile(file.get(), observed.data()", + "observed != record", "trailingRead != 0", + "state->previousDigest = digest", "++state->sequence") + if strings.Contains(journalWriterSource, "MOVEFILE_REPLACE_EXISTING") { + t.Error("driver helper append-only journal can replace a published record") + } + journalLoadSource := sourceRegion("journal chain loader", + "bool LoadInstallJournal(", "bool RetireLoadedInstallJournal(", false) + assertOrdered("journal hash-chain validation", journalLoadSource, + "std::string priorDigest(kZeroSha256)", + "ParseInstallJournalEnvelope(", + "parsed.sequence != expectedSequence", + "parsed.previousDigest.c_str(), priorDigest.c_str()", + "priorDigest = digest") + + journalPrepareSource := sourceRegion("install journal preparation", + "bool InstallJournal::Prepare(", "bool InstallJournal::Record(", false) + assertOrdered("protected evidence before Prepared", journalPrepareSource, + "BackupPackagesIntoDirectory(", "CopyCandidateIntoInstallJournal(", + "impl_->state.prior = prior;", "impl_->state.candidate = candidate;", + "impl_->state.phase = InstallJournalPhase::Prepared;", + "WriteInstallJournalRecord(") + + journalRecordSource := sourceRegion("atomic journal record", + "bool InstallJournal::RecordNext(", "bool InstallJournal::RecordCutpoint(", false) + assertOrdered("atomic journal state publication", journalRecordSource, + "ValidateInstallJournalTransition(&impl_->state, next", + "WriteInstallJournalRecord(", "impl_->state = std::move(next);", + "PublishInstallRecoveryEvidence(") + if !strings.Contains(journalRecordSource, "impl_->poisoned = true") { + t.Error("driver helper can continue after an indeterminate journal append") + } + + brokerRunSource := sourceRegion("broker proof publication", + "bool RunBrokerInstall(", "Outcome Install(", false) + assertOrdered("durable broker authority publication", brokerRunSource, + "ParseBrokerCommitProof(", "RecordBrokerProof(proof", + "*driverRollbackAuthorized = proof.driverRollbackAuthorized;", + "*brokerChanged = proof.changed;") + + brokerAckSource := sourceRegion("broker settlement acknowledgement", + "bool AcknowledgeBrokerOuterSettlement(", + "bool DiscardBrokerSettlementTombstone(", false) + assertOrdered("outer settlement lock and journal order", brokerAckSource, + "outerMutex.VerifyHeldByOuterOwner(", + "transactionMutex.Acquire(", + "ReadProtectedBrokerSettlementRequest(", + "ParseBrokerSettlementRequest(", + "LoadSettlementInstallJournal(", + "ValidateBrokerSettlementJournalBinding(", + "AppendBrokerOuterSettled(", + "RetireInstallRecoveryActiveDirectory(") + for _, fragment := range []string{ + "error, true, &retiredPath", "BrokerOuterSettlementPending", + "BrokerOuterSettled", "requestSha256", + } { + if !strings.Contains(brokerAckSource, fragment) { + t.Errorf("driver helper broker settlement acknowledgement lost %q", fragment) + } + } + brokerDiscardSource := sourceRegion("broker settlement discard", + "bool DiscardBrokerSettlementTombstone(", + "void EmitBrokerSettlementAck(", false) + assertOrdered("authenticated atomic settlement discard", brokerDiscardSource, + "outerMutex.VerifyHeldByOuterOwner(", + "transactionMutex.Acquire(", + "ReadProtectedBrokerSettlementFinal(", + "ParseBrokerSettlementFinal(", + "ReadProtectedBrokerSettlementRequest(", + "ParseBrokerSettlementRequest(", + "ValidateBrokerSettlementFinalBinding(", + "OpenSettledInstallJournalDirectory(", + "OpenDiscardingInstallJournalDirectory(", + "ValidateBrokerSettlementFinalJournal(", + "RecoveryStateMatchesForward(", + "MoveFileExW(settled.c_str(), discarding.c_str()", + "MOVEFILE_WRITE_THROUGH", + "OpenStableDirectory(") + if strings.Contains(brokerDiscardSource, + "std::filesystem::remove_all(settled") { + t.Error("driver settlement discard deletes its authoritative tombstone in place") + } + for _, fragment := range []string{ + "driverPendingDigest", "brokerPendingDigest", "settlementNonce", + } { + if !strings.Contains(helperSource, fragment) { + t.Errorf("driver helper broker settlement binding lost %q", fragment) + } + } + + watchdogSource := sourceRegion("authoritative mutation watchdog", + "class SynchronousMutationWatchdog final", "class DeviceInfoSet final", false) + for _, fragment := range []string{ + "completion_.get(), waitMilliseconds", + "timedOut_.store(true", "WaitForSingleObject(completion_.get(), INFINITE)", + "thread_.join()", "gLastSynchronousMutationTimedOut = watchdog.Complete()", + } { + if !strings.Contains(watchdogSource, fragment) { + t.Errorf("driver helper authoritative watchdog lost %q", fragment) + } + } + for _, forbidden := range []string{ + "CancelIoEx(", "CancelSynchronousIo(", "TerminateThread(", + "TerminateProcess(", ".detach()", + } { + if strings.Contains(watchdogSource, forbidden) { + t.Errorf("driver helper authoritative watchdog contains forbidden cancellation %q", forbidden) + } + } + + installEntrySource := sourceRegion("install entry", + "Outcome Install(const InstallOptions& options)", "struct PackageBackup {", false) + assertOrdered("install pre-mutation reconciliation", installEntrySource, + "mutex.Acquire(", "ReconcileInstallJournal(", "ValidateCandidateInputs(", + "CaptureSnapshot(", "installJournal.Prepare(") + if strings.Count(installEntrySource, + "RemoveAuthorizedPriorEmptyRootAfterAdmission(") != 2 || + strings.Count(installEntrySource, + "VerifyPriorTopologyBeforePackageRollback(") != 2 || + strings.Count(installEntrySource, + "!prior.devices.empty() && bindingMutationStarted") != 2 { + t.Error("driver helper must apply receipt-bound root cleanup and strict post-removal proof in both in-process rollback branches without generic prior-empty deletion") + } + removeEntrySource := sourceRegion("remove entry", + "Outcome Remove(const RemoveOptions& options)", "Outcome Recover(", false) + assertOrdered("remove pre-mutation reconciliation", removeEntrySource, + "mutex.Acquire(", "ReconcileRemoveJournal(", "ReconcileInstallJournal(", + "CaptureSnapshot(", "PrepareRemoveJournal(", "ReconcileRemoveJournal(") + + removeRequired := []string{ + `kRemoveRecoveryRootDirectory[]`, `L"VIIPER-UdeCx-RemoveTransactions"`, + `kRemoveRecoveryActiveDirectory[] = L"active-v2"`, + "WriteRemoveJournalRecord(", `\"previousSha256\"`, `\"payloadSha256\"`, + "ValidateRemoveJournalTransition(", "loaded->poisoned = true", + "PrepareRemoveJournal(", "BackupPackagesIntoDirectory(", + "DeviceRemovalEntered", "DeviceRemovalReturned", "DeviceRemovalCommitted", + "PackageRemovalEntered", "PackageRemovalReturned", "PackageRemovalCommitted", + "RollbackAdmitted", "RollbackPackageEntered", "RollbackPackageReturned", + "RollbackPackageCommitted", "RollbackBindingEntered", "RollbackBindingReturned", + "ForwardRebootPending", "RestoreRebootPending", + "ManualReconciliationRequired", "pendingRebootBootIdentifier", + "ObserveRemoveRootShape(", "ObserveRemovePackagePrefix(", + "ObserveRemovePackageSubset(", "InvokeRemovePackageMutation(", + "InvokeRestorePackageMutation(", "CurrentRemoveStateIsUninstalled(", + "CurrentRemoveStateMatchesPrior(", "RetireRemoveRecoveryActiveDirectory(", + "RetireLoadedRemoveJournal(", "RunRemoveJournalModelSelfTest(", + "RunRemoveJournalRetirementSelfTest(", "RemoveExactCapturedDevice(", + "FreshRemoveRollbackDeadline(", + "CrossedRemoveRebootStillPendingRequiresManual(", + `warning=\"remove-settled-cleanup-retained\"`, + "ReconcileRemoveJournal(", + } + for _, fragment := range removeRequired { + if !strings.Contains(helperSource, fragment) { + t.Errorf("driver helper remove journal lost %q", fragment) + } + } + removePrepareSource := sourceRegion("remove journal preparation", + "bool PrepareRemoveJournal(", "enum class RemoveRootShape", false) + assertOrdered("remove protected evidence before Prepared", removePrepareSource, + "OpenChain(true", "PublishRemoveRecoveryEvidence(", + "BackupPackagesIntoDirectory(", "ValidateRemoveJournalTransition(nullptr", + "WriteRemoveJournalRecord(") + removeRecordSource := sourceRegion("remove atomic journal record", + "bool AppendRemoveJournalRecord(", "bool PrepareRemoveJournal(", true) + assertOrdered("remove atomic journal state publication", removeRecordSource, + "ValidateRemoveJournalTransition(", "WriteRemoveJournalRecord(", + "loaded->state = std::move(next);", "PublishRemoveRecoveryEvidence(") + if !strings.Contains(removeRecordSource, "loaded->poisoned = true") { + t.Error("driver helper can continue after an indeterminate remove append") + } + removeRetireSource := sourceRegion("loaded remove journal retirement", + "bool RetireLoadedRemoveJournal(", "bool AppendRemoveJournalRecord(", false) + assertOrdered("remove descendant evidence release immediately before rename", + removeRetireSource, + "RemoveJournalPhase::ForwardValidated", + "RemoveJournalPhase::ExactPriorRestored", + "const std::string transactionId", + "loaded->priorBackups.clear();", "loaded->evidenceLocks.clear();", + "return RetireRemoveRecoveryActiveDirectory(") + removePriorRetireSource := sourceRegion("remove prior terminal retirement", + "bool RetireRemoveJournalAsPrior(", + "bool RetireRemoveJournalAsUninstalled(", false) + assertOrdered("remove prior terminal double validation", removePriorRetireSource, + "CurrentRemoveStateMatchesPrior(", + "RemoveJournalPhase::ExactPriorRestored", "RecordRemoveJournalPhase(", + "CurrentRemoveStateMatchesPrior(", "RetireLoadedRemoveJournal(") + if !strings.Contains(removePriorRetireSource, + "if (outcome->error.recoveryBackup.empty())") { + t.Error("prior retirement overwrites exact tombstone failure evidence with absent active-v2") + } + removeForwardRetireSource := sourceRegion("remove forward terminal retirement", + "bool RetireRemoveJournalAsUninstalled(", "bool FailRemoveJournalManual(", false) + assertOrdered("remove forward terminal double validation", removeForwardRetireSource, + "CurrentRemoveStateIsUninstalled(", + "RemoveJournalPhase::ForwardValidated", "RecordRemoveJournalPhase(", + "CurrentRemoveStateIsUninstalled(", "RetireLoadedRemoveJournal(") + if !strings.Contains(removeForwardRetireSource, + "if (outcome->error.recoveryBackup.empty())") { + t.Error("forward retirement overwrites exact tombstone failure evidence with absent active-v2") + } + removeManualSource := sourceRegion("remove manual evidence retention", + "bool FailRemoveJournalManual(", + "bool ReturnRemoveJournalRebootPending(", false) + if !strings.Contains(removeManualSource, "!cause->recoveryBackup.empty()") || + strings.Contains(removeManualSource, "RetireLoadedRemoveJournal(") { + t.Error("manual recovery does not preserve callee tombstone evidence or releases terminal locks") + } + + removeDeviceSource := sourceRegion("single captured device removal", + "bool RemoveExactCapturedDevice(", "bool RegisterRootDevice(", false) + assertOrdered("single captured device immutable revalidation", removeDeviceSource, + "FindExactDevices(", "LoadOwnedPackage(", + "IsExactCapturedRemoveTarget(", "return RemoveDevice(") + if strings.Contains(helperSource, "RemoveAllExactDevices(") { + t.Error("driver helper retained forbidden broad all-device remove plumbing") + } + + removeRollbackSource := sourceRegion("remove rollback recovery", + "bool RunRemoveRollbackRecovery(", "bool AdmitRemoveRollback(", false) + assertOrdered("interrupted binding admission reuse", removeRollbackSource, + "ReusesInterruptedRemoveBindingAdmission(", + "!reusingInterruptedBindingAdmission", + "RemoveJournalPhase::RollbackBindingEntered", "ObserveRemoveRootShape(", + "VerifyPackageInventory(", "RestorePriorBinding(") + assertOrdered("remove rollback uses exact-absence binding authority", + removeRollbackSource, "ObserveRemoveRootShape(", + "root != RemoveRootShape::Absent", "VerifyPackageInventory(", + "RestorePriorBinding(restorable,", + "RestorePriorBindingPolicy::RemoveJournalExactAbsence") + restoreBindingSource := sourceRegion("prior binding restore policy", + "bool RestorePriorBinding(", "bool RollbackInstall(", false) + assertOrdered("remove exact-absence race fails before mutation", + restoreBindingSource, "CaptureSnapshot(", + "RestorePriorBindingTopologyAdmitsMutation(", + "RestorePriorBindingPolicy::InstallRollbackReconcile &&", + "RemoveDevice(", "RegisterRootDeviceExact(", + "InstallPreinstalledDriverOnDevice(") + if !strings.Contains(restoreBindingSource, + "policy == RestorePriorBindingPolicy::RemoveJournalExactAbsence") || + !strings.Contains(helperSource, + `L"self-test-remove-journal-binding-exact-absence-race"`) { + t.Error("remove rollback lost its explicit zero-mutation exact-absence race policy/model") + } + removeAdmissionSource := sourceRegion("remove rollback admission", + "bool AdmitRemoveRollback(", "bool RunRemoveForwardRecovery(", false) + assertOrdered("durable rollback admission and fresh deadline", removeAdmissionSource, + "RemoveJournalPhase::RestoreRebootPending", "RecordRemoveJournalPhase(", + "FreshRemoveRollbackDeadline();", "RunRemoveRollbackRecovery(") + if strings.Contains(removeAdmissionSource, "deadlineUnixMs") { + t.Error("forward-to-rollback admission accepts or reuses the exhausted forward deadline") + } + removeForwardSource := sourceRegion("remove forward recovery", + "bool RunRemoveForwardRecovery(", "bool ReconcileRemoveJournal(", false) + assertOrdered("crossed reboot loop fails closed", removeForwardSource, + "CrossedRemoveRebootStillPendingRequiresManual(", + "FailRemoveJournalManual(", "ReturnRemoveJournalRebootPending(") + crossedRebootSource := sourceRegion("crossed remove reboot decision", + "bool CrossedRemoveRebootStillPendingRequiresManual(", + "bool ReusesInterruptedRemoveBindingAdmission(", false) + for _, fragment := range []string{ + "RemoveJournalPhase::DeviceRemovalReturned", "callSucceeded", + "freshRebootRequired", "!samePendingBoot", + } { + if !strings.Contains(crossedRebootSource, fragment) { + t.Errorf("returned-to-pending crossed reboot decision lost %q", fragment) + } + } + if !strings.Contains(helperSource, + `L"self-test-remove-journal-device-returned-pending-cut"`) { + t.Error("driver helper lost the compiled DeviceRemovalReturned-to-pending crash cut test") + } + if !strings.Contains(removeForwardSource, "RemoveExactCapturedDevice(") || + strings.Contains(removeForwardSource, "RemoveAllExactDevices(") { + t.Error("protected forward removal is not confined to one immutable captured root") + } + + removeRawRetireSource := sourceRegion("remove raw terminal retirement", + "bool RetireRemoveRecoveryActiveDirectory(", + "struct RemoveJournalStateData {", false) + assertOrdered("remove tombstone retirement and warning evidence", removeRawRetireSource, + "MoveFileExW(", "error->recoveryBackup = tombstone.wstring();", + "ClearActiveRecoveryEvidence();", "std::filesystem::remove_all(", + "gRetainedRemoveTombstoneError", "OutputDebugStringW(") + removeReconcileSource := sourceRegion("remove startup reconciliation", + "bool ReconcileRemoveJournal(", "struct RemoveOptions {", true) + for _, fragment := range []string{ + "LoadRemoveJournal(", "InstallRecoveryDirectory installDirectory", + "installExists", "ManualReconciliationRequired", "GetBootIdentifier(", + "RunRemoveRollbackRecovery(", "RunRemoveForwardRecovery(", + } { + if !strings.Contains(removeReconcileSource, fragment) { + t.Errorf("driver helper remove reconciliation lost %q", fragment) + } + } + + reconcileSource := sourceRegion("startup journal reconciliation", + "bool ReconcileInstallJournal(", "const char* RemoveJournalPhaseName(", true) + for _, fragment := range []string{ + "ForwardRebootPending && sameBoot", "RestoreRebootPending && sameBoot", + "return rebootPending(", + "loaded.state.phase == InstallJournalPhase::BrokerChildEntered", + "loaded.state.phase == InstallJournalPhase::BrokerHandoffReturned", + "loaded.state.rollbackAuthorized", "loaded.state.hasBrokerProof", + "loaded.state.brokerProofSuccess", + "no mutation was attempted", "InstallJournalPhase::ManualReconciliationRequired", + "appendPartialRootRemovalEntered", + "install-journal-partial-root-removal-inventory", + "install-journal-pre-package-rollback-inventory", + "ClassifyPartialRootRemovalJournalRecovery(", + } { + if !strings.Contains(reconcileSource, fragment) { + t.Errorf("driver helper startup reconciliation lost %q", fragment) + } + } + + type phaseWrappedAPI struct { + name, start, end, entered, wrapper, api, returned string + } + phaseWrappedAPIs := []phaseWrappedAPI{ + {"SetupCopyOEMInfW", "bool StageCandidatePackage(", "bool RemoveDevice(", + "SetupCopyEntered", "InvokeAuthoritativeSynchronousMutation(", + "SetupCopyOEMInfW(", "SetupCopyReturned"}, + {"DiInstallDevice", "bool CommitPreparedDriverBinding(", + "bool InstallPreinstalledDriverOnDevice(", "DiInstallEntered", + "InvokeAuthoritativeSynchronousMutation(", "DiInstallDevice(", "DiInstallReturned"}, + {"SetupUninstallOEMInfW", "bool RemoveStagedCandidateExact(", + "bool RestorePriorBinding(", "SetupUninstallEntered", + "InvokeAuthoritativeSynchronousMutation(", "SetupUninstallOEMInfW(", + "SetupUninstallReturned"}, + {"broker quiescence signal", "bool RequestBrokerQuiescence(", + "bool SignalBrokerHandoff(", "QuiesceSignalEntered", "", + "SetEvent(options.brokerQuiesceRequest)", "QuiesceSignalReturned"}, + {"broker handoff signal", "bool SignalBrokerHandoff(", + "bool ValidateTransactionDeadlineBudget(", "BrokerHandoffEntered", "", + "SetEvent(options.brokerHandoff)", "BrokerHandoffReturned"}, + {"broker child", "bool RunBrokerInstall(", "Outcome Install(", + "BrokerChildEntered", "", "CreateProcessW(", "BrokerChildSettled"}, + } + for _, contract := range phaseWrappedAPIs { + region := sourceRegion("phase-wrapped "+contract.name, + contract.start, contract.end, false) + fragments := []string{contract.entered} + if contract.wrapper != "" { + fragments = append(fragments, contract.wrapper) + } + fragments = append(fragments, contract.api, contract.returned) + assertOrdered("phase-wrapped "+contract.name, region, fragments...) + } + for _, registration := range []struct { + name, start, end string + }{ + {"forward root registration", "bool RegisterRootDevice(", + "bool DriverInfoUsesPublishedPackage("}, + {"restore root registration", "bool RegisterRootDeviceExact(", + "bool IssueAbiNegotiation("}, + } { + region := sourceRegion(registration.name, registration.start, registration.end, false) + entered := strings.Index(region, "RootRegistrationEntered") + mutation := strings.Index(region, "SetupDiCallClassInstaller(") + returned := strings.LastIndex(region, "RootRegistrationReturned") + if entered < 0 || mutation <= entered || returned <= mutation || + !strings.Contains(region[entered:mutation], "InvokeAuthoritativeSynchronousMutation(") { + t.Errorf("driver helper %s is not enclosed by authoritative entered/returned phases", registration.name) + } + } + forwardRegistrationSource := sourceRegion("forward root registration receipt", + "bool RegisterRootDevice(", "bool DriverInfoUsesPublishedPackage(", false) + assertOrdered("generated root receipt before every registration mutation", + forwardRegistrationSource, + "SetupDiCreateDeviceInfoW(", "DICD_GENERATE_ID", + "SetupDiGetDeviceInstanceIdW(", + "RecordActiveInstallJournalRootRegistrationIntent(", + "InstallJournalPhase::RootRegistrationEntered", + "SetupDiSetDeviceRegistryPropertyW(", + "SetupDiCallClassInstaller(", "DIF_REGISTERDEVICE", + "InstallJournalPhase::RootRegistrationReturned") + + partialRootSource := sourceRegion("in-process receipt-bound partial root removal", + "bool InstallJournal::RemoveAuthorizedPriorEmptyRootAfterAdmission(", + "bool CurrentRootIsAuthorizedForInstallRollback(", false) + assertOrdered("partial root removal write-ahead and authoritative return", + partialRootSource, + "InstallJournalPhase::PartialRootRemovalEntered", + "VerifyPackageInventory(", "observe(false, &confirmed", "RemoveDevice(", + "RecordAuthoritativeReturn(", + "InstallJournalPhase::PartialRootRemovalReturned", "observe(true, &after") + for _, fragment := range []string{ + "RemoveUnboundExactRoot", "RemoveCandidateBoundExactRoot", + "PendingExactRootRemoval", "freshRemovalReboot", + "rootRemovalRebootPending", + } { + if !strings.Contains(partialRootSource, fragment) { + t.Errorf("driver helper partial root removal lost %q", fragment) + } + } + + rawRootSource := sourceRegion("broad raw root topology observer", + "bool ObservePriorEmptyInstallRecoveryRoot(", + "bool VerifyInstallJournalRawPriorTopology(", false) + for _, fragment := range []string{ + "ReadInstallRecoveryHardwareIds(", + "IsInGeneratedRootDeviceNamespace(", "hardwareIds.containsExpected", + "related.size() == 1U", "loaded.state.hasRootRegistrationIntent", + "loaded.state.rootRegistrationInstanceId.c_str()", + "ReadCanonicalInstallRecoveryService(", + "ReadCanonicalInstallRecoveryDevicePropertyString(", + "CM_PROB_WILL_BE_REMOVED", "ClassifyPartialInstallRootRecovery(", + } { + if !strings.Contains(rawRootSource, fragment) { + t.Errorf("driver helper broad raw root observer lost %q", fragment) + } + } + + openChainSource := sourceRegion("install recovery directory chain", + " bool OpenChain(", "};", false) + assertOrdered("product-only recovery discovery", openChainSource, + "bool productExists = false;", "bool componentExists = false;", + "bool transactionsExist = false;", "bool activeExists = false;", + "if (!productExists) return true;", + "if (!componentExists) return true;", + "if (!transactionsExist) return true;", + "*exists = InstallRecoveryChainHasActive(") + for _, fragment := range []string{ + "BuildInstallRecoveryProductDirectorySecurity(", "*exactTargetUserSid", + "CreateOrOpenInstallRecoveryDirectoryWithSecurity(", + "VerifyProtectedProductDirectorySecurity(", "exactTargetUserSid", + } { + if !strings.Contains(openChainSource, fragment) { + t.Errorf("driver helper install recovery chain lost %q", fragment) + } + } + + forwardRetireSource := sourceRegion("forward journal retirement", + "bool InstallJournal::RetireAfterForwardValidation(", + "bool InstallJournal::RetireAfterPriorValidation(", false) + forwardPendingEnd := strings.Index(forwardRetireSource, "std::string expectedBuildIdentity") + if forwardPendingEnd < 0 { + t.Fatal("driver helper forward reboot-pending branch is missing") + } + forwardPending := forwardRetireSource[:forwardPendingEnd] + if !strings.Contains(forwardPending, "InstallJournalPhase::ForwardRebootPending") || + strings.Contains(forwardPending, "remove_all(") || + strings.Contains(forwardPending, "ClearActiveRecoveryEvidence(") { + t.Error("driver helper forward reboot-pending path does not retain journal evidence") + } + priorRetireSource := sourceRegion("prior journal retirement", + "bool InstallJournal::RetireAfterPriorValidation(", + "bool RequireJournalObject(", false) + priorPendingEnd := strings.Index(priorRetireSource, "const auto validatePrior") + if priorPendingEnd < 0 { + t.Fatal("driver helper restore reboot-pending branch is missing") + } + priorPending := priorRetireSource[:priorPendingEnd] + if !strings.Contains(priorPending, "InstallJournalPhase::RestoreRebootPending") || + strings.Contains(priorPending, "remove_all(") || + strings.Contains(priorPending, "ClearActiveRecoveryEvidence(") { + t.Error("driver helper restore reboot-pending path does not retain journal evidence") + } + for _, obsolete := range []string{ + "--expected-token-sha256", "--expected-broker-sha256", + } { + if strings.Contains(helperSource, obsolete) { + t.Errorf("driver helper retained obsolete nested broker option %q", obsolete) + } + } + if !strings.Contains(helperSource, "InstallPreinstalledDriverOnDevice(") || + !strings.Contains(helperSource, "DiInstallDevice(") { + t.Error("driver helper lost exact preinstalled-driver selection and DiInstallDevice binding") + } + installStart := strings.Index(helperSource, "Outcome Install(const InstallOptions& options)") + installEnd := strings.Index(helperSource, "struct PackageBackup {") + if installStart < 0 || installEnd <= installStart { + t.Fatal("driver helper forward install transaction is missing or malformed") + } + forwardInstall := helperSource[installStart:installEnd] + if strings.Contains(forwardInstall, "InstallJournalPhase::DiInstallReturned") { + t.Error("forward install synthesizes DiInstallReturned outside the actual API wrapper") + } + if !strings.Contains(forwardInstall, "InstallJournalPhase::StageReceiptCaptured") { + t.Error("forward install lost the distinct exact stage-receipt phase") + } + for _, forbidden := range []string{ + "DiInstallDriverW(", "UpdateDriverForPlugAndPlayDevicesW(", + `L"upgrade-deadline-before-device-removal"`, + "ExactRootRegistrationMode", "RegisterRootDeviceExact(", + } { + if strings.Contains(forwardInstall, forbidden) { + t.Errorf("forward install retained remove/recreate or device-auto-binding operation %q", forbidden) + } + } + quiescenceStart := strings.Index(helperSource, + "bool RequestBrokerQuiescence(const InstallOptions& options") + quiescenceEnd := strings.Index(helperSource, + "bool SignalBrokerHandoff(") + if quiescenceStart < 0 || quiescenceEnd <= quiescenceStart { + t.Fatal("broker quiescence implementation is missing or malformed") + } + quiescenceSource := helperSource[quiescenceStart:quiescenceEnd] + quiescenceDeadline := strings.Index(quiescenceSource, + `L"transaction-deadline-before-broker-quiescence"`) + quiescenceSignal := strings.Index(quiescenceSource, + "SetEvent(options.brokerQuiesceRequest)") + if quiescenceDeadline < 0 || quiescenceSignal <= quiescenceDeadline { + t.Error("broker quiescence can signal a healthy broker after the package deadline") + } + helperStageStart := strings.Index(helperSource, "bool StageCandidatePackage(") + helperStageEnd := strings.Index(helperSource, "bool RemoveDevice(") + if helperStageStart < 0 || helperStageEnd <= helperStageStart { + t.Fatal("add-only package staging implementation is missing or malformed") + } + stageSource := helperSource[helperStageStart:helperStageEnd] + stageMutationMark := strings.Index(stageSource, "MarkTransactionMutationStarted();") + setupCopy := strings.Index(stageSource, "SetupCopyOEMInfW(") + stageOwnership := strings.Index(stageSource, "*stagedHere = true;") + receiptValidation := strings.Index(stageSource, "const size_t destinationLength") + receiptRecovery := strings.Index(stageSource, "recoveredReceipt") + if stageMutationMark < 0 || setupCopy <= stageMutationMark || + stageOwnership <= setupCopy || receiptValidation <= stageOwnership || + receiptRecovery <= receiptValidation { + t.Error("SetupCopy fault interleavings no longer preserve mutation classification, success-only ownership, and exact receipt recovery") + } + commitStart := strings.Index(helperSource, "bool CommitPreparedDriverBinding(") + commitEnd := strings.Index(helperSource, "bool InstallPreinstalledDriverOnDevice(") + if commitStart < 0 || commitEnd <= commitStart { + t.Fatal("prepared selected-device binding commit is missing or malformed") + } + commitSource := helperSource[commitStart:commitEnd] + commitDeadline := strings.Index(commitSource, + `L"transaction-deadline-before-selected-device-binding"`) + commitMutation := strings.Index(commitSource, "MarkTransactionMutationStarted();") + commitSelection := strings.Index(commitSource, "SetupDiSetSelectedDriverW(") + commitInstall := strings.Index(commitSource, "DiInstallDevice(") + if commitDeadline < 0 || commitMutation <= commitDeadline || + commitSelection <= commitMutation || commitInstall <= commitSelection || + strings.Contains(commitSource[commitSelection:commitInstall], + "CheckTransactionDeadline(") { + t.Error("prepared binding commit no longer performs one deadline check followed immediately by selected-driver and DiInstallDevice mutation") + } + abiHealthStart := strings.Index(helperSource, "bool VerifyAbiHealth(") + abiHealthEnd := strings.Index(helperSource, "bool VerifyInstalledBinding(") + if abiHealthStart < 0 || abiHealthEnd <= abiHealthStart { + t.Fatal("ABI health implementation is missing or malformed") + } + abiHealthSource := helperSource[abiHealthStart:abiHealthEnd] + for _, fragment := range []string{ + "profiles = kAbiCompatibilityProfiles.data();", + "profileCount = kAbiCompatibilityProfiles.size();", + "IssueAbiNegotiation(device.get(), deadlineUnixMs, profiles[index].minor", + "IsAbiRetryEligible(purpose, expectedBuildIdentity, *error)", + "AbiNegotiationResponseMatchesProfile(", + "StatsRecordMatchesProfile(", + } { + if !strings.Contains(abiHealthSource, fragment) { + t.Errorf("bounded ABI negotiation lost %q", fragment) + } + } + mutationDecision := strings.Index(forwardInstall, + "const bool driverMutation =") + stageCall := strings.Index(forwardInstall, "StageCandidatePackage(") + stageInventory := strings.Index(forwardInstall, + `L"stage-package-inventory-verification"`) + stageRootProof := strings.Index(forwardInstall, `L"stage-root-binding-verification"`) + quiesce := strings.Index(forwardInstall, "RequestBrokerQuiescence(options") + postQuiesceInventory := strings.Index(forwardInstall, + `L"post-quiescence-package-inventory-verification"`) + postQuiesceRootProof := strings.Index(forwardInstall, + `L"post-quiescence-root-verification"`) + pristineDecision := strings.Index(forwardInstall, + "const bool requiresPristineRuntimeProof =") + pristineProof := -1 + if pristineDecision >= 0 { + if relative := strings.Index(forwardInstall[pristineDecision:], + "AbiHealthPurpose::PristineUpgrade"); relative >= 0 { + pristineProof = pristineDecision + relative + } + } + prepareBinding := strings.Index(forwardInstall, + "PreparePreinstalledDriverOnDevice(") + finalTopology := -1 + finalInventory := -1 + finalRootProof := -1 + finalPristineProof := -1 + commitBinding := -1 + if prepareBinding >= 0 { + if relative := strings.Index(forwardInstall[prepareBinding:], + `L"final-pre-bind-root-topology-verification"`); relative >= 0 { + finalTopology = prepareBinding + relative + } + if relative := strings.Index(forwardInstall[prepareBinding:], + `L"final-pre-bind-package-inventory-verification"`); relative >= 0 { + finalInventory = prepareBinding + relative + } + if relative := strings.Index(forwardInstall[prepareBinding:], + `L"final-pre-bind-root-verification"`); relative >= 0 { + finalRootProof = prepareBinding + relative + } + if relative := strings.Index(forwardInstall[prepareBinding:], + "AbiHealthPurpose::PristineRecheck"); relative >= 0 { + finalPristineProof = prepareBinding + relative + } + if relative := strings.Index(forwardInstall[prepareBinding:], + "CommitPreparedDriverBinding("); relative >= 0 { + commitBinding = prepareBinding + relative + } + } + postBindInventory := strings.Index(forwardInstall, + `L"post-bind-package-inventory-verification"`) + if mutationDecision < 0 || stageCall <= mutationDecision || + stageInventory <= stageCall || stageRootProof <= stageInventory || + quiesce <= stageRootProof || postQuiesceInventory <= quiesce || + postQuiesceRootProof <= postQuiesceInventory || + pristineDecision <= postQuiesceRootProof || pristineProof <= pristineDecision || + prepareBinding <= pristineProof || finalTopology <= prepareBinding || + finalInventory <= finalTopology || + finalRootProof <= finalInventory || finalPristineProof <= finalRootProof || + commitBinding <= finalPristineProof || postBindInventory <= commitBinding { + t.Error("driver replacement no longer orders add-only stage, exact inventory/root proof, broker quiescence, pristine admission, read-only driver preparation, final inventory/root/pristine proof, immediate in-place binding, and post-bind inventory proof") + } + if finalPristineProof >= 0 && commitBinding > finalPristineProof { + finalProofToCommit := forwardInstall[finalPristineProof:commitBinding] + for _, forbidden := range []string{ + "CaptureSnapshot(", "CaptureAndVerify", "FindExactDevices(", + "PreparePreinstalledDriverOnDevice(", "VerifyPackageInventory(", + "SetupDiBuildDriverInfoList(", + } { + if strings.Contains(finalProofToCommit, forbidden) { + t.Errorf("fallible operation %q reopened the final pristine-proof to bind window", forbidden) + } + } + } + rollbackStart := strings.Index(helperSource, "bool RollbackInstall(") + rollbackEnd := strings.Index(helperSource, "bool LockPackageFiles(") + if rollbackStart < 0 || rollbackEnd <= rollbackStart { + t.Fatal("driver helper install rollback is missing or malformed") + } + installRollback := helperSource[rollbackStart:rollbackEnd] + restoreBinding := strings.Index(installRollback, "RestorePriorBinding(") + removeStaged := strings.Index(installRollback, "RemoveStagedCandidateExact(") + verifyInventory := strings.Index(installRollback, "VerifyPackageInventory(") + if restoreBinding < 0 || removeStaged <= restoreBinding || + verifyInventory <= removeStaged { + t.Error("install rollback no longer restores a mutated binding before exact staged-here cleanup and prior-inventory proof") + } + rollbackStarted := strings.Index(installRollback, + "if (prior.devices[0].started)") + rollbackStartedProof := strings.Index(installRollback, + `L"rollback-runtime-start-verification"`) + rollbackProfileProof := strings.Index(installRollback, + `L"rollback-runtime-abi-profile"`) + rollbackHealth := strings.Index(installRollback, + "AbiHealthPurpose::RollbackHealth") + rollbackStoppedComparator := strings.LastIndex(installRollback, + "RollbackLifecycleStateMatches(") + rollbackStoppedProof := strings.Index(installRollback, + `L"rollback-stopped-state-verification"`) + if rollbackStarted <= verifyInventory || rollbackStartedProof <= rollbackStarted || + rollbackProfileProof <= rollbackStartedProof || rollbackHealth <= rollbackProfileProof || + rollbackStoppedComparator <= rollbackHealth || + rollbackStoppedProof <= rollbackStoppedComparator { + t.Error("rollback no longer proves started/problem-zero plus ABI health for a formerly-running root and exact stopped/problem state for a captured stopped root") + } + for _, decision := range []string{ + "CandidateDisposition::Exact, true, true, true", + "CandidateDisposition::InstallRequired, false, false, false", + "CandidateDisposition::Exact, false, true, false", + } { + if !strings.Contains(helperSource, decision) { + t.Errorf("driver helper lost pristine-runtime decision case %q", decision) + } + } + if strings.Contains(windowsSource, `strings.Contains(text, "result=success operation=install")`) { + t.Error("native package install must parse one exact helper outcome instead of accepting a success substring") + } + imageIntent := strings.Index(windowsSource, "nativeBrokerPhaseImageSwitchIntent") + atomicReplace := strings.Index(windowsSource, + "replaceNativePackageFileAtomically(t.temporaryPath, t.destination, priorExists)") + imageSettled := strings.Index(windowsSource, "nativeBrokerPhaseImageSwitched") + if imageIntent < 0 || atomicReplace <= imageIntent || imageSettled <= atomicReplace { + t.Error("native package image replacement lost intent -> atomic replace -> settled ordering") + } + for _, fragment := range []string{ + "copyNativeBrokerJournalImage(", "nativeBrokerJournalPriorImageName", + "appendPhase(nativeBrokerPhasePrepared", "REPLACEFILE_WRITE_THROUGH", + } { + if !strings.Contains(brokerJournalSource+windowsSource, fragment) { + t.Errorf("native broker durable image transaction lost %q", fragment) + } + } + requiredTransaction := []string{ + "transaction.Preflight(ctx)", "transaction.InspectService(ctx)", + "prepared = true", "transaction.Prepare(ctx, service)", + "transaction.InstallDriverAndBroker(ctx)", + "transaction.VerifyAuthenticatedHealth(ctx)", "transaction.Commit(ctx)", + "nativePackageRollbackTimeout", "context.WithoutCancel(ctx)", + } + for _, fragment := range requiredTransaction { + if !strings.Contains(transactionSource, fragment) { + t.Errorf("package transaction lost %q", fragment) + } + } + for _, fragment := range []string{ + "transaction.LockPackage(ctx)", "transaction.LockService(ctx)", + "transaction.Preflight(ctx)", "transaction.InspectService(ctx)", + "restoreArmed = snapshot.exists", "transaction.StopService(ctx, snapshot)", + "transaction.RemoveDriver(ctx)", "serviceRestoreVerified", + "deliberately left stopped", "transaction.RestoreService(rollbackCtx, snapshot)", + "driverRemovalSucceeded = true", "transaction.Cleanup(cleanupCtx, snapshot)", + "nativePackageUninstallRebootRequiredError", + } { + if !strings.Contains(uninstallTransactionSource, fragment) { + t.Errorf("package uninstall transaction lost %q", fragment) + } + } + for _, fragment := range []string{ + "parseNativePackageRemoveProofFields(", + "parseOptionalNativePackageRemoveWarning(", + "nativePackageRemoveRetainedTombstoneMaximumRunes", + "validateNativePackageRemoveRetainedTombstone(", + "retainedTombstoneWin32Error", + `logger.Warn("Native remove journal retired with a retained settled tombstone"`, + } { + if !strings.Contains(uninstallTransactionSource, fragment) { + t.Errorf("native remove warning proof channel lost %q", fragment) + } + } + warningParserStart := strings.Index(uninstallTransactionSource, + "func parseOptionalNativePackageRemoveWarning(") + warningParserEnd := strings.Index(uninstallTransactionSource, + "func parseNativePackageRemoveProof(") + if warningParserStart < 0 || warningParserEnd <= warningParserStart { + t.Fatal("native remove warning parser source region is missing or malformed") + } + warningParserSource := uninstallTransactionSource[warningParserStart:warningParserEnd] + assertOrdered("native remove warning tuple", + warningParserSource, `"warning"`, + `"warningWin32Error"`, `"retainedTombstone"`, + "validateNativePackageRemoveRetainedTombstone(", + "*position != len(fields)") + if strings.Contains(uninstallTransactionSource, `(?: .*)?`) { + t.Error("native remove proof parser still accepts an arbitrary trailing field wildcard") + } + if !strings.Contains(serviceSource, "func acquireNativeInstallMutex(") { + t.Error("native broker service mutex wrapper was removed") + } + for _, fragment := range []string{ + "CreateBoundaryDescriptorW", "AddSIDToBoundaryDescriptor", + "CreatePrivateNamespaceW", "OpenPrivateNamespaceW", + "windows.WinBuiltinAdministratorsSid", "nativeMutexObjectSDDL", + "runtime.LockOSThread()", "runtime.UnlockOSThread()", + } { + if !strings.Contains(mutexSource, fragment) { + t.Errorf("shared native private mutex namespace lost %q", fragment) + } + } + for name, source := range map[string]string{ + "Windows package orchestrator": windowsSource, + "Windows package uninstall orchestrator": uninstallWindowsSource, + "driver helper": helperSource, + } { + for _, forbidden := range []string{ + "TerminateProcess(", "exec.CommandContext(", "os.RemoveAll(", + } { + if strings.Contains(source, forbidden) { + t.Errorf("%s contains unsafe %q", name, forbidden) + } + } + } + if strings.Contains(uninstallWindowsSource, ".Process.Kill(") { + t.Error("package uninstall must not hard-kill the mutating driver helper") + } + if strings.Contains(uninstallWindowsSource, "lockNativePriorServiceExecutable(path)") { + t.Error("package uninstall must not retain a non-delete-shared broker leaf before its exact DELETE-capable snapshot") + } + for _, forbidden := range []string{ + "removeLegacy", "snapshotLegacy", "scheduled task", "RunVIIPER", "usbip", + } { + if strings.Contains(uninstallWindowsSource, forbidden) { + t.Errorf("package uninstall must not mutate unrelated legacy ownership; found %q", forbidden) + } + } + if strings.Contains(helperSource, "WaitForSingleObject(processHandle.get(), INFINITE)") { + t.Error("driver helper retained an unbounded nested broker wait") + } + runtimeStart := strings.Index(helperSource, "bool LockPackageFiles(") + runtimeEnd := strings.Index(helperSource, "struct InstallOptions") + if runtimeStart < 0 || runtimeEnd <= runtimeStart { + t.Fatal("could not isolate the helper runtime-package contract") + } + runtimeContract := helperSource[runtimeStart:runtimeEnd] + if strings.Contains(runtimeContract, "ViiperUde.pdb") { + t.Error("driver helper retained a certification-PDB runtime dependency") + } + if !strings.Contains(runtimeContract, + `L"ViiperUde.inf", L"ViiperUde.sys", L"ViiperUde.cat"`) { + t.Error("driver helper lost the exact INF/SYS/CAT runtime package contract") + } + if !strings.Contains(helperSource[:runtimeStart], `"ViiperUde.pdb"`) { + t.Error("driver helper stopped binding the certification PDB in the source manifest") + } + for _, forbidden := range []string{"removeLegacy", "usbip"} { + if strings.Contains(windowsSource, forbidden) { + t.Errorf("outer package transaction must leave legacy ownership to the authenticated broker commit; found %q", forbidden) + } + } +} + +func readNativePackageContractFile(t *testing.T, path string) string { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return strings.ReplaceAll(string(content), "\r\n", "\n") +} diff --git a/internal/cmd/native_package_other.go b/internal/cmd/native_package_other.go new file mode 100644 index 00000000..4ba23256 --- /dev/null +++ b/internal/cmd/native_package_other.go @@ -0,0 +1,21 @@ +//go:build !windows + +package cmd + +import ( + "context" + "errors" + "log/slog" +) + +func installNativePackage(context.Context, *slog.Logger, nativePackageRequest) error { + return errors.New("native UDE package installation is supported only on Windows") +} + +func commitNativePackageBroker( + *slog.Logger, string, string, string, string, string, bool, +) (nativePackageBrokerCommitResult, error) { + return nativePackageBrokerPreflightFailure( + errors.New("native UDE package installation is supported only on Windows"), + ) +} diff --git a/internal/cmd/native_package_process_windows.go b/internal/cmd/native_package_process_windows.go new file mode 100644 index 00000000..e745b641 --- /dev/null +++ b/internal/cmd/native_package_process_windows.go @@ -0,0 +1,185 @@ +//go:build windows + +package cmd + +import ( + "errors" + "fmt" + "os" + "os/exec" + "time" + + "golang.org/x/sys/windows" +) + +const nativePackageProcessJoinRetry = 10 * time.Millisecond + +type nativePackageProcessJoin struct { + handle windows.Handle + wait func(windows.Handle, uint32) (uint32, error) + close func(windows.Handle) error + retry func() +} + +type nativePackageProcessWaitIndeterminateError struct { + cause error +} + +func (e *nativePackageProcessWaitIndeterminateError) Error() string { + return "native package helper process result is indeterminate after independently joining termination: " + + e.cause.Error() +} + +func (e *nativePackageProcessWaitIndeterminateError) Unwrap() error { + return e.cause +} + +// retainNativePackageProcessJoin duplicates Go's exact process handle before +// exec.Cmd.Wait can release it. The duplicate is intentionally wait-only: it +// exists solely to keep the package/service transaction alive until the exact +// mutating child process is signaled. +func retainNativePackageProcessJoin(process *os.Process) (*nativePackageProcessJoin, error) { + if process == nil { + return nil, errors.New("native package helper has no process") + } + var retained windows.Handle + var duplicateErr error + if err := process.WithHandle(func(handle uintptr) { + duplicateErr = windows.DuplicateHandle( + windows.CurrentProcess(), windows.Handle(handle), + windows.CurrentProcess(), &retained, + windows.SYNCHRONIZE, false, 0, + ) + }); err != nil { + if retained != 0 { + windows.CloseHandle(retained) //nolint:errcheck + } + return nil, fmt.Errorf("retain native package helper process handle: %w", err) + } + if duplicateErr != nil { + if retained != 0 { + windows.CloseHandle(retained) //nolint:errcheck + } + return nil, fmt.Errorf("duplicate native package helper process handle: %w", duplicateErr) + } + if retained == 0 { + return nil, errors.New("duplicate native package helper process returned a null handle") + } + return &nativePackageProcessJoin{ + handle: retained, + wait: windows.WaitForSingleObject, + close: windows.CloseHandle, + retry: func() { + time.Sleep(nativePackageProcessJoinRetry) + }, + }, nil +} + +// complete independently joins the retained process object before releasing +// its handle. A non-ExitError from Cmd.Wait cannot establish an exit status, +// so it remains an indeterminate transaction failure even after the child is +// proven terminated. Wait anomalies are retried while the handle and outer +// transaction scope remain held; this function never returns unjoined. +func (j *nativePackageProcessJoin) complete(commandWaitErr error) error { + if j == nil || j.handle == 0 || j.wait == nil || j.close == nil || j.retry == nil { + return &nativePackageProcessWaitIndeterminateError{ + cause: errors.New("native package helper process join is unavailable"), + } + } + + var joinAnomaly error + for { + status, err := j.wait(j.handle, windows.INFINITE) + if err == nil && status == windows.WAIT_OBJECT_0 { + break + } + if joinAnomaly == nil { + if err != nil { + joinAnomaly = fmt.Errorf("wait for retained native package helper process: %w", err) + } else { + joinAnomaly = fmt.Errorf( + "wait for retained native package helper process returned 0x%08x", status) + } + } + j.retry() + } + closeErr := j.close(j.handle) + j.handle = 0 + if closeErr != nil { + closeErr = fmt.Errorf("close retained native package helper process handle: %w", closeErr) + } + + var exitError *exec.ExitError + if commandWaitErr != nil && !errors.As(commandWaitErr, &exitError) { + return &nativePackageProcessWaitIndeterminateError{ + cause: errors.Join(commandWaitErr, joinAnomaly, closeErr), + } + } + if joinAnomaly != nil || closeErr != nil { + return fmt.Errorf( + "independent native package helper process join failed (command wait: %v): %w", + commandWaitErr, errors.Join(joinAnomaly, closeErr), + ) + } + return commandWaitErr +} + +func waitNativePackageHelper(command *exec.Cmd) error { + return waitNativePackageHelperWith(command, retainNativePackageProcessJoin, + func() { time.Sleep(nativePackageProcessJoinRetry) }) +} + +// waitNativePackageHelperCoordinated retains the exact helper process while +// the outer package transaction services its inherited quiescence/handoff +// events. The callback must return only after the retained process is signaled +// or after a coordination anomaly; complete still joins the exact child before +// any package or service lock can unwind. +func waitNativePackageHelperCoordinated( + command *exec.Cmd, + coordinate func(windows.Handle) error, +) error { + join := retainNativePackageProcessJoinWithRetry( + command.Process, retainNativePackageProcessJoin, + func() { time.Sleep(nativePackageProcessJoinRetry) }, + ) + coordinationErr := coordinate(join.handle) + waitErr := join.complete(command.Wait()) + if coordinationErr != nil { + return &nativePackageProcessWaitIndeterminateError{ + cause: errors.Join(coordinationErr, waitErr), + } + } + return waitErr +} + +func retainNativePackageProcessJoinWithRetry( + process *os.Process, + retain func(*os.Process) (*nativePackageProcessJoin, error), + retry func(), +) *nativePackageProcessJoin { + var join *nativePackageProcessJoin + for join == nil { + var err error + join, err = retain(process) + if err == nil { + break + } + // Cmd.Wait has not run, so Go still owns the exact source handle. + // Never unwind a mutating package transaction without an independent + // wait handle; transient resource pressure is retried while every outer + // lock and immutable input handle remains held. + retry() + } + return join +} + +func waitNativePackageHelperWith( + command *exec.Cmd, + retain func(*os.Process) (*nativePackageProcessJoin, error), + retry func(), +) error { + join := retainNativePackageProcessJoinWithRetry(command.Process, retain, retry) + // A recovered pre-Wait duplication retry is not a transaction failure: the + // exact handle was retained before Cmd.Wait and supplies the required join. + return join.complete(command.Wait()) +} diff --git a/internal/cmd/native_package_process_windows_test.go b/internal/cmd/native_package_process_windows_test.go new file mode 100644 index 00000000..fa06269a --- /dev/null +++ b/internal/cmd/native_package_process_windows_test.go @@ -0,0 +1,160 @@ +//go:build windows + +package cmd + +import ( + "errors" + "os" + "os/exec" + "testing" + + "golang.org/x/sys/windows" +) + +func TestNativePackageProcessJoinPreservesSuccessfulWait(t *testing.T) { + command := exec.Command("cmd.exe", "/d", "/c", "exit", "0") + if err := command.Start(); err != nil { + t.Fatal(err) + } + join, err := retainNativePackageProcessJoin(command.Process) + if err != nil { + _ = command.Wait() + t.Fatalf("retain process join: %v", err) + } + if err := join.complete(command.Wait()); err != nil { + t.Fatalf("complete successful process join: %v", err) + } +} + +func TestNativePackageProcessJoinPreservesExitError(t *testing.T) { + command := exec.Command("cmd.exe", "/d", "/c", "exit", "7") + if err := command.Start(); err != nil { + t.Fatal(err) + } + join, err := retainNativePackageProcessJoin(command.Process) + if err != nil { + _ = command.Wait() + t.Fatalf("retain process join: %v", err) + } + err = join.complete(command.Wait()) + var exitError *exec.ExitError + if !errors.As(err, &exitError) || exitError.ExitCode() != 7 { + t.Fatalf("joined error=%v, want exec.ExitError exit 7", err) + } +} + +func TestNativePackageProcessJoinRecoveredRetainRetryIsNonFatal(t *testing.T) { + command := exec.Command("cmd.exe", "/d", "/c", "exit", "0") + if err := command.Start(); err != nil { + t.Fatal(err) + } + attempts := 0 + err := waitNativePackageHelperWith( + command, + func(process *os.Process) (*nativePackageProcessJoin, error) { + attempts++ + if attempts == 1 { + return nil, errors.New("synthetic DuplicateHandle resource pressure") + } + return retainNativePackageProcessJoin(process) + }, + func() {}, + ) + if err != nil { + t.Fatalf("recovered retain retry overrode successful process result: %v", err) + } + if attempts != 2 { + t.Fatalf("retain attempts=%d, want 2", attempts) + } +} + +func TestNativePackageProcessJoinHoldsScopeAfterAmbiguousCommandWait(t *testing.T) { + entered := make(chan struct{}) + signal := make(chan struct{}) + closed := make(chan struct{}) + join := &nativePackageProcessJoin{ + handle: 1, + wait: func(windows.Handle, uint32) (uint32, error) { + close(entered) + <-signal + return windows.WAIT_OBJECT_0, nil + }, + close: func(windows.Handle) error { + close(closed) + return nil + }, + retry: func() {}, + } + + done := make(chan error, 1) + go func() { + done <- join.complete(errors.New("synthetic Cmd.Wait failure")) + }() + <-entered + select { + case err := <-done: + t.Fatalf("ambiguous wait released transaction scope before process signal: %v", err) + default: + } + close(signal) + err := <-done + var indeterminate *nativePackageProcessWaitIndeterminateError + if !errors.As(err, &indeterminate) { + t.Fatalf("joined ambiguous wait error=%v, want indeterminate failure", err) + } + select { + case <-closed: + default: + t.Fatal("retained process handle was not closed after signal") + } +} + +func TestNativePackageProcessJoinAnomalyDoesNotExposeExitError(t *testing.T) { + commandWaitErr := exec.Command("cmd.exe", "/d", "/c", "exit", "7").Run() + var commandExitError *exec.ExitError + if !errors.As(commandWaitErr, &commandExitError) { + t.Fatalf("test command error=%v, want exec.ExitError", commandWaitErr) + } + waits := 0 + join := &nativePackageProcessJoin{ + handle: 1, + wait: func(windows.Handle, uint32) (uint32, error) { + waits++ + if waits == 1 { + return windows.WAIT_FAILED, windows.ERROR_INVALID_HANDLE + } + return windows.WAIT_OBJECT_0, nil + }, + close: func(windows.Handle) error { return nil }, + retry: func() {}, + } + err := join.complete(commandWaitErr) + var exitError *exec.ExitError + if errors.As(err, &exitError) { + t.Fatalf("join anomaly exposed command ExitError to proof parsing: %v", err) + } +} + +func TestNativePackageProcessJoinRetriesWaitAnomalyUntilSignal(t *testing.T) { + waits := 0 + join := &nativePackageProcessJoin{ + handle: 1, + wait: func(windows.Handle, uint32) (uint32, error) { + waits++ + if waits == 1 { + return windows.WAIT_FAILED, windows.ERROR_INVALID_HANDLE + } + return windows.WAIT_OBJECT_0, nil + }, + close: func(windows.Handle) error { return nil }, + retry: func() {}, + } + err := join.complete(errors.New("synthetic Cmd.Wait failure")) + var indeterminate *nativePackageProcessWaitIndeterminateError + if !errors.As(err, &indeterminate) { + t.Fatalf("joined anomalous wait error=%v, want indeterminate failure", err) + } + if waits != 2 { + t.Fatalf("retained process wait calls=%d, want 2", waits) + } +} diff --git a/internal/cmd/native_package_test.go b/internal/cmd/native_package_test.go new file mode 100644 index 00000000..384d0da6 --- /dev/null +++ b/internal/cmd/native_package_test.go @@ -0,0 +1,382 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "reflect" + "strings" + "testing" +) + +type fakeNativePackageTransaction struct { + events []string + fail string + closeErr error + rollbackErr error + snapshot nativePackageServiceSnapshot + installStarted chan struct{} + rollbackHadDeadline bool +} + +func (f *fakeNativePackageTransaction) event(name string) error { + f.events = append(f.events, name) + if f.fail == name { + return errors.New(name + " failure") + } + return nil +} + +func (f *fakeNativePackageTransaction) Preflight(context.Context) error { + return f.event("preflight") +} + +func (f *fakeNativePackageTransaction) InspectService(context.Context) (nativePackageServiceSnapshot, error) { + return f.snapshot, f.event("inspect") +} + +func (f *fakeNativePackageTransaction) Prepare( + _ context.Context, snapshot nativePackageServiceSnapshot, +) error { + if snapshot != f.snapshot { + return errors.New("service snapshot changed") + } + return f.event("prepare") +} + +func (f *fakeNativePackageTransaction) InstallDriverAndBroker(ctx context.Context) error { + if err := f.event("install"); err != nil { + return err + } + if f.installStarted != nil { + close(f.installStarted) + <-ctx.Done() + return ctx.Err() + } + return nil +} + +func (f *fakeNativePackageTransaction) VerifyAuthenticatedHealth(context.Context) error { + return f.event("verify") +} + +func (f *fakeNativePackageTransaction) Commit(context.Context) error { + return f.event("commit") +} + +func (f *fakeNativePackageTransaction) Rollback(ctx context.Context) error { + f.events = append(f.events, "rollback") + if ctx.Err() != nil { + return errors.New("rollback inherited canceled context") + } + _, f.rollbackHadDeadline = ctx.Deadline() + return f.rollbackErr +} + +func (f *fakeNativePackageTransaction) Close() error { + f.events = append(f.events, "close") + return f.closeErr +} + +func nativePackageTestLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func TestNativePackageTransactionCommitsOnlyAfterAuthenticatedHealth(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageTransaction{snapshot: nativePackageServiceSnapshot{ + disposition: nativePackageServiceWeakExactOwned, wasRunning: true, + }} + if err := runNativePackageTransaction(context.Background(), nativePackageTestLogger(), fake); err != nil { + t.Fatalf("run transaction: %v", err) + } + want := []string{"preflight", "inspect", "prepare", "install", "verify", "commit", "close"} + if !reflect.DeepEqual(fake.events, want) { + t.Fatalf("events=%v want=%v", fake.events, want) + } +} + +func TestNativePackageTransactionFailureMatrix(t *testing.T) { + t.Parallel() + for _, fail := range []string{"preflight", "inspect", "prepare", "install", "verify", "commit"} { + fail := fail + t.Run(fail, func(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageTransaction{fail: fail} + err := runNativePackageTransaction(context.Background(), nativePackageTestLogger(), fake) + if err == nil || !strings.Contains(err.Error(), fail+" failure") { + t.Fatalf("error=%v", err) + } + rollbackExpected := fail == "prepare" || fail == "install" || fail == "verify" || fail == "commit" + rollbackSeen := false + for _, event := range fake.events { + rollbackSeen = rollbackSeen || event == "rollback" + } + if rollbackSeen != rollbackExpected { + t.Fatalf("events=%v rollbackExpected=%v", fake.events, rollbackExpected) + } + if fake.events[len(fake.events)-1] != "close" { + t.Fatalf("transaction did not close: %v", fake.events) + } + }) + } +} + +func TestNativePackageTransactionRejectsCancellationBeforeMutation(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + fake := &fakeNativePackageTransaction{fail: "install"} + cancel() + err := runNativePackageTransaction(ctx, nativePackageTestLogger(), fake) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error=%v events=%v", err, fake.events) + } + if !reflect.DeepEqual(fake.events, []string{"close"}) { + t.Fatalf("events=%v", fake.events) + } +} + +func TestNativePackageTransactionCancellationReconcilesWithBoundedRollback(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + fake := &fakeNativePackageTransaction{installStarted: make(chan struct{})} + result := make(chan error, 1) + go func() { + result <- runNativePackageTransaction(ctx, nativePackageTestLogger(), fake) + }() + <-fake.installStarted + cancel() + err := <-result + if !errors.Is(err, context.Canceled) { + t.Fatalf("error=%v events=%v", err, fake.events) + } + if !fake.rollbackHadDeadline { + t.Fatal("rollback did not receive its own bounded deadline") + } + want := []string{"preflight", "inspect", "prepare", "install", "rollback", "close"} + if !reflect.DeepEqual(fake.events, want) { + t.Fatalf("events=%v want=%v", fake.events, want) + } +} + +func TestNativePackageBrokerCommitRejectsUnboundTokenBeforePlatformCall(t *testing.T) { + t.Parallel() + command := NativePackageBrokerCommit{ + TokenFile: `C:\Program Files\VIIPER\.viiper.transaction.test.token`, + ExpectedTokenSHA256: "not-a-hash", + ExpectedBrokerSHA256: strings.Repeat("b", 64), + TargetUserSID: "S-1-5-21-1-2-3-1001", + } + err := command.Run(nativePackageTestLogger()) + if err == nil || !strings.Contains(err.Error(), "64 hexadecimal") { + t.Fatalf("error=%v", err) + } + var exitCoder interface{ ExitCode() int } + if !errors.As(err, &exitCoder) || exitCoder.ExitCode() != 4 { + t.Fatalf("preflight error lost exit 4 contract: %v", err) + } +} + +func TestNativePackageBrokerCommitRejectsInvalidDeadlineBeforePlatformCall(t *testing.T) { + t.Parallel() + command := NativePackageBrokerCommit{ + TokenFile: `C:\Program Files\VIIPER\.viiper.transaction.test.token`, + ExpectedTokenSHA256: strings.Repeat("a", 64), + ExpectedBrokerSHA256: strings.Repeat("b", 64), + TargetUserSID: "S-1-5-21-1-2-3-1001", + TransactionDeadlineUnixMS: "not-a-deadline", + } + err := command.Run(nativePackageTestLogger()) + if err == nil || !strings.Contains(err.Error(), "deadline") { + t.Fatalf("error=%v", err) + } +} + +func TestNativePackageBrokerCommitProofIsCanonical(t *testing.T) { + t.Parallel() + cases := []struct { + name string + result nativePackageBrokerCommitResult + want string + }{ + { + name: "healthy no-op", result: nativePackageBrokerCommitResult{ + success: true, rollback: "not-needed", exitCode: 0, + }, + want: "result=success operation=native-package-broker-commit changed=0 rollback=not-needed exitCode=0\n", + }, + { + name: "healthy repair", result: nativePackageBrokerCommitResult{ + success: true, changed: true, rollback: "not-needed", exitCode: 0, + }, + want: "result=success operation=native-package-broker-commit changed=1 rollback=not-needed exitCode=0\n", + }, + { + name: "preflight", result: nativePackageBrokerCommitResult{ + rollback: "not-needed", exitCode: 4, + }, + want: "result=error operation=native-package-broker-commit changed=0 rollback=not-needed exitCode=4\n", + }, + { + name: "settled rollback", result: nativePackageBrokerCommitResult{ + changed: true, rollback: "succeeded", exitCode: 1, + }, + want: "result=error operation=native-package-broker-commit changed=1 rollback=succeeded exitCode=1\n", + }, + { + name: "indeterminate rollback", result: nativePackageBrokerCommitResult{ + changed: true, rollback: "failed", exitCode: 3, + }, + want: "result=error operation=native-package-broker-commit changed=1 rollback=failed exitCode=3\n", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := test.result.proofLine(); got != test.want { + t.Fatalf("proof=%q want=%q", got, test.want) + } + }) + } +} + +func TestNativePackageInstallProofFailsClosed(t *testing.T) { + t.Parallel() + cases := []struct { + name string + output string + processExit int + wantErr bool + wantSuccess bool + wantReboot bool + }{ + { + name: "healthy no-op", processExit: 0, wantSuccess: true, + output: "result=success operation=install changed=0 rebootRequired=0 rollback=not-needed exitCode=0\n", + }, + { + name: "healthy repair", processExit: 0, wantSuccess: true, + output: "result=success operation=install changed=1 rebootRequired=0 rollback=not-needed exitCode=0\r\n", + }, + { + name: "reboot boundary", processExit: nativePackageRebootRequiredCode, wantReboot: true, + output: `result=error operation=install changed=1 rebootRequired=1 rollback=succeeded exitCode=3010 phase="broker-reboot-boundary" win32Error=3010 message="restart required"` + "\n", + }, + { + name: "pristine runtime reboot boundary", processExit: nativePackageRebootRequiredCode, wantReboot: true, + output: `result=error operation=install changed=0 rebootRequired=1 rollback=not-needed exitCode=3010 phase="upgrade-runtime-reboot-boundary" win32Error=3010 message="restart required"` + "\n", + }, + { + name: "settled failure", processExit: 1, + output: "result=error operation=install changed=1 rebootRequired=0 rollback=succeeded exitCode=1\n", + }, + { + name: "preflight", processExit: 4, + output: "result=error operation=install changed=0 rebootRequired=0 rollback=not-needed exitCode=4\n", + }, + { + name: "indeterminate", processExit: 3, + output: "result=error operation=install changed=1 rebootRequired=0 rollback=failed exitCode=3\n", + }, + { + name: "missing", processExit: 0, wantErr: true, + output: "not a proof\n", + }, + { + name: "duplicate", processExit: 0, wantErr: true, + output: strings.Repeat("result=success operation=install changed=0 rebootRequired=0 rollback=not-needed exitCode=0\n", 2), + }, + { + name: "exit mismatch", processExit: 1, wantErr: true, + output: "result=success operation=install changed=0 rebootRequired=0 rollback=not-needed exitCode=0\n", + }, + { + name: "unsafe reboot", processExit: nativePackageRebootRequiredCode, wantErr: true, + output: "result=error operation=install changed=1 rebootRequired=1 rollback=failed exitCode=3010\n", + }, + { + name: "unsafe pre-mutation reboot rollback", processExit: nativePackageRebootRequiredCode, wantErr: true, + output: "result=error operation=install changed=0 rebootRequired=1 rollback=succeeded exitCode=3010\n", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + proof, err := parseNativePackageInstallProof(test.output, test.processExit) + if (err != nil) != test.wantErr { + t.Fatalf("error=%v wantErr=%v proof=%+v", err, test.wantErr, proof) + } + if err == nil && (proof.success != test.wantSuccess || proof.rebootRequired != test.wantReboot) { + t.Fatalf("proof=%+v wantSuccess=%v wantReboot=%v", proof, test.wantSuccess, test.wantReboot) + } + }) + } +} + +func TestNativePackageTransactionReportsRollbackAndCloseFailures(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageTransaction{ + fail: "verify", rollbackErr: errors.New("rollback failed"), closeErr: errors.New("close failed"), + } + err := runNativePackageTransaction(context.Background(), nativePackageTestLogger(), fake) + for _, fragment := range []string{"verify failure", "rollback failed", "close failed"} { + if err == nil || !strings.Contains(err.Error(), fragment) { + t.Fatalf("error=%v missing %q", err, fragment) + } + } +} + +func TestNativePackageRebootRequiredPreservesInstallerExitCode(t *testing.T) { + t.Parallel() + cause := errors.New("helper safely rolled back") + err := fmt.Errorf("install native package: %w", &nativePackageRebootRequiredError{cause: cause}) + var exitCoder interface{ ExitCode() int } + if !errors.As(err, &exitCoder) || exitCoder.ExitCode() != nativePackageRebootRequiredCode { + t.Fatalf("error=%v exitCoder=%T", err, exitCoder) + } + if !errors.Is(err, cause) { + t.Fatalf("reboot-required error lost cause: %v", err) + } +} + +func TestNativePackageRequestFailsClosed(t *testing.T) { + t.Parallel() + base := nativePackageRequest{ + brokerSource: `C:\bundle\viiper.exe`, packageDirectory: `C:\bundle\driver`, + submissionManifest: `C:\bundle\submission.json`, sourceRevision: strings.Repeat("a", 40), + driverHelper: `C:\bundle\ViiperUdeCtl.exe`, expectedBrokerSHA256: strings.Repeat("b", 64), + expectedHelperSHA256: strings.Repeat("c", 64), targetUserSID: "S-1-5-21-1-2-3-1001", + expectedManifestSHA256: strings.Repeat("d", 64), + expectedInfSHA256: strings.Repeat("e", 64), expectedSysSHA256: strings.Repeat("f", 64), + expectedCatSHA256: strings.Repeat("0", 64), + driverValidationMode: "production", + } + if err := base.validate(); err != nil { + t.Fatalf("valid request: %v", err) + } + cases := map[string]func(*nativePackageRequest){ + "relative package": func(r *nativePackageRequest) { r.packageDirectory = `driver` }, + "short revision": func(r *nativePackageRequest) { r.sourceRevision = "abc" }, + "bad broker hash": func(r *nativePackageRequest) { r.expectedBrokerSHA256 = strings.Repeat("z", 64) }, + "bad INF hash": func(r *nativePackageRequest) { r.expectedInfSHA256 = strings.Repeat("z", 64) }, + "bad SYS hash": func(r *nativePackageRequest) { r.expectedSysSHA256 = strings.Repeat("z", 64) }, + "bad CAT hash": func(r *nativePackageRequest) { r.expectedCatSHA256 = strings.Repeat("z", 64) }, + "embedded NUL": func(r *nativePackageRequest) { r.submissionManifest += "\x00evil" }, + "bad validation mode": func(r *nativePackageRequest) { + r.driverValidationMode = "controlled-test" + }, + } + for name, mutate := range cases { + name, mutate := name, mutate + t.Run(name, func(t *testing.T) { + t.Parallel() + request := base + mutate(&request) + if err := request.validate(); err == nil { + t.Fatal("invalid request accepted") + } + }) + } +} diff --git a/internal/cmd/native_package_uninstall.go b/internal/cmd/native_package_uninstall.go new file mode 100644 index 00000000..3c4d9386 --- /dev/null +++ b/internal/cmd/native_package_uninstall.go @@ -0,0 +1,619 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "log/slog" + "path/filepath" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +const nativePackageUninstallCleanupTimeout = 2 * time.Minute + +const ( + nativePackageRemoveProofMaximumLineBytes = 64 * 1024 + nativePackageRemoveRetainedTombstoneWarning = "remove-settled-cleanup-retained" + nativePackageRemoveRetainedTombstoneMaximumRunes = 259 + nativePackageRemoveRecoveryDirectory = "VIIPER-UdeCx-RemoveTransactions" + nativePackageRemoveSettledPrefix = "settled-v2-" +) + +type nativePackageUninstallRequest struct { + driverHelper string + expectedHelperSHA256 string + targetUserSID string +} + +func (r nativePackageUninstallRequest) validate() error { + if strings.TrimSpace(r.driverHelper) == "" { + return errors.New("native package uninstall driver helper is empty") + } + if strings.TrimSpace(r.targetUserSID) == "" { + return errors.New("native package uninstall target user SID is empty") + } + if strings.IndexByte(r.driverHelper, 0) >= 0 || strings.IndexByte(r.targetUserSID, 0) >= 0 { + return errors.New("native package uninstall input contains NUL") + } + if !filepath.IsAbs(r.driverHelper) { + return fmt.Errorf("native package uninstall driver helper must be an absolute path: %s", r.driverHelper) + } + if !strings.EqualFold(filepath.Base(r.driverHelper), "ViiperUdeCtl.exe") { + return fmt.Errorf("native package uninstall helper must be named ViiperUdeCtl.exe: %s", r.driverHelper) + } + if !nativePackageSHA256.MatchString(r.expectedHelperSHA256) { + return errors.New("native package uninstall helper SHA-256 must contain exactly 64 hexadecimal characters") + } + return nil +} + +type nativePackageRemoveResult struct { + rebootRequired bool + serviceRestoreVerified bool + retainedTombstone string + retainedTombstoneWin32Error uint32 +} + +type nativePackageRemoveProof struct { + success bool + changed bool + rebootRequired bool + rollback string + exitCode int + retainedTombstone string + retainedTombstoneWin32Error uint32 +} + +type nativePackageRemoveProofField struct { + name string + value string + quoted bool +} + +func parseNativePackageRemoveProofFields(line string) ([]nativePackageRemoveProofField, error) { + if line == "" || len(line) > nativePackageRemoveProofMaximumLineBytes || !utf8.ValidString(line) || + strings.ContainsAny(line, "\r\n") { + return nil, errors.New("driver helper emitted a malformed structured remove outcome line") + } + fields := make([]nativePackageRemoveProofField, 0, 16) + seen := make(map[string]struct{}, 16) + for position := 0; position < len(line); { + if position != 0 { + if line[position] != ' ' { + return nil, errors.New("driver helper structured remove fields are not space-delimited") + } + position++ + if position == len(line) || line[position] == ' ' { + return nil, errors.New("driver helper structured remove outcome has empty or trailing fields") + } + } + nameStart := position + for position < len(line) && line[position] != '=' { + character := line[position] + if !((character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9')) { + return nil, errors.New("driver helper structured remove outcome has an invalid field name") + } + position++ + } + if position == nameStart || position == len(line) { + return nil, errors.New("driver helper structured remove outcome has a field without a value") + } + name := line[nameStart:position] + if _, duplicate := seen[name]; duplicate { + return nil, fmt.Errorf("driver helper structured remove outcome duplicated field %q", name) + } + seen[name] = struct{}{} + position++ + quoted := position < len(line) && line[position] == '"' + var value strings.Builder + if quoted { + position++ + closed := false + for position < len(line) { + character := line[position] + position++ + if character == '"' { + closed = true + break + } + if character == '\\' { + if position == len(line) || (line[position] != '\\' && line[position] != '"') { + return nil, errors.New("driver helper structured remove outcome has an invalid quoted escape") + } + character = line[position] + position++ + } + if character < 0x20 || character == 0x7f { + return nil, errors.New("driver helper structured remove outcome has a control character") + } + value.WriteByte(character) + } + if !closed { + return nil, errors.New("driver helper structured remove outcome has an unterminated quoted value") + } + if position < len(line) && line[position] != ' ' { + return nil, errors.New("driver helper structured remove outcome has trailing quoted data") + } + } else { + valueStart := position + for position < len(line) && line[position] != ' ' { + character := line[position] + if !((character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || character == '-') { + return nil, errors.New("driver helper structured remove outcome has an invalid unquoted value") + } + position++ + } + if position == valueStart { + return nil, errors.New("driver helper structured remove outcome has an empty unquoted value") + } + value.WriteString(line[valueStart:position]) + } + fields = append(fields, nativePackageRemoveProofField{ + name: name, value: value.String(), quoted: quoted, + }) + } + return fields, nil +} + +func requireNativePackageRemoveProofField( + fields []nativePackageRemoveProofField, + position *int, + name string, + quoted bool, +) (string, error) { + if *position >= len(fields) || fields[*position].name != name || fields[*position].quoted != quoted { + return "", fmt.Errorf("driver helper structured remove outcome requires ordered field %q", name) + } + value := fields[*position].value + (*position)++ + return value, nil +} + +func parseNativePackageRemoveUint32(fieldName, value string) (uint32, error) { + parsed, err := strconv.ParseUint(value, 10, 32) + if err != nil { + return 0, fmt.Errorf("parse driver helper remove %s: %w", fieldName, err) + } + return uint32(parsed), nil +} + +func validateNativePackageRemoveErrorEvidence( + fields []nativePackageRemoveProofField, + position *int, +) error { + phase, err := requireNativePackageRemoveProofField(fields, position, "phase", true) + if err != nil { + return err + } + if phase == "" || utf8.RuneCountInString(phase) > 256 { + return errors.New("driver helper structured remove outcome has an invalid error phase") + } + win32Error, err := requireNativePackageRemoveProofField(fields, position, "win32Error", false) + if err != nil { + return err + } + if _, err := parseNativePackageRemoveUint32("Win32 error", win32Error); err != nil { + return err + } + if *position < len(fields) && fields[*position].name == "nestedExitCode" { + nestedExitCode, err := requireNativePackageRemoveProofField(fields, position, "nestedExitCode", false) + if err != nil { + return err + } + if _, err := strconv.ParseInt(nestedExitCode, 10, 32); err != nil { + return fmt.Errorf("parse driver helper remove nested exit code: %w", err) + } + } + message, err := requireNativePackageRemoveProofField(fields, position, "message", true) + if err != nil { + return err + } + if utf8.RuneCountInString(message) > 4096 { + return errors.New("driver helper structured remove outcome error message is unbounded") + } + if *position < len(fields) && fields[*position].name == "recoveryRecord" { + recoveryRecord, err := requireNativePackageRemoveProofField(fields, position, "recoveryRecord", true) + if err != nil { + return err + } + if recoveryRecord == "" || utf8.RuneCountInString(recoveryRecord) > 32767 { + return errors.New("driver helper structured remove outcome has an invalid recovery record path") + } + recordWritten, err := requireNativePackageRemoveProofField(fields, position, "recoveryRecordWritten", false) + if err != nil { + return err + } + if recordWritten != "0" && recordWritten != "1" { + return errors.New("driver helper structured remove outcome has an invalid recovery record state") + } + if *position < len(fields) && fields[*position].name == "recoveryRecordPhase" { + if recordWritten != "0" { + return errors.New("driver helper structured remove outcome attached a write failure to a published recovery record") + } + if _, err := requireNativePackageRemoveProofField(fields, position, "recoveryRecordPhase", true); err != nil { + return err + } + recordError, err := requireNativePackageRemoveProofField(fields, position, "recoveryRecordWin32Error", false) + if err != nil { + return err + } + if _, err := parseNativePackageRemoveUint32("recovery record Win32 error", recordError); err != nil { + return err + } + if _, err := requireNativePackageRemoveProofField(fields, position, "recoveryRecordMessage", true); err != nil { + return err + } + } + } + if *position < len(fields) && fields[*position].name == "recoveryBackup" { + recoveryBackup, err := requireNativePackageRemoveProofField(fields, position, "recoveryBackup", true) + if err != nil { + return err + } + if recoveryBackup == "" || utf8.RuneCountInString(recoveryBackup) > 32767 { + return errors.New("driver helper structured remove outcome has an invalid recovery backup path") + } + backupRetained, err := requireNativePackageRemoveProofField(fields, position, "recoveryBackupRetained", false) + if err != nil { + return err + } + if backupRetained != "0" && backupRetained != "1" { + return errors.New("driver helper structured remove outcome has an invalid recovery backup state") + } + } + return nil +} + +func validateNativePackageRemoveRetainedTombstone(path string) error { + if path == "" || utf8.RuneCountInString(path) > nativePackageRemoveRetainedTombstoneMaximumRunes || + len(path) < 4 || !((path[0] >= 'a' && path[0] <= 'z') || (path[0] >= 'A' && path[0] <= 'Z')) || + path[1] != ':' || path[2] != '\\' || strings.Contains(path, "/") { + return errors.New("driver helper retained tombstone is not a bounded absolute Windows path") + } + components := strings.Split(path[3:], `\`) + if len(components) < 3 || + !strings.EqualFold(components[len(components)-2], nativePackageRemoveRecoveryDirectory) { + return errors.New("driver helper retained tombstone is outside the remove recovery directory") + } + for _, component := range components { + if component == "" || component == "." || component == ".." || + strings.ContainsAny(component, `:*?"<>|`) || strings.HasSuffix(component, " ") || + strings.HasSuffix(component, ".") { + return errors.New("driver helper retained tombstone has an invalid path component") + } + for _, character := range component { + if character < 0x20 || character == 0x7f { + return errors.New("driver helper retained tombstone has a control character") + } + } + } + settledName := components[len(components)-1] + if !strings.HasPrefix(settledName, nativePackageRemoveSettledPrefix) { + return errors.New("driver helper retained tombstone has an invalid settled identity") + } + transactionID := strings.TrimPrefix(settledName, nativePackageRemoveSettledPrefix) + if len(transactionID) != 64 { + return errors.New("driver helper retained tombstone has an invalid transaction identity length") + } + for _, character := range transactionID { + if !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f')) { + return errors.New("driver helper retained tombstone has a non-canonical transaction identity") + } + } + return nil +} + +func parseOptionalNativePackageRemoveWarning( + fields []nativePackageRemoveProofField, + position *int, + proof *nativePackageRemoveProof, +) error { + if *position == len(fields) { + return nil + } + warning, err := requireNativePackageRemoveProofField(fields, position, "warning", true) + if err != nil { + return err + } + if warning != nativePackageRemoveRetainedTombstoneWarning { + return fmt.Errorf("driver helper emitted unsupported remove warning %q", warning) + } + warningError, err := requireNativePackageRemoveProofField(fields, position, "warningWin32Error", false) + if err != nil { + return err + } + parsedWarningError, err := parseNativePackageRemoveUint32("warning Win32 error", warningError) + if err != nil { + return err + } + if parsedWarningError == 0 { + return errors.New("driver helper retained tombstone warning has no cleanup error") + } + retainedTombstone, err := requireNativePackageRemoveProofField(fields, position, "retainedTombstone", true) + if err != nil { + return err + } + if err := validateNativePackageRemoveRetainedTombstone(retainedTombstone); err != nil { + return err + } + if *position != len(fields) { + return errors.New("driver helper structured remove outcome has trailing fields after its warning evidence") + } + proof.retainedTombstone = retainedTombstone + proof.retainedTombstoneWin32Error = parsedWarningError + return nil +} + +func parseNativePackageRemoveProof(output string, processExitCode int) (nativePackageRemoveResult, error) { + var proofLines []string + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSuffix(line, "\r") + if strings.HasPrefix(line, "result=") { + proofLines = append(proofLines, line) + } + } + if len(proofLines) != 1 { + return nativePackageRemoveResult{}, errors.New("driver helper did not emit exactly one structured remove outcome") + } + fields, err := parseNativePackageRemoveProofFields(proofLines[0]) + if err != nil { + return nativePackageRemoveResult{}, err + } + position := 0 + resultValue, err := requireNativePackageRemoveProofField(fields, &position, "result", false) + if err != nil { + return nativePackageRemoveResult{}, err + } + operation, err := requireNativePackageRemoveProofField(fields, &position, "operation", false) + if err != nil || operation != "remove" { + return nativePackageRemoveResult{}, errors.New("driver helper structured outcome is not an exact remove operation") + } + changed, err := requireNativePackageRemoveProofField(fields, &position, "changed", false) + if err != nil || (changed != "0" && changed != "1") { + return nativePackageRemoveResult{}, errors.New("driver helper structured remove outcome has an invalid changed state") + } + rebootRequired, err := requireNativePackageRemoveProofField(fields, &position, "rebootRequired", false) + if err != nil || (rebootRequired != "0" && rebootRequired != "1") { + return nativePackageRemoveResult{}, errors.New("driver helper structured remove outcome has an invalid reboot state") + } + rollback, err := requireNativePackageRemoveProofField(fields, &position, "rollback", false) + if err != nil || (rollback != "not-needed" && rollback != "succeeded" && rollback != "failed") { + return nativePackageRemoveResult{}, errors.New("driver helper structured remove outcome has an invalid rollback state") + } + exitCodeValue, err := requireNativePackageRemoveProofField(fields, &position, "exitCode", false) + if err != nil { + return nativePackageRemoveResult{}, err + } + proofExitCode, err := strconv.ParseUint(exitCodeValue, 10, 31) + if err != nil { + return nativePackageRemoveResult{}, fmt.Errorf("parse driver helper remove exit code: %w", err) + } + proof := nativePackageRemoveProof{ + success: resultValue == "success", + changed: changed == "1", + rebootRequired: rebootRequired == "1", + rollback: rollback, + exitCode: int(proofExitCode), + } + if resultValue != "success" && resultValue != "error" { + return nativePackageRemoveResult{}, errors.New("driver helper structured remove outcome has an invalid result state") + } + if !proof.success { + if err := validateNativePackageRemoveErrorEvidence(fields, &position); err != nil { + return nativePackageRemoveResult{}, err + } + } + if err := parseOptionalNativePackageRemoveWarning(fields, &position, &proof); err != nil { + return nativePackageRemoveResult{}, err + } + if position != len(fields) { + return nativePackageRemoveResult{}, errors.New("driver helper structured remove outcome has unknown or trailing fields") + } + if proof.exitCode != processExitCode { + return nativePackageRemoveResult{}, fmt.Errorf( + "driver helper remove process exit %d disagreed with structured exit %d", + processExitCode, proof.exitCode, + ) + } + result := nativePackageRemoveResult{ + retainedTombstone: proof.retainedTombstone, + retainedTombstoneWin32Error: proof.retainedTombstoneWin32Error, + } + switch proof.exitCode { + case 0: + if !proof.success || proof.rebootRequired || proof.rollback != "not-needed" { + return nativePackageRemoveResult{}, errors.New("driver helper emitted an invalid success remove outcome") + } + return result, nil + case nativePackageRebootRequiredCode: + if !proof.success || !proof.changed || !proof.rebootRequired || proof.rollback != "not-needed" { + return nativePackageRemoveResult{}, errors.New("driver helper emitted an invalid reboot-success remove outcome") + } + result.rebootRequired = true + return result, nil + case 4: + if proof.success || proof.changed || proof.rebootRequired || proof.rollback != "not-needed" { + return nativePackageRemoveResult{}, errors.New("driver helper emitted an invalid preflight-rejection outcome") + } + result.serviceRestoreVerified = true + return result, fmt.Errorf("driver helper rejected package removal before mutation: %s", strings.TrimSpace(output)) + case 1: + if proof.success || !proof.changed || proof.rollback != "succeeded" { + return nativePackageRemoveResult{}, errors.New("driver helper emitted an invalid rolled-back failure outcome") + } + result.serviceRestoreVerified = !proof.rebootRequired + return result, fmt.Errorf("driver helper package removal failed and rolled back: %s", strings.TrimSpace(output)) + case 3: + if proof.success || !proof.changed || proof.rollback != "failed" { + return nativePackageRemoveResult{}, errors.New("driver helper emitted an invalid rollback-failure outcome") + } + return nativePackageRemoveResult{}, fmt.Errorf("driver helper package removal and rollback failed: %s", strings.TrimSpace(output)) + default: + return nativePackageRemoveResult{}, fmt.Errorf( + "driver helper returned unsupported structured remove exit %d: %s", + proof.exitCode, strings.TrimSpace(output), + ) + } +} + +type nativePackageUninstallServiceSnapshot struct { + exists bool + wasRunning bool + opaque any +} + +type nativePackageUninstallUnsafeRestoreError struct { + cause error +} + +func (e *nativePackageUninstallUnsafeRestoreError) Error() string { + return e.cause.Error() +} + +func (e *nativePackageUninstallUnsafeRestoreError) Unwrap() error { + return e.cause +} + +type nativePackageUninstallTransaction interface { + LockPackage(context.Context) error + LockService(context.Context) error + Preflight(context.Context) error + InspectService(context.Context) (nativePackageUninstallServiceSnapshot, error) + StopService(context.Context, nativePackageUninstallServiceSnapshot) error + RemoveDriver(context.Context) (nativePackageRemoveResult, error) + Cleanup(context.Context, nativePackageUninstallServiceSnapshot) (bool, error) + RestoreService(context.Context, nativePackageUninstallServiceSnapshot) error + Close() error +} + +func runNativePackageUninstallTransaction( + ctx context.Context, + logger *slog.Logger, + transaction nativePackageUninstallTransaction, +) (resultErr error) { + if transaction == nil { + return errors.New("native package uninstall transaction is nil") + } + defer func() { + if closeErr := transaction.Close(); closeErr != nil { + resultErr = errors.Join(resultErr, + fmt.Errorf("close native package uninstall transaction: %w", closeErr)) + } + }() + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package uninstall canceled before package lock: %w", err) + } + if err := transaction.LockPackage(ctx); err != nil { + return fmt.Errorf("acquire native package uninstall mutex: %w", err) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package uninstall canceled before service lock: %w", err) + } + if err := transaction.LockService(ctx); err != nil { + return fmt.Errorf("acquire native broker service mutex after package mutex: %w", err) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package uninstall canceled before preflight: %w", err) + } + if err := transaction.Preflight(ctx); err != nil { + return fmt.Errorf("native package uninstall preflight rejected before mutation: %w", err) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package uninstall canceled before service inspection: %w", err) + } + snapshot, err := transaction.InspectService(ctx) + if err != nil { + return fmt.Errorf("inspect exact native broker service before package removal: %w", err) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package uninstall canceled before service stop: %w", err) + } + + restoreArmed := false + serviceRestoreVerified := true + driverRemovalSucceeded := false + defer func() { + if !restoreArmed || driverRemovalSucceeded { + return + } + if !serviceRestoreVerified { + resultErr = errors.Join(resultErr, errors.New( + "native driver or managed-file restoration safety is unverified; exact broker was deliberately left stopped for external reconciliation", + )) + return + } + rollbackCtx, cancelRollback := context.WithTimeout( + context.WithoutCancel(ctx), nativePackageUninstallCleanupTimeout, + ) + defer cancelRollback() + if rollbackErr := transaction.RestoreService(rollbackCtx, snapshot); rollbackErr != nil { + resultErr = errors.Join(resultErr, + fmt.Errorf("restore exact native broker after package removal failure: %w", rollbackErr)) + } + }() + + // Sending STOP is the first mutation. Arm exact run-state restoration before + // entering the method because it can fail while the service is StopPending. + restoreArmed = snapshot.exists + if err := transaction.StopService(ctx, snapshot); err != nil { + var unsafeRestore *nativePackageUninstallUnsafeRestoreError + if errors.As(err, &unsafeRestore) { + serviceRestoreVerified = false + } + return fmt.Errorf("stop exact native broker before package removal: %w", err) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package uninstall canceled before driver removal: %w", err) + } + removeResult, err := transaction.RemoveDriver(ctx) + if removeResult.retainedTombstone != "" && logger != nil { + logger.Warn("Native remove journal retired with a retained settled tombstone", + "warning", nativePackageRemoveRetainedTombstoneWarning, + "win32Error", removeResult.retainedTombstoneWin32Error, + "retainedTombstone", removeResult.retainedTombstone) + } + if err != nil { + serviceRestoreVerified = removeResult.serviceRestoreVerified + return fmt.Errorf("remove exact native driver package: %w", err) + } + // The helper owns the authoritative Driver Store snapshot and reports success + // only after either final verification or a Windows reboot-success boundary. + // Never restart a now-driverless broker after this point, even if cleanup fails. + driverRemovalSucceeded = true + if err := ctx.Err(); err != nil { + logger.Warn("Native driver removal completed at the transaction deadline; reconciling exact owned cleanup", + "deadline", err) + } + cleanupCtx, cancelCleanup := context.WithTimeout( + context.WithoutCancel(ctx), nativePackageUninstallCleanupTimeout, + ) + defer cancelCleanup() + cleanupRebootRequired, err := transaction.Cleanup(cleanupCtx, snapshot) + if err != nil { + if removeResult.rebootRequired { + return fmt.Errorf("clean up exact native broker ownership after reboot-successful driver removal (restart still required): %w", err) + } + return fmt.Errorf("clean up exact native broker ownership after driver removal: %w", err) + } + if removeResult.rebootRequired || cleanupRebootRequired { + return &nativePackageUninstallRebootRequiredError{} + } + return nil +} + +type nativePackageUninstallRebootRequiredError struct{} + +func (*nativePackageUninstallRebootRequiredError) Error() string { + return "native package removal succeeded; restart Windows to complete exact driver removal" +} + +func (*nativePackageUninstallRebootRequiredError) ExitCode() int { + return nativePackageRebootRequiredCode +} diff --git a/internal/cmd/native_package_uninstall_test.go b/internal/cmd/native_package_uninstall_test.go new file mode 100644 index 00000000..0c26c7ec --- /dev/null +++ b/internal/cmd/native_package_uninstall_test.go @@ -0,0 +1,514 @@ +package cmd + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "reflect" + "strconv" + "strings" + "testing" +) + +type fakeNativePackageUninstallTransaction struct { + events []string + fail string + closeErr error + restoreErr error + removeResult nativePackageRemoveResult + snapshot nativePackageUninstallServiceSnapshot + cancelAt string + cancel context.CancelFunc + restoreHadDeadline bool + cleanupHadDeadline bool + unsafeStop bool + cleanupReboot bool +} + +func (f *fakeNativePackageUninstallTransaction) event(name string) error { + f.events = append(f.events, name) + if f.cancelAt == name && f.cancel != nil { + f.cancel() + } + if f.fail == name { + return errors.New(name + " failure") + } + return nil +} + +func (f *fakeNativePackageUninstallTransaction) LockPackage(context.Context) error { + return f.event("package-lock") +} + +func (f *fakeNativePackageUninstallTransaction) LockService(context.Context) error { + return f.event("service-lock") +} + +func (f *fakeNativePackageUninstallTransaction) Preflight(context.Context) error { + return f.event("preflight") +} + +func (f *fakeNativePackageUninstallTransaction) InspectService(context.Context) (nativePackageUninstallServiceSnapshot, error) { + return f.snapshot, f.event("inspect") +} + +func (f *fakeNativePackageUninstallTransaction) StopService( + _ context.Context, snapshot nativePackageUninstallServiceSnapshot, +) error { + if snapshot != f.snapshot { + return errors.New("service snapshot changed") + } + err := f.event("stop") + if err != nil && f.unsafeStop { + return &nativePackageUninstallUnsafeRestoreError{cause: err} + } + return err +} + +func (f *fakeNativePackageUninstallTransaction) RemoveDriver(context.Context) (nativePackageRemoveResult, error) { + return f.removeResult, f.event("remove") +} + +func (f *fakeNativePackageUninstallTransaction) Cleanup( + ctx context.Context, snapshot nativePackageUninstallServiceSnapshot, +) (bool, error) { + if snapshot != f.snapshot { + return false, errors.New("service snapshot changed") + } + _, f.cleanupHadDeadline = ctx.Deadline() + return f.cleanupReboot, f.event("cleanup") +} + +func (f *fakeNativePackageUninstallTransaction) RestoreService( + ctx context.Context, snapshot nativePackageUninstallServiceSnapshot, +) error { + if snapshot != f.snapshot { + return errors.New("service snapshot changed") + } + f.events = append(f.events, "restore") + _, f.restoreHadDeadline = ctx.Deadline() + return f.restoreErr +} + +func (f *fakeNativePackageUninstallTransaction) Close() error { + f.events = append(f.events, "close") + return f.closeErr +} + +func TestNativePackageUninstallUsesFixedLockAndCommitOrder(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{snapshot: nativePackageUninstallServiceSnapshot{ + exists: true, wasRunning: true, + }} + if err := runNativePackageUninstallTransaction( + context.Background(), nativePackageTestLogger(), fake, + ); err != nil { + t.Fatalf("run uninstall: %v", err) + } + want := []string{ + "package-lock", "service-lock", "preflight", "inspect", + "stop", "remove", "cleanup", "close", + } + if !reflect.DeepEqual(fake.events, want) { + t.Fatalf("events=%v want=%v", fake.events, want) + } + if !fake.cleanupHadDeadline { + t.Fatal("committed driver removal cleanup did not receive a bounded reconciliation context") + } +} + +func TestNativePackageUninstallFailureMatrix(t *testing.T) { + t.Parallel() + for _, fail := range []string{ + "package-lock", "service-lock", "preflight", "inspect", "stop", "remove", "cleanup", + } { + fail := fail + t.Run(fail, func(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{ + fail: fail, snapshot: nativePackageUninstallServiceSnapshot{exists: true}, + } + if fail == "remove" { + fake.removeResult.serviceRestoreVerified = true + } + err := runNativePackageUninstallTransaction( + context.Background(), nativePackageTestLogger(), fake, + ) + if err == nil || !strings.Contains(err.Error(), fail+" failure") { + t.Fatalf("error=%v events=%v", err, fake.events) + } + restoreExpected := fail == "stop" || fail == "remove" + restoreSeen := false + for _, event := range fake.events { + restoreSeen = restoreSeen || event == "restore" + } + if restoreSeen != restoreExpected { + t.Fatalf("events=%v restoreExpected=%v", fake.events, restoreExpected) + } + if restoreSeen && !fake.restoreHadDeadline { + t.Fatal("service restoration did not receive a bounded independent context") + } + if fake.events[len(fake.events)-1] != "close" { + t.Fatalf("transaction did not close: %v", fake.events) + } + }) + } +} + +func TestNativePackageUninstallCancellationBoundaries(t *testing.T) { + t.Parallel() + cases := []struct { + cancelAt string + wantEvents []string + wantRestore bool + }{ + {cancelAt: "package-lock", wantEvents: []string{"package-lock", "close"}}, + {cancelAt: "service-lock", wantEvents: []string{"package-lock", "service-lock", "close"}}, + {cancelAt: "preflight", wantEvents: []string{"package-lock", "service-lock", "preflight", "close"}}, + {cancelAt: "inspect", wantEvents: []string{"package-lock", "service-lock", "preflight", "inspect", "close"}}, + {cancelAt: "stop", wantEvents: []string{"package-lock", "service-lock", "preflight", "inspect", "stop", "restore", "close"}, wantRestore: true}, + } + for _, test := range cases { + test := test + t.Run(test.cancelAt, func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + fake := &fakeNativePackageUninstallTransaction{ + cancelAt: test.cancelAt, cancel: cancel, + snapshot: nativePackageUninstallServiceSnapshot{exists: true}, + } + err := runNativePackageUninstallTransaction(ctx, nativePackageTestLogger(), fake) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error=%v events=%v", err, fake.events) + } + if !reflect.DeepEqual(fake.events, test.wantEvents) { + t.Fatalf("events=%v want=%v", fake.events, test.wantEvents) + } + if test.wantRestore && !fake.restoreHadDeadline { + t.Fatal("cancellation restoration did not receive an independent deadline") + } + }) + } +} + +func TestNativePackageUninstallHelperSuccessAtDeadlineStillCleans(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + fake := &fakeNativePackageUninstallTransaction{cancelAt: "remove", cancel: cancel} + if err := runNativePackageUninstallTransaction(ctx, nativePackageTestLogger(), fake); err != nil { + t.Fatalf("authoritative helper success was contradicted by caller cancellation: %v", err) + } + if !slicesContainString(fake.events, "cleanup") || slicesContainString(fake.events, "restore") { + t.Fatalf("helper success reconciliation events=%v", fake.events) + } +} + +func TestNativePackageUninstallReportsRestoreAndCloseFailures(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{ + fail: "remove", restoreErr: errors.New("restore failure"), closeErr: errors.New("close failure"), + removeResult: nativePackageRemoveResult{serviceRestoreVerified: true}, + snapshot: nativePackageUninstallServiceSnapshot{exists: true}, + } + err := runNativePackageUninstallTransaction(context.Background(), nativePackageTestLogger(), fake) + for _, fragment := range []string{"remove failure", "restore failure", "close failure"} { + if err == nil || !strings.Contains(err.Error(), fragment) { + t.Fatalf("error=%v missing %q", err, fragment) + } + } +} + +func TestNativePackageUninstallLeavesBrokerStoppedWhenDriverRollbackIsUnverified(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{ + fail: "remove", + removeResult: nativePackageRemoveResult{serviceRestoreVerified: false}, + snapshot: nativePackageUninstallServiceSnapshot{exists: true}, + } + err := runNativePackageUninstallTransaction(context.Background(), nativePackageTestLogger(), fake) + if err == nil || !strings.Contains(err.Error(), "deliberately left stopped") { + t.Fatalf("error=%v events=%v", err, fake.events) + } + if slicesContainString(fake.events, "restore") { + t.Fatalf("unverified driver rollback restarted broker: %v", fake.events) + } +} + +func TestNativePackageUninstallDoesNotRestoreAbsentBroker(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{ + fail: "remove", snapshot: nativePackageUninstallServiceSnapshot{}, + } + err := runNativePackageUninstallTransaction(context.Background(), nativePackageTestLogger(), fake) + if err == nil || !strings.Contains(err.Error(), "remove failure") { + t.Fatalf("error=%v events=%v", err, fake.events) + } + if strings.Contains(err.Error(), "deliberately left stopped") || slicesContainString(fake.events, "restore") { + t.Fatalf("absent broker was treated as restorable state: error=%v events=%v", err, fake.events) + } +} + +func TestNativePackageUninstallLeavesBrokerStoppedWhenManagedFileIdentityChanges(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{ + fail: "stop", unsafeStop: true, + snapshot: nativePackageUninstallServiceSnapshot{exists: true, wasRunning: true}, + } + err := runNativePackageUninstallTransaction(context.Background(), nativePackageTestLogger(), fake) + if err == nil || !strings.Contains(err.Error(), "deliberately left stopped") { + t.Fatalf("error=%v events=%v", err, fake.events) + } + if slicesContainString(fake.events, "restore") { + t.Fatalf("changed managed file identity restarted broker: %v", fake.events) + } +} + +func TestNativePackageUninstallRebootSuccessCleansBefore3010(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{ + removeResult: nativePackageRemoveResult{rebootRequired: true}, + } + err := runNativePackageUninstallTransaction(context.Background(), nativePackageTestLogger(), fake) + var exitCoder interface{ ExitCode() int } + if !errors.As(err, &exitCoder) || exitCoder.ExitCode() != nativePackageRebootRequiredCode { + t.Fatalf("error=%v exitCoder=%T", err, exitCoder) + } + want := []string{ + "package-lock", "service-lock", "preflight", "inspect", + "stop", "remove", "cleanup", "close", + } + if !reflect.DeepEqual(fake.events, want) { + t.Fatalf("events=%v want=%v", fake.events, want) + } +} + +func TestNativePackageUninstallDoesNotReport3010WhenOwnedCleanupFails(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{ + fail: "cleanup", + removeResult: nativePackageRemoveResult{rebootRequired: true}, + } + err := runNativePackageUninstallTransaction( + context.Background(), nativePackageTestLogger(), fake, + ) + if err == nil || !strings.Contains(err.Error(), "cleanup failure") || + !strings.Contains(err.Error(), "restart still required") { + t.Fatalf("error=%v events=%v", err, fake.events) + } + var exitCoder interface{ ExitCode() int } + if errors.As(err, &exitCoder) { + t.Fatalf("partial owned cleanup was misreported as reboot-success exit %d", exitCoder.ExitCode()) + } + if slicesContainString(fake.events, "restore") { + t.Fatalf("driverless broker was restored after cleanup failure: %v", fake.events) + } +} + +func TestNativePackageUninstallSelfImageCleanupAggregates3010(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{cleanupReboot: true} + err := runNativePackageUninstallTransaction(context.Background(), nativePackageTestLogger(), fake) + var exitCoder interface{ ExitCode() int } + if !errors.As(err, &exitCoder) || exitCoder.ExitCode() != nativePackageRebootRequiredCode { + t.Fatalf("error=%v exitCoder=%T", err, exitCoder) + } + if !slicesContainString(fake.events, "cleanup") || slicesContainString(fake.events, "restore") { + t.Fatalf("self-image cleanup reconciliation events=%v", fake.events) + } +} + +func TestNativePackageUninstallIdempotentAbsenceStillReconcilesDriver(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{snapshot: nativePackageUninstallServiceSnapshot{}} + if err := runNativePackageUninstallTransaction( + context.Background(), nativePackageTestLogger(), fake, + ); err != nil { + t.Fatalf("idempotent uninstall: %v", err) + } + for _, required := range []string{"stop", "remove", "cleanup"} { + if !strings.Contains(strings.Join(fake.events, ","), required) { + t.Fatalf("absence skipped %s reconciliation: %v", required, fake.events) + } + } +} + +func TestNativePackageRemoveStructuredExitSemantics(t *testing.T) { + t.Parallel() + cases := []struct { + name string + line string + exit int + reboot bool + wantErr bool + errContains string + }{ + {name: "success", line: "result=success operation=remove changed=1 rebootRequired=0 rollback=not-needed exitCode=0", exit: 0}, + {name: "idempotent success", line: "result=success operation=remove changed=0 rebootRequired=0 rollback=not-needed exitCode=0", exit: 0}, + {name: "reboot success", line: "result=success operation=remove changed=1 rebootRequired=1 rollback=not-needed exitCode=3010", exit: 3010, reboot: true}, + {name: "preflight", line: `result=error operation=remove changed=0 rebootRequired=0 rollback=not-needed exitCode=4 phase="remove-topology" win32Error=13 message="rejected"`, exit: 4, wantErr: true, errContains: "before mutation"}, + {name: "rolled back", line: `result=error operation=remove changed=1 rebootRequired=0 rollback=succeeded exitCode=1 phase="remove-driver" win32Error=5 message="failed"`, exit: 1, wantErr: true, errContains: "rolled back"}, + {name: "rolled back pending reboot", line: `result=error operation=remove changed=1 rebootRequired=1 rollback=succeeded exitCode=1 phase="remove-driver" win32Error=5 message="failed"`, exit: 1, wantErr: true, errContains: "rolled back"}, + {name: "rollback failed", line: `result=error operation=remove changed=1 rebootRequired=1 rollback=failed exitCode=3 phase="remove-rollback" win32Error=5 nestedExitCode=1 message="failed" recoveryRecord="C:\\ProgramData\\active-v2" recoveryRecordWritten=0 recoveryRecordPhase="journal-write" recoveryRecordWin32Error=112 recoveryRecordMessage="full" recoveryBackup="C:\\ProgramData\\backup" recoveryBackupRetained=1`, exit: 3, wantErr: true, errContains: "rollback failed"}, + {name: "exit mismatch", line: "result=success operation=remove changed=1 rebootRequired=0 rollback=not-needed exitCode=0", exit: 1, wantErr: true, errContains: "disagreed"}, + {name: "invalid 3010", line: "result=success operation=remove changed=1 rebootRequired=0 rollback=not-needed exitCode=3010", exit: 3010, wantErr: true, errContains: "invalid reboot-success"}, + {name: "unchanged 3010", line: "result=success operation=remove changed=0 rebootRequired=1 rollback=not-needed exitCode=3010", exit: 3010, wantErr: true, errContains: "invalid reboot-success"}, + {name: "success trailing field", line: "result=success operation=remove changed=1 rebootRequired=0 rollback=not-needed exitCode=0 phase=spoof", exit: 0, wantErr: true, errContains: "warning"}, + {name: "error missing evidence", line: `result=error operation=remove changed=0 rebootRequired=0 rollback=not-needed exitCode=4 phase="remove-topology"`, exit: 4, wantErr: true, errContains: "win32Error"}, + {name: "unstructured", line: "removed", exit: 0, wantErr: true, errContains: "exactly one"}, + {name: "duplicate proof", line: "result=success operation=remove changed=0 rebootRequired=0 rollback=not-needed exitCode=0\nresult=success operation=remove changed=0 rebootRequired=0 rollback=not-needed exitCode=0", exit: 0, wantErr: true, errContains: "exactly one"}, + } + for _, test := range cases { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + result, err := parseNativePackageRemoveProof(test.line, test.exit) + if (err != nil) != test.wantErr { + t.Fatalf("result=%+v error=%v", result, err) + } + if test.errContains != "" && (err == nil || !strings.Contains(err.Error(), test.errContains)) { + t.Fatalf("error=%v missing %q", err, test.errContains) + } + if err == nil && result.rebootRequired != test.reboot { + t.Fatalf("reboot=%v want=%v", result.rebootRequired, test.reboot) + } + if (test.name == "preflight" || test.name == "rolled back") && + !result.serviceRestoreVerified { + t.Fatal("structured no-mutation/rollback proof did not authorize exact broker restoration") + } + if (test.name == "rollback failed" || test.name == "unstructured" || + test.name == "rolled back pending reboot") && + result.serviceRestoreVerified { + t.Fatal("indeterminate helper outcome authorized broker restoration") + } + }) + } +} + +func TestNativePackageRemoveRetainedTombstoneProofIsExactAndBounded(t *testing.T) { + t.Parallel() + tombstone := `C:\ProgramData\VIIPER-UdeCx-RemoveTransactions\settled-v2-` + strings.Repeat("a", 64) + base := "result=success operation=remove changed=1 rebootRequired=0 rollback=not-needed exitCode=0" + warning := " warning=\"remove-settled-cleanup-retained\" warningWin32Error=5 retainedTombstone=" + strconv.Quote(tombstone) + result, err := parseNativePackageRemoveProof(base+warning, 0) + if err != nil { + t.Fatalf("parse exact retained tombstone proof: %v", err) + } + if result.retainedTombstone != tombstone || result.retainedTombstoneWin32Error != 5 { + t.Fatalf("retained tombstone result=%+v", result) + } + rolledBack := `result=error operation=remove changed=1 rebootRequired=0 rollback=succeeded exitCode=1 phase="remove-driver" win32Error=5 message="rolled back"` + result, err = parseNativePackageRemoveProof(rolledBack+warning, 1) + if err == nil || !strings.Contains(err.Error(), "rolled back") || + result.retainedTombstone != tombstone || result.retainedTombstoneWin32Error != 5 || + !result.serviceRestoreVerified { + t.Fatalf("rolled-back retained tombstone result=%+v error=%v", result, err) + } + + cases := map[string]string{ + "missing code": base + ` warning="remove-settled-cleanup-retained" retainedTombstone=` + strconv.Quote(tombstone), + "zero code": base + ` warning="remove-settled-cleanup-retained" warningWin32Error=0 retainedTombstone=` + strconv.Quote(tombstone), + "overflow code": base + ` warning="remove-settled-cleanup-retained" warningWin32Error=4294967296 retainedTombstone=` + strconv.Quote(tombstone), + "wrong warning": base + ` warning="unknown" warningWin32Error=5 retainedTombstone=` + strconv.Quote(tombstone), + "relative path": base + ` warning="remove-settled-cleanup-retained" warningWin32Error=5 retainedTombstone="settled-v2-` + strings.Repeat("a", 64) + `"`, + "wrong directory": base + ` warning="remove-settled-cleanup-retained" warningWin32Error=5 retainedTombstone=` + strconv.Quote(`C:\Other\settled-v2-`+strings.Repeat("a", 64)), + "bad identity": base + ` warning="remove-settled-cleanup-retained" warningWin32Error=5 retainedTombstone=` + strconv.Quote(strings.TrimSuffix(tombstone, "a")+"g"), + "duplicate warning": base + warning + warning, + "trailing field": base + warning + ` ignored=1`, + "reordered tuple": base + ` warning="remove-settled-cleanup-retained" retainedTombstone=` + strconv.Quote(tombstone) + ` warningWin32Error=5`, + "unescaped path": base + ` warning="remove-settled-cleanup-retained" warningWin32Error=5 retainedTombstone="C:\ProgramData"`, + "duplicate base key": base + ` changed=1`, + } + for name, line := range cases { + name, line := name, line + t.Run(name, func(t *testing.T) { + t.Parallel() + if result, err := parseNativePackageRemoveProof(line, 0); err == nil { + t.Fatalf("malformed warning proof accepted: %+v", result) + } + }) + } +} + +func TestNativePackageUninstallSurfacesRetainedTombstoneWarning(t *testing.T) { + t.Parallel() + tombstone := `C:\ProgramData\VIIPER-UdeCx-RemoveTransactions\settled-v2-` + strings.Repeat("b", 64) + fake := &fakeNativePackageUninstallTransaction{ + removeResult: nativePackageRemoveResult{ + retainedTombstone: tombstone, + retainedTombstoneWin32Error: 5, + }, + } + var records bytes.Buffer + logger := slog.New(slog.NewTextHandler(&records, nil)) + if err := runNativePackageUninstallTransaction(context.Background(), logger, fake); err != nil { + t.Fatalf("run uninstall: %v", err) + } + for _, evidence := range []string{ + "Native remove journal retired with a retained settled tombstone", + "warning=remove-settled-cleanup-retained", + "win32Error=5", + "retainedTombstone=" + tombstone, + } { + if !strings.Contains(records.String(), evidence) { + t.Fatalf("warning log %q missing %q", records.String(), evidence) + } + } +} + +func slicesContainString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func TestNativePackageUninstallRequestFailsClosed(t *testing.T) { + t.Parallel() + base := nativePackageUninstallRequest{ + driverHelper: `C:\bundle\ViiperUdeCtl.exe`, + expectedHelperSHA256: strings.Repeat("a", 64), + targetUserSID: "S-1-5-21-1-2-3-1001", + } + if err := base.validate(); err != nil { + t.Fatalf("valid request: %v", err) + } + cases := map[string]func(*nativePackageUninstallRequest){ + "empty helper": func(r *nativePackageUninstallRequest) { r.driverHelper = "" }, + "empty SID": func(r *nativePackageUninstallRequest) { r.targetUserSID = "" }, + "relative helper": func(r *nativePackageUninstallRequest) { r.driverHelper = "ViiperUdeCtl.exe" }, + "wrong helper": func(r *nativePackageUninstallRequest) { r.driverHelper = `C:\bundle\other.exe` }, + "bad hash": func(r *nativePackageUninstallRequest) { r.expectedHelperSHA256 = strings.Repeat("z", 64) }, + "embedded NUL": func(r *nativePackageUninstallRequest) { r.targetUserSID += "\x00evil" }, + } + for name, mutate := range cases { + name, mutate := name, mutate + t.Run(name, func(t *testing.T) { + t.Parallel() + request := base + mutate(&request) + if err := request.validate(); err == nil { + t.Fatal("invalid request accepted") + } + }) + } +} + +func TestNativePackageUninstallNilTransaction(t *testing.T) { + t.Parallel() + err := runNativePackageUninstallTransaction(context.Background(), nativePackageTestLogger(), nil) + if err == nil || !strings.Contains(err.Error(), "nil") { + t.Fatalf("error=%v", err) + } +} + +func Example_parseNativePackageRemoveProof() { + result, err := parseNativePackageRemoveProof( + "result=success operation=remove changed=0 rebootRequired=0 rollback=not-needed exitCode=0", 0, + ) + fmt.Println(result.rebootRequired, err) + // Output: false +} diff --git a/internal/cmd/native_package_uninstall_windows.go b/internal/cmd/native_package_uninstall_windows.go new file mode 100644 index 00000000..3bf8b5c8 --- /dev/null +++ b/internal/cmd/native_package_uninstall_windows.go @@ -0,0 +1,1160 @@ +//go:build windows + +package cmd + +import ( + "bytes" + "context" + cryptorand "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +const nativeFileDispositionInfoClass = 4 + +const nativePackageUninstallTombstoneAttempts = 32 + +var setNativeFileInformationByHandle = windows.NewLazySystemDLL( + "kernel32.dll", +).NewProc("SetFileInformationByHandle") + +type windowsNativePackageUninstallSnapshot struct { + config mgr.Config + status svc.Status + securityDescriptor string + recoveryActions []mgr.RecoveryAction + recoveryResetSeconds uint32 + recoverNonCrash bool + serviceExecutable string + serviceExecutableSHA256 string +} + +type windowsNativePackageUninstallFile struct { + kind string + path string + hash string + identity windowsNativePackageUninstallFileIdentity + handle windows.Handle +} + +type windowsNativePackageUninstallFileIdentity struct { + volumeSerialNumber uint32 + fileIndex uint64 +} + +type nativePackageFileRenameInfo struct { + replaceIfExists uint32 + rootDirectory windows.Handle + fileNameLength uint32 + fileName [1]uint16 +} + +type windowsNativePackageUninstallLiveLog struct { + path string + identity windowsNativePackageUninstallFileIdentity + handle windows.Handle +} + +type windowsNativePackageUninstallTransaction struct { + logger *slog.Logger + request nativePackageUninstallRequest + + releasePackageMutex func() + releaseServiceMutex func() + helperHandles []windows.Handle + managedDirectories []windows.Handle + helperHandle windows.Handle + + userSID string + manager nativeSCM + service nativeManagedService + snapshot *windowsNativePackageUninstallSnapshot + ownedFiles []*windowsNativePackageUninstallFile + liveLog *windowsNativePackageUninstallLiveLog + liveLogPath string + + closed bool +} + +func uninstallNativePackage( + ctx context.Context, + logger *slog.Logger, + request nativePackageUninstallRequest, +) error { + transaction := &windowsNativePackageUninstallTransaction{logger: logger, request: request} + return runNativePackageUninstallTransaction(ctx, logger, transaction) +} + +func remainingNativePackageUninstallBudget(ctx context.Context) (time.Duration, error) { + deadline, ok := ctx.Deadline() + if !ok { + return nativePackageTransactionTimeout, nil + } + remaining := time.Until(deadline) + if remaining <= 0 { + return 0, context.DeadlineExceeded + } + return remaining, nil +} + +func (t *windowsNativePackageUninstallTransaction) LockPackage(ctx context.Context) error { + budget, err := remainingNativePackageUninstallBudget(ctx) + if err != nil { + return err + } + release, err := acquireNamedNativePackageMutex(nativePackageMutexName, budget) + if err != nil { + return err + } + t.releasePackageMutex = release + return nil +} + +func (t *windowsNativePackageUninstallTransaction) LockService(ctx context.Context) error { + if t.releasePackageMutex == nil { + return errors.New("native package mutex must be held before the broker service mutex") + } + budget, err := remainingNativePackageUninstallBudget(ctx) + if err != nil { + return err + } + release, err := acquireNativeInstallMutex(budget) + if err != nil { + return err + } + t.releaseServiceMutex = release + return nil +} + +func (t *windowsNativePackageUninstallTransaction) Preflight(ctx context.Context) error { + if t.releasePackageMutex == nil || t.releaseServiceMutex == nil { + return errors.New("native package uninstall mutex order is incomplete") + } + if err := ctx.Err(); err != nil { + return err + } + userSID, err := resolveNativeInstallingUserSID(t.request.targetUserSID) + if err != nil { + return fmt.Errorf("resolve exact native broker credential owner: %w", err) + } + t.userSID = userSID + if err := reconcileNativeBrokerJournalBeforeAdmission(ctx, t.logger, t.userSID); err != nil { + return fmt.Errorf("reconcile interrupted native broker transaction before uninstall: %w", err) + } + + directoryHandles, err := lockNativePackageDirectoryChain(filepath.Dir(t.request.driverHelper)) + if err != nil { + return fmt.Errorf("lock packaged driver helper directory chain: %w", err) + } + t.helperHandles = append(t.helperHandles, directoryHandles...) + helper, err := lockNativePackageInput(t.request.driverHelper) + if err != nil { + return fmt.Errorf("lock packaged driver helper: %w", err) + } + t.helperHandle = helper + helperHash, err := hashNativePackageHandle(helper) + if err != nil { + return fmt.Errorf("hash packaged driver helper: %w", err) + } + if !strings.EqualFold(helperHash, t.request.expectedHelperSHA256) { + return fmt.Errorf("packaged driver helper SHA-256=%s expected=%s", + helperHash, t.request.expectedHelperSHA256) + } + if err := requireNativePackagePE(helper); err != nil { + return fmt.Errorf("validate packaged driver helper image: %w", err) + } + return nil +} + +func (t *windowsNativePackageUninstallTransaction) InspectService( + ctx context.Context, +) (nativePackageUninstallServiceSnapshot, error) { + manager, err := mgr.Connect() + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("connect to SCM: %w", err) + } + t.manager = &windowsNativeSCM{manager: manager} + service, err := t.manager.OpenService(NativeBrokerServiceName) + if errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { + if waitErr := waitForNativePackageServiceDeletion(ctx, t.manager); waitErr != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf( + "reconcile previously committed %s deletion: %w", + NativeBrokerServiceName, waitErr, + ) + } + if inspectErr := t.inspectOrphanedExactManagedFiles(); inspectErr != nil { + return nativePackageUninstallServiceSnapshot{}, inspectErr + } + return nativePackageUninstallServiceSnapshot{}, nil + } + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + if err := t.inspectOrphanedExactManagedFiles(); err != nil { + return nativePackageUninstallServiceSnapshot{}, err + } + return nativePackageUninstallServiceSnapshot{}, nil + } + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("open %s: %w", + NativeBrokerServiceName, err) + } + t.service = service + config, err := service.Config() + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("query %s config: %w", + NativeBrokerServiceName, err) + } + executable, err := nativeServiceExecutableFromCommandLine(config.BinaryPathName) + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("parse %s executable: %w", + NativeBrokerServiceName, err) + } + programFiles, err := windows.KnownFolderPath(windows.FOLDERID_ProgramFiles, windows.KF_FLAG_DEFAULT) + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("resolve Program Files: %w", err) + } + if _, err := nativeServiceExecutableParent(programFiles, executable); err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf( + "refusing to stop non-owned %s: %w", NativeBrokerServiceName, err, + ) + } + keyPath, err := nativeServiceKeyFilePath() + if err != nil { + return nativePackageUninstallServiceSnapshot{}, err + } + expectedConfig, _, err := nativeBrokerServiceConfiguration(executable, keyPath) + if err != nil { + return nativePackageUninstallServiceSnapshot{}, err + } + securityDescriptor, err := service.SecurityDescriptor() + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("query %s security: %w", + NativeBrokerServiceName, err) + } + recovery, err := service.RecoveryActions() + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("query %s recovery actions: %w", + NativeBrokerServiceName, err) + } + reset, err := service.ResetPeriod() + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("query %s recovery reset: %w", + NativeBrokerServiceName, err) + } + nonCrash, err := service.RecoveryActionsOnNonCrashFailures() + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("query %s recovery mode: %w", + NativeBrokerServiceName, err) + } + if !isCanonicalNativePackageService( + config, expectedConfig, securityDescriptor, recovery, reset, nonCrash, + ) { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf( + "refusing to stop %s because its LocalSystem configuration, security, or recovery ownership is not exact", + NativeBrokerServiceName, + ) + } + status, err := service.Query() + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("query %s state: %w", + NativeBrokerServiceName, err) + } + status, err = settleNativeServiceSnapshot(ctx, service, status, waitContext) + if err != nil { + return nativePackageUninstallServiceSnapshot{}, err + } + if status.State != svc.Running && status.State != svc.Stopped { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf( + "refusing native package removal while %s is in state %d", + NativeBrokerServiceName, status.State, + ) + } + broker, err := t.inspectExactBrokerFile(executable, true) + if err != nil { + return nativePackageUninstallServiceSnapshot{}, err + } + if broker == nil { + return nativePackageUninstallServiceSnapshot{}, errors.New("exact native broker executable is absent") + } + if err := t.inspectCredentialFiles(true, status.State == svc.Running); err != nil { + return nativePackageUninstallServiceSnapshot{}, err + } + t.snapshot = &windowsNativePackageUninstallSnapshot{ + config: config, status: status, securityDescriptor: securityDescriptor, + recoveryActions: append([]mgr.RecoveryAction(nil), recovery...), + recoveryResetSeconds: reset, recoverNonCrash: nonCrash, + serviceExecutable: executable, serviceExecutableSHA256: broker.hash, + } + if err := t.inspectOtherExactBrokerFiles(executable); err != nil { + return nativePackageUninstallServiceSnapshot{}, err + } + return nativePackageUninstallServiceSnapshot{ + exists: true, wasRunning: status.State == svc.Running, opaque: t.snapshot, + }, nil +} + +func (t *windowsNativePackageUninstallTransaction) inspectOrphanedExactManagedFiles() error { + if err := t.inspectCredentialFiles(false, false); err != nil { + return err + } + return t.inspectOtherExactBrokerFiles("") +} + +func (t *windowsNativePackageUninstallTransaction) inspectOtherExactBrokerFiles(exclude string) error { + programFiles, err := windows.KnownFolderPath(windows.FOLDERID_ProgramFiles, windows.KF_FLAG_DEFAULT) + if err != nil { + return fmt.Errorf("resolve Program Files: %w", err) + } + candidates := []string{ + filepath.Join(filepath.Clean(programFiles), "VIIPER", "viiper.exe"), + filepath.Join(filepath.Clean(programFiles), "DS4Windows", "VIIPER", "viiper.exe"), + } + for _, candidate := range candidates { + if exclude != "" && strings.EqualFold(filepath.Clean(candidate), filepath.Clean(exclude)) { + continue + } + if t.hasOwnedFile(candidate) { + continue + } + if _, err := t.inspectExactBrokerFile(candidate, false); err != nil { + return err + } + } + return nil +} + +func (t *windowsNativePackageUninstallTransaction) inspectExactBrokerFile( + path string, + required bool, +) (*windowsNativePackageUninstallFile, error) { + attributes, err := nativePathAttributes(path) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + if required { + return nil, fmt.Errorf("exact native broker executable is missing: %s", path) + } + return nil, nil + } + return nil, fmt.Errorf("inspect exact managed broker path %s: %w", path, err) + } + if attributes&(windows.FILE_ATTRIBUTE_DIRECTORY|windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + if required { + return nil, fmt.Errorf("exact native broker path is not a regular non-reparse file: %s", path) + } + t.logger.Warn("Leaving non-owned file at an exact native broker path", "path", path) + return nil, nil + } + if err := t.lockExactBrokerDirectoryChain(path); err != nil { + if required { + return nil, fmt.Errorf("lock exact installer-owned native broker directories: %w", err) + } + t.logger.Warn("Leaving broker path whose exact installer ownership did not verify", + "path", path, "error", err) + return nil, nil + } + owned, err := lockNativePackageUninstallFile( + path, "broker", nativeBrokerExecutableSDDL, true, + ) + if err != nil { + if required { + return nil, fmt.Errorf("snapshot exact installer-owned native broker: %w", err) + } + t.logger.Warn("Leaving broker path that changed during exact ownership snapshot", + "path", path, "error", err) + return nil, nil + } + t.ownedFiles = append(t.ownedFiles, owned) + return owned, nil +} + +// lockExactBrokerDirectoryChain retains every ancestor against rename while +// validating the package-owned directories below Program Files. Do not call +// lockNativePriorServiceExecutable here: its read-only leaf handle deliberately +// denies delete sharing and would make the subsequent exact DELETE-capable +// snapshot fail with ERROR_SHARING_VIOLATION on every healthy installation. +func (t *windowsNativePackageUninstallTransaction) lockExactBrokerDirectoryChain( + executable string, +) error { + programFiles, err := windows.KnownFolderPath( + windows.FOLDERID_ProgramFiles, windows.KF_FLAG_DEFAULT, + ) + if err != nil { + return fmt.Errorf("resolve Program Files: %w", err) + } + parent, err := nativeServiceExecutableParent(programFiles, executable) + if err != nil { + return err + } + chain, err := lockNativePackageDirectoryChain(parent) + if err != nil { + return err + } + validated := make([]windows.Handle, 0, 2) + fail := func(failErr error) error { + closeNativePackageUninstallHandles(validated) + closeNativePackageUninstallHandles(chain) + return failErr + } + relative, err := filepath.Rel(filepath.Clean(programFiles), filepath.Clean(parent)) + if err != nil || relative == "." || filepath.IsAbs(relative) || + relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return fail(fmt.Errorf("exact native broker parent escaped Program Files: %s", parent)) + } + current := filepath.Clean(programFiles) + for _, component := range strings.Split(relative, string(filepath.Separator)) { + if component == "" || component == "." || component == ".." { + return fail(fmt.Errorf("exact native broker parent contains an unsafe component: %s", parent)) + } + current = filepath.Join(current, component) + handle, openErr := openNativePathWithoutReparse( + current, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + ) + if openErr != nil { + return fail(fmt.Errorf("open protected broker directory %s: %w", current, openErr)) + } + validated = append(validated, handle) + if securityErr := validateNativeSecurityDescriptor( + handle, nativeBrokerDirectorySDDL, + ); securityErr != nil { + return fail(fmt.Errorf("validate protected broker directory %s: %w", current, securityErr)) + } + } + t.managedDirectories = append(t.managedDirectories, chain...) + t.managedDirectories = append(t.managedDirectories, validated...) + return nil +} + +func (t *windowsNativePackageUninstallTransaction) inspectCredentialFiles( + required bool, + brokerMayWriteLog bool, +) error { + keyPath, err := nativeServiceKeyFilePath() + if err != nil { + return err + } + directory := filepath.Dir(keyPath) + attributes, err := nativePathAttributes(directory) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + if required { + return errors.New("exact native broker credential directory is missing") + } + return nil + } + return fmt.Errorf("inspect exact credential directory: %w", err) + } + if attributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 || + attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + if required { + return errors.New("exact native broker credential path is not a regular directory") + } + t.logger.Warn("Leaving non-owned native credential path", "path", directory) + return nil + } + chain, err := lockNativePackageDirectoryChain(directory) + if err != nil { + if required { + return fmt.Errorf("lock exact native credential directory chain: %w", err) + } + t.logger.Warn("Leaving native credential path with unsafe ancestors", + "path", directory, "error", err) + return nil + } + directoryHandle, err := openNativePathWithoutReparse( + directory, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + ) + if err != nil { + closeNativePackageUninstallHandles(chain) + return fmt.Errorf("open exact native credential directory: %w", err) + } + if err := validateNativeSecurityDescriptor( + directoryHandle, nativeCredentialDirectorySDDL(t.userSID), + ); err != nil { + windows.CloseHandle(directoryHandle) //nolint:errcheck + closeNativePackageUninstallHandles(chain) + if required { + return fmt.Errorf("validate exact native credential directory ownership: %w", err) + } + t.logger.Warn("Leaving native credential directory whose exact ownership did not verify", + "path", directory, "error", err) + return nil + } + t.managedDirectories = append(t.managedDirectories, chain...) + t.managedDirectories = append(t.managedDirectories, directoryHandle) + credential, err := lockNativePackageUninstallFile( + keyPath, "credential", nativeCredentialFileSDDL(t.userSID), false, + ) + if err != nil { + if (errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND)) && !required { + credential = nil + } else { + return fmt.Errorf("snapshot exact native broker credential: %w", err) + } + } + if required && credential == nil { + return errors.New("exact native broker credential is missing") + } + if credential != nil { + t.ownedFiles = append(t.ownedFiles, credential) + } + logPath := filepath.Join(directory, nativeBrokerLogName) + if brokerMayWriteLog { + t.liveLogPath = logPath + liveLog, err := lockNativePackageUninstallLiveLog(logPath) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil + } + return fmt.Errorf("snapshot active exact native broker log identity: %w", err) + } + t.liveLog = liveLog + return nil + } + logFile, err := lockNativePackageUninstallFile(logPath, "broker-log", "", false) + if err != nil { + if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return fmt.Errorf("snapshot exact native broker log: %w", err) + } + logFile = nil + } + if logFile != nil { + t.ownedFiles = append(t.ownedFiles, logFile) + } + return nil +} + +func lockNativePackageUninstallLiveLog( + path string, +) (*windowsNativePackageUninstallLiveLog, error) { + pointer, err := windows.UTF16PtrFromString(filepath.Clean(path)) + if err != nil { + return nil, err + } + // The trusted broker opens its log for writing with read/write sharing. This + // probe therefore cannot request DELETE yet, but its retained identity lets + // us prove that the stronger post-STOP handle names the same exact file. + handle, err := windows.CreateFile( + pointer, + windows.GENERIC_READ|windows.READ_CONTROL, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + return nil, err + } + fail := func(failErr error) (*windowsNativePackageUninstallLiveLog, error) { + windows.CloseHandle(handle) //nolint:errcheck + return nil, failErr + } + info := nativeFileAttributeTagInfo{} + if err := windows.GetFileInformationByHandleEx( + handle, windows.FileAttributeTagInfo, + (*byte)(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)), + ); err != nil { + return fail(err) + } + if info.FileAttributes&(windows.FILE_ATTRIBUTE_DIRECTORY|windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + return fail(errors.New("active managed broker log is not a regular non-reparse file")) + } + identity, err := nativePackageUninstallFileIdentity(handle) + if err != nil { + return fail(err) + } + return &windowsNativePackageUninstallLiveLog{ + path: filepath.Clean(path), identity: identity, handle: handle, + }, nil +} + +func lockNativePackageUninstallFile( + path, kind, expectedSDDL string, + requirePE bool, +) (*windowsNativePackageUninstallFile, error) { + pointer, err := windows.UTF16PtrFromString(filepath.Clean(path)) + if err != nil { + return nil, err + } + handle, err := windows.CreateFile( + pointer, + windows.GENERIC_READ|windows.READ_CONTROL|windows.DELETE, + windows.FILE_SHARE_READ, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + return nil, err + } + fail := func(failErr error) (*windowsNativePackageUninstallFile, error) { + windows.CloseHandle(handle) //nolint:errcheck + return nil, failErr + } + info := nativeFileAttributeTagInfo{} + if err := windows.GetFileInformationByHandleEx( + handle, windows.FileAttributeTagInfo, + (*byte)(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)), + ); err != nil { + return fail(err) + } + if info.FileAttributes&(windows.FILE_ATTRIBUTE_DIRECTORY|windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + return fail(errors.New("managed uninstall target is not a regular non-reparse file")) + } + identity, err := nativePackageUninstallFileIdentity(handle) + if err != nil { + return fail(err) + } + if expectedSDDL != "" { + if err := validateNativeSecurityDescriptor(handle, expectedSDDL); err != nil { + return fail(err) + } + } + if requirePE { + if err := requireNativePackagePE(handle); err != nil { + return fail(err) + } + } + hash, err := hashNativePackageHandle(handle) + if err != nil { + return fail(err) + } + return &windowsNativePackageUninstallFile{ + kind: kind, path: filepath.Clean(path), hash: hash, + identity: identity, handle: handle, + }, nil +} + +func nativePackageUninstallFileIdentity( + handle windows.Handle, +) (windowsNativePackageUninstallFileIdentity, error) { + info := windows.ByHandleFileInformation{} + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return windowsNativePackageUninstallFileIdentity{}, fmt.Errorf("query managed file identity: %w", err) + } + if err := validateNativeFileLinkCount(info.NumberOfLinks); err != nil { + return windowsNativePackageUninstallFileIdentity{}, err + } + return windowsNativePackageUninstallFileIdentity{ + volumeSerialNumber: info.VolumeSerialNumber, + fileIndex: uint64(info.FileIndexHigh)<<32 | uint64(info.FileIndexLow), + }, nil +} + +func (t *windowsNativePackageUninstallTransaction) hasOwnedFile(path string) bool { + for _, file := range t.ownedFiles { + if strings.EqualFold(file.path, filepath.Clean(path)) { + return true + } + } + return false +} + +func (t *windowsNativePackageUninstallTransaction) StopService( + ctx context.Context, + snapshot nativePackageUninstallServiceSnapshot, +) error { + windowsSnapshot, err := t.requireSnapshot(snapshot) + if err != nil { + return err + } + if windowsSnapshot == nil { + return nil + } + if err := t.verifyExactServiceSnapshot(ctx, windowsSnapshot, false); err != nil { + return err + } + if err := stopNativeService(ctx, t.service, waitContext); err != nil { + return err + } + status, err := t.service.Query() + if err != nil { + return fmt.Errorf("verify exact native broker stopped: %w", err) + } + if status.State != svc.Stopped { + return fmt.Errorf("exact native broker remained in state %d after stop", status.State) + } + if err := t.promoteNativePackageUninstallLiveLog(ctx); err != nil { + return &nativePackageUninstallUnsafeRestoreError{cause: err} + } + return nil +} + +func (t *windowsNativePackageUninstallTransaction) promoteNativePackageUninstallLiveLog( + ctx context.Context, +) error { + if t.liveLogPath == "" { + return nil + } + var owned *windowsNativePackageUninstallFile + for { + var err error + owned, err = lockNativePackageUninstallFile(t.liveLogPath, "broker-log", "", false) + if err == nil { + break + } + if (errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND)) && + t.liveLog == nil { + t.liveLogPath = "" + return nil + } + if !errors.Is(err, windows.ERROR_SHARING_VIOLATION) { + return fmt.Errorf("lock stopped exact native broker log: %w", err) + } + if err := waitContext(ctx, 25*time.Millisecond); err != nil { + return fmt.Errorf("wait for stopped exact native broker log handle: %w", err) + } + } + if t.liveLog != nil && owned.identity != t.liveLog.identity { + windows.CloseHandle(owned.handle) //nolint:errcheck + return errors.New("exact native broker log identity changed across service stop") + } + if t.liveLog != nil { + if err := windows.CloseHandle(t.liveLog.handle); err != nil { + windows.CloseHandle(owned.handle) //nolint:errcheck + return fmt.Errorf("close active exact native broker log identity: %w", err) + } + t.liveLog = nil + } + t.liveLogPath = "" + t.ownedFiles = append(t.ownedFiles, owned) + return nil +} + +func (t *windowsNativePackageUninstallTransaction) RemoveDriver( + ctx context.Context, +) (nativePackageRemoveResult, error) { + if err := ctx.Err(); err != nil { + return nativePackageRemoveResult{serviceRestoreVerified: true}, err + } + deadline, ok := ctx.Deadline() + if !ok || !deadline.After(time.Now()) { + return nativePackageRemoveResult{serviceRestoreVerified: true}, context.DeadlineExceeded + } + arguments := []string{ + "remove", "--transaction-deadline-unix-ms", strconv.FormatInt(deadline.UnixMilli(), 10), + } + // Never use CommandContext or kill this process: once SetupAPI mutation starts, + // ViiperUdeCtl owns the exact Driver Store backup and cooperative rollback. + command := exec.Command(t.request.driverHelper, arguments...) + command.Dir = filepath.Dir(t.request.driverHelper) + var output bytes.Buffer + command.Stdout = &output + command.Stderr = &output + if err := command.Start(); err != nil { + return nativePackageRemoveResult{serviceRestoreVerified: true}, err + } + waitErr := waitNativePackageHelper(command) + exitCode := 0 + if waitErr != nil { + var exitError *exec.ExitError + if !errors.As(waitErr, &exitError) { + return nativePackageRemoveResult{}, waitErr + } + exitCode = exitError.ExitCode() + } + result, proofErr := parseNativePackageRemoveProof(output.String(), exitCode) + if proofErr != nil { + if waitErr != nil { + return result, fmt.Errorf("%w (process: %v)", proofErr, waitErr) + } + return result, proofErr + } + return result, nil +} + +func (t *windowsNativePackageUninstallTransaction) Cleanup( + ctx context.Context, + snapshot nativePackageUninstallServiceSnapshot, +) (bool, error) { + windowsSnapshot, err := t.requireSnapshot(snapshot) + if err != nil { + return false, err + } + if windowsSnapshot != nil { + if err := t.verifyExactServiceSnapshot(ctx, windowsSnapshot, true); err != nil { + return false, fmt.Errorf("revalidate exact native broker before delete: %w", err) + } + if err := t.service.Delete(); err != nil && + !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { + return false, fmt.Errorf("delete exact %s after driver removal: %w", + NativeBrokerServiceName, err) + } + if err := t.service.Close(); err != nil { + return false, fmt.Errorf("close exact %s after delete: %w", NativeBrokerServiceName, err) + } + t.service = nil + if err := waitForNativePackageServiceDeletion(ctx, t.manager); err != nil { + return false, fmt.Errorf("reconcile exact %s deletion: %w", NativeBrokerServiceName, err) + } + } + var cleanupErrors []error + cleanupRebootRequired := false + for _, file := range t.ownedFiles { + if file.handle == 0 { + continue + } + actualHash, hashErr := hashNativePackageHandle(file.handle) + if hashErr != nil { + cleanupErrors = append(cleanupErrors, + fmt.Errorf("revalidate exact %s before delete: %w", file.kind, hashErr)) + continue + } + if !strings.EqualFold(actualHash, file.hash) { + cleanupErrors = append(cleanupErrors, + fmt.Errorf("refusing to delete exact %s because its locked hash changed", file.kind)) + continue + } + if err := deleteNativePackageUninstallFileHandle(file.handle); err != nil { + isCurrentExecutable, identityErr := nativePackageUninstallIsCurrentExecutable(file) + if identityErr == nil && isCurrentExecutable && + (errors.Is(err, windows.ERROR_ACCESS_DENIED) || + errors.Is(err, windows.ERROR_SHARING_VIOLATION)) { + tombstone, renameErr := renameNativePackageUninstallFileToTombstone(file) + if renameErr != nil { + cleanupErrors = append(cleanupErrors, + fmt.Errorf("rename running exact %s %s to a protected reboot tombstone: %w", + file.kind, file.path, renameErr)) + continue + } + file.path = tombstone + if scheduleErr := scheduleNativePackageUninstallFileAtReboot(tombstone); scheduleErr == nil { + cleanupRebootRequired = true + if closeErr := windows.CloseHandle(file.handle); closeErr != nil { + cleanupErrors = append(cleanupErrors, + fmt.Errorf("close reboot-scheduled exact %s %s: %w", file.kind, file.path, closeErr)) + } + file.handle = 0 + continue + } else { + cleanupErrors = append(cleanupErrors, + fmt.Errorf("schedule running exact %s %s for reboot deletion: %w", file.kind, file.path, scheduleErr)) + continue + } + } + cleanupErrors = append(cleanupErrors, + fmt.Errorf("delete exact installer-owned %s %s: %w", file.kind, file.path, err)) + if identityErr != nil { + cleanupErrors = append(cleanupErrors, + fmt.Errorf("identify failed exact %s deletion as the running executable: %w", file.kind, identityErr)) + } + continue + } + if err := windows.CloseHandle(file.handle); err != nil { + cleanupErrors = append(cleanupErrors, + fmt.Errorf("close deleted exact %s %s: %w", file.kind, file.path, err)) + } + file.handle = 0 + } + return cleanupRebootRequired, errors.Join(cleanupErrors...) +} + +func deleteNativePackageUninstallFileHandle(handle windows.Handle) error { + disposition := struct{ DeleteFile byte }{DeleteFile: 1} + result, _, callErr := setNativeFileInformationByHandle.Call( + uintptr(handle), + nativeFileDispositionInfoClass, + uintptr(unsafe.Pointer(&disposition)), + unsafe.Sizeof(disposition), + ) + if result != 0 { + return nil + } + if callErr != nil && !errors.Is(callErr, syscall.Errno(0)) { + return callErr + } + return syscall.EINVAL +} + +func nativePackageUninstallIsCurrentExecutable( + file *windowsNativePackageUninstallFile, +) (bool, error) { + if file == nil || file.handle == 0 { + return false, errors.New("exact managed file snapshot is unavailable") + } + executable, err := currentExecutable() + if err != nil { + return false, err + } + pointer, err := windows.UTF16PtrFromString(filepath.Clean(executable)) + if err != nil { + return false, err + } + handle, err := windows.CreateFile( + pointer, + windows.GENERIC_READ|windows.READ_CONTROL, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + return false, err + } + defer windows.CloseHandle(handle) //nolint:errcheck + info := nativeFileAttributeTagInfo{} + if err := windows.GetFileInformationByHandleEx( + handle, windows.FileAttributeTagInfo, + (*byte)(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)), + ); err != nil { + return false, err + } + if info.FileAttributes&(windows.FILE_ATTRIBUTE_DIRECTORY|windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + return false, errors.New("current executable is not a regular non-reparse file") + } + identity, err := nativePackageUninstallFileIdentity(handle) + if err != nil { + return false, err + } + return identity == file.identity, nil +} + +func scheduleNativePackageUninstallFileAtReboot(path string) error { + pointer, err := windows.UTF16PtrFromString(filepath.Clean(path)) + if err != nil { + return err + } + return windows.MoveFileEx( + pointer, nil, + windows.MOVEFILE_DELAY_UNTIL_REBOOT|windows.MOVEFILE_WRITE_THROUGH, + ) +} + +func renameNativePackageUninstallFileToTombstone( + file *windowsNativePackageUninstallFile, +) (string, error) { + if file == nil || file.handle == 0 || file.path == "" { + return "", errors.New("native package uninstall file snapshot is unavailable") + } + parent := filepath.Dir(filepath.Clean(file.path)) + for attempt := 0; attempt < nativePackageUninstallTombstoneAttempts; attempt++ { + var random [16]byte + if _, err := cryptorand.Read(random[:]); err != nil { + return "", fmt.Errorf("generate reboot tombstone identity: %w", err) + } + tombstone := filepath.Join(parent, + ".viiper.uninstall."+hex.EncodeToString(random[:])+".delete") + name, err := windows.UTF16FromString(tombstone) + if err != nil { + return "", err + } + nameBytes := (len(name) - 1) * 2 + var layout nativePackageFileRenameInfo + bufferSize := int(unsafe.Offsetof(layout.fileName)) + nameBytes + buffer := make([]byte, bufferSize) + info := (*nativePackageFileRenameInfo)(unsafe.Pointer(&buffer[0])) + info.fileNameLength = uint32(nameBytes) + copy((*[windows.MAX_LONG_PATH]uint16)(unsafe.Pointer(&info.fileName[0]))[:nameBytes/2:nameBytes/2], + name[:len(name)-1]) + result, _, callErr := setNativeFileInformationByHandle.Call( + uintptr(file.handle), windows.FileRenameInfo, + uintptr(unsafe.Pointer(&buffer[0])), uintptr(bufferSize), + ) + runtime.KeepAlive(buffer) + if result != 0 { + return tombstone, nil + } + if errors.Is(callErr, windows.ERROR_ALREADY_EXISTS) || + errors.Is(callErr, windows.ERROR_FILE_EXISTS) { + continue + } + if callErr == nil || errors.Is(callErr, syscall.Errno(0)) { + callErr = windows.ERROR_GEN_FAILURE + } + return "", callErr + } + return "", errors.New("could not allocate a unique native broker reboot tombstone") +} + +func (t *windowsNativePackageUninstallTransaction) RestoreService( + ctx context.Context, + snapshot nativePackageUninstallServiceSnapshot, +) error { + windowsSnapshot, err := t.requireSnapshot(snapshot) + if err != nil { + return err + } + if windowsSnapshot == nil { + return nil + } + if err := t.verifyOwnedFileSnapshots(); err != nil { + return fmt.Errorf("revalidate exact native broker files before restart: %w", err) + } + if err := t.verifyExactServiceSnapshot(ctx, windowsSnapshot, false); err != nil { + return fmt.Errorf("revalidate exact native broker service before restart: %w", err) + } + if snapshot.wasRunning { + return reconcileNativePackageServiceRunning(ctx, t.service) + } + return stopNativeService(ctx, t.service, waitContext) +} + +func (t *windowsNativePackageUninstallTransaction) requireSnapshot( + snapshot nativePackageUninstallServiceSnapshot, +) (*windowsNativePackageUninstallSnapshot, error) { + if snapshot.exists != (t.snapshot != nil) { + return nil, errors.New("native broker service existence changed after snapshot") + } + if t.snapshot == nil { + if snapshot.opaque != nil || snapshot.wasRunning { + return nil, errors.New("absent native broker snapshot carried mutable state") + } + return nil, nil + } + if snapshot.opaque != t.snapshot || snapshot.wasRunning != (t.snapshot.status.State == svc.Running) { + return nil, errors.New("native broker service snapshot identity changed") + } + return t.snapshot, nil +} + +func (t *windowsNativePackageUninstallTransaction) verifyExactServiceSnapshot( + ctx context.Context, + snapshot *windowsNativePackageUninstallSnapshot, + requireStopped bool, +) error { + if t.service == nil { + return errors.New("exact native broker service handle is unavailable") + } + config, err := t.service.Config() + if err != nil { + return fmt.Errorf("query exact native broker config: %w", err) + } + if !nativeServiceConfigsEqual(config, snapshot.config) { + return errors.New("exact native broker configuration changed during package removal") + } + securityDescriptor, err := t.service.SecurityDescriptor() + if err != nil { + return fmt.Errorf("query exact native broker security: %w", err) + } + if err := compareNativeSecurityDescriptorStrings( + securityDescriptor, snapshot.securityDescriptor, + ); err != nil { + return fmt.Errorf("exact native broker security changed during package removal: %w", err) + } + recovery, err := t.service.RecoveryActions() + if err != nil { + return err + } + reset, err := t.service.ResetPeriod() + if err != nil { + return err + } + nonCrash, err := t.service.RecoveryActionsOnNonCrashFailures() + if err != nil { + return err + } + if !slices.Equal(recovery, snapshot.recoveryActions) || + reset != snapshot.recoveryResetSeconds || nonCrash != snapshot.recoverNonCrash { + return errors.New("exact native broker recovery ownership changed during package removal") + } + status, err := t.service.Query() + if err != nil { + return err + } + status, err = settleNativeServiceSnapshot(ctx, t.service, status, waitContext) + if err != nil { + return err + } + if status.State != svc.Running && status.State != svc.Stopped { + return fmt.Errorf("exact native broker entered unexpected state %d", status.State) + } + if requireStopped && status.State != svc.Stopped { + return errors.New("exact native broker restarted before owned cleanup") + } + return nil +} + +func (t *windowsNativePackageUninstallTransaction) verifyOwnedFileSnapshots() error { + for _, file := range t.ownedFiles { + if file.handle == 0 { + return fmt.Errorf("exact %s snapshot handle was released", file.kind) + } + hash, err := hashNativePackageHandle(file.handle) + if err != nil { + return fmt.Errorf("hash exact %s snapshot: %w", file.kind, err) + } + if !strings.EqualFold(hash, file.hash) { + return fmt.Errorf("exact %s snapshot hash changed", file.kind) + } + } + return nil +} + +func (t *windowsNativePackageUninstallTransaction) Close() error { + if t.closed { + return nil + } + t.closed = true + var closeErrors []error + if t.liveLog != nil { + if err := windows.CloseHandle(t.liveLog.handle); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("close active exact native broker log identity: %w", err)) + } + t.liveLog = nil + } + t.liveLogPath = "" + for index := len(t.ownedFiles) - 1; index >= 0; index-- { + file := t.ownedFiles[index] + if file.handle != 0 { + if err := windows.CloseHandle(file.handle); err != nil { + closeErrors = append(closeErrors, + fmt.Errorf("close exact %s snapshot: %w", file.kind, err)) + } + file.handle = 0 + } + } + closeNativePackageUninstallHandles(t.managedDirectories) + t.managedDirectories = nil + if t.helperHandle != 0 { + if err := windows.CloseHandle(t.helperHandle); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("close packaged driver helper: %w", err)) + } + t.helperHandle = 0 + } + closeNativePackageUninstallHandles(t.helperHandles) + t.helperHandles = nil + if t.service != nil { + if err := t.service.Close(); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("close exact native broker service: %w", err)) + } + t.service = nil + } + if t.manager != nil { + if err := t.manager.Close(); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("close SCM: %w", err)) + } + t.manager = nil + } + // Release nested thread-owned mutexes in reverse global acquisition order. + if t.releaseServiceMutex != nil { + t.releaseServiceMutex() + t.releaseServiceMutex = nil + } + if t.releasePackageMutex != nil { + t.releasePackageMutex() + t.releasePackageMutex = nil + } + return errors.Join(closeErrors...) +} + +func closeNativePackageUninstallHandles(handles []windows.Handle) { + for index := len(handles) - 1; index >= 0; index-- { + windows.CloseHandle(handles[index]) //nolint:errcheck + } +} diff --git a/internal/cmd/native_package_uninstall_windows_test.go b/internal/cmd/native_package_uninstall_windows_test.go new file mode 100644 index 00000000..256b831e --- /dev/null +++ b/internal/cmd/native_package_uninstall_windows_test.go @@ -0,0 +1,297 @@ +//go:build windows + +package cmd + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +func TestNativePackageUninstallSerializesConcurrentPackageTransactions(t *testing.T) { + requireNativeMutexAdministrator(t) + name := `VIIPER_NATIVE_UNINSTALL_TEST_` + filepath.Base(t.TempDir()) + releaseFirst, err := acquireNamedNativePackageMutex(name, time.Second) + if err != nil { + t.Fatalf("acquire first package owner: %v", err) + } + secondResult := make(chan error, 1) + go func() { + releaseSecond, secondErr := acquireNamedNativePackageMutex(name, 40*time.Millisecond) + if releaseSecond != nil { + releaseSecond() + } + secondResult <- secondErr + }() + select { + case secondErr := <-secondResult: + if secondErr == nil || + (!strings.Contains(secondErr.Error(), "still running") && + !errors.Is(secondErr, windows.ERROR_ACCESS_DENIED)) { + releaseFirst() + t.Fatalf("concurrent package owner error=%v", secondErr) + } + case <-time.After(2 * time.Second): + releaseFirst() + t.Fatal("concurrent package owner did not respect its bounded wait") + } + releaseFirst() + + releaseAfter, err := acquireNamedNativePackageMutex(name, time.Second) + if err != nil { + t.Fatalf("package mutex remained stranded after release: %v", err) + } + releaseAfter() +} + +func TestNativePackageUninstallRenamesRunningImageToUniqueTombstone(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "viiper.exe") + if err := os.WriteFile(path, []byte("exact-native-broker"), 0o600); err != nil { + t.Fatal(err) + } + file, err := lockNativePackageUninstallFile(path, "broker", "", false) + if err != nil { + t.Fatalf("lock test broker: %v", err) + } + defer func() { + if file.handle != 0 { + windows.CloseHandle(file.handle) //nolint:errcheck + } + }() + + tombstone, err := renameNativePackageUninstallFileToTombstone(file) + if err != nil { + t.Fatalf("rename exact broker to reboot tombstone: %v", err) + } + if filepath.Dir(tombstone) != root || + !strings.HasPrefix(filepath.Base(tombstone), ".viiper.uninstall.") || + !strings.HasSuffix(filepath.Base(tombstone), ".delete") { + t.Fatalf("unsafe reboot tombstone path %q", tombstone) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("canonical broker path remained after tombstone rename: %v", err) + } + if err := windows.CloseHandle(file.handle); err != nil { + t.Fatalf("close renamed exact broker handle: %v", err) + } + file.handle = 0 + contents, err := os.ReadFile(tombstone) + if err != nil { + t.Fatalf("read renamed tombstone: %v", err) + } + if string(contents) != "exact-native-broker" { + t.Fatalf("renamed tombstone contents=%q", contents) + } +} + +func TestNativePackageUninstallDeletesRetainedExactFileHandle(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "owned.log") + if err := os.WriteFile(path, []byte("exact-owned"), 0o600); err != nil { + t.Fatal(err) + } + owned, err := lockNativePackageUninstallFile(path, "test", "", false) + if err != nil { + t.Fatalf("lock exact file: %v", err) + } + if err := deleteNativePackageUninstallFileHandle(owned.handle); err != nil { + _ = os.NewFile(uintptr(owned.handle), path).Close() + t.Fatalf("mark exact handle for deletion: %v", err) + } + if err := os.NewFile(uintptr(owned.handle), path).Close(); err != nil { + t.Fatalf("close exact deleted handle: %v", err) + } + owned.handle = 0 + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("exact handle path still exists: %v", err) + } +} + +func TestNativePackageUninstallDoesNotPrelockBrokerLeafWithoutDeleteSharing(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "broker.exe") + if err := os.WriteFile(path, []byte("MZ-test-image"), 0o600); err != nil { + t.Fatal(err) + } + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + readOnly, err := windows.CreateFile( + pointer, windows.GENERIC_READ, windows.FILE_SHARE_READ, nil, + windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0, + ) + if err != nil { + t.Fatal(err) + } + if owned, lockErr := lockNativePackageUninstallFile(path, "broker", "", true); lockErr == nil { + if owned != nil && owned.handle != 0 { + _ = windows.CloseHandle(owned.handle) + } + _ = windows.CloseHandle(readOnly) + t.Fatal("a non-delete-shared prelock unexpectedly allowed the exact DELETE-capable snapshot") + } else if !errors.Is(lockErr, windows.ERROR_SHARING_VIOLATION) { + _ = windows.CloseHandle(readOnly) + t.Fatalf("conflicting prelock error=%v, want sharing violation", lockErr) + } + if err := windows.CloseHandle(readOnly); err != nil { + t.Fatal(err) + } + owned, err := lockNativePackageUninstallFile(path, "broker", "", true) + if err != nil { + t.Fatalf("direct exact broker snapshot: %v", err) + } + if err := windows.CloseHandle(owned.handle); err != nil { + t.Fatal(err) + } + owned.handle = 0 +} + +func TestNativePackageUninstallIdentifiesExactRunningImageByFileID(t *testing.T) { + t.Parallel() + executable, err := currentExecutable() + if err != nil { + t.Fatal(err) + } + pointer, err := windows.UTF16PtrFromString(executable) + if err != nil { + t.Fatal(err) + } + handle, err := windows.CreateFile( + pointer, windows.GENERIC_READ|windows.READ_CONTROL, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0, + ) + if err != nil { + t.Fatal(err) + } + defer windows.CloseHandle(handle) //nolint:errcheck + identity, err := nativePackageUninstallFileIdentity(handle) + if err != nil { + t.Fatal(err) + } + file := &windowsNativePackageUninstallFile{handle: handle, identity: identity} + current, err := nativePackageUninstallIsCurrentExecutable(file) + if err != nil { + t.Fatal(err) + } + if !current { + t.Fatal("exact current executable file ID was not recognized for safe reboot cleanup") + } +} + +func TestNativePackageUninstallRejectsHardLinkedManagedFile(t *testing.T) { + t.Parallel() + directory := t.TempDir() + path := filepath.Join(directory, "owned.log") + link := filepath.Join(directory, "alias.log") + if err := os.WriteFile(path, []byte("not-single-link"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Link(path, link); err != nil { + t.Skipf("hard links unavailable: %v", err) + } + owned, err := lockNativePackageUninstallFile(path, "test", "", false) + if owned != nil || err == nil { + if owned != nil && owned.handle != 0 { + _ = os.NewFile(uintptr(owned.handle), path).Close() + } + t.Fatalf("hard-linked managed file accepted: owned=%+v err=%v", owned, err) + } +} + +func TestNativePackageUninstallPromotesActiveLogIdentityAfterWriterStops(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "active.log") + writer, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + probe, err := lockNativePackageUninstallLiveLog(path) + if err != nil { + _ = writer.Close() + t.Fatalf("take active-log identity probe: %v", err) + } + defer func() { + if probe.handle != 0 { + _ = windows.CloseHandle(probe.handle) + } + }() + if owned, err := lockNativePackageUninstallFile(path, "broker-log", "", false); err == nil { + if owned != nil && owned.handle != 0 { + _ = windows.CloseHandle(owned.handle) + } + _ = writer.Close() + t.Fatal("delete-capable log lock unexpectedly succeeded while trusted writer was active") + } + if err := writer.Close(); err != nil { + t.Fatalf("stop active log writer: %v", err) + } + owned, err := lockNativePackageUninstallFile(path, "broker-log", "", false) + if err != nil { + t.Fatalf("promote stopped log lock: %v", err) + } + defer func() { + if owned.handle != 0 { + _ = windows.CloseHandle(owned.handle) + } + }() + if owned.identity != probe.identity { + t.Fatalf("promoted identity=%+v probe=%+v", owned.identity, probe.identity) + } +} + +func TestNativePackageUninstallRejectsActiveLogIdentitySwap(t *testing.T) { + t.Parallel() + directory := t.TempDir() + path := filepath.Join(directory, "active.log") + moved := filepath.Join(directory, "moved.log") + if err := os.WriteFile(path, []byte("captured"), 0o600); err != nil { + t.Fatal(err) + } + probe, err := lockNativePackageUninstallLiveLog(path) + if err != nil { + t.Fatalf("take active-log identity probe: %v", err) + } + transaction := &windowsNativePackageUninstallTransaction{ + liveLog: probe, liveLogPath: path, + } + defer func() { _ = transaction.Close() }() + if err := os.Rename(path, moved); err != nil { + t.Skipf("rename while delete-shared identity probe is held: %v", err) + } + if err := os.WriteFile(path, []byte("replacement"), 0o600); err != nil { + t.Fatal(err) + } + err = transaction.promoteNativePackageUninstallLiveLog(context.Background()) + if err == nil || !strings.Contains(err.Error(), "identity changed") { + t.Fatalf("replacement log identity accepted: %v", err) + } + if len(transaction.ownedFiles) != 0 { + t.Fatalf("replacement log became installer-owned: %+v", transaction.ownedFiles) + } +} + +func TestNativePackageUninstallCapturesLogCreatedBeforeStop(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "created-during-stop.log") + transaction := &windowsNativePackageUninstallTransaction{liveLogPath: path} + defer func() { _ = transaction.Close() }() + if err := os.WriteFile(path, []byte("trusted broker output"), 0o600); err != nil { + t.Fatal(err) + } + if err := transaction.promoteNativePackageUninstallLiveLog(context.Background()); err != nil { + t.Fatalf("capture log created before service stop: %v", err) + } + if len(transaction.ownedFiles) != 1 || transaction.ownedFiles[0].path != path { + t.Fatalf("created exact log was not locked: %+v", transaction.ownedFiles) + } +} diff --git a/internal/cmd/native_package_windows.go b/internal/cmd/native_package_windows.go new file mode 100644 index 00000000..c297f5c2 --- /dev/null +++ b/internal/cmd/native_package_windows.go @@ -0,0 +1,2245 @@ +//go:build windows + +package cmd + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +const nativePackageMutexName = "VIIPER.NativePackage.Install.v1" +const nativePackageTokenSDDL = "O:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)" + +var nativePackageDriverFiles = []string{ + "ViiperUde.inf", "ViiperUde.sys", "ViiperUde.cat", +} + +type windowsNativePackageTransaction struct { + logger *slog.Logger + request nativePackageRequest + nestedBrokerCommit bool + + releaseMutex func() + releaseServiceMutex func() + inputHandles []windows.Handle + sourceHandle windows.Handle + helperHandle windows.Handle + nestedBrokerHealthy bool + nestedMutationStarted bool + nestedRollbackSucceeded bool + nestedServiceRollbackSettled bool + driverQuiesceRequested bool + driverBrokerHandoff bool + driverHelperSettled bool + driverCoordinationErr error + pendingBrokerOuterSettlement bool + replayedBrokerRecovery bool + + programFiles string + destination string + parent string + parentHandle windows.Handle + parentMade bool + + manager nativeSCM + service nativeManagedService + serviceSnapshot nativePackageServiceSnapshot + priorServiceExecutable string + priorExecutableSHA256 string + priorServiceConfig mgr.Config + priorServiceDACL string + priorServiceRecovery []mgr.RecoveryAction + priorServiceReset uint32 + priorServiceNonCrash bool + priorExecutableRelease func() + stoppedTrustedService bool + weakServiceMutation bool + weakServiceRemoved bool + + temporaryPath string + backupPath string + destinationPublished bool + destinationRelease func() + tokenPath string + tokenSHA256 string + tokenHandle windows.Handle + boundOuterTokenPath string + installProof bool + brokerJournal *nativeBrokerJournal + brokerJournalProof nativeBrokerJournalProof + brokerJournalCutpoint func(string) error + closed bool +} + +func installNativePackage( + ctx context.Context, + logger *slog.Logger, + request nativePackageRequest, +) error { + transaction := &windowsNativePackageTransaction{logger: logger, request: request} + if err := runNativePackageTransaction(ctx, logger, transaction); err != nil { + return err + } + if transaction.replayedBrokerRecovery { + return &nativePackageRecoveryRetryError{} + } + return nil +} + +func commitNativePackageBroker( + logger *slog.Logger, + tokenPath, expectedTokenSHA256, expectedBrokerSHA256, targetUserSID, deadlineUnixMS string, + recoveryOnly bool, +) (nativePackageBrokerCommitResult, error) { + preflightFailure := func(err error) (nativePackageBrokerCommitResult, error) { + return nativePackageBrokerPreflightFailure(err) + } + deadlineMilliseconds, err := strconv.ParseInt(deadlineUnixMS, 10, 64) + if err != nil || deadlineMilliseconds <= 0 { + return preflightFailure(errors.New("native package transaction deadline must be positive Unix milliseconds")) + } + deadline := time.UnixMilli(deadlineMilliseconds) + if !deadline.After(time.Now()) || deadline.After(time.Now().Add(nativePackageTransactionTimeout)) { + return preflightFailure(errors.New("native package transaction deadline is expired or outside the package budget")) + } + if !filepath.IsAbs(tokenPath) || strings.IndexByte(tokenPath, 0) >= 0 { + return preflightFailure(errors.New("native package transaction token path must be absolute and contain no NUL")) + } + if _, err := validateNativeInstallingUserSID(targetUserSID); err != nil { + return preflightFailure(fmt.Errorf("validate package transaction target SID: %w", err)) + } + programFiles, err := windows.KnownFolderPath(windows.FOLDERID_ProgramFiles, windows.KF_FLAG_DEFAULT) + if err != nil { + return preflightFailure(fmt.Errorf("resolve Program Files: %w", err)) + } + expectedParent := filepath.Join(filepath.Clean(programFiles), "VIIPER") + base := filepath.Base(tokenPath) + if !strings.EqualFold(filepath.Dir(filepath.Clean(tokenPath)), expectedParent) || + !strings.HasPrefix(strings.ToLower(base), ".viiper.transaction.") || + !strings.HasSuffix(strings.ToLower(base), ".token") { + return preflightFailure(fmt.Errorf("package transaction token escaped the managed VIIPER directory: %s", tokenPath)) + } + handle, err := lockNativePackageInput(tokenPath) + if err != nil { + return preflightFailure(fmt.Errorf("lock package transaction token: %w", err)) + } + defer windows.CloseHandle(handle) //nolint:errcheck + if err := validateNativeSecurityDescriptor(handle, nativePackageTokenSDDL); err != nil { + return preflightFailure(fmt.Errorf("validate package transaction token ACL: %w", err)) + } + hash, err := hashNativePackageHandle(handle) + if err != nil { + return preflightFailure(fmt.Errorf("hash package transaction token: %w", err)) + } + if !strings.EqualFold(hash, expectedTokenSHA256) { + return preflightFailure(errors.New("package transaction token SHA-256 does not match the active installer")) + } + if !nativePackageSHA256.MatchString(expectedBrokerSHA256) { + return preflightFailure(errors.New("package transaction broker SHA-256 is malformed")) + } + held, err := nativePackageMutexHeldByAnotherOwner(nativePackageMutexName) + if err != nil { + return preflightFailure(fmt.Errorf("verify outer package transaction mutex: %w", err)) + } + if !held { + return preflightFailure(errors.New("outer native package transaction mutex is not held")) + } + ctx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + replayBudget := time.Until(deadline) + releaseReplayMutex, err := acquireNativeInstallMutex(replayBudget) + if err != nil { + _, active, pathErr := nativeBrokerJournalPaths(targetUserSID) + if pathErr == nil { + if _, attributeErr := nativePathAttributes(active); attributeErr == nil || + (!errors.Is(attributeErr, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(attributeErr, windows.ERROR_PATH_NOT_FOUND)) { + return nativePackageBrokerCommitResult{ + changed: true, rollback: "failed", exitCode: 3, + }, fmt.Errorf("acquire service transaction mutex with an active broker journal: %w", err) + } + } + return preflightFailure(fmt.Errorf("acquire service transaction mutex for broker proof replay: %w", err)) + } + replayProof, replayed, activeJournal, replayErr := replayNativeBrokerNestedReadyProof( + ctx, logger, targetUserSID, tokenPath, expectedTokenSHA256, expectedBrokerSHA256, + ) + releaseReplayMutex() + if replayErr != nil { + if activeJournal { + return nativePackageBrokerCommitResult{ + changed: true, rollback: "failed", exitCode: 3, journal: replayProof, + }, replayErr + } + return preflightFailure(replayErr) + } + if replayed { + logger.Info("Replayed exact durable nested broker readiness", + "transactionId", replayProof.TransactionID, "journalDigest", replayProof.Digest) + return nativePackageBrokerCommitResult{ + success: true, changed: true, rollback: "not-needed", exitCode: 0, + journal: replayProof, + }, nil + } + if activeJournal { + return nativePackageBrokerCommitResult{ + changed: true, rollback: "succeeded", exitCode: 1, journal: replayProof, + }, errors.New("reconciled an interrupted nested broker transaction to its exact prior state") + } + if recoveryOnly { + return preflightFailure(errors.New( + "broker recovery query found no exact active child transaction and will not start a new one", + )) + } + executable, err := currentExecutable() + if err != nil { + return preflightFailure(fmt.Errorf("resolve nested broker executable: %w", err)) + } + transaction := &windowsNativePackageTransaction{ + logger: logger, + request: nativePackageRequest{ + brokerSource: executable, expectedBrokerSHA256: expectedBrokerSHA256, + targetUserSID: targetUserSID, + }, + nestedBrokerCommit: true, + tokenSHA256: expectedTokenSHA256, + boundOuterTokenPath: tokenPath, + } + err = runNativePackageTransaction(ctx, logger, transaction) + if err == nil { + return nativePackageBrokerCommitResult{ + success: true, changed: transaction.nestedMutationStarted, + rollback: "not-needed", exitCode: 0, journal: transaction.brokerJournalProof, + }, nil + } + if !transaction.nestedMutationStarted { + return nativePackageBrokerPreflightFailure(err) + } + if transaction.nestedRollbackSucceeded { + return nativePackageBrokerCommitResult{ + changed: true, rollback: "succeeded", exitCode: 1, + journal: transaction.brokerJournalProof, + }, err + } + return nativePackageBrokerCommitResult{ + changed: true, rollback: "failed", exitCode: 3, + journal: transaction.brokerJournalProof, + }, err +} + +func (t *windowsNativePackageTransaction) Preflight(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + if t.nestedBrokerCommit { + return t.preflightNestedBrokerCommit() + } + mutexBudget := nativePackageTransactionTimeout + if deadline, ok := ctx.Deadline(); ok { + mutexBudget = time.Until(deadline) + if mutexBudget <= 0 { + return context.DeadlineExceeded + } + } + release, err := acquireNamedNativePackageMutex(nativePackageMutexName, mutexBudget) + if err != nil { + return err + } + t.releaseMutex = release + if _, err := validateNativeInstallingUserSID(t.request.targetUserSID); err != nil { + return fmt.Errorf("validate target user SID: %w", err) + } + programFiles, err := windows.KnownFolderPath(windows.FOLDERID_ProgramFiles, windows.KF_FLAG_DEFAULT) + if err != nil { + return fmt.Errorf("resolve Program Files known folder: %w", err) + } + t.programFiles = filepath.Clean(programFiles) + t.parent = filepath.Join(t.programFiles, "VIIPER") + t.destination = filepath.Join(t.parent, "viiper.exe") + if _, err := nativeServiceExecutableParent(t.programFiles, t.destination); err != nil { + return err + } + programFilesHandle, err := openNativePathWithoutReparse( + t.programFiles, windows.FILE_READ_ATTRIBUTES, true, + ) + if err != nil { + return fmt.Errorf("lock Program Files root: %w", err) + } + t.inputHandles = append(t.inputHandles, programFilesHandle) + for _, input := range []struct { + name string + directory string + }{ + {name: "broker source", directory: filepath.Dir(t.request.brokerSource)}, + {name: "driver helper", directory: filepath.Dir(t.request.driverHelper)}, + {name: "submission manifest", directory: filepath.Dir(t.request.submissionManifest)}, + {name: "signed driver package", directory: t.request.packageDirectory}, + } { + handles, lockErr := lockNativePackageDirectoryChain(input.directory) + if lockErr != nil { + return fmt.Errorf("lock %s directory chain: %w", input.name, lockErr) + } + t.inputHandles = append(t.inputHandles, handles...) + } + + t.sourceHandle, err = t.lockAndVerifyInput( + t.request.brokerSource, t.request.expectedBrokerSHA256, true, + ) + if err != nil { + return fmt.Errorf("verify installer-bound VIIPER broker: %w", err) + } + t.helperHandle, err = t.lockAndVerifyInput( + t.request.driverHelper, t.request.expectedHelperSHA256, true, + ) + if err != nil { + return fmt.Errorf("verify installer-bound driver helper: %w", err) + } + entries, err := os.ReadDir(t.request.packageDirectory) + if err != nil { + return fmt.Errorf("enumerate signed driver package: %w", err) + } + if len(entries) != len(nativePackageDriverFiles) { + return fmt.Errorf("signed runtime driver package must contain exactly INF, SYS, and CAT, found %d files", len(entries)) + } + for _, expected := range nativePackageDriverFiles { + matches := 0 + for _, entry := range entries { + if entry.Name() == expected && entry.Type().IsRegular() { + matches++ + } + } + if matches != 1 { + return fmt.Errorf("signed driver package must contain one case-exact regular %s", expected) + } + expectedHash := map[string]string{ + "ViiperUde.inf": t.request.expectedInfSHA256, + "ViiperUde.sys": t.request.expectedSysSHA256, + "ViiperUde.cat": t.request.expectedCatSHA256, + }[expected] + handle, lockErr := t.lockAndVerifyInput( + filepath.Join(t.request.packageDirectory, expected), expectedHash, false, + ) + if lockErr != nil { + return fmt.Errorf("verify installer-bound signed driver file %s: %w", expected, lockErr) + } + _ = handle + } + manifestHandle, err := t.lockAndVerifyInput( + t.request.submissionManifest, t.request.expectedManifestSHA256, false, + ) + if err != nil { + return fmt.Errorf("verify installer-bound driver manifest: %w", err) + } + _ = manifestHandle + + if attributes, attrErr := nativePathAttributes(t.parent); attrErr == nil { + if attributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 || + attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return errors.New("managed VIIPER directory is not a regular non-reparse directory") + } + parent, openErr := openNativePathWithoutReparse( + t.parent, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + ) + if openErr != nil { + return fmt.Errorf("open managed VIIPER directory: %w", openErr) + } + defer windows.CloseHandle(parent) //nolint:errcheck + if validateErr := validateNativeSecurityDescriptor(parent, nativeBrokerDirectorySDDL); validateErr != nil { + return fmt.Errorf("managed VIIPER directory is not installer-owned: %w", validateErr) + } + } else if !errors.Is(attrErr, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(attrErr, windows.ERROR_PATH_NOT_FOUND) { + return fmt.Errorf("inspect managed VIIPER directory: %w", attrErr) + } + return nil +} + +func (t *windowsNativePackageTransaction) preflightNestedBrokerCommit() error { + t.nestedServiceRollbackSettled = true + if _, err := validateNativeInstallingUserSID(t.request.targetUserSID); err != nil { + return fmt.Errorf("validate nested broker target SID: %w", err) + } + programFiles, err := windows.KnownFolderPath(windows.FOLDERID_ProgramFiles, windows.KF_FLAG_DEFAULT) + if err != nil { + return fmt.Errorf("resolve Program Files known folder: %w", err) + } + t.programFiles = filepath.Clean(programFiles) + t.parent = filepath.Join(t.programFiles, "VIIPER") + t.destination = filepath.Join(t.parent, "viiper.exe") + if _, err := nativeServiceExecutableParent(t.programFiles, t.destination); err != nil { + return err + } + programFilesHandle, err := openNativePathWithoutReparse( + t.programFiles, windows.FILE_READ_ATTRIBUTES, true, + ) + if err != nil { + return fmt.Errorf("lock Program Files root: %w", err) + } + t.inputHandles = append(t.inputHandles, programFilesHandle) + handles, err := lockNativePackageDirectoryChain(filepath.Dir(t.request.brokerSource)) + if err != nil { + return fmt.Errorf("lock nested broker source directory chain: %w", err) + } + t.inputHandles = append(t.inputHandles, handles...) + t.sourceHandle, err = t.lockAndVerifyInput( + t.request.brokerSource, t.request.expectedBrokerSHA256, true, + ) + if err != nil { + return fmt.Errorf("verify installer-bound nested VIIPER broker: %w", err) + } + return nil +} + +func (t *windowsNativePackageTransaction) InspectService( + ctx context.Context, +) (nativePackageServiceSnapshot, error) { + budget := nativePackageTransactionTimeout + if deadline, ok := ctx.Deadline(); ok { + budget = time.Until(deadline) + if budget <= 0 { + return nativePackageServiceSnapshot{}, context.DeadlineExceeded + } + } + release, err := acquireNativeInstallMutex(budget) + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("lock native broker service transaction: %w", err) + } + t.releaseServiceMutex = release + if t.nestedBrokerCommit { + if err := reconcileNativeBrokerJournalBeforeAdmission( + ctx, t.logger, t.request.targetUserSID, + ); err != nil { + return nativePackageServiceSnapshot{}, err + } + } else { + pending, err := reconcileNativeBrokerJournalBeforeOuterPackage( + ctx, t.logger, t.request.targetUserSID, + ) + if err != nil { + return nativePackageServiceSnapshot{}, err + } + t.pendingBrokerOuterSettlement = pending + } + manager, err := mgr.Connect() + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("connect to SCM: %w", err) + } + t.manager = &windowsNativeSCM{manager: manager} + service, err := t.manager.OpenService(NativeBrokerServiceName) + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + t.serviceSnapshot = nativePackageServiceSnapshot{disposition: nativePackageServiceAbsent} + return t.finalizeServiceInspection(ctx, t.serviceSnapshot) + } + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("open %s: %w", NativeBrokerServiceName, err) + } + t.service = service + config, err := service.Config() + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("query %s config: %w", NativeBrokerServiceName, err) + } + priorExecutable, err := nativeServiceExecutableFromCommandLine(config.BinaryPathName) + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("parse %s executable: %w", NativeBrokerServiceName, err) + } + if _, err := nativeServiceExecutableParent(t.programFiles, priorExecutable); err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf( + "refusing to delete or adopt non-owned %s: %w", NativeBrokerServiceName, err, + ) + } + t.priorServiceExecutable = priorExecutable + status, err := service.Query() + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("query %s state: %w", NativeBrokerServiceName, err) + } + status, err = settleNativeServiceSnapshot(ctx, service, status, waitContext) + if err != nil { + return nativePackageServiceSnapshot{}, err + } + securityDescriptor, err := service.SecurityDescriptor() + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("query %s DACL: %w", NativeBrokerServiceName, err) + } + keyPath, err := nativeServiceKeyFilePath() + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("resolve native broker credential: %w", err) + } + expectedConfig, _, err := nativeBrokerServiceConfiguration(priorExecutable, keyPath) + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("construct canonical native broker service: %w", err) + } + recovery, err := service.RecoveryActions() + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("query %s recovery actions: %w", + NativeBrokerServiceName, err) + } + reset, err := service.ResetPeriod() + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("query %s recovery reset: %w", + NativeBrokerServiceName, err) + } + nonCrash, err := service.RecoveryActionsOnNonCrashFailures() + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("query %s recovery mode: %w", + NativeBrokerServiceName, err) + } + canonical := isCanonicalNativePackageService( + config, expectedConfig, securityDescriptor, recovery, reset, nonCrash, + ) + disposition := nativePackageServiceWeakExactOwned + if canonical { + releaseExecutable, lockErr := lockNativePriorServiceExecutable(priorExecutable) + if lockErr == nil { + handle, openErr := lockNativePackageInput(priorExecutable) + if openErr == nil { + priorHash, hashErr := hashNativePackageHandle(handle) + closeErr := windows.CloseHandle(handle) + if hashErr == nil && closeErr == nil { + disposition = nativePackageServiceTrusted + t.priorExecutableRelease = releaseExecutable + t.priorExecutableSHA256 = priorHash + } else { + releaseExecutable() + lockErr = errors.Join(hashErr, closeErr) + } + } else { + releaseExecutable() + lockErr = openErr + } + } + if lockErr != nil { + // An exact service name/path with weak image ACLs is stale package + // ownership, not a trustworthy rollback source. It is removed and + // recreated; never "repair" its ACL while old handles may exist. + t.logger.Warn("Replacing weak exact-owned native broker service image", + "path", priorExecutable, "error", lockErr) + } else { + t.priorServiceConfig = config + t.priorServiceDACL = securityDescriptor + t.priorServiceRecovery = append([]mgr.RecoveryAction(nil), recovery...) + t.priorServiceReset = reset + t.priorServiceNonCrash = nonCrash + } + } + t.serviceSnapshot = nativePackageServiceSnapshot{ + disposition: disposition, + wasRunning: status.State == svc.Running, + } + return t.finalizeServiceInspection(ctx, t.serviceSnapshot) +} + +func (t *windowsNativePackageTransaction) finalizeServiceInspection( + ctx context.Context, + snapshot nativePackageServiceSnapshot, +) (nativePackageServiceSnapshot, error) { + if t.nestedBrokerCommit && snapshot.disposition == nativePackageServiceTrusted && snapshot.wasRunning { + healthy, err := t.verifyExactBrokerHealth(ctx) + if err != nil { + if ctx.Err() != nil { + return nativePackageServiceSnapshot{}, ctx.Err() + } + t.logger.Info("Exact native broker requires transactional repair", "reason", err) + } else { + t.nestedBrokerHealthy = healthy + } + } + t.serviceSnapshot = snapshot + return snapshot, nil +} + +func (t *windowsNativePackageTransaction) verifyExactBrokerHealth(ctx context.Context) (bool, error) { + if t.service == nil || !strings.EqualFold(t.priorServiceExecutable, t.destination) { + return false, errors.New("native broker service does not use the canonical package executable") + } + handle, err := lockNativePackageInput(t.priorServiceExecutable) + if err != nil { + return false, fmt.Errorf("lock exact native broker image: %w", err) + } + hash, hashErr := hashNativePackageHandle(handle) + closeErr := windows.CloseHandle(handle) + if hashErr != nil { + return false, fmt.Errorf("hash exact native broker image: %w", hashErr) + } + if closeErr != nil { + return false, fmt.Errorf("close exact native broker image: %w", closeErr) + } + if !strings.EqualFold(hash, t.request.expectedBrokerSHA256) { + return false, fmt.Errorf("native broker SHA-256=%s expected=%s", hash, t.request.expectedBrokerSHA256) + } + + credential, err := readNativeCredentialReadOnly(t.request.targetUserSID) + if err != nil { + return false, fmt.Errorf("read protected native broker credential: %w", err) + } + legacy, err := snapshotNativeLegacyStartup(ctx, t.request.targetUserSID) + if err != nil { + return false, fmt.Errorf("inspect legacy native broker ownership: %w", err) + } + if legacy.release != nil { + defer legacy.release() + } + if nativeLegacyStartupOwnsRuntime(legacy) { + return false, errors.New("active legacy VIIPER startup ownership is still registered") + } + + servicePID, err := requireNativeServiceProcess(t.service, 0) + if err != nil { + return false, err + } + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + if err := verifyNativeBrokerOnce(probeCtx, strings.TrimSpace(string(credential))); err != nil { + return false, err + } + if _, err := requireNativeServiceProcess(t.service, servicePID); err != nil { + return false, fmt.Errorf("revalidate exact native broker after authenticated ping: %w", err) + } + return true, nil +} + +func isCanonicalNativePackageService( + actual, expected mgr.Config, + securityDescriptor string, + recovery []mgr.RecoveryAction, + reset uint32, + nonCrash bool, +) bool { + return compareNativeSecurityDescriptorStrings( + securityDescriptor, nativeBrokerServiceSDDL, + ) == nil && nativeServiceConfigsEqual(actual, expected) && + slices.Equal(recovery, nativeServiceRecoveryActions) && + reset == nativeServiceRecoveryResetSecond && nonCrash +} + +func (t *windowsNativePackageTransaction) Prepare( + ctx context.Context, + snapshot nativePackageServiceSnapshot, +) error { + if snapshot.disposition != t.serviceSnapshot.disposition || + snapshot.wasRunning != t.serviceSnapshot.wasRunning { + return errors.New("native service snapshot changed before preparation") + } + if !t.nestedBrokerCommit { + return t.preparePackageCoordination() + } + if t.nestedBrokerHealthy { + return nil + } + journal, err := beginNativeBrokerJournal(ctx, t) + if err != nil { + return fmt.Errorf("arm durable native broker recovery: %w", err) + } + t.brokerJournal = journal + t.brokerJournalProof = journal.proof() + // From this point onward the nested callback may stop/delete SCM state or + // publish the canonical broker image. Any failure must prove rollback before + // the still-running helper may touch its captured driver snapshot again. + t.nestedMutationStarted = true + if snapshot.disposition == nativePackageServiceWeakExactOwned { + if err := t.removeWeakExactOwnedService(ctx); err != nil { + return err + } + } + if t.service != nil && snapshot.disposition == nativePackageServiceTrusted && + strings.EqualFold(t.priorServiceExecutable, t.destination) { + if snapshot.wasRunning { + // STOP is itself the mutation. Arm reconciliation before sending it so + // a timeout while StopPending cannot strand a formerly-running service. + t.stoppedTrustedService = true + if err := t.brokerJournal.appendPhase(nativeBrokerPhaseServiceStopIntent, ""); err != nil { + return fmt.Errorf("journal prior broker stop intent: %w", err) + } + if err := stopNativeService(ctx, t.service, waitContext); err != nil { + return fmt.Errorf("quiesce trusted %s for atomic image replacement: %w", + NativeBrokerServiceName, err) + } + if err := t.brokerJournal.appendPhase(nativeBrokerPhaseServiceStopped, ""); err != nil { + return fmt.Errorf("journal prior broker stopped state: %w", err) + } + } + // The read-only preflight lock deliberately denies rename/delete. Once + // the exact trusted service is quiescent, release that lock so the + // protected image can move to the rollback name in the same directory. + if t.priorExecutableRelease != nil { + t.priorExecutableRelease() + t.priorExecutableRelease = nil + } + } + return t.stageBrokerExecutable() +} + +func (t *windowsNativePackageTransaction) InstallDriverAndBroker(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + if t.nestedBrokerCommit { + if t.releaseServiceMutex == nil { + return errors.New("nested native broker transaction does not hold the service mutex") + } + if !t.nestedBrokerHealthy { + var evidence nativeBrokerInstallEvidence + if err := installNativeBrokerTransactionWithEvidence( + ctx, t.logger, t.destination, + productionNativeInstallDependenciesWithJournal( + t.request.targetUserSID, t.brokerJournal, + ), + &evidence, + ); err != nil { + t.nestedServiceRollbackSettled = + !evidence.mutationStarted || evidence.rollbackSucceeded + return fmt.Errorf("repair native broker transaction: %w", err) + } + } + } else { + if t.releaseServiceMutex == nil { + return errors.New("outer native package transaction does not hold the service mutex") + } + if err := t.runDriverHelper(ctx); err != nil { + return err + } + } + // A deadline that expires after the synchronous mutating helper starts must + // not turn its authenticated success into a contradictory outer rollback. + // The helper owns the driver snapshot and the nested broker owns its bounded + // SCM rollback; wait for that authoritative result, then commit its proof. + t.installProof = true + return nil +} + +func (t *windowsNativePackageTransaction) VerifyAuthenticatedHealth(ctx context.Context) error { + if t.nestedBrokerCommit && t.nestedBrokerHealthy { + healthy, err := t.verifyExactBrokerHealth(ctx) + if err != nil { + return fmt.Errorf("reverify exact native package no-op: %w", err) + } + if !healthy { + return errors.New("exact native package lost authenticated health before no-op commit") + } + } + // ViiperUdeCtl does not return success until the staged broker's native + // service transaction has performed authenticated ABI/capability health, + // removed legacy ownership, and authenticated a second time. Preserve that + // proof rather than adding a racy third ping after the inner commit. + if !t.installProof { + return errors.New("driver helper returned no authenticated broker health proof") + } + // The nested broker accepts this proof only when its authenticated health + // commit completed under the exact outer deadline. A scheduler delay between + // child exit and this check must not trigger a contradictory driver rollback. + if err := ctx.Err(); err != nil { + t.logger.Warn("Native package proof completed at the transaction deadline; finishing outer cleanup", + "deadline", err) + } + return nil +} + +func (t *windowsNativePackageTransaction) Commit(context.Context) error { + if t.nestedBrokerCommit && t.brokerJournal != nil { + if err := t.brokerJournal.appendPhase(nativeBrokerPhaseNestedReady, ""); err != nil { + return fmt.Errorf("persist nested broker readiness: %w", err) + } + t.brokerJournalProof = t.brokerJournal.proof() + } + if t.destinationRelease != nil { + t.destinationRelease() + t.destinationRelease = nil + } + if err := t.releaseCoordinationToken(); err != nil { + // The nested broker transaction has already authenticated the native + // service and removed legacy ownership. A stale token is inert without + // the outer package mutex, so retain it for repair instead of turning a + // committed installation into an unsafe rollback. + t.logger.Warn("Could not remove protected package transaction token after commit", + "path", t.tokenPath, "error", err) + } + if t.backupPath != "" { + if err := deleteNativePackageFile(t.backupPath); err != nil { + // Cleanup cannot invalidate an already-authenticated inner transaction. + // Keep the administrator-only backup for the next repair instead. + t.logger.Warn("Could not remove protected prior broker backup after commit", + "path", t.backupPath, "error", err) + } else { + t.backupPath = "" + } + } + return nil +} + +func (t *windowsNativePackageTransaction) Rollback(ctx context.Context) (resultErr error) { + defer func() { + if t.nestedBrokerCommit && t.nestedMutationStarted && resultErr == nil && + t.nestedServiceRollbackSettled { + t.nestedRollbackSucceeded = true + } + }() + var rollbackErrors []error + if t.brokerJournal != nil && t.brokerJournal.lastPhase() != nativeBrokerPhaseRollbackSettled && + t.brokerJournal.lastPhase() != nativeBrokerPhaseOuterSettled && + t.brokerJournal.lastPhase() != nativeBrokerPhaseManual && + nativeBrokerJournalPhaseIndex( + nativeBrokerForwardPhaseOrder, t.brokerJournal.lastPhase(), + ) >= 0 { + if err := t.brokerJournal.appendPhase(nativeBrokerPhaseRollbackIntent, ""); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("persist broker rollback intent: %w", err)) + } + } + if t.destinationRelease != nil { + t.destinationRelease() + t.destinationRelease = nil + } + retainTokenForRecovery := !t.nestedBrokerCommit && t.driverBrokerHandoff && + !t.driverHelperSettled + if retainTokenForRecovery { + if t.tokenHandle != 0 { + if err := windows.CloseHandle(t.tokenHandle); err != nil { + rollbackErrors = append(rollbackErrors, + fmt.Errorf("close retained package transaction token: %w", err)) + } + t.tokenHandle = 0 + } + t.logger.Warn("Retaining protected package transaction token for authoritative broker replay", + "path", t.tokenPath) + } else if err := t.releaseCoordinationToken(); err != nil { + rollbackErrors = append(rollbackErrors, + fmt.Errorf("remove package transaction token: %w", err)) + } + if t.nestedBrokerCommit && t.nestedMutationStarted && + !t.nestedServiceRollbackSettled { + // The inner SCM transaction deliberately leaves an indeterminate service + // stopped. Do not delete/replace the image it may still reference, restore + // a prior image under an indeterminate configuration, or restart it. Keep + // both protected images for explicit external reconciliation. + rollbackErrors = append(rollbackErrors, errors.New( + "nested native broker service rollback is unsettled; retaining staged and prior broker images and leaving the service stopped for external reconciliation")) + if t.brokerJournal != nil { + if err := t.brokerJournal.appendPhase(nativeBrokerPhaseManual, ""); err != nil { + rollbackErrors = append(rollbackErrors, err) + } + t.brokerJournalProof = t.brokerJournal.proof() + } + return errors.Join(rollbackErrors...) + } + if !t.nestedBrokerCommit && t.stoppedTrustedService && !t.driverHelperSettled { + rollbackErrors = append(rollbackErrors, errors.New( + "driver-helper or handoff proof is unsettled; leaving the prior trusted broker stopped for external reconciliation")) + return errors.Join(rollbackErrors...) + } + if !t.nestedBrokerCommit && t.stoppedTrustedService { + if err := t.restoreQuiescedPriorService(ctx); err != nil { + rollbackErrors = append(rollbackErrors, + fmt.Errorf("restore quiesced prior broker during outer rollback: %w", err)) + } + } + restored := true + if err := t.restoreBrokerExecutable(); err != nil { + restored = false + rollbackErrors = append(rollbackErrors, err) + } + if t.brokerJournal != nil && restored { + if err := t.brokerJournal.appendPhase(nativeBrokerPhaseRollbackImage, ""); err != nil { + rollbackErrors = append(rollbackErrors, err) + resultErr = errors.Join(rollbackErrors...) + return resultErr + } + _, priorLegacy, err := t.brokerJournal.loadProtectedArtifacts() + if err != nil { + rollbackErrors = append(rollbackErrors, err) + } else if err := restoreNativeBrokerJournalLegacy(ctx, t.brokerJournal, priorLegacy); err != nil { + rollbackErrors = append(rollbackErrors, err) + } else if err := t.brokerJournal.appendPhase(nativeBrokerPhaseRollbackLegacy, ""); err != nil { + rollbackErrors = append(rollbackErrors, err) + } + } + if t.nestedBrokerCommit && t.stoppedTrustedService && t.service != nil && + t.serviceSnapshot.wasRunning { + if !restored { + rollbackErrors = append(rollbackErrors, + errors.New("refusing to restart prior native broker because its image was not restored")) + return errors.Join(rollbackErrors...) + } + release, err := lockNativePriorServiceExecutable(t.priorServiceExecutable) + if err != nil { + rollbackErrors = append(rollbackErrors, + fmt.Errorf("revalidate restored native broker before restart: %w", err)) + return errors.Join(rollbackErrors...) + } + defer release() + if err := reconcileNativePackageServiceRunning(ctx, t.service); err != nil { + rollbackErrors = append(rollbackErrors, + fmt.Errorf("restore prior trusted %s run state: %w", NativeBrokerServiceName, err)) + } + } + if t.brokerJournal != nil { + if len(rollbackErrors) != 0 { + if err := t.brokerJournal.appendPhase(nativeBrokerPhaseManual, ""); err != nil { + rollbackErrors = append(rollbackErrors, err) + } + t.brokerJournalProof = t.brokerJournal.proof() + } else if err := t.brokerJournal.appendPhase(nativeBrokerPhaseRollbackSettled, ""); err != nil { + rollbackErrors = append(rollbackErrors, err) + } else { + t.brokerJournalProof = t.brokerJournal.proof() + if err := retireNativeBrokerJournal(t.brokerJournal); err != nil { + t.logger.Warn("Retaining settled native broker rollback journal for later cleanup", + "transactionId", t.brokerJournalProof.TransactionID, "error", err) + } else { + t.brokerJournal = nil + } + } + } + return errors.Join(rollbackErrors...) +} + +func (t *windowsNativePackageTransaction) Close() error { + if t.closed { + return nil + } + t.closed = true + if t.destinationRelease != nil { + t.destinationRelease() + t.destinationRelease = nil + } + if t.priorExecutableRelease != nil { + t.priorExecutableRelease() + t.priorExecutableRelease = nil + } + if t.tokenHandle != 0 { + windows.CloseHandle(t.tokenHandle) //nolint:errcheck + t.tokenHandle = 0 + } + if t.service != nil { + t.service.Close() //nolint:errcheck + } + if t.manager != nil { + t.manager.Close() //nolint:errcheck + } + if t.parentHandle != 0 { + windows.CloseHandle(t.parentHandle) //nolint:errcheck + } + for index := len(t.inputHandles) - 1; index >= 0; index-- { + windows.CloseHandle(t.inputHandles[index]) //nolint:errcheck + } + if t.releaseServiceMutex != nil { + t.releaseServiceMutex() + t.releaseServiceMutex = nil + } + if t.releaseMutex != nil { + t.releaseMutex() + t.releaseMutex = nil + } + return nil +} + +func (t *windowsNativePackageTransaction) releaseCoordinationToken() error { + if t.tokenHandle != 0 { + if err := windows.CloseHandle(t.tokenHandle); err != nil { + return fmt.Errorf("close protected package transaction token: %w", err) + } + t.tokenHandle = 0 + } + if t.tokenPath == "" { + return nil + } + path := t.tokenPath + if err := deleteNativePackageFile(path); err != nil && + !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { + return err + } + t.tokenPath = "" + t.tokenSHA256 = "" + return nil +} + +func (t *windowsNativePackageTransaction) lockAndVerifyInput( + path, expectedHash string, + requirePE bool, +) (windows.Handle, error) { + handle, err := lockNativePackageInput(path) + if err != nil { + return 0, err + } + hash, err := hashNativePackageHandle(handle) + if err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return 0, err + } + if !strings.EqualFold(hash, expectedHash) { + windows.CloseHandle(handle) //nolint:errcheck + return 0, fmt.Errorf("SHA-256=%s expected=%s", hash, expectedHash) + } + if requirePE { + if err := requireNativePackagePE(handle); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return 0, err + } + } + t.inputHandles = append(t.inputHandles, handle) + return handle, nil +} + +func (t *windowsNativePackageTransaction) runDriverHelper(ctx context.Context) error { + text, err := t.executeDriverHelper(ctx) + processExitCode := 0 + if err != nil { + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + return fmt.Errorf("wait for native driver helper: %w: %s", err, text) + } + processExitCode = exitError.ExitCode() + } + proof, proofErr := parseNativePackageInstallProof(text, processExitCode) + if proofErr != nil { + return fmt.Errorf("validate native driver helper proof: %w: %s", proofErr, text) + } + if proof.journalRecovery == "replayed" && !t.pendingBrokerOuterSettlement { + return errors.New("driver helper replayed a broker journal without a preexisting pending outer settlement") + } + if proof.journalRecovery != "replayed" && t.pendingBrokerOuterSettlement { + return errors.New("driver helper did not replay the preexisting pending broker settlement") + } + settleCtx, cancelSettle := context.WithTimeout( + context.WithoutCancel(ctx), nativePackageRollbackTimeout, + ) + defer cancelSettle() + if proof.success && proof.journal.TransactionID != "" { + var activeJournal *nativeBrokerJournal + var prepared nativeBrokerOuterSettlementPrepared + var receipt nativePackageBrokerSettlementReceipt + var settledJournal *nativeBrokerJournal + var finalReceipt nativeBrokerOuterSettlementFinalPrepared + journalErr := executeNativeBrokerOuterSettlement(nativeBrokerOuterSettlementOperations{ + recordPending: func() error { + var err error + activeJournal, prepared, err = armNativeBrokerOuterSettlement( + settleCtx, t.request.targetUserSID, t.tokenSHA256, + t.request.expectedBrokerSHA256, proof, + ) + if activeJournal != nil { + t.brokerJournalProof = activeJournal.proof() + } + return err + }, + publishRequest: func() error { + return publishNativeBrokerOuterSettlementRequest(activeJournal, prepared) + }, + acknowledgeDriver: func() error { + if activeJournal != nil && + activeJournal.lastPhase() == nativeBrokerPhaseOuterSettled { + existingFinal, err := loadNativeBrokerOuterSettlementFinalForReconciliation( + activeJournal, + ) + if err == nil { + finalReceipt = existingFinal + receipt = nativeBrokerDriverReceiptFromFinal(existingFinal.Receipt) + return validateNativeBrokerOuterSettlementReceipt(prepared, receipt) + } + if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return err + } + } + var err error + receipt, err = t.executeDriverBrokerSettlementAck(settleCtx, prepared) + return err + }, + recordBrokerSettled: func() error { + var err error + settledJournal, finalReceipt, err = recordNativeBrokerOuterSettlement( + settleCtx, t.request.targetUserSID, receipt, + ) + if settledJournal != nil { + t.brokerJournalProof = settledJournal.proof() + } + return err + }, + retireBrokerJournal: func() error { + return retireNativeBrokerJournal(settledJournal) + }, + discardInertState: func() error { + return t.discardDriverBrokerSettlementTombstone( + settleCtx, prepared, finalReceipt, receipt, t.brokerJournalProof, + ) + }, + observeDiscardError: func(err error) { + t.logger.Warn("Retaining inert settled transaction artifacts for later cleanup", + "brokerTransactionId", proof.journal.TransactionID, "error", err) + }, + }) + if journalErr != nil { + t.driverHelperSettled = false + return fmt.Errorf("complete durable two-phase broker settlement: %w", journalErr) + } + if proof.journalRecovery == "replayed" { + if err := discardSettledNativeBrokerOuterToken(settledJournal); err != nil { + t.logger.Warn("Retaining inert settled outer token after cleanup error", + "brokerTransactionId", proof.journal.TransactionID, "error", err) + } + } + t.logger.Info("Native broker and outer package journals reached exact settlement", + "brokerTransactionId", t.brokerJournalProof.TransactionID, + "brokerJournalDigest", t.brokerJournalProof.Digest, + "driverTransactionId", receipt.DriverTransactionID, + "driverJournalDigest", receipt.Digest) + } else { + journalProof, journalErr := reconcileNativeBrokerJournalAfterOuterFailure( + settleCtx, t.request.targetUserSID, t.tokenSHA256, + t.request.expectedBrokerSHA256, proof, + ) + t.brokerJournalProof = journalProof + if journalErr != nil { + t.driverHelperSettled = false + return fmt.Errorf("reconcile durable native broker ownership after driver proof: %w", journalErr) + } + } + if proof.journalRecovery == "replayed" { + t.replayedBrokerRecovery = true + } + t.driverHelperSettled = proof.exitCode != 3 + if proof.success && !t.driverBrokerHandoff && proof.journalRecovery != "replayed" { + t.driverHelperSettled = false + return errors.New("native driver helper reported success without the broker service handoff") + } + if !proof.success && t.stoppedTrustedService && t.driverHelperSettled { + rollbackCtx, cancel := context.WithTimeout( + context.WithoutCancel(ctx), nativePackageRollbackTimeout, + ) + restoreErr := t.restoreQuiescedPriorService(rollbackCtx) + cancel() + if restoreErr != nil { + return fmt.Errorf("restore trusted native broker after settled helper failure: %w", restoreErr) + } + } + if proof.success { + // The authenticated nested broker commit now owns the service run state. + t.stoppedTrustedService = false + } + if t.driverCoordinationErr != nil { + return fmt.Errorf("coordinate native broker quiescence: %w", t.driverCoordinationErr) + } + if proof.exitCode == nativePackageRebootRequiredCode { + return &nativePackageRebootRequiredError{cause: fmt.Errorf("%w: %s", err, text)} + } + if !proof.success { + return fmt.Errorf("native driver helper failed with exit %d: %w: %s", + proof.exitCode, err, text) + } + return nil +} + +func (t *windowsNativePackageTransaction) executePinnedDriverHelper( + arguments []string, +) (string, int, error) { + if t.releaseMutex == nil || t.helperHandle == 0 { + return "", 0, errors.New("driver helper replay requires the held package mutex and pinned helper") + } + helperHash, err := hashNativePackageHandle(t.helperHandle) + if err != nil { + return "", 0, fmt.Errorf("rehash pinned driver helper: %w", err) + } + if !strings.EqualFold(helperHash, t.request.expectedHelperSHA256) { + return "", 0, errors.New("pinned driver helper identity changed before journal replay") + } + // The helper owns its write-through journal transition. Its propagated + // deadline is cooperative; killing it between FlushFileBuffers and readback + // would manufacture the very indeterminate boundary this handshake closes. + command := exec.Command(t.request.driverHelper, arguments...) + command.Dir = filepath.Dir(t.request.driverHelper) + var output bytes.Buffer + command.Stdout = &output + command.Stderr = &output + err = command.Run() + processExitCode := 0 + if err != nil { + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + return output.String(), 0, err + } + processExitCode = exitError.ExitCode() + } + return output.String(), processExitCode, err +} + +func (t *windowsNativePackageTransaction) executeDriverBrokerSettlementAck( + ctx context.Context, + prepared nativeBrokerOuterSettlementPrepared, +) (nativePackageBrokerSettlementReceipt, error) { + deadline, ok := ctx.Deadline() + if !ok || !deadline.After(time.Now()) { + return nativePackageBrokerSettlementReceipt{}, context.DeadlineExceeded + } + output, processExitCode, processErr := t.executePinnedDriverHelper([]string{ + "broker-settlement-ack", + "--request", prepared.RequestPath, + "--request-sha256", prepared.RequestSHA256, + "--transaction-deadline-unix-ms", strconv.FormatInt(deadline.UnixMilli(), 10), + }) + if processErr != nil && processExitCode == 0 { + return nativePackageBrokerSettlementReceipt{}, fmt.Errorf( + "run driver settlement acknowledgement: %w", processErr, + ) + } + receipt, err := parseNativePackageBrokerSettlementReceipt(output, processExitCode) + if err != nil { + return nativePackageBrokerSettlementReceipt{}, fmt.Errorf( + "validate driver settlement acknowledgement: %w: %s", err, output, + ) + } + if err := validateNativeBrokerOuterSettlementReceipt(prepared, receipt); err != nil { + return nativePackageBrokerSettlementReceipt{}, err + } + return receipt, nil +} + +func (t *windowsNativePackageTransaction) discardDriverBrokerSettlementTombstone( + ctx context.Context, + prepared nativeBrokerOuterSettlementPrepared, + finalReceipt nativeBrokerOuterSettlementFinalPrepared, + receipt nativePackageBrokerSettlementReceipt, + brokerProof nativeBrokerJournalProof, +) error { + deadline, ok := ctx.Deadline() + if !ok || !deadline.After(time.Now()) { + return context.DeadlineExceeded + } + output, processExitCode, processErr := t.executePinnedDriverHelper([]string{ + "broker-settlement-discard", + "--broker-transaction-id", brokerProof.TransactionID, + "--broker-settled-digest", brokerProof.Digest, + "--driver-transaction-id", receipt.DriverTransactionID, + "--driver-settled-digest", receipt.Digest, + "--settlement-nonce", receipt.SettlementNonce, + "--request-sha256", receipt.RequestSHA256, + "--broker-final-receipt", finalReceipt.ReceiptPath, + "--broker-final-receipt-sha256", finalReceipt.ReceiptSHA256, + "--transaction-deadline-unix-ms", strconv.FormatInt(deadline.UnixMilli(), 10), + }) + if processErr != nil && processExitCode == 0 { + return fmt.Errorf("discard settled driver journal tombstone: %w", processErr) + } + discard, err := parseNativePackageBrokerSettlementDiscardReceipt(output, processExitCode) + if err != nil { + return fmt.Errorf("validate settled driver journal discard receipt: %w: %s", err, output) + } + if discard.BrokerTransactionID != brokerProof.TransactionID || + discard.BrokerDigest != brokerProof.Digest || + discard.DriverTransactionID != receipt.DriverTransactionID || + discard.DriverDigest != receipt.Digest || + discard.SettlementNonce != receipt.SettlementNonce || + discard.RequestSHA256 != receipt.RequestSHA256 { + return errors.New("settled driver journal discard receipt mismatched the exact two-phase transaction") + } + if discard.Retained { + t.logger.Warn("Retaining inert driver settlement cleanup tombstone", + "driverTransactionId", discard.DriverTransactionID, + "brokerTransactionId", discard.BrokerTransactionID) + } + return nil +} + +type nativePackageDriverCoordination struct { + quiesceRequest windows.Handle + quiesceReady windows.Handle + quiesceAbort windows.Handle + brokerHandoff windows.Handle +} + +func newNativePackageDriverCoordination() (*nativePackageDriverCoordination, error) { + attributes := &windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), + InheritHandle: 1, + } + coordination := &nativePackageDriverCoordination{} + create := func(target *windows.Handle, name string) error { + handle, err := windows.CreateEvent(attributes, 1, 0, nil) + if err != nil { + return fmt.Errorf("create inherited %s event: %w", name, err) + } + if handle == 0 { + return fmt.Errorf("create inherited %s event returned a null handle", name) + } + *target = handle + return nil + } + if err := create(&coordination.quiesceRequest, "broker quiesce request"); err != nil { + coordination.close() + return nil, err + } + if err := create(&coordination.quiesceReady, "broker quiesce ready"); err != nil { + coordination.close() + return nil, err + } + if err := create(&coordination.quiesceAbort, "broker quiesce abort"); err != nil { + coordination.close() + return nil, err + } + if err := create(&coordination.brokerHandoff, "broker handoff"); err != nil { + coordination.close() + return nil, err + } + return coordination, nil +} + +func (c *nativePackageDriverCoordination) close() { + if c == nil { + return + } + for _, handle := range []windows.Handle{ + c.brokerHandoff, c.quiesceAbort, c.quiesceReady, c.quiesceRequest, + } { + if handle != 0 { + windows.CloseHandle(handle) //nolint:errcheck + } + } + c.quiesceRequest = 0 + c.quiesceReady = 0 + c.quiesceAbort = 0 + c.brokerHandoff = 0 +} + +func (c *nativePackageDriverCoordination) inheritedHandles() []syscall.Handle { + return []syscall.Handle{ + syscall.Handle(c.quiesceRequest), syscall.Handle(c.quiesceReady), + syscall.Handle(c.quiesceAbort), syscall.Handle(c.brokerHandoff), + } +} + +func (c *nativePackageDriverCoordination) arguments() []string { + return []string{ + "--broker-quiesce-request-handle", strconv.FormatUint(uint64(c.quiesceRequest), 10), + "--broker-quiesce-ready-handle", strconv.FormatUint(uint64(c.quiesceReady), 10), + "--broker-quiesce-abort-handle", strconv.FormatUint(uint64(c.quiesceAbort), 10), + "--broker-handoff-handle", strconv.FormatUint(uint64(c.brokerHandoff), 10), + } +} + +func (t *windowsNativePackageTransaction) coordinateDriverHelper( + ctx context.Context, + process windows.Handle, + coordination *nativePackageDriverCoordination, +) error { + requestPending := true + handoffPending := true + for { + handles := []windows.Handle{process} + requestIndex := -1 + handoffIndex := -1 + if requestPending { + requestIndex = len(handles) + handles = append(handles, coordination.quiesceRequest) + } + if handoffPending { + handoffIndex = len(handles) + handles = append(handles, coordination.brokerHandoff) + } + status, err := windows.WaitForMultipleObjects(handles, false, windows.INFINITE) + if err != nil { + windows.SetEvent(coordination.quiesceAbort) //nolint:errcheck + return fmt.Errorf("wait for driver-helper coordination event: %w", err) + } + index := int(status - windows.WAIT_OBJECT_0) + switch index { + case 0: + return nil + case requestIndex: + requestPending = false + t.driverQuiesceRequested = true + if quiesceErr := t.quiescePriorServiceForDriver(ctx); quiesceErr != nil { + t.driverCoordinationErr = quiesceErr + if signalErr := windows.SetEvent(coordination.quiesceAbort); signalErr != nil { + return errors.Join(quiesceErr, + fmt.Errorf("signal broker quiescence abort: %w", signalErr)) + } + continue + } + if signalErr := windows.SetEvent(coordination.quiesceReady); signalErr != nil { + windows.SetEvent(coordination.quiesceAbort) //nolint:errcheck + return fmt.Errorf("signal broker quiescence readiness: %w", signalErr) + } + case handoffIndex: + handoffPending = false + if t.driverCoordinationErr != nil { + return errors.New("driver helper requested broker handoff after quiescence was aborted") + } + if handoffErr := t.releaseServiceForBrokerHandoff(); handoffErr != nil { + return handoffErr + } + default: + windows.SetEvent(coordination.quiesceAbort) //nolint:errcheck + return fmt.Errorf("unexpected driver-helper coordination wait status 0x%08x", status) + } + } +} + +func (t *windowsNativePackageTransaction) quiescePriorServiceForDriver(ctx context.Context) error { + if t.releaseServiceMutex == nil { + return errors.New("broker quiescence requires the held service mutex") + } + switch t.serviceSnapshot.disposition { + case nativePackageServiceAbsent: + return nil + case nativePackageServiceWeakExactOwned: + // A noncanonical exact-owned service is not rollback material. Remove it + // under the held service mutex before the helper mutates the root bus; the + // nested broker transaction will recreate the canonical service only after + // the driver has reached its protected handoff point. + return t.removeWeakExactOwnedService(ctx) + case nativePackageServiceTrusted: + if t.service == nil { + return errors.New("trusted broker service snapshot has no live SCM handle") + } + if !t.serviceSnapshot.wasRunning { + return nil + } + // STOP is the parent transaction's mutation. Arm restoration before the + // control request so StopPending/timeouts cannot strand the prior broker. + t.stoppedTrustedService = true + if err := stopNativeService(ctx, t.service, waitContext); err != nil { + return fmt.Errorf("quiesce trusted %s before root-bus mutation: %w", + NativeBrokerServiceName, err) + } + return nil + default: + return errors.New("broker quiescence received an unknown service disposition") + } +} + +func (t *windowsNativePackageTransaction) removeWeakExactOwnedService(ctx context.Context) error { + if t.releaseServiceMutex == nil { + return errors.New("weak broker removal requires the held service mutex") + } + if t.serviceSnapshot.disposition != nativePackageServiceWeakExactOwned { + return errors.New("weak broker removal received a non-weak service snapshot") + } + if t.weakServiceRemoved { + return nil + } + if t.service == nil { + return errors.New("weak exact-owned broker snapshot has no live SCM handle") + } + if t.manager == nil { + return errors.New("weak exact-owned broker snapshot has no live SCM manager") + } + + // The snapshot is deliberately excluded from rollback trust. Arm the + // fail-closed state before the first STOP/DELETE mutation: a partial failure + // must never restart or restore an image/configuration that was not proven. + t.weakServiceMutation = true + if t.serviceSnapshot.wasRunning { + if err := stopNativeService(ctx, t.service, waitContext); err != nil { + return fmt.Errorf("stop weak exact-owned %s: %w", NativeBrokerServiceName, err) + } + } + if err := t.service.Delete(); err != nil && + !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { + return fmt.Errorf("delete weak exact-owned %s: %w", NativeBrokerServiceName, err) + } + if err := t.service.Close(); err != nil { + return fmt.Errorf("close weak exact-owned %s after deletion: %w", NativeBrokerServiceName, err) + } + t.service = nil + if err := waitForNativePackageServiceDeletion(ctx, t.manager); err != nil { + return err + } + t.weakServiceRemoved = true + return nil +} + +func (t *windowsNativePackageTransaction) releaseServiceForBrokerHandoff() error { + if t.releaseServiceMutex == nil { + return errors.New("broker handoff requires the held service mutex") + } + if t.priorExecutableRelease != nil { + t.priorExecutableRelease() + t.priorExecutableRelease = nil + } + if t.service != nil { + if err := t.service.Close(); err != nil { + return fmt.Errorf("close prior broker service handle before handoff: %w", err) + } + t.service = nil + } + if t.manager != nil { + if err := t.manager.Close(); err != nil { + return fmt.Errorf("close prior SCM handle before handoff: %w", err) + } + t.manager = nil + } + t.releaseServiceMutex() + t.releaseServiceMutex = nil + t.driverBrokerHandoff = true + return nil +} + +func (t *windowsNativePackageTransaction) ensurePriorServiceForRestore( + ctx context.Context, +) error { + if t.releaseServiceMutex == nil { + budget := nativePackageRollbackTimeout + if deadline, ok := ctx.Deadline(); ok { + budget = time.Until(deadline) + if budget <= 0 { + return context.DeadlineExceeded + } + } + release, err := acquireNativeInstallMutex(budget) + if err != nil { + return fmt.Errorf("reacquire native broker service mutex for rollback: %w", err) + } + t.releaseServiceMutex = release + } + if t.manager == nil { + manager, err := mgr.Connect() + if err != nil { + return fmt.Errorf("reconnect to SCM for broker rollback: %w", err) + } + t.manager = &windowsNativeSCM{manager: manager} + } + if t.service == nil { + service, err := t.manager.OpenService(NativeBrokerServiceName) + if err != nil { + return fmt.Errorf("reopen prior %s for rollback: %w", NativeBrokerServiceName, err) + } + t.service = service + } + return nil +} + +func (t *windowsNativePackageTransaction) validatePriorServiceForRestart() error { + if t.serviceSnapshot.disposition != nativePackageServiceTrusted || + t.priorServiceExecutable == "" || t.priorExecutableSHA256 == "" { + return errors.New("prior broker snapshot is not a trusted restart source") + } + config, err := t.service.Config() + if err != nil { + return fmt.Errorf("query prior broker config for restart: %w", err) + } + if !nativeServiceConfigsEqual(config, t.priorServiceConfig) { + return errors.New("prior broker config changed before rollback restart") + } + executable, err := nativeServiceExecutableFromCommandLine(config.BinaryPathName) + if err != nil || !strings.EqualFold(executable, t.priorServiceExecutable) { + return errors.New("prior broker executable changed before rollback restart") + } + dacl, err := t.service.SecurityDescriptor() + if err != nil { + return fmt.Errorf("query prior broker DACL for restart: %w", err) + } + if compareNativeSecurityDescriptorStrings(dacl, t.priorServiceDACL) != nil { + return errors.New("prior broker DACL changed before rollback restart") + } + recovery, err := t.service.RecoveryActions() + if err != nil { + return fmt.Errorf("query prior broker recovery actions for restart: %w", err) + } + reset, err := t.service.ResetPeriod() + if err != nil { + return fmt.Errorf("query prior broker recovery reset for restart: %w", err) + } + nonCrash, err := t.service.RecoveryActionsOnNonCrashFailures() + if err != nil { + return fmt.Errorf("query prior broker recovery mode for restart: %w", err) + } + if !slices.Equal(recovery, t.priorServiceRecovery) || reset != t.priorServiceReset || + nonCrash != t.priorServiceNonCrash { + return errors.New("prior broker recovery policy changed before rollback restart") + } + if t.priorExecutableRelease == nil { + release, lockErr := lockNativePriorServiceExecutable(t.priorServiceExecutable) + if lockErr != nil { + return fmt.Errorf("relock protected prior broker executable: %w", lockErr) + } + t.priorExecutableRelease = release + } + handle, err := lockNativePackageInput(t.priorServiceExecutable) + if err != nil { + return fmt.Errorf("reopen protected prior broker executable: %w", err) + } + hash, hashErr := hashNativePackageHandle(handle) + closeErr := windows.CloseHandle(handle) + if hashErr != nil || closeErr != nil { + return fmt.Errorf("rehash protected prior broker executable: %w", + errors.Join(hashErr, closeErr)) + } + if !strings.EqualFold(hash, t.priorExecutableSHA256) { + return fmt.Errorf("prior broker executable SHA-256=%s expected=%s", + hash, t.priorExecutableSHA256) + } + return nil +} + +func (t *windowsNativePackageTransaction) restoreQuiescedPriorService(ctx context.Context) error { + if !t.stoppedTrustedService || !t.serviceSnapshot.wasRunning { + return nil + } + if err := t.ensurePriorServiceForRestore(ctx); err != nil { + return err + } + if err := t.validatePriorServiceForRestart(); err != nil { + return err + } + if err := reconcileNativePackageServiceRunning(ctx, t.service); err != nil { + return fmt.Errorf("restore prior trusted %s run state: %w", NativeBrokerServiceName, err) + } + if err := t.validatePriorServiceForRestart(); err != nil { + return fmt.Errorf("revalidate restarted prior broker: %w", err) + } + t.stoppedTrustedService = false + return nil +} + +func (t *windowsNativePackageTransaction) executeDriverHelper(ctx context.Context) (string, error) { + deadline, ok := ctx.Deadline() + if !ok || !deadline.After(time.Now()) { + return "", context.DeadlineExceeded + } + arguments := []string{ + "install", filepath.Join(t.request.packageDirectory, "ViiperUde.inf"), + "--manifest", t.request.submissionManifest, + "--manifest-sha256", t.request.expectedManifestSHA256, + "--source-revision", t.request.sourceRevision, + "--validation-mode", t.request.driverValidationMode, + "--expected-inf-sha256", t.request.expectedInfSHA256, + "--expected-sys-sha256", t.request.expectedSysSHA256, + "--expected-cat-sha256", t.request.expectedCatSHA256, + "--transaction-deadline-unix-ms", strconv.FormatInt(deadline.UnixMilli(), 10), + "--broker-executable", t.request.brokerSource, + "--broker-sha256", t.request.expectedBrokerSHA256, + "--broker-token", t.tokenPath, + "--broker-token-sha256", t.tokenSHA256, + "--target-user-sid", t.request.targetUserSID, + } + coordination, err := newNativePackageDriverCoordination() + if err != nil { + return "", err + } + defer coordination.close() + arguments = append(arguments, coordination.arguments()...) + // Do not use CommandContext: killing ViiperUdeCtl could interrupt its in-memory + // DriverStore rollback or the broker's deferred SCM/credential rollback. + command := exec.Command(t.request.driverHelper, arguments...) + command.Dir = filepath.Dir(t.request.driverHelper) + command.SysProcAttr = &syscall.SysProcAttr{ + AdditionalInheritedHandles: coordination.inheritedHandles(), + } + var output bytes.Buffer + command.Stdout = &output + command.Stderr = &output + if err := command.Start(); err != nil { + return "", err + } + // The helper owns the driver snapshot and nested broker rollback. Its + // propagated absolute deadline is cooperative; never terminate it here. + err = waitNativePackageHelperCoordinated(command, func(process windows.Handle) error { + return t.coordinateDriverHelper(ctx, process, coordination) + }) + // Preserve exact record framing. The authenticated journal binding is valid + // only as one canonical newline-terminated line; trimming helper output here + // would erase that boundary before the strict parser can verify it. + return output.String(), err +} + +func reconcileNativePackageServiceRunning(ctx context.Context, service nativeManagedService) error { + for { + status, err := service.Query() + if err != nil { + return err + } + switch status.State { + case svc.Running: + return nil + case svc.Stopped: + if err := service.Start(); err != nil { + return err + } + case svc.StartPending, svc.StopPending: + // Reconcile the partial forward STOP before deciding whether START is + // required. Both paths remain bounded by the rollback-only context. + default: + return fmt.Errorf("unexpected service state %d during rollback", status.State) + } + if err := waitContext(ctx, nativeServiceStatePoll); err != nil { + return err + } + } +} + +func (t *windowsNativePackageTransaction) stageCoordinationToken() error { + path, err := t.uniqueManagedPath("transaction") + if err != nil { + return err + } + path = strings.TrimSuffix(path, ".tmp") + ".token" + content := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, content); err != nil { + return fmt.Errorf("generate package transaction token: %w", err) + } + security, err := nativeSecurityAttributes(nativePackageTokenSDDL) + if err != nil { + return err + } + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return err + } + handle, err := windows.CreateFile(pointer, windows.GENERIC_READ|windows.GENERIC_WRITE, + windows.FILE_SHARE_READ, security, windows.CREATE_NEW, + windows.FILE_ATTRIBUTE_HIDDEN|windows.FILE_FLAG_OPEN_REPARSE_POINT| + windows.FILE_FLAG_WRITE_THROUGH, 0) + if err != nil { + return fmt.Errorf("create protected package transaction token: %w", err) + } + fail := func(owned windows.Handle, failErr error) error { + if owned != 0 { + windows.CloseHandle(owned) //nolint:errcheck + } + _ = deleteNativePackageFile(path) + return failErr + } + var written uint32 + if err := windows.WriteFile(handle, content, &written, nil); err != nil { + return fail(handle, err) + } + if written != uint32(len(content)) { + return fail(handle, io.ErrShortWrite) + } + if err := windows.FlushFileBuffers(handle); err != nil { + return fail(handle, err) + } + if err := validateNativeSecurityDescriptor(handle, nativePackageTokenSDDL); err != nil { + return fail(handle, err) + } + if err := requireSingleNativeFileLink(handle); err != nil { + return fail(handle, err) + } + sum := sha256.Sum256(content) + // Seal the token before another process opens it. Windows share checks are + // symmetric: a new read-only open that specifies FILE_SHARE_READ still + // conflicts with this handle's existing GENERIC_WRITE access. Close the + // write-capable handle, reopen through the ordinary immutable-input path, + // and revalidate the exact bytes before publishing the path to the helper. + // The protected parent and token DACL exclude the unelevated race boundary; + // the retained read handle then prevents replacement for the transaction. + if err := windows.CloseHandle(handle); err != nil { + return fail(handle, fmt.Errorf("seal protected package transaction token: %w", err)) + } + handle = 0 + sealed, err := lockNativePackageInput(path) + if err != nil { + return fail(0, fmt.Errorf("reopen sealed package transaction token: %w", err)) + } + if err := validateNativeSecurityDescriptor(sealed, nativePackageTokenSDDL); err != nil { + return fail(sealed, fmt.Errorf("revalidate sealed package transaction token ACL: %w", err)) + } + sealedHash, err := hashNativePackageHandle(sealed) + if err != nil { + return fail(sealed, fmt.Errorf("rehash sealed package transaction token: %w", err)) + } + expectedHash := hex.EncodeToString(sum[:]) + if !strings.EqualFold(sealedHash, expectedHash) { + return fail(sealed, errors.New("sealed package transaction token changed during publication")) + } + t.tokenPath = path + t.tokenSHA256 = expectedHash + t.tokenHandle = sealed + return nil +} + +func (t *windowsNativePackageTransaction) ensureManagedPackageDirectory() error { + if t.parentHandle != 0 { + return nil + } + if attributes, err := nativePathAttributes(t.parent); err != nil { + if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return err + } + security, securityErr := nativeSecurityAttributes(nativeBrokerDirectorySDDL) + if securityErr != nil { + return securityErr + } + parentPointer, pointerErr := windows.UTF16PtrFromString(t.parent) + if pointerErr != nil { + return pointerErr + } + if createErr := windows.CreateDirectory(parentPointer, security); createErr != nil { + return fmt.Errorf("atomically create protected VIIPER directory: %w", createErr) + } + t.parentMade = true + } else if attributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 || + attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return errors.New("managed VIIPER path is not a regular directory") + } + parent, err := openNativePathWithoutReparse( + t.parent, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + ) + if err != nil { + return fmt.Errorf("lock protected VIIPER directory: %w", err) + } + t.parentHandle = parent + if err := validateNativeSecurityDescriptor(parent, nativeBrokerDirectorySDDL); err != nil { + return fmt.Errorf("validate protected VIIPER directory: %w", err) + } + return nil +} + +func (t *windowsNativePackageTransaction) preparePackageCoordination() error { + if t.nestedBrokerCommit { + return errors.New("nested broker transaction cannot create the outer coordination token") + } + if err := t.ensureManagedPackageDirectory(); err != nil { + return err + } + if t.tokenPath != "" || t.tokenHandle != 0 { + return errors.New("native package coordination token is already staged") + } + if err := t.stageCoordinationToken(); err != nil { + return err + } + return nil +} + +func (t *windowsNativePackageTransaction) stageBrokerExecutable() error { + if !t.nestedBrokerCommit { + return errors.New("broker image staging is owned by the nested service transaction") + } + if err := t.ensureManagedPackageDirectory(); err != nil { + return err + } + var err error + priorExists := false + if existing, openErr := openNativePathWithoutReparse( + t.destination, windows.GENERIC_READ|windows.READ_CONTROL, false, + ); openErr == nil { + if err := requireSingleNativeFileLink(existing); err != nil { + windows.CloseHandle(existing) //nolint:errcheck + return err + } + if err := validateNativeSecurityDescriptor(existing, nativeBrokerExecutableSDDL); err != nil { + windows.CloseHandle(existing) //nolint:errcheck + return fmt.Errorf("existing broker is not installer-owned: %w", err) + } + existingHash, hashErr := hashNativePackageHandle(existing) + windows.CloseHandle(existing) //nolint:errcheck + if hashErr != nil { + return hashErr + } + if strings.EqualFold(existingHash, t.request.expectedBrokerSHA256) { + release, err := lockNativeServiceExecutableReadOnly(t.destination) + if err != nil { + return err + } + t.destinationRelease = release + return nil + } + priorExists = true + } else if !errors.Is(openErr, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(openErr, windows.ERROR_PATH_NOT_FOUND) { + return fmt.Errorf("inspect existing broker: %w", openErr) + } + + if t.brokerJournal != nil { + t.temporaryPath = filepath.Join( + t.parent, ".viiper.staging."+t.brokerJournal.snapshot.TransactionID+".tmp", + ) + } else { + t.temporaryPath, err = t.uniqueManagedPath("staging") + if err != nil { + return err + } + } + if err := copyNativePackageHandleAtomically( + t.sourceHandle, t.temporaryPath, t.request.expectedBrokerSHA256, + ); err != nil { + return err + } + if t.brokerJournal != nil { + if err := t.brokerJournal.appendPhase( + nativeBrokerPhaseImageSwitchIntent, t.request.expectedBrokerSHA256, + ); err != nil { + return fmt.Errorf("journal broker image switch intent: %w", err) + } + } + if err := replaceNativePackageFileAtomically(t.temporaryPath, t.destination, priorExists); err != nil { + return fmt.Errorf("publish staged broker: %w", err) + } + t.temporaryPath = "" + t.destinationPublished = true + if t.brokerJournal != nil { + if err := t.brokerJournal.appendPhase( + nativeBrokerPhaseImageSwitched, t.request.expectedBrokerSHA256, + ); err != nil { + return fmt.Errorf("journal published broker image: %w", err) + } + } + release, err := lockNativeServiceExecutableReadOnly(t.destination) + if err != nil { + return fmt.Errorf("verify published protected broker: %w", err) + } + t.destinationRelease = release + return nil +} + +func (t *windowsNativePackageTransaction) restoreBrokerExecutable() error { + var restoreErrors []error + if t.temporaryPath != "" { + if err := deleteNativePackageFile(t.temporaryPath); err != nil && + !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { + restoreErrors = append(restoreErrors, fmt.Errorf("remove staged broker: %w", err)) + } + t.temporaryPath = "" + } + if t.destinationPublished && t.brokerJournal != nil { + if err := restoreNativeBrokerJournalImage(t.brokerJournal); err != nil { + restoreErrors = append(restoreErrors, fmt.Errorf("restore durable prior broker image: %w", err)) + } else { + t.destinationPublished = false + } + } else if t.destinationPublished { + handle, err := openNativePathWithoutReparse(t.destination, windows.GENERIC_READ, false) + if err != nil { + restoreErrors = append(restoreErrors, fmt.Errorf("lock rejected broker for rollback: %w", err)) + } else { + hash, hashErr := hashNativePackageHandle(handle) + windows.CloseHandle(handle) //nolint:errcheck + if hashErr != nil || !strings.EqualFold(hash, t.request.expectedBrokerSHA256) { + restoreErrors = append(restoreErrors, + errors.New("refusing to remove broker that changed after protected staging")) + } else if deleteErr := deleteNativePackageFile(t.destination); deleteErr != nil { + restoreErrors = append(restoreErrors, fmt.Errorf("remove rejected broker: %w", deleteErr)) + } + } + t.destinationPublished = false + } + if t.backupPath != "" { + if err := moveNativePackageFile(t.backupPath, t.destination, false); err != nil { + restoreErrors = append(restoreErrors, fmt.Errorf("restore prior broker: %w", err)) + } else { + t.backupPath = "" + } + } + if t.parentMade { + if t.parentHandle != 0 { + windows.CloseHandle(t.parentHandle) //nolint:errcheck + t.parentHandle = 0 + } + pointer, err := windows.UTF16PtrFromString(t.parent) + if err == nil { + err = windows.RemoveDirectory(pointer) + } + if err != nil && !errors.Is(err, windows.ERROR_DIR_NOT_EMPTY) { + restoreErrors = append(restoreErrors, fmt.Errorf("remove created VIIPER directory: %w", err)) + } + t.parentMade = false + } + return errors.Join(restoreErrors...) +} + +func (t *windowsNativePackageTransaction) uniqueManagedPath(label string) (string, error) { + var suffix [12]byte + if _, err := io.ReadFull(rand.Reader, suffix[:]); err != nil { + return "", err + } + return filepath.Join(t.parent, ".viiper."+label+"."+hex.EncodeToString(suffix[:])+".tmp"), nil +} + +func acquireNamedNativePackageMutex(name string, timeout time.Duration) (func(), error) { + return acquireNativeNamedMutex( + name, timeout, "another VIIPER native package transaction is still running", + ) +} + +// nativePackageMutexHeldByAnotherOwner proves that this short-lived broker +// commit is nested inside the signed outer package transaction. The helper is +// a separate process/thread, so acquiring the mutex here would deadlock; a +// zero-time wait must instead report WAIT_TIMEOUT. If the mutex is absent, +// abandoned, or acquirable, no authorized outer transaction exists. +func nativePackageMutexHeldByAnotherOwner(name string) (bool, error) { + return nativeNamedMutexHeldByAnotherOwner(name) +} + +func lockNativePackageInput(path string) (windows.Handle, error) { + pointer, err := windows.UTF16PtrFromString(filepath.Clean(path)) + if err != nil { + return 0, err + } + handle, err := windows.CreateFile(pointer, windows.GENERIC_READ|windows.READ_CONTROL, + windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0) + if err != nil { + return 0, err + } + info := nativeFileAttributeTagInfo{} + if err := windows.GetFileInformationByHandleEx(handle, windows.FileAttributeTagInfo, + (*byte)(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return 0, err + } + if info.FileAttributes&(windows.FILE_ATTRIBUTE_DIRECTORY|windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + windows.CloseHandle(handle) //nolint:errcheck + return 0, errors.New("input is not a regular non-reparse file") + } + if err := requireSingleNativeFileLink(handle); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return 0, err + } + return handle, nil +} + +// lockNativePackageDirectoryChain prevents path redirection after hashing. +// Holding only the final file does not stop an ancestor directory from being +// renamed and replaced before CreateProcess/SetupAPI reopens the same string. +func lockNativePackageDirectoryChain(directory string) ([]windows.Handle, error) { + directory = filepath.Clean(directory) + if !filepath.IsAbs(directory) || strings.IndexByte(directory, 0) >= 0 { + return nil, fmt.Errorf("package input directory must be absolute and contain no NUL: %s", directory) + } + volume := filepath.VolumeName(directory) + if len(volume) != 2 || volume[1] != ':' { + return nil, fmt.Errorf("package input directory must use a local drive path: %s", directory) + } + root := volume + string(filepath.Separator) + relative, err := filepath.Rel(root, directory) + if err != nil || filepath.IsAbs(relative) || relative == ".." || + strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return nil, fmt.Errorf("package input directory escaped its volume root: %s", directory) + } + paths := []string{root} + current := root + if relative != "." { + for _, component := range strings.Split(relative, string(filepath.Separator)) { + if component == "" || component == "." || component == ".." { + return nil, fmt.Errorf("package input directory has an unsafe component: %s", directory) + } + current = filepath.Join(current, component) + paths = append(paths, current) + } + } + handles := make([]windows.Handle, 0, len(paths)) + for _, path := range paths { + handle, openErr := openNativePathWithoutReparse( + path, windows.FILE_READ_ATTRIBUTES, true, + ) + if openErr != nil { + for index := len(handles) - 1; index >= 0; index-- { + windows.CloseHandle(handles[index]) //nolint:errcheck + } + return nil, fmt.Errorf("open non-reparse ancestor %s: %w", path, openErr) + } + handles = append(handles, handle) + } + return handles, nil +} + +func hashNativePackageHandle(handle windows.Handle) (string, error) { + if _, err := windows.SetFilePointer(handle, 0, nil, windows.FILE_BEGIN); err != nil { + return "", err + } + hash := sha256.New() + buffer := make([]byte, 64*1024) + for { + var read uint32 + if err := windows.ReadFile(handle, buffer, &read, nil); err != nil { + return "", err + } + if read == 0 { + break + } + _, _ = hash.Write(buffer[:read]) + } + if _, err := windows.SetFilePointer(handle, 0, nil, windows.FILE_BEGIN); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func requireNativePackagePE(handle windows.Handle) error { + if _, err := windows.SetFilePointer(handle, 0, nil, windows.FILE_BEGIN); err != nil { + return err + } + header := make([]byte, 2) + var read uint32 + if err := windows.ReadFile(handle, header, &read, nil); err != nil { + return err + } + if _, err := windows.SetFilePointer(handle, 0, nil, windows.FILE_BEGIN); err != nil { + return err + } + if read != 2 || header[0] != 'M' || header[1] != 'Z' { + return errors.New("file is not a Windows PE image") + } + return nil +} + +func nativeSecurityAttributes(sddl string) (*windows.SecurityAttributes, error) { + descriptor, err := windows.SecurityDescriptorFromString(sddl) + if err != nil { + return nil, err + } + return &windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: descriptor, + }, nil +} + +func copyNativePackageHandleAtomically( + source windows.Handle, + destination, expectedHash string, +) (resultErr error) { + security, err := nativeSecurityAttributes(nativeBrokerExecutableSDDL) + if err != nil { + return err + } + pointer, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + target, err := windows.CreateFile(pointer, windows.GENERIC_READ|windows.GENERIC_WRITE, + windows.FILE_SHARE_READ, security, windows.CREATE_NEW, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_WRITE_THROUGH, 0) + if err != nil { + return fmt.Errorf("create protected staged broker: %w", err) + } + defer func() { + windows.CloseHandle(target) //nolint:errcheck + if resultErr != nil { + _ = deleteNativePackageFile(destination) + } + }() + if _, err := windows.SetFilePointer(source, 0, nil, windows.FILE_BEGIN); err != nil { + return err + } + buffer := make([]byte, 64*1024) + for { + var read uint32 + if err := windows.ReadFile(source, buffer, &read, nil); err != nil { + return err + } + if read == 0 { + break + } + var written uint32 + if err := windows.WriteFile(target, buffer[:read], &written, nil); err != nil { + return err + } + if written != read { + return io.ErrShortWrite + } + } + if err := windows.FlushFileBuffers(target); err != nil { + return err + } + if err := validateNativeSecurityDescriptor(target, nativeBrokerExecutableSDDL); err != nil { + return err + } + if err := requireSingleNativeFileLink(target); err != nil { + return err + } + hash, err := hashNativePackageHandle(target) + if err != nil { + return err + } + if !strings.EqualFold(hash, expectedHash) { + return fmt.Errorf("staged broker SHA-256=%s expected=%s", hash, expectedHash) + } + return nil +} + +func moveNativePackageFile(source, destination string, replace bool) error { + from, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + to, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + flags := uint32(windows.MOVEFILE_WRITE_THROUGH) + if replace { + flags |= windows.MOVEFILE_REPLACE_EXISTING + } + return windows.MoveFileEx(from, to, flags) +} + +var replaceNativePackageFileW = windows.NewLazySystemDLL("kernel32.dll").NewProc("ReplaceFileW") + +func replaceNativePackageFileAtomically(source, destination string, destinationExists bool) error { + if !destinationExists { + return moveNativePackageFile(source, destination, false) + } + sourcePointer, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + destinationPointer, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + result, _, callErr := replaceNativePackageFileW.Call( + uintptr(unsafe.Pointer(destinationPointer)), + uintptr(unsafe.Pointer(sourcePointer)), + 0, + 1, // REPLACEFILE_WRITE_THROUGH + 0, + 0, + ) + if result == 0 { + if callErr == nil || errors.Is(callErr, windows.ERROR_SUCCESS) { + callErr = errors.New("ReplaceFileW returned false") + } + return callErr + } + return nil +} + +func deleteNativePackageFile(path string) error { + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return err + } + return windows.DeleteFile(pointer) +} + +func nativePathAttributes(path string) (uint32, error) { + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + return windows.GetFileAttributes(pointer) +} + +func waitForNativePackageServiceDeletion(ctx context.Context, manager nativeSCM) error { + for { + service, err := manager.OpenService(NativeBrokerServiceName) + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + return nil + } + if err != nil && !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { + return err + } + if service != nil { + service.Close() //nolint:errcheck + } + if err := waitContext(ctx, nativeServiceStatePoll); err != nil { + return fmt.Errorf("wait for weak %s deletion: %w", NativeBrokerServiceName, err) + } + } +} diff --git a/internal/cmd/native_package_windows_test.go b/internal/cmd/native_package_windows_test.go new file mode 100644 index 00000000..d26f8de5 --- /dev/null +++ b/internal/cmd/native_package_windows_test.go @@ -0,0 +1,357 @@ +//go:build windows + +package cmd + +import ( + "context" + "errors" + "slices" + "testing" + "time" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +func TestNativePackageDriverCoordinationUsesDistinctInheritedEvents(t *testing.T) { + t.Parallel() + coordination, err := newNativePackageDriverCoordination() + if err != nil { + t.Fatalf("create driver coordination: %v", err) + } + defer coordination.close() + seen := map[windows.Handle]bool{} + for _, handle := range []windows.Handle{ + coordination.quiesceRequest, coordination.quiesceReady, + coordination.quiesceAbort, coordination.brokerHandoff, + } { + if handle == 0 || seen[handle] { + t.Fatalf("coordination event handle=%d is null or duplicated", handle) + } + seen[handle] = true + status, err := windows.WaitForSingleObject(handle, 0) + if err != nil || status != uint32(windows.WAIT_TIMEOUT) { + t.Fatalf("coordination event %d initial wait=(0x%x, %v), want timeout", + handle, status, err) + } + } + if len(coordination.inheritedHandles()) != 4 || len(coordination.arguments()) != 8 { + t.Fatal("driver coordination did not publish exactly four inherited handle arguments") + } + if unsafe.Sizeof(windows.Handle(0)) != unsafe.Sizeof(uintptr(0)) { + t.Fatal("Windows handle width no longer matches the decimal handoff contract") + } +} + +func TestNativePackageDriverCoordinationHoldsServiceMutexUntilBrokerHandoff(t *testing.T) { + t.Parallel() + coordination, err := newNativePackageDriverCoordination() + if err != nil { + t.Fatal(err) + } + defer coordination.close() + process, err := windows.CreateEvent(nil, 1, 0, nil) + if err != nil { + t.Fatal(err) + } + defer windows.CloseHandle(process) //nolint:errcheck + + released := make(chan struct{}) + transaction := &windowsNativePackageTransaction{ + serviceSnapshot: nativePackageServiceSnapshot{disposition: nativePackageServiceAbsent}, + releaseServiceMutex: func() { + close(released) + }, + } + childDone := make(chan error, 1) + go func() { + if err := windows.SetEvent(coordination.quiesceRequest); err != nil { + childDone <- err + return + } + if status, err := windows.WaitForSingleObject(coordination.quiesceReady, 1000); err != nil || status != windows.WAIT_OBJECT_0 { + childDone <- errors.Join(err, errors.New("quiescence readiness was not signaled")) + return + } + select { + case <-released: + childDone <- errors.New("service mutex released before broker handoff") + return + default: + } + if err := windows.SetEvent(coordination.brokerHandoff); err != nil { + childDone <- err + return + } + select { + case <-released: + case <-time.After(time.Second): + childDone <- errors.New("service mutex remained held after broker handoff") + return + } + childDone <- windows.SetEvent(process) + }() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := transaction.coordinateDriverHelper(ctx, process, coordination); err != nil { + t.Fatalf("coordinate driver helper: %v", err) + } + if err := <-childDone; err != nil { + t.Fatal(err) + } + if !transaction.driverQuiesceRequested || !transaction.driverBrokerHandoff || + transaction.releaseServiceMutex != nil { + t.Fatalf("coordination state request=%v handoff=%v release=%v", + transaction.driverQuiesceRequested, transaction.driverBrokerHandoff, + transaction.releaseServiceMutex != nil) + } +} + +func TestNativePackageDriverQuiescenceStopsTrustedRunningService(t *testing.T) { + t.Parallel() + events := []string{} + service := &fakeNativeService{events: &events, status: svc.Status{State: svc.Running}} + transaction := &windowsNativePackageTransaction{ + serviceSnapshot: nativePackageServiceSnapshot{ + disposition: nativePackageServiceTrusted, + wasRunning: true, + }, + service: service, + releaseServiceMutex: func() {}, + } + if err := transaction.quiescePriorServiceForDriver(context.Background()); err != nil { + t.Fatalf("quiesce trusted service: %v", err) + } + if !transaction.stoppedTrustedService || service.status.State != svc.Stopped || + !slices.Equal(events, []string{"service-stop"}) { + t.Fatalf("trusted service state stopped=%v status=%d events=%v", + transaction.stoppedTrustedService, service.status.State, events) + } + + if err := transaction.quiescePriorServiceForDriver(context.Background()); err != nil { + t.Fatalf("repeat trusted quiescence: %v", err) + } +} + +func TestNativePackageDriverQuiescenceRemovesWeakExactOwnedService(t *testing.T) { + t.Parallel() + for _, running := range []bool{false, true} { + running := running + t.Run(map[bool]string{false: "stopped", true: "running"}[running], func(t *testing.T) { + t.Parallel() + events := []string{} + state := svc.Stopped + if running { + state = svc.Running + } + service := &fakeNativeService{events: &events, status: svc.Status{State: state}} + manager := newFakeNativeSCM(service, &events) + transaction := &windowsNativePackageTransaction{ + serviceSnapshot: nativePackageServiceSnapshot{ + disposition: nativePackageServiceWeakExactOwned, + wasRunning: running, + }, + service: service, manager: manager, + releaseServiceMutex: func() {}, + } + if err := transaction.quiescePriorServiceForDriver(context.Background()); err != nil { + t.Fatalf("quiesce weak service: %v", err) + } + want := []string{"service-delete", "service-open"} + if running { + want = append([]string{"service-stop"}, want...) + } + if !transaction.weakServiceMutation || !transaction.weakServiceRemoved || + transaction.service != nil || !service.deleted || !slices.Equal(events, want) { + t.Fatalf("weak service state mutation=%v removed=%v live=%v deleted=%v events=%v want=%v", + transaction.weakServiceMutation, transaction.weakServiceRemoved, + transaction.service != nil, service.deleted, events, want) + } + if err := transaction.quiescePriorServiceForDriver(context.Background()); err != nil { + t.Fatalf("repeat weak quiescence: %v", err) + } + if !slices.Equal(events, want) { + t.Fatalf("repeat weak quiescence mutated service again: events=%v want=%v", events, want) + } + }) + } +} + +func TestNativePackageDriverQuiescenceDoesNotRestoreWeakServiceOnDeleteFailure(t *testing.T) { + t.Parallel() + events := []string{} + service := &fakeNativeService{ + events: &events, status: svc.Status{State: svc.Running}, failDelete: errors.New("delete failed"), + } + weak := &windowsNativePackageTransaction{ + serviceSnapshot: nativePackageServiceSnapshot{ + disposition: nativePackageServiceWeakExactOwned, + wasRunning: true, + }, + service: service, manager: newFakeNativeSCM(service, &events), + releaseServiceMutex: func() {}, + } + if err := weak.quiescePriorServiceForDriver(context.Background()); err == nil { + t.Fatal("weak service delete failure was accepted") + } + if !weak.weakServiceMutation || weak.weakServiceRemoved || service.status.State != svc.Stopped || + service.deleted || !slices.Equal(events, []string{"service-stop", "service-delete"}) { + t.Fatalf("weak failure state mutation=%v removed=%v status=%d deleted=%v events=%v", + weak.weakServiceMutation, weak.weakServiceRemoved, service.status.State, + service.deleted, events) + } + if err := weak.Rollback(context.Background()); err != nil { + t.Fatalf("fail-closed weak rollback: %v", err) + } + if service.startCalls != 0 || service.status.State != svc.Stopped { + t.Fatalf("untrusted weak service was restarted: starts=%d status=%d", + service.startCalls, service.status.State) + } +} + +func TestNativePackageOuterRollbackLeavesServiceStoppedOnUnsettledDriverProof(t *testing.T) { + t.Parallel() + events := []string{} + service := &fakeNativeService{events: &events, status: svc.Status{State: svc.Stopped}} + transaction := &windowsNativePackageTransaction{ + serviceSnapshot: nativePackageServiceSnapshot{ + disposition: nativePackageServiceTrusted, + wasRunning: true, + }, + service: service, + stoppedTrustedService: true, + driverHelperSettled: false, + } + err := transaction.Rollback(context.Background()) + if err == nil || service.startCalls != 0 || len(events) != 0 { + t.Fatalf("unsettled outer rollback error=%v startCalls=%d events=%v", + err, service.startCalls, events) + } +} + +func TestNativePackageCoordinationTokenAllowsNestedImmutableRead(t *testing.T) { + requireNativeMutexAdministrator(t) + transaction := &windowsNativePackageTransaction{parent: t.TempDir()} + if err := transaction.stageCoordinationToken(); err != nil { + t.Fatalf("stage coordination token: %v", err) + } + t.Cleanup(func() { + if err := transaction.releaseCoordinationToken(); err != nil { + t.Errorf("release coordination token: %v", err) + } + }) + + // This is the exact access/share combination used by the nested broker. + // It failed live while the outer transaction retained a write-capable + // handle, even though both opens requested FILE_SHARE_READ. + nested, err := lockNativePackageInput(transaction.tokenPath) + if err != nil { + t.Fatalf("nested immutable token open: %v", err) + } + defer windows.CloseHandle(nested) //nolint:errcheck + hash, err := hashNativePackageHandle(nested) + if err != nil { + t.Fatalf("hash nested token handle: %v", err) + } + if hash != transaction.tokenSHA256 { + t.Fatalf("nested token hash = %s, want %s", hash, transaction.tokenSHA256) + } +} + +func TestNativePackageRuntimePayloadExcludesCertificationPDB(t *testing.T) { + want := []string{"ViiperUde.inf", "ViiperUde.sys", "ViiperUde.cat"} + if !slices.Equal(nativePackageDriverFiles, want) { + t.Fatalf("runtime driver payload = %v, want %v", nativePackageDriverFiles, want) + } + if slices.Contains(nativePackageDriverFiles, "ViiperUde.pdb") { + t.Fatal("certification PDB became a runtime installation dependency") + } +} + +func TestNativePackageServiceTrustRequiresExactOwnedState(t *testing.T) { + t.Parallel() + expected := mgr.Config{ + ServiceType: 0x10, StartType: mgr.StartAutomatic, ErrorControl: mgr.ErrorNormal, + BinaryPathName: `"C:\Program Files\VIIPER\viiper.exe" service --transport native-ude`, + ServiceStartName: nativeServiceAccount, DisplayName: nativeBrokerDisplayName, + Description: nativeBrokerDescription, SidType: 1, + } + actions := append([]mgr.RecoveryAction(nil), nativeServiceRecoveryActions...) + canonical := func(actual mgr.Config, dacl string, recovery []mgr.RecoveryAction, + reset uint32, nonCrash bool) bool { + return isCanonicalNativePackageService( + actual, expected, dacl, recovery, reset, nonCrash, + ) + } + if !canonical(expected, nativeBrokerServiceSDDL, actions, + nativeServiceRecoveryResetSecond, true) { + t.Fatal("exact protected service was not trusted") + } + + staleConfig := expected + staleConfig.StartType = mgr.StartManual + staleRecovery := append([]mgr.RecoveryAction(nil), actions...) + staleRecovery[0].Delay = time.Millisecond + cases := map[string]bool{ + "stale config": canonical(staleConfig, nativeBrokerServiceSDDL, actions, nativeServiceRecoveryResetSecond, true), + "weak DACL": canonical(expected, "D:(A;;GA;;;WD)", actions, nativeServiceRecoveryResetSecond, true), + "stale recovery": canonical(expected, nativeBrokerServiceSDDL, staleRecovery, nativeServiceRecoveryResetSecond, true), + "stale reset": canonical(expected, nativeBrokerServiceSDDL, actions, 1, true), + "stale mode": canonical(expected, nativeBrokerServiceSDDL, actions, nativeServiceRecoveryResetSecond, false), + } + for name, trusted := range cases { + if trusted { + t.Errorf("%s was trusted instead of delete/recreate", name) + } + } +} + +func TestNativePackageRollbackReconcilesStoppedPriorService(t *testing.T) { + t.Parallel() + events := []string{} + service := &fakeNativeService{events: &events, status: svc.Status{State: svc.Stopped}} + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := reconcileNativePackageServiceRunning(ctx, service); err != nil { + t.Fatalf("reconcile prior service: %v", err) + } + if service.startCalls != 1 || service.status.State != svc.Running { + t.Fatalf("startCalls=%d state=%d events=%v", service.startCalls, service.status.State, events) + } +} + +func TestNativePackageRollbackPreservesImagesAndStoppedServiceWhenSCMRollbackIsUnsettled(t *testing.T) { + t.Parallel() + events := []string{} + service := &fakeNativeService{events: &events, status: svc.Status{State: svc.Stopped}} + transaction := &windowsNativePackageTransaction{ + nestedBrokerCommit: true, + nestedMutationStarted: true, + nestedServiceRollbackSettled: false, + destinationPublished: true, + backupPath: `C:\Program Files\VIIPER\.prior.rollback.exe`, + stoppedTrustedService: true, + serviceSnapshot: nativePackageServiceSnapshot{wasRunning: true}, + service: service, + } + + err := transaction.Rollback(context.Background()) + if err == nil { + t.Fatal("unsettled nested SCM rollback was reported as restored") + } + if service.startCalls != 0 || len(events) != 0 { + t.Fatalf("indeterminate service was restarted: startCalls=%d events=%v", + service.startCalls, events) + } + if !transaction.destinationPublished { + t.Fatal("staged broker image was removed after unsettled SCM rollback") + } + if transaction.backupPath == "" { + t.Fatal("prior broker backup was consumed after unsettled SCM rollback") + } + if transaction.nestedRollbackSucceeded { + t.Fatal("unsettled SCM rollback was exposed as a safe nested rollback") + } +} diff --git a/internal/cmd/native_service_install_windows.go b/internal/cmd/native_service_install_windows.go new file mode 100644 index 00000000..c4b8f6a7 --- /dev/null +++ b/internal/cmd/native_service_install_windows.go @@ -0,0 +1,3535 @@ +//go:build windows + +package cmd + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + "syscall" + "time" + "unsafe" + + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/auth" + "github.com/Alia5/VIIPER/internal/transport/udecx" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/viipertypes" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +const ( + nativeBrokerDisplayName = "VIIPER Native UDE Broker" + nativeBrokerDescription = "Provides authenticated native virtual-controller transport for DS4Windows." + nativeBrokerLogName = "viiper-native-broker.log" + nativeServiceAccount = "LocalSystem" + nativeServiceRecoveryResetSecond = 15 * 60 + nativeServiceInstallTimeout = 45 * time.Second + nativeServiceStatePoll = 100 * time.Millisecond + nativeInstallMutexName = "VIIPER.NativeBroker.Install.v1" + nativeBrokerServiceSDDL = "O:BAD:P(A;;GA;;;SY)(A;;GA;;;BA)" + nativeBrokerDirectorySDDL = "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)" + nativeBrokerExecutableSDDL = "O:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;GRGX;;;BU)" + nativeFileAllAccess = windows.STANDARD_RIGHTS_REQUIRED | windows.SYNCHRONIZE | 0x1ff + nativeServiceGenericRead = windows.STANDARD_RIGHTS_READ | windows.SERVICE_QUERY_CONFIG | + windows.SERVICE_QUERY_STATUS | windows.SERVICE_INTERROGATE | windows.SERVICE_ENUMERATE_DEPENDENTS + nativeServiceGenericWrite = windows.STANDARD_RIGHTS_WRITE | windows.SERVICE_CHANGE_CONFIG + nativeServiceGenericExecute = windows.STANDARD_RIGHTS_EXECUTE | windows.SERVICE_START | + windows.SERVICE_STOP | windows.SERVICE_PAUSE_CONTINUE | windows.SERVICE_USER_DEFINED_CONTROL +) + +type nativeGenericAccessMapping struct { + read windows.ACCESS_MASK + write windows.ACCESS_MASK + execute windows.ACCESS_MASK + all windows.ACCESS_MASK +} + +type nativeAccessAllowedACE struct { + flags uint8 + mask windows.ACCESS_MASK + sid string +} + +var ( + nativeFileAccessMapping = nativeGenericAccessMapping{ + read: windows.FILE_GENERIC_READ, write: windows.FILE_GENERIC_WRITE, + execute: windows.FILE_GENERIC_EXECUTE, all: nativeFileAllAccess, + } + nativeServiceAccessMapping = nativeGenericAccessMapping{ + read: nativeServiceGenericRead, write: nativeServiceGenericWrite, + execute: nativeServiceGenericExecute, all: windows.SERVICE_ALL_ACCESS, + } +) + +var nativeServiceRecoveryActions = []mgr.RecoveryAction{ + {Type: mgr.ServiceRestart, Delay: 2 * time.Second}, + {Type: mgr.ServiceRestart, Delay: 15 * time.Second}, + // SCM repeats the final action for later failures. Ending with NoAction is + // what makes this recovery policy bounded instead of a permanent restart loop. + {Type: mgr.NoAction}, +} + +var expandEnvironmentStringsForUserW = windows.NewLazySystemDLL("userenv.dll").NewProc("ExpandEnvironmentStringsForUserW") + +type nativeSCM interface { + OpenService(string) (nativeManagedService, error) + CreateService(string, string, mgr.Config, ...string) (nativeManagedService, error) + Close() error +} + +type nativeManagedService interface { + Config() (mgr.Config, error) + UpdateConfig(mgr.Config) error + SecurityDescriptor() (string, error) + SetSecurityDescriptor(string) error + Query() (svc.Status, error) + ProcessID() (uint32, error) + Start(...string) error + Control(svc.Cmd) (svc.Status, error) + Delete() error + SetRecoveryActions([]mgr.RecoveryAction, uint32) error + SetRecoveryActionsExact([]mgr.RecoveryAction, uint32) error + RecoveryActions() ([]mgr.RecoveryAction, error) + ResetPeriod() (uint32, error) + SetRecoveryActionsOnNonCrashFailures(bool) error + RecoveryActionsOnNonCrashFailures() (bool, error) + Close() error +} + +type windowsNativeSCM struct{ manager *mgr.Mgr } + +func (m *windowsNativeSCM) OpenService(name string) (nativeManagedService, error) { + service, err := m.manager.OpenService(name) + if err != nil { + return nil, err + } + return &windowsNativeService{service: service}, nil +} + +func (m *windowsNativeSCM) CreateService( + name, executable string, + config mgr.Config, + args ...string, +) (nativeManagedService, error) { + service, err := m.manager.CreateService(name, executable, config, args...) + if err != nil { + return nil, err + } + return &windowsNativeService{service: service}, nil +} + +func (m *windowsNativeSCM) Close() error { return m.manager.Disconnect() } + +type windowsNativeService struct{ service *mgr.Service } + +func (s *windowsNativeService) Config() (mgr.Config, error) { return s.service.Config() } +func (s *windowsNativeService) UpdateConfig(config mgr.Config) error { + return updateNativeServiceConfigExact(s.service.Handle, config) +} +func (s *windowsNativeService) SecurityDescriptor() (string, error) { + return nativeObjectSecurityDescriptor(s.service.Handle, windows.SE_SERVICE) +} +func (s *windowsNativeService) SetSecurityDescriptor(sddl string) error { + return setNativeObjectSecurityDescriptor(s.service.Handle, windows.SE_SERVICE, sddl) +} +func (s *windowsNativeService) Query() (svc.Status, error) { return s.service.Query() } +func (s *windowsNativeService) ProcessID() (uint32, error) { + status := windows.SERVICE_STATUS_PROCESS{} + var needed uint32 + if err := windows.QueryServiceStatusEx( + s.service.Handle, + windows.SC_STATUS_PROCESS_INFO, + (*byte)(unsafe.Pointer(&status)), + uint32(unsafe.Sizeof(status)), + &needed, + ); err != nil { + return 0, err + } + return status.ProcessId, nil +} +func (s *windowsNativeService) Start(args ...string) error { return s.service.Start(args...) } +func (s *windowsNativeService) Control(command svc.Cmd) (svc.Status, error) { + return s.service.Control(command) +} +func (s *windowsNativeService) Delete() error { return s.service.Delete() } +func (s *windowsNativeService) SetRecoveryActions(actions []mgr.RecoveryAction, reset uint32) error { + return s.service.SetRecoveryActions(actions, reset) +} +func (s *windowsNativeService) SetRecoveryActionsExact(actions []mgr.RecoveryAction, reset uint32) error { + if len(actions) != 0 { + return s.service.SetRecoveryActions(actions, reset) + } + // SERVICE_FAILURE_ACTIONS does not permit an empty action array with a + // nonzero reset period: a NULL Actions pointer leaves both values unchanged, + // while a non-NULL pointer with ActionsCount == 0 deletes both. Reject the + // unrepresentable state before mutation in openAndSnapshotNativeService. + if reset != 0 { + return errors.New("Windows SCM cannot persist empty recovery actions with a nonzero reset period") + } + dummyAction := windows.SC_ACTION{} + failureActions := windows.SERVICE_FAILURE_ACTIONS{Actions: &dummyAction} + return windows.ChangeServiceConfig2( + s.service.Handle, + windows.SERVICE_CONFIG_FAILURE_ACTIONS, + (*byte)(unsafe.Pointer(&failureActions)), + ) +} +func (s *windowsNativeService) RecoveryActions() ([]mgr.RecoveryAction, error) { + return s.service.RecoveryActions() +} +func (s *windowsNativeService) ResetPeriod() (uint32, error) { return s.service.ResetPeriod() } +func (s *windowsNativeService) SetRecoveryActionsOnNonCrashFailures(value bool) error { + return s.service.SetRecoveryActionsOnNonCrashFailures(value) +} +func (s *windowsNativeService) RecoveryActionsOnNonCrashFailures() (bool, error) { + return s.service.RecoveryActionsOnNonCrashFailures() +} +func (s *windowsNativeService) Close() error { return s.service.Close() } + +func updateNativeServiceConfigExact(handle windows.Handle, config mgr.Config) error { + if strings.TrimSpace(config.BinaryPathName) == "" || strings.IndexByte(config.BinaryPathName, 0) >= 0 { + return errors.New("service binary path must be nonempty and contain no NUL") + } + binaryPath, err := windows.UTF16PtrFromString(config.BinaryPathName) + if err != nil { + return err + } + loadOrderGroup, err := windows.UTF16PtrFromString(config.LoadOrderGroup) + if err != nil { + return err + } + dependencies, err := nativeServiceDependenciesBlock(config.Dependencies) + if err != nil { + return err + } + serviceAccount := config.ServiceStartName + if isLocalSystemServiceAccount(serviceAccount) { + serviceAccount = nativeServiceAccount + } + account, err := windows.UTF16PtrFromString(serviceAccount) + if err != nil { + return err + } + // The LocalSystem password is explicitly empty. NULL would mean "leave the + // old password unchanged", which is not an exact configuration operation. + emptyPassword, err := windows.UTF16PtrFromString("") + if err != nil { + return err + } + displayName, err := windows.UTF16PtrFromString(config.DisplayName) + if err != nil { + return err + } + if err := windows.ChangeServiceConfig( + handle, + config.ServiceType, + config.StartType, + config.ErrorControl, + binaryPath, + loadOrderGroup, + nil, + &dependencies[0], + account, + emptyPassword, + displayName, + ); err != nil { + return err + } + if err := windows.ChangeServiceConfig2( + handle, + windows.SERVICE_CONFIG_SERVICE_SID_INFO, + (*byte)(unsafe.Pointer(&config.SidType)), + ); err != nil { + return err + } + delayed := windows.SERVICE_DELAYED_AUTO_START_INFO{} + if config.DelayedAutoStart { + delayed.IsDelayedAutoStartUp = 1 + } + if err := windows.ChangeServiceConfig2( + handle, + windows.SERVICE_CONFIG_DELAYED_AUTO_START_INFO, + (*byte)(unsafe.Pointer(&delayed)), + ); err != nil { + return err + } + descriptionValue, err := windows.UTF16PtrFromString(config.Description) + if err != nil { + return err + } + description := windows.SERVICE_DESCRIPTION{Description: descriptionValue} + return windows.ChangeServiceConfig2( + handle, + windows.SERVICE_CONFIG_DESCRIPTION, + (*byte)(unsafe.Pointer(&description)), + ) +} + +func nativeServiceDependenciesBlock(dependencies []string) ([]uint16, error) { + block := make([]uint16, 0, 2) + for _, dependency := range dependencies { + if dependency == "" || strings.IndexByte(dependency, 0) >= 0 { + return nil, errors.New("service dependency must be nonempty and contain no NUL") + } + value, err := windows.UTF16FromString(dependency) + if err != nil { + return nil, err + } + block = append(block, value...) + } + // ChangeServiceConfig requires a non-NULL empty string to clear existing + // dependencies. Every nonempty block also needs the second terminating NUL. + block = append(block, 0) + if len(block) == 1 { + block = append(block, 0) + } + return block, nil +} + +type nativeServiceSnapshot struct { + exists bool + config mgr.Config + status svc.Status + securityDescriptor string + recoveryActions []mgr.RecoveryAction + recoveryResetSeconds uint32 + recoverNonCrash bool + releaseExecutable func() +} + +type nativeCredential struct { + path string + password string + userSID string + created bool + replaced bool + priorBytes []byte +} + +type nativeLegacyCommand struct { + executable string + arguments []string + workingDirectory string + source nativeLegacyCommandSource + running bool +} + +type nativeLegacyCommandSource uint8 + +const ( + legacyCommandRun nativeLegacyCommandSource = iota + 1 +) + +type nativeLegacyState struct { + userSID string + userHive registry.Key + runKey registry.Key + runKeyExisted bool + runValue *nativeRunRegistration + scheduledAction *nativeLegacyCommand + scheduledXML *string + scheduledCurrentXML *string + scheduledActive bool + scheduledEnabled bool + scheduledDisabled bool + scheduledStopped bool + verifyTaskAction func() error + release func() + commands []nativeLegacyCommand +} + +func nativeLegacyStartupOwnsRuntime(state nativeLegacyState) bool { + return state.runValue != nil || + (state.scheduledAction != nil && (state.scheduledActive || state.scheduledEnabled)) || + slices.ContainsFunc(state.commands, func(command nativeLegacyCommand) bool { + return command.running + }) +} + +type nativeRunRegistration struct { + value string + valueType uint32 +} + +type nativeScheduledStopResult struct { + stopped bool + disabled bool + currentXML string +} + +type nativeInstallDependencies struct { + connectSCM func() (nativeSCM, error) + lockExecutable func(string) (func(), error) + lockPriorExecutable func(string) (func(), error) + provisionCredential func() (nativeCredential, error) + rollbackCredential func(nativeCredential) error + preflightDriver func() error + snapshotLegacy func(context.Context) (nativeLegacyState, error) + stopLegacy func(context.Context, *nativeLegacyState, *slog.Logger) error + removeLegacy func(context.Context, nativeLegacyState) error + restoreLegacy func(context.Context, nativeLegacyState) error + restartLegacy func(context.Context, nativeLegacyState) error + verifyBroker func(context.Context, string) error + wait func(context.Context, time.Duration) error + brokerJournal *nativeBrokerJournal +} + +func productionNativeInstallDependencies(userSID string) nativeInstallDependencies { + return productionNativeInstallDependenciesWithJournal(userSID, nil) +} + +func productionNativeInstallDependenciesWithJournal( + userSID string, + journal *nativeBrokerJournal, +) nativeInstallDependencies { + return nativeInstallDependencies{ + connectSCM: func() (nativeSCM, error) { + manager, err := mgr.Connect() + if err != nil { + return nil, err + } + return &windowsNativeSCM{manager: manager}, nil + }, + lockExecutable: lockNativeServiceExecutable, + lockPriorExecutable: lockNativePriorServiceExecutable, + provisionCredential: func() (nativeCredential, error) { + return provisionNativeServiceCredentialWithJournal(userSID, journal) + }, + rollbackCredential: rollbackNativeServiceCredential, + preflightDriver: requireNativeUDEBroker, + snapshotLegacy: func(ctx context.Context) (nativeLegacyState, error) { + return snapshotNativeLegacyStartup(ctx, userSID) + }, + stopLegacy: stopNativeLegacyStartup, + removeLegacy: removeNativeLegacyRegistrations, + restoreLegacy: func(ctx context.Context, state nativeLegacyState) error { + return restoreNativeLegacyRegistrationsAfterRemoval(ctx, state, nil, true) + }, + restartLegacy: restartNativeLegacyStartup, + verifyBroker: verifyNativeBroker, + wait: func(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } + }, + brokerJournal: journal, + } +} + +func installNativeBroker(logger *slog.Logger, explicitUserSID string) error { + release, err := acquireNativeInstallMutex(nativeServiceInstallTimeout) + if err != nil { + return err + } + defer release() + userSID, err := resolveNativeInstallingUserSID(explicitUserSID) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), nativeServiceInstallTimeout) + defer cancel() + if err := reconcileNativeBrokerJournalBeforeAdmission(ctx, logger, userSID); err != nil { + return err + } + executable, err := currentExecutable() + if err != nil { + return err + } + return installNativeBrokerTransaction(ctx, logger, executable, productionNativeInstallDependencies(userSID)) +} + +// installNativeBrokerUntil is reserved for the nested native-package commit. +// It shares the outer transaction's absolute deadline rather than granting a +// fresh service-install budget after the driver has already been mutated. +func installNativeBrokerUntil( + logger *slog.Logger, explicitUserSID string, deadline time.Time, +) error { + remaining := time.Until(deadline) + if remaining <= 0 { + return context.DeadlineExceeded + } + if remaining > nativeServiceInstallTimeout { + remaining = nativeServiceInstallTimeout + } + release, err := acquireNativeInstallMutex(remaining) + if err != nil { + return err + } + defer release() + userSID, err := resolveNativeInstallingUserSID(explicitUserSID) + if err != nil { + return err + } + ctx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + if err := reconcileNativeBrokerJournalBeforeAdmission(ctx, logger, userSID); err != nil { + return err + } + executable, err := currentExecutable() + if err != nil { + return err + } + return installNativeBrokerTransaction(ctx, logger, executable, productionNativeInstallDependencies(userSID)) +} + +func uninstallNativeBrokerTransaction( + ctx context.Context, + logger *slog.Logger, + manager nativeSCM, + dependencies nativeInstallDependencies, +) (resultErr error) { + service, before, err := openAndSnapshotNativeService( + ctx, manager, dependencies.wait, dependencies.lockPriorExecutable, + ) + if err != nil { + return err + } + if before.releaseExecutable != nil { + defer before.releaseExecutable() + } + if before.exists && !isLocalSystemServiceAccount(before.config.ServiceStartName) { + if service != nil { + service.Close() //nolint:errcheck + } + return fmt.Errorf( + "refusing to remove %s because it runs as non-LocalSystem account %q and cannot be transactionally restored", + NativeBrokerServiceName, before.config.ServiceStartName, + ) + } + if service != nil { + defer service.Close() //nolint:errcheck + } + legacy, err := dependencies.snapshotLegacy(ctx) + if err != nil { + return fmt.Errorf("snapshot legacy VIIPER startup before uninstall: %w", err) + } + if legacy.release != nil { + defer legacy.release() + } + if err := dependencies.brokerJournal.validatePriorOwnership(before, legacy); err != nil { + return fmt.Errorf("revalidate durable prior broker ownership: %w", err) + } + + serviceChanged := false + legacyStopped := false + registrationsMayHaveChanged := false + defer func() { + if resultErr == nil { + return + } + rollbackCtx, cancelRollback := context.WithTimeout(context.Background(), nativeServiceInstallTimeout) + defer cancelRollback() + var rollbackErrors []error + safeToRestartLegacy := true + if serviceChanged { + var rollbackErr error + safeToRestartLegacy, rollbackErr = rollbackNativeService( + rollbackCtx, manager, service, before, dependencies.wait, nil, + ) + if rollbackErr != nil { + rollbackErrors = append(rollbackErrors, rollbackErr) + } + } + // A restored scheduled task can start immediately through a registration + // trigger or StartWhenAvailable. Do not make any legacy registration live + // until the rejected service has been stopped/deleted or the prior service + // has been restored completely. + if dependencies.brokerJournal == nil && registrationsMayHaveChanged && safeToRestartLegacy { + if rollbackErr := dependencies.restoreLegacy(rollbackCtx, legacy); rollbackErr != nil { + safeToRestartLegacy = false + rollbackErrors = append(rollbackErrors, rollbackErr) + } + } + if dependencies.brokerJournal == nil && legacyStopped && safeToRestartLegacy { + if rollbackErr := dependencies.restartLegacy(rollbackCtx, legacy); rollbackErr != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("restart legacy VIIPER after uninstall rollback: %w", rollbackErr)) + } + } + if len(rollbackErrors) != 0 { + resultErr = errors.Join(resultErr, errors.Join(rollbackErrors...)) + } + }() + + if before.exists && before.status.State == svc.Running { + serviceChanged = true + if err := stopNativeService(ctx, service, dependencies.wait); err != nil { + return fmt.Errorf("stop %s before uninstall: %w", NativeBrokerServiceName, err) + } + } + legacyStopped = true + stopLegacyErr := dependencies.stopLegacy(ctx, &legacy, logger) + registrationsMayHaveChanged = legacy.scheduledDisabled + if stopLegacyErr != nil { + return fmt.Errorf("stop legacy VIIPER before uninstall: %w", stopLegacyErr) + } + legacyStopped = hasRunningLegacyCommand(legacy) + registrationsMayHaveChanged = true + if err := dependencies.removeLegacy(ctx, legacy); err != nil { + return fmt.Errorf("remove legacy VIIPER startup during uninstall: %w", err) + } + if before.exists { + serviceChanged = true + if err := service.Delete(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { + return fmt.Errorf("delete %s during uninstall: %w", NativeBrokerServiceName, err) + } + } + logger.Info("VIIPER native broker service and legacy startup ownership removed", + "service", NativeBrokerServiceName) + return nil +} + +type nativeBrokerInstallEvidence struct { + mutationStarted bool + rollbackSucceeded bool +} + +func installNativeBrokerTransaction( + ctx context.Context, + logger *slog.Logger, + executable string, + dependencies nativeInstallDependencies, +) error { + return installNativeBrokerTransactionWithEvidence( + ctx, logger, executable, dependencies, nil, + ) +} + +func installNativeBrokerTransactionWithEvidence( + ctx context.Context, + logger *slog.Logger, + executable string, + dependencies nativeInstallDependencies, + evidence *nativeBrokerInstallEvidence, +) (resultErr error) { + rollbackFailed := false + if evidence != nil { + *evidence = nativeBrokerInstallEvidence{} + defer func() { + if resultErr != nil && evidence.mutationStarted { + evidence.rollbackSucceeded = !rollbackFailed + } + }() + } + markMutation := func() { + if evidence != nil { + evidence.mutationStarted = true + } + } + if !filepath.IsAbs(executable) { + return fmt.Errorf("native broker executable must be an absolute path: %s", executable) + } + if strings.IndexByte(executable, 0) >= 0 { + return errors.New("native broker executable contains NUL") + } + releaseExecutable, err := dependencies.lockExecutable(executable) + if err != nil { + return fmt.Errorf("validate protected native broker executable: %w", err) + } + if releaseExecutable == nil { + return errors.New("protected native broker executable lock returned no release function") + } + defer releaseExecutable() + + var credential nativeCredential + credentialProvisioned := false + credentialFinalized := false + rollbackCredential := func() error { + if !credentialProvisioned || credentialFinalized { + return nil + } + if err := dependencies.rollbackCredential(credential); err != nil { + return err + } + credentialFinalized = true + return nil + } + defer func() { + if !credentialProvisioned || credentialFinalized { + return + } + if rollbackErr := rollbackCredential(); rollbackErr != nil { + rollbackFailed = true + resultErr = errors.Join(resultErr, fmt.Errorf("roll back native broker credential: %w", rollbackErr)) + } + }() + + manager, err := dependencies.connectSCM() + if err != nil { + return fmt.Errorf("connect to Windows Service Control Manager: %w", err) + } + defer manager.Close() //nolint:errcheck -- closing a handle cannot invalidate a committed transaction + + service, before, err := openAndSnapshotNativeService( + ctx, manager, dependencies.wait, dependencies.lockPriorExecutable, + ) + if err != nil { + return err + } + if before.releaseExecutable != nil { + defer before.releaseExecutable() + } + if before.exists && !isLocalSystemServiceAccount(before.config.ServiceStartName) { + return fmt.Errorf( + "refusing to replace %s because it runs as non-LocalSystem account %q and its password cannot be transactionally restored", + NativeBrokerServiceName, before.config.ServiceStartName, + ) + } + defer func() { + if service != nil { + service.Close() //nolint:errcheck -- the SCM handle owns no transactional state + } + }() + + legacy, err := dependencies.snapshotLegacy(ctx) + if err != nil { + return fmt.Errorf("snapshot legacy VIIPER startup: %w", err) + } + if legacy.release != nil { + defer legacy.release() + } + if err := dependencies.brokerJournal.validatePriorOwnership(before, legacy); err != nil { + return fmt.Errorf("revalidate durable prior broker ownership: %w", err) + } + + serviceChanged := false + legacyStopped := false + registrationsMayHaveChanged := false + defer func() { + if resultErr == nil { + return + } + // The forward operation commonly fails because its deadline elapsed. + // Rollback must have an independent budget or a stopped prior service can + // never be restored once the installation context is canceled. + rollbackCtx, cancelRollback := context.WithTimeout(context.Background(), nativeServiceInstallTimeout) + defer cancelRollback() + var rollbackErrors []error + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseRollbackIntent, "", + ); err != nil { + rollbackFailed = true + rollbackErrors = append(rollbackErrors, fmt.Errorf("persist broker rollback intent: %w", err)) + } + safeToRestartLegacy := true + if serviceChanged { + var rollbackErr error + rollbackBefore := before + if dependencies.brokerJournal != nil { + // The package layer restores the prior image atomically after this + // inner SCM/key rollback. Starting here could execute the candidate + // bytes under the restored prior credential. + rollbackBefore.status.State = svc.Stopped + } + safeToRestartLegacy, rollbackErr = rollbackNativeService( + rollbackCtx, manager, service, rollbackBefore, dependencies.wait, rollbackCredential, + ) + if rollbackErr != nil { + rollbackFailed = true + rollbackErrors = append(rollbackErrors, rollbackErr) + } + if !safeToRestartLegacy { + rollbackFailed = true + } + if rollbackErr == nil && safeToRestartLegacy { + if journalErr := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseRollbackService, "", + ); journalErr != nil { + rollbackFailed = true + safeToRestartLegacy = false + rollbackErrors = append(rollbackErrors, journalErr) + } + } + if !safeToRestartLegacy && credentialProvisioned && !credentialFinalized { + // The replacement could still own the key path. Retain the new + // credential rather than invalidating a service we failed to stop + // or prove restored. This is fail-closed and is reported alongside + // the rollback failure. + credentialFinalized = true + rollbackErrors = append(rollbackErrors, + errors.New("retained native credential because service ownership could not be rolled back safely")) + } + } else if rollbackErr := rollbackCredential(); rollbackErr != nil { + rollbackFailed = true + safeToRestartLegacy = false + rollbackErrors = append(rollbackErrors, + fmt.Errorf("restore native broker credential before legacy restart: %w", rollbackErr)) + } + if safeToRestartLegacy && credentialFinalized { + if journalErr := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseRollbackCredential, "", + ); journalErr != nil { + rollbackFailed = true + safeToRestartLegacy = false + rollbackErrors = append(rollbackErrors, journalErr) + } + } + // Restoring task XML can itself launch the legacy process. Keep legacy + // ownership absent until the service and credential rollback has made it + // safe for that process to exist again. + if registrationsMayHaveChanged && safeToRestartLegacy { + if rollbackErr := dependencies.restoreLegacy(rollbackCtx, legacy); rollbackErr != nil { + rollbackFailed = true + safeToRestartLegacy = false + rollbackErrors = append(rollbackErrors, rollbackErr) + } + } + if legacyStopped && safeToRestartLegacy { + if rollbackErr := dependencies.restartLegacy(rollbackCtx, legacy); rollbackErr != nil { + rollbackFailed = true + rollbackErrors = append(rollbackErrors, fmt.Errorf("restart prior legacy VIIPER process: %w", rollbackErr)) + } + } + if dependencies.brokerJournal == nil && safeToRestartLegacy { + if journalErr := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseRollbackLegacy, "", + ); journalErr != nil { + rollbackFailed = true + rollbackErrors = append(rollbackErrors, journalErr) + } + } + if rollbackFailed && dependencies.brokerJournal != nil && + dependencies.brokerJournal.lastPhase() != nativeBrokerPhaseManual { + if journalErr := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseManual, "", + ); journalErr != nil { + rollbackErrors = append(rollbackErrors, journalErr) + } + } + if len(rollbackErrors) != 0 { + resultErr = errors.Join(resultErr, errors.Join(rollbackErrors...)) + } + }() + + if before.exists && before.status.State != svc.Stopped { + // Control(STOP) is itself a mutation. Even if the subsequent wait or + // status query fails, rollback must reconcile the snapshotted state. + serviceChanged = true + markMutation() + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseServiceStopIntent, "", + ); err != nil { + return fmt.Errorf("journal broker service stop intent: %w", err) + } + if err := stopNativeService(ctx, service, dependencies.wait); err != nil { + return fmt.Errorf("stop previous %s service: %w", NativeBrokerServiceName, err) + } + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseServiceStopped, "", + ); err != nil { + return fmt.Errorf("journal broker service stopped state: %w", err) + } + } + legacyStopped = true + markMutation() + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseLegacyStopIntent, "", + ); err != nil { + return fmt.Errorf("journal legacy stop intent: %w", err) + } + stopLegacyErr := dependencies.stopLegacy(ctx, &legacy, logger) + registrationsMayHaveChanged = legacy.scheduledDisabled + if stopLegacyErr != nil { + return fmt.Errorf("stop legacy VIIPER process: %w", stopLegacyErr) + } + legacyStopped = hasRunningLegacyCommand(legacy) + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseLegacyStopped, "", + ); err != nil { + return fmt.Errorf("journal legacy stopped state: %w", err) + } + + if err := dependencies.preflightDriver(); err != nil { + return err + } + // Rotate the machine credential only after every prior owner is stopped. + // Existing bytes are retained solely for rollback; they are never trusted as + // the new service secret because an unprivileged user may have pre-seeded the + // ProgramData path before its ACL was hardened. + markMutation() + credential, err = dependencies.provisionCredential() + if err != nil { + return fmt.Errorf("provision native broker credential: %w", err) + } + credentialProvisioned = true + if !filepath.IsAbs(credential.path) || strings.TrimSpace(credential.password) == "" { + return errors.New("provisioned native broker credential must have an absolute path and nonempty value") + } + + config, arguments, err := nativeBrokerServiceConfiguration(executable, credential.path) + if err != nil { + return err + } + if before.exists { + // ChangeServiceConfig is followed by ChangeServiceConfig2 calls inside + // x/sys. Mark the service dirty before the call because a later optional + // configuration failure can occur after the base configuration changed. + serviceChanged = true + markMutation() + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseServiceConfigIntent, "", + ); err != nil { + return fmt.Errorf("journal broker service configuration intent: %w", err) + } + if err := service.UpdateConfig(config); err != nil { + return fmt.Errorf("update %s service: %w", NativeBrokerServiceName, err) + } + } else { + // x/sys CreateService applies optional fields after the SCM create call + // and ignores a failed cleanup DeleteService. Create only the atomic base + // record first, then mark it owned and apply all optional settings through + // UpdateConfig so every later partial failure is covered by rollback. + baseConfig := config + baseConfig.Description = "" + baseConfig.SidType = windows.SERVICE_SID_TYPE_NONE + baseConfig.DelayedAutoStart = false + markMutation() + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseServiceConfigIntent, "", + ); err != nil { + return fmt.Errorf("journal broker service creation intent: %w", err) + } + service, err = manager.CreateService(NativeBrokerServiceName, executable, baseConfig, arguments...) + if err != nil { + return fmt.Errorf("create %s service: %w", NativeBrokerServiceName, err) + } + serviceChanged = true + if err := protectNativeServiceObject(service); err != nil { + return err + } + if err := service.UpdateConfig(config); err != nil { + return fmt.Errorf("complete %s service configuration: %w", NativeBrokerServiceName, err) + } + } + if err := configureNativeServiceRecovery(service); err != nil { + return err + } + if err := verifyConfiguredNativeService(service, config); err != nil { + return err + } + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseServiceConfigured, "", + ); err != nil { + return fmt.Errorf("journal configured broker service: %w", err) + } + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseServiceStartIntent, "", + ); err != nil { + return fmt.Errorf("journal broker service start intent: %w", err) + } + if err := service.Start(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) { + return fmt.Errorf("start %s service: %w", NativeBrokerServiceName, err) + } + if err := waitForNativeServiceState(ctx, service, svc.Running, dependencies.wait); err != nil { + return fmt.Errorf("wait for %s service readiness: %w", NativeBrokerServiceName, err) + } + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseServiceStarted, "", + ); err != nil { + return fmt.Errorf("journal broker service running state: %w", err) + } + servicePID, err := requireNativeServiceProcess(service, 0) + if err != nil { + return err + } + if err := dependencies.verifyBroker(ctx, credential.password); err != nil { + return fmt.Errorf("authenticate and verify %s: %w", NativeBrokerServiceName, err) + } + if _, err := requireNativeServiceProcess(service, servicePID); err != nil { + return fmt.Errorf("revalidate %s after authenticated ping: %w", NativeBrokerServiceName, err) + } + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseAuthenticated, "", + ); err != nil { + return fmt.Errorf("journal authenticated broker state: %w", err) + } + + // Legacy registrations remain intact through authenticated readiness. They + // are removed last so a failed native migration can still restart the exact + // legacy command without reconstructing startup ownership. + registrationsMayHaveChanged = true + markMutation() + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseLegacyRemoveIntent, "", + ); err != nil { + return fmt.Errorf("journal legacy ownership removal intent: %w", err) + } + if err := dependencies.removeLegacy(ctx, legacy); err != nil { + return fmt.Errorf("remove legacy VIIPER startup after native verification: %w", err) + } + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseLegacyRemoved, "", + ); err != nil { + return fmt.Errorf("journal removed legacy ownership: %w", err) + } + // Re-authenticate after removing the legacy owner. A task trigger or restart + // policy can race the earlier stop; the migration is committed only while the + // verified native service still owns the exact endpoint contract. + if err := dependencies.verifyBroker(ctx, credential.password); err != nil { + return fmt.Errorf("reverify %s after legacy removal: %w", NativeBrokerServiceName, err) + } + if _, err := requireNativeServiceProcess(service, servicePID); err != nil { + return fmt.Errorf("revalidate %s after legacy removal: %w", NativeBrokerServiceName, err) + } + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseReauthenticated, "", + ); err != nil { + return fmt.Errorf("journal final authenticated broker state: %w", err) + } + credentialFinalized = true + logger.Info("VIIPER native broker service installed and authenticated", + "service", NativeBrokerServiceName, "exe", executable, "credential", credential.path) + return nil +} + +func requireNativeServiceProcess(service nativeManagedService, expectedPID uint32) (uint32, error) { + status, err := service.Query() + if err != nil { + return 0, fmt.Errorf("query %s state: %w", NativeBrokerServiceName, err) + } + if status.State != svc.Running { + return 0, fmt.Errorf("%s left Running state after verification (state=%d)", NativeBrokerServiceName, status.State) + } + pid, err := service.ProcessID() + if err != nil { + return 0, fmt.Errorf("query %s process identity: %w", NativeBrokerServiceName, err) + } + if pid == 0 { + return 0, fmt.Errorf("%s reports no running process", NativeBrokerServiceName) + } + if expectedPID != 0 && pid != expectedPID { + return 0, fmt.Errorf("%s process changed during verification (before=%d after=%d)", + NativeBrokerServiceName, expectedPID, pid) + } + return pid, nil +} + +func openAndSnapshotNativeService( + ctx context.Context, + manager nativeSCM, + wait func(context.Context, time.Duration) error, + lockExecutable func(string) (func(), error), +) (nativeManagedService, nativeServiceSnapshot, error) { + var service nativeManagedService + for { + var err error + service, err = manager.OpenService(NativeBrokerServiceName) + if err == nil { + break + } + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + return nil, nativeServiceSnapshot{}, nil + } + if !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { + return nil, nativeServiceSnapshot{}, fmt.Errorf("open %s service: %w", NativeBrokerServiceName, err) + } + if err := wait(ctx, nativeServiceStatePoll); err != nil { + return nil, nativeServiceSnapshot{}, fmt.Errorf( + "wait for prior %s deletion to finish: %w", NativeBrokerServiceName, err, + ) + } + } + config, err := service.Config() + if err != nil { + service.Close() //nolint:errcheck + return nil, nativeServiceSnapshot{}, fmt.Errorf("query %s configuration: %w", NativeBrokerServiceName, err) + } + if lockExecutable == nil { + service.Close() //nolint:errcheck + return nil, nativeServiceSnapshot{}, errors.New("prior service executable lock is unavailable") + } + priorExecutable, err := nativeServiceExecutableFromCommandLine(config.BinaryPathName) + if err != nil { + service.Close() //nolint:errcheck + return nil, nativeServiceSnapshot{}, fmt.Errorf("parse prior %s executable: %w", NativeBrokerServiceName, err) + } + releasePriorExecutable, err := lockExecutable(priorExecutable) + if err != nil { + service.Close() //nolint:errcheck + return nil, nativeServiceSnapshot{}, fmt.Errorf("lock prior %s executable: %w", NativeBrokerServiceName, err) + } + if releasePriorExecutable == nil { + service.Close() //nolint:errcheck + return nil, nativeServiceSnapshot{}, errors.New("prior service executable lock returned no release function") + } + fail := func(err error) (nativeManagedService, nativeServiceSnapshot, error) { + releasePriorExecutable() + service.Close() //nolint:errcheck + return nil, nativeServiceSnapshot{}, err + } + securityDescriptor, err := service.SecurityDescriptor() + if err != nil { + return fail(fmt.Errorf("query %s security descriptor: %w", NativeBrokerServiceName, err)) + } + if _, err := windows.SecurityDescriptorFromString(securityDescriptor); err != nil { + return fail(fmt.Errorf("parse %s security descriptor: %w", NativeBrokerServiceName, err)) + } + // Replacing a permissive DACL does not revoke dangerous service handles + // that another process opened while the old ACL was live. Reuse only an + // already-protected SCM object; an untrusted prior service must be repaired + // by an explicit delete-and-recreate flow, never silently adopted as + // LocalSystem code by this rollback-capable update transaction. + if err := compareNativeSecurityDescriptorStrings(securityDescriptor, nativeBrokerServiceSDDL); err != nil { + return fail(fmt.Errorf("%s has an untrusted service security descriptor: %w", NativeBrokerServiceName, err)) + } + // ChangeServiceConfig can request a load-order tag but cannot restore an + // exact previously assigned TagId. VIIPER does not need a load-order group, + // so reject that unrepresentable preexisting state before any mutation. + if config.LoadOrderGroup != "" || config.TagId != 0 { + return fail(fmt.Errorf( + "%s uses unrepresentable load-order state group=%q tag=%d", + NativeBrokerServiceName, config.LoadOrderGroup, config.TagId, + )) + } + status, err := service.Query() + if err != nil { + return fail(fmt.Errorf("query %s state: %w", NativeBrokerServiceName, err)) + } + status, err = settleNativeServiceSnapshot(ctx, service, status, wait) + if err != nil { + return fail(err) + } + actions, err := service.RecoveryActions() + if err != nil { + return fail(fmt.Errorf("query %s recovery actions: %w", NativeBrokerServiceName, err)) + } + reset, err := service.ResetPeriod() + if err != nil { + return fail(fmt.Errorf("query %s recovery reset period: %w", NativeBrokerServiceName, err)) + } + // Per the SERVICE_FAILURE_ACTIONS contract, an empty action array can only + // be restored with a zero reset period. A malformed/noncanonical preexisting + // state must be rejected before we stop or reconfigure the service because + // exact transactional rollback would otherwise be impossible. + if len(actions) == 0 && reset != 0 { + return fail(fmt.Errorf( + "%s has an unrepresentable recovery policy (no actions, reset=%d); refusing transactional replacement", + NativeBrokerServiceName, reset, + )) + } + nonCrash, err := service.RecoveryActionsOnNonCrashFailures() + if err != nil { + return fail(fmt.Errorf("query %s recovery flag: %w", NativeBrokerServiceName, err)) + } + return service, nativeServiceSnapshot{ + exists: true, config: config, status: status, + securityDescriptor: securityDescriptor, + recoveryActions: actions, recoveryResetSeconds: reset, recoverNonCrash: nonCrash, + releaseExecutable: releasePriorExecutable, + }, nil +} + +func nativeServiceExecutableFromCommandLine(commandLine string) (string, error) { + if commandLine == "" || strings.IndexByte(commandLine, 0) >= 0 { + return "", errors.New("service command line is empty or contains NUL") + } + arguments, err := windows.DecomposeCommandLine(commandLine) + if err != nil { + return "", err + } + if len(arguments) == 0 || !filepath.IsAbs(arguments[0]) { + return "", errors.New("service command line does not name an absolute executable") + } + return filepath.Clean(arguments[0]), nil +} + +func nativeBrokerServiceConfiguration(executable, keyPath string) (mgr.Config, []string, error) { + if !filepath.IsAbs(executable) || !filepath.IsAbs(keyPath) { + return mgr.Config{}, nil, errors.New("native broker executable and credential paths must be absolute") + } + logPath := filepath.Join(filepath.Dir(keyPath), nativeBrokerLogName) + arguments := []string{ + "service", "--transport", "native-ude", "--key-file", keyPath, + "--log.file", logPath, + } + binaryPath, err := windowsCommandLine(executable, arguments...) + if err != nil { + return mgr.Config{}, nil, err + } + return mgr.Config{ + ServiceType: windows.SERVICE_WIN32_OWN_PROCESS, + StartType: mgr.StartAutomatic, + ErrorControl: mgr.ErrorNormal, + BinaryPathName: binaryPath, + ServiceStartName: nativeServiceAccount, + DisplayName: nativeBrokerDisplayName, + Description: nativeBrokerDescription, + SidType: windows.SERVICE_SID_TYPE_UNRESTRICTED, + DelayedAutoStart: false, + }, arguments, nil +} + +func windowsCommandLine(executable string, arguments ...string) (string, error) { + parts := append([]string{executable}, arguments...) + for _, part := range parts { + if strings.IndexByte(part, 0) >= 0 { + return "", errors.New("Windows command-line argument contains NUL") + } + } + commandLine := syscall.EscapeArg(executable) + for _, argument := range arguments { + commandLine += " " + syscall.EscapeArg(argument) + } + return commandLine, nil +} + +func configureNativeServiceRecovery(service nativeManagedService) error { + if err := service.SetRecoveryActions(nativeServiceRecoveryActions, nativeServiceRecoveryResetSecond); err != nil { + return fmt.Errorf("configure %s bounded recovery actions: %w", NativeBrokerServiceName, err) + } + if err := service.SetRecoveryActionsOnNonCrashFailures(true); err != nil { + return fmt.Errorf("configure %s non-crash recovery: %w", NativeBrokerServiceName, err) + } + return nil +} + +func verifyConfiguredNativeService(service nativeManagedService, expected mgr.Config) error { + current, err := service.Config() + if err != nil { + return fmt.Errorf("verify %s configuration: %w", NativeBrokerServiceName, err) + } + if !nativeServiceConfigsEqual(current, expected) { + return fmt.Errorf("%s configuration did not match after update", NativeBrokerServiceName) + } + securityDescriptor, err := service.SecurityDescriptor() + if err != nil { + return fmt.Errorf("verify %s service security: %w", NativeBrokerServiceName, err) + } + if err := compareNativeSecurityDescriptorStrings(securityDescriptor, nativeBrokerServiceSDDL); err != nil { + return fmt.Errorf("%s service security did not match after update: %w", NativeBrokerServiceName, err) + } + return verifyNativeServiceRecovery(service, nativeServiceSnapshot{ + recoveryActions: nativeServiceRecoveryActions, + recoveryResetSeconds: nativeServiceRecoveryResetSecond, + recoverNonCrash: true, + }) +} + +func rollbackNativeService( + ctx context.Context, + manager nativeSCM, + service nativeManagedService, + before nativeServiceSnapshot, + wait func(context.Context, time.Duration) error, + beforeResume func() error, +) (bool, error) { + var rollbackErrors []error + if service != nil { + if err := stopNativeService(ctx, service, wait); err != nil { + return false, fmt.Errorf("stop replacement native service before rollback: %w", err) + } + } + if !before.exists { + if service != nil { + if err := service.Delete(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { + return false, fmt.Errorf("delete replacement native service: %w", err) + } + } + if beforeResume != nil { + if err := beforeResume(); err != nil { + return false, fmt.Errorf("restore native credential after deleting replacement service: %w", err) + } + } + return true, nil + } + if service == nil { + var err error + service, err = manager.OpenService(NativeBrokerServiceName) + if err != nil { + return false, errors.Join(append(rollbackErrors, fmt.Errorf("reopen prior native service: %w", err))...) + } + defer service.Close() //nolint:errcheck + } + if err := service.UpdateConfig(before.config); err != nil { + return false, fmt.Errorf("restore prior native service configuration: %w", err) + } else if current, err := service.Config(); err != nil { + return false, fmt.Errorf("verify prior native service configuration: %w", err) + } else if !nativeServiceConfigsEqual(current, before.config) { + return false, errors.New("prior native service configuration did not verify after rollback") + } + if err := service.SetRecoveryActionsExact(before.recoveryActions, before.recoveryResetSeconds); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("restore native service recovery actions: %w", err)) + } + if err := service.SetRecoveryActionsOnNonCrashFailures(before.recoverNonCrash); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("restore native service recovery flag: %w", err)) + } + if err := verifyNativeServiceRecovery(service, before); err != nil { + rollbackErrors = append(rollbackErrors, err) + } + if before.securityDescriptor == "" { + rollbackErrors = append(rollbackErrors, errors.New("prior native service security descriptor is unavailable")) + } else if err := service.SetSecurityDescriptor(before.securityDescriptor); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("restore prior native service security descriptor: %w", err)) + } else if current, err := service.SecurityDescriptor(); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("verify prior native service security descriptor: %w", err)) + } else if err := compareNativeSecurityDescriptorStrings(current, before.securityDescriptor); err != nil { + rollbackErrors = append(rollbackErrors, + fmt.Errorf("prior native service security descriptor did not verify after rollback: %w", err)) + } + if beforeResume != nil { + if err := beforeResume(); err != nil { + rollbackErrors = append(rollbackErrors, + fmt.Errorf("restore native credential before prior service restart: %w", err)) + } + } + // Never start a service after an incomplete configuration/recovery restore: + // BinaryPathName may still name the rejected replacement. + if len(rollbackErrors) == 0 && serviceWasOperational(before.status.State) { + if err := service.Start(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) { + rollbackErrors = append(rollbackErrors, fmt.Errorf("restart prior native service: %w", err)) + } else if err := waitForNativeServiceState(ctx, service, svc.Running, wait); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("wait for prior native service: %w", err)) + } + } + return len(rollbackErrors) == 0, errors.Join(rollbackErrors...) +} + +func nativeServiceConfigsEqual(first, second mgr.Config) bool { + return first.ServiceType == second.ServiceType && + first.StartType == second.StartType && + first.ErrorControl == second.ErrorControl && + first.BinaryPathName == second.BinaryPathName && + first.LoadOrderGroup == second.LoadOrderGroup && + first.TagId == second.TagId && + slices.Equal(first.Dependencies, second.Dependencies) && + isEquivalentServiceAccount(first.ServiceStartName, second.ServiceStartName) && + first.DisplayName == second.DisplayName && + first.Description == second.Description && + first.SidType == second.SidType && + first.DelayedAutoStart == second.DelayedAutoStart +} + +func verifyNativeServiceRecovery(service nativeManagedService, before nativeServiceSnapshot) error { + actions, err := service.RecoveryActions() + if err != nil { + return fmt.Errorf("verify native service recovery actions: %w", err) + } + reset, err := service.ResetPeriod() + if err != nil { + return fmt.Errorf("verify native service recovery reset period: %w", err) + } + nonCrash, err := service.RecoveryActionsOnNonCrashFailures() + if err != nil { + return fmt.Errorf("verify native service recovery flag: %w", err) + } + if !slices.Equal(actions, before.recoveryActions) || reset != before.recoveryResetSeconds || + nonCrash != before.recoverNonCrash { + return errors.New("prior native service recovery policy did not verify after rollback") + } + return nil +} + +func stopNativeService( + ctx context.Context, + service nativeManagedService, + wait func(context.Context, time.Duration) error, +) error { + status, err := service.Query() + if err != nil { + return err + } + if status.State == svc.Stopped { + return nil + } + if status.State != svc.StopPending { + if _, err := service.Control(svc.Stop); err != nil && !errors.Is(err, windows.ERROR_SERVICE_NOT_ACTIVE) { + return err + } + } + return waitForNativeServiceState(ctx, service, svc.Stopped, wait) +} + +func waitForNativeServiceState( + ctx context.Context, + service nativeManagedService, + want svc.State, + wait func(context.Context, time.Duration) error, +) error { + for { + status, err := service.Query() + if err != nil { + return err + } + if status.State == want { + return nil + } + if want == svc.Running && status.State == svc.Stopped && status.Win32ExitCode != 0 { + return fmt.Errorf("service stopped during startup (win32=%d service=%d)", + status.Win32ExitCode, status.ServiceSpecificExitCode) + } + if err := wait(ctx, nativeServiceStatePoll); err != nil { + return err + } + } +} + +func settleNativeServiceSnapshot( + ctx context.Context, + service nativeManagedService, + status svc.Status, + wait func(context.Context, time.Duration) error, +) (svc.Status, error) { + for { + switch status.State { + case svc.Stopped, svc.Running: + return status, nil + case svc.StartPending, svc.StopPending, svc.ContinuePending: + if err := wait(ctx, nativeServiceStatePoll); err != nil { + return svc.Status{}, fmt.Errorf("wait for %s stable state: %w", NativeBrokerServiceName, err) + } + var err error + status, err = service.Query() + if err != nil { + return svc.Status{}, fmt.Errorf("query %s stable state: %w", NativeBrokerServiceName, err) + } + default: + return svc.Status{}, fmt.Errorf( + "%s is in unsupported state %d; stop or resume it before transactional replacement", + NativeBrokerServiceName, status.State, + ) + } + } +} + +func verifyNativeBroker(ctx context.Context, password string) error { + if strings.TrimSpace(password) == "" { + return errors.New("native broker credential is empty") + } + client := viiperclient.NewWithConfig(api.DefaultListenAddress, &viiperclient.Config{ + DialTimeout: time.Second, ReadTimeout: 2 * time.Second, + WriteTimeout: 2 * time.Second, Password: password, + }) + var lastErr error + for { + response, err := client.PingCtx(ctx) + if err == nil { + err = validateNativeBrokerPing(response) + } + if err == nil { + return nil + } + lastErr = err + if err := waitContext(ctx, 100*time.Millisecond); err != nil { + return fmt.Errorf("broker did not satisfy the native contract: %w (last ping: %v)", err, lastErr) + } + } +} + +func verifyNativeBrokerOnce(ctx context.Context, password string) error { + if strings.TrimSpace(password) == "" { + return errors.New("native broker credential is empty") + } + client := viiperclient.NewWithConfig(api.DefaultListenAddress, &viiperclient.Config{ + DialTimeout: time.Second, ReadTimeout: 2 * time.Second, + WriteTimeout: 2 * time.Second, Password: password, + }) + response, err := client.PingCtx(ctx) + if err != nil { + return fmt.Errorf("authenticate exact native broker: %w", err) + } + return validateNativeBrokerPing(response) +} + +func validateNativeBrokerPing(response *viipertypes.PingResponse) error { + expected, err := udecx.ExpectedBuildIdentity() + if err != nil { + return fmt.Errorf("derive expected native loaded-driver identity: %w", err) + } + return validateNativeBrokerPingAgainstIdentity(response, expected) +} + +func validateNativeBrokerPingAgainstIdentity( + response *viipertypes.PingResponse, + expected [udecx.BuildIdentitySize]byte, +) error { + if response == nil { + return errors.New("empty ping response") + } + if response.Server != "VIIPER" || !strings.EqualFold(response.Transport, "native-ude") { + return fmt.Errorf("unexpected broker identity server=%q transport=%q", response.Server, response.Transport) + } + if response.Ready == nil || !*response.Ready { + return errors.New("native broker reports not ready") + } + if response.NativeUDE == nil { + return errors.New("native broker omitted its negotiated driver contract") + } + native := response.NativeUDE + requiredCapabilities := uint32(udecx.AdvertisedCapabilities) + if native.ABIMajor != udecx.ABIMajor || native.ABIMinor != udecx.ABIMinor { + return fmt.Errorf("native broker ABI=%d.%d expected=%d.%d", + native.ABIMajor, native.ABIMinor, udecx.ABIMajor, udecx.ABIMinor) + } + if native.Capabilities != requiredCapabilities { + return fmt.Errorf("native broker capabilities=%#x expected exact=%#x", native.Capabilities, requiredCapabilities) + } + if native.ExpectedDriverPackageVersion != udecx.DriverPackageVersion { + return fmt.Errorf("native broker package version=%q expected=%q", + native.ExpectedDriverPackageVersion, udecx.DriverPackageVersion) + } + if !udecx.IsCanonicalControllerSessionID(native.ControllerSessionID) { + return fmt.Errorf("native broker controller session identity=%q is not canonical", + native.ControllerSessionID) + } + if !udecx.IsCanonicalControllerInstanceID(native.ControllerInstanceID) { + return fmt.Errorf("native broker controller instance identity=%q is not canonical", + native.ControllerInstanceID) + } + if len(native.LoadedDriverBuildIdentity) != 64 { + return errors.New("native broker omitted the negotiated loaded-driver build identity") + } + loaded, err := hex.DecodeString(native.LoadedDriverBuildIdentity) + if err != nil || + native.LoadedDriverBuildIdentity != strings.ToLower(native.LoadedDriverBuildIdentity) { + return errors.New("native broker returned a malformed loaded-driver build identity") + } + if subtle.ConstantTimeCompare(loaded, expected[:]) != 1 { + return fmt.Errorf( + "native broker loaded-driver build identity=%s expected=%s", + native.LoadedDriverBuildIdentity, udecx.BuildIdentityHex(expected), + ) + } + return nil +} + +func waitContext(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func acquireNativeInstallMutex(timeout time.Duration) (func(), error) { + return acquireNativeNamedMutex( + nativeInstallMutexName, timeout, + "another VIIPER native install, update, or uninstall is still running", + ) +} + +type nativeFileAttributeTagInfo struct { + FileAttributes uint32 + ReparseTag uint32 +} + +func lockNativeServiceExecutable(executable string) (func(), error) { + return lockNativeServiceExecutableReadOnly(executable) +} + +// lockNativePriorServiceExecutable proves that a preexisting service already +// points at an installer-owned image without changing any filesystem metadata. +// The snapshot operation runs before transactional rollback is armed, so it +// must be strictly read-only. Older or user-writable layouts fail closed and +// can be repaired explicitly rather than being silently adopted as LocalSystem +// code. +func lockNativePriorServiceExecutable(executable string) (func(), error) { + return lockNativeServiceExecutableReadOnly(executable) +} + +func lockNativeServiceExecutableReadOnly(executable string) (func(), error) { + programFiles, err := windows.KnownFolderPath(windows.FOLDERID_ProgramFiles, windows.KF_FLAG_DEFAULT) + if err != nil { + return nil, fmt.Errorf("resolve Program Files: %w", err) + } + _, err = nativeServiceExecutableParent(programFiles, executable) + if err != nil { + return nil, err + } + programFiles = filepath.Clean(programFiles) + executable = filepath.Clean(executable) + relative, _ := filepath.Rel(programFiles, executable) + parts := strings.Split(relative, string(filepath.Separator)) + + var handles []windows.Handle + closeHandles := func() { + for index := len(handles) - 1; index >= 0; index-- { + windows.CloseHandle(handles[index]) //nolint:errcheck + } + handles = nil + } + fail := func(err error) (func(), error) { + closeHandles() + return nil, err + } + + // Reject every reparse point between the known folder and the executable. + // Keep non-delete-shared handles to every component through authenticated + // service startup and require that the package installer already established + // the exact protected owner/DACL contract. Rewriting an ACL here would not + // revoke dangerous handles opened under a former permissive DACL, so trust + // must be proven without mutating the image or its parents. + rootHandle, err := openNativePathWithoutReparse(programFiles, windows.FILE_READ_ATTRIBUTES, true) + if err != nil { + return nil, fmt.Errorf("open Program Files without reparse traversal: %w", err) + } + handles = append(handles, rootHandle) + current := programFiles + for index, part := range parts { + if part == "" { + continue + } + current = filepath.Join(current, part) + isDirectory := index < len(parts)-1 + access := uint32(windows.FILE_READ_ATTRIBUTES | windows.READ_CONTROL) + if isDirectory { + } else { + access |= windows.GENERIC_READ + } + handle, openErr := openNativePathWithoutReparse(current, access, isDirectory) + if openErr != nil { + return fail(fmt.Errorf("open protected broker path %s: %w", current, openErr)) + } + handles = append(handles, handle) + if isDirectory { + if err := validateNativeSecurityDescriptor(handle, nativeBrokerDirectorySDDL); err != nil { + return fail(fmt.Errorf("validate protected broker directory %s: %w", current, err)) + } + } + } + executableHandle := handles[len(handles)-1] + if err := requireSingleNativeFileLink(executableHandle); err != nil { + return fail(fmt.Errorf("reject hard-linked broker executable: %w", err)) + } + if err := validateNativeSecurityDescriptor(executableHandle, nativeBrokerExecutableSDDL); err != nil { + return fail(fmt.Errorf("validate protected broker executable: %w", err)) + } + header := make([]byte, 2) + var read uint32 + if err := windows.ReadFile(executableHandle, header, &read, nil); err != nil { + return fail(fmt.Errorf("read broker executable header: %w", err)) + } + if read != uint32(len(header)) || header[0] != 'M' || header[1] != 'Z' { + return fail(errors.New("native broker executable is not a Windows PE image")) + } + return closeHandles, nil +} + +func nativeServiceExecutableParent(programFiles, executable string) (string, error) { + programFiles = filepath.Clean(programFiles) + executable = filepath.Clean(executable) + relative, err := filepath.Rel(programFiles, executable) + if err != nil || relative == "." || filepath.IsAbs(relative) || relative == ".." || + strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("native broker must be installed below Program Files, got %s", executable) + } + parent := filepath.Dir(executable) + parts := strings.Split(relative, string(filepath.Separator)) + allowed := len(parts) == 2 && strings.EqualFold(parts[0], "VIIPER") && + strings.EqualFold(parts[1], "viiper.exe") + allowed = allowed || len(parts) == 3 && strings.EqualFold(parts[0], "DS4Windows") && + strings.EqualFold(parts[1], "VIIPER") && strings.EqualFold(parts[2], "viiper.exe") + if !allowed { + return "", fmt.Errorf("native broker must use a managed Program Files VIIPER path, got %s", executable) + } + return parent, nil +} + +func openNativePathWithoutReparse(path string, access uint32, directory bool) (windows.Handle, error) { + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + flags := uint32(windows.FILE_FLAG_OPEN_REPARSE_POINT) + if directory { + flags |= windows.FILE_FLAG_BACKUP_SEMANTICS + } + shareMode := uint32(windows.FILE_SHARE_READ) + if directory { + // Directory contents may still be read/written by trusted installers, but + // omitting DELETE keeps every validated ancestor from being renamed or + // removed until the native service transaction commits. + shareMode |= windows.FILE_SHARE_WRITE + } + handle, err := windows.CreateFile( + pointer, + access, + shareMode, + nil, + windows.OPEN_EXISTING, + flags, + 0, + ) + if err != nil { + return 0, err + } + info := nativeFileAttributeTagInfo{} + if err := windows.GetFileInformationByHandleEx( + handle, + windows.FileAttributeTagInfo, + (*byte)(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return 0, err + } + if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + windows.CloseHandle(handle) //nolint:errcheck + return 0, errors.New("path is a reparse point") + } + if directory != (info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0) { + windows.CloseHandle(handle) //nolint:errcheck + return 0, errors.New("path type does not match the expected broker object") + } + return handle, nil +} + +func applyNativeACLToHandle(handle windows.Handle, sddl string) error { + return setNativeObjectSecurityDescriptor(handle, windows.SE_FILE_OBJECT, sddl) +} + +func nativeObjectSecurityDescriptor(handle windows.Handle, objectType windows.SE_OBJECT_TYPE) (string, error) { + descriptor, err := windows.GetSecurityInfo( + handle, + objectType, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return "", err + } + if descriptor == nil || !descriptor.IsValid() { + return "", errors.New("object returned an invalid security descriptor") + } + sddl := descriptor.String() + if sddl == "" { + return "", errors.New("object security descriptor could not be serialized") + } + return sddl, nil +} + +func setNativeObjectSecurityDescriptor( + handle windows.Handle, + objectType windows.SE_OBJECT_TYPE, + sddl string, +) error { + descriptor, err := windows.SecurityDescriptorFromString(sddl) + if err != nil { + return err + } + owner, _, err := descriptor.Owner() + if err != nil { + return err + } + dacl, _, err := descriptor.DACL() + if err != nil { + return err + } + control, _, err := descriptor.Control() + if err != nil { + return err + } + securityInformation := windows.SECURITY_INFORMATION( + windows.OWNER_SECURITY_INFORMATION | windows.DACL_SECURITY_INFORMATION, + ) + if control&windows.SE_DACL_PROTECTED != 0 { + securityInformation |= windows.PROTECTED_DACL_SECURITY_INFORMATION + } else { + securityInformation |= windows.UNPROTECTED_DACL_SECURITY_INFORMATION + } + return windows.SetSecurityInfo( + handle, + objectType, + securityInformation, + owner, + nil, + dacl, + nil, + ) +} + +func protectNativeServiceObject(service nativeManagedService) error { + if err := service.SetSecurityDescriptor(nativeBrokerServiceSDDL); err != nil { + return fmt.Errorf("apply protected %s service DACL: %w", NativeBrokerServiceName, err) + } + actual, err := service.SecurityDescriptor() + if err != nil { + return fmt.Errorf("verify protected %s service DACL: %w", NativeBrokerServiceName, err) + } + return compareNativeSecurityDescriptorStrings(actual, nativeBrokerServiceSDDL) +} + +func compareNativeSecurityDescriptorStrings(actual, expected string) error { + actualDescriptor, err := windows.SecurityDescriptorFromString(actual) + if err != nil { + return fmt.Errorf("parse actual security descriptor: %w", err) + } + expectedDescriptor, err := windows.SecurityDescriptorFromString(expected) + if err != nil { + return fmt.Errorf("parse expected security descriptor: %w", err) + } + return nativeSecurityDescriptorsEqual( + actualDescriptor, expectedDescriptor, nativeServiceAccessMapping, + ) +} + +func requireSingleNativeFileLink(handle windows.Handle) error { + info := windows.ByHandleFileInformation{} + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return fmt.Errorf("query file link identity: %w", err) + } + return validateNativeFileLinkCount(info.NumberOfLinks) +} + +func validateNativeFileLinkCount(numberOfLinks uint32) error { + if numberOfLinks != 1 { + return fmt.Errorf("expected exactly one file link, found %d", numberOfLinks) + } + return nil +} + +func serviceWasOperational(state svc.State) bool { + return state == svc.Running +} + +func provisionNativeServiceCredential(userSID string) (nativeCredential, error) { + return provisionNativeServiceCredentialWithJournal(userSID, nil) +} + +func provisionNativeServiceCredentialWithJournal( + userSID string, + journal *nativeBrokerJournal, +) (nativeCredential, error) { + path, err := nativeServiceKeyFilePath() + if err != nil { + return nativeCredential{}, err + } + if _, err := validateNativeInstallingUserSID(userSID); err != nil { + return nativeCredential{}, err + } + directory := filepath.Dir(path) + directoryHandle, err := secureNativeCredentialDirectory(directory, userSID) + if err != nil { + return nativeCredential{}, err + } + defer windows.CloseHandle(directoryHandle) //nolint:errcheck + + prior, existed, err := readNativeCredential(path, userSID) + if err != nil { + return nativeCredential{}, fmt.Errorf("read credential: %w", err) + } + if err := journal.validatePriorCredential(existed, prior); err != nil { + return nativeCredential{}, err + } + password, err := rotatedNativeServiceKey(prior, auth.GenerateKey) + if err != nil { + return nativeCredential{}, fmt.Errorf("generate credential: %w", err) + } + candidateDigest := nativeBrokerJournalHash([]byte(password)) + if err := journal.appendPhase(nativeBrokerPhaseCredentialWriteIntent, candidateDigest); err != nil { + return nativeCredential{}, err + } + if err := writeNativeCredentialAtomically(path, []byte(password), userSID); err != nil { + return nativeCredential{}, err + } + if err := journal.appendPhase(nativeBrokerPhaseCredentialWritten, candidateDigest); err != nil { + return nativeCredential{}, err + } + return nativeCredential{ + path: path, password: password, userSID: userSID, + created: !existed, replaced: existed, priorBytes: append([]byte(nil), prior...), + }, nil +} + +func resolveNativeInstallingUserSID(explicit string) (string, error) { + if strings.TrimSpace(explicit) != "" { + return validateNativeInstallingUserSID(explicit) + } + currentUser, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return "", fmt.Errorf("query installer token user SID: %w", err) + } + currentSID, err := validateNativeInstallingUserSID(currentUser.User.Sid.String()) + if err != nil && !currentUser.User.Sid.IsWellKnown(windows.WinLocalSystemSid) { + return "", err + } + + // Elevation can change the process identity: deferred MSI work commonly runs + // as LocalSystem, and over-the-shoulder UAC runs as a different administrator. + // Prefer the shell owner when it is visible in this session. A session-0 + // LocalSystem installer has no shell window, so use the active-console token. + interactiveSID := "" + interactiveErr := error(nil) + if token, tokenErr := nativeInteractiveUserToken("", windows.TOKEN_QUERY); tokenErr == nil { + defer token.Close() //nolint:errcheck + user, userErr := token.GetTokenUser() + if userErr != nil { + return "", fmt.Errorf("query interactive installer user SID: %w", userErr) + } + interactiveSID, interactiveErr = validateNativeInstallingUserSID(user.User.Sid.String()) + } else { + interactiveErr = tokenErr + } + selected, err := selectNativeInstallingUserSID( + currentSID, + currentUser.User.Sid.IsWellKnown(windows.WinLocalSystemSid), + interactiveSID, + interactiveErr, + ) + if err != nil { + return "", err + } + return validateNativeInstallingUserSID(selected) +} + +func selectNativeInstallingUserSID( + currentSID string, + currentIsLocalSystem bool, + interactiveSID string, + interactiveErr error, +) (string, error) { + if interactiveErr == nil && strings.TrimSpace(interactiveSID) != "" { + return interactiveSID, nil + } + if currentIsLocalSystem { + return "", errors.Join( + interactiveErr, + errors.New("cannot identify the interactive installing user; pass --target-user-sid from the bootstrapper"), + ) + } + if strings.TrimSpace(currentSID) == "" { + return "", errors.Join(interactiveErr, errors.New("installer token has no user SID")) + } + return currentSID, nil +} + +func validateNativeInstallingUserSID(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" || strings.ContainsAny(value, `\/`) { + return "", errors.New("installing user SID is missing or invalid") + } + sid, err := windows.StringToSid(value) + if err != nil { + return "", fmt.Errorf("parse installing user SID: %w", err) + } + if sid.IsWellKnown(windows.WinLocalSystemSid) || sid.IsWellKnown(windows.WinLocalServiceSid) || + sid.IsWellKnown(windows.WinNetworkServiceSid) { + return "", errors.New("installing user SID names a service identity") + } + _, _, accountType, err := sid.LookupAccount("") + if err != nil { + return "", fmt.Errorf("resolve installing user SID: %w", err) + } + if accountType != windows.SidTypeUser { + return "", fmt.Errorf("installing user SID is not a user account (type=%d)", accountType) + } + return sid.String(), nil +} + +func nativeInteractiveUserToken(expectedSID string, access uint32) (windows.Token, error) { + var shellErr error + if shellWindow := windows.GetShellWindow(); shellWindow != 0 { + var shellPID uint32 + if _, err := windows.GetWindowThreadProcessId(shellWindow, &shellPID); err != nil { + shellErr = fmt.Errorf("query interactive shell process: %w", err) + } else if shellPID == 0 { + shellErr = errors.New("interactive shell reported no process identifier") + } else if shellProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, shellPID); err != nil { + shellErr = fmt.Errorf("open interactive shell process: %w", err) + } else { + var shellToken windows.Token + err = windows.OpenProcessToken(shellProcess, access, &shellToken) + windows.CloseHandle(shellProcess) //nolint:errcheck + if err != nil { + shellErr = fmt.Errorf("open interactive shell token: %w", err) + } else if err := validateNativeInteractiveToken(shellToken, expectedSID); err != nil { + shellToken.Close() //nolint:errcheck + shellErr = err + } else { + return shellToken, nil + } + } + } + + session := windows.WTSGetActiveConsoleSessionId() + if session == ^uint32(0) { + return 0, errors.Join(shellErr, errors.New("Windows reports no active console session")) + } + var token windows.Token + if err := windows.WTSQueryUserToken(session, &token); err != nil { + return 0, errors.Join(shellErr, fmt.Errorf("query active-console user token: %w", err)) + } + if err := validateNativeInteractiveToken(token, expectedSID); err != nil { + token.Close() //nolint:errcheck + return 0, errors.Join(shellErr, err) + } + return token, nil +} + +func validateNativeInteractiveToken(token windows.Token, expectedSID string) error { + user, err := token.GetTokenUser() + if err != nil { + return fmt.Errorf("query interactive user token: %w", err) + } + actual, err := validateNativeInstallingUserSID(user.User.Sid.String()) + if err != nil { + return err + } + if expectedSID != "" && !strings.EqualFold(actual, expectedSID) { + return fmt.Errorf("interactive user SID %s does not match installer target %s", actual, expectedSID) + } + return nil +} + +func expandNativeUserEnvironment(expectedSID, value string) (string, error) { + if strings.IndexByte(value, 0) >= 0 { + return "", errors.New("target-user environment string contains NUL") + } + token, err := nativeInteractiveUserToken(expectedSID, windows.TOKEN_QUERY) + if err != nil { + return "", err + } + defer token.Close() //nolint:errcheck + source, err := windows.UTF16PtrFromString(value) + if err != nil { + return "", err + } + // Windows paths are bounded to 32,767 UTF-16 code units. The API does not + // expose a size-probe contract, so allocate that maximum once and fail closed + // if userenv.dll rejects it. + destination := make([]uint16, 32768) + result, _, callErr := expandEnvironmentStringsForUserW.Call( + uintptr(token), + uintptr(unsafe.Pointer(source)), + uintptr(unsafe.Pointer(&destination[0])), + uintptr(len(destination)), + ) + if result == 0 { + if callErr == nil || errors.Is(callErr, windows.ERROR_SUCCESS) { + callErr = errors.New("ExpandEnvironmentStringsForUserW returned false") + } + return "", fmt.Errorf("expand environment for target interactive user: %w", callErr) + } + return windows.UTF16ToString(destination), nil +} + +func rotatedNativeServiceKey(prior []byte, generate func() (string, error)) (string, error) { + priorKey := strings.TrimSpace(string(prior)) + for attempt := 0; attempt < 4; attempt++ { + password, err := generate() + if err != nil { + return "", err + } + password = strings.TrimSpace(password) + if password != "" && password != priorKey { + return password, nil + } + } + return "", errors.New("credential generator did not produce a fresh nonempty key") +} + +func secureNativeCredentialDirectory(directory, userSID string) (windows.Handle, error) { + programData, err := windows.KnownFolderPath(windows.FOLDERID_ProgramData, windows.KF_FLAG_DEFAULT) + if err != nil { + return 0, fmt.Errorf("resolve ProgramData known folder: %w", err) + } + programData = filepath.Clean(programData) + if !strings.EqualFold(filepath.Clean(directory), filepath.Join(programData, "VIIPER")) { + return 0, fmt.Errorf("credential directory escaped ProgramData: %s", directory) + } + programDataHandle, err := openNativePathWithoutReparse(programData, windows.FILE_READ_ATTRIBUTES, true) + if err != nil { + return 0, fmt.Errorf("open ProgramData without reparse traversal: %w", err) + } + defer windows.CloseHandle(programDataHandle) //nolint:errcheck + sddl := nativeCredentialDirectorySDDL(userSID) + descriptor, err := windows.SecurityDescriptorFromString(sddl) + if err != nil { + return 0, fmt.Errorf("build credential directory security descriptor: %w", err) + } + pointer, err := windows.UTF16PtrFromString(directory) + if err != nil { + return 0, err + } + attributes := windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: descriptor, + } + created := false + if err := windows.CreateDirectory(pointer, &attributes); err == nil { + created = true + } else if !errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + return 0, fmt.Errorf("atomically create protected credential directory: %w", err) + } + // Never take ownership of or re-ACL an existing ProgramData directory. A + // standard user can pre-create it and keep an already-authorized directory + // handle even after a later DACL change. Only an atomically created directory + // or an existing directory that already has our exact protected owner/DACL is + // eligible to contain the service credential. + directoryHandle, err := openNativePathWithoutReparse( + directory, + windows.READ_CONTROL, + true, + ) + if err != nil { + return 0, fmt.Errorf("open credential directory without reparse traversal: %w", err) + } + if err := validateNativeSecurityDescriptor(directoryHandle, sddl); err != nil { + windows.CloseHandle(directoryHandle) //nolint:errcheck + origin := "existing" + if created { + origin = "newly created" + } + return 0, fmt.Errorf("reject %s credential directory security: %w", origin, err) + } + return directoryHandle, nil +} + +func validateNativeSecurityDescriptor(handle windows.Handle, expectedSDDL string) error { + actual, err := windows.GetSecurityInfo( + handle, + windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return fmt.Errorf("query security descriptor: %w", err) + } + expected, err := windows.SecurityDescriptorFromString(expectedSDDL) + if err != nil { + return err + } + return nativeSecurityDescriptorsEqual(actual, expected, nativeFileAccessMapping) +} + +func normalizeNativeAccessMask( + mask windows.ACCESS_MASK, + mapping nativeGenericAccessMapping, +) windows.ACCESS_MASK { + generic := mask & (windows.GENERIC_READ | windows.GENERIC_WRITE | + windows.GENERIC_EXECUTE | windows.GENERIC_ALL) + mask &^= windows.GENERIC_READ | windows.GENERIC_WRITE | + windows.GENERIC_EXECUTE | windows.GENERIC_ALL + if generic&windows.GENERIC_READ != 0 { + mask |= mapping.read + } + if generic&windows.GENERIC_WRITE != 0 { + mask |= mapping.write + } + if generic&windows.GENERIC_EXECUTE != 0 { + mask |= mapping.execute + } + if generic&windows.GENERIC_ALL != 0 { + mask |= mapping.all + } + return mask +} + +func nativeAccessAllowedACEs( + dacl *windows.ACL, + mapping nativeGenericAccessMapping, +) ([]nativeAccessAllowedACE, error) { + if dacl == nil { + return nil, errors.New("security descriptor has no DACL") + } + entries := make([]nativeAccessAllowedACE, 0, dacl.AceCount) + for index := uint32(0); index < uint32(dacl.AceCount); index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, index, &ace); err != nil { + return nil, fmt.Errorf("read DACL ACE %d: %w", index, err) + } + if ace == nil || ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + return nil, fmt.Errorf("DACL ACE %d is not an explicit access-allowed ACE", index) + } + sidOffset := unsafe.Offsetof(ace.SidStart) + aceSize := uintptr(ace.Header.AceSize) + if aceSize < sidOffset+8 { + return nil, fmt.Errorf("DACL ACE %d is truncated", index) + } + remaining := aceSize - sidOffset + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + subAuthorityCount := *(*uint8)(unsafe.Add(unsafe.Pointer(sid), 1)) + sidLength := uintptr(8 + 4*uint32(subAuthorityCount)) + if sidLength > remaining || !sid.IsValid() || uintptr(sid.Len()) != sidLength { + return nil, fmt.Errorf("DACL ACE %d contains an invalid SID", index) + } + sidString := sid.String() + if sidString == "" { + return nil, fmt.Errorf("DACL ACE %d SID could not be serialized", index) + } + entries = append(entries, nativeAccessAllowedACE{ + flags: ace.Header.AceFlags, + mask: normalizeNativeAccessMask(ace.Mask, mapping), + sid: sidString, + }) + } + return entries, nil +} + +func nativeSecurityDescriptorsEqual( + actual, expected *windows.SECURITY_DESCRIPTOR, + mapping nativeGenericAccessMapping, +) error { + actualOwner, actualOwnerDefaulted, err := actual.Owner() + if err != nil { + return err + } + expectedOwner, expectedOwnerDefaulted, err := expected.Owner() + if err != nil { + return err + } + if actualOwner == nil || expectedOwner == nil || + actualOwnerDefaulted != expectedOwnerDefaulted || !actualOwner.Equals(expectedOwner) { + return errors.New("security descriptor owner is not the trusted installer owner") + } + actualDACL, actualDefaulted, err := actual.DACL() + if err != nil { + return err + } + expectedDACL, expectedDefaulted, err := expected.DACL() + if err != nil { + return err + } + if actualDACL == nil || expectedDACL == nil || actualDefaulted != expectedDefaulted { + return errors.New("security descriptor DACL metadata does not match") + } + actualEntries, err := nativeAccessAllowedACEs(actualDACL, mapping) + if err != nil { + return fmt.Errorf("inspect actual security descriptor DACL: %w", err) + } + expectedEntries, err := nativeAccessAllowedACEs(expectedDACL, mapping) + if err != nil { + return fmt.Errorf("inspect expected security descriptor DACL: %w", err) + } + if !slices.Equal(actualEntries, expectedEntries) { + return errors.New("security descriptor DACL access rules do not match") + } + actualControl, _, err := actual.Control() + if err != nil { + return err + } + expectedControl, _, err := expected.Control() + if err != nil { + return err + } + if actualControl&windows.SE_DACL_PROTECTED != expectedControl&windows.SE_DACL_PROTECTED || + actualControl&windows.SE_DACL_PRESENT != expectedControl&windows.SE_DACL_PRESENT { + return errors.New("security descriptor protection flags do not match") + } + return nil +} + +func readNativeCredential(path, userSID string) ([]byte, bool, error) { + handle, err := openNativePathWithoutReparse( + path, + windows.GENERIC_READ|windows.READ_CONTROL|windows.WRITE_DAC|windows.WRITE_OWNER, + false, + ) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil, false, nil + } + return nil, false, err + } + // A standard user can pre-create ProgramData\VIIPER before its DACL is + // hardened. Reject a planted hard link before taking ownership or changing + // its security descriptor, because those operations affect the underlying + // file and every link to it. + if err := requireSingleNativeFileLink(handle); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return nil, false, fmt.Errorf("reject hard-linked credential: %w", err) + } + if err := applyNativeACLToHandle(handle, nativeCredentialFileSDDL(userSID)); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return nil, false, err + } + file := os.NewFile(uintptr(handle), path) + if file == nil { + windows.CloseHandle(handle) //nolint:errcheck + return nil, false, errors.New("wrap credential file handle") + } + defer file.Close() //nolint:errcheck + contents, err := io.ReadAll(io.LimitReader(file, 64*1024+1)) + if err != nil { + return nil, false, err + } + if len(contents) > 64*1024 { + return nil, false, fmt.Errorf("credential is unexpectedly large: more than %d bytes", 64*1024) + } + return contents, true, nil +} + +func readNativeCredentialReadOnly(userSID string) ([]byte, error) { + if _, err := validateNativeInstallingUserSID(userSID); err != nil { + return nil, err + } + path, err := nativeServiceKeyFilePath() + if err != nil { + return nil, err + } + programData, err := windows.KnownFolderPath(windows.FOLDERID_ProgramData, windows.KF_FLAG_DEFAULT) + if err != nil { + return nil, fmt.Errorf("resolve ProgramData known folder: %w", err) + } + programData = filepath.Clean(programData) + directory := filepath.Join(programData, "VIIPER") + if !strings.EqualFold(filepath.Clean(path), filepath.Join(directory, keyFileName)) { + return nil, fmt.Errorf("native credential escaped the managed ProgramData path: %s", path) + } + rootHandle, err := openNativePathWithoutReparse( + programData, windows.FILE_READ_ATTRIBUTES, true, + ) + if err != nil { + return nil, fmt.Errorf("open ProgramData without reparse traversal: %w", err) + } + defer windows.CloseHandle(rootHandle) //nolint:errcheck + directoryHandle, err := openNativePathWithoutReparse( + directory, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + ) + if err != nil { + return nil, fmt.Errorf("open protected native credential directory: %w", err) + } + defer windows.CloseHandle(directoryHandle) //nolint:errcheck + if err := validateNativeSecurityDescriptor( + directoryHandle, nativeCredentialDirectorySDDL(userSID), + ); err != nil { + return nil, fmt.Errorf("validate protected native credential directory: %w", err) + } + credentialHandle, err := openNativePathWithoutReparse( + path, windows.GENERIC_READ|windows.READ_CONTROL, false, + ) + if err != nil { + return nil, err + } + if err := requireSingleNativeFileLink(credentialHandle); err != nil { + windows.CloseHandle(credentialHandle) //nolint:errcheck + return nil, fmt.Errorf("reject hard-linked credential: %w", err) + } + if err := validateNativeSecurityDescriptor( + credentialHandle, nativeCredentialFileSDDL(userSID), + ); err != nil { + windows.CloseHandle(credentialHandle) //nolint:errcheck + return nil, fmt.Errorf("validate protected native credential: %w", err) + } + file := os.NewFile(uintptr(credentialHandle), path) + if file == nil { + windows.CloseHandle(credentialHandle) //nolint:errcheck + return nil, errors.New("wrap protected native credential handle") + } + defer file.Close() //nolint:errcheck + contents, err := io.ReadAll(io.LimitReader(file, 64*1024+1)) + if err != nil { + return nil, err + } + if len(contents) == 0 || len(contents) > 64*1024 { + return nil, fmt.Errorf("native credential has invalid length %d", len(contents)) + } + return contents, nil +} + +func writeNativeCredentialAtomically(path string, contents []byte, userSID string) error { + directory := filepath.Dir(path) + temporary, temporaryPath, err := createProtectedNativeCredentialStagingFile( + directory, userSID, + ) + if err != nil { + return fmt.Errorf("create credential staging file: %w", err) + } + cleanupTemporary := true + defer func() { + temporary.Close() //nolint:errcheck + if cleanupTemporary { + os.Remove(temporaryPath) //nolint:errcheck + } + }() + if _, err := temporary.Write(contents); err != nil { + return fmt.Errorf("write credential staging file: %w", err) + } + if err := temporary.Sync(); err != nil { + return fmt.Errorf("flush credential staging file: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close credential staging file: %w", err) + } + if err := replaceFileAtomically(temporaryPath, path); err != nil { + return fmt.Errorf("publish credential atomically: %w", err) + } + cleanupTemporary = false + return nil +} + +func createProtectedNativeCredentialStagingFile( + directory, userSID string, +) (*os.File, string, error) { + sddl := nativeCredentialFileSDDL(userSID) + security, err := nativeSecurityAttributes(sddl) + if err != nil { + return nil, "", fmt.Errorf("build credential staging security descriptor: %w", err) + } + for attempt := 0; attempt < 8; attempt++ { + var suffix [16]byte + if _, err := io.ReadFull(rand.Reader, suffix[:]); err != nil { + return nil, "", fmt.Errorf("generate credential staging name: %w", err) + } + path := filepath.Join(directory, + ".viiper-key-"+hex.EncodeToString(suffix[:])+".tmp") + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, "", err + } + handle, err := windows.CreateFile( + pointer, + windows.GENERIC_READ|windows.GENERIC_WRITE|windows.READ_CONTROL, + 0, + security, + windows.CREATE_NEW, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT| + windows.FILE_FLAG_WRITE_THROUGH, + 0, + ) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_EXISTS) || + errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + continue + } + return nil, "", err + } + fail := func(failErr error) (*os.File, string, error) { + windows.CloseHandle(handle) //nolint:errcheck + _ = os.Remove(path) + return nil, "", failErr + } + attribute := nativeFileAttributeTagInfo{} + if err := windows.GetFileInformationByHandleEx( + handle, + windows.FileAttributeTagInfo, + (*byte)(unsafe.Pointer(&attribute)), + uint32(unsafe.Sizeof(attribute)), + ); err != nil { + return fail(fmt.Errorf("inspect credential staging file: %w", err)) + } + if attribute.FileAttributes&(windows.FILE_ATTRIBUTE_DIRECTORY| + windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + return fail(errors.New("credential staging path is not a regular file")) + } + if err := requireSingleNativeFileLink(handle); err != nil { + return fail(fmt.Errorf("reject hard-linked credential staging file: %w", err)) + } + if err := validateNativeSecurityDescriptor(handle, sddl); err != nil { + return fail(fmt.Errorf("validate credential staging file security: %w", err)) + } + file := os.NewFile(uintptr(handle), path) + if file == nil { + return fail(errors.New("wrap credential staging file handle")) + } + return file, path, nil + } + return nil, "", errors.New("credential staging name collisions exceeded retry budget") +} + +func replaceFileAtomically(source, destination string) error { + sourcePointer, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + destinationPointer, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + return windows.MoveFileEx(sourcePointer, destinationPointer, + windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) +} + +func rollbackNativeServiceCredential(credential nativeCredential) error { + if credential.created { + if err := os.Remove(credential.path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + if credential.replaced { + return writeNativeCredentialAtomically(credential.path, credential.priorBytes, credential.userSID) + } + return nil +} + +func nativeCredentialDirectorySDDL(userSID string) string { + return "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;" + userSID + ")" +} + +func nativeCredentialFileSDDL(userSID string) string { + return "O:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;GR;;;" + userSID + ")" +} + +func snapshotNativeLegacyStartup(ctx context.Context, userSID string) (nativeLegacyState, error) { + if _, err := validateNativeInstallingUserSID(userSID); err != nil { + return nativeLegacyState{}, err + } + state := nativeLegacyState{userSID: userSID} + succeeded := false + defer func() { + if !succeeded && state.release != nil { + state.release() + } + }() + scheduledCommand, scheduledXML, scheduledActive, scheduledEnabled, found, err := currentScheduledTaskCommand(ctx) + if err != nil { + return state, err + } + if found { + if err := validateNativeScheduledTaskState(scheduledActive, scheduledEnabled); err != nil { + return state, err + } + if scheduledEnabled || scheduledActive { + verify, release, lockErr := lockNativeLegacyTaskExecutable(scheduledCommand.executable) + if lockErr != nil { + return state, fmt.Errorf("lock RunVIIPER action through migration: %w", lockErr) + } + state.verifyTaskAction = verify + appendNativeLegacyRelease(&state, release) + } + currentXML := scheduledXML + state.scheduledAction = &scheduledCommand + state.scheduledXML = &scheduledXML + state.scheduledCurrentXML = ¤tXML + state.scheduledActive = scheduledActive + state.scheduledEnabled = scheduledEnabled + } + hive, err := registry.OpenKey(registry.USERS, userSID, registry.READ) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return state, fmt.Errorf("target user hive HKU\\%s is not loaded; resume migration after that user signs in", userSID) + } + return state, fmt.Errorf("open target user hive HKU\\%s: %w", userSID, err) + } + state.userHive = hive + appendNativeLegacyRelease(&state, func() { hive.Close() }) //nolint:errcheck + runKey, err := registry.OpenKey(hive, runKeyPath, registry.QUERY_VALUE|registry.SET_VALUE) + if err != nil && !errors.Is(err, registry.ErrNotExist) { + return state, fmt.Errorf("open target user Run key: %w", err) + } + if err == nil { + state.runKey = runKey + state.runKeyExisted = true + appendNativeLegacyRelease(&state, func() { runKey.Close() }) //nolint:errcheck + } + runRegistration, found, err := currentNativeRunRegistration(state) + if err != nil { + return state, err + } + if found { + state.runValue = &runRegistration + if strings.TrimSpace(runRegistration.value) != "" { + expand := func(value string) (string, error) { return value, nil } + if runRegistration.valueType == registry.EXPAND_SZ { + expand = func(value string) (string, error) { + return expandNativeUserEnvironment(userSID, value) + } + } + command, err := parseWindowsCommand(runRegistration.value, expand) + if err != nil { + return state, fmt.Errorf("parse VIIPER Run command: %w", err) + } + command.source = legacyCommandRun + state.commands = append(state.commands, command) + } + } + succeeded = true + return state, nil +} + +func validateNativeScheduledTaskState(active, enabled bool) error { + if active && !enabled { + return errors.New("RunVIIPER is active while disabled and cannot be restored transactionally") + } + return nil +} + +func appendNativeLegacyRelease(state *nativeLegacyState, release func()) { + if release == nil { + return + } + prior := state.release + state.release = func() { + release() + if prior != nil { + prior() + } + } +} + +func currentNativeRunRegistration(state nativeLegacyState) (nativeRunRegistration, bool, error) { + if state.userHive == 0 { + return nativeRunRegistration{}, false, errors.New("target user hive is not retained by the transaction") + } + key := state.runKey + closeKey := false + if !state.runKeyExisted { + var err error + key, err = registry.OpenKey(state.userHive, runKeyPath, registry.QUERY_VALUE) + if errors.Is(err, registry.ErrNotExist) { + return nativeRunRegistration{}, false, nil + } + if err != nil { + return nativeRunRegistration{}, false, fmt.Errorf("open retained target-user Run key: %w", err) + } + closeKey = true + } + if key == 0 { + return nativeRunRegistration{}, false, errors.New("retained target-user Run key is unavailable") + } + if closeKey { + defer key.Close() //nolint:errcheck + } + return readNativeRunRegistration(key) +} + +func readNativeRunRegistration(key registry.Key) (nativeRunRegistration, bool, error) { + value, valueType, err := key.GetStringValue(runValueKey) + if errors.Is(err, registry.ErrNotExist) { + return nativeRunRegistration{}, false, nil + } + if err != nil { + return nativeRunRegistration{}, false, err + } + if valueType != registry.SZ && valueType != registry.EXPAND_SZ { + return nativeRunRegistration{}, false, fmt.Errorf("VIIPER Run value has unsupported registry type %d", valueType) + } + return nativeRunRegistration{value: value, valueType: valueType}, true, nil +} + +func nativeRunRegistrationsEqual(first nativeRunRegistration, second nativeRunRegistration) bool { + return first.value == second.value && first.valueType == second.valueType +} + +func validateNativeRunRegistrationSnapshot( + expected *nativeRunRegistration, + current nativeRunRegistration, + found bool, +) error { + if expected == nil { + if found { + return errors.New("VIIPER Run registration appeared during native service migration") + } + return nil + } + if !found || !nativeRunRegistrationsEqual(current, *expected) { + return errors.New("VIIPER Run registration changed or disappeared during native service migration") + } + return nil +} + +func setNativeRunRegistration(key registry.Key, value nativeRunRegistration) error { + switch value.valueType { + case registry.SZ: + return key.SetStringValue(runValueKey, value.value) + case registry.EXPAND_SZ: + return key.SetExpandStringValue(runValueKey, value.value) + default: + return fmt.Errorf("cannot restore VIIPER Run value with registry type %d", value.valueType) + } +} + +func nativeUserRunKeyPath(userSID string) (string, error) { + userSID = strings.TrimSpace(userSID) + if userSID == "" || strings.ContainsAny(userSID, `\/`) { + return "", errors.New("installing user SID is missing or invalid") + } + if _, err := windows.StringToSid(userSID); err != nil { + return "", fmt.Errorf("parse installing user SID: %w", err) + } + return userSID + `\` + runKeyPath, nil +} + +func parseWindowsCommand( + commandLine string, + expand func(string) (string, error), +) (nativeLegacyCommand, error) { + arguments, err := windows.DecomposeCommandLine(commandLine) + if err != nil { + return nativeLegacyCommand{}, err + } + if len(arguments) == 0 || strings.TrimSpace(arguments[0]) == "" { + return nativeLegacyCommand{}, errors.New("startup command has no executable") + } + if expand == nil { + return nativeLegacyCommand{}, errors.New("startup command environment expander is required") + } + executable, err := expand(arguments[0]) + if err != nil { + return nativeLegacyCommand{}, fmt.Errorf("expand target-user startup executable: %w", err) + } + executable = filepath.Clean(executable) + if !strings.EqualFold(filepath.Base(executable), "viiper.exe") { + return nativeLegacyCommand{}, fmt.Errorf("startup command is not VIIPER: %s", executable) + } + return nativeLegacyCommand{executable: executable, arguments: arguments[1:]}, nil +} + +func currentScheduledTaskCommand(ctx context.Context) (nativeLegacyCommand, string, bool, bool, bool, error) { + // The script is a fixed program: no path, account, or other caller-controlled + // text is interpolated into it. JSON preserves spaces and quoting exactly, + // while CommandLineToArgvW below applies Windows' own argument grammar. + const script = `$ErrorActionPreference='Stop';` + + `[Console]::OutputEncoding=[Text.UTF8Encoding]::new();` + + `$m=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -ceq '\' -and $_.TaskName -ieq 'RunVIIPER'});` + + `if($m.Count -gt 1){throw 'multiple root RunVIIPER tasks found'};$t=$null;if($m.Count -eq 1){$t=$m[0]};` + + `if($null -eq $t){[pscustomobject]@{Found=$false}|ConvertTo-Json -Compress;exit 0};` + + `$a=@($t.Actions);if($a.Count -ne 1){throw 'scheduled task must contain exactly one action'};` + + `$x=Export-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop;` + + `$s=[string]$t.State;` + + `[pscustomobject]@{Found=$true;Name=$t.TaskName;Active=($s -eq 'Running' -or $s -eq 'Queued');Enabled=[bool]$t.Settings.Enabled;Execute=$a[0].Execute;Arguments=$a[0].Arguments;WorkingDirectory=$a[0].WorkingDirectory;Xml=$x}|ConvertTo-Json -Compress` + powershell, err := trustedSystemExecutable("WindowsPowerShell", "v1.0", "powershell.exe") + if err != nil { + return nativeLegacyCommand{}, "", false, false, false, fmt.Errorf("resolve trusted PowerShell: %w", err) + } + output, err := exec.CommandContext(ctx, powershell, "-NoProfile", "-NonInteractive", "-Command", script).CombinedOutput() + if err != nil { + return nativeLegacyCommand{}, "", false, false, false, fmt.Errorf("scheduled task query failed: %w: %s", err, strings.TrimSpace(string(output))) + } + var action struct { + Found bool + Name string + Active bool + Enabled bool + Execute string + Arguments string + WorkingDirectory string + XML string + } + if err := json.Unmarshal(output, &action); err != nil { + return nativeLegacyCommand{}, "", false, false, false, fmt.Errorf("decode scheduled task action: %w", err) + } + if !action.Found { + return nativeLegacyCommand{}, "", false, false, false, nil + } + if !nativeScheduledTaskNameMatches(action.Name) { + return nativeLegacyCommand{}, "", false, false, false, + fmt.Errorf("Task Scheduler returned unexpected task identity %q", action.Name) + } + if strings.TrimSpace(action.XML) == "" { + return nativeLegacyCommand{}, "", false, false, false, errors.New("scheduled task export returned empty XML") + } + // Task Scheduler owns expansion of its action environment. Preserve the raw + // action path for exact comparison and never reinterpret it under the + // elevated installer or LocalSystem environment. + executable := filepath.Clean(strings.Trim(action.Execute, `"`)) + if !strings.EqualFold(filepath.Base(executable), "viiper.exe") { + return nativeLegacyCommand{}, "", false, false, false, fmt.Errorf("%s action is not a VIIPER executable: %s", runScheduledTask, executable) + } + arguments, err := decomposeWindowsArguments(action.Arguments) + if err != nil { + return nativeLegacyCommand{}, "", false, false, false, fmt.Errorf("parse %s arguments: %w", runScheduledTask, err) + } + return nativeLegacyCommand{ + executable: executable, arguments: arguments, + workingDirectory: strings.TrimSpace(action.WorkingDirectory), + }, action.XML, action.Active, action.Enabled, true, nil +} + +func nativeScheduledTaskNameMatches(name string) bool { + return strings.EqualFold(name, runScheduledTask) +} + +func lockNativeLegacyTaskExecutable(executable string) (func() error, func(), error) { + executable = filepath.Clean(executable) + if !filepath.IsAbs(executable) || strings.Contains(executable, "%") { + return nil, nil, fmt.Errorf("RunVIIPER action must use an absolute, already-expanded executable path: %s", executable) + } + volume := filepath.VolumeName(executable) + if len(volume) != 2 || volume[1] != ':' { + return nil, nil, fmt.Errorf("RunVIIPER action must use a local drive path: %s", executable) + } + root := volume + `\` + relative, err := filepath.Rel(root, executable) + if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, `..\`) { + return nil, nil, fmt.Errorf("derive RunVIIPER action path components: %w", err) + } + parts := strings.Split(relative, `\`) + var handles []windows.Handle + closeHandles := func() { + for index := len(handles) - 1; index >= 0; index-- { + windows.CloseHandle(handles[index]) //nolint:errcheck + } + handles = nil + } + fail := func(err error) (func() error, func(), error) { + closeHandles() + return nil, nil, err + } + current := root + rootHandle, err := openNativePathWithoutReparse(current, windows.FILE_READ_ATTRIBUTES, true) + if err != nil { + return nil, nil, err + } + handles = append(handles, rootHandle) + for index, part := range parts { + if part == "" { + continue + } + current = filepath.Join(current, part) + isDirectory := index < len(parts)-1 + access := uint32(windows.FILE_READ_ATTRIBUTES) + if !isDirectory { + access |= windows.GENERIC_READ + } + handle, openErr := openNativePathWithoutReparse(current, access, isDirectory) + if openErr != nil { + return fail(fmt.Errorf("open locked RunVIIPER component %s: %w", current, openErr)) + } + handles = append(handles, handle) + } + leaf := handles[len(handles)-1] + if err := requireSingleNativeFileLink(leaf); err != nil { + return fail(fmt.Errorf("reject hard-linked RunVIIPER action: %w", err)) + } + verify := func() error { + finalPath, err := nativeFinalPathByHandle(leaf) + if err != nil { + return err + } + if !strings.EqualFold(finalPath, executable) { + return fmt.Errorf("RunVIIPER action path identity changed: requested=%s final=%s", executable, finalPath) + } + return nil + } + if err := verify(); err != nil { + return fail(err) + } + header := make([]byte, 2) + var read uint32 + if err := windows.ReadFile(leaf, header, &read, nil); err != nil { + return fail(err) + } + if read != 2 || header[0] != 'M' || header[1] != 'Z' { + return fail(errors.New("RunVIIPER action is not a Windows PE image")) + } + return verify, closeHandles, nil +} + +func nativeFinalPathByHandle(handle windows.Handle) (string, error) { + buffer := make([]uint16, 32768) + length, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), 0) + if err != nil { + return "", fmt.Errorf("resolve final path by handle: %w", err) + } + if length == 0 || length >= uint32(len(buffer)) { + return "", errors.New("final path by handle exceeded the Windows path bound") + } + path := windows.UTF16ToString(buffer[:length]) + if strings.HasPrefix(path, `\\?\UNC\`) { + path = `\\` + strings.TrimPrefix(path, `\\?\UNC\`) + } else { + path = strings.TrimPrefix(path, `\\?\`) + } + return filepath.Clean(path), nil +} + +func decomposeWindowsArguments(argumentLine string) ([]string, error) { + if strings.TrimSpace(argumentLine) == "" { + return nil, nil + } + arguments, err := windows.DecomposeCommandLine("viiper.exe " + argumentLine) + if err != nil { + return nil, err + } + if len(arguments) == 0 { + return nil, errors.New("argument decomposition returned no executable") + } + return arguments[1:], nil +} + +func stopNativeLegacyStartup(ctx context.Context, state *nativeLegacyState, logger *slog.Logger) error { + return stopNativeLegacyStartupWith(ctx, state, logger, nativeLegacyStopOperations{ + stopScheduled: stopNativeScheduledTask, + openProcesses: openLegacyProcessesByExecutable, + terminate: terminateVerifiedLegacyProcess, + closeHandle: func(handle windows.Handle) { + windows.CloseHandle(handle) //nolint:errcheck + }, + }) +} + +type nativeLegacyStopOperations struct { + stopScheduled func(context.Context, string, bool) (nativeScheduledStopResult, error) + openProcesses func(string, string) ([]nativeLegacyProcess, error) + terminate func(nativeLegacyProcess) error + closeHandle func(windows.Handle) +} + +func stopNativeLegacyStartupWith( + ctx context.Context, + state *nativeLegacyState, + logger *slog.Logger, + operations nativeLegacyStopOperations, +) error { + if state.scheduledAction != nil { + if state.scheduledXML == nil { + return errors.New("cannot stop RunVIIPER without snapshotted task XML") + } + // Once PowerShell is launched, process termination or context cancellation + // can occur after Disable-ScheduledTask but before JSON reaches Go. Mark the + // registration as potentially changed before the call so the outer + // transaction always runs the controlled task rollback probe on failure. + state.scheduledDisabled = true + state.scheduledStopped = state.scheduledActive + result, err := operations.stopScheduled(ctx, *state.scheduledXML, state.scheduledActive) + if err != nil { + return err + } + if strings.TrimSpace(result.currentXML) == "" { + return errors.New("RunVIIPER stop returned no current task XML") + } + currentXML := result.currentXML + state.scheduledCurrentXML = ¤tXML + state.scheduledDisabled = result.disabled + state.scheduledStopped = state.scheduledActive && result.stopped + } + seen := make(map[string]bool) + for index := range state.commands { + key := strings.ToLower(filepath.Clean(state.commands[index].executable)) + if seen[key] { + state.commands[index].running = false + continue + } + seen[key] = true + processes, err := operations.openProcesses(state.commands[index].executable, state.userSID) + if err != nil { + return err + } + // Record the need to restart before the first termination. A later + // termination failure must not lose the fact that migration already + // changed the legacy process set. + state.commands[index].running = len(processes) != 0 + for processIndex, process := range processes { + if err := operations.terminate(process); err != nil { + for closeIndex := processIndex; closeIndex < len(processes); closeIndex++ { + operations.closeHandle(processes[closeIndex].handle) + } + return err + } + operations.closeHandle(process.handle) + logger.Info("terminated legacy VIIPER process", "pid", process.pid) + } + } + return nil +} + +func stopNativeScheduledTask( + ctx context.Context, + expectedXML string, + expectedActive bool, +) (nativeScheduledStopResult, error) { + if strings.TrimSpace(expectedXML) == "" { + return nativeScheduledStopResult{}, errors.New("cannot compare-and-stop RunVIIPER without snapshotted task XML") + } + snapshotCheck := `if($active){throw 'RunVIIPER started after migration snapshot'}` + if expectedActive { + snapshotCheck = `if(-not $active){throw 'RunVIIPER stopped after migration snapshot'}` + } + script := `$ErrorActionPreference='Stop';` + + `[Console]::OutputEncoding=[Text.UTF8Encoding]::new();` + + `$b=[Console]::In.ReadToEnd();$x=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b));` + + `$m=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -ceq '\' -and $_.TaskName -ieq 'RunVIIPER'});` + + `if($m.Count -ne 1){throw 'expected exactly one root RunVIIPER task'};$t=$m[0];` + + `$c=Export-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop;` + + `if($c -cne $x){throw 'RunVIIPER changed during migration'};` + + `$s=[string]$t.State;$active=($s -eq 'Running' -or $s -eq 'Queued');` + snapshotCheck + `;` + + `$stopped=$false;` + + `if([bool]$t.Settings.Enabled){Disable-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop|Out-Null};` + + `$t=Get-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop;$s=[string]$t.State;` + + `$nowActive=($s -eq 'Running' -or $s -eq 'Queued');if($nowActive){` + + `Stop-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop;` + + `$end=[DateTime]::UtcNow.AddSeconds(5);do{Start-Sleep -Milliseconds 50;` + + `$t=Get-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop;$s=[string]$t.State;` + + `if($s -ne 'Running' -and $s -ne 'Queued'){$stopped=$true;break}}while([DateTime]::UtcNow -lt $end);` + + `if(-not $stopped){throw 'RunVIIPER did not stop'}};` + + `$current=Export-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop;` + + `[pscustomobject]@{Stopped=$nowActive;Disabled=$true;CurrentXML=$current}|ConvertTo-Json -Compress` + powershell, err := trustedSystemExecutable("WindowsPowerShell", "v1.0", "powershell.exe") + if err != nil { + return nativeScheduledStopResult{}, fmt.Errorf("resolve trusted PowerShell: %w", err) + } + command := exec.CommandContext(ctx, powershell, "-NoProfile", "-NonInteractive", "-Command", script) + command.Stdin = strings.NewReader(encodeNativeTaskXML(expectedXML)) + output, err := command.CombinedOutput() + if err != nil { + return nativeScheduledStopResult{}, fmt.Errorf("compare-disable-and-stop RunVIIPER scheduled task: %w: %s", + err, strings.TrimSpace(string(output))) + } + var result struct { + Stopped bool + Disabled bool + CurrentXML string + } + if err := json.Unmarshal(output, &result); err != nil { + return nativeScheduledStopResult{}, fmt.Errorf("decode RunVIIPER stop result: %w", err) + } + if strings.TrimSpace(result.CurrentXML) == "" { + return nativeScheduledStopResult{}, errors.New("RunVIIPER stop returned empty task XML") + } + return nativeScheduledStopResult{ + stopped: result.Stopped, disabled: result.Disabled, currentXML: result.CurrentXML, + }, nil +} + +func encodeNativeTaskXML(value string) string { + return base64.StdEncoding.EncodeToString([]byte(value)) +} + +func encodeNativeTaskRestorePayload(original, current string) string { + payload, err := json.Marshal(struct { + Original string + Current string + }{Original: original, Current: current}) + if err != nil { + panic("fixed scheduled-task restore payload could not be encoded: " + err.Error()) + } + return base64.StdEncoding.EncodeToString(payload) +} + +type nativeLegacyProcess struct { + handle windows.Handle + pid uint32 +} + +func openLegacyProcessesByExecutable(target, expectedUserSID string) ([]nativeLegacyProcess, error) { + target = filepath.Clean(target) + if _, err := validateNativeInstallingUserSID(expectedUserSID); err != nil { + return nil, err + } + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, err + } + defer windows.CloseHandle(snapshot) //nolint:errcheck + entry := windows.ProcessEntry32{Size: uint32(unsafe.Sizeof(windows.ProcessEntry32{}))} + if err := windows.Process32First(snapshot, &entry); err != nil { + if errors.Is(err, windows.ERROR_NO_MORE_FILES) { + return nil, nil + } + return nil, err + } + var result []nativeLegacyProcess + closeResult := func() { + for _, process := range result { + windows.CloseHandle(process.handle) //nolint:errcheck + } + } + for { + entryNameMatches := strings.EqualFold(windows.UTF16ToString(entry.ExeFile[:]), filepath.Base(target)) + if entryNameMatches && entry.ProcessID != uint32(os.Getpid()) { + process, openErr := windows.OpenProcess( + windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, + false, + entry.ProcessID, + ) + if openErr == nil { + keepHandle := false + buffer := make([]uint16, 32768) + size := uint32(len(buffer)) + if queryErr := windows.QueryFullProcessImageName(process, 0, &buffer[0], &size); queryErr == nil { + actual := filepath.Clean(windows.UTF16ToString(buffer[:size])) + if strings.EqualFold(actual, target) { + var processToken windows.Token + if tokenErr := windows.OpenProcessToken(process, windows.TOKEN_QUERY, &processToken); tokenErr != nil { + windows.CloseHandle(process) //nolint:errcheck + closeResult() + return nil, fmt.Errorf("query owner of possible legacy VIIPER pid %d: %w", entry.ProcessID, tokenErr) + } + owner, tokenErr := processToken.GetTokenUser() + processToken.Close() //nolint:errcheck + if tokenErr != nil { + windows.CloseHandle(process) //nolint:errcheck + closeResult() + return nil, fmt.Errorf("read owner of possible legacy VIIPER pid %d: %w", entry.ProcessID, tokenErr) + } + if strings.EqualFold(owner.User.Sid.String(), expectedUserSID) { + result = append(result, nativeLegacyProcess{handle: process, pid: entry.ProcessID}) + keepHandle = true + } + } + } else if status, _ := windows.WaitForSingleObject(process, 0); status != windows.WAIT_OBJECT_0 { + windows.CloseHandle(process) //nolint:errcheck + closeResult() + return nil, fmt.Errorf("revalidate possible legacy VIIPER pid %d: %w", entry.ProcessID, queryErr) + } + if !keepHandle { + windows.CloseHandle(process) //nolint:errcheck + } + } else if !errors.Is(openErr, windows.ERROR_INVALID_PARAMETER) { + closeResult() + return nil, fmt.Errorf("open possible legacy VIIPER pid %d: %w", entry.ProcessID, openErr) + } + } + if err := windows.Process32Next(snapshot, &entry); err != nil { + if errors.Is(err, windows.ERROR_NO_MORE_FILES) { + break + } + closeResult() + return nil, err + } + } + return result, nil +} + +func terminateVerifiedLegacyProcess(process nativeLegacyProcess) error { + status, err := windows.WaitForSingleObject(process.handle, 0) + if err != nil { + return fmt.Errorf("query legacy VIIPER pid %d: %w", process.pid, err) + } + if status == windows.WAIT_OBJECT_0 { + return nil + } + if err := windows.TerminateProcess(process.handle, 1); err != nil { + if status, _ := windows.WaitForSingleObject(process.handle, 0); status == windows.WAIT_OBJECT_0 { + return nil + } + return fmt.Errorf("terminate legacy VIIPER pid %d: %w", process.pid, err) + } + status, err = windows.WaitForSingleObject(process.handle, 5_000) + if err != nil { + return fmt.Errorf("wait for legacy VIIPER pid %d: %w", process.pid, err) + } + if status != windows.WAIT_OBJECT_0 { + return fmt.Errorf("legacy VIIPER pid %d did not terminate within 5 seconds", process.pid) + } + return nil +} + +func removeNativeLegacyRegistrations(ctx context.Context, state nativeLegacyState) error { + currentRun, runFound, err := currentNativeRunRegistration(state) + if err != nil { + return err + } + if err := validateNativeRunRegistrationSnapshot(state.runValue, currentRun, runFound); err != nil { + return err + } + if state.runValue != nil { + if state.runKey == 0 { + return errors.New("cannot remove VIIPER Run registration without its retained key") + } + if err := state.runKey.DeleteValue(runValueKey); err != nil { + return fmt.Errorf("remove VIIPER Run registration: %w", err) + } + if _, found, err := currentNativeRunRegistration(state); err != nil { + return fmt.Errorf("verify VIIPER Run registration removal: %w", err) + } else if found { + return errors.New("VIIPER Run registration still exists after removal") + } + } + currentAction, currentXML, _, _, taskFound, err := currentScheduledTaskCommand(ctx) + if err != nil { + return restoreNativeLegacyRegistrationsAfterRemoval(ctx, state, err, false) + } + if err := validateNativeScheduledTaskSnapshot(state, currentAction, currentXML, taskFound); err != nil { + return restoreNativeLegacyRegistrationsAfterRemoval(ctx, state, err, false) + } + if state.scheduledAction != nil { + // Keep the exact registered task disabled instead of unregistering it. + // Exported task XML omits its registered ACL and cannot recreate a + // Password-logon credential, so delete/re-register cannot be an exact + // transaction. A disabled task has no native-mode ownership and remains + // losslessly reversible on rollback. + if !state.scheduledDisabled { + return restoreNativeLegacyRegistrationsAfterRemoval( + ctx, + state, + errors.New("RunVIIPER was not disabled before native ownership commit"), + false, + ) + } + } + return nil +} + +func validateNativeScheduledTaskSnapshot( + state nativeLegacyState, + currentAction nativeLegacyCommand, + currentXML string, + found bool, +) error { + if state.scheduledAction == nil { + if found { + return errors.New("RunVIIPER scheduled task appeared during native service migration") + } + return nil + } + if state.scheduledXML == nil || state.scheduledCurrentXML == nil { + return errors.New("RunVIIPER task XML state is incomplete") + } + if !found { + return errors.New("RunVIIPER scheduled task disappeared during native service migration") + } + if !nativeLegacyCommandsEqual(currentAction, *state.scheduledAction) || + currentXML != *state.scheduledCurrentXML { + return errors.New("RunVIIPER scheduled task changed during native service migration") + } + return nil +} + +func restoreNativeLegacyRegistrationsAfterRemoval( + ctx context.Context, + state nativeLegacyState, + cause error, + restoreScheduledTask bool, +) error { + var restoreErrors []error + if state.runValue != nil { + if state.runKey == 0 || !state.runKeyExisted { + restoreErrors = append(restoreErrors, + errors.New("restore VIIPER Run registration: retained Run key is unavailable")) + } else { + current, found, restoreErr := currentNativeRunRegistration(state) + switch { + case restoreErr != nil: + case found && nativeRunRegistrationsEqual(current, *state.runValue): + // Another recovery path already restored the exact data and type. + case found: + restoreErr = errors.New("refusing to overwrite a concurrently changed VIIPER Run registration") + default: + restoreErr = setNativeRunRegistration(state.runKey, *state.runValue) + } + if restoreErr != nil { + restoreErrors = append(restoreErrors, fmt.Errorf("restore VIIPER Run registration: %w", restoreErr)) + } + } + } + if restoreScheduledTask && state.scheduledXML != nil { + currentXML := *state.scheduledXML + if state.scheduledCurrentXML != nil { + currentXML = *state.scheduledCurrentXML + } + if err := restoreNativeScheduledTask(ctx, *state.scheduledXML, currentXML); err != nil { + restoreErrors = append(restoreErrors, err) + } + } + return errors.Join(cause, errors.Join(restoreErrors...)) +} + +func restoreNativeScheduledTask(ctx context.Context, taskXML, expectedCurrentXML string) error { + if strings.TrimSpace(taskXML) == "" { + return errors.New("cannot restore RunVIIPER from empty task XML") + } + if strings.TrimSpace(expectedCurrentXML) == "" { + return errors.New("cannot restore RunVIIPER without its expected current task XML") + } + _, currentXML, _, _, found, err := currentScheduledTaskCommand(ctx) + if err != nil { + return fmt.Errorf("query RunVIIPER before rollback: %w", err) + } + if !found { + return errors.New("RunVIIPER disappeared during rollback") + } + if currentXML == taskXML { + return nil + } + if expectedCurrentXML != taskXML && currentXML != expectedCurrentXML { + return errors.New("RunVIIPER changed outside the installer disable transition") + } + if err := validateNativeTaskDisabledOnly(taskXML, currentXML); err != nil { + return fmt.Errorf("refuse to enable unvalidated RunVIIPER task: %w", err) + } + const script = `$ErrorActionPreference='Stop';` + + `$b=[Console]::In.ReadToEnd();$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b))|ConvertFrom-Json;` + + `$x=[string]$p.Original;$expected=[string]$p.Current;` + + `if([string]::IsNullOrWhiteSpace($x) -or [string]::IsNullOrWhiteSpace($expected)){throw 'empty scheduled-task XML'};` + + `$m=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -ceq '\' -and $_.TaskName -ieq 'RunVIIPER'});` + + `if($m.Count -gt 1){throw 'multiple root RunVIIPER tasks found'};$t=$null;if($m.Count -eq 1){$t=$m[0]};` + + `if($null -eq $t){throw 'RunVIIPER disappeared during rollback'};` + + `$c=Export-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop;` + + `if($c -cne $expected){throw 'RunVIIPER changed after structural rollback validation'};` + + `Enable-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop|Out-Null;` + + `$verify=Export-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop;` + + `if($verify -cne $x){Disable-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop|Out-Null;throw 'RunVIIPER did not verify after rollback'}` + powershell, err := trustedSystemExecutable("WindowsPowerShell", "v1.0", "powershell.exe") + if err != nil { + return fmt.Errorf("resolve trusted PowerShell: %w", err) + } + command := exec.CommandContext(ctx, powershell, "-NoProfile", "-NonInteractive", "-Command", script) + command.Stdin = strings.NewReader(encodeNativeTaskRestorePayload(taskXML, currentXML)) + if output, err := command.CombinedOutput(); err != nil { + return fmt.Errorf("restore RunVIIPER scheduled task: %w: %s", err, strings.TrimSpace(string(output))) + } + return nil +} + +func validateNativeTaskDisabledOnly(original, current string) error { + originalCanonical, originalEnabled, originalHasEnabled, err := canonicalNativeTaskXML(original) + if err != nil { + return fmt.Errorf("parse original task XML: %w", err) + } + currentCanonical, currentEnabled, currentHasEnabled, err := canonicalNativeTaskXML(current) + if err != nil { + return fmt.Errorf("parse current task XML: %w", err) + } + if !originalHasEnabled || !currentHasEnabled || !originalEnabled || currentEnabled { + return errors.New("task does not represent an enabled-to-disabled transition") + } + if originalCanonical != currentCanonical { + return errors.New("task XML differs outside Settings/Enabled") + } + return nil +} + +func canonicalNativeTaskXML(value string) (string, bool, bool, error) { + decoder := xml.NewDecoder(strings.NewReader(value)) + decoder.Strict = true + var canonical strings.Builder + var stack []xml.Name + enabled := false + hasEnabled := false + for { + token, err := decoder.Token() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", false, false, err + } + switch typed := token.(type) { + case xml.StartElement: + stack = append(stack, typed.Name) + attributes := make([]string, 0, len(typed.Attr)) + for _, attribute := range typed.Attr { + attributes = append(attributes, + attribute.Name.Space+"\x00"+attribute.Name.Local+"="+strconv.Quote(attribute.Value)) + } + sort.Strings(attributes) + canonical.WriteString("S") + canonical.WriteString(typed.Name.Space) + canonical.WriteByte(0) + canonical.WriteString(typed.Name.Local) + canonical.WriteByte('[') + canonical.WriteString(strings.Join(attributes, ",")) + canonical.WriteByte(']') + case xml.EndElement: + canonical.WriteString("E") + canonical.WriteString(typed.Name.Space) + canonical.WriteByte(0) + canonical.WriteString(typed.Name.Local) + if len(stack) == 0 || stack[len(stack)-1] != typed.Name { + return "", false, false, errors.New("task XML element stack is inconsistent") + } + stack = stack[:len(stack)-1] + case xml.CharData: + text := string(typed) + if nativeTaskEnabledElement(stack) { + value := strings.TrimSpace(text) + if value == "" { + continue + } + parsed, err := strconv.ParseBool(value) + if err != nil || hasEnabled { + return "", false, false, errors.New("task XML has an invalid Settings/Enabled value") + } + enabled, hasEnabled = parsed, true + canonical.WriteString("T") + } else if strings.TrimSpace(text) != "" { + canonical.WriteString("T") + canonical.WriteString(strconv.Quote(text)) + } + case xml.Comment: + canonical.WriteString("C") + canonical.WriteString(strconv.Quote(string(typed))) + case xml.ProcInst: + canonical.WriteString("P") + canonical.WriteString(typed.Target) + canonical.WriteString(strconv.Quote(string(typed.Inst))) + case xml.Directive: + canonical.WriteString("D") + canonical.WriteString(strconv.Quote(string(typed))) + } + } + if len(stack) != 0 { + return "", false, false, errors.New("task XML ended with open elements") + } + return canonical.String(), enabled, hasEnabled, nil +} + +func nativeTaskEnabledElement(stack []xml.Name) bool { + return len(stack) >= 3 && stack[len(stack)-1].Local == "Enabled" && + stack[len(stack)-2].Local == "Settings" && stack[0].Local == "Task" +} + +func nativeLegacyCommandsEqual(first, second nativeLegacyCommand) bool { + if !strings.EqualFold(filepath.Clean(first.executable), filepath.Clean(second.executable)) || + !strings.EqualFold(filepath.Clean(first.workingDirectory), filepath.Clean(second.workingDirectory)) || + len(first.arguments) != len(second.arguments) { + return false + } + for index := range first.arguments { + if first.arguments[index] != second.arguments[index] { + return false + } + } + return true +} + +func restartNativeLegacyStartup(ctx context.Context, state nativeLegacyState) error { + if err := ctx.Err(); err != nil { + return err + } + if state.scheduledStopped { + if state.scheduledXML == nil { + return errors.New("cannot restart RunVIIPER without snapshotted task XML") + } + if state.verifyTaskAction == nil { + return errors.New("cannot restart RunVIIPER without its retained action identity") + } + if err := state.verifyTaskAction(); err != nil { + return fmt.Errorf("revalidate RunVIIPER action before rollback restart: %w", err) + } + if err := startNativeScheduledTask(ctx, *state.scheduledXML); err != nil { + return err + } + } + started := make(map[string]bool) + for _, command := range state.commands { + key := strings.ToLower(filepath.Clean(command.executable)) + if !command.running || started[key] { + continue + } + started[key] = true + var err error + switch command.source { + case legacyCommandRun: + current, found, queryErr := currentNativeRunRegistration(state) + if queryErr != nil { + err = fmt.Errorf("verify VIIPER Run registration before restart: %w", queryErr) + } else if state.runValue == nil || !found || !nativeRunRegistrationsEqual(current, *state.runValue) { + err = errors.New("refusing to restart HKCU VIIPER because its registration changed during migration") + } else { + err = startNativeLegacyCommandAsShellUser(command, state.userSID) + } + default: + err = errors.New("legacy VIIPER command has no trusted startup source") + } + if err != nil { + return err + } + } + return nil +} + +func startNativeScheduledTask(ctx context.Context, expectedXML string) error { + if strings.TrimSpace(expectedXML) == "" { + return errors.New("cannot restart RunVIIPER without snapshotted task XML") + } + const script = `$ErrorActionPreference='Stop';` + + `$b=[Console]::In.ReadToEnd();$x=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b));` + + `$m=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -ceq '\' -and $_.TaskName -ieq 'RunVIIPER'});` + + `if($m.Count -ne 1){throw 'expected exactly one root RunVIIPER task'};$t=$m[0];` + + `$c=Export-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop;` + + `if($c -cne $x){throw 'RunVIIPER changed before restart'};` + + `$s=[string]$t.State;if($s -eq 'Running' -or $s -eq 'Queued'){exit 0};` + + `Start-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop` + powershell, err := trustedSystemExecutable("WindowsPowerShell", "v1.0", "powershell.exe") + if err != nil { + return fmt.Errorf("resolve trusted PowerShell: %w", err) + } + command := exec.CommandContext(ctx, powershell, "-NoProfile", "-NonInteractive", "-Command", script) + command.Stdin = strings.NewReader(encodeNativeTaskXML(expectedXML)) + if output, err := command.CombinedOutput(); err != nil { + return fmt.Errorf("restart RunVIIPER scheduled task: %w: %s", err, strings.TrimSpace(string(output))) + } + return nil +} + +func startNativeLegacyCommandAsShellUser(command nativeLegacyCommand, expectedUserSID string) error { + shellToken, err := nativeInteractiveUserToken( + expectedUserSID, + windows.TOKEN_QUERY|windows.TOKEN_DUPLICATE|windows.TOKEN_ASSIGN_PRIMARY| + windows.TOKEN_ADJUST_DEFAULT|windows.TOKEN_ADJUST_SESSIONID, + ) + if err != nil { + return fmt.Errorf("open target interactive user token: %w", err) + } + defer shellToken.Close() //nolint:errcheck + + commandLine, err := windowsCommandLine(command.executable, command.arguments...) + if err != nil { + return err + } + application, err := windows.UTF16PtrFromString(command.executable) + if err != nil { + return err + } + mutableCommandLine, err := windows.UTF16FromString(commandLine) + if err != nil { + return err + } + var currentDirectory *uint16 + if command.workingDirectory != "" { + currentDirectory, err = windows.UTF16PtrFromString(command.workingDirectory) + if err != nil { + return err + } + } + desktop, err := windows.UTF16PtrFromString(`winsta0\default`) + if err != nil { + return err + } + var environment *uint16 + if err := windows.CreateEnvironmentBlock(&environment, shellToken, false); err != nil { + return fmt.Errorf("create interactive user environment: %w", err) + } + defer windows.DestroyEnvironmentBlock(environment) //nolint:errcheck + startup := windows.StartupInfo{Cb: uint32(unsafe.Sizeof(windows.StartupInfo{})), Desktop: desktop} + process := windows.ProcessInformation{} + if err := windows.CreateProcessAsUser( + shellToken, + application, + &mutableCommandLine[0], + nil, + nil, + false, + windows.CREATE_UNICODE_ENVIRONMENT, + environment, + currentDirectory, + &startup, + &process, + ); err != nil { + return fmt.Errorf("restart HKCU VIIPER under the interactive shell token: %w", err) + } + windows.CloseHandle(process.Thread) //nolint:errcheck + windows.CloseHandle(process.Process) //nolint:errcheck + return nil +} + +func hasRunningLegacyCommand(state nativeLegacyState) bool { + if state.scheduledStopped { + return true + } + for _, command := range state.commands { + if command.running { + return true + } + } + return false +} + +func isLocalSystemServiceAccount(account string) bool { + account = strings.TrimSpace(account) + return account == "" || strings.EqualFold(account, "LocalSystem") || + strings.EqualFold(account, `.\LocalSystem`) || + strings.EqualFold(account, `NT AUTHORITY\SYSTEM`) +} + +func isEquivalentServiceAccount(first, second string) bool { + if isLocalSystemServiceAccount(first) && isLocalSystemServiceAccount(second) { + return true + } + return strings.EqualFold(strings.TrimSpace(first), strings.TrimSpace(second)) +} diff --git a/internal/cmd/native_service_install_windows_test.go b/internal/cmd/native_service_install_windows_test.go new file mode 100644 index 00000000..228fdbe1 --- /dev/null +++ b/internal/cmd/native_service_install_windows_test.go @@ -0,0 +1,1769 @@ +//go:build windows + +package cmd + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "log/slog" + "os" + "path/filepath" + "reflect" + "runtime" + "slices" + "strings" + "testing" + "time" + + "github.com/Alia5/VIIPER/internal/transport/udecx" + "github.com/Alia5/VIIPER/viipertypes" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +func TestNativeCredentialStagingIsProtectedAtCreation(t *testing.T) { + requireNativeMutexAdministrator(t) + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + t.Fatalf("query test user: %v", err) + } + userSID, err := validateNativeInstallingUserSID(user.User.Sid.String()) + if err != nil { + t.Fatalf("validate test user: %v", err) + } + directory := t.TempDir() + path := filepath.Join(directory, "credential.key") + contents := []byte("native-credential-contract") + if err := writeNativeCredentialAtomically(path, contents, userSID); err != nil { + t.Fatalf("write protected credential: %v", err) + } + actual, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read protected credential: %v", err) + } + if !slices.Equal(actual, contents) { + t.Fatalf("credential contents = %q, want %q", actual, contents) + } + handle, err := openNativePathWithoutReparse( + path, windows.GENERIC_READ|windows.READ_CONTROL, false, + ) + if err != nil { + t.Fatalf("open protected credential: %v", err) + } + defer windows.CloseHandle(handle) //nolint:errcheck + if err := requireSingleNativeFileLink(handle); err != nil { + t.Fatalf("credential link identity: %v", err) + } + if err := validateNativeSecurityDescriptor( + handle, nativeCredentialFileSDDL(userSID), + ); err != nil { + t.Fatalf("credential security: %v", err) + } + leftovers, err := filepath.Glob(filepath.Join(directory, ".viiper-key-*.tmp")) + if err != nil { + t.Fatal(err) + } + if len(leftovers) != 0 { + t.Fatalf("credential staging residue: %v", leftovers) + } +} + +func TestNativeBrokerServiceConfigurationIsExplicitAndEscaped(t *testing.T) { + executable := `C:\Program Files\VIIPER\viiper.exe` + credential := `C:\ProgramData\VIIPER\viiper key.txt` + config, arguments, err := nativeBrokerServiceConfiguration(executable, credential) + if err != nil { + t.Fatal(err) + } + wantArguments := []string{ + "service", "--transport", "native-ude", "--key-file", credential, + "--log.file", filepath.Join(filepath.Dir(credential), nativeBrokerLogName), + } + if !reflect.DeepEqual(arguments, wantArguments) { + t.Fatalf("arguments=%q want=%q", arguments, wantArguments) + } + if config.StartType != mgr.StartAutomatic || config.ServiceType != windows.SERVICE_WIN32_OWN_PROCESS { + t.Fatalf("service config is not an automatic own-process service: %+v", config) + } + if config.ServiceStartName != nativeServiceAccount || config.DelayedAutoStart { + t.Fatalf("service account/start mode=%q/%v", config.ServiceStartName, config.DelayedAutoStart) + } + decomposed, err := windows.DecomposeCommandLine(config.BinaryPathName) + if err != nil { + t.Fatal(err) + } + if want := append([]string{executable}, wantArguments...); !reflect.DeepEqual(decomposed, want) { + t.Fatalf("binary command=%q want=%q", decomposed, want) + } +} + +func TestNativeServiceConfigVerificationDoesNotFoldArgumentCase(t *testing.T) { + first := mgr.Config{BinaryPathName: `"C:\Program Files\VIIPER\viiper.exe" service --key-file C:\key`} + second := first + second.BinaryPathName = `"C:\Program Files\VIIPER\viiper.exe" service --KEY-FILE C:\key` + if nativeServiceConfigsEqual(first, second) { + t.Fatal("case-only service switch mismatch verified as equal") + } + second = first + second.BinaryPathName += " " + if nativeServiceConfigsEqual(first, second) { + t.Fatal("trailing service-command whitespace mismatch verified as equal") + } +} + +func TestNativeServiceDependenciesBlockCanClearAndRoundTrip(t *testing.T) { + empty, err := nativeServiceDependenciesBlock(nil) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(empty, []uint16{0, 0}) { + t.Fatalf("empty dependency block=%v", empty) + } + block, err := nativeServiceDependenciesBlock([]string{"Tcpip", "+NetworkProvider"}) + if err != nil { + t.Fatal(err) + } + want := append([]uint16{}, windows.StringToUTF16("Tcpip")...) + want = append(want, windows.StringToUTF16("+NetworkProvider")...) + want = append(want, 0) + if !reflect.DeepEqual(block, want) { + t.Fatalf("dependency block=%v want=%v", block, want) + } + if _, err := nativeServiceDependenciesBlock([]string{""}); err == nil { + t.Fatal("accepted empty dependency name") + } +} + +func TestNativeServiceExecutablePathRejectsPortableAndNonDedicatedLocations(t *testing.T) { + programFiles := `C:\Program Files` + for _, executable := range []string{ + `C:\Users\user\Downloads\viiper.exe`, + `C:\Program Files\viiper.exe`, + `C:\Program Files\DS4Windows\viiper.exe`, + `C:\Program Files\Other\VIIPER\viiper.exe`, + `C:\Program Files\DS4Windows\VIIPER\renamed.exe`, + } { + if _, err := nativeServiceExecutableParent(programFiles, executable); err == nil { + t.Fatalf("accepted unsafe LocalSystem service executable %q", executable) + } + } + parent, err := nativeServiceExecutableParent( + programFiles, + `C:\Program Files\DS4Windows\VIIPER\viiper.exe`, + ) + if err != nil || parent != `C:\Program Files\DS4Windows\VIIPER` { + t.Fatalf("parent=%q error=%v", parent, err) + } + if parent, err := nativeServiceExecutableParent( + programFiles, `C:\Program Files\VIIPER\viiper.exe`, + ); err != nil || parent != `C:\Program Files\VIIPER` { + t.Fatalf("direct parent=%q error=%v", parent, err) + } +} + +func TestRotatedNativeServiceKeyNeverReusesPreseededCredential(t *testing.T) { + generated := []string{"", "attacker-known", "fresh-random"} + index := 0 + key, err := rotatedNativeServiceKey([]byte(" attacker-known\r\n"), func() (string, error) { + value := generated[index] + index++ + return value, nil + }) + if err != nil { + t.Fatal(err) + } + if key != "fresh-random" || index != 3 { + t.Fatalf("rotated key=%q attempts=%d", key, index) + } +} + +func TestNativeFileLinkCountRejectsHardLinks(t *testing.T) { + if err := validateNativeFileLinkCount(1); err != nil { + t.Fatal(err) + } + for _, count := range []uint32{0, 2, 17} { + if err := validateNativeFileLinkCount(count); err == nil { + t.Fatalf("accepted unsafe file link count %d", count) + } + } +} + +func TestNativeTaskXMLUsesExplicitUTF8Base64Transport(t *testing.T) { + xml := `Zoë 日本語 🎮` + decoded, err := base64.StdEncoding.DecodeString(encodeNativeTaskXML(xml)) + if err != nil { + t.Fatal(err) + } + if string(decoded) != xml { + t.Fatalf("decoded XML=%q want=%q", decoded, xml) + } +} + +func TestNativeTaskRestorePayloadPreservesOriginalAndDisabledXML(t *testing.T) { + original := `Zoë 日本語true` + current := `Zoë 日本語false` + decoded, err := base64.StdEncoding.DecodeString(encodeNativeTaskRestorePayload(original, current)) + if err != nil { + t.Fatal(err) + } + var payload struct{ Original, Current string } + if err := json.Unmarshal(decoded, &payload); err != nil { + t.Fatal(err) + } + if payload.Original != original || payload.Current != current { + t.Fatalf("restore payload=%+v", payload) + } +} + +func TestValidateNativeTaskDisabledOnlyRequiresExactStructuralTransition(t *testing.T) { + const original = `VIIPERtrueS-1-5-21-1trueC:\VIIPER\viiper.exe` + disabled := strings.Replace(original, `true`, `false`, 1) + if err := validateNativeTaskDisabledOnly(original, disabled); err != nil { + t.Fatalf("exact enabled-to-disabled transition rejected: %v", err) + } + mutations := map[string]string{ + "action": strings.Replace(disabled, `C:\VIIPER\viiper.exe`, `C:\Evil\viiper.exe`, 1), + "principal": strings.Replace(disabled, `S-1-5-21-1`, `S-1-5-18`, 1), + "trigger": strings.Replace(disabled, ``, ``, 1), + "namespace": strings.Replace(disabled, `urn:task`, `urn:other`, 1), + "missing": strings.Replace(original, `true`, ``, 1), + "duplicate": strings.Replace(disabled, `false`, `falsefalse`, 1), + "invalid": strings.Replace(disabled, `false`, `maybe`, 1), + "malformed": strings.TrimSuffix(disabled, ``), + "unchanged": original, + } + for name, current := range mutations { + t.Run(name, func(t *testing.T) { + if err := validateNativeTaskDisabledOnly(original, current); err == nil { + t.Fatal("accepted non-exact task transition") + } + }) + } +} + +func TestNativeLegacyRegistrationSnapshotRejectsAbsentOwnersAndTypeChanges(t *testing.T) { + snapshot := nativeRunRegistration{value: `"C:\VIIPER\viiper.exe"`, valueType: registry.EXPAND_SZ} + if err := validateNativeRunRegistrationSnapshot(nil, nativeRunRegistration{}, false); err != nil { + t.Fatal(err) + } + if err := validateNativeRunRegistrationSnapshot(nil, snapshot, true); err == nil { + t.Fatal("accepted Run registration that appeared after an absent snapshot") + } + changedType := snapshot + changedType.valueType = registry.SZ + if err := validateNativeRunRegistrationSnapshot(&snapshot, changedType, true); err == nil { + t.Fatal("accepted Run registration type change with identical data") + } + if err := validateNativeRunRegistrationSnapshot(&snapshot, nativeRunRegistration{}, false); err == nil { + t.Fatal("accepted disappeared Run registration") + } + if err := validateNativeScheduledTaskSnapshot(nativeLegacyState{}, nativeLegacyCommand{}, "", true); err == nil { + t.Fatal("accepted RunVIIPER task that appeared after an absent snapshot") + } + if _, _, err := currentNativeRunRegistration(nativeLegacyState{}); err == nil || + !strings.Contains(err.Error(), "hive is not retained") { + t.Fatalf("unretained user hive did not fail closed: %v", err) + } +} + +func TestNativeScheduledTaskIdentityAndStateAreFailClosed(t *testing.T) { + for _, name := range []string{"RunVIIPER", "runviiper", "RUNVIIPER"} { + if !nativeScheduledTaskNameMatches(name) { + t.Fatalf("case-equivalent Task Scheduler name %q was missed", name) + } + } + if nativeScheduledTaskNameMatches("RunVIIPER-Evil") { + t.Fatal("accepted a different Task Scheduler name") + } + if err := validateNativeScheduledTaskState(true, false); err == nil { + t.Fatal("accepted active-but-disabled task snapshot") + } + for _, state := range [][2]bool{{false, false}, {false, true}, {true, true}} { + if err := validateNativeScheduledTaskState(state[0], state[1]); err != nil { + t.Fatalf("active=%v enabled=%v error=%v", state[0], state[1], err) + } + } +} + +func TestNativeServiceExecutableCommandMustRemainRepresentable(t *testing.T) { + got, err := nativeServiceExecutableFromCommandLine(`"C:\Program Files\VIIPER\viiper.exe" service --key-file C:\key`) + if err != nil || got != `C:\Program Files\VIIPER\viiper.exe` { + t.Fatalf("executable=%q error=%v", got, err) + } + for _, commandLine := range []string{"", "viiper.exe service", "\x00"} { + if _, err := nativeServiceExecutableFromCommandLine(commandLine); err == nil { + t.Fatalf("accepted unsafe service command line %q", commandLine) + } + } +} + +func TestInstallingUserSelectionPrefersInteractiveOriginAndFailsClosedForSystem(t *testing.T) { + selected, err := selectNativeInstallingUserSID( + "S-1-5-21-1-2-3-500", false, + "S-1-5-21-1-2-3-1001", nil, + ) + if err != nil || selected != "S-1-5-21-1-2-3-1001" { + t.Fatalf("over-the-shoulder selection=%q error=%v", selected, err) + } + if _, err := selectNativeInstallingUserSID( + "", true, "", errors.New("no active console"), + ); err == nil || !strings.Contains(err.Error(), "--target-user-sid") { + t.Fatalf("LocalSystem without origin did not fail closed: %v", err) + } +} + +func TestNativeInstallRejectsUntrustedExecutableBeforeAnyMutation(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + dependencies.lockExecutable = func(string) (func(), error) { + return nil, errors.New("user-writable path") + } + credentialProvisioned := false + dependencies.provisionCredential = func() (nativeCredential, error) { + credentialProvisioned = true + return nativeCredential{}, nil + } + err := installNativeBrokerTransaction( + context.Background(), testLogger(), `C:\Users\user\viiper.exe`, dependencies, + ) + if err == nil || !strings.Contains(err.Error(), "user-writable path") { + t.Fatalf("error=%v", err) + } + if credentialProvisioned || len(events) != 0 { + t.Fatalf("unsafe executable mutated state: credential=%v events=%v", credentialProvisioned, events) + } +} + +func TestNativeInstallLocksPriorServiceExecutableBeforeMutation(t *testing.T) { + events := []string{} + service := &fakeNativeService{ + config: mgr.Config{ + ServiceStartName: nativeServiceAccount, + BinaryPathName: `"C:\Untrusted\viiper.exe" service`, + }, + status: svc.Status{State: svc.Stopped}, events: &events, + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + var currentValidatedPaths []string + dependencies.lockExecutable = func(path string) (func(), error) { + currentValidatedPaths = append(currentValidatedPaths, path) + return func() {}, nil + } + var validatedPaths []string + dependencies.lockPriorExecutable = func(path string) (func(), error) { + validatedPaths = append(validatedPaths, path) + if strings.EqualFold(path, `C:\Untrusted\viiper.exe`) { + return nil, errors.New("prior service path is not protected") + } + return func() {}, nil + } + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil || !strings.Contains(err.Error(), "prior service path is not protected") { + t.Fatalf("error=%v", err) + } + if !reflect.DeepEqual(events, []string{"service-open"}) { + t.Fatalf("untrusted prior service path mutated transaction state: %v", events) + } + if !reflect.DeepEqual(currentValidatedPaths, []string{`C:\Program Files\VIIPER\viiper.exe`}) || + !reflect.DeepEqual(validatedPaths, []string{`C:\Untrusted\viiper.exe`}) { + t.Fatalf("prior proof did not remain isolated: current=%v prior=%v", currentValidatedPaths, validatedPaths) + } +} + +func TestNativeServiceExecutableTrustPathIsReadOnlyByConstruction(t *testing.T) { + _, testFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve test source path") + } + sourcePath := filepath.Join(filepath.Dir(testFile), "native_service_install_windows.go") + source, err := os.ReadFile(sourcePath) + if err != nil { + t.Fatal(err) + } + text := string(source) + start := strings.Index(text, "func lockNativeServiceExecutableReadOnly(") + end := strings.Index(text, "func nativeServiceExecutableParent(") + if start < 0 || end <= start { + t.Fatal("cannot isolate native executable trust implementation") + } + span := text[start:end] + for _, forbidden := range []string{"applyNativeACLToHandle", "SetSecurityInfo", "WRITE_DAC", "WRITE_OWNER"} { + if strings.Contains(span, forbidden) { + t.Fatalf("read-only executable trust path contains mutating primitive %q", forbidden) + } + } +} + +func TestNativeInstallRejectsWeakPriorServiceSecurityBeforeMutation(t *testing.T) { + events := []string{} + const priorSecurity = "O:BAD:(A;;GA;;;SY)(A;;GA;;;BA)(A;;RPWP;;;BU)" + service := &fakeNativeService{ + config: mgr.Config{ + ServiceType: windows.SERVICE_WIN32_OWN_PROCESS, StartType: mgr.StartAutomatic, + ServiceStartName: nativeServiceAccount, + BinaryPathName: `"C:\Program Files\VIIPER\viiper.exe" service`, + }, + securityDescriptor: priorSecurity, + status: svc.Status{State: svc.Running}, + events: &events, + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + var evidence nativeBrokerInstallEvidence + err := installNativeBrokerTransactionWithEvidence(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies, &evidence) + if err == nil || !strings.Contains(err.Error(), "untrusted service security descriptor") { + t.Fatalf("error=%v", err) + } + if service.securityDescriptor != priorSecurity { + t.Fatalf("weak prior service DACL was mutated: %q", service.securityDescriptor) + } + if service.status.State != svc.Running { + t.Fatalf("weak prior service was stopped during rejected snapshot: %+v", service.status) + } + if !reflect.DeepEqual(events, []string{"service-open"}) { + t.Fatalf("weak prior service caused mutation before rejection: %v", events) + } + if evidence.mutationStarted || evidence.rollbackSucceeded { + t.Fatalf("preflight rejection reported mutation evidence: %+v", evidence) + } +} + +func TestNativeTransactionContextBoundsLegacyProviderCalls(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + dependencies.snapshotLegacy = func(ctx context.Context) (nativeLegacyState, error) { + <-ctx.Done() + return nativeLegacyState{}, ctx.Err() + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + started := time.Now() + err := installNativeBrokerTransaction(ctx, testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("error=%v", err) + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("canceled legacy provider exceeded bound: %s", elapsed) + } +} + +func TestNativeInstallKeepsLegacyRegistrationUntilAuthenticatedReady(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + legacy := nativeLegacyState{ + runValue: nativeRunRegistrationPointer(`"C:\Legacy\viiper.exe" server --transport usbip`, registry.SZ), + commands: []nativeLegacyCommand{{executable: `C:\Legacy\viiper.exe`}}, + } + rolledBackCredential := false + dependencies := fakeNativeInstallDependencies(manager, legacy, &events) + dependencies.rollbackCredential = func(nativeCredential) error { + rolledBackCredential = true + return nil + } + dependencies.stopLegacy = func(_ context.Context, state *nativeLegacyState, _ *slog.Logger) error { + events = append(events, "legacy-stop") + state.commands[0].running = true + return nil + } + dependencies.verifyBroker = func(_ context.Context, password string) error { + events = append(events, "verify") + if password != "credential" { + t.Fatalf("password=%q", password) + } + if manager.service == nil || manager.service.status.State != svc.Running { + t.Fatal("service was not running during authenticated verification") + } + return nil + } + dependencies.removeLegacy = func(_ context.Context, state nativeLegacyState) error { + events = append(events, "legacy-remove") + if state.runValue == nil { + t.Fatal("legacy registration was not retained through verification") + } + return nil + } + + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err != nil { + t.Fatal(err) + } + if rolledBackCredential { + t.Fatal("committed credential was rolled back") + } + if !beforeEvent(events, "verify", "legacy-remove") { + t.Fatalf("legacy registration was removed before authenticated verification: %v", events) + } + if manager.service == nil || manager.service.deleted { + t.Fatal("native service was not retained") + } + if manager.service.config.StartType != mgr.StartAutomatic { + t.Fatal("native service was not registered for automatic startup") + } + if !reflect.DeepEqual(manager.service.recoveryActions, nativeServiceRecoveryActions) || + manager.service.recoveryReset != nativeServiceRecoveryResetSecond || + !manager.service.recoverNonCrash { + t.Fatalf("bounded recovery policy not applied: %+v", manager.service) + } +} + +func TestNativeInstallHoldsExecutableLockThroughAuthenticatedReady(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + released := false + dependencies.lockExecutable = func(string) (func(), error) { + events = append(events, "executable-lock") + return func() { + released = true + events = append(events, "executable-release") + }, nil + } + dependencies.verifyBroker = func(context.Context, string) error { + if released { + t.Fatal("service executable lock was released before authenticated readiness") + } + events = append(events, "verify") + return nil + } + if err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies); err != nil { + t.Fatal(err) + } + if !released || !beforeEvent(events, "verify", "executable-release") { + t.Fatalf("executable handle lifetime was not transactional: %v", events) + } +} + +func TestNativeInstallReverifiesAfterLegacyRemovalAndRestoresOnFailure(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + legacy := nativeLegacyState{runValue: nativeRunRegistrationPointer(`"C:\Legacy\viiper.exe" server`, registry.SZ)} + dependencies := fakeNativeInstallDependencies(manager, legacy, &events) + verifications := 0 + dependencies.verifyBroker = func(context.Context, string) error { + verifications++ + events = append(events, "verify") + if verifications == 2 { + return errors.New("legacy owner raced endpoint") + } + return nil + } + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil || !strings.Contains(err.Error(), "legacy owner raced endpoint") { + t.Fatalf("error=%v", err) + } + if verifications != 2 || !beforeEvent(events, "legacy-remove", "legacy-restore") { + t.Fatalf("post-removal verification/rollback events=%v", events) + } + if manager.service == nil || !manager.service.deleted { + t.Fatalf("failed migration retained replacement service: %+v", manager.service) + } +} + +func TestNativeInstallRejectsBrokerThatStopsAfterAuthenticatedPing(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + dependencies.verifyBroker = func(context.Context, string) error { + events = append(events, "verify") + manager.service.status.State = svc.Stopped + manager.service.processID = 0 + return nil + } + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil || !strings.Contains(err.Error(), "left Running state") { + t.Fatalf("error=%v events=%v", err, events) + } + if manager.service == nil || !manager.service.deleted { + t.Fatalf("stopped impersonable broker was committed: %+v", manager.service) + } +} + +func TestNativeInstallRestoresPriorServiceAndLegacyProcessOnPingFailure(t *testing.T) { + events := []string{} + priorConfig := mgr.Config{ + ServiceType: windows.SERVICE_WIN32_OWN_PROCESS, StartType: mgr.StartManual, + ErrorControl: mgr.ErrorIgnore, BinaryPathName: `"C:\Old\viiper.exe" service`, + ServiceStartName: nativeServiceAccount, DisplayName: "Prior VIIPER", + } + priorRecovery := []mgr.RecoveryAction{{Type: mgr.ServiceRestart, Delay: time.Minute}, {Type: mgr.NoAction}} + service := &fakeNativeService{ + config: priorConfig, status: svc.Status{State: svc.Running}, + recoveryActions: priorRecovery, recoveryReset: 321, recoverNonCrash: false, + events: &events, + } + manager := newFakeNativeSCM(service, &events) + legacy := nativeLegacyState{commands: []nativeLegacyCommand{{executable: `C:\Legacy\viiper.exe`}}} + credentialRolledBack := false + legacyRestarted := false + dependencies := fakeNativeInstallDependencies(manager, legacy, &events) + dependencies.rollbackCredential = func(nativeCredential) error { + events = append(events, "credential-restore") + credentialRolledBack = true + return nil + } + dependencies.stopLegacy = func(_ context.Context, state *nativeLegacyState, _ *slog.Logger) error { + events = append(events, "legacy-stop") + state.commands[0].running = true + return nil + } + dependencies.restartLegacy = func(_ context.Context, state nativeLegacyState) error { + events = append(events, "legacy-restart") + legacyRestarted = hasRunningLegacyCommand(state) + return nil + } + dependencies.verifyBroker = func(context.Context, string) error { + events = append(events, "verify-failed") + return errors.New("wrong ABI") + } + + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil || !strings.Contains(err.Error(), "wrong ABI") { + t.Fatalf("error=%v", err) + } + if !reflect.DeepEqual(service.config, priorConfig) { + t.Fatalf("prior config was not restored: %+v", service.config) + } + if service.status.State != svc.Running { + t.Fatalf("prior running state was not restored: %v", service.status.State) + } + if !reflect.DeepEqual(service.recoveryActions, priorRecovery) || service.recoveryReset != 321 || service.recoverNonCrash { + t.Fatalf("prior recovery policy was not restored: %+v", service) + } + if !legacyRestarted || !credentialRolledBack { + t.Fatalf("rollback incomplete: legacy=%v credential=%v", legacyRestarted, credentialRolledBack) + } + if beforeEvent(events, "legacy-remove", "verify-failed") { + t.Fatalf("legacy startup changed before a failed verification: %v", events) + } + credentialIndex := slices.Index(events, "credential-restore") + priorStartIndex := lastIndex(events, "service-start") + legacyRestartIndex := slices.Index(events, "legacy-restart") + if credentialIndex < 0 || priorStartIndex <= credentialIndex || legacyRestartIndex <= priorStartIndex { + t.Fatalf("rollback did not restore credential before prior owners: %v", events) + } +} + +func TestNativeInstallDeletesNewServiceWhenMigrationFails(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + dependencies.verifyBroker = func(context.Context, string) error { return errors.New("not ready") } + + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil { + t.Fatal("expected verification failure") + } + if manager.service == nil || !manager.service.deleted { + t.Fatal("new service was not deleted during rollback") + } + if manager.service.status.State != svc.Stopped { + t.Fatalf("new service was not stopped before deletion: %v", manager.service.status.State) + } +} + +func TestNativeInstallDeletesNewServiceAfterOptionalConfigFailure(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + manager.newServiceFailUpdate = errors.New("optional service config failed") + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil || !strings.Contains(err.Error(), "optional service config failed") { + t.Fatalf("error=%v", err) + } + if manager.service == nil || !manager.service.deleted { + t.Fatalf("partially configured new service was orphaned: events=%v", events) + } +} + +func TestNativeInstallRestoresAfterPartialUpdateConfigFailure(t *testing.T) { + events := []string{} + prior := mgr.Config{ + ServiceType: windows.SERVICE_WIN32_OWN_PROCESS, StartType: mgr.StartManual, + ServiceStartName: nativeServiceAccount, BinaryPathName: `"C:\Prior\viiper.exe" service`, + } + service := &fakeNativeService{ + config: prior, status: svc.Status{State: svc.Stopped}, events: &events, + failUpdate: errors.New("optional config failed after base config changed"), + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + updateCalls := 0 + service.updateHook = func() { + updateCalls++ + if updateCalls == 2 { + service.failUpdate = nil + } + } + + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil { + t.Fatal("expected partial UpdateConfig failure") + } + if !reflect.DeepEqual(service.config, prior) { + t.Fatalf("partially changed service config was not restored: %+v", service.config) + } +} + +func TestNativeInstallRestoresRunningServiceAfterStopWaitFails(t *testing.T) { + events := []string{} + prior := mgr.Config{ + ServiceType: windows.SERVICE_WIN32_OWN_PROCESS, StartType: mgr.StartAutomatic, + ServiceStartName: nativeServiceAccount, BinaryPathName: `"C:\Program Files\VIIPER\viiper.exe" service`, + } + service := &fakeNativeService{ + config: prior, status: svc.Status{State: svc.Running}, events: &events, + failControl: errors.New("status wait failed after stop was accepted"), + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + controlCalls := 0 + service.controlHook = func() { + controlCalls++ + if controlCalls == 2 { + service.failControl = nil + } + } + + var evidence nativeBrokerInstallEvidence + err := installNativeBrokerTransactionWithEvidence(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies, &evidence) + if err == nil { + t.Fatal("expected the forward stop failure") + } + if service.status.State != svc.Running || service.startCalls != 1 { + t.Fatalf("prior running state was not reconciled: status=%v starts=%d", service.status.State, service.startCalls) + } + if !evidence.mutationStarted || !evidence.rollbackSucceeded { + t.Fatalf("settled rollback evidence=%+v", evidence) + } +} + +func TestNativeRollbackDoesNotStartServiceAfterConfigRestoreFailure(t *testing.T) { + events := []string{} + service := &fakeNativeService{ + config: mgr.Config{ + ServiceType: windows.SERVICE_WIN32_OWN_PROCESS, StartType: mgr.StartAutomatic, + ServiceStartName: nativeServiceAccount, BinaryPathName: `"C:\Old\viiper.exe" service`, + }, + status: svc.Status{State: svc.Running}, events: &events, + failUpdate: errors.New("configuration write failed"), + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + credentialRolledBack := false + dependencies.rollbackCredential = func(nativeCredential) error { + credentialRolledBack = true + return nil + } + var evidence nativeBrokerInstallEvidence + err := installNativeBrokerTransactionWithEvidence(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies, &evidence) + if err == nil { + t.Fatal("expected update and rollback failure") + } + if service.startCalls != 0 || service.status.State != svc.Stopped { + t.Fatalf("service started with unverified config: starts=%d state=%v", service.startCalls, service.status.State) + } + if credentialRolledBack { + t.Fatal("credential was invalidated while the replacement service configuration remained installed") + } + if !evidence.mutationStarted || evidence.rollbackSucceeded { + t.Fatalf("indeterminate rollback evidence=%+v", evidence) + } +} + +func TestNativeInstallRejectsUnrepresentableRecoveryPolicyBeforeMutation(t *testing.T) { + events := []string{} + service := &fakeNativeService{ + config: mgr.Config{ + ServiceType: windows.SERVICE_WIN32_OWN_PROCESS, StartType: mgr.StartManual, + ServiceStartName: nativeServiceAccount, BinaryPathName: `"C:\Old\viiper.exe" service`, + }, + status: svc.Status{State: svc.Stopped}, events: &events, + recoveryActions: nil, recoveryReset: 777, + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil || !strings.Contains(err.Error(), "unrepresentable recovery policy") { + t.Fatalf("expected an unrepresentable-policy error, got %v", err) + } + if !reflect.DeepEqual(events, []string{"service-open"}) { + t.Fatalf("unrepresentable policy mutated SCM/legacy state: %v", events) + } +} + +func TestNativeInstallRejectsUnrestorableLoadOrderStateBeforeMutation(t *testing.T) { + for _, config := range []mgr.Config{ + {ServiceStartName: nativeServiceAccount, LoadOrderGroup: "legacy-group"}, + {ServiceStartName: nativeServiceAccount, TagId: 7}, + } { + events := []string{} + service := &fakeNativeService{ + config: config, status: svc.Status{State: svc.Stopped}, events: &events, + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil || !strings.Contains(err.Error(), "load-order") { + t.Fatalf("config=%+v error=%v", config, err) + } + if !reflect.DeepEqual(events, []string{"service-open"}) { + t.Fatalf("unrestorable config mutated state: %v", events) + } + } +} + +func TestNativeInstallWaitsForPriorServiceDeletionBeforeCreating(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + manager.openErrors = []error{ + windows.ERROR_SERVICE_MARKED_FOR_DELETE, + windows.ERROR_SERVICE_MARKED_FOR_DELETE, + } + waits := 0 + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + dependencies.wait = func(context.Context, time.Duration) error { + waits++ + return nil + } + if err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies); err != nil { + t.Fatal(err) + } + if waits != 2 || manager.service == nil { + t.Fatalf("deletion retry waits=%d service=%v events=%v", waits, manager.service, events) + } +} + +func TestNativeInstallRejectsPausedPriorServiceBeforeMutation(t *testing.T) { + events := []string{} + service := &fakeNativeService{ + config: mgr.Config{ServiceStartName: nativeServiceAccount}, + status: svc.Status{State: svc.Paused}, events: &events, + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil || !strings.Contains(err.Error(), "unsupported state") { + t.Fatalf("error=%v", err) + } + if len(events) != 1 || events[0] != "service-open" { + t.Fatalf("paused service was mutated: %v", events) + } +} + +func TestNativeInstallRejectsEmptyCredentialBeforeServiceConfiguration(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + rolledBack := false + dependencies.provisionCredential = func() (nativeCredential, error) { + return nativeCredential{path: `C:\ProgramData\VIIPER\viiper.key.txt`, password: " ", created: true}, nil + } + dependencies.rollbackCredential = func(nativeCredential) error { + rolledBack = true + return nil + } + if err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies); err == nil { + t.Fatal("accepted empty credential") + } + for _, event := range events { + if event == "service-create" || event == "service-update" || event == "service-start" { + t.Fatalf("empty credential changed service configuration: events=%v", events) + } + } + if !rolledBack { + t.Fatalf("empty credential was not rolled back: events=%v", events) + } +} + +func TestRollbackUsesIndependentContextAfterForwardTimeout(t *testing.T) { + events := []string{} + service := &fakeNativeService{ + config: mgr.Config{ServiceStartName: nativeServiceAccount}, + status: svc.Status{State: svc.Running}, events: &events, delayStartAfter: 1, + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + ctx, cancel := context.WithCancel(context.Background()) + dependencies.verifyBroker = func(context.Context, string) error { + cancel() + return context.Canceled + } + rollbackObservedLiveContext := false + dependencies.wait = func(waitCtx context.Context, _ time.Duration) error { + if ctx.Err() != nil && waitCtx.Err() == nil { + rollbackObservedLiveContext = true + } + service.status.State = svc.Running + return nil + } + if err := installNativeBrokerTransaction(ctx, testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies); err == nil { + t.Fatal("expected canceled verification") + } + if !rollbackObservedLiveContext { + t.Fatal("rollback reused the canceled forward-operation context") + } +} + +func TestNativeInstallRollsBackPartialLegacyStop(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + legacy := nativeLegacyState{commands: []nativeLegacyCommand{ + {executable: `C:\One\viiper.exe`}, {executable: `C:\Two\viiper.exe`}, + }} + restarted := false + dependencies := fakeNativeInstallDependencies(manager, legacy, &events) + dependencies.stopLegacy = func(_ context.Context, state *nativeLegacyState, _ *slog.Logger) error { + state.commands[0].running = true + return errors.New("second process query failed") + } + dependencies.restartLegacy = func(_ context.Context, state nativeLegacyState) error { + restarted = state.commands[0].running + return nil + } + + if err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies); err == nil { + t.Fatal("expected stop failure") + } + if !restarted { + t.Fatal("partially stopped legacy process was not restarted") + } +} + +func TestLegacyTaskAndRunOwnershipAreStoppedByTheirOwnMechanisms(t *testing.T) { + events := []string{} + command := nativeLegacyCommand{ + executable: `C:\Users\user\VIIPER\viiper.exe`, source: legacyCommandRun, + } + state := nativeLegacyState{ + userSID: "S-1-5-21-1-2-3-1001", + scheduledAction: &nativeLegacyCommand{executable: command.executable}, + scheduledXML: stringPointer(""), + scheduledCurrentXML: stringPointer(""), + scheduledActive: true, + commands: []nativeLegacyCommand{command}, + } + operations := nativeLegacyStopOperations{ + stopScheduled: func(_ context.Context, xml string, active bool) (nativeScheduledStopResult, error) { + events = append(events, "task-stop") + if xml != "" || !active { + t.Fatalf("task snapshot xml=%q active=%v", xml, active) + } + return nativeScheduledStopResult{stopped: true, disabled: true, currentXML: ""}, nil + }, + openProcesses: func(executable, userSID string) ([]nativeLegacyProcess, error) { + events = append(events, "run-process-query") + if executable != command.executable || userSID != state.userSID { + t.Fatalf("residual query executable=%q user=%q", executable, userSID) + } + return []nativeLegacyProcess{{handle: 123, pid: 456}}, nil + }, + terminate: func(nativeLegacyProcess) error { + events = append(events, "run-process-stop") + return nil + }, + closeHandle: func(windows.Handle) { events = append(events, "run-process-close") }, + } + if err := stopNativeLegacyStartupWith(context.Background(), &state, testLogger(), operations); err != nil { + t.Fatal(err) + } + if !state.scheduledStopped || !state.commands[0].running { + t.Fatalf("source state not preserved: %+v", state) + } + want := []string{"task-stop", "run-process-query", "run-process-stop", "run-process-close"} + if !reflect.DeepEqual(events, want) { + t.Fatalf("source ordering=%v want=%v", events, want) + } + + // A task-only registration must not enumerate or terminate an unrelated + // manual process merely because it shares the scheduled action's path. + state = nativeLegacyState{ + scheduledAction: &nativeLegacyCommand{executable: command.executable}, + scheduledXML: stringPointer(""), + scheduledCurrentXML: stringPointer(""), + } + operations.stopScheduled = func(context.Context, string, bool) (nativeScheduledStopResult, error) { + return nativeScheduledStopResult{currentXML: ""}, nil + } + operations.openProcesses = func(string, string) ([]nativeLegacyProcess, error) { + t.Fatal("task-only migration enumerated residual same-path processes") + return nil, nil + } + if err := stopNativeLegacyStartupWith(context.Background(), &state, testLogger(), operations); err != nil { + t.Fatal(err) + } +} + +func TestLegacyTaskStopMarksPossibleDisableBeforeSubprocessResult(t *testing.T) { + original := "" + state := nativeLegacyState{ + scheduledAction: &nativeLegacyCommand{executable: `C:\Legacy\viiper.exe`}, + scheduledXML: &original, + scheduledCurrentXML: &original, + scheduledEnabled: true, + } + operations := nativeLegacyStopOperations{ + stopScheduled: func(context.Context, string, bool) (nativeScheduledStopResult, error) { + return nativeScheduledStopResult{}, context.DeadlineExceeded + }, + openProcesses: func(string, string) ([]nativeLegacyProcess, error) { return nil, nil }, + terminate: func(nativeLegacyProcess) error { return nil }, + closeHandle: func(windows.Handle) {}, + } + err := stopNativeLegacyStartupWith(context.Background(), &state, testLogger(), operations) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("error=%v", err) + } + if !state.scheduledDisabled || state.scheduledCurrentXML == nil || *state.scheduledCurrentXML != original { + t.Fatalf("partial disable was not admitted to rollback state: %+v", state) + } +} + +func TestKilledTaskStopRollbackRestoresOnlyExactDisabledTaskAndRunningState(t *testing.T) { + const original = `trueC:\Legacy\viiper.exe` + validDisabled := strings.Replace(original, `true`, `false`, 1) + for _, test := range []struct { + name string + current string + wantRestarted bool + }{ + {name: "exact disabled task", current: validDisabled, wantRestarted: true}, + {name: "concurrent replacement", current: strings.Replace(validDisabled, `C:\Legacy`, `C:\Evil`, 1)}, + } { + t.Run(test.name, func(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + legacy := nativeLegacyState{ + scheduledAction: &nativeLegacyCommand{executable: `C:\Legacy\viiper.exe`}, + scheduledXML: stringPointer(original), + scheduledActive: true, + scheduledEnabled: true, + } + dependencies := fakeNativeInstallDependencies(manager, legacy, &events) + dependencies.stopLegacy = func(_ context.Context, state *nativeLegacyState, _ *slog.Logger) error { + state.scheduledDisabled = true + state.scheduledStopped = state.scheduledActive + return context.DeadlineExceeded + } + dependencies.restoreLegacy = func(_ context.Context, state nativeLegacyState) error { + events = append(events, "legacy-restore") + return validateNativeTaskDisabledOnly(*state.scheduledXML, test.current) + } + restarted := false + dependencies.restartLegacy = func(_ context.Context, state nativeLegacyState) error { + events = append(events, "legacy-restart") + restarted = state.scheduledStopped + return nil + } + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("error=%v", err) + } + if restarted != test.wantRestarted { + t.Fatalf("restarted=%v want=%v events=%v error=%v", restarted, test.wantRestarted, events, err) + } + }) + } +} + +func TestLegacyTaskAlreadyDisabledRemainsValidForNativeOwnership(t *testing.T) { + original := "" + state := nativeLegacyState{ + scheduledAction: &nativeLegacyCommand{executable: `C:\Missing\viiper.exe`}, + scheduledXML: &original, + scheduledCurrentXML: &original, + } + operations := nativeLegacyStopOperations{ + stopScheduled: func(context.Context, string, bool) (nativeScheduledStopResult, error) { + return nativeScheduledStopResult{disabled: true, currentXML: original}, nil + }, + openProcesses: func(string, string) ([]nativeLegacyProcess, error) { + t.Fatal("disabled task action was treated as an active process owner") + return nil, nil + }, + terminate: func(nativeLegacyProcess) error { return nil }, + closeHandle: func(windows.Handle) {}, + } + if err := stopNativeLegacyStartupWith(context.Background(), &state, testLogger(), operations); err != nil { + t.Fatal(err) + } + if !state.scheduledDisabled || state.scheduledStopped { + t.Fatalf("pre-disabled task state=%+v", state) + } +} + +func TestNativeUninstallSnapshotsAndRemovesLegacyBeforeServiceDelete(t *testing.T) { + events := []string{} + service := &fakeNativeService{ + config: mgr.Config{ServiceStartName: nativeServiceAccount}, + status: svc.Status{State: svc.Running}, events: &events, + } + manager := newFakeNativeSCM(service, &events) + legacy := nativeLegacyState{userSID: "S-1-5-21-1-2-3-1001"} + dependencies := fakeNativeInstallDependencies(manager, legacy, &events) + dependencies.snapshotLegacy = func(context.Context) (nativeLegacyState, error) { + events = append(events, "legacy-snapshot") + return legacy, nil + } + if err := uninstallNativeBrokerTransaction( + context.Background(), testLogger(), manager, dependencies, + ); err != nil { + t.Fatal(err) + } + if !service.deleted { + t.Fatal("native service was not deleted") + } + if !beforeEvent(events, "legacy-snapshot", "service-stop") || + !beforeEvent(events, "legacy-remove", "service-delete") { + t.Fatalf("uninstall transaction order=%v", events) + } +} + +func TestNativeUninstallRollsBackServiceRegistrationsAndProcessOnDeleteFailure(t *testing.T) { + events := []string{} + service := &fakeNativeService{ + config: mgr.Config{ServiceStartName: nativeServiceAccount}, + status: svc.Status{State: svc.Running}, events: &events, + failDelete: errors.New("delete failed"), + } + manager := newFakeNativeSCM(service, &events) + legacy := nativeLegacyState{ + userSID: "S-1-5-21-1-2-3-1001", + commands: []nativeLegacyCommand{{ + executable: `C:\Legacy\viiper.exe`, source: legacyCommandRun, + }}, + } + dependencies := fakeNativeInstallDependencies(manager, legacy, &events) + dependencies.stopLegacy = func(_ context.Context, state *nativeLegacyState, _ *slog.Logger) error { + events = append(events, "legacy-stop") + state.commands[0].running = true + return nil + } + err := uninstallNativeBrokerTransaction(context.Background(), testLogger(), manager, dependencies) + if err == nil || !strings.Contains(err.Error(), "delete failed") { + t.Fatalf("error=%v", err) + } + if service.deleted || service.status.State != svc.Running { + t.Fatalf("service rollback state=%v deleted=%v", service.status.State, service.deleted) + } + if !beforeEvent(events, "service-start", "legacy-restore") || + !beforeEvent(events, "legacy-restore", "legacy-restart") { + t.Fatalf("uninstall rollback order=%v", events) + } +} + +func TestNativeUninstallRejectsPausedServiceBeforeMutation(t *testing.T) { + events := []string{} + service := &fakeNativeService{ + config: mgr.Config{ServiceStartName: nativeServiceAccount}, + status: svc.Status{State: svc.Paused}, events: &events, + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + err := uninstallNativeBrokerTransaction(context.Background(), testLogger(), manager, dependencies) + if err == nil || !strings.Contains(err.Error(), "unsupported state") { + t.Fatalf("error=%v", err) + } + if service.deleted || slices.Contains(events, "service-stop") { + t.Fatalf("paused service was mutated: %v", events) + } +} + +func TestValidateNativeBrokerPingRequiresExactContract(t *testing.T) { + expected, err := udecx.DeriveBuildIdentity( + strings.Repeat("a", 40), udecx.DriverPackageVersion, + udecx.ABIMajor, udecx.ABIMinor, udecx.AdvertisedCapabilities, + ) + if err != nil { + t.Fatal(err) + } + ready := true + valid := &viipertypes.PingResponse{ + Server: "VIIPER", Transport: "native-ude", Ready: &ready, + NativeUDE: &viipertypes.NativeUDEInfo{ + ABIMajor: udecx.ABIMajor, ABIMinor: udecx.ABIMinor, + Capabilities: uint32(udecx.AdvertisedCapabilities), + ExpectedDriverPackageVersion: udecx.DriverPackageVersion, + LoadedDriverBuildIdentity: udecx.BuildIdentityHex(expected), + ControllerSessionID: "17", + ControllerInstanceID: `ROOT\VIIPERUDE\0042`, + }, + } + if err := validateNativeBrokerPingAgainstIdentity(valid, expected); err != nil { + t.Fatal(err) + } + cases := map[string]func(*viipertypes.PingResponse){ + "not ready": func(p *viipertypes.PingResponse) { value := false; p.Ready = &value }, + "wrong ABI": func(p *viipertypes.PingResponse) { p.NativeUDE.ABIMinor++ }, + "extra caps": func(p *viipertypes.PingResponse) { p.NativeUDE.Capabilities |= uint32(udecx.CapabilityStreams) }, + "wrong package version": func(p *viipertypes.PingResponse) { + p.NativeUDE.ExpectedDriverPackageVersion = "0.1.0.3" + }, + "missing loaded identity": func(p *viipertypes.PingResponse) { + p.NativeUDE.LoadedDriverBuildIdentity = "" + }, + "malformed loaded identity": func(p *viipertypes.PingResponse) { + p.NativeUDE.LoadedDriverBuildIdentity = strings.Repeat("z", 64) + }, + "missing controller session identity": func(p *viipertypes.PingResponse) { + p.NativeUDE.ControllerSessionID = "" + }, + "noncanonical controller session identity": func(p *viipertypes.PingResponse) { + p.NativeUDE.ControllerSessionID = "017" + }, + "missing controller identity": func(p *viipertypes.PingResponse) { + p.NativeUDE.ControllerInstanceID = "" + }, + "noncanonical controller identity": func(p *viipertypes.PingResponse) { + p.NativeUDE.ControllerInstanceID = `ROOT\VIIPERUDE\42` + }, + "stale loaded identity with matching ABI and caps": func(p *viipertypes.PingResponse) { + p.NativeUDE.LoadedDriverBuildIdentity = strings.Repeat("0", 64) + }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + copyResponse := *valid + copyNative := *valid.NativeUDE + copyResponse.NativeUDE = ©Native + mutate(©Response) + if err := validateNativeBrokerPingAgainstIdentity(©Response, expected); err == nil { + t.Fatal("expected exact-contract rejection") + } + }) + } +} + +func TestValidateNativeBrokerPingFailsClosedWithoutBuildInjection(t *testing.T) { + if _, err := udecx.ExpectedBuildIdentity(); err == nil { + t.Skip("test binary has an explicitly injected native source revision") + } + if err := validateNativeBrokerPing(nil); !errors.Is(err, udecx.ErrBuildIdentity) { + t.Fatalf("error=%v want ErrBuildIdentity", err) + } +} + +func TestValidateNativeBrokerPingUsesInjectedBuildIdentity(t *testing.T) { + expected, err := udecx.ExpectedBuildIdentity() + if err != nil { + t.Skip("test binary has no injected native source revision") + } + ready := true + response := &viipertypes.PingResponse{ + Server: "VIIPER", Transport: "native-ude", Ready: &ready, + NativeUDE: &viipertypes.NativeUDEInfo{ + ABIMajor: udecx.ABIMajor, ABIMinor: udecx.ABIMinor, + Capabilities: uint32(udecx.AdvertisedCapabilities), + ExpectedDriverPackageVersion: udecx.DriverPackageVersion, + LoadedDriverBuildIdentity: udecx.BuildIdentityHex(expected), + ControllerSessionID: "17", + ControllerInstanceID: `ROOT\VIIPERUDE\0042`, + }, + } + if err := validateNativeBrokerPing(response); err != nil { + t.Fatal(err) + } + response.NativeUDE.LoadedDriverBuildIdentity = strings.Repeat("0", 64) + if err := validateNativeBrokerPing(response); err == nil { + t.Fatal("authenticated readiness accepted a stale same-ABI/capability loaded kernel") + } +} + +func TestCredentialACLUsesSIDsRatherThanLocalizedAccountNames(t *testing.T) { + const userSID = "S-1-5-21-1-2-3-1001" + for _, sddl := range []string{nativeCredentialDirectorySDDL(userSID), nativeCredentialFileSDDL(userSID)} { + if !strings.Contains(sddl, ";;;SY") || !strings.Contains(sddl, ";;;BA") || !strings.Contains(sddl, userSID) { + t.Fatalf("ACL does not explicitly name SYSTEM, administrators, and installing user by SID: %s", sddl) + } + if _, err := windows.SecurityDescriptorFromString(sddl); err != nil { + t.Fatalf("invalid SDDL %q: %v", sddl, err) + } + } +} + +func TestCredentialDirectorySecurityRejectsPrecreatedOwnerOrDACL(t *testing.T) { + const userSID = "S-1-5-21-1-2-3-1001" + expected, err := windows.SecurityDescriptorFromString(nativeCredentialDirectorySDDL(userSID)) + if err != nil { + t.Fatal(err) + } + identical, _ := windows.SecurityDescriptorFromString(nativeCredentialDirectorySDDL(userSID)) + if err := nativeSecurityDescriptorsEqual(identical, expected, nativeFileAccessMapping); err != nil { + t.Fatalf("exact protected descriptor rejected: %v", err) + } + wrongOwner, _ := windows.SecurityDescriptorFromString( + "O:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;" + userSID + ")", + ) + if err := nativeSecurityDescriptorsEqual(wrongOwner, expected, nativeFileAccessMapping); err == nil { + t.Fatal("accepted user-precreated credential directory with wrong owner") + } + unprotected, _ := windows.SecurityDescriptorFromString( + "O:BAD:(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;" + userSID + ")", + ) + if err := nativeSecurityDescriptorsEqual(unprotected, expected, nativeFileAccessMapping); err == nil { + t.Fatal("accepted credential directory without protected canonical DACL") + } +} + +func TestNativeFileSecurityComparisonAcceptsWindowsMaterializedGenericRights(t *testing.T) { + expected, err := windows.SecurityDescriptorFromString(nativeBrokerDirectorySDDL) + if err != nil { + t.Fatal(err) + } + actual, err := windows.SecurityDescriptorFromString( + "O:BAG:S-1-5-21-1-2-3-1001D:P" + + "(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1200a9;;;BU)", + ) + if err != nil { + t.Fatal(err) + } + if err := nativeSecurityDescriptorsEqual(actual, expected, nativeFileAccessMapping); err != nil { + t.Fatalf("rejected exact Windows-materialized file DACL: %v", err) + } +} + +func TestNativeFileSecurityComparisonRoundTripsThroughObjectManager(t *testing.T) { + path := filepath.Join(t.TempDir(), "protected") + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + t.Fatal(err) + } + ownerSID := user.User.Sid.String() + if ownerSID == "" { + t.Fatal("current process token returned an empty owner SID") + } + roundTripSDDL := strings.Replace(nativeBrokerDirectorySDDL, "O:BA", "O:"+ownerSID, 1) + security, err := nativeSecurityAttributes(roundTripSDDL) + if err != nil { + t.Fatal(err) + } + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + if err := windows.CreateDirectory(pointer, security); err != nil { + t.Fatal(err) + } + handle, err := openNativePathWithoutReparse( + path, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + ) + if err != nil { + t.Fatal(err) + } + defer windows.CloseHandle(handle) //nolint:errcheck + if err := validateNativeSecurityDescriptor(handle, roundTripSDDL); err != nil { + actual, queryErr := windows.GetSecurityInfo( + handle, windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION, + ) + if queryErr != nil { + t.Fatalf("round-trip rejected (%v), then query failed: %v", err, queryErr) + } + t.Fatalf("round-trip rejected: %v (actual=%s)", err, actual.String()) + } +} + +func TestNativeSecurityComparisonRejectsWidenedOrNonAllowDACLs(t *testing.T) { + expected, err := windows.SecurityDescriptorFromString(nativeBrokerDirectorySDDL) + if err != nil { + t.Fatal(err) + } + for name, sddl := range map[string]string{ + "widened": "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;BU)", + "narrowed": "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GR;;;BU)", + "wrong_sid": "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + + "(A;OICI;GRGX;;;WD)", + "wrong_flags": "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + + "(A;OI;GRGX;;;BU)", + "inherited": "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + + "(A;OICIID;GRGX;;;BU)", + "reordered": "O:BAD:P(A;OICI;FA;;;BA)(A;OICI;FA;;;SY)" + + "(A;OICI;GRGX;;;BU)", + "extra": "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + + "(A;OICI;GRGX;;;BU)(A;OICI;GR;;;WD)", + "deny": "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(D;OICI;GW;;;BU)" + + "(A;OICI;GRGX;;;BU)", + } { + t.Run(name, func(t *testing.T) { + actual, parseErr := windows.SecurityDescriptorFromString(sddl) + if parseErr != nil { + t.Fatal(parseErr) + } + if err := nativeSecurityDescriptorsEqual( + actual, expected, nativeFileAccessMapping, + ); err == nil { + t.Fatal("accepted a non-exact protected DACL") + } + }) + } +} + +func TestNativeSecurityComparisonRejectsMissingNullOrDefaultedDACL(t *testing.T) { + expected, err := windows.SecurityDescriptorFromString(nativeBrokerDirectorySDDL) + if err != nil { + t.Fatal(err) + } + owner, _, err := expected.Owner() + if err != nil { + t.Fatal(err) + } + dacl, _, err := expected.DACL() + if err != nil { + t.Fatal(err) + } + for name, build := range map[string]func(*windows.SECURITY_DESCRIPTOR) error{ + "missing": func(descriptor *windows.SECURITY_DESCRIPTOR) error { + return descriptor.SetDACL(nil, false, false) + }, + "null": func(descriptor *windows.SECURITY_DESCRIPTOR) error { + return descriptor.SetDACL(nil, true, false) + }, + "defaulted": func(descriptor *windows.SECURITY_DESCRIPTOR) error { + return descriptor.SetDACL(dacl, true, true) + }, + } { + t.Run(name, func(t *testing.T) { + actual, newErr := windows.NewSecurityDescriptor() + if newErr != nil { + t.Fatal(newErr) + } + if err := actual.SetOwner(owner, false); err != nil { + t.Fatal(err) + } + if err := build(actual); err != nil { + t.Fatal(err) + } + if err := actual.SetControl( + windows.SE_DACL_PROTECTED, windows.SE_DACL_PROTECTED, + ); err != nil { + t.Fatal(err) + } + if err := nativeSecurityDescriptorsEqual( + actual, expected, nativeFileAccessMapping, + ); err == nil { + t.Fatal("accepted missing, NULL, or defaulted DACL") + } + }) + } +} + +func TestNativeServiceSecurityComparisonMapsGenericAll(t *testing.T) { + expected, err := windows.SecurityDescriptorFromString(nativeBrokerServiceSDDL) + if err != nil { + t.Fatal(err) + } + actual, err := windows.SecurityDescriptorFromString( + "O:BAG:S-1-5-21-1-2-3-1001D:P(A;;0xf01ff;;;SY)(A;;0xf01ff;;;BA)", + ) + if err != nil { + t.Fatal(err) + } + if err := nativeSecurityDescriptorsEqual(actual, expected, nativeServiceAccessMapping); err != nil { + t.Fatalf("rejected exact Windows-materialized service DACL: %v", err) + } +} + +func TestParseWindowsCommandRejectsNonViiperAndPreservesArguments(t *testing.T) { + identity := func(value string) (string, error) { return value, nil } + command, err := parseWindowsCommand( + `"C:\Program Files\VIIPER\viiper.exe" server --log.file "C:\logs\native log.txt"`, + identity, + ) + if err != nil { + t.Fatal(err) + } + if command.executable != `C:\Program Files\VIIPER\viiper.exe` || + !reflect.DeepEqual(command.arguments, []string{"server", "--log.file", `C:\logs\native log.txt`}) { + t.Fatalf("command=%+v", command) + } + if _, err := parseWindowsCommand(`"C:\Windows\System32\cmd.exe" /c calc`, identity); err == nil { + t.Fatal("accepted a non-VIIPER startup command") + } + command, err = parseWindowsCommand(`"%LOCALAPPDATA%\VIIPER\viiper.exe" server`, func(value string) (string, error) { + return strings.ReplaceAll(value, `%LOCALAPPDATA%`, `C:\Users\target\AppData\Local`), nil + }) + if err != nil || command.executable != `C:\Users\target\AppData\Local\VIIPER\viiper.exe` { + t.Fatalf("target-user expansion command=%+v error=%v", command, err) + } +} + +func TestNativeUserRunKeyPathUsesExplicitSIDHive(t *testing.T) { + got, err := nativeUserRunKeyPath("S-1-5-21-1-2-3-1001") + if err != nil { + t.Fatal(err) + } + want := `S-1-5-21-1-2-3-1001\` + runKeyPath + if got != want { + t.Fatalf("run key=%q want=%q", got, want) + } + for _, invalid := range []string{"", `S-1-5-21\Software`, "not-a-sid"} { + if _, err := nativeUserRunKeyPath(invalid); err == nil { + t.Fatalf("accepted invalid user SID %q", invalid) + } + } +} + +type fakeNativeSCM struct { + service *fakeNativeService + events *[]string + newServiceFailUpdate error + openErrors []error +} + +func newFakeNativeSCM(service *fakeNativeService, events *[]string) *fakeNativeSCM { + if service != nil { + if service.config.BinaryPathName == "" { + service.config.BinaryPathName = `"C:\Program Files\VIIPER\viiper.exe" service` + } + service.events = events + } + return &fakeNativeSCM{service: service, events: events} +} + +func (m *fakeNativeSCM) OpenService(string) (nativeManagedService, error) { + *m.events = append(*m.events, "service-open") + if len(m.openErrors) != 0 { + err := m.openErrors[0] + m.openErrors = m.openErrors[1:] + return nil, err + } + if m.service == nil || m.service.deleted { + return nil, windows.ERROR_SERVICE_DOES_NOT_EXIST + } + return m.service, nil +} + +func (m *fakeNativeSCM) CreateService(_ string, executable string, config mgr.Config, args ...string) (nativeManagedService, error) { + *m.events = append(*m.events, "service-create") + if m.service != nil && !m.service.deleted { + return nil, windows.ERROR_SERVICE_EXISTS + } + commandLine, err := windowsCommandLine(executable, args...) + if err != nil { + return nil, err + } + config.BinaryPathName = commandLine + m.service = &fakeNativeService{ + config: config, status: svc.Status{State: svc.Stopped}, events: m.events, + failUpdate: m.newServiceFailUpdate, + } + return m.service, nil +} + +func (m *fakeNativeSCM) Close() error { return nil } + +type fakeNativeService struct { + config mgr.Config + securityDescriptor string + status svc.Status + recoveryActions []mgr.RecoveryAction + recoveryReset uint32 + recoverNonCrash bool + deleted bool + events *[]string + failUpdate error + failSecurity error + failRecovery error + failRecoveryFlag error + failControl error + failDelete error + updateHook func() + controlHook func() + processID uint32 + startCalls int + delayStartAfter int +} + +func (s *fakeNativeService) Config() (mgr.Config, error) { return s.config, nil } +func (s *fakeNativeService) UpdateConfig(config mgr.Config) error { + *s.events = append(*s.events, "service-update") + // Model x/sys' multi-call behavior: the base service configuration may be + // committed before an optional service setting reports failure. + s.config = config + if s.updateHook != nil { + s.updateHook() + } + return s.failUpdate +} +func (s *fakeNativeService) SecurityDescriptor() (string, error) { + if s.securityDescriptor == "" { + return nativeBrokerServiceSDDL, nil + } + return s.securityDescriptor, nil +} +func (s *fakeNativeService) SetSecurityDescriptor(sddl string) error { + *s.events = append(*s.events, "service-security") + if s.failSecurity != nil { + return s.failSecurity + } + s.securityDescriptor = sddl + return nil +} +func (s *fakeNativeService) Query() (svc.Status, error) { return s.status, nil } +func (s *fakeNativeService) ProcessID() (uint32, error) { + if s.processID != 0 { + return s.processID, nil + } + if s.status.State == svc.Running { + return 4242, nil + } + return 0, nil +} +func (s *fakeNativeService) Start(...string) error { + *s.events = append(*s.events, "service-start") + s.startCalls++ + if s.delayStartAfter != 0 && s.startCalls > s.delayStartAfter { + s.status.State = svc.StartPending + } else { + s.status.State = svc.Running + } + return nil +} +func (s *fakeNativeService) Control(command svc.Cmd) (svc.Status, error) { + if command != svc.Stop { + return s.status, errors.New("unsupported fake control") + } + *s.events = append(*s.events, "service-stop") + s.status.State = svc.Stopped + if s.controlHook != nil { + s.controlHook() + } + return s.status, s.failControl +} +func (s *fakeNativeService) Delete() error { + *s.events = append(*s.events, "service-delete") + if s.failDelete != nil { + return s.failDelete + } + s.deleted = true + return nil +} +func (s *fakeNativeService) SetRecoveryActions(actions []mgr.RecoveryAction, reset uint32) error { + *s.events = append(*s.events, "service-recovery") + s.recoveryActions = append([]mgr.RecoveryAction(nil), actions...) + s.recoveryReset = reset + return s.failRecovery +} +func (s *fakeNativeService) SetRecoveryActionsExact(actions []mgr.RecoveryAction, reset uint32) error { + return s.SetRecoveryActions(actions, reset) +} +func (s *fakeNativeService) RecoveryActions() ([]mgr.RecoveryAction, error) { + return append([]mgr.RecoveryAction(nil), s.recoveryActions...), nil +} +func (s *fakeNativeService) ResetRecoveryActions() error { + s.recoveryActions = nil + s.recoveryReset = 0 + return nil +} +func (s *fakeNativeService) ResetPeriod() (uint32, error) { return s.recoveryReset, nil } +func (s *fakeNativeService) SetRecoveryActionsOnNonCrashFailures(value bool) error { + *s.events = append(*s.events, "service-recovery-flag") + s.recoverNonCrash = value + return s.failRecoveryFlag +} +func (s *fakeNativeService) RecoveryActionsOnNonCrashFailures() (bool, error) { + return s.recoverNonCrash, nil +} +func (s *fakeNativeService) Close() error { return nil } + +func fakeNativeInstallDependencies( + manager *fakeNativeSCM, + legacy nativeLegacyState, + events *[]string, +) nativeInstallDependencies { + return nativeInstallDependencies{ + connectSCM: func() (nativeSCM, error) { return manager, nil }, + lockExecutable: func(string) (func(), error) { return func() {}, nil }, + lockPriorExecutable: func(string) (func(), error) { return func() {}, nil }, + provisionCredential: func() (nativeCredential, error) { + return nativeCredential{path: `C:\ProgramData\VIIPER\viiper.key.txt`, password: "credential", created: true}, nil + }, + rollbackCredential: func(nativeCredential) error { return nil }, + preflightDriver: func() error { + *events = append(*events, "driver-preflight") + return nil + }, + snapshotLegacy: func(context.Context) (nativeLegacyState, error) { return legacy, nil }, + stopLegacy: func(context.Context, *nativeLegacyState, *slog.Logger) error { + *events = append(*events, "legacy-stop") + return nil + }, + removeLegacy: func(context.Context, nativeLegacyState) error { + *events = append(*events, "legacy-remove") + return nil + }, + restoreLegacy: func(context.Context, nativeLegacyState) error { + *events = append(*events, "legacy-restore") + return nil + }, + restartLegacy: func(context.Context, nativeLegacyState) error { + *events = append(*events, "legacy-restart") + return nil + }, + verifyBroker: func(context.Context, string) error { + *events = append(*events, "verify") + return nil + }, + wait: immediateWait, + } +} + +func immediateWait(context.Context, time.Duration) error { return nil } + +func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } + +func stringPointer(value string) *string { return &value } + +func nativeRunRegistrationPointer(value string, valueType uint32) *nativeRunRegistration { + return &nativeRunRegistration{value: value, valueType: valueType} +} + +func beforeEvent(events []string, first, second string) bool { + firstIndex, secondIndex := -1, -1 + for index, event := range events { + if event == first && firstIndex < 0 { + firstIndex = index + } + if event == second && secondIndex < 0 { + secondIndex = index + } + } + return firstIndex >= 0 && secondIndex >= 0 && firstIndex < secondIndex +} + +func lastIndex(events []string, value string) int { + for index := len(events) - 1; index >= 0; index-- { + if events[index] == value { + return index + } + } + return -1 +} diff --git a/internal/cmd/native_transport.go b/internal/cmd/native_transport.go new file mode 100644 index 00000000..8eb11a34 --- /dev/null +++ b/internal/cmd/native_transport.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "context" + "errors" + "sync" + "sync/atomic" + + "github.com/Alia5/VIIPER/viipertypes" +) + +type nativeUDETransport interface { + Done() <-chan error + Close() error + Status() (bool, *viipertypes.NativeUDEInfo) +} + +// nativeUDETransportSession owns the lifetime boundary between the Go host +// and the kernel broker handle. Cancellation is session-owned rather than +// delegated to Host.Close so shutdown is safe even if it races the Serve +// goroutine's first instruction. The broker handle is closed only after every +// dequeue worker, endpoint lane, and input publisher has stopped using it. +type nativeUDETransportSession struct { + cancel context.CancelFunc + closeClient func() error + done chan error + ready atomic.Bool + info viipertypes.NativeUDEInfo + closeOnce sync.Once + closeErr error +} + +func (s *nativeUDETransportSession) Done() <-chan error { return s.done } + +func (s *nativeUDETransportSession) Status() (bool, *viipertypes.NativeUDEInfo) { + info := s.info + return s.ready.Load(), &info +} + +func (s *nativeUDETransportSession) Close() error { + s.closeOnce.Do(func() { + s.ready.Store(false) + s.cancel() + serveErr := <-s.done + s.closeErr = errors.Join(serveErr, s.closeClient()) + }) + return s.closeErr +} diff --git a/internal/cmd/native_transport_other.go b/internal/cmd/native_transport_other.go new file mode 100644 index 00000000..cb9da63d --- /dev/null +++ b/internal/cmd/native_transport_other.go @@ -0,0 +1,14 @@ +//go:build !windows + +package cmd + +import ( + "context" + "errors" + + serverusb "github.com/Alia5/VIIPER/internal/server/usb" +) + +func startNativeUDETransport(context.Context, *serverusb.Server) (nativeUDETransport, error) { + return nil, errors.New("native UDE transport is available only on Windows") +} diff --git a/internal/cmd/native_transport_test.go b/internal/cmd/native_transport_test.go new file mode 100644 index 00000000..5f5b5b97 --- /dev/null +++ b/internal/cmd/native_transport_test.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" +) + +func TestNativeUDETransportCloseWaitsForHostBeforeClosingClient(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + var hostStopped atomic.Bool + var clientClosed atomic.Bool + orderingErr := errors.New("kernel client closed before native host stopped") + session := &nativeUDETransportSession{ + cancel: cancel, + done: done, + closeClient: func() error { + if !hostStopped.Load() { + return orderingErr + } + clientClosed.Store(true) + return nil + }, + } + session.ready.Store(true) + go func() { + <-ctx.Done() + hostStopped.Store(true) + done <- nil + close(done) + }() + + closed := make(chan error, 1) + go func() { closed <- session.Close() }() + select { + case err := <-closed: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("native transport shutdown did not complete") + } + if !clientClosed.Load() { + t.Fatal("kernel client was not closed") + } + if err := session.Close(); err != nil { + t.Fatalf("idempotent Close returned %v", err) + } +} + +func TestNativeUDETransportStatusIsSnapshot(t *testing.T) { + session := &nativeUDETransportSession{} + session.info.ABIMajor = 1 + session.info.ABIMinor = 10 + session.ready.Store(true) + + ready, first := session.Status() + if !ready || first.ABIMajor != 1 || first.ABIMinor != 10 { + t.Fatalf("unexpected native status: ready=%v info=%+v", ready, first) + } + first.ABIMinor = 99 + _, second := session.Status() + if second.ABIMinor != 10 { + t.Fatal("Status exposed mutable session state") + } +} + +func TestNativeUDETransportClosePreservesHostAndClientErrors(t *testing.T) { + hostErr := errors.New("host failed") + clientErr := errors.New("client close failed") + done := make(chan error, 1) + done <- hostErr + close(done) + session := &nativeUDETransportSession{ + cancel: func() {}, + done: done, + closeClient: func() error { return clientErr }, + } + err := session.Close() + if !errors.Is(err, hostErr) || !errors.Is(err, clientErr) { + t.Fatalf("Close error=%v, want joined host and client errors", err) + } +} diff --git a/internal/cmd/native_transport_windows.go b/internal/cmd/native_transport_windows.go new file mode 100644 index 00000000..c0a45371 --- /dev/null +++ b/internal/cmd/native_transport_windows.go @@ -0,0 +1,57 @@ +//go:build windows + +package cmd + +import ( + "context" + "strconv" + + serverusb "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/transport/udecx" + "github.com/Alia5/VIIPER/viipertypes" +) + +func startNativeUDETransport(ctx context.Context, server *serverusb.Server) (nativeUDETransport, error) { + client, err := udecx.Open(ctx) + if err != nil { + return nil, err + } + processor, err := serverusb.NewNativeProcessor(server) + if err != nil { + _ = client.Close() + return nil, err + } + host, err := udecx.NewHost(client, processor, 0) + if err != nil { + _ = client.Close() + return nil, err + } + if err := server.EnableNativeTransport(host); err != nil { + _ = client.Close() + return nil, err + } + sessionCtx, cancel := context.WithCancel(ctx) + limits := client.Limits() + session := &nativeUDETransportSession{ + cancel: cancel, closeClient: client.Close, done: make(chan error, 1), + info: viipertypes.NativeUDEInfo{ + ABIMajor: udecx.ABIMajor, ABIMinor: udecx.ABIMinor, + Capabilities: uint32(client.Capabilities()), + ExpectedDriverPackageVersion: udecx.DriverPackageVersion, + LoadedDriverBuildIdentity: udecx.BuildIdentityHex(client.BuildIdentity()), + ControllerSessionID: strconv.FormatUint(client.ControllerSessionID(), 10), + ControllerInstanceID: client.ControllerInstanceID(), + MaxDevices: limits.MaxDevices, MaxDescriptorBytes: limits.MaxDescriptorBytes, + MaxTransferBytes: limits.MaxTransferBytes, MaxIsoPackets: limits.MaxIsoPackets, + MaxPendingOperations: limits.MaxPendingOperations, + }, + } + session.ready.Store(true) + go func() { + err := host.Serve(sessionCtx) + session.ready.Store(false) + session.done <- err + close(session.done) + }() + return session, nil +} diff --git a/internal/cmd/server.go b/internal/cmd/server.go index 3d453fdf..09a176fb 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "log/slog" "os" @@ -18,6 +19,7 @@ import ( "github.com/Alia5/VIIPER/internal/server/api/handler" "github.com/Alia5/VIIPER/internal/server/usb" "github.com/Alia5/VIIPER/internal/tray" + "github.com/Alia5/VIIPER/viipertypes" ) const keyFileName = "viiper.key.txt" @@ -26,6 +28,10 @@ type Server struct { USBServerConfig usb.ServerConfig `embed:"" prefix:"usb."` APIServerConfig api.ServerConfig `embed:"" prefix:"api."` ConnectionTimeout time.Duration `help:"ConnectionTimeout operation timeout" default:"30s" env:"VIIPER_CONNECTION_TIMEOUT"` + Transport string `help:"Virtual USB transport: usbip or native-ude" default:"usbip" env:"VIIPER_TRANSPORT"` + KeyFile string `help:"Path to the API credential file." env:"VIIPER_KEY_FILE" type:"path"` + serviceMode bool + ready func() } // Run is called by Kong when the server command is executed. @@ -36,13 +42,23 @@ func (s *Server) Run(logger *slog.Logger, rawLogger log.RawLogger) error { } func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger log.RawLogger) error { - if err := requireUSBIPRuntime(); err != nil { - logger.Error("Refusing to start VIIPER with an incompatible USB/IP runtime", "error", err) - return err + transport := strings.ToLower(strings.TrimSpace(s.Transport)) + if transport != "usbip" && transport != "native-ude" { + return fmt.Errorf("unsupported VIIPER transport %q (expected usbip or native-ude)", s.Transport) + } + if transport == "usbip" { + if err := requireUSBIPRuntime(); err != nil { + logger.Error("Refusing to start VIIPER with an incompatible USB/IP runtime", "error", err) + return err + } } + applyTransportAPISecurityPolicy(transport, &s.APIServerConfig) ctx, cancel := context.WithCancel(ctx) - stopTray := tray.Run(ctx, cancel) + stopTray := func() {} + if !s.serviceMode { + stopTray = tray.Run(ctx, cancel) + } defer func() { cancel() stopTray() @@ -52,16 +68,29 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger s.APIServerConfig.ConnectionTimeout = s.ConnectionTimeout s.USBServerConfig.BusCleanupTimeout = s.APIServerConfig.DeviceHandlerConnectTimeout - logger.Info("Starting VIIPER USB-IP server", "addr", s.USBServerConfig.Addr) + logger.Info("Starting VIIPER virtual USB server", "transport", transport, + "usbipAddr", s.USBServerConfig.Addr) - keyFileDir, err := configpaths.KeyFileDir() - if err != nil { - return fmt.Errorf("failed to resolve key file path: %w", err) + keyFilePath := strings.TrimSpace(s.KeyFile) + if keyFilePath == "" { + keyFileDir, err := configpaths.KeyFileDir() + if err != nil { + return fmt.Errorf("failed to resolve key file path: %w", err) + } + keyFilePath = filepath.Join(keyFileDir, keyFileName) + } else if !filepath.IsAbs(keyFilePath) { + return fmt.Errorf("API credential path must be absolute: %s", keyFilePath) } - keyFilePath := filepath.Join(keyFileDir, keyFileName) + keyFileDir := filepath.Dir(keyFilePath) if pwd, err := os.ReadFile(keyFilePath); err == nil { s.APIServerConfig.Password = strings.TrimSpace(string(pwd)) + if s.APIServerConfig.Password == "" { + return fmt.Errorf("API credential file is empty: %s", keyFilePath) + } } else { + if s.serviceMode { + return fmt.Errorf("managed service API credential is missing or unreadable at %s: %w", keyFilePath, err) + } newPwd, err := auth.GenerateKey() if err != nil { return fmt.Errorf("failed to generate new API password: %w", err) @@ -73,45 +102,60 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger return fmt.Errorf("failed to write new API password to file: %w", err) } s.APIServerConfig.Password = newPwd - logger.Info("Generated API server password", "path", keyFilePath) - logger.Info("-------------------------------------") - logger.Info("Your VIIPER API server password is:") - logger.Info("-------------------------------------") - logger.Info(newPwd) - logger.Info("-------------------------------------") - logger.Info("You can change this password at any time by editing the file") + logGeneratedAPICredential(logger, keyFilePath) } usbSrv := usb.New(s.USBServerConfig, logger, rawLogger) - usbErrCh := make(chan error, 1) - go func() { - usbErrCh <- usbSrv.ListenAndServe() - }() - - select { - case err := <-usbErrCh: - return err - case <-usbSrv.Ready(): + var usbErrCh <-chan error + var nativeSession nativeUDETransport + if transport == "usbip" { + errors := make(chan error, 1) + usbErrCh = errors + go func() { + errors <- usbSrv.ListenAndServe() + }() + select { + case err := <-usbErrCh: + return err + case <-usbSrv.Ready(): + } + } else { + var err error + nativeSession, err = startNativeUDETransport(ctx, usbSrv) + if err != nil { + return fmt.Errorf("start native UDE transport: %w", err) + } + defer nativeSession.Close() + logger.Info("Starting VIIPER native UDE transport") } if s.APIServerConfig.Addr == "" { - logger.Error("API server address must be set (default :3242).") - return fmt.Errorf("API server address must be set (default :3242).") // nolint + logger.Error("API server address must be set", "default", api.DefaultListenAddress) + return fmt.Errorf("API server address must be set (default %s)", api.DefaultListenAddress) } apiSrv := api.New(usbSrv, s.APIServerConfig.Addr, s.APIServerConfig, logger) r := apiSrv.Router() - r.Register("ping", handler.Ping()) + r.Register("ping", handler.Ping(handler.PingOptions{ + Transport: transport, + Status: func() (bool, *viipertypes.NativeUDEInfo) { + if nativeSession != nil { + return nativeSession.Status() + } + return true, nil + }, + })) r.Register("bus/list", handler.BusList(usbSrv)) r.Register("bus/create", handler.BusCreate(usbSrv)) r.Register("bus/remove", handler.BusRemove(usbSrv)) r.Register("bus/{id}/list", handler.BusDevicesList(usbSrv)) r.Register("bus/{id}/add", handler.BusDeviceAdd(usbSrv, apiSrv)) r.Register("bus/{id}/remove", handler.BusDeviceRemove(usbSrv)) + r.Register("bus/{id}/remove-native", handler.BusDeviceRemoveNative(usbSrv)) r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(usbSrv)) - if s.APIServerConfig.AutoAttachLocalClient { + if s.APIServerConfig.AutoAttachLocalClient && transport == "usbip" { logger.Info("Auto-attach is enabled, checking prerequisites...") if !api.CheckAutoAttachPrerequisites(s.APIServerConfig.AutoAttachWindowsNative, logger) { logger.Warn("Auto-attach prerequisites not met") @@ -126,19 +170,53 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger logger.Error("failed to start API server", "error", err) return err } + if s.ready != nil { + s.ready() + } select { case <-ctx.Done(): if apiSrv != nil { apiSrv.Close() } - _ = usbSrv.Close() - _ = <-usbErrCh // nolint + if transport == "usbip" { + _ = usbSrv.Close() + _ = <-usbErrCh // nolint + } return nil case err := <-usbErrCh: if apiSrv != nil { apiSrv.Close() } return err + case err := <-nativeDone(nativeSession): + if apiSrv != nil { + apiSrv.Close() + } + if err == nil && ctx.Err() == nil { + return errors.New("native UDE transport stopped unexpectedly") + } + return err + } +} + +func applyTransportAPISecurityPolicy(transport string, config *api.ServerConfig) { + // The native broker owns local kernel topology and live controller streams. + // It must never inherit the historical unauthenticated-localhost exemption, + // particularly when the broker is eventually hosted as LocalSystem. + if transport == "native-ude" { + config.RequireLocalHostAuth = true + } +} + +func logGeneratedAPICredential(logger *slog.Logger, path string) { + logger.Info("Generated API server credential", "path", path) + logger.Info("API clients must authenticate with the credential stored in that file") +} + +func nativeDone(session nativeUDETransport) <-chan error { + if session == nil { + return nil } + return session.Done() } diff --git a/internal/cmd/server_security_test.go b/internal/cmd/server_security_test.go new file mode 100644 index 00000000..28e7912c --- /dev/null +++ b/internal/cmd/server_security_test.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "bytes" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApplyTransportAPISecurityPolicy(t *testing.T) { + tests := []struct { + name string + transport string + initial bool + expected bool + }{ + {name: "native forces local authentication", transport: "native-ude", expected: true}, + {name: "usbip preserves explicit local opt-out", transport: "usbip", expected: false}, + {name: "usbip preserves local authentication", transport: "usbip", initial: true, expected: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + config := api.ServerConfig{RequireLocalHostAuth: test.initial} + applyTransportAPISecurityPolicy(test.transport, &config) + assert.Equal(t, test.expected, config.RequireLocalHostAuth) + }) + } +} + +func TestGeneratedAPICredentialLogDoesNotExposeSecret(t *testing.T) { + const secret = "do-not-print-this-api-secret" + keyPath := filepath.Join(t.TempDir(), "viiper.key.txt") + require.NoError(t, os.WriteFile(keyPath, []byte(secret), 0o600)) + + var output bytes.Buffer + logger := slog.New(slog.NewTextHandler(&output, nil)) + logGeneratedAPICredential(logger, keyPath) + + assert.Contains(t, output.String(), keyPath) + assert.NotContains(t, output.String(), secret) +} diff --git a/internal/cmd/service.go b/internal/cmd/service.go new file mode 100644 index 00000000..d8b52c00 --- /dev/null +++ b/internal/cmd/service.go @@ -0,0 +1,9 @@ +package cmd + +// ServiceCommand hosts the native UDE broker under the Windows Service +// Control Manager. It is intentionally a distinct command from Server so an +// interactive VIIPER instance can never accidentally claim the privileged +// native driver session. +type ServiceCommand struct { + Server `embed:""` +} diff --git a/internal/cmd/service_other.go b/internal/cmd/service_other.go new file mode 100644 index 00000000..84e69334 --- /dev/null +++ b/internal/cmd/service_other.go @@ -0,0 +1,14 @@ +//go:build !windows + +package cmd + +import ( + "errors" + "log/slog" + + "github.com/Alia5/VIIPER/internal/log" +) + +func (c *ServiceCommand) Run(_ *slog.Logger, _ log.RawLogger) error { + return errors.New("the VIIPER native broker service is available only on Windows") +} diff --git a/internal/cmd/service_windows.go b/internal/cmd/service_windows.go new file mode 100644 index 00000000..64f00754 --- /dev/null +++ b/internal/cmd/service_windows.go @@ -0,0 +1,156 @@ +//go:build windows + +package cmd + +import ( + "context" + "errors" + "fmt" + "log/slog" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/Alia5/VIIPER/internal/log" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" +) + +const NativeBrokerServiceName = "VIIPERNativeBroker" + +const serviceStopTimeout = 30 * time.Second + +type nativeBrokerService struct { + run func(context.Context, func()) error + logger *slog.Logger +} + +func (c *ServiceCommand) Run(logger *slog.Logger, rawLogger log.RawLogger) error { + isService, err := svc.IsWindowsService() + if err != nil { + return fmt.Errorf("detect Windows service context: %w", err) + } + if !isService { + return errors.New("the VIIPER native broker service command may only be started by Windows Service Control Manager") + } + if !strings.EqualFold(strings.TrimSpace(c.Transport), "native-ude") { + return fmt.Errorf("the VIIPER native broker service requires --transport native-ude, got %q", c.Transport) + } + if strings.TrimSpace(c.KeyFile) == "" { + path, pathErr := nativeServiceKeyFilePath() + if pathErr != nil { + return pathErr + } + c.KeyFile = path + } + executable, err := currentExecutable() + if err != nil { + return fmt.Errorf("resolve native broker service image: %w", err) + } + if err := admitNativeBrokerServiceStartup(executable, c.KeyFile); err != nil { + return fmt.Errorf("native broker startup admission rejected: %w", err) + } + c.serviceMode = true + handler := &nativeBrokerService{logger: logger, run: func(ctx context.Context, ready func()) error { + c.ready = ready + return c.StartServer(ctx, logger, rawLogger) + }} + return svc.Run(NativeBrokerServiceName, handler) +} + +func nativeServiceKeyFilePath() (string, error) { + programData, err := windows.KnownFolderPath(windows.FOLDERID_ProgramData, windows.KF_FLAG_DEFAULT) + if err != nil { + return "", fmt.Errorf("resolve ProgramData known folder: %w", err) + } + return filepath.Join(filepath.Clean(programData), "VIIPER", keyFileName), nil +} + +func (s *nativeBrokerService) Execute( + _ []string, + requests <-chan svc.ChangeRequest, + changes chan<- svc.Status, +) (bool, uint32) { + changes <- svc.Status{State: svc.StartPending, WaitHint: 15_000} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ready := make(chan struct{}) + var readyOnce sync.Once + done := make(chan error, 1) + go func() { + done <- s.run(ctx, func() { readyOnce.Do(func() { close(ready) }) }) + }() + + running := svc.Status{ + State: svc.Running, + Accepts: svc.AcceptStop | svc.AcceptShutdown, + } + starting := svc.Status{State: svc.StartPending, WaitHint: 15_000, CheckPoint: 1} + for { + select { + case <-ready: + changes <- running + goto Running + case err := <-done: + changes <- svc.Status{State: svc.StopPending, WaitHint: 1_000} + if err != nil { + s.logFailure(err) + return true, 1 + } + return true, 3 + case request := <-requests: + switch request.Cmd { + case svc.Interrogate: + starting.CheckPoint++ + changes <- starting + case svc.Stop, svc.Shutdown: + changes <- svc.Status{State: svc.StopPending, WaitHint: uint32(serviceStopTimeout / time.Millisecond)} + cancel() + return waitForServiceStop(done) + } + } + } + +Running: + for { + select { + case err := <-done: + changes <- svc.Status{State: svc.StopPending, WaitHint: 1_000} + if err != nil { + s.logFailure(err) + return true, 1 + } + return false, 0 + case request := <-requests: + switch request.Cmd { + case svc.Interrogate: + changes <- running + case svc.Stop, svc.Shutdown: + changes <- svc.Status{State: svc.StopPending, WaitHint: uint32(serviceStopTimeout / time.Millisecond)} + cancel() + return waitForServiceStop(done) + } + } + } +} + +func (s *nativeBrokerService) logFailure(err error) { + if err != nil && s.logger != nil { + s.logger.Error("VIIPER native broker stopped unexpectedly", "error", err) + } +} + +func waitForServiceStop(done <-chan error) (bool, uint32) { + timer := time.NewTimer(serviceStopTimeout) + defer timer.Stop() + select { + case err := <-done: + if err != nil { + return true, 1 + } + return false, 0 + case <-timer.C: + return true, 2 + } +} diff --git a/internal/cmd/service_windows_test.go b/internal/cmd/service_windows_test.go new file mode 100644 index 00000000..b8c6f7fd --- /dev/null +++ b/internal/cmd/service_windows_test.go @@ -0,0 +1,169 @@ +//go:build windows + +package cmd + +import ( + "bytes" + "context" + "errors" + "log/slog" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" +) + +func TestNativeServiceKeyFileUsesMachineData(t *testing.T) { + t.Setenv("ProgramData", `C:\Users\attacker\redirected`) + got, err := nativeServiceKeyFilePath() + if err != nil { + t.Fatal(err) + } + programData, err := windows.KnownFolderPath(windows.FOLDERID_ProgramData, windows.KF_FLAG_DEFAULT) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(programData, "VIIPER", keyFileName) + if got != want { + t.Fatalf("key path=%q want=%q", got, want) + } +} + +func TestNativeServiceStopsCooperatively(t *testing.T) { + started := make(chan struct{}) + stopped := make(chan struct{}) + handler := &nativeBrokerService{run: func(ctx context.Context, ready func()) error { + close(started) + ready() + <-ctx.Done() + close(stopped) + return nil + }} + requests := make(chan svc.ChangeRequest, 1) + changes := make(chan svc.Status, 8) + result := make(chan struct { + specific bool + code uint32 + }, 1) + go func() { + specific, code := handler.Execute(nil, requests, changes) + result <- struct { + specific bool + code uint32 + }{specific, code} + }() + + waitForServiceState(t, changes, svc.StartPending) + waitForServiceState(t, changes, svc.Running) + <-started + requests <- svc.ChangeRequest{Cmd: svc.Stop} + waitForServiceState(t, changes, svc.StopPending) + <-stopped + + select { + case got := <-result: + if got.specific || got.code != 0 { + t.Fatalf("service result=(specific=%v code=%d), want clean stop", got.specific, got.code) + } + case <-time.After(2 * time.Second): + t.Fatal("service did not stop after cancellation") + } +} + +func TestNativeServiceDoesNotReportRunningBeforeBrokerReady(t *testing.T) { + releaseReady := make(chan struct{}) + handler := &nativeBrokerService{run: func(ctx context.Context, ready func()) error { + select { + case <-releaseReady: + ready() + case <-ctx.Done(): + return ctx.Err() + } + <-ctx.Done() + return nil + }} + requests := make(chan svc.ChangeRequest, 1) + changes := make(chan svc.Status, 8) + result := make(chan uint32, 1) + go func() { + _, code := handler.Execute(nil, requests, changes) + result <- code + }() + + waitForServiceState(t, changes, svc.StartPending) + select { + case got := <-changes: + t.Fatalf("service reported state %v before broker readiness", got.State) + case <-time.After(50 * time.Millisecond): + } + close(releaseReady) + waitForServiceState(t, changes, svc.Running) + requests <- svc.ChangeRequest{Cmd: svc.Stop} + waitForServiceState(t, changes, svc.StopPending) + if code := <-result; code != 0 { + t.Fatalf("service exit code=%d want=0", code) + } +} + +func TestNativeServiceReportsUnexpectedBrokerFailure(t *testing.T) { + var records bytes.Buffer + handler := &nativeBrokerService{ + logger: slog.New(slog.NewTextHandler(&records, nil)), + run: func(context.Context, func()) error { + return errors.New("broker failed") + }, + } + changes := make(chan svc.Status, 8) + specific, code := handler.Execute(nil, make(chan svc.ChangeRequest), changes) + if !specific || code != 1 { + t.Fatalf("service result=(specific=%v code=%d), want service-specific failure 1", specific, code) + } + if logged := records.String(); !strings.Contains(logged, "VIIPER native broker stopped unexpectedly") || + !strings.Contains(logged, "broker failed") { + t.Fatalf("service failure log=%q", logged) + } +} + +func TestNativeServiceLogsFailureAfterReportingRunning(t *testing.T) { + var records bytes.Buffer + release := make(chan struct{}) + handler := &nativeBrokerService{ + logger: slog.New(slog.NewTextHandler(&records, nil)), + run: func(_ context.Context, ready func()) error { + ready() + <-release + return errors.New("live transport failed") + }, + } + changes := make(chan svc.Status, 8) + result := make(chan uint32, 1) + go func() { + _, code := handler.Execute(nil, make(chan svc.ChangeRequest), changes) + result <- code + }() + waitForServiceState(t, changes, svc.StartPending) + waitForServiceState(t, changes, svc.Running) + close(release) + waitForServiceState(t, changes, svc.StopPending) + if code := <-result; code != 1 { + t.Fatalf("live service failure exit code=%d want=1", code) + } + if logged := records.String(); !strings.Contains(logged, "live transport failed") { + t.Fatalf("live service failure log=%q", logged) + } +} + +func waitForServiceState(t *testing.T, changes <-chan svc.Status, want svc.State) { + t.Helper() + select { + case got := <-changes: + if got.State != want { + t.Fatalf("service state=%v want=%v", got.State, want) + } + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for service state %v", want) + } +} diff --git a/internal/codegen/scanner/dtos_test.go b/internal/codegen/scanner/dtos_test.go index b6588790..f5611d65 100644 --- a/internal/codegen/scanner/dtos_test.go +++ b/internal/codegen/scanner/dtos_test.go @@ -20,13 +20,14 @@ func TestScanDTOs(t *testing.T) { // Expected DTOs expectedDTOs := map[string]bool{ - "APIError": true, - "BusListResponse": true, - "BusCreateResponse": true, - "BusRemoveResponse": true, - "Device": true, - "DevicesListResponse": true, - "DeviceRemoveResponse": true, + "APIError": true, + "BusListResponse": true, + "BusCreateResponse": true, + "BusRemoveResponse": true, + "Device": true, + "DevicesListResponse": true, + "DeviceRemoveResponse": true, + "NativeUDEDeviceRemoveRequest": true, } foundDTOs := make(map[string]bool) diff --git a/internal/codegen/scanner/payload.go b/internal/codegen/scanner/payload.go index fade2387..50f08ce5 100644 --- a/internal/codegen/scanner/payload.go +++ b/internal/codegen/scanner/payload.go @@ -86,10 +86,11 @@ func scanPayloadFile(filePath string, acc map[string]PayloadInfo) error { numericBitSize := "" jsonTargetType := "" - // Walk body - also track local variable declarations + // Collect local variable declarations in a separate pass so payload type + // inference is independent of AST visitation order (including variables + // declared inside the returned HandlerFunc literal). localVarTypes := make(map[string]string) ast.Inspect(funcDecl.Body, func(nn ast.Node) bool { - // Track local variable declarations (var x Type) if decl, ok := nn.(*ast.DeclStmt); ok { if gen, ok := decl.Decl.(*ast.GenDecl); ok && gen.Tok == token.VAR { for _, spec := range gen.Specs { @@ -101,6 +102,10 @@ func scanPayloadFile(filePath string, acc map[string]PayloadInfo) error { } } } + return true + }) + + ast.Inspect(funcDecl.Body, func(nn ast.Node) bool { // If statements for empty/non-empty checks if ifs, ok := nn.(*ast.IfStmt); ok { diff --git a/internal/codegen/scanner/routes_test.go b/internal/codegen/scanner/routes_test.go index 6ec9116f..e8595d60 100644 --- a/internal/codegen/scanner/routes_test.go +++ b/internal/codegen/scanner/routes_test.go @@ -29,6 +29,7 @@ func TestScannerSuite(t *testing.T) { "bus/{id}/list": true, "bus/{id}/add": true, "bus/{id}/remove": true, + "bus/{id}/remove-native": true, "bus/{busId}/{deviceid}": true, } found := make(map[string]bool) @@ -76,6 +77,14 @@ func TestScannerSuite(t *testing.T) { assertPayload("bus/create", PayloadNumeric, false) assertPayload("bus/remove", PayloadNumeric, true) assertPayload("bus/{id}/remove", PayloadString, true) + assertPayload("bus/{id}/remove-native", PayloadJSON, true) + for _, route := range enriched { + if route.Path == "bus/{id}/remove-native" && + route.Payload.RawType != "NativeUDEDeviceRemoveRequest" { + t.Errorf("native remove payload type=%q want NativeUDEDeviceRemoveRequest", + route.Payload.RawType) + } + } assertPayload("bus/list", PayloadNone, false) assertPayload("bus/{id}/list", PayloadNone, false) }, diff --git a/internal/config/config.go b/internal/config/config.go index 1a4215cd..5c06ae53 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,10 +27,13 @@ type CLI struct { Log `embed:"" prefix:"log."` codegenCommand - Server cmd.Server `cmd:"" help:"Start the VIIPER USB-IP server" default:""` - Proxy cmd.Proxy `cmd:"" help:"Start the VIIPER USB-IP proxy"` + Server cmd.Server `cmd:"" help:"Start the VIIPER USB-IP server" default:""` + Service cmd.ServiceCommand `cmd:"" help:"Run the managed Windows native UDE broker service" hidden:""` + Proxy cmd.Proxy `cmd:"" help:"Start the VIIPER USB-IP proxy"` - Config cmd.ConfigCommand `cmd:"" help:"Manage configuration files"` - Install cmd.Install `cmd:"" help:"Add the current VIIPER executable to system startup and runs it (creates a Systemd service on Linux)"` - Uninstall cmd.Uninstall `cmd:"" help:"Remove any VIIPER system startup configuration / Systemd service"` + Config cmd.ConfigCommand `cmd:"" help:"Manage configuration files"` + Install cmd.Install `cmd:"" help:"Add the current VIIPER executable to system startup and runs it (creates a Systemd service on Linux)"` + Uninstall cmd.Uninstall `cmd:"" help:"Remove any VIIPER system startup configuration / Systemd service"` + NativePackageInstall cmd.NativePackageInstall `cmd:"" name:"native-package-install" help:"Install a verified native UDE package and broker transactionally" hidden:""` + NativePackageBrokerCommit cmd.NativePackageBrokerCommit `cmd:"" name:"native-package-broker-commit" help:"Commit the broker inside an active native package transaction" hidden:""` } diff --git a/internal/log/logging.go b/internal/log/logging.go index 13f69dce..2b7ba059 100644 --- a/internal/log/logging.go +++ b/internal/log/logging.go @@ -7,13 +7,97 @@ package log import ( "context" + "errors" "fmt" "io" "log/slog" "os" "strings" + "sync" ) +const maxLogFileBytes int64 = 16 << 20 + +type boundedFile struct { + mu sync.Mutex + file *os.File + path string + mode os.FileMode + size int64 + maxBytes int64 +} + +func openBoundedFile(path string, mode os.FileMode, maxBytes int64) (*boundedFile, error) { + if maxBytes <= 0 { + return nil, errors.New("bounded log file size must be positive") + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_RDWR, mode) + if err != nil { + return nil, err + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, err + } + return &boundedFile{ + file: file, path: path, mode: mode, size: info.Size(), maxBytes: maxBytes, + }, nil +} + +// OpenBoundedFile opens a durable append-only session log. Recovery restarts +// retain the previous failure, while a fixed-size wrap prevents unattended +// trace logging from consuming the machine's disk. +func OpenBoundedFile(path string, mode os.FileMode) (io.WriteCloser, error) { + return openBoundedFile(path, mode, maxLogFileBytes) +} + +func (f *boundedFile) Write(payload []byte) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.file == nil { + return 0, os.ErrClosed + } + originalLength := len(payload) + if int64(len(payload)) > f.maxBytes { + payload = payload[len(payload)-int(f.maxBytes):] + } + if f.size+int64(len(payload)) > f.maxBytes { + if err := f.file.Close(); err != nil { + f.file = nil + return 0, err + } + file, err := os.OpenFile( + f.path, os.O_CREATE|os.O_TRUNC|os.O_APPEND|os.O_RDWR, f.mode) + if err != nil { + f.file = nil + return 0, err + } + f.file = file + f.size = 0 + } + written, err := f.file.Write(payload) + f.size += int64(written) + if err != nil { + return written, err + } + if written != len(payload) { + return written, io.ErrShortWrite + } + return originalLength, nil +} + +func (f *boundedFile) Close() error { + f.mu.Lock() + defer f.mu.Unlock() + if f.file == nil { + return nil + } + err := f.file.Close() + f.file = nil + return err +} + // LevelTrace defines a custom slog level below Debug for very verbose output. const LevelTrace slog.Level = -8 @@ -46,7 +130,7 @@ func SetupLogger(logLevel, logFile string) (*slog.Logger, []io.Closer, error) { handlers = append(handlers, LevelFilter{pass: func(l slog.Level) bool { return l >= slog.LevelError }, h: stderrHandler}) var closeFiles []io.Closer if logFile != "" { - f, err := os.OpenFile(logFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + f, err := OpenBoundedFile(logFile, 0o644) if err != nil { return nil, nil, err } diff --git a/internal/log/logging_test.go b/internal/log/logging_test.go new file mode 100644 index 00000000..32ca2c16 --- /dev/null +++ b/internal/log/logging_test.go @@ -0,0 +1,58 @@ +package log + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBoundedFileAppendsAcrossRecoveryRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "broker.log") + if err := os.WriteFile(path, []byte("first failure\n"), 0o600); err != nil { + t.Fatal(err) + } + file, err := openBoundedFile(path, 0o600, 1024) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = file.Close() }) + if _, err = file.Write([]byte("recovered\n")); err != nil { + t.Fatal(err) + } + if err = file.Close(); err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if got := string(contents); got != "first failure\nrecovered\n" { + t.Fatalf("appended log=%q", got) + } +} + +func TestBoundedFileWrapsBeforeDiskLimit(t *testing.T) { + path := filepath.Join(t.TempDir(), "broker.log") + file, err := openBoundedFile(path, 0o600, 16) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = file.Close() }) + if _, err = file.Write([]byte("old-record\n")); err != nil { + t.Fatal(err) + } + if _, err = file.Write([]byte("new-record\n")); err != nil { + t.Fatal(err) + } + if err = file.Close(); err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if got := string(contents); got != "new-record\n" || strings.Contains(got, "old") { + t.Fatalf("wrapped log=%q", got) + } +} diff --git a/internal/server/api/auth/conn.go b/internal/server/api/auth/conn.go index cbc8680d..ee90d38e 100644 --- a/internal/server/api/auth/conn.go +++ b/internal/server/api/auth/conn.go @@ -1,10 +1,12 @@ package auth import ( - "bytes" "crypto/cipher" "encoding/binary" + "errors" + "fmt" "io" + "math" "net" "sync" @@ -13,74 +15,273 @@ import ( type Conn struct { net.Conn - aead cipher.AEAD - sendCtr uint64 - recvBuf bytes.Buffer - mu sync.Mutex + aead cipher.AEAD + sendNoncePrefix uint32 + sendCtr uint64 + recvNoncePrefix uint32 + recvCtr uint64 + sendBuf []byte + recvHeader [4]byte + recvHeaderRead int + recvPacket []byte + recvPacketRead int + recvRecordLength int + recvPlain []byte + sendMu sync.Mutex + recvMu sync.Mutex + sendErr error + recvErr error + sendExhausted bool + recvExhausted bool } -const maxPacketSize = 2 * 1024 * 1024 // 2 MB +const ( + maxPacketSize = 2 * 1024 * 1024 // 2 MB -func WrapConn(conn net.Conn, sessionKey []byte) (net.Conn, error) { + // ChaCha20-Poly1305 uses a 96-bit nonce. Split it into a fixed + // direction domain and a monotonically increasing record counter so the + // client and server never use the same nonce with their shared session + // key. cipher.AEAD requires every nonce to be unique for a given key. + clientNoncePrefix uint32 = 0 + serverNoncePrefix uint32 = 1 +) + +var ( + errPacketTooLarge = errors.New("authenticated stream packet is too large") + errPacketTooShort = errors.New("authenticated stream packet is too short") + errNonceExhausted = errors.New("authenticated stream nonce space is exhausted") + errRecvExhausted = errors.New("authenticated stream receive nonce space is exhausted") + errInvalidWrite = errors.New("authenticated stream transport returned an invalid write count") +) + +// WrapClientConn authenticates conn as the client half of a VIIPER stream. +// A client wrapper must only be paired with a server wrapper using the same +// session key; the roles assign disjoint send-nonce domains. +func WrapClientConn(conn net.Conn, sessionKey []byte) (net.Conn, error) { + return wrapConn(conn, sessionKey, clientNoncePrefix, serverNoncePrefix) +} + +// WrapServerConn authenticates conn as the server half of a VIIPER stream. +// A server wrapper must only be paired with a client wrapper using the same +// session key; the roles assign disjoint send-nonce domains. +func WrapServerConn(conn net.Conn, sessionKey []byte) (net.Conn, error) { + return wrapConn(conn, sessionKey, serverNoncePrefix, clientNoncePrefix) +} + +func wrapConn(conn net.Conn, sessionKey []byte, sendNoncePrefix, recvNoncePrefix uint32) (net.Conn, error) { aead, err := chacha20poly1305.New(sessionKey) if err != nil { return nil, err } - return &Conn{Conn: conn, aead: aead}, nil + return &Conn{ + Conn: conn, + aead: aead, + sendNoncePrefix: sendNoncePrefix, + recvNoncePrefix: recvNoncePrefix, + }, nil } -func (s *Conn) Write(p []byte) (int, error) { - s.mu.Lock() - defer s.mu.Unlock() - - nonce := make([]byte, 12) - binary.BigEndian.PutUint64(nonce[4:], s.sendCtr) - s.sendCtr++ - - ct := s.aead.Seal(nil, nonce, p, nil) - length := uint32(len(nonce) + len(ct)) +func (s *Conn) Close() error { + err := s.Conn.Close() + // Closing the transport first releases any Read or Write currently holding + // its lane lock. Once both lanes join, no cipher or record storage can still + // be in use, so clear it before making subsequent calls fail closed. + s.sendMu.Lock() + s.recvMu.Lock() + clear(s.sendBuf[:cap(s.sendBuf)]) + clear(s.recvHeader[:]) + clear(s.recvPacket[:cap(s.recvPacket)]) + s.sendBuf = nil + s.recvPacket = nil + s.recvPlain = nil + s.recvHeaderRead = 0 + s.recvPacketRead = 0 + s.recvRecordLength = 0 + s.sendNoncePrefix = 0 + s.sendCtr = 0 + s.recvNoncePrefix = 0 + s.recvCtr = 0 + s.sendExhausted = false + s.recvExhausted = false + s.aead = nil + if s.sendErr == nil { + s.sendErr = net.ErrClosed + } + if s.recvErr == nil { + s.recvErr = net.ErrClosed + } + s.recvMu.Unlock() + s.sendMu.Unlock() + return err +} - var hdr [4]byte - binary.BigEndian.PutUint32(hdr[:], length) +func (s *Conn) Write(p []byte) (int, error) { + s.sendMu.Lock() + defer s.sendMu.Unlock() - if i, err := s.Conn.Write(hdr[:]); err != nil { - return i, err + if s.sendErr != nil { + return 0, s.sendErr } - if i, err := s.Conn.Write(nonce); err != nil { - return i, err + if s.sendExhausted { + return 0, errNonceExhausted } - if i, err := s.Conn.Write(ct); err != nil { - return i, err + nonceSize := s.aead.NonceSize() + recordOverhead := nonceSize + s.aead.Overhead() + if len(p) > maxPacketSize-recordOverhead { + return 0, errPacketTooLarge + } + recordLength := recordOverhead + len(p) + totalLength := 4 + recordLength + if cap(s.sendBuf) < totalLength { + s.sendBuf = make([]byte, totalLength) + } + record := s.sendBuf[:4+nonceSize] + nonce := record[4:] + binary.BigEndian.PutUint32(nonce[:4], s.sendNoncePrefix) + binary.BigEndian.PutUint64(nonce[4:], s.sendCtr) + record = s.aead.Seal(record, nonce, p, nil) + binary.BigEndian.PutUint32(record[:4], uint32(len(record)-4)) + + for written := 0; written < len(record); { + remaining := record[written:] + n, err := s.Conn.Write(remaining) + if n < 0 || n > len(remaining) { + s.sendErr = errInvalidWrite + _ = s.Conn.Close() + return 0, errInvalidWrite + } + written += n + if err != nil { + if written == len(record) { + s.advanceSendCounter() + s.sendErr = err + _ = s.Conn.Close() + return len(p), err + } + if written > 0 { + s.sendErr = err + _ = s.Conn.Close() + } + return 0, err + } + if n == 0 { + s.sendErr = io.ErrNoProgress + _ = s.Conn.Close() + return 0, io.ErrNoProgress + } } + s.advanceSendCounter() return len(p), nil } +func (s *Conn) advanceSendCounter() { + if s.sendCtr == math.MaxUint64 { + s.sendExhausted = true + return + } + s.sendCtr++ +} + func (s *Conn) Read(p []byte) (int, error) { - if s.recvBuf.Len() == 0 { - var hdr [4]byte - if i, err := io.ReadFull(s.Conn, hdr[:]); err != nil { - return i, err + s.recvMu.Lock() + defer s.recvMu.Unlock() + + if len(p) == 0 { + return 0, nil + } + for len(s.recvPlain) == 0 { + if s.recvErr != nil { + return 0, s.recvErr } - length := binary.BigEndian.Uint32(hdr[:]) - if length > maxPacketSize { - return 0, io.ErrUnexpectedEOF + if err := s.readRecord(); err != nil { + return 0, err } + } + n := copy(p, s.recvPlain) + s.recvPlain = s.recvPlain[n:] + return n, nil +} - pkt := make([]byte, length) - if i, err := io.ReadFull(s.Conn, pkt); err != nil { - return i, err +func (s *Conn) readRecord() error { + if s.recvHeaderRead < len(s.recvHeader) { + n, err := io.ReadFull(s.Conn, s.recvHeader[s.recvHeaderRead:]) + s.recvHeaderRead += n + if err != nil { + // The bytes belong to authenticated framing, not to the caller's + // plaintext buffer. Keep the offset so a cleared network deadline can + // resume this record without parsing ciphertext as a new header. + return err } + } - nonce := pkt[:12] - ct := pkt[12:] + if s.recvRecordLength == 0 { + wireLength := binary.BigEndian.Uint32(s.recvHeader[:]) + minimumLength := uint32(s.aead.NonceSize() + s.aead.Overhead()) + switch { + case wireLength < minimumLength: + s.recvErr = errPacketTooShort + return s.recvErr + case wireLength > maxPacketSize: + s.recvErr = errPacketTooLarge + return s.recvErr + } + s.recvRecordLength = int(wireLength) + if cap(s.recvPacket) < s.recvRecordLength { + s.recvPacket = make([]byte, s.recvRecordLength) + } + s.recvPacket = s.recvPacket[:s.recvRecordLength] + } - pt, err := s.aead.Open(nil, nonce, ct, nil) + if s.recvPacketRead < s.recvRecordLength { + n, err := io.ReadFull(s.Conn, s.recvPacket[s.recvPacketRead:]) + s.recvPacketRead += n if err != nil { - return 0, err + return err } + } + + nonceSize := s.aead.NonceSize() + nonce := s.recvPacket[:nonceSize] + ct := s.recvPacket[nonceSize:] + noncePrefix := binary.BigEndian.Uint32(nonce[:4]) + nonceCounter := binary.BigEndian.Uint64(nonce[4:]) + + // AEAD permits dst and ciphertext to overlap exactly. Decrypting in place + // keeps one reusable record slab per authenticated stream instead of + // allocating ciphertext and plaintext for every controller/media frame. + // recvPlain is fully consumed before the slab is reused. + pt, err := s.aead.Open(ct[:0], nonce, ct, nil) + if err != nil { + s.recvErr = err + return err + } + if err = s.validateReceiveNonce(noncePrefix, nonceCounter); err != nil { + clear(pt) + s.recvErr = err + return err + } + s.recvPlain = pt + s.recvHeaderRead = 0 + s.recvPacketRead = 0 + s.recvRecordLength = 0 + return nil +} - s.recvBuf.Write(pt) +func (s *Conn) validateReceiveNonce(prefix uint32, counter uint64) error { + if prefix != s.recvNoncePrefix { + return fmt.Errorf("authenticated stream nonce direction=%d, want %d", prefix, s.recvNoncePrefix) + } + if s.recvExhausted { + return errRecvExhausted + } + if counter != s.recvCtr { + return fmt.Errorf("authenticated stream nonce counter=%d, want %d", counter, s.recvCtr) + } + if s.recvCtr == math.MaxUint64 { + s.recvExhausted = true + } else { + s.recvCtr++ } - return s.recvBuf.Read(p) + return nil } diff --git a/internal/server/api/auth/conn_internal_test.go b/internal/server/api/auth/conn_internal_test.go new file mode 100644 index 00000000..118b9144 --- /dev/null +++ b/internal/server/api/auth/conn_internal_test.go @@ -0,0 +1,306 @@ +package auth + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "math" + "net" + "sync" + "testing" + "time" +) + +type internalRecordConn struct { + bytes.Buffer +} + +type loopingInternalConn struct { + record []byte + offset int +} + +func (*internalRecordConn) Close() error { return nil } +func (*internalRecordConn) LocalAddr() net.Addr { return internalTestAddr("local") } +func (*internalRecordConn) RemoteAddr() net.Addr { return internalTestAddr("remote") } +func (*internalRecordConn) SetDeadline(time.Time) error { return nil } +func (*internalRecordConn) SetReadDeadline(time.Time) error { return nil } +func (*internalRecordConn) SetWriteDeadline(time.Time) error { return nil } + +func (c *loopingInternalConn) Read(p []byte) (int, error) { + if c.offset == len(c.record) { + c.offset = 0 + } + n := copy(p, c.record[c.offset:]) + c.offset += n + return n, nil +} +func (*loopingInternalConn) Write(p []byte) (int, error) { return len(p), nil } +func (*loopingInternalConn) Close() error { return nil } +func (*loopingInternalConn) LocalAddr() net.Addr { return internalTestAddr("local") } +func (*loopingInternalConn) RemoteAddr() net.Addr { return internalTestAddr("remote") } +func (*loopingInternalConn) SetDeadline(time.Time) error { return nil } +func (*loopingInternalConn) SetReadDeadline(time.Time) error { + return nil +} +func (*loopingInternalConn) SetWriteDeadline(time.Time) error { + return nil +} + +type internalTestAddr string + +func (a internalTestAddr) Network() string { return string(a) } +func (a internalTestAddr) String() string { return string(a) } + +type blockingInternalConn struct { + readStarted chan struct{} + writeStarted chan struct{} + closed chan struct{} + readOnce sync.Once + writeOnce sync.Once + closeOnce sync.Once +} + +func (c *blockingInternalConn) Read([]byte) (int, error) { + c.readOnce.Do(func() { close(c.readStarted) }) + <-c.closed + return 0, net.ErrClosed +} + +func (c *blockingInternalConn) Write([]byte) (int, error) { + c.writeOnce.Do(func() { close(c.writeStarted) }) + <-c.closed + return 0, net.ErrClosed +} + +func (c *blockingInternalConn) Close() error { + c.closeOnce.Do(func() { close(c.closed) }) + return nil +} +func (*blockingInternalConn) LocalAddr() net.Addr { return internalTestAddr("local") } +func (*blockingInternalConn) RemoteAddr() net.Addr { return internalTestAddr("remote") } +func (*blockingInternalConn) SetDeadline(time.Time) error { return nil } +func (*blockingInternalConn) SetReadDeadline(time.Time) error { return nil } +func (*blockingInternalConn) SetWriteDeadline(time.Time) error { return nil } + +func TestConnUsesFinalCounterNonceExactlyOnceBeforeExhaustion(t *testing.T) { + key, err := DeriveKey("nonce-exhaustion") + if err != nil { + t.Fatal(err) + } + raw := &internalRecordConn{} + wrapper, err := WrapClientConn(raw, key) + if err != nil { + t.Fatal(err) + } + conn := wrapper.(*Conn) + conn.sendCtr = math.MaxUint64 + payload := []byte("final nonce") + if written, writeErr := conn.Write(payload); written != len(payload) || writeErr != nil { + t.Fatalf("final nonce write=(%d, %v), want (%d, nil)", written, writeErr, len(payload)) + } + wire := append([]byte(nil), raw.Bytes()...) + if got := binary.BigEndian.Uint64(wire[8:16]); got != math.MaxUint64 { + t.Fatalf("final nonce counter=%d want=%d", got, uint64(math.MaxUint64)) + } + if written, writeErr := conn.Write([]byte("must not wrap")); written != 0 || !errors.Is(writeErr, errNonceExhausted) { + t.Fatalf("exhausted write=(%d, %v), want (0, %v)", written, writeErr, errNonceExhausted) + } + if !bytes.Equal(raw.Bytes(), wire) { + t.Fatal("nonce-exhausted write emitted wire data") + } + + receiverWrapper, err := WrapServerConn(raw, key) + if err != nil { + t.Fatal(err) + } + receiver := receiverWrapper.(*Conn) + receiver.recvCtr = math.MaxUint64 + decoded := make([]byte, len(payload)) + if _, err = io.ReadFull(receiverWrapper, decoded); err != nil { + t.Fatal(err) + } + if !bytes.Equal(decoded, payload) { + t.Fatalf("final nonce plaintext=%q want=%q", decoded, payload) + } + _, _ = raw.Buffer.Write(wire) + if n, readErr := receiver.Read(decoded[:1]); n != 0 || !errors.Is(readErr, errRecvExhausted) { + t.Fatalf("receive after final nonce=(%d, %v), want (0, %v)", n, readErr, errRecvExhausted) + } +} + +func BenchmarkConnReadAuthenticatedRecord(b *testing.B) { + key, err := DeriveKey("authenticated-read-benchmark") + if err != nil { + b.Fatal(err) + } + payload := make([]byte, 512) + wire := &internalRecordConn{} + sender, err := WrapClientConn(wire, key) + if err != nil { + b.Fatal(err) + } + if _, err = sender.Write(payload); err != nil { + b.Fatal(err) + } + raw := &loopingInternalConn{record: append([]byte(nil), wire.Bytes()...)} + wrapper, err := WrapServerConn(raw, key) + if err != nil { + b.Fatal(err) + } + receiver := wrapper.(*Conn) + dst := make([]byte, len(payload)) + b.SetBytes(int64(len(payload))) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + // The transport loops one authenticated counter-zero fixture to isolate + // decrypt/copy cost. Reset only the expected test counter; production + // streams reject this replay. + receiver.recvCtr = 0 + receiver.recvExhausted = false + if _, err = io.ReadFull(receiver, dst); err != nil { + b.Fatal(err) + } + } +} + +func TestConnCloseJoinsLanesAndClearsRecordAndCipherState(t *testing.T) { + key, err := DeriveKey("clear-connection-state") + if err != nil { + t.Fatal(err) + } + raw := &internalRecordConn{} + senderWrapper, err := WrapClientConn(raw, key) + if err != nil { + t.Fatal(err) + } + sender := senderWrapper.(*Conn) + largePayload := bytes.Repeat([]byte{0x5a}, 4096) + smallPayload := []byte("small sensitive controller state") + if _, err = sender.Write(largePayload); err != nil { + t.Fatal(err) + } + if _, err = sender.Write(smallPayload); err != nil { + t.Fatal(err) + } + sendBacking := sender.sendBuf[:cap(sender.sendBuf)] + if err = sender.Close(); err != nil { + t.Fatal(err) + } + if sender.aead != nil || sender.sendBuf != nil || sender.sendNoncePrefix != 0 || sender.sendCtr != 0 { + t.Fatal("close retained send cipher or record state") + } + for i, value := range sendBacking { + if value != 0 { + t.Fatalf("close retained send byte %d=%02x", i, value) + } + } + if _, writeErr := sender.Write([]byte("closed")); !errors.Is(writeErr, net.ErrClosed) { + t.Fatalf("write after close=%v want %v", writeErr, net.ErrClosed) + } + + receiveRaw := &internalRecordConn{} + _, _ = receiveRaw.Buffer.Write(raw.Bytes()) + receiverWrapper, err := WrapServerConn(receiveRaw, key) + if err != nil { + t.Fatal(err) + } + receiver := receiverWrapper.(*Conn) + largeDecoded := make([]byte, len(largePayload)) + if _, err = io.ReadFull(receiver, largeDecoded); err != nil { + t.Fatal(err) + } + if !bytes.Equal(largeDecoded, largePayload) { + t.Fatal("large receive record changed before close") + } + firstSmallByte := make([]byte, 1) + if _, err = receiver.Read(firstSmallByte); err != nil { + t.Fatal(err) + } + if firstSmallByte[0] != smallPayload[0] { + t.Fatalf("small receive prefix=%02x want=%02x", firstSmallByte[0], smallPayload[0]) + } + if len(receiver.recvPlain) == 0 { + t.Fatal("test did not leave plaintext buffered before close") + } + if len(receiver.recvPacket) >= cap(receiver.recvPacket) { + t.Fatalf("test did not shrink receive slab: len=%d cap=%d", len(receiver.recvPacket), cap(receiver.recvPacket)) + } + receiveBacking := receiver.recvPacket[:cap(receiver.recvPacket)] + tailRetainedData := false + for _, value := range receiveBacking[len(receiver.recvPacket):] { + if value != 0 { + tailRetainedData = true + break + } + } + if !tailRetainedData { + t.Fatal("test setup did not retain a large-record tail outside the shrunken receive slice") + } + if err = receiver.Close(); err != nil { + t.Fatal(err) + } + if receiver.aead != nil || receiver.recvPacket != nil || receiver.recvPlain != nil || + receiver.recvNoncePrefix != 0 || receiver.recvCtr != 0 || receiver.recvExhausted { + t.Fatal("close retained receive cipher or record state") + } + for i, value := range receiveBacking { + if value != 0 { + t.Fatalf("close retained receive byte %d=%02x", i, value) + } + } + if _, readErr := receiver.Read(make([]byte, 1)); !errors.Is(readErr, net.ErrClosed) { + t.Fatalf("read after close=%v want %v", readErr, net.ErrClosed) + } +} + +func TestConnCloseUnblocksAndJoinsConcurrentReadAndWrite(t *testing.T) { + key, err := DeriveKey("close-concurrent-lanes") + if err != nil { + t.Fatal(err) + } + raw := &blockingInternalConn{ + readStarted: make(chan struct{}), writeStarted: make(chan struct{}), closed: make(chan struct{}), + } + wrapper, err := WrapClientConn(raw, key) + if err != nil { + t.Fatal(err) + } + readDone := make(chan error, 1) + writeDone := make(chan error, 1) + go func() { + _, readErr := wrapper.Read(make([]byte, 1)) + readDone <- readErr + }() + go func() { + _, writeErr := wrapper.Write([]byte("blocked")) + writeDone <- writeErr + }() + select { + case <-raw.readStarted: + case <-time.After(time.Second): + t.Fatal("read lane did not block in transport") + } + select { + case <-raw.writeStarted: + case <-time.After(time.Second): + t.Fatal("write lane did not block in transport") + } + closeDone := make(chan error, 1) + go func() { closeDone <- wrapper.Close() }() + for name, done := range map[string]<-chan error{"read": readDone, "write": writeDone, "close": closeDone} { + select { + case laneErr := <-done: + if name != "close" && !errors.Is(laneErr, net.ErrClosed) { + t.Fatalf("%s lane error=%v want %v", name, laneErr, net.ErrClosed) + } + if name == "close" && laneErr != nil { + t.Fatalf("close error=%v", laneErr) + } + case <-time.After(time.Second): + t.Fatalf("%s lane did not join", name) + } + } +} diff --git a/internal/server/api/auth/conn_test.go b/internal/server/api/auth/conn_test.go index 7c2153c6..fdc0078e 100644 --- a/internal/server/api/auth/conn_test.go +++ b/internal/server/api/auth/conn_test.go @@ -1,19 +1,927 @@ package auth_test import ( + "bytes" + "encoding/binary" "errors" + "io" "net" + "strings" + "sync" "testing" + "time" "github.com/Alia5/VIIPER/internal/server/api/auth" "github.com/stretchr/testify/assert" + "golang.org/x/crypto/chacha20poly1305" ) +type recordConn struct { + bytes.Buffer + writeCalls int + maxWrite int +} + +type partialFailureConn struct { + recordConn + remaining int + err error + closed bool +} + +type interruptedReadConn struct { + recordConn + beforeError int + err error + interrupted bool +} + +type fullWriteErrorConn struct { + recordConn + err error + closed bool +} + +type firstWriteErrorConn struct { + recordConn + err error + first bool +} + +type zeroProgressConn struct { + recordConn + closed bool +} + +func (c *partialFailureConn) Write(p []byte) (int, error) { + if c.remaining == 0 { + return 0, c.err + } + if len(p) > c.remaining { + p = p[:c.remaining] + } + n, _ := c.recordConn.Write(p) + c.remaining -= n + return n, nil +} + +func (c *partialFailureConn) Close() error { + c.closed = true + return nil +} + +func (c *interruptedReadConn) Read(p []byte) (int, error) { + if c.interrupted { + return c.recordConn.Read(p) + } + if c.beforeError == 0 { + c.interrupted = true + return 0, c.err + } + if len(p) > c.beforeError { + p = p[:c.beforeError] + } + n, _ := c.recordConn.Read(p) + c.beforeError -= n + if c.beforeError == 0 { + c.interrupted = true + return n, c.err + } + return n, nil +} + +func (c *fullWriteErrorConn) Write(p []byte) (int, error) { + n, _ := c.recordConn.Write(p) + return n, c.err +} + +func (c *fullWriteErrorConn) Close() error { + c.closed = true + return nil +} + +func (c *firstWriteErrorConn) Write(p []byte) (int, error) { + if c.first { + c.first = false + return 0, c.err + } + return c.recordConn.Write(p) +} + +func (c *zeroProgressConn) Write([]byte) (int, error) { return 0, nil } + +func (c *zeroProgressConn) Close() error { + c.closed = true + return nil +} + +type discardConn struct{} + +func (discardConn) Read([]byte) (int, error) { return 0, io.EOF } +func (discardConn) Write(p []byte) (int, error) { return len(p), nil } +func (discardConn) Close() error { return nil } +func (discardConn) LocalAddr() net.Addr { return testAddr("local") } +func (discardConn) RemoteAddr() net.Addr { return testAddr("remote") } +func (discardConn) SetDeadline(time.Time) error { return nil } +func (discardConn) SetReadDeadline(time.Time) error { return nil } +func (discardConn) SetWriteDeadline(time.Time) error { return nil } + +type segmentedReadConn struct { + recordConn + segments [][]byte + index int + offset int +} + +func (c *segmentedReadConn) Read(p []byte) (int, error) { + if c.index == len(c.segments) { + return 0, io.EOF + } + segment := c.segments[c.index] + n := copy(p, segment[c.offset:]) + c.offset += n + if c.offset == len(segment) { + c.index++ + c.offset = 0 + } + return n, nil +} + +func (c *recordConn) Write(p []byte) (int, error) { + c.writeCalls++ + if c.maxWrite > 0 && len(p) > c.maxWrite { + p = p[:c.maxWrite] + } + return c.Buffer.Write(p) +} + +func (*recordConn) Close() error { return nil } +func (*recordConn) LocalAddr() net.Addr { return testAddr("local") } +func (*recordConn) RemoteAddr() net.Addr { return testAddr("remote") } +func (*recordConn) SetDeadline(time.Time) error { return nil } +func (*recordConn) SetReadDeadline(time.Time) error { return nil } +func (*recordConn) SetWriteDeadline(time.Time) error { return nil } + +type testAddr string + +func (a testAddr) Network() string { return string(a) } +func (a testAddr) String() string { return string(a) } + +func legacyRolelessRecord(t *testing.T, key, payload []byte, counter uint64) []byte { + t.Helper() + aead, err := chacha20poly1305.New(key) + if err != nil { + t.Fatal(err) + } + nonce := make([]byte, aead.NonceSize()) + binary.BigEndian.PutUint64(nonce[4:], counter) + record := make([]byte, 4, 4+len(nonce)+len(payload)+aead.Overhead()) + record = append(record, nonce...) + record = aead.Seal(record, nonce, payload, nil) + binary.BigEndian.PutUint32(record[:4], uint32(len(record)-4)) + return record +} + +func TestConnNewServerAcceptsLegacyClientWireDomain(t *testing.T) { + key, err := auth.DeriveKey("legacy-client-new-server") + if err != nil { + t.Fatal(err) + } + payload := []byte("legacy client request") + raw := &recordConn{} + _, _ = raw.Buffer.Write(legacyRolelessRecord(t, key, payload, 0)) + server, err := auth.WrapServerConn(raw, key) + if err != nil { + t.Fatal(err) + } + decoded := make([]byte, len(payload)) + if _, err = io.ReadFull(server, decoded); err != nil { + t.Fatal(err) + } + if !bytes.Equal(decoded, payload) { + t.Fatalf("legacy client plaintext=%q want=%q", decoded, payload) + } +} + +func TestConnNewClientRejectsLegacyRolelessServerDomain(t *testing.T) { + key, err := auth.DeriveKey("new-client-legacy-server") + if err != nil { + t.Fatal(err) + } + payload := []byte("legacy server response") + raw := &recordConn{} + _, _ = raw.Buffer.Write(legacyRolelessRecord(t, key, payload, 0)) + client, err := auth.WrapClientConn(raw, key) + if err != nil { + t.Fatal(err) + } + dst := bytes.Repeat([]byte{0xa5}, len(payload)) + if n, readErr := client.Read(dst); n != 0 || readErr == nil || !strings.Contains(readErr.Error(), "nonce direction=0, want 1") { + t.Fatalf("legacy-server read=(%d, %v), want fail-closed direction error", n, readErr) + } + if !bytes.Equal(dst, bytes.Repeat([]byte{0xa5}, len(payload))) { + t.Fatal("rejected legacy-server record changed the caller plaintext buffer") + } +} + +func TestConnUsesDisjointDirectionalNonceDomains(t *testing.T) { + key, err := auth.DeriveKey("directional-nonce-domains") + if err != nil { + t.Fatal(err) + } + clientWire := &recordConn{} + serverWire := &recordConn{} + client, err := auth.WrapClientConn(clientWire, key) + if err != nil { + t.Fatal(err) + } + server, err := auth.WrapServerConn(serverWire, key) + if err != nil { + t.Fatal(err) + } + payload := []byte("same-session duplex record") + if _, err = client.Write(payload); err != nil { + t.Fatal(err) + } + if _, err = server.Write(payload); err != nil { + t.Fatal(err) + } + + clientNonce := clientWire.Bytes()[4:16] + serverNonce := serverWire.Bytes()[4:16] + if bytes.Equal(clientNonce, serverNonce) { + t.Fatalf("client and server reused nonce %x with one session key", clientNonce) + } + if prefix := binary.BigEndian.Uint32(clientNonce[:4]); prefix != 0 { + t.Fatalf("client nonce prefix=%d want=0", prefix) + } + if prefix := binary.BigEndian.Uint32(serverNonce[:4]); prefix != 1 { + t.Fatalf("server nonce prefix=%d want=1", prefix) + } + if counter := binary.BigEndian.Uint64(clientNonce[4:]); counter != 0 { + t.Fatalf("first client nonce counter=%d want=0", counter) + } + if counter := binary.BigEndian.Uint64(serverNonce[4:]); counter != 0 { + t.Fatalf("first server nonce counter=%d want=0", counter) + } +} + +func TestConnSupportsConcurrentFullDuplexDirectionalTraffic(t *testing.T) { + key, err := auth.DeriveKey("full-duplex-directions") + if err != nil { + t.Fatal(err) + } + clientTransport, serverTransport := net.Pipe() + deadline := time.Now().Add(2 * time.Second) + if err = clientTransport.SetDeadline(deadline); err != nil { + t.Fatal(err) + } + if err = serverTransport.SetDeadline(deadline); err != nil { + t.Fatal(err) + } + client, err := auth.WrapClientConn(clientTransport, key) + if err != nil { + t.Fatal(err) + } + server, err := auth.WrapServerConn(serverTransport, key) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = client.Close() + _ = server.Close() + }) + + clientPayload := []byte("client controller state") + serverPayload := []byte("server output media") + type writeResult struct { + name string + n int + err error + } + writes := make(chan writeResult, 2) + go func() { + n, writeErr := client.Write(clientPayload) + writes <- writeResult{name: "client", n: n, err: writeErr} + }() + go func() { + n, writeErr := server.Write(serverPayload) + writes <- writeResult{name: "server", n: n, err: writeErr} + }() + + gotClientPayload := make([]byte, len(clientPayload)) + if _, err = io.ReadFull(server, gotClientPayload); err != nil { + t.Fatal(err) + } + gotServerPayload := make([]byte, len(serverPayload)) + if _, err = io.ReadFull(client, gotServerPayload); err != nil { + t.Fatal(err) + } + if !bytes.Equal(gotClientPayload, clientPayload) || !bytes.Equal(gotServerPayload, serverPayload) { + t.Fatalf("duplex plaintext=%q/%q want=%q/%q", gotClientPayload, gotServerPayload, clientPayload, serverPayload) + } + for range 2 { + result := <-writes + want := len(clientPayload) + if result.name == "server" { + want = len(serverPayload) + } + if result.n != want || result.err != nil { + t.Fatalf("%s write=(%d, %v), want (%d, nil)", result.name, result.n, result.err, want) + } + } +} + +func TestConnRejectsWrongDirectionalRole(t *testing.T) { + key, err := auth.DeriveKey("wrong-directional-role") + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + wrapOut func(net.Conn, []byte) (net.Conn, error) + wrapIn func(net.Conn, []byte) (net.Conn, error) + }{ + {name: "server_wrapper_on_client", wrapOut: auth.WrapServerConn, wrapIn: auth.WrapServerConn}, + {name: "client_wrapper_on_server", wrapOut: auth.WrapClientConn, wrapIn: auth.WrapClientConn}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + raw := &recordConn{} + sender, wrapErr := tc.wrapOut(raw, key) + if wrapErr != nil { + t.Fatal(wrapErr) + } + receiver, wrapErr := tc.wrapIn(raw, key) + if wrapErr != nil { + t.Fatal(wrapErr) + } + if _, writeErr := sender.Write([]byte("valid but wrong-direction record")); writeErr != nil { + t.Fatal(writeErr) + } + if n, readErr := receiver.Read(make([]byte, 1)); n != 0 || readErr == nil || !strings.Contains(readErr.Error(), "nonce direction") { + t.Fatalf("wrong-role read=(%d, %v), want (0, nonce direction error)", n, readErr) + } + }) + } +} + +func TestConnRejectsAuthenticatedReplayAndOutOfOrderRecords(t *testing.T) { + key, err := auth.DeriveKey("record-order") + if err != nil { + t.Fatal(err) + } + wireBuffer := &recordConn{} + sender, err := auth.WrapClientConn(wireBuffer, key) + if err != nil { + t.Fatal(err) + } + firstPayload := []byte("first") + secondPayload := []byte("second") + if _, err = sender.Write(firstPayload); err != nil { + t.Fatal(err) + } + firstLength := 4 + int(binary.BigEndian.Uint32(wireBuffer.Bytes()[:4])) + if _, err = sender.Write(secondPayload); err != nil { + t.Fatal(err) + } + wire := append([]byte(nil), wireBuffer.Bytes()...) + firstRecord := wire[:firstLength] + secondRecord := wire[firstLength:] + + t.Run("replay", func(t *testing.T) { + raw := &recordConn{} + _, _ = raw.Buffer.Write(firstRecord) + _, _ = raw.Buffer.Write(firstRecord) + receiver, wrapErr := auth.WrapServerConn(raw, key) + if wrapErr != nil { + t.Fatal(wrapErr) + } + decoded := make([]byte, len(firstPayload)) + if _, readErr := io.ReadFull(receiver, decoded); readErr != nil { + t.Fatal(readErr) + } + if n, readErr := receiver.Read(decoded[:1]); n != 0 || readErr == nil || !strings.Contains(readErr.Error(), "nonce counter=0, want 1") { + t.Fatalf("replayed read=(%d, %v), want counter rejection", n, readErr) + } + }) + + t.Run("out_of_order", func(t *testing.T) { + raw := &recordConn{} + _, _ = raw.Buffer.Write(secondRecord) + _, _ = raw.Buffer.Write(firstRecord) + receiver, wrapErr := auth.WrapServerConn(raw, key) + if wrapErr != nil { + t.Fatal(wrapErr) + } + if n, readErr := receiver.Read(make([]byte, 1)); n != 0 || readErr == nil || !strings.Contains(readErr.Error(), "nonce counter=1, want 0") { + t.Fatalf("out-of-order read=(%d, %v), want counter rejection", n, readErr) + } + }) +} + +func TestConnCoalescesOneAuthenticatedRecordIntoOneWrite(t *testing.T) { + key, err := auth.DeriveKey("coalesced-record") + if err != nil { + t.Fatal(err) + } + raw := &recordConn{} + sender, err := auth.WrapClientConn(raw, key) + if err != nil { + t.Fatal(err) + } + receiver, err := auth.WrapServerConn(raw, key) + if err != nil { + t.Fatal(err) + } + payload := []byte("one input/media frame") + if written, writeErr := sender.Write(payload); writeErr != nil || written != len(payload) { + t.Fatalf("write=(%d, %v), want (%d, nil)", written, writeErr, len(payload)) + } + if raw.writeCalls != 1 { + t.Fatalf("authenticated frame used %d transport writes, want 1", raw.writeCalls) + } + decoded := make([]byte, len(payload)) + if _, err = io.ReadFull(receiver, decoded); err != nil { + t.Fatal(err) + } + if !bytes.Equal(decoded, payload) { + t.Fatalf("decoded=%q want=%q", decoded, payload) + } +} + +func TestConnFinishesPartialUnderlyingWritesWithoutSplittingARecord(t *testing.T) { + key, err := auth.DeriveKey("partial-record") + if err != nil { + t.Fatal(err) + } + raw := &recordConn{maxWrite: 3} + sender, _ := auth.WrapClientConn(raw, key) + receiver, _ := auth.WrapServerConn(raw, key) + payload := []byte("partial writes are completed") + if _, err = sender.Write(payload); err != nil { + t.Fatal(err) + } + if raw.writeCalls <= 1 { + t.Fatal("partial transport did not exercise the full-write loop") + } + decoded := make([]byte, len(payload)) + if _, err = io.ReadFull(receiver, decoded); err != nil { + t.Fatal(err) + } + if !bytes.Equal(decoded, payload) { + t.Fatalf("decoded=%q want=%q", decoded, payload) + } +} + +func TestConnRejectsTruncatedAuthenticatedRecordWithoutPanicking(t *testing.T) { + key, err := auth.DeriveKey("short-record") + if err != nil { + t.Fatal(err) + } + raw := &recordConn{} + var header [4]byte + binary.BigEndian.PutUint32(header[:], 1) + _, _ = raw.Buffer.Write(header[:]) + _ = raw.Buffer.WriteByte(0) + receiver, _ := auth.WrapServerConn(raw, key) + if _, err = receiver.Read(make([]byte, 1)); err == nil { + t.Fatal("truncated authenticated record was accepted") + } +} + +func TestConnRejectsInvalidRecordLengthTerminallyBeforeAllocation(t *testing.T) { + key, err := auth.DeriveKey("invalid-record-length") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + length uint32 + }{ + {name: "below_nonce_and_tag", length: 12 + 16 - 1}, + {name: "above_bound", length: 2*1024*1024 + 1}, + {name: "uint32_max", length: ^uint32(0)}, + } { + t.Run(tc.name, func(t *testing.T) { + raw := &recordConn{} + var header [4]byte + binary.BigEndian.PutUint32(header[:], tc.length) + _, _ = raw.Buffer.Write(header[:]) + receiver, wrapErr := auth.WrapServerConn(raw, key) + if wrapErr != nil { + t.Fatal(wrapErr) + } + if n, readErr := receiver.Read(make([]byte, 1)); n != 0 || readErr == nil { + t.Fatalf("invalid length read=(%d, %v), want (0, error)", n, readErr) + } + remaining := raw.Len() + if n, readErr := receiver.Read(make([]byte, 1)); n != 0 || readErr == nil { + t.Fatalf("repeated invalid length read=(%d, %v), want sticky error", n, readErr) + } + if raw.Len() != remaining { + t.Fatal("terminal framing error consumed bytes on retry") + } + }) + } +} + +func TestConnSerializesConcurrentRecordsWithMonotonicNonces(t *testing.T) { + key, err := auth.DeriveKey("concurrent-records") + if err != nil { + t.Fatal(err) + } + raw := &recordConn{} + sender, err := auth.WrapClientConn(raw, key) + if err != nil { + t.Fatal(err) + } + const records = 64 + start := make(chan struct{}) + errs := make(chan error, records) + var writers sync.WaitGroup + for id := 0; id < records; id++ { + writers.Add(1) + go func(id int) { + defer writers.Done() + <-start + var payload [4]byte + binary.BigEndian.PutUint32(payload[:], uint32(id)) + _, writeErr := sender.Write(payload[:]) + errs <- writeErr + }(id) + } + close(start) + writers.Wait() + close(errs) + for writeErr := range errs { + if writeErr != nil { + t.Fatal(writeErr) + } + } + + wire := append([]byte(nil), raw.Bytes()...) + for counter := uint64(0); counter < records; counter++ { + if len(wire) < 4 { + t.Fatalf("record %d has no length prefix", counter) + } + length := int(binary.BigEndian.Uint32(wire[:4])) + if length < 12 || len(wire) < 4+length { + t.Fatalf("record %d length=%d remaining=%d", counter, length, len(wire)) + } + nonceCounter := binary.BigEndian.Uint64(wire[8:16]) + if nonceCounter != counter { + t.Fatalf("record %d nonce counter=%d", counter, nonceCounter) + } + wire = wire[4+length:] + } + if len(wire) != 0 { + t.Fatalf("%d trailing authenticated bytes", len(wire)) + } + + receiver, err := auth.WrapServerConn(raw, key) + if err != nil { + t.Fatal(err) + } + seen := make(map[uint32]bool, records) + for range records { + var payload [4]byte + if _, err = io.ReadFull(receiver, payload[:]); err != nil { + t.Fatal(err) + } + seen[binary.BigEndian.Uint32(payload[:])] = true + } + if len(seen) != records { + t.Fatalf("decoded %d unique records, want %d", len(seen), records) + } +} + +func TestConnClosesAfterPartialRecordFailure(t *testing.T) { + key, err := auth.DeriveKey("terminal-partial-record") + if err != nil { + t.Fatal(err) + } + wantErr := errors.New("injected transport failure") + raw := &partialFailureConn{remaining: 7, err: wantErr} + sender, err := auth.WrapClientConn(raw, key) + if err != nil { + t.Fatal(err) + } + if written, writeErr := sender.Write([]byte("frame")); written != 0 || !errors.Is(writeErr, wantErr) { + t.Fatalf("partial write=(%d, %v), want (0, %v)", written, writeErr, wantErr) + } + if !raw.closed { + t.Fatal("partially emitted authenticated record did not close the stream") + } + wireLength := raw.Len() + if written, writeErr := sender.Write([]byte("retry")); written != 0 || !errors.Is(writeErr, wantErr) { + t.Fatalf("retry=(%d, %v), want terminal (0, %v)", written, writeErr, wantErr) + } + if raw.Len() != wireLength { + t.Fatal("terminal authenticated stream emitted bytes after partial failure") + } +} + +func TestConnReturnsCompletePlaintextCountWhenTransportReportsFullWriteAndError(t *testing.T) { + key, err := auth.DeriveKey("full-record-error") + if err != nil { + t.Fatal(err) + } + wantErr := errors.New("transport failed after accepting the record") + raw := &fullWriteErrorConn{err: wantErr} + sender, err := auth.WrapClientConn(raw, key) + if err != nil { + t.Fatal(err) + } + payload := []byte("complete authenticated frame") + if written, writeErr := sender.Write(payload); written != len(payload) || !errors.Is(writeErr, wantErr) { + t.Fatalf("full write=(%d, %v), want (%d, %v)", written, writeErr, len(payload), wantErr) + } + if !raw.closed { + t.Fatal("transport error after a complete record did not make the write side terminal") + } + wireLength := raw.Len() + if written, writeErr := sender.Write([]byte("retry")); written != 0 || !errors.Is(writeErr, wantErr) { + t.Fatalf("retry=(%d, %v), want terminal (0, %v)", written, writeErr, wantErr) + } + if raw.Len() != wireLength { + t.Fatal("terminal stream emitted bytes after a full-record transport error") + } + + receiver, err := auth.WrapServerConn(raw, key) + if err != nil { + t.Fatal(err) + } + decoded := make([]byte, len(payload)) + if _, err = io.ReadFull(receiver, decoded); err != nil { + t.Fatal(err) + } + if !bytes.Equal(decoded, payload) { + t.Fatalf("decoded=%q want=%q", decoded, payload) + } +} + +func TestConnRetriesRecordAfterZeroByteTransportErrorWithoutNonceReuseOnWire(t *testing.T) { + key, err := auth.DeriveKey("zero-byte-retry") + if err != nil { + t.Fatal(err) + } + wantErr := errors.New("temporary transport error") + raw := &firstWriteErrorConn{err: wantErr, first: true} + sender, err := auth.WrapClientConn(raw, key) + if err != nil { + t.Fatal(err) + } + payload := []byte("retry safely") + if written, writeErr := sender.Write(payload); written != 0 || !errors.Is(writeErr, wantErr) { + t.Fatalf("first write=(%d, %v), want (0, %v)", written, writeErr, wantErr) + } + if raw.Len() != 0 { + t.Fatal("zero-byte transport error emitted authenticated wire data") + } + if written, writeErr := sender.Write(payload); written != len(payload) || writeErr != nil { + t.Fatalf("retry=(%d, %v), want (%d, nil)", written, writeErr, len(payload)) + } + if counter := binary.BigEndian.Uint64(raw.Bytes()[8:16]); counter != 0 { + t.Fatalf("retried record nonce counter=%d want=0", counter) + } +} + +func TestConnClosesAfterZeroProgressWrite(t *testing.T) { + key, err := auth.DeriveKey("zero-progress") + if err != nil { + t.Fatal(err) + } + raw := &zeroProgressConn{} + sender, err := auth.WrapClientConn(raw, key) + if err != nil { + t.Fatal(err) + } + if written, writeErr := sender.Write([]byte("frame")); written != 0 || !errors.Is(writeErr, io.ErrNoProgress) { + t.Fatalf("zero-progress write=(%d, %v), want (0, %v)", written, writeErr, io.ErrNoProgress) + } + if !raw.closed { + t.Fatal("zero-progress transport did not close the unrecoverable stream") + } + if written, writeErr := sender.Write([]byte("retry")); written != 0 || !errors.Is(writeErr, io.ErrNoProgress) { + t.Fatalf("retry=(%d, %v), want terminal (0, %v)", written, writeErr, io.ErrNoProgress) + } +} + +func TestConnRejectsOversizedRecordBeforeTransportWrite(t *testing.T) { + key, err := auth.DeriveKey("oversized-record") + if err != nil { + t.Fatal(err) + } + raw := &recordConn{} + sender, err := auth.WrapClientConn(raw, key) + if err != nil { + t.Fatal(err) + } + if written, writeErr := sender.Write(make([]byte, 2*1024*1024)); written != 0 || writeErr == nil { + t.Fatalf("oversized write=(%d, %v), want rejection", written, writeErr) + } + if raw.writeCalls != 0 { + t.Fatalf("oversized record reached transport in %d write(s)", raw.writeCalls) + } +} + +func TestConnAcceptsExactMaximumRecordBound(t *testing.T) { + key, err := auth.DeriveKey("maximum-record") + if err != nil { + t.Fatal(err) + } + raw := &recordConn{} + sender, err := auth.WrapClientConn(raw, key) + if err != nil { + t.Fatal(err) + } + // The 2 MiB bound includes the 12-byte nonce and 16-byte Poly1305 tag. + payload := make([]byte, 2*1024*1024-12-16) + payload[0], payload[len(payload)-1] = 0x5a, 0xa5 + if written, writeErr := sender.Write(payload); written != len(payload) || writeErr != nil { + t.Fatalf("maximum write=(%d, %v), want (%d, nil)", written, writeErr, len(payload)) + } + if got := binary.BigEndian.Uint32(raw.Bytes()[:4]); got != 2*1024*1024 { + t.Fatalf("maximum wire record length=%d", got) + } + + receiver, err := auth.WrapServerConn(raw, key) + if err != nil { + t.Fatal(err) + } + decoded := make([]byte, len(payload)) + if _, err = io.ReadFull(receiver, decoded); err != nil { + t.Fatal(err) + } + if !bytes.Equal(decoded, payload) { + t.Fatal("maximum authenticated record changed during round trip") + } +} + +func TestConnResumesInterruptedAuthenticatedFramingWithoutExposingWireBytes(t *testing.T) { + key, err := auth.DeriveKey("interrupted-framing") + if err != nil { + t.Fatal(err) + } + wireBuffer := &recordConn{} + sender, err := auth.WrapClientConn(wireBuffer, key) + if err != nil { + t.Fatal(err) + } + payload := []byte("only authenticated plaintext may reach the caller") + if _, err = sender.Write(payload); err != nil { + t.Fatal(err) + } + wire := append([]byte(nil), wireBuffer.Bytes()...) + wantErr := errors.New("injected read deadline") + + for _, tc := range []struct { + name string + beforeError int + }{ + {name: "partial_header", beforeError: 2}, + {name: "partial_record", beforeError: 4 + 7}, + } { + t.Run(tc.name, func(t *testing.T) { + raw := &interruptedReadConn{beforeError: tc.beforeError, err: wantErr} + _, _ = raw.Buffer.Write(wire) + receiver, wrapErr := auth.WrapServerConn(raw, key) + if wrapErr != nil { + t.Fatal(wrapErr) + } + dst := bytes.Repeat([]byte{0xa5}, len(payload)) + if n, readErr := receiver.Read(dst[:1]); n != 0 || !errors.Is(readErr, wantErr) { + t.Fatalf("interrupted read=(%d, %v), want (0, %v)", n, readErr, wantErr) + } + if !bytes.Equal(dst, bytes.Repeat([]byte{0xa5}, len(payload))) { + t.Fatal("unauthenticated framing bytes changed the caller buffer") + } + if _, readErr := io.ReadFull(receiver, dst); readErr != nil { + t.Fatal(readErr) + } + if !bytes.Equal(dst, payload) { + t.Fatalf("resumed plaintext=%q want=%q", dst, payload) + } + }) + } +} + +func TestConnSkipsAuthenticatedEmptyRecordAndZeroLengthReadDoesNotConsumeWire(t *testing.T) { + key, err := auth.DeriveKey("empty-record") + if err != nil { + t.Fatal(err) + } + raw := &recordConn{} + sender, err := auth.WrapClientConn(raw, key) + if err != nil { + t.Fatal(err) + } + if written, writeErr := sender.Write(nil); written != 0 || writeErr != nil { + t.Fatalf("empty write=(%d, %v), want (0, nil)", written, writeErr) + } + payload := []byte("after empty") + if _, err = sender.Write(payload); err != nil { + t.Fatal(err) + } + wireLength := raw.Len() + receiver, err := auth.WrapServerConn(raw, key) + if err != nil { + t.Fatal(err) + } + if n, readErr := receiver.Read(nil); n != 0 || readErr != nil { + t.Fatalf("zero-length read=(%d, %v), want (0, nil)", n, readErr) + } + if raw.Len() != wireLength { + t.Fatal("zero-length destination consumed authenticated wire data") + } + dst := make([]byte, len(payload)) + if n, readErr := receiver.Read(dst); n != len(payload) || readErr != nil { + t.Fatalf("read after empty record=(%d, %v), want (%d, nil)", n, readErr, len(payload)) + } + if !bytes.Equal(dst, payload) { + t.Fatalf("read after empty record=%q want=%q", dst, payload) + } +} + +func TestConnReadCopiesPlaintextOutOfReusableRecordSlab(t *testing.T) { + key, err := auth.DeriveKey("retained-read-buffer") + if err != nil { + t.Fatal(err) + } + raw := &recordConn{} + sender, _ := auth.WrapClientConn(raw, key) + receiver, _ := auth.WrapServerConn(raw, key) + firstWant := []byte("first-frame") + secondWant := []byte("second-frame") + _, _ = sender.Write(firstWant) + first := make([]byte, len(firstWant)) + if _, err = io.ReadFull(receiver, first); err != nil { + t.Fatal(err) + } + _, _ = sender.Write(secondWant) + second := make([]byte, len(secondWant)) + if _, err = io.ReadFull(receiver, second); err != nil { + t.Fatal(err) + } + if !bytes.Equal(first, firstWant) || !bytes.Equal(second, secondWant) { + t.Fatalf("retained=%q/%q want=%q/%q", first, second, firstWant, secondWant) + } +} + +func TestConnReadPreservesFourSegmentClientWireCompatibility(t *testing.T) { + key, err := auth.DeriveKey("segmented-client-record") + if err != nil { + t.Fatal(err) + } + wireBuffer := &recordConn{} + sender, _ := auth.WrapClientConn(wireBuffer, key) + payload := []byte("header nonce ciphertext tag remain one protocol record") + if _, err = sender.Write(payload); err != nil { + t.Fatal(err) + } + wire := append([]byte(nil), wireBuffer.Bytes()...) + tagStart := len(wire) - 16 + raw := &segmentedReadConn{segments: [][]byte{ + wire[:4], wire[4:16], wire[16:tagStart], wire[tagStart:], + }} + receiver, _ := auth.WrapServerConn(raw, key) + decoded := make([]byte, len(payload)) + if _, err = io.ReadFull(receiver, decoded); err != nil { + t.Fatal(err) + } + if !bytes.Equal(decoded, payload) { + t.Fatalf("decoded=%q want=%q", decoded, payload) + } +} + +func BenchmarkConnWriteAuthenticatedRecord(b *testing.B) { + key, err := auth.DeriveKey("authenticated-write-benchmark") + if err != nil { + b.Fatal(err) + } + wrapped, err := auth.WrapClientConn(discardConn{}, key) + if err != nil { + b.Fatal(err) + } + payload := make([]byte, 512) + if _, err = wrapped.Write(payload); err != nil { + b.Fatal(err) + } + b.SetBytes(int64(len(payload))) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + if _, err = wrapped.Write(payload); err != nil { + b.Fatal(err) + } + } +} + func TestConn(t *testing.T) { type testCase struct { name string - wrapConn func(net.Conn, []byte) (net.Conn, error) setupFn func(clientConn net.Conn, serverConn net.Conn) (clientKey []byte, serverKey []byte) input []byte expected []byte @@ -22,8 +930,7 @@ func TestConn(t *testing.T) { testCases := []testCase{ { - name: "valid read", - wrapConn: auth.WrapConn, + name: "valid read", setupFn: func(clientConn, serverConn net.Conn) (clientKey []byte, serverKey []byte) { password := "test123" key, err := auth.DeriveKey(password) @@ -36,8 +943,7 @@ func TestConn(t *testing.T) { expected: []byte("Hello, World!"), }, { - name: "Differing Keys", - wrapConn: auth.WrapConn, + name: "Differing Keys", setupFn: func(clientConn, serverConn net.Conn) (clientKey []byte, serverKey []byte) { key, err := auth.DeriveKey("test123") if err != nil { @@ -54,8 +960,7 @@ func TestConn(t *testing.T) { expectedErr: errors.New("chacha20poly1305: message authentication failed"), }, { - name: "bad key length (client)", - wrapConn: auth.WrapConn, + name: "bad key length (client)", setupFn: func(clientConn, serverConn net.Conn) (clientKey []byte, serverKey []byte) { key, err := auth.DeriveKey("test123") if err != nil { @@ -68,8 +973,7 @@ func TestConn(t *testing.T) { expectedErr: errors.New("chacha20poly1305: bad key length"), }, { - name: "bad key length (server)", - wrapConn: auth.WrapConn, + name: "bad key length (server)", setupFn: func(clientConn, serverConn net.Conn) (clientKey []byte, serverKey []byte) { key, err := auth.DeriveKey("test123") if err != nil { @@ -82,8 +986,7 @@ func TestConn(t *testing.T) { expectedErr: errors.New("chacha20poly1305: bad key length"), }, { - name: "client closed before write", - wrapConn: auth.WrapConn, + name: "client closed before write", setupFn: func(clientConn, serverConn net.Conn) (clientKey []byte, serverKey []byte) { key, err := auth.DeriveKey("test123") if err != nil { @@ -97,8 +1000,7 @@ func TestConn(t *testing.T) { expectedErr: errors.New("use of closed network connection"), }, { - name: "server closed before read", - wrapConn: auth.WrapConn, + name: "server closed before read", setupFn: func(clientConn, serverConn net.Conn) (clientKey []byte, serverKey []byte) { key, err := auth.DeriveKey("test123") if err != nil { @@ -138,27 +1040,23 @@ func TestConn(t *testing.T) { clientKey, serverKey = tc.setupFn(clientConn, serverConn) } - var wrappedServerConn net.Conn - var wrappedClientConn net.Conn - if tc.wrapConn != nil { - wrappedServerConn, err = tc.wrapConn(serverConn, serverKey) - if err != nil { - if tc.expectedErr != nil { - assert.ErrorContains(t, err, tc.expectedErr.Error()) - } else { - t.Fatalf("failed to wrap server conn: %v", err) - } - return + wrappedServerConn, err := auth.WrapServerConn(serverConn, serverKey) + if err != nil { + if tc.expectedErr != nil { + assert.ErrorContains(t, err, tc.expectedErr.Error()) + } else { + t.Fatalf("failed to wrap server conn: %v", err) } - wrappedClientConn, err = tc.wrapConn(clientConn, clientKey) - if err != nil { - if tc.expectedErr != nil { - assert.ErrorContains(t, err, tc.expectedErr.Error()) - } else { - t.Fatalf("failed to wrap client conn: %v", err) - } - return + return + } + wrappedClientConn, err := auth.WrapClientConn(clientConn, clientKey) + if err != nil { + if tc.expectedErr != nil { + assert.ErrorContains(t, err, tc.expectedErr.Error()) + } else { + t.Fatalf("failed to wrap client conn: %v", err) } + return } _, err = wrappedClientConn.Write(tc.input) @@ -170,7 +1068,11 @@ func TestConn(t *testing.T) { } return } - buf := make([]byte, len(tc.expected)) + readSize := len(tc.expected) + if tc.expectedErr != nil && readSize == 0 { + readSize = 1 + } + buf := make([]byte, readSize) _, err = wrappedServerConn.Read(buf) if err != nil { if tc.expectedErr != nil { @@ -180,6 +1082,9 @@ func TestConn(t *testing.T) { } return } + if tc.expectedErr != nil { + t.Fatalf("server read succeeded, want error containing %q", tc.expectedErr) + } assert.Equal(t, tc.expected, buf) }) diff --git a/internal/server/api/config.go b/internal/server/api/config.go index 3c446c47..b4b5fb1f 100644 --- a/internal/server/api/config.go +++ b/internal/server/api/config.go @@ -4,12 +4,12 @@ import "time" // ServerConfig represents the server subcommand configuration. type ServerConfig struct { - Addr string `help:"API server listen address" default:":3242" env:"VIIPER_API_ADDR"` + Addr string `help:"API server listen address" default:"127.0.0.1:3242" env:"VIIPER_API_ADDR"` DeviceHandlerConnectTimeout time.Duration `help:"Time before auto-cleanup occurs when device handler has no active connection" default:"5s" env:"VIIPER_API_DEVICE_HANDLER_TIMEOUT"` AutoAttachLocalClient bool `help:"Controls usbip-client on localhost to auto-attach devices added to the virtual bus" default:"true" env:"VIIPER_API_AUTO_ATTACH_LOCAL_CLIENT"` - RequireLocalHostAuth bool `help:"Require authentication for clients connecting from localhost" default:"false" env:"VIIPER_API_REQUIRE_LOCALHOST_AUTH"` + RequireLocalHostAuth bool `help:"Require authentication for clients connecting from localhost" default:"true" env:"VIIPER_API_REQUIRE_LOCALHOST_AUTH"` ConnectionTimeout time.Duration `kong:"-"` PlatformOpts `embed:""` - // password for api (remote) server auth (ALWAYS read from file) + // Password authenticates API clients and is always read from the credential file. Password string `kong:"-"` } diff --git a/internal/server/api/device_stream_ownership.go b/internal/server/api/device_stream_ownership.go index 7094c1a1..5c36e74c 100644 --- a/internal/server/api/device_stream_ownership.go +++ b/internal/server/api/device_stream_ownership.go @@ -7,12 +7,34 @@ import ( "time" ) -// deviceStreamKey identifies the lifetime of one virtual device. Bus and -// device identifiers can eventually be reused, so the monotonically increasing -// generation in deviceStreamOwnership remains authoritative across reconnects. +// deviceStreamKey identifies one exact virtual-device lifetime. Bus and device +// identifiers can be reused, so the bus-owned cancellation channel fences a +// recreated successor from stale stream claims and cleanup timers. The local +// generation remains authoritative only for reconnects within that lifetime. type deviceStreamKey struct { - busID uint32 - devID string + busID uint32 + devID string + lifetime <-chan struct{} +} + +func newDeviceStreamKey(busID uint32, devID string, deviceContext context.Context) deviceStreamKey { + var lifetime <-chan struct{} + if deviceContext != nil { + lifetime = deviceContext.Done() + } + return deviceStreamKey{busID: busID, devID: devID, lifetime: lifetime} +} + +func deviceStreamLifetimeEnded(key deviceStreamKey) bool { + if key.lifetime == nil { + return false + } + select { + case <-key.lifetime: + return true + default: + return false + } } // deviceStreamCoordinator gives each virtual device exactly one current API @@ -48,17 +70,57 @@ type deviceStreamLease struct { finishOnce sync.Once } -func (c *deviceStreamCoordinator) claim(key deviceStreamKey, - conn net.Conn) *deviceStreamLease { - c.mu.Lock() +func (c *deviceStreamCoordinator) stateForKeyLocked( + key deviceStreamKey, +) *deviceStreamOwnership { if c.streams == nil { c.streams = make(map[deviceStreamKey]*deviceStreamOwnership) } state := c.streams[key] - if state == nil { - state = &deviceStreamOwnership{} - c.streams[key] = state + if state != nil { + return state + } + + state = &deviceStreamOwnership{} + c.streams[key] = state + if key.lifetime != nil { + go c.watchLifetime(key, state) + } + return state +} + +// watchLifetime makes device removal authoritative even when a handler is +// blocked in a transport read. It removes only the state object created for +// this exact lifetime, then closes its current connection outside the +// coordinator lock so the handler can return and release its lease. +func (c *deviceStreamCoordinator) watchLifetime( + key deviceStreamKey, expected *deviceStreamOwnership, +) { + <-key.lifetime + + c.mu.Lock() + state := c.streams[key] + if state != expected { + c.mu.Unlock() + return + } + conn := state.conn + c.retireLocked(key, state) + c.mu.Unlock() + + if conn != nil { + _ = conn.Close() + } +} + +func (c *deviceStreamCoordinator) claim(key deviceStreamKey, + conn net.Conn) *deviceStreamLease { + c.mu.Lock() + if deviceStreamLifetimeEnded(key) { + c.mu.Unlock() + return nil } + state := c.stateForKeyLocked(key) if state.cleanupTimer != nil { state.cleanupTimer.Stop() @@ -94,6 +156,20 @@ func (c *deviceStreamCoordinator) claim(key deviceStreamKey, return lease } +func (c *deviceStreamCoordinator) retireLocked( + key deviceStreamKey, state *deviceStreamOwnership, +) { + if state.finalizeTimer != nil { + state.finalizeTimer.Stop() + state.finalizeTimer = nil + } + if state.cleanupTimer != nil { + state.cleanupTimer.Stop() + state.cleanupTimer = nil + } + delete(c.streams, key) +} + // waitForTurn waits until the displaced handler has returned. It reports false // when an even newer stream superseded this lease while it was waiting. func (l *deviceStreamLease) waitForTurn(ctx context.Context) bool { @@ -143,6 +219,11 @@ func (l *deviceStreamLease) finish(reconnectGrace, cleanupDelay time.Duration, state.done = nil generation := state.generation close(l.done) + if deviceStreamLifetimeEnded(l.key) { + c.retireLocked(l.key, state) + c.mu.Unlock() + return + } state.finalizeTimer = time.AfterFunc(reconnectGrace, func() { c.mu.Lock() defer c.mu.Unlock() @@ -151,6 +232,10 @@ func (l *deviceStreamLease) finish(reconnectGrace, cleanupDelay time.Duration, currentState.generation != generation || currentState.finalized { return } + if deviceStreamLifetimeEnded(l.key) { + c.retireLocked(l.key, currentState) + return + } currentState.finalizeTimer = nil currentState.finalized = true if deviceContext != nil { @@ -173,6 +258,10 @@ func (l *deviceStreamLease) finish(reconnectGrace, cleanupDelay time.Duration, return } currentState.cleanupTimer = nil + if deviceStreamLifetimeEnded(l.key) { + c.retireLocked(l.key, currentState) + return + } if !currentState.finalized { if currentState.finalizeTimer != nil { currentState.finalizeTimer.Stop() @@ -193,6 +282,9 @@ func (l *deviceStreamLease) finish(reconnectGrace, cleanupDelay time.Duration, if cleanup != nil { cleanup() } + if deviceStreamLifetimeEnded(l.key) { + c.retireLocked(l.key, currentState) + } }) c.mu.Unlock() }) @@ -213,6 +305,9 @@ func (l *deviceStreamLease) abandon() { state.active = false state.conn = nil state.done = nil + if deviceStreamLifetimeEnded(l.key) { + c.retireLocked(l.key, state) + } } c.mu.Unlock() }) @@ -223,14 +318,11 @@ func (l *deviceStreamLease) abandon() { func (c *deviceStreamCoordinator) scheduleCleanup(key deviceStreamKey, delay time.Duration, deviceContext context.Context, cleanup func()) { c.mu.Lock() - if c.streams == nil { - c.streams = make(map[deviceStreamKey]*deviceStreamOwnership) - } - state := c.streams[key] - if state == nil { - state = &deviceStreamOwnership{} - c.streams[key] = state + if deviceStreamLifetimeEnded(key) { + c.mu.Unlock() + return } + state := c.stateForKeyLocked(key) if state.active { c.mu.Unlock() return @@ -248,6 +340,10 @@ func (c *deviceStreamCoordinator) scheduleCleanup(key deviceStreamKey, return } current.cleanupTimer = nil + if deviceStreamLifetimeEnded(key) { + c.retireLocked(key, current) + return + } if deviceContext != nil { select { case <-deviceContext.Done(): @@ -258,6 +354,9 @@ func (c *deviceStreamCoordinator) scheduleCleanup(key deviceStreamKey, if cleanup != nil { cleanup() } + if deviceStreamLifetimeEnded(key) { + c.retireLocked(key, current) + } }) c.mu.Unlock() } diff --git a/internal/server/api/device_stream_ownership_test.go b/internal/server/api/device_stream_ownership_test.go index 2c2bc382..30e2f806 100644 --- a/internal/server/api/device_stream_ownership_test.go +++ b/internal/server/api/device_stream_ownership_test.go @@ -208,6 +208,95 @@ func TestInitialCleanupCannotRemoveActivelyClaimedDevice(t *testing.T) { lease.abandon() } +func TestRecreatedDeviceLifetimeCannotShareStreamOrCleanupOwnership(t *testing.T) { + var coordinator deviceStreamCoordinator + oldContext, cancelOld := context.WithCancel(context.Background()) + newContext, cancelNew := context.WithCancel(context.Background()) + defer cancelNew() + oldKey := newDeviceStreamKey(20, "7", oldContext) + newKey := newDeviceStreamKey(20, "7", newContext) + + var successorCleanup atomic.Int32 + coordinator.scheduleCleanup(newKey, 35*time.Millisecond, newContext, func() { + successorCleanup.Add(1) + }) + + // The stale lifetime may have passed an earlier topology check, but once its + // bus context is retired it cannot claim the recreated successor's key or + // cancel the successor's initial cleanup timer. + cancelOld() + staleServer, staleClient := net.Pipe() + defer staleServer.Close() //nolint:errcheck + defer staleClient.Close() //nolint:errcheck + require.Nil(t, coordinator.claim(oldKey, staleServer)) + require.Eventually(t, func() bool { + return successorCleanup.Load() == 1 + }, time.Second, time.Millisecond) + + // A stale key also cannot displace or close an active successor stream. + successorServer, successorClient := net.Pipe() + defer successorServer.Close() //nolint:errcheck + defer successorClient.Close() //nolint:errcheck + successor := coordinator.claim(newKey, successorServer) + require.NotNil(t, successor) + require.True(t, successor.waitForTurn(newContext)) + + staleServer2, staleClient2 := net.Pipe() + defer staleServer2.Close() //nolint:errcheck + defer staleClient2.Close() //nolint:errcheck + require.Nil(t, coordinator.claim(oldKey, staleServer2)) + require.NoError(t, successorClient.SetReadDeadline(time.Now().Add(25*time.Millisecond))) + var one [1]byte + _, err := successorClient.Read(one[:]) + var netErr net.Error + require.ErrorAs(t, err, &netErr) + require.True(t, netErr.Timeout(), "stale lifetime closed successor stream: %v", err) + successor.abandon() +} + +func TestDeviceLifetimeCancellationClosesOnlyItsActiveStreamAndRetiresState(t *testing.T) { + var coordinator deviceStreamCoordinator + oldContext, cancelOld := context.WithCancel(context.Background()) + newContext, cancelNew := context.WithCancel(context.Background()) + defer cancelNew() + oldKey := newDeviceStreamKey(27, "11", oldContext) + newKey := newDeviceStreamKey(27, "11", newContext) + + oldServer, oldClient := net.Pipe() + defer oldClient.Close() //nolint:errcheck + oldLease := coordinator.claim(oldKey, oldServer) + require.NotNil(t, oldLease) + require.True(t, oldLease.waitForTurn(oldContext)) + + newServer, newClient := net.Pipe() + defer newServer.Close() //nolint:errcheck + defer newClient.Close() //nolint:errcheck + newLease := coordinator.claim(newKey, newServer) + require.NotNil(t, newLease) + require.True(t, newLease.waitForTurn(newContext)) + + require.NoError(t, oldClient.SetReadDeadline(time.Now().Add(time.Second))) + cancelOld() + var one [1]byte + _, err := oldClient.Read(one[:]) + require.Error(t, err, "cancelled lifetime left its stream open") + require.Eventually(t, func() bool { + coordinator.mu.Lock() + defer coordinator.mu.Unlock() + _, oldPresent := coordinator.streams[oldKey] + return !oldPresent && coordinator.streams[newKey] != nil + }, time.Second, time.Millisecond) + + require.NoError(t, newClient.SetReadDeadline(time.Now().Add(25*time.Millisecond))) + _, err = newClient.Read(one[:]) + var netErr net.Error + require.ErrorAs(t, err, &netErr) + require.True(t, netErr.Timeout(), "old lifetime cancellation closed successor: %v", err) + + oldLease.abandon() + newLease.abandon() +} + func TestDeviceStreamCloseFirstReconnectCancelsPendingFinalization(t *testing.T) { var coordinator deviceStreamCoordinator key := deviceStreamKey{busID: 23, devID: "5"} diff --git a/internal/server/api/handler/bus_device_add.go b/internal/server/api/handler/bus_device_add.go index 7e8e6ff2..3bd56a61 100644 --- a/internal/server/api/handler/bus_device_add.go +++ b/internal/server/api/handler/bus_device_add.go @@ -11,6 +11,7 @@ import ( "github.com/Alia5/VIIPER/internal/server/api" apierror "github.com/Alia5/VIIPER/internal/server/api/error" usbs "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/transport/udecx" "github.com/Alia5/VIIPER/viipertypes" ) @@ -66,7 +67,7 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { if err != nil { return apierror.ErrBadRequest(fmt.Sprintf("failed to create device: %v", err)) } - devCtx, err := b.Add(dev) + devCtx, nativeRegistration, err := s.AddDeviceToBusWithRegistration(req.Ctx, uint32(busID), dev) if err != nil { return apierror.ErrInternal(fmt.Sprintf("failed to add device to bus: %v", err)) } @@ -77,10 +78,10 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { } apiSrv.ScheduleDeviceCleanup(uint32(busID), - fmt.Sprintf("%d", exportMeta.DevID), devCtx) + fmt.Sprintf("%d", exportMeta.DevID), devCtx, nativeRegistration) autoAttachResult := api.AutoAttachResult{} - if apiSrv.Config().AutoAttachLocalClient { + if apiSrv.Config().AutoAttachLocalClient && !s.NativeTransportEnabled() { autoAttachResult, err = attachLocalhostClientWithResult( req.Ctx, exportMeta, @@ -96,6 +97,12 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { } } + transport := "usbip" + var nativeInfo *viipertypes.NativeUDEDeviceInfo + if nativeRegistration != nil { + transport = "native-ude" + nativeInfo = nativeUDEDeviceInfo(*nativeRegistration) + } payload, err := json.Marshal(viipertypes.Device{ BusID: uint32(busID), DevID: fmt.Sprintf("%d", exportMeta.DevID), @@ -103,6 +110,8 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { Pid: fmt.Sprintf("0x%04x", dev.GetDescriptor().Device.IDProduct), Type: name, DeviceSpecific: dev.GetDeviceSpecificArgs(), + Transport: transport, + NativeUDE: nativeInfo, USBIPPort: autoAttachResult.USBIPPort, USBIPOwnerSerial: autoAttachResult.USBIPOwnerSerial, }) @@ -114,3 +123,14 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { return nil } } + +func nativeUDEDeviceInfo(registration udecx.DeviceRegistration) *viipertypes.NativeUDEDeviceInfo { + return &viipertypes.NativeUDEDeviceInfo{ + DeviceID: strconv.FormatUint(registration.DeviceID, 10), + DeviceGeneration: registration.Generation, + ControllerSessionID: strconv.FormatUint(registration.ControllerSessionID, 10), + ControllerInstanceID: registration.ControllerInstanceID, + USB20PortNumber: registration.USB20PortNumber, + USB30PortNumber: registration.USB30PortNumber, + } +} diff --git a/internal/server/api/handler/bus_device_add_internal_test.go b/internal/server/api/handler/bus_device_add_internal_test.go index 63eadc94..b6b8d634 100644 --- a/internal/server/api/handler/bus_device_add_internal_test.go +++ b/internal/server/api/handler/bus_device_add_internal_test.go @@ -2,8 +2,11 @@ package handler import ( "context" + "encoding/json" "log/slog" + "strconv" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -12,11 +15,51 @@ import ( th "github.com/Alia5/VIIPER/internal/_testing" "github.com/Alia5/VIIPER/internal/server/api" usbs "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/transport/udecx" + usbdevice "github.com/Alia5/VIIPER/usb" "github.com/Alia5/VIIPER/usbip" "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/viipertypes" "github.com/Alia5/VIIPER/virtualbus" ) +type apiNativeCorrelationDriver struct { + destroyed []udecx.DeviceIdentity +} + +func (*apiNativeCorrelationDriver) CreateDevice(_ context.Context, device udecx.CreateDevice) (udecx.DeviceRegistration, error) { + return udecx.DeviceRegistration{ + DeviceIdentity: udecx.DeviceIdentity{DeviceID: device.DeviceID, Generation: device.Generation}, + Speed: device.Speed, + ControllerSessionID: 17, + USB20PortNumber: 5, + ControllerInstanceID: `ROOT\VIIPERUDE\0042`, + }, nil +} + +func (d *apiNativeCorrelationDriver) DestroyDevice(_ context.Context, identity udecx.DeviceIdentity) error { + d.destroyed = append(d.destroyed, identity) + return nil +} +func (*apiNativeCorrelationDriver) Dequeue(ctx context.Context, _ []byte) (udecx.Operation, error) { + <-ctx.Done() + return udecx.Operation{}, ctx.Err() +} +func (*apiNativeCorrelationDriver) Complete(context.Context, udecx.Completion) error { return nil } +func (*apiNativeCorrelationDriver) QueryStats(context.Context) (udecx.Stats, error) { + return udecx.Stats{}, nil +} + +type apiNativeCorrelationProcessor struct{} + +func (*apiNativeCorrelationProcessor) Process(context.Context, usbdevice.Device, udecx.Operation) (udecx.Completion, error) { + return udecx.Completion{}, nil +} +func (*apiNativeCorrelationProcessor) Lifecycle(context.Context, usbdevice.Device, udecx.Operation) error { + return nil +} +func (*apiNativeCorrelationProcessor) Reset(usbdevice.Device, udecx.DeviceIdentity) {} + func TestBusDeviceAddReturnsNativeAutoAttachMetadata(t *testing.T) { const ownerSerial = "DS4W123456789AB" type attachCall struct { @@ -36,6 +79,7 @@ func TestBusDeviceAddReturnsNativeAutoAttachMetadata(t *testing.T) { } addr, _, done := th.StartAPIServer(t, func(r *api.Router, s *usbs.Server, apiSrv *api.Server) { + apiSrv.Config().ConnectionTimeout = time.Second apiSrv.Config().AutoAttachLocalClient = true apiSrv.Config().AutoAttachWindowsNative = true @@ -56,6 +100,7 @@ func TestBusDeviceAddReturnsNativeAutoAttachMetadata(t *testing.T) { "deviceSpecific": {"subType": 1}, "vid": "0x045e", "pid": "0x028e", + "transport": "usbip", "type": "xbox360", "usbipPort": 7, "usbipOwnerSerial": "DS4W123456789AB" @@ -66,3 +111,117 @@ func TestBusDeviceAddReturnsNativeAutoAttachMetadata(t *testing.T) { require.Equal(t, uint32(1), call.devID) require.True(t, call.native) } + +func TestBusDeviceAddAndListReturnExactNativeCorrelation(t *testing.T) { + const busID = uint32(81234) + addr, _, done := th.StartAPIServer(t, func(r *api.Router, s *usbs.Server, apiSrv *api.Server) { + apiSrv.Config().ConnectionTimeout = time.Second + apiSrv.Config().DeviceHandlerConnectTimeout = 30 * time.Second + bus, err := virtualbus.NewWithBusID(busID) + require.NoError(t, err) + require.NoError(t, s.AddBus(bus)) + host, err := udecx.NewHost(&apiNativeCorrelationDriver{}, &apiNativeCorrelationProcessor{}, 0) + require.NoError(t, err) + require.NoError(t, s.EnableNativeTransport(host)) + r.Register("bus/{id}/add", BusDeviceAdd(s, apiSrv)) + r.Register("bus/{id}/list", BusDevicesList(s)) + }) + defer done() + + client := viiperclient.NewTransport(addr) + response, err := client.Do("bus/{id}/add", `{"type":"xbox360"}`, + map[string]string{"id": strconv.FormatUint(uint64(busID), 10)}) + require.NoError(t, err) + var created viipertypes.Device + require.NoError(t, json.Unmarshal([]byte(response), &created)) + wantDeviceID := strconv.FormatUint(uint64(busID)<<32|1, 10) + require.Equal(t, "native-ude", created.Transport) + require.NotNil(t, created.NativeUDE) + require.Equal(t, wantDeviceID, created.NativeUDE.DeviceID) + require.Equal(t, uint32(1), created.NativeUDE.DeviceGeneration) + require.Equal(t, "17", created.NativeUDE.ControllerSessionID) + require.Equal(t, `ROOT\VIIPERUDE\0042`, created.NativeUDE.ControllerInstanceID) + require.Equal(t, uint32(5), created.NativeUDE.USB20PortNumber) + require.Zero(t, created.NativeUDE.USB30PortNumber) + require.Zero(t, created.USBIPPort) + require.Empty(t, created.USBIPOwnerSerial) + + response, err = client.Do("bus/{id}/list", nil, + map[string]string{"id": strconv.FormatUint(uint64(busID), 10)}) + require.NoError(t, err) + var listed viipertypes.DevicesListResponse + require.NoError(t, json.Unmarshal([]byte(response), &listed)) + require.Len(t, listed.Devices, 1) + require.Equal(t, created.NativeUDE, listed.Devices[0].NativeUDE) + require.Equal(t, "native-ude", listed.Devices[0].Transport) +} + +func TestNativeExactRemoveRejectsStaleReceiptAndPreservesSuccessor(t *testing.T) { + const busID = uint32(81235) + driver := &apiNativeCorrelationDriver{} + addr, _, done := th.StartAPIServer(t, func(r *api.Router, s *usbs.Server, apiSrv *api.Server) { + apiSrv.Config().ConnectionTimeout = time.Second + apiSrv.Config().DeviceHandlerConnectTimeout = 30 * time.Second + bus, err := virtualbus.NewWithBusID(busID) + require.NoError(t, err) + require.NoError(t, s.AddBus(bus)) + host, err := udecx.NewHost(driver, &apiNativeCorrelationProcessor{}, 0) + require.NoError(t, err) + require.NoError(t, s.EnableNativeTransport(host)) + r.Register("bus/{id}/add", BusDeviceAdd(s, apiSrv)) + r.Register("bus/{id}/list", BusDevicesList(s)) + r.Register("bus/{id}/remove", BusDeviceRemove(s)) + r.Register("bus/{id}/remove-native", BusDeviceRemoveNative(s)) + r.Register("bus/remove", BusRemove(s)) + }) + defer done() + + client := viiperclient.NewTransport(addr) + params := map[string]string{"id": strconv.FormatUint(uint64(busID), 10)} + response, err := client.Do("bus/{id}/add", `{"type":"xbox360"}`, params) + require.NoError(t, err) + var created viipertypes.Device + require.NoError(t, json.Unmarshal([]byte(response), &created)) + require.NotNil(t, created.NativeUDE) + + response, err = client.Do("bus/{id}/remove", created.DevID, params) + require.NoError(t, err) + var unsafeRemove viipertypes.APIError + require.NoError(t, json.Unmarshal([]byte(response), &unsafeRemove)) + require.Equal(t, 409, unsafeRemove.Status) + require.Empty(t, driver.destroyed) + + staleNative := *created.NativeUDE + staleNative.DeviceGeneration++ + staleRequest := viipertypes.NativeUDEDeviceRemoveRequest{ + DevID: created.DevID, Transport: "native-ude", NativeUDE: &staleNative, + } + response, err = client.Do("bus/{id}/remove-native", staleRequest, params) + require.NoError(t, err) + var conflict viipertypes.APIError + require.NoError(t, json.Unmarshal([]byte(response), &conflict)) + require.Equal(t, 409, conflict.Status) + require.Empty(t, driver.destroyed) + + response, err = client.Do("bus/remove", strconv.FormatUint(uint64(busID), 10), nil) + require.NoError(t, err) + var busConflict viipertypes.APIError + require.NoError(t, json.Unmarshal([]byte(response), &busConflict)) + require.Equal(t, 409, busConflict.Status) + require.Empty(t, driver.destroyed) + + response, err = client.Do("bus/{id}/list", nil, params) + require.NoError(t, err) + var listed viipertypes.DevicesListResponse + require.NoError(t, json.Unmarshal([]byte(response), &listed)) + require.Len(t, listed.Devices, 1) + require.Equal(t, created.NativeUDE, listed.Devices[0].NativeUDE) + + exactRequest := viipertypes.NativeUDEDeviceRemoveRequest{ + DevID: created.DevID, Transport: "native-ude", NativeUDE: created.NativeUDE, + } + response, err = client.Do("bus/{id}/remove-native", exactRequest, params) + require.NoError(t, err) + require.JSONEq(t, `{"busId":81235,"devId":"1"}`, response) + require.Len(t, driver.destroyed, 1) +} diff --git a/internal/server/api/handler/bus_device_add_test.go b/internal/server/api/handler/bus_device_add_test.go index b0393b1b..b6f3da95 100644 --- a/internal/server/api/handler/bus_device_add_test.go +++ b/internal/server/api/handler/bus_device_add_test.go @@ -44,7 +44,7 @@ func TestBusDeviceAdd(t *testing.T) { }, pathParams: map[string]string{"id": "80001"}, payload: `{"type": "xbox360"}`, - expectedResponse: `{"busId":80001, "devId": "1", "deviceSpecific": {"subType": 1}, "vid":"0x045e", "pid":"0x028e", "type":"xbox360"}`, + expectedResponse: `{"busId":80001, "devId": "1", "deviceSpecific": {"subType": 1}, "vid":"0x045e", "pid":"0x028e", "type":"xbox360", "transport":"usbip"}`, }, { name: "add device to existing bus with device specific args", @@ -59,7 +59,7 @@ func TestBusDeviceAdd(t *testing.T) { }, pathParams: map[string]string{"id": "80001"}, payload: `{"type": "xbox360", "deviceSpecific":{"subType": 7}}`, - expectedResponse: `{"busId":80001, "devId": "1", "deviceSpecific": {"subType": 7}, "vid":"0x045e", "pid":"0x028e", "type":"xbox360"}`, + expectedResponse: `{"busId":80001, "devId": "1", "deviceSpecific": {"subType": 7}, "vid":"0x045e", "pid":"0x028e", "type":"xbox360", "transport":"usbip"}`, }, { name: "invalid device specific args", @@ -143,7 +143,7 @@ func TestBusDeviceAdd(t *testing.T) { }, pathParams: map[string]string{"id": "80005"}, payload: `{"type": "xbox360"}`, - expectedResponse: `{"busId":80005, "devId": "1", "deviceSpecific": {"subType":1}, "vid":"0x045e", "pid":"0x028e", "type":"xbox360"}`, + expectedResponse: `{"busId":80005, "devId": "1", "deviceSpecific": {"subType":1}, "vid":"0x045e", "pid":"0x028e", "type":"xbox360", "transport":"usbip"}`, }, { name: "autoattach fails returns error", diff --git a/internal/server/api/handler/bus_device_remove.go b/internal/server/api/handler/bus_device_remove.go index d4ab4c3d..492a9d5f 100644 --- a/internal/server/api/handler/bus_device_remove.go +++ b/internal/server/api/handler/bus_device_remove.go @@ -32,6 +32,11 @@ func BusDeviceRemove(s *usb.Server) api.HandlerFunc { if b == nil { return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) } + if s.NativeTransportEnabled() { + return apierror.ErrConflict( + "native transport requires bus/{id}/remove-native with the exact correlation receipt", + ) + } if err := s.RemoveDeviceByID(uint32(busID), deviceID); err != nil { return apierror.ErrNotFound(fmt.Sprintf("device %s not found on bus %d", deviceID, busID)) } diff --git a/internal/server/api/handler/bus_device_remove_native.go b/internal/server/api/handler/bus_device_remove_native.go new file mode 100644 index 00000000..68c98077 --- /dev/null +++ b/internal/server/api/handler/bus_device_remove_native.go @@ -0,0 +1,97 @@ +package handler + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "strconv" + + "github.com/Alia5/VIIPER/internal/server/api" + apierror "github.com/Alia5/VIIPER/internal/server/api/error" + "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/transport/udecx" + "github.com/Alia5/VIIPER/viipertypes" +) + +// BusDeviceRemoveNative performs a correlation-conditioned native removal. +// The full add/list receipt is compared atomically by usb.Server immediately +// before Unregister, so a delayed lifetime cannot remove an ID-reusing child. +func BusDeviceRemoveNative(s *usb.Server) api.HandlerFunc { + return func(req *api.Request, res *api.Response, _ *slog.Logger) error { + idStr, ok := req.Params["id"] + if !ok { + return apierror.ErrBadRequest("missing id parameter") + } + busID64, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + return apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) + } + if req.Payload == "" { + return apierror.ErrBadRequest("missing payload") + } + + var removeRequest viipertypes.NativeUDEDeviceRemoveRequest + if err := json.Unmarshal([]byte(req.Payload), &removeRequest); err != nil { + return apierror.ErrBadRequest(fmt.Sprintf("invalid JSON payload: %v", err)) + } + if removeRequest.Transport != "native-ude" { + return apierror.ErrBadRequest("transport must be exactly native-ude") + } + if removeRequest.NativeUDE == nil { + return apierror.ErrBadRequest("missing nativeUde correlation receipt") + } + + deviceID, err := parseCanonicalUint(removeRequest.DevID, 32) + if err != nil || deviceID == 0 { + return apierror.ErrBadRequest("devId must be a canonical nonzero uint32 decimal string") + } + nativeDeviceID, err := parseCanonicalUint(removeRequest.NativeUDE.DeviceID, 64) + if err != nil || nativeDeviceID == 0 { + return apierror.ErrBadRequest("nativeUde.deviceId must be a canonical nonzero uint64 decimal string") + } + controllerSessionID, err := parseCanonicalUint(removeRequest.NativeUDE.ControllerSessionID, 64) + if err != nil || controllerSessionID == 0 { + return apierror.ErrBadRequest("nativeUde.controllerSessionId must be a canonical nonzero uint64 decimal string") + } + + expected := udecx.DeviceRegistration{ + DeviceIdentity: udecx.DeviceIdentity{ + DeviceID: nativeDeviceID, Generation: removeRequest.NativeUDE.DeviceGeneration, + }, + ControllerSessionID: controllerSessionID, + ControllerInstanceID: removeRequest.NativeUDE.ControllerInstanceID, + USB20PortNumber: removeRequest.NativeUDE.USB20PortNumber, + USB30PortNumber: removeRequest.NativeUDE.USB30PortNumber, + } + if err := s.RemoveNativeDeviceExact(uint32(busID64), removeRequest.DevID, expected); err != nil { + switch { + case errors.Is(err, usb.ErrInvalidNativeDeviceCorrelation): + return apierror.ErrBadRequest(err.Error()) + case errors.Is(err, usb.ErrNativeDeviceCorrelationMismatch): + return apierror.ErrConflict("native device correlation is stale; no device was removed") + case errors.Is(err, usb.ErrBusNotFound): + return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID64)) + default: + return apierror.ErrInternal(fmt.Sprintf("failed to remove native device: %v", err)) + } + } + + response, err := json.Marshal(viipertypes.DeviceRemoveResponse{ + BusID: uint32(busID64), DevID: removeRequest.DevID, + }) + if err != nil { + return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) + } + res.JSON = string(response) + return nil + } +} + +func parseCanonicalUint(value string, bitSize int) (uint64, error) { + parsed, err := strconv.ParseUint(value, 10, bitSize) + if err != nil || strconv.FormatUint(parsed, 10) != value { + return 0, fmt.Errorf("non-canonical unsigned decimal value") + } + return parsed, nil +} diff --git a/internal/server/api/handler/bus_devices_list.go b/internal/server/api/handler/bus_devices_list.go index 9207cbc6..aabc836e 100644 --- a/internal/server/api/handler/bus_devices_list.go +++ b/internal/server/api/handler/bus_devices_list.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "errors" "fmt" "log/slog" "path/filepath" @@ -26,14 +27,27 @@ func BusDevicesList(s *usb.Server) api.HandlerFunc { if err != nil { return apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) } - b := s.GetBus(uint32(busID)) - if b == nil { - return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) + snapshots, err := s.SnapshotBusDevices(uint32(busID)) + if err != nil { + switch { + case errors.Is(err, usb.ErrBusNotFound): + return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) + case errors.Is(err, usb.ErrNativeDeviceCorrelationMismatch): + return apierror.ErrConflict("native bus topology changed during list") + default: + return apierror.ErrInternal(fmt.Sprintf("snapshot bus %d: %v", busID, err)) + } } - metas := b.GetAllDeviceMetas() - out := make([]viipertypes.Device, 0, len(metas)) - for _, m := range metas { + out := make([]viipertypes.Device, 0, len(snapshots)) + for _, snapshot := range snapshots { + m := snapshot.DeviceMeta dtype := inferDeviceType(m.Dev) + transport := "usbip" + var nativeInfo *viipertypes.NativeUDEDeviceInfo + if snapshot.NativeRegistration != nil { + transport = "native-ude" + nativeInfo = nativeUDEDeviceInfo(*snapshot.NativeRegistration) + } out = append(out, viipertypes.Device{ BusID: m.Meta.BusID, DevID: fmt.Sprintf("%d", m.Meta.DevID), @@ -41,6 +55,8 @@ func BusDevicesList(s *usb.Server) api.HandlerFunc { Pid: fmt.Sprintf("0x%04x", m.Dev.GetDescriptor().Device.IDProduct), Type: dtype, DeviceSpecific: m.Dev.GetDeviceSpecificArgs(), + Transport: transport, + NativeUDE: nativeInfo, }) } payload, err := json.Marshal(viipertypes.DevicesListResponse{Devices: out}) diff --git a/internal/server/api/handler/bus_devices_list_test.go b/internal/server/api/handler/bus_devices_list_test.go index fe9e80ba..c4c0764d 100644 --- a/internal/server/api/handler/bus_devices_list_test.go +++ b/internal/server/api/handler/bus_devices_list_test.go @@ -54,7 +54,7 @@ func TestBusDevicesList(t *testing.T) { } }, pathParams: map[string]string{"id": "60009"}, - expectedResponse: `{"devices":[{"busId":60009,"devId":"1","deviceSpecific":{"subType": 1},"vid":"0x045e","pid":"0x028e","type":"xbox360"}]}`, + expectedResponse: `{"devices":[{"busId":60009,"devId":"1","deviceSpecific":{"subType": 1},"vid":"0x045e","pid":"0x028e","type":"xbox360","transport":"usbip"}]}`, }, { name: "list devices with multiple additions", @@ -82,7 +82,7 @@ func TestBusDevicesList(t *testing.T) { } }, pathParams: map[string]string{"id": "60010"}, - expectedResponse: `{"devices":[{"busId":60010,"devId":"1","deviceSpecific":{"subType": 1},"vid":"0x045e","pid":"0x028e","type":"xbox360"},{"busId":60010,"devId":"2","deviceSpecific":{"subType": 1},"vid":"0x045e","pid":"0x028e","type":"xbox360"}]}`, + expectedResponse: `{"devices":[{"busId":60010,"devId":"1","deviceSpecific":{"subType": 1},"vid":"0x045e","pid":"0x028e","type":"xbox360","transport":"usbip"},{"busId":60010,"devId":"2","deviceSpecific":{"subType": 1},"vid":"0x045e","pid":"0x028e","type":"xbox360","transport":"usbip"}]}`, }, { name: "list devices on non-existing bus", diff --git a/internal/server/api/handler/bus_remove.go b/internal/server/api/handler/bus_remove.go index 845c2b6f..804a77f7 100644 --- a/internal/server/api/handler/bus_remove.go +++ b/internal/server/api/handler/bus_remove.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "errors" "fmt" "log/slog" "strconv" @@ -22,7 +23,16 @@ func BusRemove(s *usb.Server) api.HandlerFunc { if err != nil { return apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) } - if err := s.RemoveBus(uint32(busID)); err != nil { + remove := s.RemoveBus + if s.NativeTransportEnabled() { + remove = s.RemoveBusIfEmpty + } + if err := remove(uint32(busID)); err != nil { + if errors.Is(err, usb.ErrBusNotEmpty) { + return apierror.ErrConflict( + "native transport refuses ID-only removal of a non-empty bus", + ) + } return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) } out, err := json.Marshal(viipertypes.BusRemoveResponse{BusID: uint32(busID)}) diff --git a/internal/server/api/handler/ping.go b/internal/server/api/handler/ping.go index b4773b49..6930b56a 100644 --- a/internal/server/api/handler/ping.go +++ b/internal/server/api/handler/ping.go @@ -9,9 +9,21 @@ import ( "github.com/Alia5/VIIPER/viipertypes" ) +// PingOptions adds live backend proof to the legacy identity response. The +// variadic form intentionally preserves source compatibility for embedded API +// users that still call Ping() without transport metadata. +type PingOptions struct { + Transport string + Status func() (ready bool, native *viipertypes.NativeUDEInfo) +} + // Ping returns a handler for the "ping" endpoint. // It provides a minimal identity + version response. -func Ping() api.HandlerFunc { +func Ping(options ...PingOptions) api.HandlerFunc { + var option PingOptions + if len(options) != 0 { + option = options[0] + } return func(_ *api.Request, res *api.Response, logger *slog.Logger) error { ver, err := common.GetVersion() if err != nil { @@ -22,7 +34,14 @@ func Ping() api.HandlerFunc { logger.Error("ping: invalid version format", "error", err, "version", ver) } - payload := viipertypes.PingResponse{Server: "VIIPER", Version: ver} + payload := viipertypes.PingResponse{ + Server: "VIIPER", Version: ver, Transport: option.Transport, + } + if option.Status != nil { + ready, native := option.Status() + payload.Ready = &ready + payload.NativeUDE = native + } b, err := json.Marshal(payload) if err != nil { return err diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index 74bf3748..1aceb275 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -2,6 +2,7 @@ package handler_test import ( "encoding/json" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -29,4 +30,41 @@ func TestPing(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "VIIPER", out.Server) assert.NotEmpty(t, out.Version) + assert.Empty(t, out.Transport) + assert.Nil(t, out.Ready) + assert.Nil(t, out.NativeUDE) +} + +func TestPingReportsNegotiatedNativeBackend(t *testing.T) { + want := &viipertypes.NativeUDEInfo{ + ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, + ExpectedDriverPackageVersion: "0.1.0.29", + LoadedDriverBuildIdentity: strings.Repeat("a", 64), + ControllerSessionID: "17", + ControllerInstanceID: `ROOT\VIIPERUDE\0042`, + MaxDevices: 32, MaxDescriptorBytes: 262144, + MaxTransferBytes: 1048576, MaxIsoPackets: 1024, + MaxPendingOperations: 4096, + } + addr, _, done := handlerTest.StartAPIServer(t, func(r *api.Router, _ *usb.Server, _ *api.Server) { + r.Register("ping", handler.Ping(handler.PingOptions{ + Transport: "native-ude", + Status: func() (bool, *viipertypes.NativeUDEInfo) { + copy := *want + return true, © + }, + })) + }) + defer done() + + c := viiperclient.NewTransport(addr) + line, err := c.Do("ping", nil, nil) + assert.NoError(t, err) + var out viipertypes.PingResponse + assert.NoError(t, json.Unmarshal([]byte(line), &out)) + assert.Equal(t, "native-ude", out.Transport) + if assert.NotNil(t, out.Ready) { + assert.True(t, *out.Ready) + } + assert.Equal(t, want, out.NativeUDE) } diff --git a/internal/server/api/router.go b/internal/server/api/router.go index 5e37e89e..e64834c0 100644 --- a/internal/server/api/router.go +++ b/internal/server/api/router.go @@ -32,6 +32,20 @@ type HandlerFunc func(req *Request, res *Response, logger *slog.Logger) error // the handler encountered a terminal failure; the dispatcher/server will log it. type StreamHandlerFunc func(conn net.Conn, dev *usb.Device, logger *slog.Logger) error +type streamLifetime interface { + StreamDone() <-chan struct{} +} + +// StreamDone is closed when the server, peer, or a replacement stream closes +// conn. Device handlers use it to cancel bounded backpressure without leaving +// displaced stream cleanup blocked behind an input queue. +func StreamDone(conn net.Conn) <-chan struct{} { + if lifetime, ok := conn.(streamLifetime); ok { + return lifetime.StreamDone() + } + return nil +} + // Router implements simple path pattern matching with placeholders in {name}. type Router struct { routes []routeEntry diff --git a/internal/server/api/security_test.go b/internal/server/api/security_test.go new file mode 100644 index 00000000..87e490b0 --- /dev/null +++ b/internal/server/api/security_test.go @@ -0,0 +1,122 @@ +package api + +import ( + "log/slog" + "testing" + + "github.com/alecthomas/kong" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServerConfigSecureDefaults(t *testing.T) { + var options struct { + API ServerConfig `embed:"" prefix:"api."` + } + parser, err := kong.New(&options) + require.NoError(t, err) + _, err = parser.Parse(nil) + require.NoError(t, err) + + assert.Equal(t, DefaultListenAddress, options.API.Addr) + assert.True(t, options.API.RequireLocalHostAuth) +} + +func TestServerConfigExplicitLocalDevelopmentOptOut(t *testing.T) { + var options struct { + API ServerConfig `embed:"" prefix:"api."` + } + parser, err := kong.New(&options) + require.NoError(t, err) + _, err = parser.Parse([]string{ + "--api.addr=:43242", + "--api.require-local-host-auth=false", + }) + require.NoError(t, err) + + assert.Equal(t, ":43242", options.API.Addr) + assert.False(t, options.API.RequireLocalHostAuth) +} + +func TestNewUsesLoopbackWhenAddressIsEmpty(t *testing.T) { + server := New(nil, " ", ServerConfig{}, slog.Default()) + + assert.Equal(t, DefaultListenAddress, server.Addr()) + assert.Equal(t, DefaultListenAddress, server.Config().Addr) +} + +func TestLoopbackListenAddress(t *testing.T) { + tests := map[string]bool{ + "127.0.0.1:3242": true, + "127.99.1.2:0": true, + "localhost:3242": true, + "[::1]:3242": true, + "[::1%1]:3242": true, + ":3242": false, + "0.0.0.0:3242": false, + "[::]:3242": false, + "192.0.2.1:3242": false, + "viiper.test:42": false, + "not-an-address": false, + } + + for addr, expected := range tests { + t.Run(addr, func(t *testing.T) { + assert.Equal(t, expected, isLoopbackListenAddress(addr)) + }) + } +} + +func TestValidateSecurityConfiguration(t *testing.T) { + tests := []struct { + name string + addr string + config ServerConfig + wantError string + }{ + { + name: "explicit local development opt-out", + addr: "127.0.0.1:3242", + config: ServerConfig{}, + }, + { + name: "authenticated localhost needs credential", + addr: "127.0.0.1:3242", + config: ServerConfig{RequireLocalHostAuth: true}, + wantError: "authentication is required for localhost", + }, + { + name: "authenticated localhost", + addr: "127.0.0.1:3242", + config: ServerConfig{RequireLocalHostAuth: true, Password: "secret"}, + }, + { + name: "wildcard needs credential", + addr: ":3242", + config: ServerConfig{}, + wantError: "may accept remote connections", + }, + { + name: "specific remote interface needs credential", + addr: "192.0.2.10:3242", + config: ServerConfig{}, + wantError: "may accept remote connections", + }, + { + name: "explicit authenticated remote listener", + addr: ":3242", + config: ServerConfig{Password: "secret"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateSecurityConfiguration(test.addr, test.config) + if test.wantError == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, test.wantError) + }) + } +} diff --git a/internal/server/api/server.go b/internal/server/api/server.go index eb0b1d65..8fd0f911 100644 --- a/internal/server/api/server.go +++ b/internal/server/api/server.go @@ -12,11 +12,13 @@ import ( "regexp" "strconv" "strings" + "sync" "time" "github.com/Alia5/VIIPER/internal/server/api/auth" apierror "github.com/Alia5/VIIPER/internal/server/api/error" "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/transport/udecx" pusb "github.com/Alia5/VIIPER/usb" "github.com/Alia5/VIIPER/viipertypes" ) @@ -32,6 +34,11 @@ type Server struct { deviceStreams deviceStreamCoordinator } +// DefaultListenAddress deliberately names one loopback interface. An empty +// host (for example, ":3242") asks the operating system to listen on every +// interface and must only be selected explicitly by an administrator. +const DefaultListenAddress = "127.0.0.1:3242" + // microphonePCMResetter is implemented by audio-capable virtual controllers. // Its reset is coordinated with stream ownership instead of individual device // handlers so a same-device replacement can retain already-buffered capture. @@ -48,6 +55,11 @@ const deviceStreamReconnectGrace = 250 * time.Millisecond // New creates a new ApiServer bound to a server.Server instance. func New(s *usb.Server, addr string, config ServerConfig, logger *slog.Logger) *Server { cfg := config + addr = strings.TrimSpace(addr) + if addr == "" { + addr = DefaultListenAddress + } + cfg.Addr = addr a := &Server{ usbs: s, addr: addr, @@ -71,11 +83,12 @@ func (s *Server) Config() *ServerConfig { return s.config } // generation owner used for reconnects. A stream that claims the device before // the timeout atomically cancels this cleanup. func (s *Server) ScheduleDeviceCleanup(busID uint32, devID string, - deviceContext context.Context) { - key := deviceStreamKey{busID: busID, devID: devID} + deviceContext context.Context, nativeRegistration *udecx.DeviceRegistration) { + expected := cloneNativeRegistration(nativeRegistration) + key := newDeviceStreamKey(busID, devID, deviceContext) s.deviceStreams.scheduleCleanup(key, s.config.DeviceHandlerConnectTimeout, deviceContext, func() { - if err := s.usbs.RemoveDeviceByID(busID, devID); err != nil { + if err := s.removeRegisteredDevice(busID, devID, expected); err != nil { s.logger.Error("timeout: failed to remove device", "busID", busID, "deviceID", devID, "error", err) } else { @@ -85,6 +98,29 @@ func (s *Server) ScheduleDeviceCleanup(busID uint32, devID string, }) } +func cloneNativeRegistration( + registration *udecx.DeviceRegistration, +) *udecx.DeviceRegistration { + if registration == nil { + return nil + } + copy := *registration + return © +} + +func (s *Server) removeRegisteredDevice( + busID uint32, devID string, nativeRegistration *udecx.DeviceRegistration, +) error { + if nativeRegistration != nil { + return s.usbs.RemoveNativeDeviceExact(busID, devID, *nativeRegistration) + } + if s.usbs.NativeTransportEnabled() { + return fmt.Errorf("%w: cleanup has no exact native registration for bus %d device %s", + usb.ErrNativeDeviceCorrelationMismatch, busID, devID) + } + return s.usbs.RemoveDeviceByID(busID, devID) +} + // Addr returns the actual address the server is listening on. // If Start hasn't been called yet, it returns the configured address. func (s *Server) Addr() string { @@ -96,6 +132,9 @@ func (s *Server) Addr() string { // Start listens on the configured address and serves incoming API commands. func (s *Server) Start() error { + if err := validateSecurityConfiguration(s.addr, *s.config); err != nil { + return err + } ln, err := net.Listen("tcp", s.addr) if err != nil { return err @@ -109,6 +148,33 @@ func (s *Server) Start() error { return nil } +func validateSecurityConfiguration(addr string, config ServerConfig) error { + passwordPresent := strings.TrimSpace(config.Password) != "" + if config.RequireLocalHostAuth && !passwordPresent { + return errors.New("API authentication is required for localhost, but no API credential is configured") + } + if !isLoopbackListenAddress(addr) && !passwordPresent { + return fmt.Errorf("API address %q may accept remote connections, but no API credential is configured", addr) + } + return nil +} + +func isLoopbackListenAddress(addr string) bool { + host, _, err := net.SplitHostPort(strings.TrimSpace(addr)) + if err != nil { + return false + } + host = strings.Trim(strings.TrimSpace(host), "[]") + if strings.EqualFold(host, "localhost") { + return true + } + if zone := strings.LastIndexByte(host, '%'); zone >= 0 { + host = host[:zone] + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + // Close stops the API server. func (s *Server) Close() { if s.ln != nil { @@ -160,7 +226,7 @@ func (s *Server) writeOK(w io.Writer, rest string) { } func (s *Server) handleConn(conn net.Conn) { - defer conn.Close() //nolint:errcheck + defer func() { _ = conn.Close() }() connCtx, connCancel := context.WithCancel(context.Background()) defer connCancel() @@ -201,7 +267,7 @@ func (s *Server) handleConn(conn net.Conn) { } sessionKey := auth.DeriveSessionKey(key, serverNonce, clientNonce) - secConn, err := auth.WrapConn(conn, sessionKey) + secConn, err := auth.WrapServerConn(conn, sessionKey) if err != nil { connLogger.Error("wrap secure conn failed", "error", err) return @@ -273,7 +339,8 @@ func (s *Server) handleConn(conn net.Conn) { // path. Keep that reader in front of the connection for the device // handler; otherwise the first input/microphone frame of a reconnect can // disappear in the handshake reader and stall framing indefinitely. - streamConn := &bufferedReadConn{Conn: conn, reader: r} + streamConn := newStreamLifetimeConn( + &bufferedReadConn{Conn: conn, reader: r}) busIDStr, ok := params["busId"] if !ok { s.writeError(w, apierror.ErrBadRequest("missing busId parameter")) @@ -297,11 +364,13 @@ func (s *Server) handleConn(conn net.Conn) { } var dev pusb.Device var devCtx context.Context + var devID uint32 metas := bus.GetAllDeviceMetas() for _, meta := range metas { if fmt.Sprintf("%d", meta.Meta.DevID) == devIDStr { dev = meta.Dev devCtx = bus.GetDeviceContext(dev) + devID = meta.Meta.DevID break } } @@ -309,9 +378,23 @@ func (s *Server) handleConn(conn net.Conn) { s.writeError(w, apierror.ErrNotFound(fmt.Sprintf("device %s not found on bus %d", devIDStr, busID))) return } + var nativeRegistration *udecx.DeviceRegistration + if s.usbs.NativeTransportEnabled() { + registration, found := s.usbs.NativeDeviceRegistrationForDevice( + uint32(busID), devID, dev, devCtx) + if !found { + s.writeError(w, apierror.ErrConflict( + "native device lifetime changed before stream admission")) + return + } + nativeRegistration = cloneNativeRegistration(®istration) + } - streamKey := deviceStreamKey{busID: uint32(busID), devID: devIDStr} + streamKey := newDeviceStreamKey(uint32(busID), devIDStr, devCtx) lease := s.deviceStreams.claim(streamKey, streamConn) + if lease == nil { + return + } handlerStarted := false defer func() { if !handlerStarted { @@ -324,7 +407,8 @@ func (s *Server) handleConn(conn net.Conn) { resetter.ResetMicrophonePCM() } }, func() { - if err := bus.RemoveDeviceByID(devIDStr); err != nil { + if err := s.removeRegisteredDevice( + uint32(busID), devIDStr, nativeRegistration); err != nil { connLogger.Error("disconnect timeout: failed to remove device", "busID", busID, "deviceID", devIDStr, "error", err) } else { @@ -361,6 +445,45 @@ type bufferedReadConn struct { reader *bufio.Reader } +type streamLifetimeConn struct { + net.Conn + done chan struct{} + once sync.Once +} + +func newStreamLifetimeConn(conn net.Conn) *streamLifetimeConn { + return &streamLifetimeConn{Conn: conn, done: make(chan struct{})} +} + +func (c *streamLifetimeConn) StreamDone() <-chan struct{} { + return c.done +} + +func (c *streamLifetimeConn) Read(buffer []byte) (int, error) { + n, err := c.Conn.Read(buffer) + if err != nil { + c.signalClosed() + } + return n, err +} + +func (c *streamLifetimeConn) Write(buffer []byte) (int, error) { + n, err := c.Conn.Write(buffer) + if err != nil { + c.signalClosed() + } + return n, err +} + +func (c *streamLifetimeConn) Close() error { + c.signalClosed() + return c.Conn.Close() +} + +func (c *streamLifetimeConn) signalClosed() { + c.once.Do(func() { close(c.done) }) +} + func (c *bufferedReadConn) Read(buffer []byte) (int, error) { return c.reader.Read(buffer) } diff --git a/internal/server/usb/native.go b/internal/server/usb/native.go new file mode 100644 index 00000000..ba3b5f64 --- /dev/null +++ b/internal/server/usb/native.go @@ -0,0 +1,802 @@ +package usb + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/Alia5/VIIPER/internal/transport/udecx" + usbdevice "github.com/Alia5/VIIPER/usb" +) + +type nativeLaneKey struct { + deviceID uint64 + generation uint32 + endpointGeneration uint32 + endpoint uint8 + attributes uint8 + interval uint8 + maxPacket uint16 +} + +type nativeSessionKey struct { + deviceID uint64 + generation uint32 +} + +type nativeEndpointSignature struct { + endpointGeneration uint32 + address uint8 + attributes uint8 + interval uint8 + maxPacket uint16 +} + +type nativeSessionState struct { + mu sync.Mutex + active map[nativeEndpointSignature]struct{} +} + +type nativeClockSample struct { + now time.Time + frame uint32 +} + +type nativeIsoEndpoint struct { + number uint32 + direction uint32 + interval time.Duration + key nativeLaneKey +} + +const ( + // USBD_ISO_START_FRAME_RANGE from usb.h. + usbdIsoStartFrameRange = int64(1024) +) + +type nativeUSBDCompletionError struct { + status uint32 + err error +} + +func (e *nativeUSBDCompletionError) Error() string { return e.err.Error() } +func (e *nativeUSBDCompletionError) Unwrap() error { return e.err } +func (e *nativeUSBDCompletionError) USBDCompletionStatus() uint32 { + return e.status +} + +func nativeBadStartFrameError(format string, args ...any) error { + return &nativeUSBDCompletionError{ + status: udecx.USBDStatusBadStartFrame, + err: fmt.Errorf(format, args...), + } +} + +// NativeProcessor adapts the native UdeCx broker to the same control and +// transfer engine used by USB/IP. Transport-specific clocks live here; device +// state, feedback, HID, audio, and descriptor behavior remain in usb.Device. +type NativeProcessor struct { + server *Server + mu sync.Mutex + next map[nativeLaneKey]time.Time + lastIn map[nativeLaneKey][]byte + sessions map[nativeSessionKey]*nativeSessionState + clock func() nativeClockSample + wait func(context.Context, time.Time) bool +} + +func NewNativeProcessor(server *Server) (*NativeProcessor, error) { + if server == nil { + return nil, errors.New("native UDE processor requires a USB server engine") + } + return &NativeProcessor{ + server: server, + next: make(map[nativeLaneKey]time.Time), + lastIn: make(map[nativeLaneKey][]byte), + sessions: make(map[nativeSessionKey]*nativeSessionState), + clock: nativeClockSnapshot, + wait: waitUntilContext, + }, nil +} + +func (p *NativeProcessor) Reset(dev usbdevice.Device, identity udecx.DeviceIdentity) { + key := nativeSessionKey{deviceID: identity.DeviceID, generation: identity.Generation} + session := p.lockSession(key) + p.resetDeviceLocked(dev, identity, session) + p.mu.Lock() + if p.sessions[key] == session { + delete(p.sessions, key) + } + p.mu.Unlock() + session.mu.Unlock() +} + +func (p *NativeProcessor) lockSession(key nativeSessionKey) *nativeSessionState { + for { + p.mu.Lock() + session := p.sessions[key] + if session == nil { + session = &nativeSessionState{active: make(map[nativeEndpointSignature]struct{})} + p.sessions[key] = session + } + p.mu.Unlock() + + session.mu.Lock() + p.mu.Lock() + current := p.sessions[key] + p.mu.Unlock() + if current == session { + return session + } + // Reset retired this state while this goroutine waited. Retry against + // the current generation-owned state rather than mutating an orphan. + session.mu.Unlock() + } +} + +func (p *NativeProcessor) resetDeviceLocked(dev usbdevice.Device, identity udecx.DeviceIdentity, + session *nativeSessionState) { + p.invalidateInterruptInput(dev, 0) + p.server.resetInterfaceAlts(dev) + p.clearDeviceTransportLocked(identity, session) +} + +func (p *NativeProcessor) invalidateInterruptInput(dev usbdevice.Device, endpoint uint8) { + if input, ok := dev.(usbdevice.InterruptInputLifecycleDevice); ok { + input.InvalidateInterruptInput(endpoint) + } +} + +func (p *NativeProcessor) clearDeviceTransportLocked(identity udecx.DeviceIdentity, + session *nativeSessionState) { + p.mu.Lock() + for key := range p.next { + if key.deviceID == identity.DeviceID && key.generation == identity.Generation { + delete(p.next, key) + delete(p.lastIn, key) + } + } + for key := range p.lastIn { + if key.deviceID == identity.DeviceID && key.generation == identity.Generation { + delete(p.lastIn, key) + } + } + p.mu.Unlock() + clear(session.active) +} + +func (p *NativeProcessor) Lifecycle(ctx context.Context, dev usbdevice.Device, op udecx.Operation) error { + if err := ctx.Err(); err != nil { + return err + } + identity := udecx.DeviceIdentity{DeviceID: op.DeviceID, Generation: op.Generation} + sessionKey := nativeSessionKey{deviceID: op.DeviceID, generation: op.Generation} + session := p.lockSession(sessionKey) + defer session.mu.Unlock() + // A newer device barrier may cancel this lifecycle operation while it waits + // behind an older callback on the same session. Recheck after acquiring the + // mutation lock so the retired reset/purge cannot cross the new boundary. + if err := ctx.Err(); err != nil { + return err + } + key := nativeLaneKey{ + deviceID: op.DeviceID, generation: op.Generation, + endpointGeneration: op.EndpointGeneration, endpoint: op.EndpointAddress, + attributes: op.EndpointAttributes, interval: op.EndpointInterval, + maxPacket: op.EndpointMaxPacketSize, + } + + switch op.Kind { + case udecx.OperationEndpointStart: + p.clearEndpointAddressLanes(key) + p.invalidateInterruptInput(dev, op.EndpointAddress) + p.activateEndpointLocked(dev, op, session) + case udecx.OperationEndpointPurge: + p.clearEndpointLanes(key) + p.invalidateInterruptInput(dev, op.EndpointAddress) + // Closing the last endpoint of an alternate setting already establishes + // the controller's media-generation boundary through + // SetInterfaceAltSetting(0). Reset the individual pipe only when the + // interface stays active (or the endpoint cannot be mapped). Otherwise a + // native purge would flush the PlayStation stream twice while USB/IP + // closes it once. + resetByInterfaceClose := p.deactivateEndpointLocked(dev, op, session) + if resetter, ok := dev.(usbdevice.EndpointResetDevice); ok && !resetByInterfaceClose { + resetter.ResetEndpoint(op.EndpointAddress) + } + case udecx.OperationEndpointReset: + p.clearEndpointLanes(key) + p.invalidateInterruptInput(dev, op.EndpointAddress) + if resetter, ok := dev.(usbdevice.EndpointResetDevice); ok { + resetter.ResetEndpoint(op.EndpointAddress) + } + case udecx.OperationDeviceReset: + p.resetDeviceLocked(dev, identity, session) + case udecx.OperationDeviceD0Entry, udecx.OperationDeviceD0Exit: + // A link-power transition is not a USB reset. Preserve the selected + // audio interfaces and controller state, but discard stale service-clock + // anchors so the first resumed transfer starts from the current time. + p.invalidateInterruptInput(dev, 0) + p.clearDeviceLanes(identity) + case udecx.OperationSetInterface: + // Some UdeCx stacks return incorrect interface/alternate values for + // composite devices. Treat this callback data as + // a hint only. Interfaces with endpoint-bearing alternate settings are + // driven by the exact endpoint descriptors carried by start/purge and + // transfer operations instead. + p.applyInterfaceHintLocked(dev, op) + default: + return fmt.Errorf("unsupported native UDE lifecycle operation %d", op.Kind) + } + return nil +} + +func signatureFromOperation(op udecx.Operation) nativeEndpointSignature { + return nativeEndpointSignature{ + endpointGeneration: op.EndpointGeneration, + address: op.EndpointAddress, attributes: op.EndpointAttributes, + interval: op.EndpointInterval, maxPacket: op.EndpointMaxPacketSize, + } +} + +func signatureFromDescriptor(endpoint usbdevice.EndpointDescriptor) nativeEndpointSignature { + return nativeEndpointSignature{ + address: endpoint.BEndpointAddress, attributes: endpoint.BMAttributes, + interval: endpoint.BInterval, maxPacket: endpoint.WMaxPacketSize, + } +} + +func sameNativeEndpointShape(left, right nativeEndpointSignature) bool { + return left.address == right.address && left.attributes == right.attributes && + left.interval == right.interval && left.maxPacket == right.maxPacket +} + +func nativeSignatureFromDescriptor(speed uint32, + endpoint usbdevice.EndpointDescriptor) (nativeEndpointSignature, bool) { + projected, err := udecx.EndpointDescriptorForNativeUdeCx( + udecx.DeviceSpeed(speed), endpoint) + if err != nil { + return nativeEndpointSignature{}, false + } + return signatureFromDescriptor(projected), true +} + +func descriptorInterfaceAltForEndpoint(desc *usbdevice.Descriptor, + signature nativeEndpointSignature) (uint8, uint8, bool) { + if desc == nil || signature.address == 0 { + return 0, 0, false + } + var interfaceNumber, alternateSetting uint8 + found := false + for _, iface := range desc.Interfaces { + if iface.Descriptor.BAlternateSetting == 0 { + continue + } + for _, endpoint := range iface.Endpoints { + projected, valid := nativeSignatureFromDescriptor(desc.Device.Speed, endpoint) + if !valid || !sameNativeEndpointShape(projected, signature) { + continue + } + candidateInterface := iface.Descriptor.BInterfaceNumber + candidateAlt := iface.Descriptor.BAlternateSetting + if found && (candidateInterface != interfaceNumber || candidateAlt != alternateSetting) { + return 0, 0, false + } + interfaceNumber, alternateSetting, found = candidateInterface, candidateAlt, true + } + } + return interfaceNumber, alternateSetting, found +} + +func descriptorInterfaceUsesEndpointLifecycle(desc *usbdevice.Descriptor, interfaceNumber uint8) bool { + if desc == nil { + return false + } + for _, iface := range desc.Interfaces { + if iface.Descriptor.BInterfaceNumber == interfaceNumber && + iface.Descriptor.BAlternateSetting != 0 && len(iface.Endpoints) != 0 { + return true + } + } + return false +} + +func descriptorInterfaceAltIsActive(desc *usbdevice.Descriptor, interfaceNumber, alternateSetting uint8, + active map[nativeEndpointSignature]struct{}) bool { + if desc == nil { + return false + } + for _, iface := range desc.Interfaces { + if iface.Descriptor.BInterfaceNumber != interfaceNumber || + iface.Descriptor.BAlternateSetting != alternateSetting { + continue + } + for _, endpoint := range iface.Endpoints { + projected, valid := nativeSignatureFromDescriptor(desc.Device.Speed, endpoint) + if valid { + for signature := range active { + if sameNativeEndpointShape(projected, signature) { + return true + } + } + } + } + } + return false +} + +func (p *NativeProcessor) activateEndpoint(dev usbdevice.Device, op udecx.Operation) { + key := nativeSessionKey{deviceID: op.DeviceID, generation: op.Generation} + session := p.lockSession(key) + defer session.mu.Unlock() + p.activateEndpointLocked(dev, op, session) +} + +func (p *NativeProcessor) activateEndpointLocked(dev usbdevice.Device, op udecx.Operation, + session *nativeSessionState) { + signature := signatureFromOperation(op) + interfaceNumber, alternateSetting, ok := descriptorInterfaceAltForEndpoint( + dev.GetDescriptor(), signature) + if !ok { + return + } + for active := range session.active { + if active.address == signature.address && + active.endpointGeneration != signature.endpointGeneration { + delete(session.active, active) + } + } + session.active[signature] = struct{}{} + if p.server.getInterfaceAlt(dev, interfaceNumber) != alternateSetting { + p.server.setInterfaceAlt(dev, interfaceNumber, alternateSetting) + p.server.notifyInterfaceAlt(dev, interfaceNumber, alternateSetting) + } +} + +func (p *NativeProcessor) deactivateEndpointLocked(dev usbdevice.Device, op udecx.Operation, + session *nativeSessionState) bool { + signature := signatureFromOperation(op) + interfaceNumber, alternateSetting, ok := descriptorInterfaceAltForEndpoint( + dev.GetDescriptor(), signature) + if !ok { + return false + } + delete(session.active, signature) + if p.server.getInterfaceAlt(dev, interfaceNumber) == alternateSetting && + !descriptorInterfaceAltIsActive(dev.GetDescriptor(), interfaceNumber, alternateSetting, session.active) { + p.server.setInterfaceAlt(dev, interfaceNumber, 0) + p.server.notifyInterfaceAlt(dev, interfaceNumber, 0) + return true + } + return false +} + +func (p *NativeProcessor) applyInterfaceHintLocked(dev usbdevice.Device, op udecx.Operation) { + desc := dev.GetDescriptor() + if !descriptorHasInterfaceAlt(desc, op.InterfaceNumber, op.InterfaceSetting) || + descriptorInterfaceUsesEndpointLifecycle(desc, op.InterfaceNumber) { + return + } + if p.server.getInterfaceAlt(dev, op.InterfaceNumber) == op.InterfaceSetting { + return + } + p.server.setInterfaceAlt(dev, op.InterfaceNumber, op.InterfaceSetting) + p.server.notifyInterfaceAlt(dev, op.InterfaceNumber, op.InterfaceSetting) +} + +func (p *NativeProcessor) clearDeviceLanes(identity udecx.DeviceIdentity) { + p.mu.Lock() + for key := range p.next { + if key.deviceID == identity.DeviceID && key.generation == identity.Generation { + delete(p.next, key) + delete(p.lastIn, key) + } + } + for key := range p.lastIn { + if key.deviceID == identity.DeviceID && key.generation == identity.Generation { + delete(p.lastIn, key) + } + } + p.mu.Unlock() +} + +func (p *NativeProcessor) clearEndpointLanes(endpoint nativeLaneKey) { + p.mu.Lock() + for key := range p.next { + if key.deviceID == endpoint.deviceID && key.generation == endpoint.generation && + key.endpoint == endpoint.endpoint && + key.endpointGeneration == endpoint.endpointGeneration { + delete(p.next, key) + delete(p.lastIn, key) + } + } + for key := range p.lastIn { + if key.deviceID == endpoint.deviceID && key.generation == endpoint.generation && + key.endpoint == endpoint.endpoint && + key.endpointGeneration == endpoint.endpointGeneration { + delete(p.lastIn, key) + } + } + p.mu.Unlock() +} + +func (p *NativeProcessor) clearEndpointAddressLanes(endpoint nativeLaneKey) { + p.mu.Lock() + for key := range p.next { + if key.deviceID == endpoint.deviceID && key.generation == endpoint.generation && + key.endpoint == endpoint.endpoint { + delete(p.next, key) + delete(p.lastIn, key) + } + } + for key := range p.lastIn { + if key.deviceID == endpoint.deviceID && key.generation == endpoint.generation && + key.endpoint == endpoint.endpoint { + delete(p.lastIn, key) + } + } + p.mu.Unlock() +} + +func nativeLaneKeyFromOperation(op udecx.Operation) nativeLaneKey { + return nativeLaneKey{ + deviceID: op.DeviceID, generation: op.Generation, + endpointGeneration: op.EndpointGeneration, endpoint: op.EndpointAddress, + attributes: op.EndpointAttributes, interval: op.EndpointInterval, + maxPacket: op.EndpointMaxPacketSize, + } +} + +func logicalEndpointForNativeSignature(desc *usbdevice.Descriptor, + signature nativeEndpointSignature) (usbdevice.EndpointDescriptor, bool) { + if desc == nil { + return usbdevice.EndpointDescriptor{}, false + } + for _, iface := range desc.Interfaces { + for _, endpoint := range iface.Endpoints { + projected, valid := nativeSignatureFromDescriptor(desc.Device.Speed, endpoint) + if valid && sameNativeEndpointShape(projected, signature) { + return endpoint, true + } + } + } + return usbdevice.EndpointDescriptor{}, false +} + +func nativeIsoServiceInterval(speed uint32, bInterval uint8) (time.Duration, error) { + if bInterval == 0 { + return 0, errors.New("native ISO endpoint has zero bInterval") + } + if speed == uint32(udecx.DeviceSpeedLow) { + return 0, errors.New("low-speed USB does not support isochronous endpoints") + } + if speed >= uint32(udecx.DeviceSpeedHigh) { + if bInterval > 16 { + return 0, fmt.Errorf("native high-speed ISO bInterval %d exceeds 16", bInterval) + } + return time.Duration(1<<(bInterval-1)) * 125 * time.Microsecond, nil + } + return time.Duration(bInterval) * time.Millisecond, nil +} + +func resolveNativeIsoEndpoint(dev usbdevice.Device, op udecx.Operation) (nativeIsoEndpoint, error) { + desc := dev.GetDescriptor() + if desc == nil { + return nativeIsoEndpoint{}, errors.New("native ISO operation has no device descriptor") + } + signature := signatureFromOperation(op) + if signature.address == 0 || signature.attributes&0x03 != 0x01 { + return nativeIsoEndpoint{}, fmt.Errorf( + "native ISO operation has invalid endpoint signature %+v", signature) + } + direction := uint8(0) + usbDirection := usbdevice.DirectionOut + if signature.address&0x80 != 0 { + direction = 1 + usbDirection = usbdevice.DirectionIn + } + flagDirection := uint8(0) + if op.TransferFlags&udecx.TransferFlagDirectionIn != 0 { + flagDirection = 1 + } + if op.Direction != direction || flagDirection != direction { + return nativeIsoEndpoint{}, fmt.Errorf( + "native ISO endpoint 0x%02x direction %d disagrees with operation %d/flags %d", + signature.address, direction, op.Direction, flagDirection) + } + logicalEndpoint, ok := logicalEndpointForNativeSignature(desc, signature) + if !ok { + return nativeIsoEndpoint{}, fmt.Errorf( + "native ISO endpoint signature %+v is not present in the device descriptor", signature) + } + interval, err := nativeIsoServiceInterval(desc.Device.Speed, logicalEndpoint.BInterval) + if err != nil { + return nativeIsoEndpoint{}, err + } + return nativeIsoEndpoint{ + number: uint32(signature.address & 0x0f), direction: usbDirection, interval: interval, + key: nativeLaneKeyFromOperation(op), + }, nil +} + +func (p *NativeProcessor) Process(ctx context.Context, dev usbdevice.Device, op udecx.Operation) (udecx.Completion, error) { + // The kernel cancel notification can win after Host's final cancellation + // check but immediately before this callback. Reject that already-retired + // request before reserving an ISO service window, activating an endpoint, or + // publishing an output/state report into the immutable controller engine. + if err := ctx.Err(); err != nil { + return udecx.Completion{}, err + } + if dev == nil { + return udecx.Completion{}, errors.New("native UDE operation has no device") + } + if op.TransferLength > udecx.MaxTransferBytes || len(op.Payload) > udecx.MaxTransferBytes { + return udecx.Completion{}, udecx.ErrLimitExceeded + } + if len(op.IsoPackets) != 0 { + if op.Kind != udecx.OperationTransfer { + return udecx.Completion{}, fmt.Errorf( + "native operation kind %d carries ISO packets", op.Kind) + } + for index, packet := range op.IsoPackets { + if packet.Offset > op.TransferLength || + packet.Length > op.TransferLength-packet.Offset { + return udecx.Completion{}, fmt.Errorf( + "native ISO packet %d is outside transfer buffer", index) + } + } + endpoint, err := resolveNativeIsoEndpoint(dev, op) + if err != nil { + return udecx.Completion{}, err + } + if endpoint.direction == usbdevice.DirectionIn { + return p.processIsoIn(ctx, dev, op, endpoint) + } + return p.processIsoOut(ctx, dev, op, endpoint) + } + ep := uint32(op.EndpointAddress & 0x0f) + dir := usbdevice.DirectionOut + if op.Direction != 0 { + dir = usbdevice.DirectionIn + } + key := nativeLaneKeyFromOperation(op) + + switch { + case op.Kind == udecx.OperationControl: + return p.processControl(ctx, dev, op, ep, dir) + case dir == usbdevice.DirectionIn: + return p.processInterruptIn(ctx, dev, op, ep, dir, key) + default: + p.server.processSubmit(ctx, dev, ep, dir, nil, op.Payload) + return successCompletion(op, op.TransferLength, nil, nil), ctx.Err() + } +} + +func (p *NativeProcessor) processControl(ctx context.Context, dev usbdevice.Device, + op udecx.Operation, ep, dir uint32) (udecx.Completion, error) { + if op.SetupPacket[0] == usbReqTypeStandardToDevice && op.SetupPacket[1] == usbReqSetConfiguration { + identity := udecx.DeviceIdentity{DeviceID: op.DeviceID, Generation: op.Generation} + session := p.lockSession(nativeSessionKey{ + deviceID: op.DeviceID, generation: op.Generation, + }) + // Server.processSubmit applies the USB request's interface reset and + // publishes that notification exactly once. Retire the native endpoint + // activity and media-clock state here after the host's device barrier has + // joined every pre-configuration callback. + p.invalidateInterruptInput(dev, 0) + p.clearDeviceTransportLocked(identity, session) + session.mu.Unlock() + } + setup := op.SetupPacket[:] + response := p.server.processSubmit(ctx, dev, ep, dir, setup, op.Payload) + if err := ctx.Err(); err != nil { + return udecx.Completion{}, err + } + if dir == usbdevice.DirectionOut { + return successCompletion(op, op.TransferLength, nil, nil), nil + } + if uint32(len(response)) > op.TransferLength { + response = response[:op.TransferLength] + } + return successCompletion(op, uint32(len(response)), response, nil), nil +} + +func (p *NativeProcessor) processInterruptIn(ctx context.Context, dev usbdevice.Device, + op udecx.Operation, ep, dir uint32, key nativeLaneKey) (udecx.Completion, error) { + interval := endpointInterval(dev.GetDescriptor(), ep) + if interval <= 0 { + interval = time.Millisecond + } + + for { + serviceTime := p.reserveServiceTime(key, interval) + if !waitUntilContext(ctx, serviceTime) { + return udecx.Completion{}, ctx.Err() + } + attemptCtx, cancel := context.WithTimeout(ctx, interval) + response := p.server.processSubmit(attemptCtx, dev, ep, dir, nil, nil) + expired := len(response) == 0 && errors.Is(attemptCtx.Err(), context.DeadlineExceeded) + cancel() + if ctx.Err() != nil { + return udecx.Completion{}, ctx.Err() + } + if len(response) != 0 { + if uint32(len(response)) > op.TransferLength { + response = response[:op.TransferLength] + } + p.mu.Lock() + p.lastIn[key] = append(p.lastIn[key][:0], response...) + p.mu.Unlock() + return successCompletion(op, uint32(len(response)), response, nil), nil + } + if expired { + p.mu.Lock() + cached := append([]byte(nil), p.lastIn[key]...) + p.mu.Unlock() + if len(cached) != 0 { + if uint32(len(cached)) > op.TransferLength { + cached = cached[:op.TransferLength] + } + return successCompletion(op, uint32(len(cached)), cached, nil), nil + } + continue + } + return successCompletion(op, 0, nil, nil), nil + } +} + +func (p *NativeProcessor) processIsoOut(ctx context.Context, dev usbdevice.Device, + op udecx.Operation, endpoint nativeIsoEndpoint) (udecx.Completion, error) { + duration := time.Duration(len(op.IsoPackets)) * endpoint.interval + serviceStart, serviceEnd, err := p.reserveIsoServiceWindow( + endpoint.key, op.StartFrame, op.TransferFlags, duration) + if err != nil { + return udecx.Completion{}, err + } + // The operation's full endpoint signature is the authoritative active + // UdeCx identity. Applying it only after frame validation avoids mutating + // alternate-setting state for a rejected explicit schedule. + p.activateEndpoint(dev, op) + if !p.wait(ctx, serviceStart) { + return udecx.Completion{}, ctx.Err() + } + p.server.processSubmit(ctx, dev, endpoint.number, endpoint.direction, nil, op.Payload) + if !p.wait(ctx, serviceEnd) { + return udecx.Completion{}, ctx.Err() + } + packets := make([]udecx.IsoPacket, len(op.IsoPackets)) + for i, packet := range op.IsoPackets { + packets[i] = udecx.IsoPacket{Offset: packet.Offset, Length: packet.Length} + } + return successCompletion(op, op.TransferLength, nil, packets), nil +} + +func (p *NativeProcessor) processIsoIn(ctx context.Context, dev usbdevice.Device, + op udecx.Operation, endpoint nativeIsoEndpoint) (udecx.Completion, error) { + duration := time.Duration(len(op.IsoPackets)) * endpoint.interval + serviceStart, _, err := p.reserveIsoServiceWindow( + endpoint.key, op.StartFrame, op.TransferFlags, duration) + if err != nil { + return udecx.Completion{}, err + } + p.activateEndpoint(dev, op) + payload := make([]byte, op.TransferLength) + packets := make([]udecx.IsoPacket, len(op.IsoPackets)) + actualTotal := uint32(0) + serviceTime := serviceStart + reader, direct := dev.(usbdevice.IsochronousInputDevice) + for i, packet := range op.IsoPackets { + if !p.wait(ctx, serviceTime) { + return udecx.Completion{}, ctx.Err() + } + serviceTime = serviceTime.Add(endpoint.interval) + var packetData []byte + if direct { + packetRegion := payload[packet.Offset : packet.Offset+packet.Length] + written, readErr := reader.ReadIsochronousInput(ctx, endpoint.number, packetRegion) + if readErr != nil { + return udecx.Completion{}, readErr + } + if written < 0 || uint32(written) > packet.Length { + return udecx.Completion{}, fmt.Errorf( + "native ISO packet %d encoded %d bytes into a %d-byte region", + i, written, packet.Length) + } + packetData = packetRegion[:written] + } else { + attemptCtx, cancel := context.WithTimeout(ctx, endpoint.interval) + packetData = p.server.processSubmit( + attemptCtx, dev, endpoint.number, endpoint.direction, nil, nil) + cancel() + } + if ctx.Err() != nil { + return udecx.Completion{}, ctx.Err() + } + if len(packetData) == 0 { + if direct { + packetData = payload[packet.Offset : packet.Offset+packet.Length] + } else { + packetData = make([]byte, packet.Length) + } + } + actual := min(packet.Length, uint32(len(packetData))) + if !direct { + copy(payload[packet.Offset:packet.Offset+actual], packetData[:actual]) + } + packets[i] = udecx.IsoPacket{Offset: packet.Offset, Length: actual} + actualTotal += actual + serviceTime = reanchorMissedIsoPacketSlot( + serviceTime, endpoint.interval, p.clock().now) + } + p.extendIsoServiceWindow(endpoint.key, serviceTime) + return successCompletion(op, actualTotal, payload, packets), nil +} + +func (p *NativeProcessor) extendIsoServiceWindow(key nativeLaneKey, serviceEnd time.Time) { + p.mu.Lock() + if serviceEnd.After(p.next[key]) { + p.next[key] = serviceEnd + } + p.mu.Unlock() +} + +func (p *NativeProcessor) reserveServiceTime(key nativeLaneKey, interval time.Duration) time.Time { + p.mu.Lock() + defer p.mu.Unlock() + now := time.Now() + serviceTime := p.next[key] + if serviceTime.IsZero() || now.Sub(serviceTime) >= interval { + serviceTime = now + } + p.next[key] = serviceTime.Add(interval) + return serviceTime +} + +func (p *NativeProcessor) reserveIsoServiceWindow( + key nativeLaneKey, startFrame, transferFlags uint32, duration time.Duration, +) (time.Time, time.Time, error) { + sample := p.clock() + delta := int64(int32(startFrame - sample.frame)) + explicit := transferFlags&udecx.TransferFlagStartIsoASAP == 0 + if explicit && (delta <= 0 || delta >= usbdIsoStartFrameRange) { + return time.Time{}, time.Time{}, nativeBadStartFrameError( + "native explicit ISO start frame %d is outside the future frame range from %d", + startFrame, sample.frame) + } + plannedStart := sample.now.Add(time.Duration(delta) * time.Millisecond) + if plannedStart.Before(sample.now) { + // A delayed ASAP dequeue must not replay elapsed USB frames in a burst. + // Explicit frames take the range-error path above instead. + plannedStart = sample.now + } + // For ASAP URBs the kernel has already discarded the caller's input value + // and replaced StartFrame with its ordered output reservation. This mapping + // gates host-side service only; controller media-clock correction remains in + // the device engine. + + p.mu.Lock() + defer p.mu.Unlock() + if previousEnd := p.next[key]; previousEnd.After(plannedStart) { + if explicit { + return time.Time{}, time.Time{}, nativeBadStartFrameError( + "native explicit ISO start frame %d overlaps the previous endpoint window", + startFrame) + } + plannedStart = previousEnd + } + serviceEnd := plannedStart.Add(duration) + p.next[key] = serviceEnd + return plannedStart, serviceEnd, nil +} + +func successCompletion(op udecx.Operation, transferLength uint32, payload []byte, + packets []udecx.IsoPacket) udecx.Completion { + return udecx.Completion{ + Token: op.Token, DeviceID: op.DeviceID, Generation: op.Generation, + EndpointGeneration: op.EndpointGeneration, + Status: 0, USBDStatus: 0, IsoPackets: packets, Payload: payload, + TransferLength: transferLength, + } +} diff --git a/internal/server/usb/native_frame_other.go b/internal/server/usb/native_frame_other.go new file mode 100644 index 00000000..71a5fead --- /dev/null +++ b/internal/server/usb/native_frame_other.go @@ -0,0 +1,15 @@ +//go:build !windows + +package usb + +import "time" + +var nativeProcessClockStart = time.Now() + +func nativeClockSnapshot() nativeClockSample { + now := time.Now() + return nativeClockSample{ + now: now, + frame: uint32(now.Sub(nativeProcessClockStart) / time.Millisecond), + } +} diff --git a/internal/server/usb/native_frame_windows.go b/internal/server/usb/native_frame_windows.go new file mode 100644 index 00000000..903c4d1a --- /dev/null +++ b/internal/server/usb/native_frame_windows.go @@ -0,0 +1,22 @@ +//go:build windows + +package usb + +import ( + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +var queryInterruptTimePrecise = windows.NewLazySystemDLL("api-ms-win-core-realtime-l1-1-1.dll"). + NewProc("QueryInterruptTimePrecise") + +func nativeClockSnapshot() nativeClockSample { + var interruptTime100ns uint64 + queryInterruptTimePrecise.Call(uintptr(unsafe.Pointer(&interruptTime100ns))) + return nativeClockSample{ + now: time.Now(), + frame: uint32(interruptTime100ns / 10_000), + } +} diff --git a/internal/server/usb/native_live_teardown_gate_test.go b/internal/server/usb/native_live_teardown_gate_test.go new file mode 100644 index 00000000..756c2d62 --- /dev/null +++ b/internal/server/usb/native_live_teardown_gate_test.go @@ -0,0 +1,532 @@ +package usb_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/Alia5/VIIPER/internal/transport/udecx" +) + +type nativeLiveTeardownDeviceKey struct { + deviceID uint64 + generation uint32 + deviceObject uint64 +} + +type nativeLiveTeardownEndpointKey struct { + nativeLiveTeardownDeviceKey + endpointObject uint64 + endpointAddress uint8 +} + +type nativeLivePurgeProgress struct { + key nativeLiveTeardownEndpointKey + beginSequence uint64 + quiescentSequence uint64 + drainEndSequence uint64 + completeEndSequence uint64 +} + +type nativeLiveEndpointHistory struct { + beginSequences []uint64 + cycles []*nativeLivePurgeProgress + building *nativeLivePurgeProgress + prefixFragments int +} + +type nativeLiveTeardownAudit struct { + complete bool + purgeCount int + diagnostic string +} + +func nativeLiveTraceEventName(event uint16) string { + switch event { + case udecx.TraceEndpointPurgeBegin: + return "endpoint-purge-begin" + case udecx.TraceEndpointDriverQuiescent: + return "endpoint-driver-quiescent" + case udecx.TraceEndpointDrainEnd: + return "endpoint-drain-end" + case udecx.TraceEndpointPurgeCompleteEnd: + return "endpoint-purge-complete-end" + case udecx.TraceEndpointCleanupEnd: + return "endpoint-cleanup-end" + case udecx.TraceDeviceCleanupEnd: + return "device-cleanup-end" + case udecx.TraceEndpointQuiescenceWatchdog: + return "endpoint-quiescence-watchdog" + case udecx.TraceCompletionRundownWatchdog: + return "completion-rundown-watchdog" + case udecx.TraceControllerRundownWatchdog: + return "controller-rundown-watchdog" + case udecx.TraceOwnerRundownWatchdog: + return "owner-rundown-watchdog" + default: + return fmt.Sprintf("event-%d", event) + } +} + +func nativeLiveTeardownDevice(record udecx.LifecycleTraceRecord) nativeLiveTeardownDeviceKey { + return nativeLiveTeardownDeviceKey{ + deviceID: record.DeviceID, + generation: record.Generation, + deviceObject: record.DeviceObject, + } +} + +func nativeLiveTeardownEndpoint(record udecx.LifecycleTraceRecord) nativeLiveTeardownEndpointKey { + return nativeLiveTeardownEndpointKey{ + nativeLiveTeardownDeviceKey: nativeLiveTeardownDevice(record), + endpointObject: record.EndpointObject, + endpointAddress: record.EndpointAddress, + } +} + +func nativeLiveEndpointDiagnostic( + prefix string, + progress *nativeLivePurgeProgress, + last udecx.LifecycleTraceRecord, +) string { + return fmt.Sprintf( + "%s: device=%#x generation=%d device-object=%#x endpoint=%#02x endpoint-object=%#x purge-sequence=%d last-sequence=%d last-event=%s line=%d queue-state=%#x active=%d", + prefix, + progress.key.deviceID, + progress.key.generation, + progress.key.deviceObject, + progress.key.endpointAddress, + progress.key.endpointObject, + progress.beginSequence, + last.PublishedSequence, + nativeLiveTraceEventName(last.Event), + last.Line, + last.QueueState, + last.ActiveOperations, + ) +} + +func auditNativeLiveTeardown( + trace udecx.LifecycleTrace, + stats udecx.Stats, +) (nativeLiveTeardownAudit, error) { + if trace.StatusFlags&udecx.LifecycleTraceStatusWatchdogFired != 0 { + return nativeLiveTeardownAudit{}, fmt.Errorf( + "lifecycle watchdog status is sticky even if its record rolled out: flags=%#x latest-sequence=%d", + trace.StatusFlags, trace.LatestSequence) + } + if trace.StatusFlags&udecx.LifecycleTraceStatusDroppedRecord != 0 { + return nativeLiveTeardownAudit{}, fmt.Errorf( + "lifecycle recorder dropped a contended record: flags=%#x latest-sequence=%d", + trace.StatusFlags, trace.LatestSequence) + } + if trace.LatestSequence == 0 { + return nativeLiveTeardownAudit{diagnostic: "no lifecycle records are published yet"}, nil + } + wantRecords := trace.LatestSequence + if wantRecords > udecx.LifecycleTraceCapacity { + wantRecords = udecx.LifecycleTraceCapacity + } + if uint64(len(trace.Records)) != wantRecords { + return nativeLiveTeardownAudit{diagnostic: fmt.Sprintf( + "lifecycle suffix snapshot is incomplete: latest-sequence=%d records=%d want=%d", + trace.LatestSequence, len(trace.Records), wantRecords)}, nil + } + firstSequence := trace.LatestSequence - uint64(len(trace.Records)) + 1 + for index, record := range trace.Records { + expected := firstSequence + uint64(index) + if record.PublishedSequence != expected { + return nativeLiveTeardownAudit{diagnostic: fmt.Sprintf( + "lifecycle snapshot has a sequence gap: index=%d sequence=%d want=%d latest=%d", + index, record.PublishedSequence, expected, trace.LatestSequence)}, nil + } + } + truncation := "" + if firstSequence > 1 { + truncation = fmt.Sprintf( + "retained lifecycle suffix starts at sequence %d (latest=%d capacity=%d); %d prefix records are unavailable", + firstSequence, trace.LatestSequence, udecx.LifecycleTraceCapacity, firstSequence-1) + } + withTruncation := func(diagnostic string) string { + if truncation == "" { + return diagnostic + } + return truncation + "; " + diagnostic + } + + histories := make(map[nativeLiveTeardownEndpointKey]*nativeLiveEndpointHistory) + lastEndpoint := make(map[nativeLiveTeardownEndpointKey]udecx.LifecycleTraceRecord) + lastDevice := make(map[nativeLiveTeardownDeviceKey]udecx.LifecycleTraceRecord) + endpointCleanup := make(map[nativeLiveTeardownEndpointKey]uint64) + deviceCleanup := make(map[nativeLiveTeardownDeviceKey]uint64) + historyFor := func(key nativeLiveTeardownEndpointKey) *nativeLiveEndpointHistory { + history := histories[key] + if history == nil { + history = &nativeLiveEndpointHistory{} + histories[key] = history + } + return history + } + + for _, record := range trace.Records { + if record.Event >= udecx.TraceEndpointQuiescenceWatchdog && + record.Event <= udecx.TraceOwnerRundownWatchdog { + return nativeLiveTeardownAudit{}, fmt.Errorf( + "lifecycle watchdog %s fired at sequence %d: device=%#x generation=%d endpoint=%#02x object=%#x line=%d queue-state=%#x active=%d pending=%d", + nativeLiveTraceEventName(record.Event), + record.PublishedSequence, record.DeviceID, record.Generation, + record.EndpointAddress, record.EndpointObject, record.Line, + record.QueueState, record.ActiveOperations, record.PendingOperations) + } + deviceKey := nativeLiveTeardownDevice(record) + lastDevice[deviceKey] = record + if record.EndpointObject != 0 { + lastEndpoint[nativeLiveTeardownEndpoint(record)] = record + } + + switch record.Event { + case udecx.TraceEndpointPurgeBegin: + key := nativeLiveTeardownEndpoint(record) + historyFor(key).beginSequences = append( + historyFor(key).beginSequences, record.PublishedSequence) + case udecx.TraceEndpointDriverQuiescent: + key := nativeLiveTeardownEndpoint(record) + history := historyFor(key) + if history.building != nil { + return nativeLiveTeardownAudit{}, fmt.Errorf( + "driver-quiescent sequence %d overlaps an unfinished retained cycle for device=%#x generation=%d endpoint=%#02x object=%#x", + record.PublishedSequence, key.deviceID, key.generation, + key.endpointAddress, key.endpointObject) + } + if record.Status != 0 || record.ActiveOperations != 0 { + return nativeLiveTeardownAudit{}, fmt.Errorf( + "driver-quiescent sequence %d is not terminal: status=%#x active=%d", + record.PublishedSequence, uint32(record.Status), record.ActiveOperations) + } + history.building = &nativeLivePurgeProgress{ + key: key, + quiescentSequence: record.PublishedSequence, + } + case udecx.TraceEndpointDrainEnd: + key := nativeLiveTeardownEndpoint(record) + history := historyFor(key) + if history.building == nil { + if firstSequence > 1 && len(history.cycles) == 0 { + history.prefixFragments++ + continue + } + return nativeLiveTeardownAudit{}, fmt.Errorf( + "drain-end sequence %d has no quiescent purge for device=%#x generation=%d endpoint=%#02x object=%#x", + record.PublishedSequence, key.deviceID, key.generation, + key.endpointAddress, key.endpointObject) + } + history.building.drainEndSequence = record.PublishedSequence + case udecx.TraceEndpointPurgeCompleteEnd: + key := nativeLiveTeardownEndpoint(record) + history := historyFor(key) + if history.building == nil || history.building.drainEndSequence == 0 { + if firstSequence > 1 && len(history.cycles) == 0 { + history.prefixFragments++ + history.building = nil + continue + } + return nativeLiveTeardownAudit{}, fmt.Errorf( + "purge-complete sequence %d has no drained purge for device=%#x generation=%d endpoint=%#02x object=%#x", + record.PublishedSequence, key.deviceID, key.generation, + key.endpointAddress, key.endpointObject) + } + history.building.completeEndSequence = record.PublishedSequence + history.cycles = append(history.cycles, history.building) + history.building = nil + case udecx.TraceEndpointCleanupEnd: + endpointCleanup[nativeLiveTeardownEndpoint(record)] = record.PublishedSequence + case udecx.TraceDeviceCleanupEnd: + deviceCleanup[deviceKey] = record.PublishedSequence + } + } + + progresses := make([]*nativeLivePurgeProgress, 0) + for key, history := range histories { + if history.building != nil { + last := lastEndpoint[key] + phase := "drain-end" + if history.building.drainEndSequence != 0 { + phase = "purge-complete-end" + } + return nativeLiveTeardownAudit{diagnostic: withTruncation(fmt.Sprintf( + "retained endpoint cycle has not reached %s: device=%#x generation=%d endpoint=%#02x object=%#x quiescent-sequence=%d last-sequence=%d last-event=%s line=%d", + phase, key.deviceID, key.generation, key.endpointAddress, key.endpointObject, + history.building.quiescentSequence, last.PublishedSequence, + nativeLiveTraceEventName(last.Event), last.Line))}, nil + } + beginCount := len(history.beginSequences) + cycleCount := len(history.cycles) + pairCount := beginCount + if cycleCount < pairCount { + pairCount = cycleCount + } + if beginCount > cycleCount { + return nativeLiveTeardownAudit{purgeCount: len(progresses), diagnostic: withTruncation(fmt.Sprintf( + "%d retained purge begin(s) for device=%#x generation=%d endpoint=%#02x object=%#x have only %d complete cycles; at least one has not reached driver-quiescent; begin-sequences=%v", + beginCount, key.deviceID, key.generation, key.endpointAddress, + key.endpointObject, cycleCount, history.beginSequences))}, nil + } + for index := 0; index < pairCount; index++ { + beginSequence := history.beginSequences[beginCount-pairCount+index] + progress := history.cycles[cycleCount-pairCount+index] + if beginSequence >= progress.quiescentSequence { + return nativeLiveTeardownAudit{purgeCount: len(progresses), diagnostic: withTruncation(fmt.Sprintf( + "retained purge begin sequence %d has no later complete cycle for device=%#x generation=%d endpoint=%#02x object=%#x", + beginSequence, key.deviceID, key.generation, key.endpointAddress, + key.endpointObject))}, nil + } + progress.beginSequence = beginSequence + progresses = append(progresses, progress) + } + } + + if len(progresses) == 0 && firstSequence == 1 { + last := trace.Records[len(trace.Records)-1] + return nativeLiveTeardownAudit{diagnostic: fmt.Sprintf( + "no endpoint purge was observed; latest sequence=%d event=%s line=%d", + last.PublishedSequence, nativeLiveTraceEventName(last.Event), last.Line)}, nil + } + + for _, progress := range progresses { + last := lastEndpoint[progress.key] + if sequence := endpointCleanup[progress.key]; sequence <= progress.completeEndSequence { + return nativeLiveTeardownAudit{purgeCount: len(progresses), diagnostic: withTruncation( + nativeLiveEndpointDiagnostic("purged endpoint has not reached endpoint-cleanup-end", progress, last))}, nil + } + if sequence := deviceCleanup[progress.key.nativeLiveTeardownDeviceKey]; sequence <= progress.completeEndSequence { + last = lastDevice[progress.key.nativeLiveTeardownDeviceKey] + return nativeLiveTeardownAudit{purgeCount: len(progresses), diagnostic: withTruncation( + nativeLiveEndpointDiagnostic("purged device has not reached device-cleanup-end", progress, last))}, nil + } + } + + if stats.ActiveDevices != 0 || stats.PendingOperations != 0 || stats.ReservedPorts != 0 { + return nativeLiveTeardownAudit{purgeCount: len(progresses), diagnostic: withTruncation(fmt.Sprintf( + "kernel teardown counters are not clean: ActiveDevices=%d PendingOperations=%d ReservedPorts=%d", + stats.ActiveDevices, stats.PendingOperations, stats.ReservedPorts))}, nil + } + + summary := truncation + if summary != "" { + prefixFragments := 0 + for _, history := range histories { + prefixFragments += history.prefixFragments + } + summary += fmt.Sprintf( + "; audited %d retained purge begin(s), ignored %d prefix-only phase fragment(s); zeroed kernel counters prove whole-run cleanup", + len(progresses), prefixFragments) + } + return nativeLiveTeardownAudit{complete: true, purgeCount: len(progresses), diagnostic: summary}, nil +} + +func nativeLiveTrace(events ...uint16) udecx.LifecycleTrace { + const ( + deviceID = uint64(0x5649495000000001) + deviceObject = uint64(0xffff800000001000) + endpointObject = uint64(0xffff800000002000) + ) + records := make([]udecx.LifecycleTraceRecord, 0, len(events)) + for index, event := range events { + record := udecx.LifecycleTraceRecord{ + PublishedSequence: uint64(index + 1), + DeviceID: deviceID, + DeviceObject: deviceObject, + Generation: 1, + Event: event, + Line: uint32(100 + index), + } + if (event >= udecx.TraceEndpointPurgeBegin && event <= udecx.TraceEndpointCleanupEnd) || + event == udecx.TraceEndpointQuiescenceWatchdog { + record.EndpointObject = endpointObject + record.EndpointAddress = 0x81 + } + if event == udecx.TraceEndpointPurgeBegin || event == udecx.TraceEndpointDriverQuiescent { + record.QueueState = 0x0f + } + records = append(records, record) + } + return udecx.LifecycleTrace{LatestSequence: uint64(len(records)), Records: records} +} + +func nativeLiveRolledTrace(events ...uint16) udecx.LifecycleTrace { + latestSequence := uint64(udecx.LifecycleTraceCapacity + 10) + firstSequence := latestSequence - udecx.LifecycleTraceCapacity + 1 + prefixRecords := udecx.LifecycleTraceCapacity - len(events) + records := make([]udecx.LifecycleTraceRecord, 0, udecx.LifecycleTraceCapacity) + for index := 0; index < prefixRecords; index++ { + records = append(records, udecx.LifecycleTraceRecord{ + PublishedSequence: firstSequence + uint64(index), + Event: udecx.TraceControllerShutdownBegin, + }) + } + for _, record := range nativeLiveTrace(events...).Records { + record.PublishedSequence = firstSequence + uint64(len(records)) + records = append(records, record) + } + return udecx.LifecycleTrace{LatestSequence: latestSequence, Records: records} +} + +func TestNativeLiveTeardownAuditAcceptsReadyQueuePurge(t *testing.T) { + trace := nativeLiveTrace( + udecx.TraceEndpointPurgeBegin, + udecx.TraceEndpointDriverQuiescent, + udecx.TraceEndpointDrainEnd, + udecx.TraceEndpointPurgeCompleteEnd, + udecx.TraceEndpointCleanupEnd, + udecx.TraceDeviceCleanupEnd, + ) + audit, err := auditNativeLiveTeardown(trace, udecx.Stats{}) + if err != nil { + t.Fatal(err) + } + if !audit.complete || audit.purgeCount != 1 { + t.Fatalf("ready 0x0f purge did not pass teardown audit: %+v", audit) + } +} + +func TestNativeLiveTeardownAuditRejectsAnyQuiescenceWatchdog(t *testing.T) { + trace := nativeLiveTrace( + udecx.TraceEndpointPurgeBegin, + udecx.TraceEndpointQuiescenceWatchdog, + ) + trace.Records[1].Status = -1 + trace.Records[1].ActiveOperations = 1 + trace.Records[1].QueueState = 0x0f + _, err := auditNativeLiveTeardown(trace, udecx.Stats{}) + if err == nil || !strings.Contains(err.Error(), "endpoint-quiescence-watchdog") || + !strings.Contains(err.Error(), "active=1") { + t.Fatalf("watchdog audit error=%v want explicit active rundown snapshot", err) + } +} + +func TestNativeLiveTeardownAuditRejectsStickyWatchdogAfterRecordRollover(t *testing.T) { + trace := nativeLiveTrace(udecx.TraceCreateBegin) + trace.StatusFlags = udecx.LifecycleTraceStatusWatchdogFired + _, err := auditNativeLiveTeardown(trace, udecx.Stats{}) + if err == nil || !strings.Contains(err.Error(), "watchdog status is sticky") { + t.Fatalf("sticky watchdog audit error=%v want permanent release failure", err) + } +} + +func TestNativeLiveTeardownAuditRejectsRecorderContentionDrop(t *testing.T) { + trace := nativeLiveTrace(udecx.TraceCreateBegin) + trace.StatusFlags = udecx.LifecycleTraceStatusDroppedRecord + _, err := auditNativeLiveTeardown(trace, udecx.Stats{}) + if err == nil || !strings.Contains(err.Error(), "dropped a contended record") { + t.Fatalf("recorder drop audit error=%v want fail-closed release result", err) + } +} + +func TestNativeLiveTeardownAuditTracksRepeatedPurgesFIFO(t *testing.T) { + trace := nativeLiveTrace( + udecx.TraceEndpointPurgeBegin, + udecx.TraceEndpointPurgeBegin, + udecx.TraceEndpointDriverQuiescent, + udecx.TraceEndpointDrainEnd, + udecx.TraceEndpointPurgeCompleteEnd, + udecx.TraceEndpointDriverQuiescent, + udecx.TraceEndpointDrainEnd, + udecx.TraceEndpointPurgeCompleteEnd, + udecx.TraceEndpointCleanupEnd, + udecx.TraceDeviceCleanupEnd, + ) + audit, err := auditNativeLiveTeardown(trace, udecx.Stats{}) + if err != nil { + t.Fatal(err) + } + if !audit.complete || audit.purgeCount != 2 { + t.Fatalf("repeated purges were not correlated one-for-one: %+v", audit) + } +} + +func TestNativeLiveTeardownAuditReportsEveryIncompletePhase(t *testing.T) { + tests := []struct { + name string + events []uint16 + diagnostic string + }{ + {name: "quiescent", events: []uint16{udecx.TraceEndpointPurgeBegin}, diagnostic: "driver-quiescent"}, + {name: "drain", events: []uint16{ + udecx.TraceEndpointPurgeBegin, udecx.TraceEndpointDriverQuiescent, + }, diagnostic: "drain-end"}, + {name: "complete", events: []uint16{ + udecx.TraceEndpointPurgeBegin, udecx.TraceEndpointDriverQuiescent, + udecx.TraceEndpointDrainEnd, + }, diagnostic: "purge-complete-end"}, + {name: "endpoint cleanup", events: []uint16{ + udecx.TraceEndpointPurgeBegin, udecx.TraceEndpointDriverQuiescent, + udecx.TraceEndpointDrainEnd, udecx.TraceEndpointPurgeCompleteEnd, + }, diagnostic: "endpoint-cleanup-end"}, + {name: "device cleanup", events: []uint16{ + udecx.TraceEndpointPurgeBegin, udecx.TraceEndpointDriverQuiescent, + udecx.TraceEndpointDrainEnd, udecx.TraceEndpointPurgeCompleteEnd, + udecx.TraceEndpointCleanupEnd, + }, diagnostic: "device-cleanup-end"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + audit, err := auditNativeLiveTeardown(nativeLiveTrace(test.events...), udecx.Stats{}) + if err != nil { + t.Fatal(err) + } + if audit.complete || !strings.Contains(audit.diagnostic, test.diagnostic) { + t.Fatalf("incomplete phase diagnostic=%q want %q", audit.diagnostic, test.diagnostic) + } + }) + } +} + +func TestNativeLiveTeardownAuditToleratesRolloverAndFailsReservations(t *testing.T) { + trace := nativeLiveTrace( + udecx.TraceEndpointPurgeBegin, + udecx.TraceEndpointDriverQuiescent, + udecx.TraceEndpointDrainEnd, + udecx.TraceEndpointPurgeCompleteEnd, + udecx.TraceEndpointCleanupEnd, + udecx.TraceDeviceCleanupEnd, + ) + rolled := nativeLiveRolledTrace( + // This completion belongs to a purge whose begin and drain were in the + // overwritten prefix. The complete retained cycle after it remains + // independently auditable. + udecx.TraceEndpointPurgeCompleteEnd, + udecx.TraceEndpointPurgeBegin, + udecx.TraceEndpointDriverQuiescent, + udecx.TraceEndpointDrainEnd, + udecx.TraceEndpointPurgeCompleteEnd, + udecx.TraceEndpointCleanupEnd, + udecx.TraceDeviceCleanupEnd, + ) + rolledAudit, err := auditNativeLiveTeardown(rolled, udecx.Stats{}) + if err != nil { + t.Fatal(err) + } + if !rolledAudit.complete || rolledAudit.purgeCount != 1 || + !strings.Contains(rolledAudit.diagnostic, "retained lifecycle suffix") { + t.Fatalf("complete retained suffix did not survive rollover: %+v", rolledAudit) + } + + stalledAudit, err := auditNativeLiveTeardown( + nativeLiveRolledTrace(udecx.TraceEndpointPurgeBegin), udecx.Stats{}) + if err != nil { + t.Fatal(err) + } + if stalledAudit.complete || + !strings.Contains(stalledAudit.diagnostic, "driver-quiescent") || + !strings.Contains(stalledAudit.diagnostic, "retained lifecycle suffix") { + t.Fatalf("retained stalled purge was hidden by rollover: %+v", stalledAudit) + } + + audit, err := auditNativeLiveTeardown(trace, udecx.Stats{ReservedPorts: 1}) + if err != nil { + t.Fatal(err) + } + if audit.complete || !strings.Contains(audit.diagnostic, "ReservedPorts=1") { + t.Fatalf("reserved port leak did not block teardown: %+v", audit) + } +} diff --git a/internal/server/usb/native_live_teardown_gate_windows_test.go b/internal/server/usb/native_live_teardown_gate_windows_test.go new file mode 100644 index 00000000..17b313cb --- /dev/null +++ b/internal/server/usb/native_live_teardown_gate_windows_test.go @@ -0,0 +1,118 @@ +//go:build windows + +package usb_test + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + "time" + + "github.com/Alia5/VIIPER/internal/transport/udecx" +) + +const nativeLiveTeardownGateTimeout = 15 * time.Second + +func openNativeLiveTeardownClient(ctx context.Context) (*udecx.Client, error) { + var lastTemporary error + for { + client, err := udecx.Open(ctx) + if err == nil { + return client, nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, fmt.Errorf("acquire clean controller after live teardown: %w (last transient error: %v)", + ctxErr, lastTemporary) + } + var temporary interface{ Temporary() bool } + if !errors.As(err, &temporary) || !temporary.Temporary() { + return nil, err + } + lastTemporary = err + timer := time.NewTimer(50 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return nil, fmt.Errorf("acquire clean controller after live teardown: %w (last transient error: %v)", + ctx.Err(), lastTemporary) + case <-timer.C: + } + } +} + +func waitForNativeLiveTeardown(ctx context.Context, client *udecx.Client) error { + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + lastDiagnostic := "no lifecycle snapshot queried" + var lastStats udecx.Stats + timedOut := func() error { + return fmt.Errorf("teardown did not complete within %s: %s; stats=%+v", + nativeLiveTeardownGateTimeout, lastDiagnostic, lastStats) + } + for { + if ctx.Err() != nil { + return timedOut() + } + trace, err := client.QueryLifecycleTrace(ctx) + if err != nil { + if ctx.Err() != nil { + return timedOut() + } + return fmt.Errorf("query lifecycle trace: %w", err) + } + stats, err := client.QueryStats(ctx) + if err != nil { + if ctx.Err() != nil { + return timedOut() + } + return fmt.Errorf("query teardown stats: %w", err) + } + lastStats = stats + audit, err := auditNativeLiveTeardown(trace, stats) + if err != nil { + return err + } + if audit.complete { + if audit.diagnostic != "" { + fmt.Fprintf(os.Stderr, "native live teardown gate notice: %s\n", audit.diagnostic) + } + return nil + } + lastDiagnostic = audit.diagnostic + + select { + case <-ctx.Done(): + return timedOut() + case <-ticker.C: + } + } +} + +func runNativeLiveTeardownGate(timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + client, err := openNativeLiveTeardownClient(ctx) + if err != nil { + return err + } + auditErr := waitForNativeLiveTeardown(ctx, client) + closeErr := client.Close() + if closeErr != nil { + closeErr = fmt.Errorf("close teardown-audit controller: %w", closeErr) + } + return errors.Join(auditErr, closeErr) +} + +func TestMain(m *testing.M) { + code := m.Run() + if os.Getenv(liveNativeTestEnvironment) == "1" && + os.Getenv(liveNativeCrashChild) != "1" { + if err := runNativeLiveTeardownGate(nativeLiveTeardownGateTimeout); err != nil { + fmt.Fprintf(os.Stderr, "native live teardown gate failed: %v\n", err) + code = 1 + } + } + os.Exit(code) +} diff --git a/internal/server/usb/native_live_windows_test.go b/internal/server/usb/native_live_windows_test.go new file mode 100644 index 00000000..a7c6a2fc --- /dev/null +++ b/internal/server/usb/native_live_windows_test.go @@ -0,0 +1,1343 @@ +//go:build windows + +package usb_test + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + "unsafe" + + "github.com/Alia5/VIIPER/device/dualsense" + "github.com/Alia5/VIIPER/device/dualshock4" + "github.com/Alia5/VIIPER/device/ns2pro" + "github.com/Alia5/VIIPER/device/xbox360" + serverusb "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/transport/udecx" + usbdevice "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" + "golang.org/x/sys/windows" +) + +const ( + liveNativeTestEnvironment = "VIIPER_UDE_LIVE" + liveNativeTestIterations = "VIIPER_UDE_LIVE_ITERATIONS" + liveNativeCrashChild = "VIIPER_UDE_LIVE_CRASH_CHILD" + liveNativeMediaProbe = "VIIPER_UDE_LIVE_MEDIA_PROBE" + liveNativeMediaSeconds = "VIIPER_UDE_LIVE_MEDIA_SECONDS" + liveNativeInputProbe = "VIIPER_UDE_LIVE_INPUT_PROBE" + liveNativeRestartInstance = "VIIPER_UDE_LIVE_RESTART_INSTANCE_ID" + liveNativeCrashExitCode = 86 +) + +type liveNativeController struct { + name string + vendorID uint16 + productID uint16 + inputMarkerOffset uint16 + feedbackProbeKind string + feedbackReportLen uint64 + armFeedbackProbe func(usbdevice.Device) (func(context.Context) error, error) + new func() (usbdevice.Device, func(uint64), func(byte), error) +} + +// liveNativeMediaWitness observes the controller-engine side of the same +// CoreAudio stream exercised by ViiperUdeMediaProbe. CoreAudio frame counts and +// kernel byte counters alone can both advance while a broken broker returns +// silence or drops the payload before it reaches the preserved PlayStation +// media logic. The witness therefore proves non-silent render data arrives at +// that existing logic and feeds deterministic non-silent microphone PCM back +// through the virtual USB endpoint. It does not alter media construction. +type liveNativeMediaWitness struct { + speakerBytes atomic.Uint64 + speakerNonZeroBytes atomic.Uint64 + hapticsGenerations atomic.Uint64 + hapticsNonSilent atomic.Uint64 + queueMicrophone func([]byte) + microphoneFrame []byte + speakerBytesPerSec uint64 + requireHaptics bool +} + +func countNonZeroBytes(data []byte) uint64 { + var count uint64 + for _, value := range data { + if value != 0 { + count++ + } + } + return count +} + +func nonZeroPCMFrame(size int) []byte { + frame := make([]byte, size) + for offset := 0; offset+1 < len(frame); offset += 2 { + frame[offset] = 0x34 + frame[offset+1] = 0x12 + } + return frame +} + +func armLiveNativeMediaWitness(dev usbdevice.Device) (*liveNativeMediaWitness, error) { + switch controller := dev.(type) { + case *dualshock4.DualShock4: + witness := &liveNativeMediaWitness{ + queueMicrophone: controller.QueueMicrophonePCMFrame, + microphoneFrame: nonZeroPCMFrame(dualshock4.USBMicrophoneClientFrameSize), + speakerBytesPerSec: dualshock4.USBSpeakerSampleRate * + dualshock4.USBSpeakerChannels * dualshock4.USBSpeakerBytesPerSample, + } + controller.SetSpeakerCallback(func(pcm []byte) { + witness.speakerBytes.Add(uint64(len(pcm))) + witness.speakerNonZeroBytes.Add(countNonZeroBytes(pcm)) + }) + return witness, nil + case *dualsense.DualSense: + witness := &liveNativeMediaWitness{ + queueMicrophone: controller.QueueMicrophonePCMFrame, + microphoneFrame: nonZeroPCMFrame(dualsense.USBMicrophoneClientFrameSize), + speakerBytesPerSec: dualsense.USBHapticsAudioSampleRate * 2 * + dualsense.USBHapticsAudioBytesPerSample, + requireHaptics: true, + } + controller.SetAtomicAudioHapticsCallback(func(_ dualsense.OutputState, speaker []byte) { + witness.speakerBytes.Add(uint64(len(speaker))) + witness.speakerNonZeroBytes.Add(countNonZeroBytes(speaker)) + }) + controller.SetRealtimeHapticsCallback(func(feedback dualsense.OutputState) { + witness.hapticsGenerations.Add(1) + sample := feedback.BluetoothCombinedOutputReport[dualsense.BluetoothCombinedHapticsOffset:(dualsense.BluetoothCombinedHapticsOffset + dualsense.BluetoothHapticsSampleSize)] + if countNonZeroBytes(sample) != 0 { + witness.hapticsNonSilent.Add(1) + } + }) + return witness, nil + default: + return nil, fmt.Errorf("media witness does not support %T", dev) + } +} + +func (w *liveNativeMediaWitness) startMicrophone(ctx context.Context) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + // Queueing slightly ahead of the 10 ms client-frame cadence makes the + // content assertion independent of the instant CoreAudio selects alt 1; + // the controller's existing bounded/adaptive microphone queue remains + // responsible for presentation cadence. + ticker := time.NewTicker(8 * time.Millisecond) + defer ticker.Stop() + for { + w.queueMicrophone(w.microphoneFrame) + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }() + return done +} + +func (w *liveNativeMediaWitness) validate(duration time.Duration) error { + seconds := uint64(duration / time.Second) + minimumSpeakerBytes := w.speakerBytesPerSec * seconds * 9 / 10 + speakerBytes := w.speakerBytes.Load() + if speakerBytes < minimumSpeakerBytes { + return fmt.Errorf("controller engine received only %d speaker bytes; want at least %d", + speakerBytes, minimumSpeakerBytes) + } + if nonZero := w.speakerNonZeroBytes.Load(); nonZero < speakerBytes/4 { + return fmt.Errorf("controller engine speaker payload was silent or malformed: nonzero=%d total=%d", + nonZero, speakerBytes) + } + if w.requireHaptics { + minimumHaptics := seconds * 50 + haptics := w.hapticsGenerations.Load() + if haptics < minimumHaptics { + return fmt.Errorf("controller engine received only %d realtime haptics generations; want at least %d", + haptics, minimumHaptics) + } + if nonSilent := w.hapticsNonSilent.Load(); nonSilent < haptics/2 { + return fmt.Errorf("controller engine haptics payload was silent or malformed: nonSilent=%d total=%d", + nonSilent, haptics) + } + } + return nil +} + +func TestLiveNativeMediaWitnessRejectsSilentOrIncompleteContent(t *testing.T) { + valid := &liveNativeMediaWitness{speakerBytesPerSec: 100, requireHaptics: true} + valid.speakerBytes.Store(100) + valid.speakerNonZeroBytes.Store(30) + valid.hapticsGenerations.Store(50) + valid.hapticsNonSilent.Store(25) + if err := valid.validate(time.Second); err != nil { + t.Fatalf("complete non-silent media was rejected: %v", err) + } + + for _, testCase := range []struct { + name string + prepare func(*liveNativeMediaWitness) + }{ + {name: "short speaker stream", prepare: func(w *liveNativeMediaWitness) { + w.speakerBytes.Store(89) + w.speakerNonZeroBytes.Store(89) + w.hapticsGenerations.Store(50) + w.hapticsNonSilent.Store(50) + }}, + {name: "silent speaker stream", prepare: func(w *liveNativeMediaWitness) { + w.speakerBytes.Store(100) + w.speakerNonZeroBytes.Store(24) + w.hapticsGenerations.Store(50) + w.hapticsNonSilent.Store(50) + }}, + {name: "missing realtime haptics", prepare: func(w *liveNativeMediaWitness) { + w.speakerBytes.Store(100) + w.speakerNonZeroBytes.Store(100) + w.hapticsGenerations.Store(49) + w.hapticsNonSilent.Store(49) + }}, + {name: "silent realtime haptics", prepare: func(w *liveNativeMediaWitness) { + w.speakerBytes.Store(100) + w.speakerNonZeroBytes.Store(100) + w.hapticsGenerations.Store(50) + w.hapticsNonSilent.Store(24) + }}, + } { + t.Run(testCase.name, func(t *testing.T) { + witness := &liveNativeMediaWitness{speakerBytesPerSec: 100, requireHaptics: true} + testCase.prepare(witness) + if err := witness.validate(time.Second); err == nil { + t.Fatal("incomplete or silent media was accepted") + } + }) + } +} + +func armDualShock4FeedbackProbe(dev usbdevice.Device) (func(context.Context) error, error) { + controller, ok := dev.(*dualshock4.DualShock4) + if !ok { + return nil, fmt.Errorf("feedback probe expected *dualshock4.DualShock4, got %T", dev) + } + want := dualshock4.OutputState{ + RumbleSmall: 0x23, RumbleLarge: 0xA7, + LedRed: 0x11, LedGreen: 0x52, LedBlue: 0xC3, + FlashOn: 0x04, FlashOff: 0x09, + } + done := make(chan struct{}) + var matched sync.Once + controller.SetOutputCallback(func(got dualshock4.OutputState) { + if got == want { + matched.Do(func() { close(done) }) + } + }) + return func(ctx context.Context) error { + select { + case <-done: + return nil + case <-ctx.Done(): + return fmt.Errorf("DualShock 4 feedback marker did not reach the device engine: %w", ctx.Err()) + } + }, nil +} + +func armDualSenseFeedbackProbe(dev usbdevice.Device) (func(context.Context) error, error) { + controller, ok := dev.(*dualsense.DualSense) + if !ok { + return nil, fmt.Errorf("feedback probe expected *dualsense.DualSense, got %T", dev) + } + var want [dualsense.OutputReportSize]byte + want[0] = dualsense.ReportIDOutput + want[1] = 0x0F + want[2] = 0x14 + want[3] = 0x22 + want[4] = 0x88 + want[11] = 0x21 + want[12] = 0xFC + want[13] = 0x03 + want[20] = 0x44 + want[22] = 0x25 + want[23] = 0x40 + want[24] = 0x05 + want[31] = 0x55 + want[44] = 0x24 + want[45] = 0x11 + want[46] = 0x52 + want[47] = 0xC3 + + done := make(chan struct{}) + var matched sync.Once + controller.SetOutputCallback(func(got dualsense.OutputState) { + if got.RawOutputReport == want && + got.RumbleSmall == 0x22 && got.RumbleLarge == 0x88 && + got.LedRed == 0x11 && got.LedGreen == 0x52 && got.LedBlue == 0xC3 && + got.PlayerLeds == 0x24 && + got.TriggerR2Mode == 0x21 && got.TriggerR2StartResistance == 0xFC && + got.TriggerR2EffectForce == 0x03 && got.TriggerR2Frequency == 0x44 && + got.TriggerL2Mode == 0x25 && got.TriggerL2StartResistance == 0x40 && + got.TriggerL2EffectForce == 0x05 && got.TriggerL2Frequency == 0x55 { + matched.Do(func() { close(done) }) + } + }) + return func(ctx context.Context) error { + select { + case <-done: + return nil + case <-ctx.Done(): + return fmt.Errorf("DualSense feedback marker did not reach the device engine: %w", ctx.Err()) + } + }, nil +} + +func liveNativeControllers() []liveNativeController { + return []liveNativeController{ + {name: "Xbox360", new: func() (usbdevice.Device, func(uint64), func(byte), error) { + dev, err := xbox360.New(nil) + return dev, func(sequence uint64) { + state := xbox360.NewInputState() + state.LX = int16(sequence % 1024) + dev.UpdateInputState(*state) + }, nil, err + }}, + {name: "DualShock4", vendorID: dualshock4.DefaultVID, + productID: dualshock4.DefaultPID, inputMarkerOffset: 1, + feedbackProbeKind: "dualshock4", feedbackReportLen: 32, + armFeedbackProbe: armDualShock4FeedbackProbe, + new: func() (usbdevice.Device, func(uint64), func(byte), error) { + dev, err := dualshock4.New(nil) + return dev, func(sequence uint64) { + state := dualshock4.NewInputState() + state.LX = int8(sequence % 32) + dev.UpdateInputState(state) + }, func(marker byte) { + state := dualshock4.NewInputState() + state.LX = int8(int(marker) - 128) + dev.UpdateInputState(state) + }, err + }}, + {name: "DualSense", vendorID: dualsense.DefaultVID, + productID: dualsense.DefaultPIDDS, inputMarkerOffset: 1, + feedbackProbeKind: "dualsense", feedbackReportLen: dualsense.OutputReportSize, + armFeedbackProbe: armDualSenseFeedbackProbe, + new: func() (usbdevice.Device, func(uint64), func(byte), error) { + dev, err := dualsense.New(nil) + return dev, func(sequence uint64) { + state := dualsense.NewInputState() + state.LX = int8(sequence % 32) + dev.UpdateInputState(state) + }, func(marker byte) { + state := dualsense.NewInputState() + state.LX = int8(int(marker) - 128) + dev.UpdateInputState(state) + }, err + }}, + {name: "DualSenseEdge", vendorID: dualsense.DefaultVID, + productID: dualsense.DefaultPIDDSEdge, inputMarkerOffset: 3, + feedbackProbeKind: "dualsense-edge", feedbackReportLen: dualsense.OutputReportSize, + armFeedbackProbe: armDualSenseFeedbackProbe, + new: func() (usbdevice.Device, func(uint64), func(byte), error) { + dev, err := dualsense.NewEdge(nil) + return dev, func(sequence uint64) { + state := dualsense.NewInputState() + state.RX = int8(sequence % 32) + dev.UpdateInputState(state) + }, func(marker byte) { + state := dualsense.NewInputState() + state.RX = int8(int(marker) - 128) + dev.UpdateInputState(state) + }, err + }}, + {name: "Switch2Pro", new: func() (usbdevice.Device, func(uint64), func(byte), error) { + dev, err := ns2pro.New(nil) + return dev, func(sequence uint64) { + state := ns2pro.NewInputState() + state.LX += uint16(sequence % 32) + dev.UpdateInputState(*state) + }, nil, err + }}, + } +} + +func TestNativeLiveFeedbackProbeContracts(t *testing.T) { + for _, controller := range liveNativeControllers()[1:4] { + controller := controller + t.Run(controller.name, func(t *testing.T) { + dev, _, _, err := controller.new() + if err != nil { + t.Fatal(err) + } + waitForFeedback, err := controller.armFeedbackProbe(dev) + if err != nil { + t.Fatal(err) + } + + var endpoint uint8 + var report []byte + switch controller.feedbackProbeKind { + case "dualshock4": + endpoint = dualshock4.EndpointOut + report = make([]byte, 32) + report[0] = dualshock4.ReportIDOutput + report[4], report[5] = 0x23, 0xA7 + report[6], report[7], report[8] = 0x11, 0x52, 0xC3 + report[9], report[10] = 0x04, 0x09 + case "dualsense", "dualsense-edge": + endpoint = dualsense.EndpointOut + report = make([]byte, dualsense.OutputReportSize) + report[0], report[1], report[2] = dualsense.ReportIDOutput, 0x0F, 0x14 + report[3], report[4] = 0x22, 0x88 + report[11], report[12], report[13], report[20] = 0x21, 0xFC, 0x03, 0x44 + report[22], report[23], report[24], report[31] = 0x25, 0x40, 0x05, 0x55 + report[44], report[45], report[46], report[47] = 0x24, 0x11, 0x52, 0xC3 + default: + t.Fatalf("unsupported feedback probe kind %q", controller.feedbackProbeKind) + } + + dev.HandleTransfer(context.Background(), uint32(endpoint), usbip.DirOut, report) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err = waitForFeedback(ctx); err != nil { + t.Fatal(err) + } + }) + } +} + +func liveNativeIterationCount(t *testing.T) int { + t.Helper() + raw := os.Getenv(liveNativeTestIterations) + if raw == "" { + return 1 + } + iterations, err := strconv.Atoi(raw) + if err != nil || iterations < 1 || iterations > 100 { + t.Fatalf("%s must be an integer from 1 through 100, got %q", + liveNativeTestIterations, raw) + } + return iterations +} + +func liveNativeMediaDuration(t *testing.T) time.Duration { + t.Helper() + raw := os.Getenv(liveNativeMediaSeconds) + if raw == "" { + return 3 * time.Second + } + seconds, err := strconv.Atoi(raw) + if err != nil || seconds < 1 || seconds > 300 { + t.Fatalf("%s must be an integer from 1 through 300, got %q", + liveNativeMediaSeconds, raw) + } + return time.Duration(seconds) * time.Second +} + +func TestNativeLiveMediaDurationContract(t *testing.T) { + t.Setenv(liveNativeMediaSeconds, "") + if got := liveNativeMediaDuration(t); got != 3*time.Second { + t.Fatalf("default native media duration=%s want 3s", got) + } + t.Setenv(liveNativeMediaSeconds, "180") + if got := liveNativeMediaDuration(t); got != 3*time.Minute { + t.Fatalf("release native media duration=%s want 3m", got) + } +} + +func waitForNativeStats(ctx context.Context, client *udecx.Client, description string, + accept func(udecx.Stats) bool) (udecx.Stats, error) { + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + var last udecx.Stats + for { + stats, err := client.QueryStats(ctx) + if err != nil { + return last, fmt.Errorf("query stats while waiting for %s: %w", description, err) + } + last = stats + if accept(stats) { + return stats, nil + } + select { + case <-ctx.Done(): + return last, fmt.Errorf("wait for %s: %w (last stats: %+v)", + description, ctx.Err(), last) + case <-ticker.C: + } + } +} + +func assertCleanNativeStatsDelta(t *testing.T, before, after udecx.Stats) { + t.Helper() + if after.InvalidMessages != before.InvalidMessages || + after.QueueExhaustions != before.QueueExhaustions || + after.NotificationEventOverflows != before.NotificationEventOverflows || + after.LateCompletions != before.LateCompletions || + after.CleanupRetries != before.CleanupRetries { + t.Fatalf("native driver recorded a protocol/lifecycle fault: before=%+v after=%+v", + before, after) + } +} + +func runLiveMediaProbe(t *testing.T, ctx context.Context, probe string, arguments ...string) string { + t.Helper() + command := exec.CommandContext(ctx, probe, arguments...) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("run native CoreAudio probe %v: %v\n%s", arguments, err, output) + } + return string(output) +} + +type liveProbeResult struct { + output string + err error +} + +func startLiveProbe(ctx context.Context, probe string, arguments ...string) <-chan liveProbeResult { + done := make(chan liveProbeResult, 1) + go func() { + output, err := exec.CommandContext(ctx, probe, arguments...).CombinedOutput() + done <- liveProbeResult{output: string(output), err: err} + }() + return done +} + +var queryPerformanceCounter = windows.NewLazySystemDLL("kernel32.dll"). + NewProc("QueryPerformanceCounter") + +func performanceCounter(t *testing.T) int64 { + t.Helper() + var counter int64 + result, _, callErr := queryPerformanceCounter.Call( + uintptr(unsafe.Pointer(&counter))) + if result == 0 { + t.Fatalf("QueryPerformanceCounter: %v", callErr) + } + return counter +} + +func percentile(sorted []float64, percentile float64) float64 { + if len(sorted) == 0 { + return 0 + } + index := int(float64(len(sorted)-1) * percentile) + return sorted[index] +} + +func runLiveInputLatencyProbe( + t *testing.T, + ctx context.Context, + probe string, + snapshot string, + controller liveNativeController, + publishMarker func(byte), +) { + t.Helper() + const samples = 256 + probeCtx, cancelProbe := context.WithTimeout(ctx, 45*time.Second) + defer cancelProbe() + command := exec.CommandContext(probeCtx, probe, + "measure", snapshot, + fmt.Sprintf("0x%04X", controller.vendorID), + fmt.Sprintf("0x%04X", controller.productID), + strconv.Itoa(int(controller.inputMarkerOffset)), + strconv.Itoa(samples), "qpc-v1") + stdout, err := command.StdoutPipe() + if err != nil { + t.Fatalf("open %s input-probe stdout: %v", controller.name, err) + } + var stderr strings.Builder + command.Stderr = &stderr + if err = command.Start(); err != nil { + t.Fatalf("start %s input probe: %v", controller.name, err) + } + waited := false + defer func() { + if !waited { + _ = command.Wait() + } + }() + + scanner := bufio.NewScanner(stdout) + if !scanner.Scan() { + _ = command.Wait() + waited = true + t.Fatalf("%s input probe never became ready: scan=%v stderr=%s", + controller.name, scanner.Err(), stderr.String()) + } + ready := strings.Fields(scanner.Text()) + if len(ready) < 3 || ready[0] != "READY" { + t.Fatalf("%s input probe returned an invalid ready record: %q", + controller.name, scanner.Text()) + } + frequency, err := strconv.ParseInt(ready[1], 10, 64) + if err != nil || frequency <= 0 { + t.Fatalf("%s input probe returned invalid QPC frequency %q", + controller.name, ready[1]) + } + + latencies := make([]float64, 0, samples) + for index := 0; index < samples; index++ { + marker := byte(0xFD + (index & 1)) + published := performanceCounter(t) + publishMarker(marker) + if !scanner.Scan() { + _ = command.Wait() + waited = true + t.Fatalf("%s input probe ended after %d/%d samples: scan=%v stderr=%s", + controller.name, index, samples, scanner.Err(), stderr.String()) + } + match := strings.Fields(scanner.Text()) + if len(match) != 3 || match[0] != "MATCH" { + t.Fatalf("%s input probe returned an invalid match record: %q", + controller.name, scanner.Text()) + } + observedMarker, markerErr := strconv.ParseUint(match[1], 10, 8) + observed, observedErr := strconv.ParseInt(match[2], 10, 64) + if markerErr != nil || observedErr != nil || byte(observedMarker) != marker || + observed < published { + t.Fatalf("%s input probe returned an invalid marker/timestamp: %q published=%d", + controller.name, scanner.Text(), published) + } + latencies = append(latencies, + float64(observed-published)*1000/float64(frequency)) + } + if err = command.Wait(); err != nil { + waited = true + t.Fatalf("%s input probe failed: %v stderr=%s", + controller.name, err, stderr.String()) + } + waited = true + sort.Float64s(latencies) + p50 := percentile(latencies, 0.50) + p95 := percentile(latencies, 0.95) + p99 := percentile(latencies, 0.99) + maximum := latencies[len(latencies)-1] + t.Logf("%s native publish-to-HID latency: samples=%d p50=%.3fms p95=%.3fms p99=%.3fms max=%.3fms", + controller.name, samples, p50, p95, p99, maximum) + // These limits include the Go publisher, native IOCTL, UdeCx/HIDClass, and + // the independent observer process. They deliberately gate long-tail loss + // without pretending the host's nominal poll interval is end-to-end latency. + if p95 > 4 || p99 > 8 || maximum > 20 { + t.Fatalf("%s native input latency exceeded the release gate: p95=%.3fms p99=%.3fms max=%.3fms", + controller.name, p95, p99, maximum) + } +} + +// TestNativeUDELiveProductionControllers is deliberately inert in normal CI. +// It opens an already-installed native controller and never installs, updates, +// enables, or removes a kernel driver. Release validation must first verify the +// package's Microsoft kernel-policy signature, then invoke the signed-package +// PowerShell gate on a disposable test machine. That gate sets +// VIIPER_UDE_LIVE=1 and links this test binary to the exact reviewed source +// identity; setting the environment variable alone is intentionally insufficient. +func TestNativeUDELiveProductionControllers(t *testing.T) { + if os.Getenv(liveNativeTestEnvironment) != "1" { + t.Skipf("invoke the signed-package validation gate (which sets %s=1 and injects its source identity)", + liveNativeTestEnvironment) + } + + iterations := liveNativeIterationCount(t) + mediaDuration := liveNativeMediaDuration(t) + testCtx, cancelTest := context.WithTimeout(context.Background(), + time.Duration(iterations)*5*time.Minute+3*mediaDuration+2*time.Minute) + defer cancelTest() + + client, err := udecx.Open(testCtx) + if err != nil { + t.Fatalf("open native UDE controller: %v", err) + } + defer func() { + if closeErr := client.Close(); closeErr != nil { + t.Errorf("close native UDE controller: %v", closeErr) + } + }() + + baseline, err := client.QueryStats(testCtx) + if err != nil { + t.Fatalf("query native UDE baseline: %v", err) + } + if baseline.ActiveDevices != 0 || baseline.PendingOperations != 0 { + t.Fatalf("refusing a dirty native UDE session: %+v", baseline) + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + server := serverusb.New(serverusb.ServerConfig{ConnectionTimeout: 5 * time.Second}, logger, nil) + processor, err := serverusb.NewNativeProcessor(server) + if err != nil { + t.Fatal(err) + } + host, err := udecx.NewHost(client, processor, 0) + if err != nil { + t.Fatal(err) + } + serveCtx, cancelServe := context.WithCancel(testCtx) + serveDone := make(chan error, 1) + go func() { serveDone <- host.Serve(serveCtx) }() + defer func() { + cancelServe() + host.Close() + select { + case serveErr := <-serveDone: + if serveErr != nil { + t.Errorf("native UDE host shutdown: %v", serveErr) + } + case <-time.After(5 * time.Second): + t.Error("native UDE host did not stop within 5 seconds") + } + }() + + const deviceIDBase uint64 = 0x5649495000000000 + mediaProbe := os.Getenv(liveNativeMediaProbe) + inputProbe := os.Getenv(liveNativeInputProbe) + for iteration := 1; iteration <= iterations; iteration++ { + for controllerIndex, controller := range liveNativeControllers() { + controller := controller + t.Run(fmt.Sprintf("%s/generation-%d", controller.name, iteration), func(t *testing.T) { + deviceID := deviceIDBase + uint64(controllerIndex+1) + dev, publishInput, publishMarker, createErr := controller.new() + if createErr != nil { + t.Fatalf("construct %s: %v", controller.name, createErr) + } + feedbackController := iteration == 1 && inputProbe != "" && + controller.armFeedbackProbe != nil + var waitForFeedback func(context.Context) error + if feedbackController { + waitForFeedback, createErr = controller.armFeedbackProbe(dev) + if createErr != nil { + t.Fatalf("arm %s HID feedback probe: %v", controller.name, createErr) + } + } + mediaSnapshot := "" + mediaController := iteration == 1 && mediaProbe != "" && + (controller.name == "DualShock4" || controller.name == "DualSense" || + controller.name == "DualSenseEdge") + var mediaWitness *liveNativeMediaWitness + if mediaController { + mediaWitness, createErr = armLiveNativeMediaWitness(dev) + if createErr != nil { + t.Fatalf("arm %s media witness: %v", controller.name, createErr) + } + snapshot, snapshotErr := os.CreateTemp("", "viiper-ude-media-*.snapshot") + if snapshotErr != nil { + t.Fatalf("create media endpoint snapshot: %v", snapshotErr) + } + mediaSnapshot = snapshot.Name() + if closeErr := snapshot.Close(); closeErr != nil { + t.Fatalf("close media endpoint snapshot: %v", closeErr) + } + defer os.Remove(mediaSnapshot) + runLiveMediaProbe(t, testCtx, mediaProbe, "snapshot", mediaSnapshot) + } + inputSnapshot := "" + inputController := iteration == 1 && inputProbe != "" && publishMarker != nil + if inputController || feedbackController { + snapshot, snapshotErr := os.CreateTemp("", "viiper-ude-input-*.snapshot") + if snapshotErr != nil { + t.Fatalf("create input endpoint snapshot: %v", snapshotErr) + } + inputSnapshot = snapshot.Name() + if closeErr := snapshot.Close(); closeErr != nil { + t.Fatalf("close input endpoint snapshot: %v", closeErr) + } + defer os.Remove(inputSnapshot) + runLiveMediaProbe(t, testCtx, inputProbe, "snapshot", inputSnapshot) + } + before, queryErr := client.QueryStats(testCtx) + if queryErr != nil { + t.Fatal(queryErr) + } + identity, registerErr := host.Register(testCtx, deviceID, dev) + if registerErr != nil { + t.Fatalf("register %s: %v", controller.name, registerErr) + } + if identity.Generation != uint32(iteration) { + t.Fatalf("%s generation=%d want %d", controller.name, + identity.Generation, iteration) + } + registered := true + defer func() { + if registered { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cleanupCancel() + if unregisterErr := host.Unregister(cleanupCtx, identity); unregisterErr != nil { + t.Errorf("cleanup %s: %v", controller.name, unregisterErr) + } + } + }() + + enumerateCtx, cancelEnumerate := context.WithTimeout(testCtx, 20*time.Second) + _, waitErr := waitForNativeStats(enumerateCtx, client, + controller.name+" enumeration", func(stats udecx.Stats) bool { + return stats.ActiveDevices == 1 + }) + cancelEnumerate() + if waitErr != nil { + t.Fatal(waitErr) + } + + feedbackVerified := false + verifyFeedback := func() { + feedbackBefore, feedbackErr := client.QueryStats(testCtx) + if feedbackErr != nil { + t.Fatalf("query %s feedback baseline: %v", controller.name, feedbackErr) + } + probeOutput := runLiveMediaProbe(t, testCtx, inputProbe, + "feedback", inputSnapshot, + fmt.Sprintf("0x%04X", controller.vendorID), + fmt.Sprintf("0x%04X", controller.productID), + controller.feedbackProbeKind, "hid-output-v1") + feedbackCtx, cancelFeedback := context.WithTimeout(testCtx, 10*time.Second) + defer cancelFeedback() + if feedbackErr = waitForFeedback(feedbackCtx); feedbackErr != nil { + t.Fatalf("%s HID output was not preserved end to end: %v; probe=%s", + controller.name, feedbackErr, probeOutput) + } + feedbackAfter, feedbackWaitErr := waitForNativeStats(feedbackCtx, client, + controller.name+" HID output completion", func(stats udecx.Stats) bool { + return stats.OperationsDequeued > feedbackBefore.OperationsDequeued && + stats.OperationsCompleted > feedbackBefore.OperationsCompleted && + stats.BytesToDevice >= feedbackBefore.BytesToDevice+controller.feedbackReportLen + }) + if feedbackWaitErr != nil { + t.Fatalf("%s HID output did not complete through the native driver: %v; before=%+v after=%+v probe=%s", + controller.name, feedbackWaitErr, feedbackBefore, feedbackAfter, probeOutput) + } + feedbackVerified = true + } + + if mediaController { + mediaBefore, mediaErr := client.QueryStats(testCtx) + if mediaErr != nil { + t.Fatalf("query %s media baseline: %v", controller.name, mediaErr) + } + mediaCtx, cancelMedia := context.WithCancel(testCtx) + defer cancelMedia() + microphoneDone := mediaWitness.startMicrophone(mediaCtx) + probeDone := startLiveProbe( + mediaCtx, mediaProbe, "exercise", mediaSnapshot, + strconv.Itoa(int(mediaDuration/time.Second)), + strings.ToLower(controller.name)) + stressCtx, cancelStress := context.WithCancel(testCtx) + defer cancelStress() + stressDone := make(chan struct{}) + go func() { + defer close(stressDone) + for sequence := uint64(1); ; sequence++ { + select { + case <-stressCtx.Done(): + return + default: + publishInput(sequence) + time.Sleep(time.Millisecond) + } + } + }() + if feedbackController { + verifyFeedback() + } + probeResult := <-probeDone + cancelMedia() + <-microphoneDone + cancelStress() + <-stressDone + if probeResult.err != nil { + t.Fatalf("run native CoreAudio probe: %v\n%s", + probeResult.err, probeResult.output) + } + if witnessErr := mediaWitness.validate(mediaDuration); witnessErr != nil { + t.Fatalf("%s media content did not survive the native bus: %v; probe=%s", + controller.name, witnessErr, probeResult.output) + } + mediaAfter, mediaErr := client.QueryStats(testCtx) + if mediaErr != nil { + t.Fatalf("query %s media result: %v", controller.name, mediaErr) + } + if mediaAfter.IsoPackets <= mediaBefore.IsoPackets || + mediaAfter.BytesToDevice <= mediaBefore.BytesToDevice || + mediaAfter.BytesFromDevice <= mediaBefore.BytesFromDevice { + t.Fatalf("%s CoreAudio did not exercise full-duplex ISO media: before=%+v after=%+v probe=%s", + controller.name, mediaBefore, mediaAfter, probeResult.output) + } + } + if inputController { + runLiveInputLatencyProbe( + t, testCtx, inputProbe, inputSnapshot, controller, publishMarker) + } + if feedbackController && !feedbackVerified { + verifyFeedback() + } + + inputDeadline := time.Now().Add(750 * time.Millisecond) + for sequence := uint64(1); time.Now().Before(inputDeadline); sequence++ { + publishInput(sequence) + time.Sleep(time.Millisecond) + } + inputCtx, cancelInput := context.WithTimeout(testCtx, 20*time.Second) + inputStats, waitErr := waitForNativeStats(inputCtx, client, + controller.name+" direct interrupt input", func(stats udecx.Stats) bool { + return stats.InputReportsCompleted > before.InputReportsCompleted + }) + cancelInput() + if waitErr != nil { + t.Fatal(waitErr) + } + if inputStats.InputReportsSubmitted <= before.InputReportsSubmitted { + t.Fatalf("%s did not publish a direct input state: before=%+v after=%+v", + controller.name, before, inputStats) + } + + unregisterCtx, cancelUnregister := context.WithTimeout(testCtx, 20*time.Second) + if unregisterErr := host.Unregister(unregisterCtx, identity); unregisterErr != nil { + cancelUnregister() + t.Fatalf("unregister %s: %v", controller.name, unregisterErr) + } + cancelUnregister() + registered = false + + teardownCtx, cancelTeardown := context.WithTimeout(testCtx, 20*time.Second) + after, waitErr := waitForNativeStats(teardownCtx, client, + controller.name+" teardown", func(stats udecx.Stats) bool { + return stats.ActiveDevices == 0 && stats.PendingOperations == 0 + }) + cancelTeardown() + if waitErr != nil { + t.Fatal(waitErr) + } + assertCleanNativeStatsDelta(t, before, after) + }) + } + } + + t.Run("ConcurrentProductionSet", func(t *testing.T) { + type activeController struct { + name string + identity udecx.DeviceIdentity + publishInput func(uint64) + err error + } + controllers := liveNativeControllers() + before, queryErr := client.QueryStats(testCtx) + if queryErr != nil { + t.Fatal(queryErr) + } + + registered := make(chan activeController, len(controllers)) + var registerWG sync.WaitGroup + for controllerIndex, controller := range controllers { + controllerIndex, controller := controllerIndex, controller + registerWG.Add(1) + go func() { + defer registerWG.Done() + dev, publishInput, _, createErr := controller.new() + if createErr != nil { + registered <- activeController{name: controller.name, err: createErr} + return + } + identity, registerErr := host.Register( + testCtx, deviceIDBase+uint64(controllerIndex+1), dev) + registered <- activeController{ + name: controller.name, identity: identity, + publishInput: publishInput, err: registerErr, + } + }() + } + registerWG.Wait() + close(registered) + active := make([]activeController, 0, len(controllers)) + var registerErrors []error + for result := range registered { + if result.err != nil { + registerErrors = append(registerErrors, + fmt.Errorf("register %s: %w", result.name, result.err)) + continue + } + if result.identity.Generation != uint32(iterations+1) { + registerErrors = append(registerErrors, fmt.Errorf( + "%s concurrent generation=%d want %d", result.name, + result.identity.Generation, iterations+1)) + } + active = append(active, result) + } + defer func() { + for _, controller := range active { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 20*time.Second) + _ = host.Unregister(cleanupCtx, controller.identity) + cleanupCancel() + } + }() + if len(registerErrors) != 0 { + t.Fatalf("concurrent registration errors: %v", registerErrors) + } + + enumerateCtx, cancelEnumerate := context.WithTimeout(testCtx, 30*time.Second) + _, waitErr := waitForNativeStats(enumerateCtx, client, + "concurrent production enumeration", func(stats udecx.Stats) bool { + return stats.ActiveDevices == uint32(len(controllers)) + }) + cancelEnumerate() + if waitErr != nil { + t.Fatal(waitErr) + } + + var publishWG sync.WaitGroup + for _, controller := range active { + controller := controller + publishWG.Add(1) + go func() { + defer publishWG.Done() + deadline := time.Now().Add(2 * time.Second) + for sequence := uint64(1); time.Now().Before(deadline); sequence++ { + controller.publishInput(sequence) + time.Sleep(time.Millisecond) + } + }() + } + publishWG.Wait() + inputCtx, cancelInput := context.WithTimeout(testCtx, 20*time.Second) + _, waitErr = waitForNativeStats(inputCtx, client, + "concurrent direct interrupt input", func(stats udecx.Stats) bool { + return stats.InputReportsCompleted >= + before.InputReportsCompleted+uint64(len(controllers)) + }) + cancelInput() + if waitErr != nil { + t.Fatal(waitErr) + } + + unregistered := make(chan error, len(active)) + var unregisterWG sync.WaitGroup + for _, controller := range active { + controller := controller + unregisterWG.Add(1) + go func() { + defer unregisterWG.Done() + unregisterCtx, cancelUnregister := context.WithTimeout(testCtx, 30*time.Second) + defer cancelUnregister() + unregistered <- host.Unregister(unregisterCtx, controller.identity) + }() + } + unregisterWG.Wait() + close(unregistered) + var unregisterErrors []error + for unregisterErr := range unregistered { + if unregisterErr != nil { + unregisterErrors = append(unregisterErrors, unregisterErr) + } + } + if len(unregisterErrors) != 0 { + t.Fatalf("concurrent unregister errors: %v", unregisterErrors) + } + active = nil + + teardownCtx, cancelTeardown := context.WithTimeout(testCtx, 30*time.Second) + after, waitErr := waitForNativeStats(teardownCtx, client, + "concurrent production teardown", func(stats udecx.Stats) bool { + return stats.ActiveDevices == 0 && stats.PendingOperations == 0 + }) + cancelTeardown() + if waitErr != nil { + t.Fatal(waitErr) + } + assertCleanNativeStatsDelta(t, before, after) + }) +} + +// TestNativeUDELiveOwnerCrashRecovery proves the kernel owner's file cleanup +// contract with an actual process death. The child intentionally bypasses all +// Go defers; the parent must be able to reacquire the exclusive broker only +// after the driver has removed its children and drained forwarded URBs. +func TestNativeUDELiveOwnerCrashRecovery(t *testing.T) { + if os.Getenv(liveNativeTestEnvironment) != "1" { + t.Skipf("invoke the signed-package validation gate (which sets %s=1 and injects its source identity)", + liveNativeTestEnvironment) + } + if os.Getenv(liveNativeCrashChild) == "1" { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + client, err := udecx.Open(ctx) + if err != nil { + t.Fatalf("crash child open native UDE controller: %v", err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + server := serverusb.New(serverusb.ServerConfig{ConnectionTimeout: 5 * time.Second}, logger, nil) + processor, err := serverusb.NewNativeProcessor(server) + if err != nil { + t.Fatal(err) + } + host, err := udecx.NewHost(client, processor, 0) + if err != nil { + t.Fatal(err) + } + serveDone := make(chan error, 1) + go func() { serveDone <- host.Serve(ctx) }() + + dev, publishInput, _, err := liveNativeControllers()[2].new() + if err != nil { + t.Fatal(err) + } + if _, err = host.Register(ctx, 0x5649495043524153, dev); err != nil { + t.Fatalf("crash child register DualSense: %v", err) + } + enumerateCtx, cancelEnumerate := context.WithTimeout(ctx, 20*time.Second) + _, err = waitForNativeStats(enumerateCtx, client, + "crash child enumeration", func(stats udecx.Stats) bool { + return stats.ActiveDevices == 1 + }) + cancelEnumerate() + if err != nil { + t.Fatal(err) + } + inputDeadline := time.Now().Add(time.Second) + for sequence := uint64(1); time.Now().Before(inputDeadline); sequence++ { + publishInput(sequence) + time.Sleep(time.Millisecond) + } + inputCtx, cancelInput := context.WithTimeout(ctx, 20*time.Second) + _, err = waitForNativeStats(inputCtx, client, + "crash child direct input", func(stats udecx.Stats) bool { + return stats.InputReportsCompleted != 0 + }) + cancelInput() + if err != nil { + t.Fatal(err) + } + os.Exit(liveNativeCrashExitCode) + } + + command := exec.Command(os.Args[0], + "-test.run=^TestNativeUDELiveOwnerCrashRecovery$", "-test.timeout=90s") + command.Env = append(os.Environ(), liveNativeCrashChild+"=1") + output, err := command.CombinedOutput() + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != liveNativeCrashExitCode { + t.Fatalf("crash child exit=%v want %d; output:\n%s", + err, liveNativeCrashExitCode, output) + } + + recoveryCtx, cancelRecovery := context.WithTimeout(context.Background(), 45*time.Second) + defer cancelRecovery() + var recovered *udecx.Client + for recovered == nil { + candidate, openErr := udecx.Open(recoveryCtx) + if openErr == nil { + stats, queryErr := candidate.QueryStats(recoveryCtx) + if queryErr == nil && stats.ActiveDevices == 0 && stats.PendingOperations == 0 { + recovered = candidate + break + } + _ = candidate.Close() + } + select { + case <-recoveryCtx.Done(): + t.Fatalf("native UDE owner/child cleanup did not recover after broker death: %v", + recoveryCtx.Err()) + case <-time.After(50 * time.Millisecond): + } + } + defer func() { + if closeErr := recovered.Close(); closeErr != nil { + t.Errorf("close recovered native UDE controller: %v", closeErr) + } + }() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + server := serverusb.New(serverusb.ServerConfig{ConnectionTimeout: 5 * time.Second}, logger, nil) + processor, err := serverusb.NewNativeProcessor(server) + if err != nil { + t.Fatal(err) + } + host, err := udecx.NewHost(recovered, processor, 0) + if err != nil { + t.Fatal(err) + } + serveCtx, cancelServe := context.WithCancel(recoveryCtx) + serveDone := make(chan error, 1) + go func() { serveDone <- host.Serve(serveCtx) }() + dev, _, _, err := liveNativeControllers()[2].new() + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(recoveryCtx, 0x5649495043524153, dev) + if err != nil { + t.Fatalf("register controller after broker-death recovery: %v", err) + } + if err = host.Unregister(recoveryCtx, identity); err != nil { + t.Fatalf("unregister controller after broker-death recovery: %v", err) + } + cancelServe() + host.Close() + select { + case err = <-serveDone: + if err != nil { + t.Fatalf("recovered native UDE host shutdown: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("recovered native UDE host did not stop within 5 seconds") + } +} + +// TestNativeUDELiveRootRestartRecovery is enabled only by the signed-package +// PowerShell gate on a disposable Windows test machine. It restarts the exact +// installed root devnode while a real child and direct-input publisher are +// active, then requires the invalidated owner to terminate and a fresh broker +// session to enumerate and service input without stale kernel state. +func TestNativeUDELiveRootRestartRecovery(t *testing.T) { + if os.Getenv(liveNativeTestEnvironment) != "1" { + t.Skipf("invoke the signed-package validation gate (which sets %s=1 and injects its source identity)", + liveNativeTestEnvironment) + } + instanceID := os.Getenv(liveNativeRestartInstance) + if instanceID == "" { + t.Skipf("set %s only through the signed disposable-machine validation gate", + liveNativeRestartInstance) + } + + testCtx, cancelTest := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancelTest() + client, err := udecx.Open(testCtx) + if err != nil { + t.Fatalf("open native UDE controller before root restart: %v", err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + server := serverusb.New(serverusb.ServerConfig{ConnectionTimeout: 5 * time.Second}, logger, nil) + processor, err := serverusb.NewNativeProcessor(server) + if err != nil { + t.Fatal(err) + } + host, err := udecx.NewHost(client, processor, 0) + if err != nil { + t.Fatal(err) + } + serveDone := make(chan error, 1) + go func() { serveDone <- host.Serve(testCtx) }() + dev, publishInput, _, err := liveNativeControllers()[2].new() + if err != nil { + t.Fatal(err) + } + if _, err = host.Register(testCtx, 0x56494950504e5052, dev); err != nil { + t.Fatalf("register DualSense before root restart: %v", err) + } + inputDeadline := time.Now().Add(time.Second) + for sequence := uint64(1); time.Now().Before(inputDeadline); sequence++ { + publishInput(sequence) + time.Sleep(time.Millisecond) + } + inputCtx, cancelInput := context.WithTimeout(testCtx, 20*time.Second) + _, err = waitForNativeStats(inputCtx, client, + "pre-restart direct input", func(stats udecx.Stats) bool { + return stats.ActiveDevices == 1 && stats.InputReportsCompleted != 0 + }) + cancelInput() + if err != nil { + t.Fatal(err) + } + + restart := exec.CommandContext(testCtx, "pnputil.exe", "/restart-device", instanceID) + restartOutput, restartErr := restart.CombinedOutput() + if restartErr != nil { + host.Close() + _ = client.Close() + t.Fatalf("restart exact native UDE root devnode %q: %v\n%s", + instanceID, restartErr, restartOutput) + } + select { + case <-serveDone: + case <-time.After(30 * time.Second): + host.Close() + _ = client.Close() + t.Fatal("native host did not observe root-devnode restart within 30 seconds") + } + host.Close() + if closeErr := client.Close(); closeErr != nil { + t.Fatalf("close invalidated pre-restart controller handle: %v", closeErr) + } + + var recovered *udecx.Client + recoveryDeadline := time.Now().Add(45 * time.Second) + for recovered == nil && time.Now().Before(recoveryDeadline) { + candidate, openErr := udecx.Open(testCtx) + if openErr == nil { + stats, queryErr := candidate.QueryStats(testCtx) + if queryErr == nil && stats.ActiveDevices == 0 && stats.PendingOperations == 0 { + recovered = candidate + break + } + _ = candidate.Close() + } + time.Sleep(50 * time.Millisecond) + } + if recovered == nil { + t.Fatal("native UDE root devnode did not return as a clean exclusive broker after restart") + } + defer recovered.Close() + + server = serverusb.New(serverusb.ServerConfig{ConnectionTimeout: 5 * time.Second}, logger, nil) + processor, err = serverusb.NewNativeProcessor(server) + if err != nil { + t.Fatal(err) + } + recoveredHost, err := udecx.NewHost(recovered, processor, 0) + if err != nil { + t.Fatal(err) + } + recoveredCtx, cancelRecovered := context.WithCancel(testCtx) + recoveredDone := make(chan error, 1) + go func() { recoveredDone <- recoveredHost.Serve(recoveredCtx) }() + dev, publishInput, _, err = liveNativeControllers()[2].new() + if err != nil { + t.Fatal(err) + } + identity, err := recoveredHost.Register(testCtx, 0x56494950504e5052, dev) + if err != nil { + t.Fatalf("register DualSense after root restart: %v", err) + } + for sequence := uint64(1); sequence <= 1000; sequence++ { + publishInput(sequence) + time.Sleep(time.Millisecond) + } + recoveredInputCtx, cancelRecoveredInput := context.WithTimeout(testCtx, 20*time.Second) + _, err = waitForNativeStats(recoveredInputCtx, recovered, + "post-restart direct input", func(stats udecx.Stats) bool { + return stats.ActiveDevices == 1 && stats.InputReportsCompleted != 0 + }) + cancelRecoveredInput() + if err != nil { + t.Fatal(err) + } + if err = recoveredHost.Unregister(testCtx, identity); err != nil { + t.Fatalf("unregister DualSense after root restart: %v", err) + } + cleanCtx, cancelClean := context.WithTimeout(testCtx, 20*time.Second) + after, err := waitForNativeStats(cleanCtx, recovered, + "post-restart teardown", func(stats udecx.Stats) bool { + return stats.ActiveDevices == 0 && stats.PendingOperations == 0 + }) + cancelClean() + if err != nil { + t.Fatal(err) + } + assertCleanNativeStatsDelta(t, udecx.Stats{}, after) + cancelRecovered() + recoveredHost.Close() + select { + case err = <-recoveredDone: + if err != nil { + t.Fatalf("post-restart host shutdown: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("post-restart native host did not stop within 5 seconds") + } +} diff --git a/internal/server/usb/native_playstation_parity_test.go b/internal/server/usb/native_playstation_parity_test.go new file mode 100644 index 00000000..1a5e341d --- /dev/null +++ b/internal/server/usb/native_playstation_parity_test.go @@ -0,0 +1,853 @@ +package usb_test + +import ( + "bytes" + "context" + "encoding/binary" + "io" + "log/slog" + "testing" + + "github.com/Alia5/VIIPER/device/dualsense" + "github.com/Alia5/VIIPER/device/dualshock4" + usbserver "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/transport/udecx" + usbdevice "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" +) + +const ( + dualSenseHIDInterface = 3 + hidRequestTypeOut = 0x21 + hidRequestSetReport = 0x09 + hidReportTypeOutput = 0x02 +) + +// playStationParityHarness treats the established USB/IP adapter and the +// controller engine behind it as the oracle. Native operations are applied to +// an independent controller instance and compared at the controller-stream +// boundary. The harness deliberately does not duplicate media algorithms. +type playStationParityHarness struct { + native *usbserver.NativeProcessor + identity udecx.DeviceIdentity + token uint64 +} + +func newPlayStationParityHarness(t *testing.T) *playStationParityHarness { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + native, err := usbserver.NewNativeProcessor(usbserver.New(usbserver.ServerConfig{}, logger, nil)) + if err != nil { + t.Fatal(err) + } + + return &playStationParityHarness{ + native: native, + identity: udecx.DeviceIdentity{DeviceID: 0x5053, Generation: 1}, + } +} + +func (h *playStationParityHarness) nextToken() uint64 { + h.token++ + return h.token +} + +func parityEndpoint(t *testing.T, dev usbdevice.Device, address uint8) usbdevice.EndpointDescriptor { + t.Helper() + for _, iface := range dev.GetDescriptor().Interfaces { + for _, endpoint := range iface.Endpoints { + if endpoint.BEndpointAddress == address { + return endpoint + } + } + } + t.Fatalf("endpoint 0x%02x is absent from the controller descriptor", address) + return usbdevice.EndpointDescriptor{} +} + +func endpointOperation(t *testing.T, identity udecx.DeviceIdentity, kind udecx.OperationKind, + speed uint32, endpoint usbdevice.EndpointDescriptor, +) udecx.Operation { + t.Helper() + endpoint, err := udecx.EndpointDescriptorForNativeUdeCx(udecx.DeviceSpeed(speed), endpoint) + if err != nil { + t.Fatal(err) + } + return udecx.Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, Kind: kind, + EndpointAddress: endpoint.BEndpointAddress, + EndpointAttributes: endpoint.BMAttributes, + EndpointInterval: endpoint.BInterval, + EndpointMaxPacketSize: endpoint.WMaxPacketSize, + } +} + +func (h *playStationParityHarness) nativeLifecycle(t *testing.T, dev usbdevice.Device, + kind udecx.OperationKind, address uint8, +) { + t.Helper() + op := udecx.Operation{ + DeviceID: h.identity.DeviceID, Generation: h.identity.Generation, Kind: kind, + } + if address != 0 { + op = endpointOperation(t, h.identity, kind, dev.GetDescriptor().Device.Speed, + parityEndpoint(t, dev, address)) + } + if err := h.native.Lifecycle(context.Background(), dev, op); err != nil { + t.Fatalf("native lifecycle kind %d endpoint 0x%02x: %v", kind, address, err) + } +} + +func setupPacket(bmRequestType, request uint8, value, index, length uint16) [8]byte { + var setup [8]byte + setup[0] = bmRequestType + setup[1] = request + binary.LittleEndian.PutUint16(setup[2:4], value) + binary.LittleEndian.PutUint16(setup[4:6], index) + binary.LittleEndian.PutUint16(setup[6:8], length) + return setup +} + +func (h *playStationParityHarness) legacySetInterface(dev usbdevice.Device, iface, alt uint8) { + dev.(usbdevice.InterfaceAltSettingDevice).SetInterfaceAltSetting(iface, alt) +} + +func (h *playStationParityHarness) legacyResetEndpoint(dev usbdevice.Device, address uint8) { + dev.(usbdevice.EndpointResetDevice).ResetEndpoint(address) +} + +func (h *playStationParityHarness) legacyOutput(dev usbdevice.Device, address uint8, payload []byte) { + dev.HandleTransfer(context.Background(), uint32(address&0x0f), + usbdevice.DirectionOut, payload) +} + +func (h *playStationParityHarness) nativeOutput(t *testing.T, dev usbdevice.Device, + address uint8, payload []byte, +) { + t.Helper() + endpoint := parityEndpoint(t, dev, address) + op := endpointOperation(t, h.identity, udecx.OperationTransfer, + dev.GetDescriptor().Device.Speed, endpoint) + op.Token = h.nextToken() + op.TransferLength = uint32(len(payload)) + op.Payload = append([]byte(nil), payload...) + completion, err := h.native.Process(context.Background(), dev, op) + if err != nil { + t.Fatalf("native OUT endpoint 0x%02x: %v", address, err) + } + if completion.TransferLength != uint32(len(payload)) || len(completion.Payload) != 0 { + t.Fatalf("native OUT completion length=%d payload=% x, want length=%d and no echo", + completion.TransferLength, completion.Payload, len(payload)) + } +} + +func (h *playStationParityHarness) legacyHIDSetReport(t *testing.T, dev usbdevice.Device, + interfaceNumber, reportID uint8, payload []byte, +) { + t.Helper() + _, handled := dev.(usbdevice.ControlDevice).HandleControl( + hidRequestTypeOut, hidRequestSetReport, + uint16(hidReportTypeOutput)<<8|uint16(reportID), + uint16(interfaceNumber), uint16(len(payload)), payload) + if !handled { + t.Fatal("USB/IP oracle rejected HID SET_REPORT") + } +} + +func (h *playStationParityHarness) nativeHIDSetReport(t *testing.T, dev usbdevice.Device, + interfaceNumber, reportID uint8, payload []byte, +) { + t.Helper() + op := udecx.Operation{ + Token: h.nextToken(), DeviceID: h.identity.DeviceID, Generation: h.identity.Generation, + Kind: udecx.OperationControl, Direction: 0, TransferLength: uint32(len(payload)), + SetupPacket: setupPacket(hidRequestTypeOut, hidRequestSetReport, + uint16(hidReportTypeOutput)<<8|uint16(reportID), uint16(interfaceNumber), uint16(len(payload))), + Payload: append([]byte(nil), payload...), + } + completion, err := h.native.Process(context.Background(), dev, op) + if err != nil { + t.Fatalf("native HID SET_REPORT: %v", err) + } + if completion.TransferLength != uint32(len(payload)) || len(completion.Payload) != 0 { + t.Fatalf("native SET_REPORT completion=%+v", completion) + } +} + +func sequentialIsoPackets(totalBytes, packetBytes int) []udecx.IsoPacket { + packets := make([]udecx.IsoPacket, 0, (totalBytes+packetBytes-1)/packetBytes) + for offset := 0; offset < totalBytes; offset += packetBytes { + length := min(packetBytes, totalBytes-offset) + packets = append(packets, udecx.IsoPacket{Offset: uint32(offset), Length: uint32(length)}) + } + return packets +} + +func sparseIsoPackets(count int, packetBytes, gap uint32) ([]udecx.IsoPacket, uint32) { + packets := make([]udecx.IsoPacket, count) + var transferLength uint32 + for index := range packets { + offset := uint32(index) * (packetBytes + gap) + packets[index] = udecx.IsoPacket{Offset: offset, Length: packetBytes} + transferLength = offset + packetBytes + } + return packets, transferLength +} + +func (h *playStationParityHarness) nativeISO(t *testing.T, dev usbdevice.Device, + address uint8, transferLength uint32, payload []byte, packets []udecx.IsoPacket, +) udecx.Completion { + t.Helper() + endpoint := parityEndpoint(t, dev, address) + op := endpointOperation(t, h.identity, udecx.OperationTransfer, + dev.GetDescriptor().Device.Speed, endpoint) + op.Token = h.nextToken() + op.TransferLength = transferLength + op.IsoPackets = append([]udecx.IsoPacket(nil), packets...) + op.TransferFlags = udecx.TransferFlagStartIsoASAP + if address&0x80 != 0 { + op.Direction = 1 + op.TransferFlags |= udecx.TransferFlagDirectionIn + } else { + op.Payload = append([]byte(nil), payload...) + } + completion, err := h.native.Process(context.Background(), dev, op) + if err != nil { + t.Fatalf("native ISO endpoint 0x%02x: %v", address, err) + } + return completion +} + +func legacyISOIn(t *testing.T, dev usbdevice.Device, address uint8, + packets []udecx.IsoPacket, +) ([]byte, []usbip.IsoPacketDescriptor) { + t.Helper() + payload := make([]byte, 0) + completed := make([]usbip.IsoPacketDescriptor, len(packets)) + for index, packet := range packets { + packetData := dev.HandleTransfer(context.Background(), uint32(address&0x0f), + usbdevice.DirectionIn, nil) + actual := min(packet.Length, uint32(len(packetData))) + payload = append(payload, packetData[:actual]...) + completed[index] = usbip.IsoPacketDescriptor{ + Offset: packet.Offset, Length: packet.Length, ActualLength: actual, + } + } + return payload, completed +} + +func compactNativeISO(t *testing.T, completion udecx.Completion) ([]byte, []uint32) { + t.Helper() + compact := make([]byte, 0, completion.TransferLength) + lengths := make([]uint32, len(completion.IsoPackets)) + for index, packet := range completion.IsoPackets { + end := packet.Offset + packet.Length + if end > uint32(len(completion.Payload)) { + t.Fatalf("native completed packet %d exceeds payload: %+v payload=%d", + index, packet, len(completion.Payload)) + } + compact = append(compact, completion.Payload[packet.Offset:end]...) + lengths[index] = packet.Length + } + return compact, lengths +} + +func compactLegacyISOLengths(completed []usbip.IsoPacketDescriptor) []uint32 { + lengths := make([]uint32, len(completed)) + for index, packet := range completed { + lengths[index] = packet.ActualLength + } + return lengths +} + +func normalizeDualSenseInput(report []byte) []byte { + normalized := append([]byte(nil), report...) + if len(normalized) >= dualsense.InputReportSize { + clear(normalized[28:32]) + clear(normalized[49:53]) + } + return normalized +} + +func normalizeDualShock4Input(report []byte) []byte { + normalized := append([]byte(nil), report...) + if len(normalized) >= dualshock4.InputReportSize { + clear(normalized[10:12]) + } + return normalized +} + +func patternedPCM(size int, seed byte) []byte { + pcm := make([]byte, size) + for index := range pcm { + pcm[index] = seed + byte(index*29+index/7) + } + return pcm +} + +func requireDeviceBool(t *testing.T, dev usbdevice.Device, key string, want bool) { + t.Helper() + got, ok := dev.GetDeviceSpecificArgs()[key].(bool) + if !ok || got != want { + t.Fatalf("device state %s=%v (bool=%t), want %t", key, got, ok, want) + } +} + +type dualSenseAtomicCapture struct { + feedback dualsense.OutputState + speaker []byte +} + +type dualSenseParityCapture struct { + outputs []dualsense.OutputState + atomic []dualSenseAtomicCapture + realtime []dualsense.OutputState + resets int + events []string +} + +func (capture *dualSenseParityCapture) attach(dev *dualsense.DualSense) { + dev.SetOutputCallback(func(state dualsense.OutputState) { + capture.outputs = append(capture.outputs, state) + capture.events = append(capture.events, "output") + }) + dev.SetAtomicAudioHapticsCallback(func(state dualsense.OutputState, speaker []byte) { + capture.atomic = append(capture.atomic, dualSenseAtomicCapture{ + feedback: state, speaker: append([]byte(nil), speaker...), + }) + capture.events = append(capture.events, "atomic") + }) + dev.SetRealtimeHapticsCallback(func(state dualsense.OutputState) { + capture.realtime = append(capture.realtime, state) + capture.events = append(capture.events, "realtime") + }) + dev.SetSpeakerResetCallback(func() { + capture.resets++ + capture.events = append(capture.events, "reset") + }) +} + +func dualSensePCM(frames int, bias int16) []byte { + pcm := make([]byte, frames*dualsense.USBHapticsAudioFrameSize) + for frame := 0; frame < frames; frame++ { + offset := frame * dualsense.USBHapticsAudioFrameSize + binary.LittleEndian.PutUint16(pcm[offset:offset+2], uint16(bias+int16(frame))) + binary.LittleEndian.PutUint16(pcm[offset+2:offset+4], uint16(-bias-int16(frame))) + binary.LittleEndian.PutUint16(pcm[offset+4:offset+6], uint16(2*bias+int16(frame*3))) + binary.LittleEndian.PutUint16(pcm[offset+6:offset+8], uint16(-2*bias-int16(frame*3))) + } + return pcm +} + +func dualSenseFrontStereo(pcm []byte) []byte { + frames := len(pcm) / dualsense.USBHapticsAudioFrameSize + front := make([]byte, frames*dualsense.USBHapticsAudioBytesPerSample*2) + for frame := 0; frame < frames; frame++ { + copy(front[frame*4:frame*4+4], + pcm[frame*dualsense.USBHapticsAudioFrameSize:frame*dualsense.USBHapticsAudioFrameSize+4]) + } + return front +} + +func requireDualSenseCapturesEqual(t *testing.T, legacy, native *dualSenseParityCapture) { + t.Helper() + if len(legacy.outputs) != len(native.outputs) || + len(legacy.atomic) != len(native.atomic) || + len(legacy.realtime) != len(native.realtime) || legacy.resets != native.resets || + !bytes.Equal([]byte(joinParityEvents(legacy.events)), []byte(joinParityEvents(native.events))) { + t.Fatalf("DualSense callback boundary mismatch:\nlegacy outputs=%d atomic=%d realtime=%d resets=%d events=%v\nnative outputs=%d atomic=%d realtime=%d resets=%d events=%v", + len(legacy.outputs), len(legacy.atomic), len(legacy.realtime), legacy.resets, legacy.events, + len(native.outputs), len(native.atomic), len(native.realtime), native.resets, native.events) + } + for index := range legacy.outputs { + if legacy.outputs[index] != native.outputs[index] { + t.Fatalf("DualSense output state %d differs across transports", index) + } + } + for index := range legacy.atomic { + if legacy.atomic[index].feedback != native.atomic[index].feedback || + !bytes.Equal(legacy.atomic[index].speaker, native.atomic[index].speaker) { + t.Fatalf("DualSense atomic media generation %d differs across transports", index) + } + } + for index := range legacy.realtime { + if legacy.realtime[index] != native.realtime[index] { + t.Fatalf("DualSense realtime haptics generation %d differs across transports", index) + } + } +} + +func joinParityEvents(events []string) string { + var joined string + for _, event := range events { + joined += event + "\x00" + } + return joined +} + +func TestNativeDualSenseMatchesUSBIPOracle(t *testing.T) { + harness := newPlayStationParityHarness(t) + legacy, err := dualsense.New(nil) + if err != nil { + t.Fatal(err) + } + native, err := dualsense.New(nil) + if err != nil { + t.Fatal(err) + } + legacyCapture := &dualSenseParityCapture{} + nativeCapture := &dualSenseParityCapture{} + legacyCapture.attach(legacy) + nativeCapture.attach(native) + + t.Run("native fast HID input preserves state bytes", func(t *testing.T) { + state := dualsense.NewInputState() + state.LX, state.LY, state.RX, state.RY = -101, 87, 45, -32 + state.Buttons = dualsense.ButtonCross | dualsense.ButtonR1 | dualsense.ButtonPS + state.DPad = dualsense.DPadUp | dualsense.DPadRight + state.L2, state.R2 = 0x39, 0xe4 + state.Touch1Active, state.Touch1Tracking = true, 7 + state.Touch1X, state.Touch1Y = 1234, 567 + state.GyroX, state.GyroY, state.GyroZ = 101, -202, 303 + legacy.UpdateInputState(state) + native.UpdateInputState(state) + + legacyReport := legacy.HandleTransfer(context.Background(), + uint32(dualsense.EndpointIn&0x0f), usbdevice.DirectionIn, nil) + nativeReport := make([]byte, dualsense.InputReportSize) + written, readErr := native.ReadInterruptInput(context.Background(), + uint32(dualsense.EndpointIn), nativeReport) + if readErr != nil || written != dualsense.InputReportSize { + t.Fatalf("native DualSense HID read wrote %d: %v", written, readErr) + } + if !bytes.Equal(normalizeDualSenseInput(legacyReport), normalizeDualSenseInput(nativeReport)) { + t.Fatalf("DualSense HID state differs:\nlegacy=% x\nnative=% x", legacyReport, nativeReport) + } + if nativeReport[1] != uint8(int16(state.LX)+128) || nativeReport[5] != state.L2 || + nativeReport[6] != state.R2 || nativeReport[34] != byte(state.Touch1X) { + t.Fatalf("native DualSense HID report did not encode the requested state: % x", nativeReport) + } + }) + + t.Run("HID feedback preserves rumble lightbar player LEDs and triggers", func(t *testing.T) { + triggers := make([]byte, dualsense.OutputReportSize) + triggers[0] = dualsense.ReportIDOutput + triggers[1] = 0x0c + copy(triggers[11:21], []byte{0x21, 0xf0, 0x03, 0x04, 0x05, 0x06, 0x07, 0, 0, 0x44}) + copy(triggers[22:32], []byte{0x25, 0x40, 0x05, 0x14, 0x15, 0x16, 0x17, 0, 0, 0x55}) + harness.legacyOutput(legacy, dualsense.EndpointOut, triggers) + harness.nativeOutput(t, native, dualsense.EndpointOut, triggers) + + visualRumble := make([]byte, dualsense.OutputReportSize) + visualRumble[0] = dualsense.ReportIDOutput + visualRumble[1] = 0x03 + visualRumble[2] = 0x14 + visualRumble[3], visualRumble[4] = 0x2a, 0xb4 + visualRumble[44] = 0x1f + visualRumble[45], visualRumble[46], visualRumble[47] = 0x12, 0x67, 0xcd + harness.legacyHIDSetReport(t, legacy, dualSenseHIDInterface, dualsense.ReportIDOutput, visualRumble) + harness.nativeHIDSetReport(t, native, dualSenseHIDInterface, + dualsense.ReportIDOutput, visualRumble) + + requireDualSenseCapturesEqual(t, legacyCapture, nativeCapture) + got := nativeCapture.outputs[len(nativeCapture.outputs)-1] + if got.RumbleSmall != 0x2a || got.RumbleLarge != 0xb4 || + got.LedRed != 0x12 || got.LedGreen != 0x67 || got.LedBlue != 0xcd || + got.PlayerLeds != 0x1f || got.TriggerR2Mode != 0x21 || + got.TriggerL2Mode != 0x25 || !bytes.Equal(got.RawOutputReport[:], visualRumble) { + t.Fatalf("native DualSense feedback state lost a host field: %+v", got) + } + }) + + t.Run("haptics OUT preserves bytes and independent 480/512-frame boundaries", func(t *testing.T) { + harness.legacySetInterface(legacy, dualsense.InterfaceHapticsAudio, 1) + harness.nativeLifecycle(t, native, udecx.OperationEndpointStart, + dualsense.EndpointHapticsAudioOut) + requireDeviceBool(t, legacy, "speakerInterfaceActive", true) + requireDeviceBool(t, native, "speakerInterfaceActive", true) + requireDualSenseCapturesEqual(t, legacyCapture, nativeCapture) + + pcm := dualSensePCM(512, 700) + parts := [][2]int{{0, 240}, {240, 480}, {480, 512}} + for index, part := range parts { + payload := pcm[part[0]*dualsense.USBHapticsAudioFrameSize : part[1]*dualsense.USBHapticsAudioFrameSize] + packets := sequentialIsoPackets(len(payload), dualsense.USBHapticsAudioPacketSize) + legacy.HandleTransfer(context.Background(), + uint32(dualsense.EndpointHapticsAudioOut&0x0f), usbdevice.DirectionOut, payload) + completion := harness.nativeISO(t, native, dualsense.EndpointHapticsAudioOut, + uint32(len(payload)), payload, packets) + if completion.TransferLength != uint32(len(payload)) || len(completion.Payload) != 0 || + len(completion.IsoPackets) != len(packets) { + t.Fatalf("native DualSense ISO OUT part %d completion=%+v", index, completion) + } + requireDualSenseCapturesEqual(t, legacyCapture, nativeCapture) + switch index { + case 0: + if len(nativeCapture.atomic) != 0 || len(nativeCapture.realtime) != 0 { + t.Fatal("DualSense emitted media before either source-clock boundary") + } + case 1: + if len(nativeCapture.atomic) != 1 || len(nativeCapture.realtime) != 0 { + t.Fatal("DualSense 480-frame speaker boundary was not independent") + } + wantSpeaker := dualSenseFrontStereo(pcm[:480*dualsense.USBHapticsAudioFrameSize]) + if !bytes.Equal(nativeCapture.atomic[0].speaker, wantSpeaker) { + t.Fatal("native DualSense front-channel PCM changed byte order") + } + case 2: + if len(nativeCapture.atomic) != 1 || len(nativeCapture.realtime) != 1 { + t.Fatal("DualSense 512-frame haptics boundary was not preserved") + } + } + } + + // Leave 32 old speaker frames pending, reset the pipe, then prove that + // 448 fresh frames cannot complete a stale 480-frame generation. + harness.legacyResetEndpoint(legacy, dualsense.EndpointHapticsAudioOut) + harness.nativeLifecycle(t, native, udecx.OperationEndpointReset, + dualsense.EndpointHapticsAudioOut) + requireDualSenseCapturesEqual(t, legacyCapture, nativeCapture) + requireDeviceBool(t, legacy, "speakerInterfaceActive", true) + requireDeviceBool(t, native, "speakerInterfaceActive", true) + + fresh := dualSensePCM(480, 2_000) + for _, part := range [][2]int{{0, 448}, {448, 480}} { + payload := fresh[part[0]*dualsense.USBHapticsAudioFrameSize : part[1]*dualsense.USBHapticsAudioFrameSize] + packets := sequentialIsoPackets(len(payload), dualsense.USBHapticsAudioPacketSize) + legacy.HandleTransfer(context.Background(), + uint32(dualsense.EndpointHapticsAudioOut&0x0f), usbdevice.DirectionOut, payload) + harness.nativeISO(t, native, dualsense.EndpointHapticsAudioOut, + uint32(len(payload)), payload, packets) + if part[1] == 448 && len(nativeCapture.atomic) != 1 { + t.Fatal("stale DualSense speaker PCM crossed the endpoint reset") + } + } + requireDualSenseCapturesEqual(t, legacyCapture, nativeCapture) + if len(nativeCapture.atomic) != 2 || + !bytes.Equal(nativeCapture.atomic[1].speaker, dualSenseFrontStereo(fresh)) { + t.Fatal("fresh DualSense speaker generation was not byte-exact after reset") + } + + harness.legacySetInterface(legacy, dualsense.InterfaceHapticsAudio, 0) + harness.nativeLifecycle(t, native, udecx.OperationEndpointPurge, + dualsense.EndpointHapticsAudioOut) + requireDeviceBool(t, legacy, "speakerInterfaceActive", false) + requireDeviceBool(t, native, "speakerInterfaceActive", false) + requireDualSenseCapturesEqual(t, legacyCapture, nativeCapture) + }) + + t.Run("microphone IN preserves sparse packet bytes and reset priming", func(t *testing.T) { + harness.legacySetInterface(legacy, dualsense.InterfaceMicrophone, 1) + harness.nativeLifecycle(t, native, udecx.OperationEndpointStart, + dualsense.EndpointMicrophoneIn) + requireDeviceBool(t, legacy, "microphoneInterfaceActive", true) + requireDeviceBool(t, native, "microphoneInterfaceActive", true) + + queued := make([]byte, 0, 6*dualsense.USBMicrophoneClientFrameSize) + for frame := 0; frame < 6; frame++ { + pcm := patternedPCM(dualsense.USBMicrophoneClientFrameSize, byte(0x10+frame*17)) + legacy.QueueMicrophonePCMFrame(pcm) + native.QueueMicrophonePCMFrame(pcm) + queued = append(queued, pcm...) + } + packets, transferLength := sparseIsoPackets(3, dualsense.USBMicrophoneMaxPacketSize, 11) + legacyPayload, legacyCompleted := legacyISOIn(t, legacy, + dualsense.EndpointMicrophoneIn, packets) + nativeCompletion := harness.nativeISO(t, native, dualsense.EndpointMicrophoneIn, + transferLength, nil, packets) + nativePayload, nativeLengths := compactNativeISO(t, nativeCompletion) + legacyLengths := compactLegacyISOLengths(legacyCompleted) + if !bytes.Equal(legacyPayload, nativePayload) || + !bytes.Equal(uint32sAsBytes(legacyLengths), uint32sAsBytes(nativeLengths)) || + !bytes.Equal(nativePayload, queued[:len(nativePayload)]) { + t.Fatalf("DualSense microphone packet parity failed:\nlegacy lengths=%v payload=% x\nnative lengths=%v payload=% x", + legacyLengths, legacyPayload, nativeLengths, nativePayload) + } + + harness.legacyResetEndpoint(legacy, dualsense.EndpointMicrophoneIn) + harness.nativeLifecycle(t, native, udecx.OperationEndpointReset, + dualsense.EndpointMicrophoneIn) + requireDeviceBool(t, legacy, "microphoneInterfaceActive", true) + requireDeviceBool(t, native, "microphoneInterfaceActive", true) + zeroPackets, zeroLength := sparseIsoPackets(1, dualsense.USBMicrophoneMaxPacketSize, 0) + legacyZero, legacyZeroCompleted := legacyISOIn(t, legacy, + dualsense.EndpointMicrophoneIn, zeroPackets) + nativeZeroCompletion := harness.nativeISO(t, native, dualsense.EndpointMicrophoneIn, + zeroLength, nil, zeroPackets) + nativeZero, nativeZeroLengths := compactNativeISO(t, nativeZeroCompletion) + if !bytes.Equal(legacyZero, nativeZero) || + !bytes.Equal(nativeZero, make([]byte, dualsense.USBMicrophonePacketSize)) || + legacyZeroCompleted[0].ActualLength != uint32(dualsense.USBMicrophonePacketSize) || + nativeZeroLengths[0] != uint32(dualsense.USBMicrophonePacketSize) { + t.Fatalf("DualSense reset silence differs: legacy=% x native=% x", legacyZero, nativeZero) + } + + freshQueued := make([]byte, 0, 6*dualsense.USBMicrophoneClientFrameSize) + for frame := 0; frame < 6; frame++ { + pcm := patternedPCM(dualsense.USBMicrophoneClientFrameSize, byte(0x90+frame*9)) + legacy.QueueMicrophonePCMFrame(pcm) + native.QueueMicrophonePCMFrame(pcm) + freshQueued = append(freshQueued, pcm...) + } + legacyFresh, _ := legacyISOIn(t, legacy, + dualsense.EndpointMicrophoneIn, zeroPackets) + nativeFreshCompletion := harness.nativeISO(t, native, dualsense.EndpointMicrophoneIn, + zeroLength, nil, zeroPackets) + nativeFresh, _ := compactNativeISO(t, nativeFreshCompletion) + if !bytes.Equal(legacyFresh, nativeFresh) || + !bytes.Equal(nativeFresh, freshQueued[:len(nativeFresh)]) { + t.Fatal("DualSense microphone reset replayed stale capture bytes") + } + + harness.legacySetInterface(legacy, dualsense.InterfaceMicrophone, 0) + harness.nativeLifecycle(t, native, udecx.OperationEndpointPurge, + dualsense.EndpointMicrophoneIn) + requireDeviceBool(t, legacy, "microphoneInterfaceActive", false) + requireDeviceBool(t, native, "microphoneInterfaceActive", false) + }) +} + +func uint32sAsBytes(values []uint32) []byte { + result := make([]byte, len(values)*4) + for index, value := range values { + binary.LittleEndian.PutUint32(result[index*4:index*4+4], value) + } + return result +} + +type dualShock4ParityCapture struct { + outputs []dualshock4.OutputState + speaker [][]byte + resets int + events []string +} + +func (capture *dualShock4ParityCapture) attach(dev *dualshock4.DualShock4) { + dev.SetOutputCallback(func(state dualshock4.OutputState) { + capture.outputs = append(capture.outputs, state) + capture.events = append(capture.events, "output") + }) + dev.SetSpeakerCallback(func(pcm []byte) { + capture.speaker = append(capture.speaker, append([]byte(nil), pcm...)) + capture.events = append(capture.events, "speaker") + }) + dev.SetSpeakerResetCallback(func() { + capture.resets++ + capture.events = append(capture.events, "reset") + }) +} + +func requireDualShock4CapturesEqual(t *testing.T, legacy, native *dualShock4ParityCapture) { + t.Helper() + if len(legacy.outputs) != len(native.outputs) || len(legacy.speaker) != len(native.speaker) || + legacy.resets != native.resets || joinParityEvents(legacy.events) != joinParityEvents(native.events) { + t.Fatalf("DualShock 4 callback boundary mismatch:\nlegacy outputs=%d speaker=%d resets=%d events=%v\nnative outputs=%d speaker=%d resets=%d events=%v", + len(legacy.outputs), len(legacy.speaker), legacy.resets, legacy.events, + len(native.outputs), len(native.speaker), native.resets, native.events) + } + for index := range legacy.outputs { + if legacy.outputs[index] != native.outputs[index] { + t.Fatalf("DualShock 4 output state %d differs across transports", index) + } + } + for index := range legacy.speaker { + if !bytes.Equal(legacy.speaker[index], native.speaker[index]) { + t.Fatalf("DualShock 4 speaker generation %d differs across transports", index) + } + } +} + +func TestNativeDualShock4MatchesUSBIPOracle(t *testing.T) { + harness := newPlayStationParityHarness(t) + legacy, err := dualshock4.New(nil) + if err != nil { + t.Fatal(err) + } + native, err := dualshock4.New(nil) + if err != nil { + t.Fatal(err) + } + legacyCapture := &dualShock4ParityCapture{} + nativeCapture := &dualShock4ParityCapture{} + legacyCapture.attach(legacy) + nativeCapture.attach(native) + + t.Run("native fast HID input preserves state bytes", func(t *testing.T) { + state := dualshock4.NewInputState() + state.LX, state.LY, state.RX, state.RY = -95, 71, 44, -23 + state.Buttons = dualshock4.ButtonCircle | dualshock4.ButtonL1 | + dualshock4.ButtonPS | dualshock4.ButtonTouchpadClick + state.DPad = dualshock4.DPadDown | dualshock4.DPadLeft + state.L2, state.R2 = 0x28, 0xdd + state.Touch2Active, state.Touch2X, state.Touch2Y = true, 777, 999 + state.GyroX, state.GyroY, state.GyroZ = -111, 222, -333 + legacy.UpdateInputState(state) + native.UpdateInputState(state) + + legacyReport := legacy.HandleTransfer(context.Background(), + uint32(dualshock4.EndpointIn&0x0f), usbdevice.DirectionIn, nil) + nativeReport := make([]byte, dualshock4.InputReportSize) + written, readErr := native.ReadInterruptInput(context.Background(), + uint32(dualshock4.EndpointIn), nativeReport) + if readErr != nil || written != dualshock4.InputReportSize { + t.Fatalf("native DualShock 4 HID read wrote %d: %v", written, readErr) + } + if !bytes.Equal(normalizeDualShock4Input(legacyReport), normalizeDualShock4Input(nativeReport)) { + t.Fatalf("DualShock 4 HID state differs:\nlegacy=% x\nnative=% x", legacyReport, nativeReport) + } + if nativeReport[1] != uint8(int16(state.LX)+128) || nativeReport[8] != state.L2 || + nativeReport[9] != state.R2 || nativeReport[7]&0x03 != 0x03 { + t.Fatalf("native DualShock 4 HID report did not encode the requested state: % x", nativeReport) + } + }) + + t.Run("HID feedback preserves rumble lightbar and flash state", func(t *testing.T) { + first := []byte{dualshock4.ReportIDOutput, 0, 0, 0, 0x12, 0xfe, 1, 2, 3, 4, 5} + harness.legacyOutput(legacy, dualshock4.EndpointOut, first) + harness.nativeOutput(t, native, dualshock4.EndpointOut, first) + + second := []byte{dualshock4.ReportIDOutput, 0, 0, 0, 0x39, 0xa4, 0x10, 0x20, 0x30, 0x40, 0x50} + harness.legacyHIDSetReport(t, legacy, dualshock4.InterfaceHID, + dualshock4.ReportIDOutput, second) + harness.nativeHIDSetReport(t, native, dualshock4.InterfaceHID, + dualshock4.ReportIDOutput, second) + requireDualShock4CapturesEqual(t, legacyCapture, nativeCapture) + got := nativeCapture.outputs[len(nativeCapture.outputs)-1] + want := dualshock4.OutputState{ + RumbleSmall: 0x39, RumbleLarge: 0xa4, + LedRed: 0x10, LedGreen: 0x20, LedBlue: 0x30, + FlashOn: 0x40, FlashOff: 0x50, + } + if got != want { + t.Fatalf("native DualShock 4 feedback=%+v want=%+v", got, want) + } + }) + + t.Run("speaker OUT preserves URB byte order and reset boundaries", func(t *testing.T) { + harness.legacySetInterface(legacy, dualshock4.InterfaceSpeaker, 1) + harness.nativeLifecycle(t, native, udecx.OperationEndpointStart, + dualshock4.EndpointAudioOut) + requireDeviceBool(t, legacy, "speakerInterfaceActive", true) + requireDeviceBool(t, native, "speakerInterfaceActive", true) + requireDualShock4CapturesEqual(t, legacyCapture, nativeCapture) + + payloads := [][]byte{ + patternedPCM(4*128, 0x11), + patternedPCM(3*128, 0x83), + } + for index, payload := range payloads { + packets := sequentialIsoPackets(len(payload), 128) + legacy.HandleTransfer(context.Background(), + uint32(dualshock4.EndpointAudioOut&0x0f), usbdevice.DirectionOut, payload) + completion := harness.nativeISO(t, native, dualshock4.EndpointAudioOut, + uint32(len(payload)), payload, packets) + if completion.TransferLength != uint32(len(payload)) || len(completion.IsoPackets) != len(packets) { + t.Fatalf("native DualShock 4 ISO OUT part %d completion=%+v", index, completion) + } + requireDualShock4CapturesEqual(t, legacyCapture, nativeCapture) + } + if len(nativeCapture.speaker) != len(payloads) { + t.Fatalf("native DualShock 4 combined or split speaker URBs: %d callbacks", len(nativeCapture.speaker)) + } + for index := range payloads { + if !bytes.Equal(nativeCapture.speaker[index], payloads[index]) { + t.Fatalf("native DualShock 4 speaker payload %d changed byte order", index) + } + } + + harness.legacyResetEndpoint(legacy, dualshock4.EndpointAudioOut) + harness.nativeLifecycle(t, native, udecx.OperationEndpointReset, + dualshock4.EndpointAudioOut) + requireDualShock4CapturesEqual(t, legacyCapture, nativeCapture) + requireDeviceBool(t, legacy, "speakerInterfaceActive", true) + requireDeviceBool(t, native, "speakerInterfaceActive", true) + + fresh := patternedPCM(2*128, 0x57) + packets := sequentialIsoPackets(len(fresh), 128) + legacy.HandleTransfer(context.Background(), + uint32(dualshock4.EndpointAudioOut&0x0f), usbdevice.DirectionOut, fresh) + harness.nativeISO(t, native, dualshock4.EndpointAudioOut, + uint32(len(fresh)), fresh, packets) + requireDualShock4CapturesEqual(t, legacyCapture, nativeCapture) + if !bytes.Equal(nativeCapture.speaker[len(nativeCapture.speaker)-1], fresh) { + t.Fatal("DualShock 4 endpoint reset changed the fresh speaker generation") + } + + harness.legacySetInterface(legacy, dualshock4.InterfaceSpeaker, 0) + harness.nativeLifecycle(t, native, udecx.OperationEndpointPurge, + dualshock4.EndpointAudioOut) + requireDeviceBool(t, legacy, "speakerInterfaceActive", false) + requireDeviceBool(t, native, "speakerInterfaceActive", false) + requireDualShock4CapturesEqual(t, legacyCapture, nativeCapture) + }) + + t.Run("microphone IN preserves sparse packet bytes and reset priming", func(t *testing.T) { + harness.legacySetInterface(legacy, dualshock4.InterfaceMicrophone, 1) + harness.nativeLifecycle(t, native, udecx.OperationEndpointStart, + dualshock4.EndpointMicrophoneIn) + requireDeviceBool(t, legacy, "microphoneInterfaceActive", true) + requireDeviceBool(t, native, "microphoneInterfaceActive", true) + + queued := make([]byte, 0, 6*dualshock4.USBMicrophoneClientFrameSize) + for frame := 0; frame < 6; frame++ { + pcm := patternedPCM(dualshock4.USBMicrophoneClientFrameSize, byte(0x20+frame*13)) + legacy.QueueMicrophonePCMFrame(pcm) + native.QueueMicrophonePCMFrame(pcm) + queued = append(queued, pcm...) + } + packets, transferLength := sparseIsoPackets(4, dualshock4.USBMicrophoneMaxPacketSize, 7) + legacyPayload, legacyCompleted := legacyISOIn(t, legacy, + dualshock4.EndpointMicrophoneIn, packets) + nativeCompletion := harness.nativeISO(t, native, dualshock4.EndpointMicrophoneIn, + transferLength, nil, packets) + nativePayload, nativeLengths := compactNativeISO(t, nativeCompletion) + legacyLengths := compactLegacyISOLengths(legacyCompleted) + if !bytes.Equal(legacyPayload, nativePayload) || + !bytes.Equal(uint32sAsBytes(legacyLengths), uint32sAsBytes(nativeLengths)) || + !bytes.Equal(nativePayload, queued[:len(nativePayload)]) { + t.Fatalf("DualShock 4 microphone packet parity failed:\nlegacy lengths=%v payload=% x\nnative lengths=%v payload=% x", + legacyLengths, legacyPayload, nativeLengths, nativePayload) + } + + harness.legacyResetEndpoint(legacy, dualshock4.EndpointMicrophoneIn) + harness.nativeLifecycle(t, native, udecx.OperationEndpointReset, + dualshock4.EndpointMicrophoneIn) + requireDeviceBool(t, legacy, "microphoneInterfaceActive", true) + requireDeviceBool(t, native, "microphoneInterfaceActive", true) + zeroPackets, zeroLength := sparseIsoPackets(1, dualshock4.USBMicrophoneMaxPacketSize, 0) + legacyZero, legacyZeroCompleted := legacyISOIn(t, legacy, + dualshock4.EndpointMicrophoneIn, zeroPackets) + nativeZeroCompletion := harness.nativeISO(t, native, dualshock4.EndpointMicrophoneIn, + zeroLength, nil, zeroPackets) + nativeZero, nativeZeroLengths := compactNativeISO(t, nativeZeroCompletion) + if !bytes.Equal(legacyZero, nativeZero) || + !bytes.Equal(nativeZero, make([]byte, dualshock4.USBMicrophonePacketSize)) || + legacyZeroCompleted[0].ActualLength != uint32(dualshock4.USBMicrophonePacketSize) || + nativeZeroLengths[0] != uint32(dualshock4.USBMicrophonePacketSize) { + t.Fatalf("DualShock 4 reset silence differs: legacy=% x native=% x", legacyZero, nativeZero) + } + + freshQueued := make([]byte, 0, 6*dualshock4.USBMicrophoneClientFrameSize) + for frame := 0; frame < 6; frame++ { + pcm := patternedPCM(dualshock4.USBMicrophoneClientFrameSize, byte(0xa0+frame*7)) + legacy.QueueMicrophonePCMFrame(pcm) + native.QueueMicrophonePCMFrame(pcm) + freshQueued = append(freshQueued, pcm...) + } + legacyFresh, _ := legacyISOIn(t, legacy, + dualshock4.EndpointMicrophoneIn, zeroPackets) + nativeFreshCompletion := harness.nativeISO(t, native, dualshock4.EndpointMicrophoneIn, + zeroLength, nil, zeroPackets) + nativeFresh, _ := compactNativeISO(t, nativeFreshCompletion) + if !bytes.Equal(legacyFresh, nativeFresh) || + !bytes.Equal(nativeFresh, freshQueued[:len(nativeFresh)]) { + t.Fatal("DualShock 4 microphone reset replayed stale capture bytes") + } + + harness.legacySetInterface(legacy, dualshock4.InterfaceMicrophone, 0) + harness.nativeLifecycle(t, native, udecx.OperationEndpointPurge, + dualshock4.EndpointMicrophoneIn) + requireDeviceBool(t, legacy, "microphoneInterfaceActive", false) + requireDeviceBool(t, native, "microphoneInterfaceActive", false) + }) +} diff --git a/internal/server/usb/native_playstation_transport_soak_test.go b/internal/server/usb/native_playstation_transport_soak_test.go new file mode 100644 index 00000000..ff978faa --- /dev/null +++ b/internal/server/usb/native_playstation_transport_soak_test.go @@ -0,0 +1,1258 @@ +package usb_test + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "sync" + "testing" + "time" + + "github.com/Alia5/VIIPER/device/dualsense" + "github.com/Alia5/VIIPER/device/dualshock4" + serverusb "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/transport/udecx" + usbdevice "github.com/Alia5/VIIPER/usb" +) + +const nativePlayStationSoakTimeout = 5 * time.Second + +type nativePlayStationEndpointKey struct { + deviceID uint64 + address uint8 +} + +// nativePlayStationSoakDriver is an in-memory model of the exclusive UdeCx +// broker session. It deliberately assigns endpoint and device sequences at the +// submission boundary, then lets several Host dequeue workers observe them out +// of order. Every token gets exactly one waiter, so a missing, duplicate, stale, +// or cross-device completion fails the transport gate instead of being hidden +// by callback counts. +type nativePlayStationSoakDriver struct { + operations chan udecx.Operation + + mu sync.Mutex + nextToken uint64 + endpointSequences map[nativePlayStationEndpointKey]uint64 + deviceSequences map[uint64]uint64 + waiters map[uint64]chan udecx.Completion + completed map[uint64]struct{} + inputs map[udecx.DeviceIdentity][]udecx.InputReport + created []udecx.CreateDevice + destroyed []udecx.DeviceIdentity + failures []error +} + +func nativePlayStationOperationUsesEndpointGeneration(kind udecx.OperationKind) bool { + switch kind { + case udecx.OperationControl, udecx.OperationTransfer, + udecx.OperationEndpointStart, udecx.OperationEndpointPurge, + udecx.OperationEndpointReset, udecx.OperationCancel: + return true + default: + return false + } +} + +func newNativePlayStationSoakDriver() *nativePlayStationSoakDriver { + return &nativePlayStationSoakDriver{ + operations: make(chan udecx.Operation, 4096), + nextToken: 1, + endpointSequences: make(map[nativePlayStationEndpointKey]uint64), + deviceSequences: make(map[uint64]uint64), + waiters: make(map[uint64]chan udecx.Completion), + completed: make(map[uint64]struct{}), + inputs: make(map[udecx.DeviceIdentity][]udecx.InputReport), + } +} + +func (d *nativePlayStationSoakDriver) CreateDevice(_ context.Context, device udecx.CreateDevice) (udecx.DeviceRegistration, error) { + d.mu.Lock() + d.created = append(d.created, device) + d.mu.Unlock() + registration := udecx.DeviceRegistration{ + DeviceIdentity: udecx.DeviceIdentity{DeviceID: device.DeviceID, Generation: device.Generation}, + Speed: device.Speed, ControllerSessionID: 17, + ControllerInstanceID: `ROOT\VIIPERUDE\0000`, + } + port := uint32((device.DeviceID-1)%udecx.MaxDevices + 1) + if device.Speed == udecx.DeviceSpeedSuper { + registration.USB30PortNumber = udecx.MaxDevices + port + } else { + registration.USB20PortNumber = port + } + return registration, nil +} + +func (d *nativePlayStationSoakDriver) DestroyDevice(_ context.Context, identity udecx.DeviceIdentity) error { + d.mu.Lock() + d.destroyed = append(d.destroyed, identity) + d.mu.Unlock() + return nil +} + +func (d *nativePlayStationSoakDriver) Dequeue(ctx context.Context, _ []byte) (udecx.Operation, error) { + select { + case op := <-d.operations: + return op, nil + case <-ctx.Done(): + return udecx.Operation{}, ctx.Err() + } +} + +func cloneNativeCompletion(completion udecx.Completion) udecx.Completion { + completion.Payload = append([]byte(nil), completion.Payload...) + completion.IsoPackets = append([]udecx.IsoPacket(nil), completion.IsoPackets...) + return completion +} + +func (d *nativePlayStationSoakDriver) Complete( + ctx context.Context, completion udecx.Completion, +) error { + completion = cloneNativeCompletion(completion) + d.mu.Lock() + waiter := d.waiters[completion.Token] + _, duplicate := d.completed[completion.Token] + if waiter == nil { + d.failures = append(d.failures, fmt.Errorf( + "completion token %d had no live UdeCx request", completion.Token)) + } else if duplicate { + d.failures = append(d.failures, fmt.Errorf( + "completion token %d was delivered more than once", completion.Token)) + } else { + d.completed[completion.Token] = struct{}{} + } + d.mu.Unlock() + if waiter == nil || duplicate { + return nil + } + select { + case waiter <- completion: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (d *nativePlayStationSoakDriver) QueryStats(context.Context) (udecx.Stats, error) { + return udecx.Stats{}, nil +} + +func (d *nativePlayStationSoakDriver) SubmitInputReport( + ctx context.Context, report udecx.InputReport, +) error { + if err := ctx.Err(); err != nil { + return err + } + report.Payload = append([]byte(nil), report.Payload...) + identity := udecx.DeviceIdentity{DeviceID: report.DeviceID, Generation: report.Generation} + d.mu.Lock() + d.inputs[identity] = append(d.inputs[identity], report) + d.mu.Unlock() + return nil +} + +func (d *nativePlayStationSoakDriver) submit( + identity udecx.DeviceIdentity, op udecx.Operation, acknowledged bool, +) (uint64, <-chan udecx.Completion) { + d.mu.Lock() + op.DeviceID, op.Generation = identity.DeviceID, identity.Generation + if nativePlayStationOperationUsesEndpointGeneration(op.Kind) && op.EndpointGeneration == 0 { + op.EndpointGeneration = 1 + } + if op.Kind != udecx.OperationCancel { + key := nativePlayStationEndpointKey{deviceID: identity.DeviceID, address: op.EndpointAddress} + d.endpointSequences[key]++ + d.deviceSequences[identity.DeviceID]++ + op.EndpointSequence = d.endpointSequences[key] + op.DeviceSequence = d.deviceSequences[identity.DeviceID] + } + if op.Kind == udecx.OperationTransfer || op.Kind == udecx.OperationControl || acknowledged { + op.Token = d.nextToken + d.nextToken++ + waiter := make(chan udecx.Completion, 1) + d.waiters[op.Token] = waiter + d.mu.Unlock() + d.operations <- op + return op.Token, waiter + } + d.mu.Unlock() + d.operations <- op + return 0, nil +} + +// submitCancellable models a kernel-owned request: it has a stable token and +// ordered endpoint/device sequences, but cancellation retires it in the driver +// and therefore no user-mode completion waiter may ever observe it. +func (d *nativePlayStationSoakDriver) submitCancellable( + identity udecx.DeviceIdentity, op udecx.Operation, +) uint64 { + d.mu.Lock() + op.DeviceID, op.Generation = identity.DeviceID, identity.Generation + if nativePlayStationOperationUsesEndpointGeneration(op.Kind) && op.EndpointGeneration == 0 { + op.EndpointGeneration = 1 + } + key := nativePlayStationEndpointKey{deviceID: identity.DeviceID, address: op.EndpointAddress} + d.endpointSequences[key]++ + d.deviceSequences[identity.DeviceID]++ + op.EndpointSequence = d.endpointSequences[key] + op.DeviceSequence = d.deviceSequences[identity.DeviceID] + op.Token = d.nextToken + d.nextToken++ + d.mu.Unlock() + d.operations <- op + return op.Token +} + +func (d *nativePlayStationSoakDriver) cancel( + identity udecx.DeviceIdentity, token uint64, endpoint uint8, +) { + d.operations <- udecx.Operation{ + Kind: udecx.OperationCancel, Token: token, + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: endpoint, EndpointGeneration: 1, + } +} + +func (d *nativePlayStationSoakDriver) wait( + t *testing.T, token uint64, waiter <-chan udecx.Completion, +) udecx.Completion { + t.Helper() + select { + case completion := <-waiter: + if completion.Token != token { + t.Fatalf("completion token=%d want=%d", completion.Token, token) + } + return completion + case <-time.After(nativePlayStationSoakTimeout): + t.Fatalf("timed out waiting for native completion token %d", token) + return udecx.Completion{} + } +} + +func (d *nativePlayStationSoakDriver) waitFromWorker( + token uint64, waiter <-chan udecx.Completion, +) (udecx.Completion, error) { + select { + case completion := <-waiter: + if completion.Token != token { + return completion, fmt.Errorf("completion token=%d want=%d", completion.Token, token) + } + return completion, nil + case <-time.After(nativePlayStationSoakTimeout): + return udecx.Completion{}, fmt.Errorf("timed out waiting for native completion token %d", token) + } +} + +func (d *nativePlayStationSoakDriver) inputSnapshot( + identity udecx.DeviceIdentity, +) []udecx.InputReport { + d.mu.Lock() + defer d.mu.Unlock() + reports := make([]udecx.InputReport, len(d.inputs[identity])) + for index, report := range d.inputs[identity] { + reports[index] = report + reports[index].Payload = append([]byte(nil), report.Payload...) + } + return reports +} + +func (d *nativePlayStationSoakDriver) requireClean(t *testing.T) { + t.Helper() + d.mu.Lock() + defer d.mu.Unlock() + if len(d.failures) != 0 { + t.Fatalf("native broker failures: %v", d.failures) + } + for token := range d.waiters { + if _, ok := d.completed[token]; !ok { + t.Fatalf("native request token %d never reached a terminal completion", token) + } + } +} + +// nativePlayStationCancelGate creates the real scheduling race in a controlled +// place: a dequeued media request is held immediately before NativeProcessor, +// then the kernel cancel notification wins ownership. NativeProcessor is still +// invoked with the cancelled context so this gate proves the adapter itself +// does not consume or publish media after cancellation. +type nativePlayStationCancelGate struct { + inner udecx.OperationProcessor + + mu sync.Mutex + armed bool + identity udecx.DeviceIdentity + endpoint uint8 + started chan struct{} + result chan error +} + +func (g *nativePlayStationCancelGate) arm( + identity udecx.DeviceIdentity, endpoint uint8, +) (<-chan struct{}, <-chan error) { + g.mu.Lock() + defer g.mu.Unlock() + if g.armed { + panic("native PlayStation cancellation gate was armed twice") + } + g.armed = true + g.identity = identity + g.endpoint = endpoint + g.started = make(chan struct{}) + g.result = make(chan error, 1) + return g.started, g.result +} + +func (g *nativePlayStationCancelGate) Process( + ctx context.Context, dev usbdevice.Device, op udecx.Operation, +) (udecx.Completion, error) { + g.mu.Lock() + blocked := g.armed && op.Kind == udecx.OperationTransfer && + op.DeviceID == g.identity.DeviceID && op.Generation == g.identity.Generation && + op.EndpointAddress == g.endpoint + started, result := g.started, g.result + if blocked { + g.armed = false + } + g.mu.Unlock() + if !blocked { + return g.inner.Process(ctx, dev, op) + } + close(started) + <-ctx.Done() + completion, err := g.inner.Process(ctx, dev, op) + result <- err + return completion, err +} + +func (g *nativePlayStationCancelGate) Lifecycle( + ctx context.Context, dev usbdevice.Device, op udecx.Operation, +) error { + return g.inner.Lifecycle(ctx, dev, op) +} + +func (g *nativePlayStationCancelGate) Reset( + dev usbdevice.Device, identity udecx.DeviceIdentity, +) { + g.inner.Reset(dev, identity) +} + +type synchronizedDualSenseCapture struct { + mu sync.Mutex + outputs []dualsense.OutputState + atomic []dualSenseAtomicCapture + realtime []dualsense.OutputState + resets int +} + +type dualSenseCaptureSnapshot struct { + outputs []dualsense.OutputState + atomic []dualSenseAtomicCapture + realtime []dualsense.OutputState + resets int +} + +func (capture *synchronizedDualSenseCapture) attach(dev *dualsense.DualSense) { + dev.SetOutputCallback(func(state dualsense.OutputState) { + capture.mu.Lock() + capture.outputs = append(capture.outputs, state) + capture.mu.Unlock() + }) + dev.SetAtomicAudioHapticsCallback(func(state dualsense.OutputState, speaker []byte) { + capture.mu.Lock() + capture.atomic = append(capture.atomic, dualSenseAtomicCapture{ + feedback: state, speaker: append([]byte(nil), speaker...), + }) + capture.mu.Unlock() + }) + dev.SetRealtimeHapticsCallback(func(state dualsense.OutputState) { + capture.mu.Lock() + capture.realtime = append(capture.realtime, state) + capture.mu.Unlock() + }) + dev.SetSpeakerResetCallback(func() { + capture.mu.Lock() + capture.resets++ + capture.mu.Unlock() + }) +} + +func (capture *synchronizedDualSenseCapture) snapshot() dualSenseCaptureSnapshot { + capture.mu.Lock() + defer capture.mu.Unlock() + result := dualSenseCaptureSnapshot{ + outputs: append([]dualsense.OutputState(nil), capture.outputs...), + atomic: append([]dualSenseAtomicCapture(nil), capture.atomic...), + realtime: append([]dualsense.OutputState(nil), capture.realtime...), + resets: capture.resets, + } + for index := range result.atomic { + result.atomic[index].speaker = append([]byte(nil), result.atomic[index].speaker...) + } + return result +} + +type synchronizedDualShock4Capture struct { + mu sync.Mutex + outputs []dualshock4.OutputState + speaker [][]byte + resets int +} + +type dualShock4CaptureSnapshot struct { + outputs []dualshock4.OutputState + speaker [][]byte + resets int +} + +func (capture *synchronizedDualShock4Capture) attach(dev *dualshock4.DualShock4) { + dev.SetOutputCallback(func(state dualshock4.OutputState) { + capture.mu.Lock() + capture.outputs = append(capture.outputs, state) + capture.mu.Unlock() + }) + dev.SetSpeakerCallback(func(pcm []byte) { + capture.mu.Lock() + capture.speaker = append(capture.speaker, append([]byte(nil), pcm...)) + capture.mu.Unlock() + }) + dev.SetSpeakerResetCallback(func() { + capture.mu.Lock() + capture.resets++ + capture.mu.Unlock() + }) +} + +func (capture *synchronizedDualShock4Capture) snapshot() dualShock4CaptureSnapshot { + capture.mu.Lock() + defer capture.mu.Unlock() + result := dualShock4CaptureSnapshot{ + outputs: append([]dualshock4.OutputState(nil), capture.outputs...), + resets: capture.resets, + } + for _, pcm := range capture.speaker { + result.speaker = append(result.speaker, append([]byte(nil), pcm...)) + } + return result +} + +type nativePlayStationSoakCase struct { + name string + identity udecx.DeviceIdentity + native usbdevice.Device + legacy usbdevice.Device + speakerEP uint8 + microphoneEP uint8 + hidInEP uint8 + hidOutEP uint8 + speakerMeta udecx.Operation + microphoneMeta udecx.Operation + hidInMeta udecx.Operation + hidOutMeta udecx.Operation + + setLegacyAudioActive func(bool) + resetLegacyEndpoint func(uint8) + queueMicrophone func([]byte) + oracleMicrophone func([]udecx.IsoPacket) nativeSoakIsoExpectation + legacySpeaker func([]byte) + legacyHID func([]byte) + makeSpeaker func(int) ([]byte, int, uint32) + makeMicrophone func(int) ([]byte, int, uint32) + makeHID func() []byte + setInputMarker func(int8) + inputMarker func([]byte) int8 + requireParity func(*testing.T) + requireFinalCounts func(*testing.T, int, int) +} + +func nativeSoakEndpointMetadata(t *testing.T, dev usbdevice.Device, address uint8) udecx.Operation { + t.Helper() + return endpointOperation(t, udecx.DeviceIdentity{DeviceID: 1, Generation: 1}, + udecx.OperationTransfer, dev.GetDescriptor().Device.Speed, + parityEndpoint(t, dev, address)) +} + +func copyNativeEndpointMetadata(dst *udecx.Operation, source udecx.Operation) { + dst.EndpointAddress = source.EndpointAddress + dst.EndpointAttributes = source.EndpointAttributes + dst.EndpointInterval = source.EndpointInterval + dst.EndpointMaxPacketSize = source.EndpointMaxPacketSize +} + +func makeNativePlayStationSoakCases(t *testing.T) []*nativePlayStationSoakCase { + t.Helper() + cases := make([]*nativePlayStationSoakCase, 0, 3) + + for index, edge := range []bool{false, true} { + var native, legacy *dualsense.DualSense + var err error + if edge { + native, err = dualsense.NewEdge(nil) + if err == nil { + legacy, err = dualsense.NewEdge(nil) + } + } else { + native, err = dualsense.New(nil) + if err == nil { + legacy, err = dualsense.New(nil) + } + } + if err != nil { + t.Fatal(err) + } + nativeCapture, legacyCapture := &synchronizedDualSenseCapture{}, &synchronizedDualSenseCapture{} + nativeCapture.attach(native) + legacyCapture.attach(legacy) + name := "DualSense" + if edge { + name = "DualSense Edge" + } + soakCase := &nativePlayStationSoakCase{ + name: name, identity: udecx.DeviceIdentity{DeviceID: uint64(index + 1)}, + native: native, legacy: legacy, + speakerEP: dualsense.EndpointHapticsAudioOut, + microphoneEP: dualsense.EndpointMicrophoneIn, + hidInEP: dualsense.EndpointIn, hidOutEP: dualsense.EndpointOut, + setLegacyAudioActive: func(active bool) { + alt := uint8(0) + if active { + alt = 1 + } + legacy.SetInterfaceAltSetting(dualsense.InterfaceHapticsAudio, alt) + legacy.SetInterfaceAltSetting(dualsense.InterfaceMicrophone, alt) + }, + resetLegacyEndpoint: legacy.ResetEndpoint, + queueMicrophone: func(frame []byte) { + legacy.QueueMicrophonePCMFrame(frame) + native.QueueMicrophonePCMFrame(frame) + }, + oracleMicrophone: func(packets []udecx.IsoPacket) nativeSoakIsoExpectation { + return readOracleIsoPackets(t, legacy, dualsense.EndpointMicrophoneIn, packets) + }, + legacySpeaker: func(payload []byte) { + legacy.HandleTransfer(context.Background(), dualsense.EndpointHapticsAudioOut&0x0f, + usbdevice.DirectionOut, payload) + }, + legacyHID: func(payload []byte) { + legacy.HandleTransfer(context.Background(), dualsense.EndpointOut&0x0f, + usbdevice.DirectionOut, payload) + }, + makeSpeaker: func(iteration int) ([]byte, int, uint32) { + return dualSensePCM(480, int16(300+iteration*7)), 10, + dualsense.USBHapticsAudioPacketSize + }, + makeMicrophone: func(iteration int) ([]byte, int, uint32) { + return patternedPCM(dualsense.USBMicrophoneClientFrameSize, + byte(0x31+iteration*13)), 10, dualsense.USBMicrophoneMaxPacketSize + }, + makeHID: func() []byte { + report := make([]byte, dualsense.OutputReportSize) + report[0], report[1], report[2] = dualsense.ReportIDOutput, 0x03, 0x14 + report[3], report[4] = 0x39, 0xa7 + report[44], report[45], report[46], report[47] = 0x1f, 0x24, 0x68, 0xb2 + return report + }, + setInputMarker: func(marker int8) { + state := dualsense.NewInputState() + state.LX = marker + state.Buttons = dualsense.ButtonCross | dualsense.ButtonR3 + native.UpdateInputState(state) + }, + inputMarker: func(payload []byte) int8 { return int8(int16(payload[1]) - 128) }, + requireParity: func(t *testing.T) { + requireSynchronizedDualSenseParity(t, legacyCapture.snapshot(), nativeCapture.snapshot()) + }, + requireFinalCounts: func(t *testing.T, mediaFrames, stateReports int) { + for label, snapshot := range map[string]dualSenseCaptureSnapshot{ + "oracle": legacyCapture.snapshot(), "native": nativeCapture.snapshot(), + } { + if len(snapshot.atomic) != mediaFrames || len(snapshot.outputs) != stateReports { + t.Fatalf("%s %s final delivery totals: atomic=%d/%d state=%d/%d", + name, label, len(snapshot.atomic), mediaFrames, + len(snapshot.outputs), stateReports) + } + } + }, + } + soakCase.speakerMeta = nativeSoakEndpointMetadata(t, native, soakCase.speakerEP) + soakCase.microphoneMeta = nativeSoakEndpointMetadata(t, native, soakCase.microphoneEP) + soakCase.hidInMeta = nativeSoakEndpointMetadata(t, native, soakCase.hidInEP) + soakCase.hidOutMeta = nativeSoakEndpointMetadata(t, native, soakCase.hidOutEP) + cases = append(cases, soakCase) + } + + native, err := dualshock4.New(nil) + if err != nil { + t.Fatal(err) + } + legacy, err := dualshock4.New(nil) + if err != nil { + t.Fatal(err) + } + nativeCapture, legacyCapture := &synchronizedDualShock4Capture{}, &synchronizedDualShock4Capture{} + nativeCapture.attach(native) + legacyCapture.attach(legacy) + ds4Case := &nativePlayStationSoakCase{ + name: "DualShock 4", identity: udecx.DeviceIdentity{DeviceID: 3}, + native: native, legacy: legacy, + speakerEP: dualshock4.EndpointAudioOut, + microphoneEP: dualshock4.EndpointMicrophoneIn, + hidInEP: dualshock4.EndpointIn, hidOutEP: dualshock4.EndpointOut, + setLegacyAudioActive: func(active bool) { + alt := uint8(0) + if active { + alt = 1 + } + legacy.SetInterfaceAltSetting(dualshock4.InterfaceSpeaker, alt) + legacy.SetInterfaceAltSetting(dualshock4.InterfaceMicrophone, alt) + }, + resetLegacyEndpoint: legacy.ResetEndpoint, + queueMicrophone: func(frame []byte) { + legacy.QueueMicrophonePCMFrame(frame) + native.QueueMicrophonePCMFrame(frame) + }, + oracleMicrophone: func(packets []udecx.IsoPacket) nativeSoakIsoExpectation { + return readOracleIsoPackets(t, legacy, dualshock4.EndpointMicrophoneIn, packets) + }, + legacySpeaker: func(payload []byte) { + legacy.HandleTransfer(context.Background(), dualshock4.EndpointAudioOut&0x0f, + usbdevice.DirectionOut, payload) + }, + legacyHID: func(payload []byte) { + legacy.HandleTransfer(context.Background(), dualshock4.EndpointOut&0x0f, + usbdevice.DirectionOut, payload) + }, + makeSpeaker: func(iteration int) ([]byte, int, uint32) { + const packetLength = 128 + return patternedPCM(10*packetLength, byte(0x47+iteration*17)), 10, packetLength + }, + makeMicrophone: func(iteration int) ([]byte, int, uint32) { + return patternedPCM(dualshock4.USBMicrophoneClientFrameSize, + byte(0x19+iteration*11)), 10, dualshock4.USBMicrophoneMaxPacketSize + }, + makeHID: func() []byte { + return []byte{dualshock4.ReportIDOutput, 0, 0, 0, 0x29, 0xc8, 0x12, 0x56, 0x9a, 4, 8} + }, + setInputMarker: func(marker int8) { + state := dualshock4.NewInputState() + state.LX = marker + state.Buttons = dualshock4.ButtonCross | dualshock4.ButtonR3 + native.UpdateInputState(state) + }, + inputMarker: func(payload []byte) int8 { return int8(int16(payload[1]) - 128) }, + requireParity: func(t *testing.T) { + requireSynchronizedDualShock4Parity(t, legacyCapture.snapshot(), nativeCapture.snapshot()) + }, + requireFinalCounts: func(t *testing.T, mediaFrames, stateReports int) { + for label, snapshot := range map[string]dualShock4CaptureSnapshot{ + "oracle": legacyCapture.snapshot(), "native": nativeCapture.snapshot(), + } { + if len(snapshot.speaker) != mediaFrames || len(snapshot.outputs) != stateReports { + t.Fatalf("DualShock 4 %s final delivery totals: speaker=%d/%d state=%d/%d", + label, len(snapshot.speaker), mediaFrames, + len(snapshot.outputs), stateReports) + } + } + }, + } + ds4Case.speakerMeta = nativeSoakEndpointMetadata(t, native, ds4Case.speakerEP) + ds4Case.microphoneMeta = nativeSoakEndpointMetadata(t, native, ds4Case.microphoneEP) + ds4Case.hidInMeta = nativeSoakEndpointMetadata(t, native, ds4Case.hidInEP) + ds4Case.hidOutMeta = nativeSoakEndpointMetadata(t, native, ds4Case.hidOutEP) + return append(cases, ds4Case) +} + +type nativeSoakIsoExpectation struct { + payload []byte + transferLength uint32 + packets []udecx.IsoPacket +} + +func readOracleIsoPackets(t *testing.T, dev usbdevice.Device, endpoint uint8, + packets []udecx.IsoPacket, +) nativeSoakIsoExpectation { + t.Helper() + reader, ok := dev.(usbdevice.IsochronousInputDevice) + if !ok { + t.Fatalf("%T does not expose its immutable caller-buffer ISO-IN contract", dev) + } + expectation := nativeSoakIsoExpectation{packets: make([]udecx.IsoPacket, len(packets))} + for _, packet := range packets { + end := packet.Offset + packet.Length + if uint32(len(expectation.payload)) < end { + expectation.payload = append(expectation.payload, + make([]byte, int(end)-len(expectation.payload))...) + } + } + for index, packet := range packets { + region := expectation.payload[packet.Offset : packet.Offset+packet.Length] + written, err := reader.ReadIsochronousInput( + context.Background(), uint32(endpoint&0x0f), region) + if err != nil { + t.Fatalf("%T oracle ISO-IN packet %d: %v", dev, index, err) + } + if written < 0 || written > len(region) { + t.Fatalf("%T oracle ISO-IN packet %d wrote %d bytes into %d bytes", + dev, index, written, len(region)) + } + expectation.packets[index] = udecx.IsoPacket{ + Offset: packet.Offset, Length: uint32(written), + } + expectation.transferLength += uint32(written) + } + return expectation +} + +func nativeSoakIsoPacketsEqual(got, want []udecx.IsoPacket) bool { + if len(got) != len(want) { + return false + } + for index := range got { + if got[index] != want[index] { + return false + } + } + return true +} + +func nativeSoakByteDifference(got, want []byte) string { + limit := min(len(got), len(want)) + index := 0 + for index < limit && got[index] == want[index] { + index++ + } + if index == limit { + if len(got) == len(want) { + return "none" + } + return fmt.Sprintf("length boundary %d (got=%d want=%d)", index, len(got), len(want)) + } + start := max(0, index-8) + end := min(limit, index+9) + return fmt.Sprintf("offset %d got[%d:%d]=%x want[%d:%d]=%x", + index, start, end, got[start:end], start, end, want[start:end]) +} + +func requireSynchronizedDualSenseParity( + t *testing.T, legacy, native dualSenseCaptureSnapshot, +) { + t.Helper() + if legacy.resets != native.resets || len(legacy.outputs) != len(native.outputs) || + len(legacy.atomic) != len(native.atomic) || len(legacy.realtime) != len(native.realtime) { + t.Fatalf("DualSense transport callbacks differ: legacy outputs=%d atomic=%d realtime=%d resets=%d; native outputs=%d atomic=%d realtime=%d resets=%d", + len(legacy.outputs), len(legacy.atomic), len(legacy.realtime), legacy.resets, + len(native.outputs), len(native.atomic), len(native.realtime), native.resets) + } + for index := range legacy.outputs { + if legacy.outputs[index] != native.outputs[index] { + t.Fatalf("DualSense HID output %d changed across native transport", index) + } + } + for index := range legacy.atomic { + if legacy.atomic[index].feedback != native.atomic[index].feedback || + !bytes.Equal(legacy.atomic[index].speaker, native.atomic[index].speaker) { + t.Fatalf("DualSense atomic media frame %d changed or was reordered", index) + } + } + for index := range legacy.realtime { + if legacy.realtime[index] != native.realtime[index] { + t.Fatalf("DualSense realtime haptics frame %d changed or was reordered", index) + } + } +} + +func requireSynchronizedDualShock4Parity( + t *testing.T, legacy, native dualShock4CaptureSnapshot, +) { + t.Helper() + if legacy.resets != native.resets || len(legacy.outputs) != len(native.outputs) || + len(legacy.speaker) != len(native.speaker) { + t.Fatalf("DualShock 4 transport callbacks differ: legacy outputs=%d speaker=%d resets=%d; native outputs=%d speaker=%d resets=%d", + len(legacy.outputs), len(legacy.speaker), legacy.resets, + len(native.outputs), len(native.speaker), native.resets) + } + for index := range legacy.outputs { + if legacy.outputs[index] != native.outputs[index] { + t.Fatalf("DualShock 4 HID output %d changed across native transport", index) + } + } + for index := range legacy.speaker { + if !bytes.Equal(legacy.speaker[index], native.speaker[index]) { + t.Fatalf("DualShock 4 speaker frame %d changed or was reordered", index) + } + } +} + +func nativeSoakIsoOperation(meta udecx.Operation, payload []byte, packetCount int, + packetLength uint32, input bool, +) udecx.Operation { + op := udecx.Operation{ + Kind: udecx.OperationTransfer, TransferLength: uint32(packetCount) * packetLength, + TransferFlags: udecx.TransferFlagStartIsoASAP, + IsoPackets: make([]udecx.IsoPacket, packetCount), Payload: append([]byte(nil), payload...), + } + copyNativeEndpointMetadata(&op, meta) + for index := range op.IsoPackets { + op.IsoPackets[index] = udecx.IsoPacket{ + Offset: uint32(index) * packetLength, Length: packetLength, + } + } + if input { + op.Direction = 1 + op.TransferFlags |= udecx.TransferFlagDirectionIn + } + return op +} + +func submitAndWaitNativeSoakOperation(t *testing.T, driver *nativePlayStationSoakDriver, + identity udecx.DeviceIdentity, op udecx.Operation, acknowledged bool, +) udecx.Completion { + t.Helper() + token, waiter := driver.submit(identity, op, acknowledged) + if waiter == nil { + return udecx.Completion{} + } + return driver.wait(t, token, waiter) +} + +func setNativeSoakEndpointState(t *testing.T, driver *nativePlayStationSoakDriver, + soakCase *nativePlayStationSoakCase, kind udecx.OperationKind, +) { + t.Helper() + for _, meta := range []udecx.Operation{ + soakCase.speakerMeta, soakCase.microphoneMeta, soakCase.hidInMeta, soakCase.hidOutMeta, + } { + op := udecx.Operation{Kind: kind} + copyNativeEndpointMetadata(&op, meta) + completion := submitAndWaitNativeSoakOperation(t, driver, soakCase.identity, op, true) + if completion.Status != 0 || completion.USBDStatus != 0 { + t.Fatalf("%s endpoint 0x%02x lifecycle %d failed: %+v", + soakCase.name, op.EndpointAddress, kind, completion) + } + } +} + +func primeNativeSoakMicrophone(soakCase *nativePlayStationSoakCase, seed int) { + for frame := 0; frame < 6; frame++ { + payload, _, _ := soakCase.makeMicrophone(seed + frame) + soakCase.queueMicrophone(payload) + } +} + +func runNativePlayStationMediaPhase(t *testing.T, driver *nativePlayStationSoakDriver, + cases []*nativePlayStationSoakCase, phase, cycles int, +) { + t.Helper() + for _, soakCase := range cases { + primeNativeSoakMicrophone(soakCase, phase*1000) + report := soakCase.makeHID() + soakCase.legacyHID(report) + op := udecx.Operation{Kind: udecx.OperationTransfer, + TransferLength: uint32(len(report)), Payload: append([]byte(nil), report...)} + copyNativeEndpointMetadata(&op, soakCase.hidOutMeta) + completion := submitAndWaitNativeSoakOperation(t, driver, soakCase.identity, op, false) + if completion.TransferLength != uint32(len(report)) || len(completion.Payload) != 0 { + t.Fatalf("%s initial HID completion=%+v", soakCase.name, completion) + } + } + + start := make(chan struct{}) + errorsCh := make(chan error, len(cases)*3) + var workers sync.WaitGroup + for caseIndex, soakCase := range cases { + caseIndex, soakCase := caseIndex, soakCase + workers.Add(3) + go func() { + defer workers.Done() + <-start + for iteration := 0; iteration < cycles; iteration++ { + absolute := phase*cycles + iteration + caseIndex*97 + payload, packetCount, packetLength := soakCase.makeSpeaker(absolute) + soakCase.legacySpeaker(payload) + op := nativeSoakIsoOperation(soakCase.speakerMeta, payload, + packetCount, packetLength, false) + token, waiter := driver.submit(soakCase.identity, op, false) + completion, err := driver.waitFromWorker(token, waiter) + if err != nil { + errorsCh <- fmt.Errorf("%s speaker %d: %w", soakCase.name, iteration, err) + return + } + if completion.TransferLength != uint32(len(payload)) || + len(completion.Payload) != 0 || len(completion.IsoPackets) != packetCount { + errorsCh <- fmt.Errorf("%s speaker %d malformed completion: %+v", + soakCase.name, iteration, completion) + return + } + } + }() + go func() { + defer workers.Done() + <-start + for iteration := 0; iteration < cycles; iteration++ { + absolute := phase*cycles + iteration + caseIndex*131 + frame, packetCount, packetLength := soakCase.makeMicrophone(absolute + 6) + soakCase.queueMicrophone(frame) + packets := make([]udecx.IsoPacket, packetCount) + for index := range packets { + packets[index] = udecx.IsoPacket{Offset: uint32(index) * packetLength, Length: packetLength} + } + want := soakCase.oracleMicrophone(packets) + op := nativeSoakIsoOperation(soakCase.microphoneMeta, nil, + packetCount, packetLength, true) + token, waiter := driver.submit(soakCase.identity, op, false) + completion, err := driver.waitFromWorker(token, waiter) + if err != nil { + errorsCh <- fmt.Errorf("%s microphone %d: %w", soakCase.name, iteration, err) + return + } + if completion.TransferLength != want.transferLength || + !bytes.Equal(completion.Payload, want.payload) || + !nativeSoakIsoPacketsEqual(completion.IsoPackets, want.packets) { + errorsCh <- fmt.Errorf("%s microphone %d changed packet contract: got=%d/%v want=%d/%v; %s", + soakCase.name, iteration, completion.TransferLength, completion.IsoPackets, + want.transferLength, want.packets, + nativeSoakByteDifference(completion.Payload, want.payload)) + return + } + } + }() + go func() { + defer workers.Done() + <-start + report := soakCase.makeHID() + for iteration := 0; iteration < cycles; iteration++ { + soakCase.legacyHID(report) + op := udecx.Operation{Kind: udecx.OperationTransfer, + TransferLength: uint32(len(report)), Payload: append([]byte(nil), report...)} + copyNativeEndpointMetadata(&op, soakCase.hidOutMeta) + token, waiter := driver.submit(soakCase.identity, op, false) + completion, err := driver.waitFromWorker(token, waiter) + if err != nil { + errorsCh <- fmt.Errorf("%s HID %d: %w", soakCase.name, iteration, err) + return + } + if completion.TransferLength != uint32(len(report)) || len(completion.Payload) != 0 { + errorsCh <- fmt.Errorf("%s HID %d malformed completion: %+v", + soakCase.name, iteration, completion) + return + } + } + }() + } + close(start) + workers.Wait() + close(errorsCh) + for err := range errorsCh { + t.Error(err) + } + for _, soakCase := range cases { + soakCase.requireParity(t) + } +} + +func requireNativeInputContinuity(t *testing.T, driver *nativePlayStationSoakDriver, + soakCase *nativePlayStationSoakCase, +) { + t.Helper() + reports := driver.inputSnapshot(soakCase.identity) + if len(reports) < 2 { + t.Fatalf("%s published only %d native HID input reports", soakCase.name, len(reports)) + } + for index, report := range reports { + wantSequence := uint64(index + 1) + if report.DeviceID != soakCase.identity.DeviceID || + report.Generation != soakCase.identity.Generation || + report.EndpointAddress != soakCase.hidInEP || report.Sequence != wantSequence || + len(report.Payload) != 64 { + t.Fatalf("%s native input report %d=%+v len=%d want sequence=%d endpoint=0x%02x", + soakCase.name, index, report, len(report.Payload), wantSequence, soakCase.hidInEP) + } + } +} + +func waitForNativeInputMarker(t *testing.T, driver *nativePlayStationSoakDriver, + soakCase *nativePlayStationSoakCase, marker int8, +) { + t.Helper() + deadline := time.Now().Add(nativePlayStationSoakTimeout) + for time.Now().Before(deadline) { + reports := driver.inputSnapshot(soakCase.identity) + if len(reports) != 0 && soakCase.inputMarker(reports[len(reports)-1].Payload) == marker { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("%s never published post-lifecycle input marker %d", soakCase.name, marker) +} + +func exerciseNativeD0Boundary(t *testing.T, driver *nativePlayStationSoakDriver, + soakCase *nativePlayStationSoakCase, +) { + t.Helper() + exit := submitAndWaitNativeSoakOperation(t, driver, soakCase.identity, + udecx.Operation{Kind: udecx.OperationDeviceD0Exit}, true) + if exit.Status != 0 { + t.Fatalf("%s D0 exit failed: %+v", soakCase.name, exit) + } + before := len(driver.inputSnapshot(soakCase.identity)) + soakCase.setInputMarker(63) + time.Sleep(4 * time.Millisecond) + after := len(driver.inputSnapshot(soakCase.identity)) + if after != before { + t.Fatalf("%s published %d stale input reports after acknowledged D0 exit", + soakCase.name, after-before) + } + entry := submitAndWaitNativeSoakOperation(t, driver, soakCase.identity, + udecx.Operation{Kind: udecx.OperationDeviceD0Entry}, true) + if entry.Status != 0 { + t.Fatalf("%s D0 entry failed: %+v", soakCase.name, entry) + } + waitForNativeInputMarker(t, driver, soakCase, 63) +} + +func exerciseNativeCancellationBoundary(t *testing.T, driver *nativePlayStationSoakDriver, + gate *nativePlayStationCancelGate, soakCase *nativePlayStationSoakCase, +) { + t.Helper() + for _, meta := range []udecx.Operation{ + soakCase.speakerMeta, soakCase.microphoneMeta, soakCase.hidOutMeta, + } { + packetCount, packetLength := 10, uint32(0) + var op udecx.Operation + if meta.EndpointAddress == soakCase.speakerEP { + payload, count, length := soakCase.makeSpeaker(0x513) + op = nativeSoakIsoOperation(meta, payload, count, length, false) + } else if meta.EndpointAddress == soakCase.microphoneEP { + _, packetCount, packetLength = soakCase.makeMicrophone(0x517) + op = nativeSoakIsoOperation(meta, nil, packetCount, packetLength, true) + } else { + payload := soakCase.makeHID() + op = udecx.Operation{Kind: udecx.OperationTransfer, + TransferLength: uint32(len(payload)), Payload: payload} + copyNativeEndpointMetadata(&op, meta) + } + started, result := gate.arm(soakCase.identity, meta.EndpointAddress) + token := driver.submitCancellable(soakCase.identity, op) + select { + case <-started: + case <-time.After(nativePlayStationSoakTimeout): + t.Fatalf("%s cancelled endpoint 0x%02x never reached native adapter gate", + soakCase.name, meta.EndpointAddress) + } + driver.cancel(soakCase.identity, token, meta.EndpointAddress) + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("%s cancelled endpoint 0x%02x returned %v", + soakCase.name, meta.EndpointAddress, err) + } + case <-time.After(nativePlayStationSoakTimeout): + t.Fatalf("%s cancelled endpoint 0x%02x did not leave native adapter", + soakCase.name, meta.EndpointAddress) + } + } + + // A valid state write on the same device proves both canceled endpoint + // sequences retired and no stale completion or media callback blocked the + // following generation. Callback parity proves the canceled speaker frame + // itself was not published. + report := soakCase.makeHID() + soakCase.legacyHID(report) + op := udecx.Operation{Kind: udecx.OperationTransfer, + TransferLength: uint32(len(report)), Payload: append([]byte(nil), report...)} + copyNativeEndpointMetadata(&op, soakCase.hidOutMeta) + completion := submitAndWaitNativeSoakOperation(t, driver, soakCase.identity, op, false) + if completion.TransferLength != uint32(len(report)) || len(completion.Payload) != 0 { + t.Fatalf("%s post-cancel HID completion=%+v", soakCase.name, completion) + } + soakCase.requireParity(t) +} + +func runNativePlayStationSoakSession(t *testing.T, session int) { + t.Helper() + driver := newNativePlayStationSoakDriver() + processor, err := serverusb.NewNativeProcessor( + serverusb.New(serverusb.ServerConfig{}, slog.Default(), nil)) + if err != nil { + t.Fatal(err) + } + cancelGate := &nativePlayStationCancelGate{inner: processor} + host, err := udecx.NewHost(driver, cancelGate, 8) + if err != nil { + t.Fatal(err) + } + cases := makeNativePlayStationSoakCases(t) + for _, soakCase := range cases { + identity, registerErr := host.Register(context.Background(), soakCase.identity.DeviceID, + soakCase.native) + if registerErr != nil { + t.Fatal(registerErr) + } + soakCase.identity = identity + } + serveCtx, stopServe := context.WithCancel(context.Background()) + serveDone := make(chan error, 1) + go func() { serveDone <- host.Serve(serveCtx) }() + + for _, soakCase := range cases { + soakCase.setLegacyAudioActive(true) + setNativeSoakEndpointState(t, driver, soakCase, udecx.OperationEndpointStart) + soakCase.setInputMarker(int8(10 + session)) + waitForNativeInputMarker(t, driver, soakCase, int8(10+session)) + } + + const phaseCycles = 12 + mediaStarted := time.Now() + runNativePlayStationMediaPhase(t, driver, cases, 0, phaseCycles) + for _, soakCase := range cases { + exerciseNativeCancellationBoundary(t, driver, cancelGate, soakCase) + } + + // Endpoint reset must retire the previous audio generation without losing + // the selected interfaces or allowing stale PCM across the boundary. + for _, soakCase := range cases { + for _, endpoint := range []uint8{soakCase.speakerEP, soakCase.microphoneEP} { + soakCase.resetLegacyEndpoint(endpoint) + meta := soakCase.speakerMeta + if endpoint == soakCase.microphoneEP { + meta = soakCase.microphoneMeta + } + op := udecx.Operation{Kind: udecx.OperationEndpointReset} + copyNativeEndpointMetadata(&op, meta) + completion := submitAndWaitNativeSoakOperation(t, driver, soakCase.identity, op, true) + if completion.Status != 0 { + t.Fatalf("%s endpoint reset 0x%02x failed: %+v", soakCase.name, endpoint, completion) + } + } + soakCase.requireParity(t) + exerciseNativeD0Boundary(t, driver, soakCase) + } + runNativePlayStationMediaPhase(t, driver, cases, 1, phaseCycles) + + // A real device reset closes every selected audio interface. Re-open the + // exact descriptors and prove fresh frames cannot inherit the old media or + // microphone generation. + for _, soakCase := range cases { + soakCase.setLegacyAudioActive(false) + completion := submitAndWaitNativeSoakOperation(t, driver, soakCase.identity, + udecx.Operation{Kind: udecx.OperationDeviceReset}, true) + if completion.Status != 0 { + t.Fatalf("%s device reset failed: %+v", soakCase.name, completion) + } + soakCase.requireParity(t) + soakCase.setLegacyAudioActive(true) + setNativeSoakEndpointState(t, driver, soakCase, udecx.OperationEndpointStart) + } + runNativePlayStationMediaPhase(t, driver, cases, 2, phaseCycles) + + // Purge completion is the documented UdeCx boundary: all old forwarded I/O + // is terminal before start. Exercise it after sustained duplex traffic and + // require the new generation to continue with no stale or duplicate bytes. + for _, soakCase := range cases { + soakCase.setLegacyAudioActive(false) + setNativeSoakEndpointState(t, driver, soakCase, udecx.OperationEndpointPurge) + soakCase.requireParity(t) + soakCase.setLegacyAudioActive(true) + setNativeSoakEndpointState(t, driver, soakCase, udecx.OperationEndpointStart) + } + runNativePlayStationMediaPhase(t, driver, cases, 3, phaseCycles) + mediaElapsed := time.Since(mediaStarted) + minimumCadence := time.Duration(4*phaseCycles*9) * time.Millisecond + if mediaElapsed < minimumCadence { + t.Fatalf("native media soak collapsed USB service cadence: elapsed=%s minimum=%s", + mediaElapsed, minimumCadence) + } + if mediaElapsed > 3*time.Second { + t.Fatalf("native media soak exceeded continuity deadline: elapsed=%s", mediaElapsed) + } + + for _, soakCase := range cases { + requireNativeInputContinuity(t, driver, soakCase) + soakCase.requireFinalCounts(t, 4*phaseCycles, 4*(phaseCycles+1)+1) + } + driver.requireClean(t) + + for _, soakCase := range cases { + unregisterCtx, cancel := context.WithTimeout(context.Background(), nativePlayStationSoakTimeout) + if err = host.Unregister(unregisterCtx, soakCase.identity); err != nil { + cancel() + t.Fatal(err) + } + cancel() + } + stopServe() + select { + case err = <-serveDone: + if err != nil { + t.Fatalf("native host session shutdown: %v", err) + } + case <-time.After(nativePlayStationSoakTimeout): + t.Fatal("native host session did not stop after broker close") + } + + // No publisher from the retired owner may survive into the next broker + // session. The next outer iteration uses the same stable device IDs and must + // begin again at input sequence one with fresh controller objects. + for _, soakCase := range cases { + before := len(driver.inputSnapshot(soakCase.identity)) + soakCase.setInputMarker(-51) + time.Sleep(2 * time.Millisecond) + if after := len(driver.inputSnapshot(soakCase.identity)); after != before { + t.Fatalf("%s retired broker published %d zombie input reports", + soakCase.name, after-before) + } + } +} + +func TestNativePlayStationTransportZeroDropoutFaultSoak(t *testing.T) { + // Four complete owner sessions cover reconnect and generation teardown while + // carrying 192 ten-millisecond speaker/haptics and microphone intervals per + // controller through resets, D0, purge/start, HID feedback, and fast input. + // The established USB/IP engines are the content oracle throughout. + for session := 0; session < 4; session++ { + t.Run(fmt.Sprintf("broker_session_%d", session+1), func(t *testing.T) { + runNativePlayStationSoakSession(t, session) + }) + } +} + +func TestNativePlayStationCancelledLifecycleDoesNotMutateController(t *testing.T) { + processor, err := serverusb.NewNativeProcessor( + serverusb.New(serverusb.ServerConfig{}, slog.Default(), nil)) + if err != nil { + t.Fatal(err) + } + for _, soakCase := range makeNativePlayStationSoakCases(t) { + t.Run(soakCase.name, func(t *testing.T) { + soakCase.identity.Generation = 1 + soakCase.setLegacyAudioActive(true) + for _, meta := range []udecx.Operation{soakCase.speakerMeta, soakCase.microphoneMeta} { + op := udecx.Operation{Kind: udecx.OperationEndpointStart, + DeviceID: soakCase.identity.DeviceID, Generation: soakCase.identity.Generation} + copyNativeEndpointMetadata(&op, meta) + if lifecycleErr := processor.Lifecycle(context.Background(), soakCase.native, op); lifecycleErr != nil { + t.Fatal(lifecycleErr) + } + } + soakCase.requireParity(t) + + cancelledCtx, cancel := context.WithCancel(context.Background()) + cancel() + reset := udecx.Operation{Kind: udecx.OperationEndpointReset, + DeviceID: soakCase.identity.DeviceID, Generation: soakCase.identity.Generation} + copyNativeEndpointMetadata(&reset, soakCase.speakerMeta) + if lifecycleErr := processor.Lifecycle(cancelledCtx, soakCase.native, reset); !errors.Is(lifecycleErr, context.Canceled) { + t.Fatalf("cancelled lifecycle returned %v", lifecycleErr) + } + soakCase.requireParity(t) + + soakCase.resetLegacyEndpoint(soakCase.speakerEP) + if lifecycleErr := processor.Lifecycle(context.Background(), soakCase.native, reset); lifecycleErr != nil { + t.Fatal(lifecycleErr) + } + soakCase.requireParity(t) + }) + } +} diff --git a/internal/server/usb/native_production_test.go b/internal/server/usb/native_production_test.go new file mode 100644 index 00000000..7dff379a --- /dev/null +++ b/internal/server/usb/native_production_test.go @@ -0,0 +1,561 @@ +package usb_test + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "sync" + "sync/atomic" + "testing" + + "github.com/Alia5/VIIPER/device/dualsense" + "github.com/Alia5/VIIPER/device/dualshock4" + "github.com/Alia5/VIIPER/device/ns2pro" + "github.com/Alia5/VIIPER/device/xbox360" + serverusb "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/transport/udecx" + usbdevice "github.com/Alia5/VIIPER/usb" +) + +func TestNativeProcessorPreservesProductionControllerOutputReports(t *testing.T) { + t.Run("DualSense", func(t *testing.T) { + dev, err := dualsense.New(nil) + if err != nil { + t.Fatal(err) + } + var got dualsense.OutputState + dev.SetOutputCallback(func(state dualsense.OutputState) { got = state }) + report := make([]byte, dualsense.OutputReportSize) + report[0], report[1], report[3], report[4] = dualsense.ReportIDOutput, 0x03, 0x31, 0x92 + processNativeOutput(t, dev, dualsense.EndpointOut, report) + if got.RumbleSmall != 0x31 || got.RumbleLarge != 0x92 || + !bytes.Equal(got.RawOutputReport[:], report) { + t.Fatalf("DualSense output changed across native transport: %+v", got) + } + }) + + t.Run("DualShock4", func(t *testing.T) { + dev, err := dualshock4.New(nil) + if err != nil { + t.Fatal(err) + } + var got dualshock4.OutputState + dev.SetOutputCallback(func(state dualshock4.OutputState) { got = state }) + report := []byte{dualshock4.ReportIDOutput, 0, 0, 0, 0x22, 0xe1, 1, 2, 3, 4, 5} + processNativeOutput(t, dev, dualshock4.EndpointOut, report) + if got != (dualshock4.OutputState{ + RumbleSmall: 0x22, RumbleLarge: 0xe1, + LedRed: 1, LedGreen: 2, LedBlue: 3, FlashOn: 4, FlashOff: 5, + }) { + t.Fatalf("DualShock 4 output changed across native transport: %+v", got) + } + }) + + t.Run("Xbox360", func(t *testing.T) { + dev, err := xbox360.New(nil) + if err != nil { + t.Fatal(err) + } + var got xbox360.XRumbleState + dev.SetRumbleCallback(func(state xbox360.XRumbleState) { got = state }) + processNativeOutput(t, dev, 0x01, []byte{0x00, 0x08, 0x00, 0x74, 0x29, 0, 0, 0}) + if got != (xbox360.XRumbleState{LeftMotor: 0x74, RightMotor: 0x29}) { + t.Fatalf("Xbox 360 rumble changed across native transport: %+v", got) + } + }) + + t.Run("Switch2Pro", func(t *testing.T) { + dev, err := ns2pro.New(nil) + if err != nil { + t.Fatal(err) + } + var got ns2pro.OutputState + clear := dev.SetOutputCallback(func(state ns2pro.OutputState) { got = state }) + defer clear() + report := make([]byte, ns2pro.OutputReportSize) + report[0] = ns2pro.ReportIDOutput + for i := range 16 { + report[1+i] = byte(i + 1) + report[17+i] = byte(0x80 + i) + } + processNativeOutput(t, dev, ns2pro.EndpointHIDOut, report) + if got.Flags != ns2pro.OutputFlagRumble || + !bytes.Equal(got.LeftRumble[:], report[1:17]) || + !bytes.Equal(got.RightRumble[:], report[17:33]) { + t.Fatalf("Switch 2 Pro rumble changed across native transport: %+v", got) + } + }) +} + +func processNativeOutput(t *testing.T, dev usbdevice.Device, endpoint uint8, payload []byte) { + t.Helper() + server := serverusb.New(serverusb.ServerConfig{}, slog.Default(), nil) + processor, err := serverusb.NewNativeProcessor(server) + if err != nil { + t.Fatal(err) + } + op := udecx.Operation{ + Token: 99, DeviceID: 1, Generation: 1, Kind: udecx.OperationTransfer, + EndpointAddress: endpoint, Direction: 0, + TransferLength: uint32(len(payload)), Payload: payload, + } + completion, err := processor.Process(context.Background(), dev, op) + if err != nil { + t.Fatal(err) + } + if completion.TransferLength != uint32(len(payload)) || len(completion.Payload) != 0 { + t.Fatalf("native OUT completion=%+v", completion) + } +} + +func TestNativeProcessorPreservesPlayStationIsochronousMedia(t *testing.T) { + t.Run("DualSense", func(t *testing.T) { + dev, err := dualsense.New(nil) + if err != nil { + t.Fatal(err) + } + processor := newProductionProcessor(t) + startNativeEndpoint(t, processor, dev, dualsense.EndpointHapticsAudioOut) + startNativeEndpoint(t, processor, dev, dualsense.EndpointMicrophoneIn) + + var speaker []byte + dev.SetAtomicAudioHapticsCallback(func(_ dualsense.OutputState, pcm []byte) { + speaker = append([]byte(nil), pcm...) + }) + usbPCM := make([]byte, 480*dualsense.USBHapticsAudioFrameSize) + for i := range usbPCM { + usbPCM[i] = byte(i*37 + 11) + } + completion := processNativeIso(t, processor, dev, dualsense.EndpointHapticsAudioOut, + false, usbPCM, 10, dualsense.USBHapticsAudioPacketSize) + if completion.TransferLength != uint32(len(usbPCM)) || len(speaker) != 480*4 { + t.Fatalf("DualSense speaker completion=%d callback=%d", completion.TransferLength, len(speaker)) + } + + microphoneFrame := make([]byte, dualsense.USBMicrophoneClientFrameSize) + for i := range microphoneFrame { + microphoneFrame[i] = byte(i*13 + 7) + } + for range 6 { + dev.QueueMicrophonePCMFrame(microphoneFrame) + } + completion = processNativeIso(t, processor, dev, dualsense.EndpointMicrophoneIn, + true, nil, 10, dualsense.USBMicrophonePacketSize) + if !bytes.Equal(completion.Payload, microphoneFrame) { + t.Fatal("DualSense microphone PCM changed across native transport") + } + }) + + t.Run("DualShock4", func(t *testing.T) { + dev, err := dualshock4.New(nil) + if err != nil { + t.Fatal(err) + } + processor := newProductionProcessor(t) + startNativeEndpoint(t, processor, dev, dualshock4.EndpointAudioOut) + startNativeEndpoint(t, processor, dev, dualshock4.EndpointMicrophoneIn) + + speakerPCM := make([]byte, 128) + for i := range speakerPCM { + speakerPCM[i] = byte(i*19 + 3) + } + var speaker []byte + dev.SetSpeakerCallback(func(pcm []byte) { speaker = append([]byte(nil), pcm...) }) + completion := processNativeIso(t, processor, dev, dualshock4.EndpointAudioOut, + false, speakerPCM, 1, uint32(len(speakerPCM))) + if completion.TransferLength != uint32(len(speakerPCM)) || !bytes.Equal(speaker, speakerPCM) { + t.Fatal("DualShock 4 speaker PCM changed across native transport") + } + + microphoneFrame := make([]byte, dualshock4.USBMicrophoneClientFrameSize) + for i := range microphoneFrame { + microphoneFrame[i] = byte(i*23 + 5) + } + for range 6 { + dev.QueueMicrophonePCMFrame(microphoneFrame) + } + completion = processNativeIso(t, processor, dev, dualshock4.EndpointMicrophoneIn, + true, nil, 10, dualshock4.USBMicrophonePacketSize) + if !bytes.Equal(completion.Payload, microphoneFrame) { + t.Fatal("DualShock 4 microphone PCM changed across native transport") + } + }) +} + +func TestNativeProcessorRunsDualSenseHIDSpeakerAndMicrophoneConcurrently(t *testing.T) { + const iterations = 12 + dev, err := dualsense.New(nil) + if err != nil { + t.Fatal(err) + } + processor := newProductionProcessor(t) + startNativeEndpoint(t, processor, dev, dualsense.EndpointHapticsAudioOut) + startNativeEndpoint(t, processor, dev, dualsense.EndpointMicrophoneIn) + + var outputReports atomic.Uint64 + var speakerFrames atomic.Uint64 + var callbackFailure atomic.Value + dev.SetOutputCallback(func(dualsense.OutputState) { + outputReports.Add(1) + }) + dev.SetAtomicAudioHapticsCallback(func(_ dualsense.OutputState, pcm []byte) { + if len(pcm) != 480*4 { + callbackFailure.CompareAndSwap(nil, fmt.Errorf( + "atomic speaker callback length=%d want=%d", len(pcm), 480*4)) + return + } + speakerFrames.Add(1) + }) + + microphoneFrame := make([]byte, dualsense.USBMicrophoneClientFrameSize) + for index := range microphoneFrame { + microphoneFrame[index] = byte(index*17 + 3) + } + // The production microphone contract deliberately primes six 10 ms source + // frames before serving its first 1 ms USB packet. + for range 6 { + dev.QueueMicrophonePCMFrame(microphoneFrame) + } + + errors := make(chan error, 3) + var workers sync.WaitGroup + workers.Add(3) + + go func() { + defer workers.Done() + for iteration := range iterations { + report := make([]byte, dualsense.OutputReportSize) + report[0], report[1] = dualsense.ReportIDOutput, 0x03 + report[3], report[4] = byte(iteration+1), byte(0x80+iteration) + _, processErr := processor.Process(context.Background(), dev, udecx.Operation{ + Token: uint64(1000 + iteration), DeviceID: 1, Generation: 1, + Kind: udecx.OperationTransfer, EndpointAddress: dualsense.EndpointOut, + TransferLength: uint32(len(report)), Payload: report, + }) + if processErr != nil { + errors <- fmt.Errorf("HID output iteration %d: %w", iteration, processErr) + return + } + } + }() + + go func() { + defer workers.Done() + packetCount := 10 + packetLength := uint32(dualsense.USBHapticsAudioPacketSize) + payload := make([]byte, packetCount*int(packetLength)) + for index := range payload { + payload[index] = byte(index*29 + 5) + } + for iteration := range iterations { + op := productionIsoOperation( + uint64(2000+iteration), dualsense.EndpointHapticsAudioOut, + false, payload, packetCount, packetLength) + populateProductionEndpointMetadata(dev, &op) + completion, processErr := processor.Process(context.Background(), dev, op) + if processErr != nil { + errors <- fmt.Errorf("speaker iteration %d: %w", iteration, processErr) + return + } + if completion.TransferLength != uint32(len(payload)) || + len(completion.IsoPackets) != packetCount { + errors <- fmt.Errorf("speaker iteration %d malformed completion: %+v", + iteration, completion) + return + } + } + }() + + go func() { + defer workers.Done() + packetCount := 10 + packetLength := uint32(dualsense.USBMicrophonePacketSize) + for iteration := range iterations { + dev.QueueMicrophonePCMFrame(microphoneFrame) + op := productionIsoOperation( + uint64(3000+iteration), dualsense.EndpointMicrophoneIn, + true, nil, packetCount, packetLength) + populateProductionEndpointMetadata(dev, &op) + completion, processErr := processor.Process(context.Background(), dev, op) + if processErr != nil { + errors <- fmt.Errorf("microphone iteration %d: %w", iteration, processErr) + return + } + if completion.TransferLength == 0 || len(completion.Payload) != packetCount*int(packetLength) || + len(completion.IsoPackets) != packetCount { + errors <- fmt.Errorf("microphone iteration %d malformed completion: %+v", + iteration, completion) + return + } + } + }() + + workers.Wait() + close(errors) + for workerErr := range errors { + t.Error(workerErr) + } + if failure := callbackFailure.Load(); failure != nil { + t.Error(failure) + } + if got := outputReports.Load(); got != iterations { + t.Errorf("DualSense output callbacks=%d want=%d", got, iterations) + } + if got := speakerFrames.Load(); got != iterations { + t.Errorf("DualSense atomic speaker callbacks=%d want=%d", got, iterations) + } +} + +func TestNativeProcessorRunsDualShock4HIDSpeakerAndMicrophoneConcurrently(t *testing.T) { + const iterations = 12 + dev, err := dualshock4.New(nil) + if err != nil { + t.Fatal(err) + } + processor := newProductionProcessor(t) + startNativeEndpoint(t, processor, dev, dualshock4.EndpointAudioOut) + startNativeEndpoint(t, processor, dev, dualshock4.EndpointMicrophoneIn) + + var outputReports atomic.Uint64 + var speakerTransfers atomic.Uint64 + var callbackFailure atomic.Value + dev.SetOutputCallback(func(dualshock4.OutputState) { + outputReports.Add(1) + }) + const speakerPackets = 10 + const speakerPacketLength = 128 + dev.SetSpeakerCallback(func(pcm []byte) { + if len(pcm) != speakerPackets*speakerPacketLength { + callbackFailure.CompareAndSwap(nil, fmt.Errorf( + "speaker callback length=%d want=%d", + len(pcm), speakerPackets*speakerPacketLength)) + return + } + speakerTransfers.Add(1) + }) + + microphoneFrame := make([]byte, dualshock4.USBMicrophoneClientFrameSize) + for index := range microphoneFrame { + microphoneFrame[index] = byte(index*11 + 7) + } + // Match the production capture contract's startup reserve before the first + // 1 ms USB microphone packet is requested. + for range 6 { + dev.QueueMicrophonePCMFrame(microphoneFrame) + } + + errors := make(chan error, 3) + var workers sync.WaitGroup + workers.Add(3) + + go func() { + defer workers.Done() + for iteration := range iterations { + report := []byte{ + dualshock4.ReportIDOutput, 0, 0, 0, + byte(iteration + 1), byte(0x80 + iteration), + 1, 2, 3, 0, 0, + } + _, processErr := processor.Process(context.Background(), dev, udecx.Operation{ + Token: uint64(4000 + iteration), DeviceID: 2, Generation: 1, + Kind: udecx.OperationTransfer, EndpointAddress: dualshock4.EndpointOut, + TransferLength: uint32(len(report)), Payload: report, + }) + if processErr != nil { + errors <- fmt.Errorf("HID output iteration %d: %w", iteration, processErr) + return + } + } + }() + + go func() { + defer workers.Done() + payload := make([]byte, speakerPackets*speakerPacketLength) + for index := range payload { + payload[index] = byte(index*31 + 9) + } + for iteration := range iterations { + op := productionIsoOperation( + uint64(5000+iteration), dualshock4.EndpointAudioOut, + false, payload, speakerPackets, speakerPacketLength) + op.DeviceID = 2 + populateProductionEndpointMetadata(dev, &op) + completion, processErr := processor.Process(context.Background(), dev, op) + if processErr != nil { + errors <- fmt.Errorf("speaker iteration %d: %w", iteration, processErr) + return + } + if completion.TransferLength != uint32(len(payload)) || + len(completion.IsoPackets) != speakerPackets { + errors <- fmt.Errorf("speaker iteration %d malformed completion: %+v", + iteration, completion) + return + } + } + }() + + go func() { + defer workers.Done() + const microphonePackets = 10 + for iteration := range iterations { + dev.QueueMicrophonePCMFrame(microphoneFrame) + op := productionIsoOperation( + uint64(6000+iteration), dualshock4.EndpointMicrophoneIn, + true, nil, microphonePackets, dualshock4.USBMicrophonePacketSize) + op.DeviceID = 2 + populateProductionEndpointMetadata(dev, &op) + completion, processErr := processor.Process(context.Background(), dev, op) + if processErr != nil { + errors <- fmt.Errorf("microphone iteration %d: %w", iteration, processErr) + return + } + if completion.TransferLength == 0 || + len(completion.Payload) != microphonePackets*dualshock4.USBMicrophonePacketSize || + len(completion.IsoPackets) != microphonePackets { + errors <- fmt.Errorf("microphone iteration %d malformed completion: %+v", + iteration, completion) + return + } + } + }() + + workers.Wait() + close(errors) + for workerErr := range errors { + t.Error(workerErr) + } + if failure := callbackFailure.Load(); failure != nil { + t.Error(failure) + } + if got := outputReports.Load(); got != iterations { + t.Errorf("DualShock 4 output callbacks=%d want=%d", got, iterations) + } + if got := speakerTransfers.Load(); got != iterations { + t.Errorf("DualShock 4 speaker callbacks=%d want=%d", got, iterations) + } +} + +func productionIsoOperation(token uint64, endpoint uint8, input bool, payload []byte, + packetCount int, packetLength uint32) udecx.Operation { + packets := make([]udecx.IsoPacket, packetCount) + for index := range packets { + packets[index] = udecx.IsoPacket{ + Offset: uint32(index) * packetLength, Length: packetLength, + } + } + op := udecx.Operation{ + Token: token, DeviceID: 1, Generation: 1, Kind: udecx.OperationTransfer, + EndpointAddress: endpoint, TransferLength: uint32(packetCount) * packetLength, + TransferFlags: udecx.TransferFlagStartIsoASAP, IsoPackets: packets, Payload: payload, + } + if input { + op.Direction = 1 + op.TransferFlags |= udecx.TransferFlagDirectionIn + } + return op +} + +func populateProductionEndpointMetadata(dev usbdevice.Device, op *udecx.Operation) { + for _, iface := range dev.GetDescriptor().Interfaces { + for _, endpoint := range iface.Endpoints { + if endpoint.BEndpointAddress != op.EndpointAddress { + continue + } + endpoint, err := udecx.EndpointDescriptorForNativeUdeCx( + udecx.DeviceSpeed(dev.GetDescriptor().Device.Speed), endpoint) + if err != nil { + panic(err) + } + op.EndpointAttributes = endpoint.BMAttributes + op.EndpointInterval = endpoint.BInterval + op.EndpointMaxPacketSize = endpoint.WMaxPacketSize + return + } + } +} + +func newProductionProcessor(t *testing.T) *serverusb.NativeProcessor { + t.Helper() + processor, err := serverusb.NewNativeProcessor( + serverusb.New(serverusb.ServerConfig{}, slog.Default(), nil)) + if err != nil { + t.Fatal(err) + } + return processor +} + +func startNativeEndpoint(t *testing.T, processor *serverusb.NativeProcessor, + dev usbdevice.Device, endpointAddress uint8) { + t.Helper() + for _, iface := range dev.GetDescriptor().Interfaces { + if iface.Descriptor.BAlternateSetting == 0 { + continue + } + for _, endpoint := range iface.Endpoints { + if endpoint.BEndpointAddress != endpointAddress { + continue + } + endpoint, err := udecx.EndpointDescriptorForNativeUdeCx( + udecx.DeviceSpeed(dev.GetDescriptor().Device.Speed), endpoint) + if err != nil { + t.Fatal(err) + } + err = processor.Lifecycle(context.Background(), dev, udecx.Operation{ + DeviceID: 1, Generation: 1, Kind: udecx.OperationEndpointStart, + EndpointAddress: endpoint.BEndpointAddress, + EndpointAttributes: endpoint.BMAttributes, + EndpointInterval: endpoint.BInterval, + EndpointMaxPacketSize: endpoint.WMaxPacketSize, + }) + if err != nil { + t.Fatal(err) + } + return + } + } + t.Fatalf("endpoint %#x has no nonzero alternate setting", endpointAddress) +} + +func processNativeIso(t *testing.T, processor *serverusb.NativeProcessor, + dev usbdevice.Device, endpoint uint8, input bool, payload []byte, + packetCount int, packetLength uint32) udecx.Completion { + t.Helper() + packets := make([]udecx.IsoPacket, packetCount) + for i := range packets { + packets[i] = udecx.IsoPacket{Offset: uint32(i) * packetLength, Length: packetLength} + } + transferLength := uint32(packetCount) * packetLength + op := udecx.Operation{ + Token: 100, DeviceID: 1, Generation: 1, Kind: udecx.OperationTransfer, + EndpointAddress: endpoint, TransferLength: transferLength, + TransferFlags: udecx.TransferFlagStartIsoASAP, IsoPackets: packets, Payload: payload, + } + for _, iface := range dev.GetDescriptor().Interfaces { + for _, descEndpoint := range iface.Endpoints { + if descEndpoint.BEndpointAddress == endpoint { + var err error + descEndpoint, err = udecx.EndpointDescriptorForNativeUdeCx( + udecx.DeviceSpeed(dev.GetDescriptor().Device.Speed), descEndpoint) + if err != nil { + t.Fatal(err) + } + op.EndpointAttributes = descEndpoint.BMAttributes + op.EndpointInterval = descEndpoint.BInterval + op.EndpointMaxPacketSize = descEndpoint.WMaxPacketSize + } + } + } + if input { + op.Direction = 1 + op.TransferFlags |= udecx.TransferFlagDirectionIn + } + completion, err := processor.Process(context.Background(), dev, op) + if err != nil { + t.Fatal(err) + } + if len(completion.IsoPackets) != packetCount { + t.Fatalf("native ISO completion has %d packets, want %d", len(completion.IsoPackets), packetCount) + } + return completion +} diff --git a/internal/server/usb/native_test.go b/internal/server/usb/native_test.go new file mode 100644 index 00000000..1a3757a2 --- /dev/null +++ b/internal/server/usb/native_test.go @@ -0,0 +1,1057 @@ +package usb + +import ( + "bytes" + "context" + "errors" + "log/slog" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Alia5/VIIPER/internal/transport/udecx" + usbdevice "github.com/Alia5/VIIPER/usb" +) + +func nativeProcessorForTest(t *testing.T) *NativeProcessor { + t.Helper() + processor, err := NewNativeProcessor(New(ServerConfig{}, slog.Default(), nil)) + if err != nil { + t.Fatal(err) + } + return processor +} + +type inputLifecycleTestDevice struct { + *altSettingTestDevice + invalidated []uint8 +} + +func (d *inputLifecycleTestDevice) InvalidateInterruptInput(endpoint uint8) { + d.invalidated = append(d.invalidated, endpoint) +} + +func TestNativeProcessorInvalidatesRetainedInputAtEveryGenerationBoundary(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{{ + Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 0, BAlternateSetting: 0, BNumEndpoints: 1, + }, + Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x84, BMAttributes: 0x03, + WMaxPacketSize: 64, BInterval: 4, + }}, + }}, + } + dev := &inputLifecycleTestDevice{ + altSettingTestDevice: &altSettingTestDevice{desc: desc}, + } + processor := nativeProcessorForTest(t) + endpoint := udecx.Operation{ + DeviceID: 1, Generation: 1, EndpointAddress: 0x84, + EndpointAttributes: 0x03, EndpointInterval: 4, + EndpointMaxPacketSize: 64, + } + for _, kind := range []udecx.OperationKind{ + udecx.OperationEndpointStart, + udecx.OperationEndpointPurge, + udecx.OperationEndpointReset, + } { + endpoint.Kind = kind + if err := processor.Lifecycle(context.Background(), dev, endpoint); err != nil { + t.Fatal(err) + } + } + for _, kind := range []udecx.OperationKind{ + udecx.OperationDeviceD0Exit, + udecx.OperationDeviceD0Entry, + udecx.OperationDeviceReset, + } { + if err := processor.Lifecycle(context.Background(), dev, udecx.Operation{ + DeviceID: 1, Generation: 1, Kind: kind, + }); err != nil { + t.Fatal(err) + } + } + want := []uint8{0x84, 0x84, 0x84, 0, 0, 0} + if !bytes.Equal(dev.invalidated, want) { + t.Fatalf("input invalidations=%x want %x", dev.invalidated, want) + } +} + +func TestNativeProcessorServesControlDescriptor(t *testing.T) { + dev := newNativeTransportTestDevice() + op := udecx.Operation{ + Token: 1, DeviceID: 1, Generation: 1, Kind: udecx.OperationControl, + Direction: 1, TransferLength: 18, + SetupPacket: [8]byte{0x80, usbReqGetDescriptor, 0, usbDescTypeDevice, 0, 0, 18, 0}, + } + completion, err := nativeProcessorForTest(t).Process(context.Background(), dev, op) + if err != nil { + t.Fatal(err) + } + if completion.TransferLength != 18 || len(completion.Payload) != 18 || + completion.Payload[1] != usbDescTypeDevice { + t.Fatalf("unexpected device descriptor completion: %+v payload=%x", completion, completion.Payload) + } +} + +func TestNativeProcessorServesSwitchMicrosoftOS10FeatureDescriptor(t *testing.T) { + msOS := &usbdevice.MicrosoftOS10Descriptor{ + VendorCode: 0x20, InterfaceNumber: 1, CompatibleID: "WINUSB", + } + dev := &altSettingTestDevice{desc: &usbdevice.Descriptor{MicrosoftOS10: msOS}} + op := udecx.Operation{ + Token: 2, DeviceID: 1, Generation: 1, Kind: udecx.OperationControl, + Direction: 1, TransferLength: 40, + SetupPacket: [8]byte{0xC0, msOS.EffectiveVendorCode(), 0, 0, 4, 0, 40, 0}, + } + completion, err := nativeProcessorForTest(t).Process(context.Background(), dev, op) + if err != nil { + t.Fatal(err) + } + want := msOS.CompatibleIDDescriptor() + if completion.TransferLength != uint32(len(want)) || !bytes.Equal(completion.Payload, want) { + t.Fatalf("native Microsoft OS feature response=%x want=%x", completion.Payload, want) + } +} + +func TestNativeProcessorDerivesInterfaceSettingFromEndpointLifecycle(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 0, + }}, + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 1, BNumEndpoints: 1, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x82, BMAttributes: 0x05, + WMaxPacketSize: 196, BInterval: 4, + }}}, + }, + } + dev := &altSettingTestDevice{desc: desc} + processor := nativeProcessorForTest(t) + // UdeCx supplies incorrect numeric interface fields for some composite + // devices. An endpoint-bearing alternate must therefore ignore this hint. + if err := processor.Lifecycle(context.Background(), dev, udecx.Operation{ + DeviceID: 1, Generation: 1, Kind: udecx.OperationSetInterface, + InterfaceNumber: 0, InterfaceSetting: 0, + }); err != nil { + t.Fatal(err) + } + if got := processor.server.getInterfaceAlt(dev, 2); got != 0 { + t.Fatalf("unreliable interface hint changed interface 2 alt to %d", got) + } + + op := udecx.Operation{ + DeviceID: 1, Generation: 1, Kind: udecx.OperationEndpointStart, + EndpointAddress: 0x82, EndpointAttributes: 0x05, + EndpointInterval: 4, EndpointMaxPacketSize: 196, + } + if err := processor.Lifecycle(context.Background(), dev, op); err != nil { + t.Fatal(err) + } + if got := processor.server.getInterfaceAlt(dev, 2); got != 1 { + t.Fatalf("interface 2 alt=%d want 1 after endpoint start", got) + } + + op.Kind = udecx.OperationEndpointPurge + if err := processor.Lifecycle(context.Background(), dev, op); err != nil { + t.Fatal(err) + } + if got := processor.server.getInterfaceAlt(dev, 2); got != 0 { + t.Fatalf("interface 2 alt=%d want 0 after endpoint purge", got) + } + if want := [][2]uint8{{2, 1}, {2, 0}}; !bytes.Equal(flattenAltEvents(dev.altEvents), flattenAltEvents(want)) { + t.Fatalf("device alternate-setting events=%v want %v", dev.altEvents, want) + } +} + +func flattenAltEvents(events [][2]uint8) []byte { + result := make([]byte, 0, len(events)*2) + for _, event := range events { + result = append(result, event[0], event[1]) + } + return result +} + +func TestNativeProcessorFirstISOTransferClosesEndpointStartRace(t *testing.T) { + desc := &usbdevice.Descriptor{Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2}}, + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 1, BNumEndpoints: 1, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x05, + WMaxPacketSize: 196, BInterval: 4, + }}}, + }} + dev := &isoOutRecordingDevice{desc: desc} + processor := nativeProcessorForTest(t) + _, err := processor.Process(context.Background(), dev, udecx.Operation{ + Token: 1, DeviceID: 3, Generation: 7, Kind: udecx.OperationTransfer, + EndpointAddress: 0x02, EndpointAttributes: 0x05, + EndpointInterval: 4, EndpointMaxPacketSize: 196, + TransferFlags: udecx.TransferFlagStartIsoASAP, + TransferLength: 4, Payload: []byte{1, 2, 3, 4}, + IsoPackets: []udecx.IsoPacket{{Offset: 0, Length: 4}}, + }) + if err != nil { + t.Fatal(err) + } + if got := processor.server.getInterfaceAlt(dev, 2); got != 1 { + t.Fatalf("first ISO transfer left interface 2 at alt %d", got) + } +} + +func TestNativeProcessorPreservesAlternateSettingAcrossLinkPower(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2, BAlternateSetting: 0}}, + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2, BAlternateSetting: 1}}, + }, + } + dev := &altSettingTestDevice{desc: desc} + processor := nativeProcessorForTest(t) + processor.server.setInterfaceAlt(dev, 2, 1) + identity := udecx.DeviceIdentity{DeviceID: 4, Generation: 7} + key := nativeLaneKey{deviceID: identity.DeviceID, generation: identity.Generation, endpoint: 0x82} + processor.next[key] = time.Now() + processor.lastIn[key] = []byte{1, 2, 3} + + for _, kind := range []udecx.OperationKind{ + udecx.OperationDeviceD0Exit, udecx.OperationDeviceD0Entry, + } { + if err := processor.Lifecycle(context.Background(), dev, udecx.Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, Kind: kind, + }); err != nil { + t.Fatal(err) + } + if got := processor.server.getInterfaceAlt(dev, 2); got != 1 { + t.Fatalf("link-power event %d reset interface 2 alt to %d", kind, got) + } + if _, ok := processor.next[key]; ok { + t.Fatalf("link-power event %d retained stale service clock", kind) + } + } +} + +func TestNativeProcessorSetConfigurationRetiresGenerationTransportState(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2, BAlternateSetting: 0}}, + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 1, BNumEndpoints: 1, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x05, + WMaxPacketSize: 196, BInterval: 4, + }}}, + }, + } + dev := &altSettingTestDevice{desc: desc} + processor := nativeProcessorForTest(t) + identity := udecx.DeviceIdentity{DeviceID: 5, Generation: 9} + endpoint := udecx.Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + Kind: udecx.OperationEndpointStart, + EndpointAddress: 0x02, EndpointAttributes: 0x05, + EndpointInterval: 4, EndpointMaxPacketSize: 196, + } + if err := processor.Lifecycle(context.Background(), dev, endpoint); err != nil { + t.Fatal(err) + } + key := nativeLaneKeyFromOperation(endpoint) + cacheOnlyKey := key + cacheOnlyKey.endpoint = 0x83 + processor.mu.Lock() + processor.next[key] = time.Now() + processor.lastIn[key] = []byte{1, 2, 3} + processor.lastIn[cacheOnlyKey] = []byte{4, 5, 6} + processor.mu.Unlock() + + _, err := processor.Process(context.Background(), dev, udecx.Operation{ + Token: 2, DeviceID: identity.DeviceID, Generation: identity.Generation, + Kind: udecx.OperationControl, EndpointAddress: 0, + SetupPacket: [8]byte{usbReqTypeStandardToDevice, usbReqSetConfiguration, 1}, + }) + if err != nil { + t.Fatal(err) + } + if got := processor.server.getInterfaceAlt(dev, 2); got != 0 { + t.Fatalf("SET_CONFIGURATION left interface 2 at alternate %d", got) + } + processor.mu.Lock() + _, hasClock := processor.next[key] + _, hasCachedInput := processor.lastIn[key] + _, hasCacheOnlyInput := processor.lastIn[cacheOnlyKey] + session := processor.sessions[nativeSessionKey{ + deviceID: identity.DeviceID, generation: identity.Generation, + }] + processor.mu.Unlock() + if hasClock || hasCachedInput || hasCacheOnlyInput { + t.Fatalf("SET_CONFIGURATION retained clock=%v cachedInput=%v cacheOnlyInput=%v", + hasClock, hasCachedInput, hasCacheOnlyInput) + } + if session == nil { + t.Fatal("SET_CONFIGURATION lost the registered generation session") + } + session.mu.Lock() + activeEndpoints := len(session.active) + session.mu.Unlock() + if activeEndpoints != 0 { + t.Fatalf("SET_CONFIGURATION retained %d active endpoint signatures", activeEndpoints) + } +} + +func TestNativeProcessorPreservesSparseIsoInLayout(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{{Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x82, BMAttributes: 0x01, WMaxPacketSize: 32, BInterval: 1, + }}}}, + } + dev := &isoInTestDevice{desc: desc, payloads: [][]byte{ + bytes.Repeat([]byte{0x11}, 12), bytes.Repeat([]byte{0x22}, 8), + }} + op := udecx.Operation{ + Token: 2, DeviceID: 1, Generation: 1, Kind: udecx.OperationTransfer, + EndpointAddress: 0x82, Direction: 1, EndpointAttributes: 0x01, + EndpointInterval: 1, EndpointMaxPacketSize: 32, + TransferFlags: udecx.TransferFlagStartIsoASAP | udecx.TransferFlagDirectionIn, + TransferLength: 48, + IsoPackets: []udecx.IsoPacket{{Offset: 0, Length: 16}, {Offset: 32, Length: 16}}, + } + completion, err := nativeProcessorForTest(t).Process(context.Background(), dev, op) + if err != nil { + t.Fatal(err) + } + if completion.TransferLength != 20 || len(completion.Payload) != 48 { + t.Fatalf("transfer=%d payload=%d want 20/48", completion.TransferLength, len(completion.Payload)) + } + if completion.IsoPackets[0].Length != 12 || completion.IsoPackets[1].Length != 8 || + !bytes.Equal(completion.Payload[:12], bytes.Repeat([]byte{0x11}, 12)) || + !bytes.Equal(completion.Payload[32:40], bytes.Repeat([]byte{0x22}, 8)) { + t.Fatalf("sparse ISO payload or packet actuals were not preserved: %+v", completion) + } +} + +type isoOutRecordingDevice struct { + desc *usbdevice.Descriptor + payload []byte + endpoint uint32 + direction uint32 +} + +type directIsoInTestDevice struct { + desc *usbdevice.Descriptor + calls int + fallbackCalls int + endpoint uint32 +} + +func (d *directIsoInTestDevice) HandleTransfer( + context.Context, uint32, uint32, []byte, +) []byte { + d.fallbackCalls++ + return nil +} + +func (d *directIsoInTestDevice) ReadIsochronousInput( + _ context.Context, endpoint uint32, dst []byte, +) (int, error) { + d.calls++ + d.endpoint = endpoint + actual := len(dst) - d.calls + for index := 0; index < actual; index++ { + dst[index] = byte(0x20*d.calls + index) + } + return actual, nil +} + +func (d *directIsoInTestDevice) GetDescriptor() *usbdevice.Descriptor { return d.desc } +func (*directIsoInTestDevice) GetDeviceSpecificArgs() map[string]any { return nil } + +func TestNativeProcessorWritesIsoInDirectlyIntoURBPacketRegions(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{{Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x82, BMAttributes: 0x05, WMaxPacketSize: 8, BInterval: 1, + }}}}, + } + dev := &directIsoInTestDevice{desc: desc} + op := udecx.Operation{ + Token: 8, DeviceID: 4, Generation: 2, Kind: udecx.OperationTransfer, + EndpointAddress: 0x82, Direction: 1, EndpointAttributes: 0x05, + EndpointInterval: 1, EndpointMaxPacketSize: 8, + TransferFlags: udecx.TransferFlagStartIsoASAP | udecx.TransferFlagDirectionIn, + TransferLength: 24, + IsoPackets: []udecx.IsoPacket{{Offset: 0, Length: 8}, {Offset: 16, Length: 8}}, + } + completion, err := nativeProcessorForTest(t).Process(context.Background(), dev, op) + if err != nil { + t.Fatal(err) + } + if dev.calls != 2 || dev.fallbackCalls != 0 { + t.Fatalf("direct calls=%d fallback calls=%d want 2/0", dev.calls, dev.fallbackCalls) + } + if completion.TransferLength != 13 || len(completion.Payload) != 24 || + completion.IsoPackets[0].Length != 7 || completion.IsoPackets[1].Length != 6 { + t.Fatalf("unexpected direct ISO completion: %+v", completion) + } + wantFirst := []byte{0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26} + wantSecond := []byte{0x40, 0x41, 0x42, 0x43, 0x44, 0x45} + if !bytes.Equal(completion.Payload[:7], wantFirst) || + !bytes.Equal(completion.Payload[16:22], wantSecond) || + !bytes.Equal(completion.Payload[8:16], make([]byte, 8)) { + t.Fatalf("direct ISO packet regions were not preserved: % x", completion.Payload) + } +} + +func (d *isoOutRecordingDevice) HandleTransfer( + _ context.Context, endpoint, direction uint32, out []byte, +) []byte { + d.endpoint = endpoint + d.direction = direction + d.payload = append(d.payload[:0], out...) + return nil +} +func (d *isoOutRecordingDevice) GetDescriptor() *usbdevice.Descriptor { return d.desc } +func (*isoOutRecordingDevice) GetDeviceSpecificArgs() map[string]any { return nil } + +func TestNativeProcessorCompletesIsoOutWithoutEchoPayload(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{{Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x01, WMaxPacketSize: 32, BInterval: 1, + }}}}, + } + dev := &isoOutRecordingDevice{desc: desc} + payload := bytes.Repeat([]byte{0x5a}, 32) + op := udecx.Operation{ + Token: 3, DeviceID: 1, Generation: 1, Kind: udecx.OperationTransfer, + EndpointAddress: 0x02, EndpointAttributes: 0x01, + EndpointInterval: 1, EndpointMaxPacketSize: 32, + TransferFlags: udecx.TransferFlagStartIsoASAP, TransferLength: 32, Payload: payload, + IsoPackets: []udecx.IsoPacket{{Offset: 0, Length: 16}, {Offset: 16, Length: 16}}, + } + completion, err := nativeProcessorForTest(t).Process(context.Background(), dev, op) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(dev.payload, payload) || len(completion.Payload) != 0 || + completion.TransferLength != 32 || len(completion.IsoPackets) != 2 { + t.Fatalf("unexpected ISO OUT completion: %+v captured=%x", completion, dev.payload) + } +} + +func TestNativeProcessorSchedulesIsoOutFromExactOperationFrame(t *testing.T) { + base := time.Unix(500, 0) + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 1, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x01, + WMaxPacketSize: 32, BInterval: 1, + }}}, + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 2, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x09, + WMaxPacketSize: 64, BInterval: 4, + }}}, + }, + } + dev := &isoOutRecordingDevice{desc: desc} + processor := nativeProcessorForTest(t) + processor.clock = func() nativeClockSample { + return nativeClockSample{now: base, frame: 100} + } + var waits []time.Time + processor.wait = func(_ context.Context, deadline time.Time) bool { + waits = append(waits, deadline) + return true + } + payload := []byte{1, 2, 3, 4} + completion, err := processor.Process(context.Background(), dev, udecx.Operation{ + Token: 20, DeviceID: 4, Generation: 7, Kind: udecx.OperationTransfer, + EndpointAddress: 0x02, Direction: 0, EndpointAttributes: 0x09, + EndpointInterval: 4, EndpointMaxPacketSize: 64, + StartFrame: 103, TransferLength: uint32(len(payload)), Payload: payload, + IsoPackets: []udecx.IsoPacket{{Offset: 0, Length: 2}, {Offset: 2, Length: 2}}, + }) + if err != nil { + t.Fatal(err) + } + wantWaits := []time.Time{base.Add(3 * time.Millisecond), base.Add(5 * time.Millisecond)} + if len(waits) != len(wantWaits) || !waits[0].Equal(wantWaits[0]) || !waits[1].Equal(wantWaits[1]) { + t.Fatalf("ISO OUT waits=%v want %v", waits, wantWaits) + } + if dev.endpoint != 2 || dev.direction != 0 || !bytes.Equal(dev.payload, payload) { + t.Fatalf("ISO OUT routed endpoint=%d direction=%d payload=%x", dev.endpoint, dev.direction, dev.payload) + } + if completion.TransferLength != uint32(len(payload)) || len(completion.IsoPackets) != 2 { + t.Fatalf("unexpected ISO OUT completion: %+v", completion) + } +} + +func TestNativeProcessorSchedulesIsoInPacketsFromExactOperationFrame(t *testing.T) { + base := time.Unix(600, 0) + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{{Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x82, BMAttributes: 0x05, + WMaxPacketSize: 8, BInterval: 4, + }}}}, + } + dev := &directIsoInTestDevice{desc: desc} + processor := nativeProcessorForTest(t) + processor.clock = func() nativeClockSample { + return nativeClockSample{now: base, frame: 700} + } + var waits []time.Time + processor.wait = func(_ context.Context, deadline time.Time) bool { + waits = append(waits, deadline) + return true + } + completion, err := processor.Process(context.Background(), dev, udecx.Operation{ + Token: 21, DeviceID: 4, Generation: 7, Kind: udecx.OperationTransfer, + EndpointAddress: 0x82, Direction: 1, EndpointAttributes: 0x05, + EndpointInterval: 4, EndpointMaxPacketSize: 8, + TransferFlags: udecx.TransferFlagDirectionIn, StartFrame: 702, TransferLength: 16, + IsoPackets: []udecx.IsoPacket{{Offset: 0, Length: 8}, {Offset: 8, Length: 8}}, + }) + if err != nil { + t.Fatal(err) + } + wantWaits := []time.Time{base.Add(2 * time.Millisecond), base.Add(3 * time.Millisecond)} + if len(waits) != len(wantWaits) || !waits[0].Equal(wantWaits[0]) || !waits[1].Equal(wantWaits[1]) { + t.Fatalf("ISO IN waits=%v want %v", waits, wantWaits) + } + if dev.endpoint != 2 || dev.calls != 2 || completion.TransferLength != 13 { + t.Fatalf("ISO IN endpoint=%d calls=%d completion=%+v", dev.endpoint, dev.calls, completion) + } +} + +func TestResolveNativeIsoEndpointUsesDirectionAndAlternateSignature(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 1, BAlternateSetting: 1}, + Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x01, + WMaxPacketSize: 32, BInterval: 1, + }}}, + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 1, BAlternateSetting: 2}, + Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x09, + WMaxPacketSize: 64, BInterval: 4, + }}}, + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2, BAlternateSetting: 1}, + Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x82, BMAttributes: 0x05, + WMaxPacketSize: 96, BInterval: 2, + }}}, + }, + } + dev := &altSettingTestDevice{desc: desc} + out, err := resolveNativeIsoEndpoint(dev, udecx.Operation{ + DeviceID: 1, Generation: 1, EndpointAddress: 0x02, Direction: 0, + EndpointAttributes: 0x09, EndpointInterval: 4, EndpointMaxPacketSize: 64, + }) + if err != nil { + t.Fatal(err) + } + in, err := resolveNativeIsoEndpoint(dev, udecx.Operation{ + DeviceID: 1, Generation: 1, EndpointAddress: 0x82, Direction: 1, + EndpointAttributes: 0x05, EndpointInterval: 2, EndpointMaxPacketSize: 96, + TransferFlags: udecx.TransferFlagDirectionIn, + }) + if err != nil { + t.Fatal(err) + } + if out.interval != time.Millisecond || out.direction != 0 || out.key == in.key { + t.Fatalf("exact OUT endpoint=%+v IN endpoint=%+v", out, in) + } + if in.interval != 250*time.Microsecond || in.direction != 1 { + t.Fatalf("exact IN endpoint=%+v", in) + } + _, err = resolveNativeIsoEndpoint(dev, udecx.Operation{ + EndpointAddress: 0x82, Direction: 0, EndpointAttributes: 0x05, + EndpointInterval: 2, EndpointMaxPacketSize: 96, + TransferFlags: udecx.TransferFlagDirectionIn, + }) + if err == nil { + t.Fatal("direction mismatch was accepted") + } +} + +func TestResolveNativeIsoEndpointMapsProjectedFullSpeedSignatureToLogicalCadence(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedFull)}, + Interfaces: []usbdevice.InterfaceConfig{{ + Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 1, BAlternateSetting: 1}, + Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x01, BMAttributes: 0x09, + WMaxPacketSize: 132, BInterval: 1, + }}, + }}, + } + dev := &altSettingTestDevice{desc: desc} + endpoint, err := resolveNativeIsoEndpoint(dev, udecx.Operation{ + DeviceID: 1, Generation: 1, EndpointAddress: 0x01, + EndpointAttributes: 0x09, EndpointInterval: 4, EndpointMaxPacketSize: 132, + }) + if err != nil { + t.Fatal(err) + } + if endpoint.interval != time.Millisecond { + t.Fatalf("projected full-speed interval=%s want=1ms logical cadence", endpoint.interval) + } + _, err = resolveNativeIsoEndpoint(dev, udecx.Operation{ + DeviceID: 1, Generation: 1, EndpointAddress: 0x01, + EndpointAttributes: 0x09, EndpointInterval: 1, EndpointMaxPacketSize: 132, + }) + if err == nil { + t.Fatal("unprojected full-speed UdeCx signature was accepted") + } +} + +func TestNativeIsoExplicitFrameRangeHandlesWrap(t *testing.T) { + base := time.Unix(700, 0) + processor := nativeProcessorForTest(t) + processor.clock = func() nativeClockSample { + return nativeClockSample{now: base, frame: 0xfffffffe} + } + key := nativeLaneKey{deviceID: 1, generation: 1, endpoint: 0x02, interval: 4} + start, end, err := processor.reserveIsoServiceWindow(key, 1, 0, 2*time.Millisecond) + if err != nil { + t.Fatal(err) + } + if !start.Equal(base.Add(3*time.Millisecond)) || !end.Equal(base.Add(5*time.Millisecond)) { + t.Fatalf("wrapped explicit frame start=%s end=%s", start, end) + } + + processor.clock = func() nativeClockSample { + return nativeClockSample{now: base, frame: 100} + } + if _, _, err := processor.reserveIsoServiceWindow( + key, 100+uint32(usbdIsoStartFrameRange), 0, time.Millisecond); err == nil { + t.Fatal("out-of-range explicit frame was accepted") + } else { + var statusError interface{ USBDCompletionStatus() uint32 } + if !errors.As(err, &statusError) || + statusError.USBDCompletionStatus() != udecx.USBDStatusBadStartFrame { + t.Fatalf("out-of-range explicit error=%v does not report BAD_START_FRAME", err) + } + } + if _, _, err := processor.reserveIsoServiceWindow(key, 99, 0, time.Millisecond); err == nil { + t.Fatal("past explicit frame was accepted") + } else { + var statusError interface{ USBDCompletionStatus() uint32 } + if !errors.As(err, &statusError) || + statusError.USBDCompletionStatus() != udecx.USBDStatusBadStartFrame { + t.Fatalf("past explicit error=%v does not report BAD_START_FRAME", err) + } + } +} + +func TestNativeIsoASAPContinuityReanchorsAfterDrift(t *testing.T) { + base := time.Unix(800, 0) + processor := nativeProcessorForTest(t) + sample := nativeClockSample{now: base, frame: 100} + processor.clock = func() nativeClockSample { return sample } + key := nativeLaneKey{deviceID: 1, generation: 1, endpoint: 0x82, interval: 4} + start, end, err := processor.reserveIsoServiceWindow( + key, 101, udecx.TransferFlagStartIsoASAP, 4*time.Millisecond) + if err != nil { + t.Fatal(err) + } + if !start.Equal(base.Add(time.Millisecond)) || !end.Equal(base.Add(5*time.Millisecond)) { + t.Fatalf("first ASAP window start=%s end=%s", start, end) + } + + start, end, err = processor.reserveIsoServiceWindow( + key, 102, udecx.TransferFlagStartIsoASAP, 4*time.Millisecond) + if err != nil { + t.Fatal(err) + } + if !start.Equal(base.Add(5*time.Millisecond)) || !end.Equal(base.Add(9*time.Millisecond)) { + t.Fatalf("ordered ASAP window start=%s end=%s", start, end) + } + + sample = nativeClockSample{now: base.Add(20 * time.Millisecond), frame: 120} + start, end, err = processor.reserveIsoServiceWindow( + key, 103, udecx.TransferFlagStartIsoASAP, 4*time.Millisecond) + if err != nil { + t.Fatal(err) + } + if !start.Equal(sample.now) || !end.Equal(sample.now.Add(4*time.Millisecond)) { + t.Fatalf("late ASAP drift replayed stale slots: start=%s end=%s", start, end) + } +} + +func TestNativeEndpointResetClearsAlternateReuseClocks(t *testing.T) { + processor := nativeProcessorForTest(t) + base := udecx.Operation{ + DeviceID: 8, Generation: 3, EndpointAddress: 0x02, + EndpointAttributes: 0x01, EndpointInterval: 1, EndpointMaxPacketSize: 32, + } + first := nativeLaneKeyFromOperation(base) + base.EndpointAttributes = 0x09 + base.EndpointInterval = 4 + base.EndpointMaxPacketSize = 64 + second := nativeLaneKeyFromOperation(base) + other := second + other.endpoint = 0x82 + processor.next[first] = time.Now() + processor.next[second] = time.Now() + processor.next[other] = time.Now() + processor.lastIn[first] = []byte{1} + processor.lastIn[second] = []byte{2} + + if err := processor.Lifecycle(context.Background(), &altSettingTestDevice{}, udecx.Operation{ + DeviceID: 8, Generation: 3, Kind: udecx.OperationEndpointReset, + EndpointAddress: 0x02, EndpointAttributes: 0x09, + EndpointInterval: 4, EndpointMaxPacketSize: 64, + }); err != nil { + t.Fatal(err) + } + if _, ok := processor.next[first]; ok { + t.Fatal("endpoint reset retained the old alternate clock") + } + if _, ok := processor.next[second]; ok { + t.Fatal("endpoint reset retained the current alternate clock") + } + if _, ok := processor.lastIn[first]; ok { + t.Fatal("endpoint reset retained the old alternate cache") + } + if _, ok := processor.next[other]; !ok { + t.Fatal("endpoint reset cleared an independent direction") + } +} + +func TestNativeEndpointIncarnationDoesNotReuseWorkerOrActiveState(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2}}, + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 1, BNumEndpoints: 1, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x82, BMAttributes: 0x03, + WMaxPacketSize: 64, BInterval: 4, + }}}, + }, + } + dev := &altSettingTestDevice{desc: desc} + processor := nativeProcessorForTest(t) + first := udecx.Operation{ + DeviceID: 8, Generation: 3, EndpointGeneration: 1, + EndpointAddress: 0x82, EndpointAttributes: 0x03, + EndpointInterval: 4, EndpointMaxPacketSize: 64, + } + second := first + second.EndpointGeneration = 2 + firstKey, secondKey := nativeLaneKeyFromOperation(first), nativeLaneKeyFromOperation(second) + if firstKey == secondKey { + t.Fatal("same-address endpoint incarnations share a worker/cache key") + } + processor.next[firstKey] = time.Now() + processor.next[secondKey] = time.Now().Add(time.Second) + processor.lastIn[firstKey] = []byte{1} + processor.lastIn[secondKey] = []byte{2} + processor.clearEndpointLanes(firstKey) + if _, ok := processor.next[secondKey]; !ok { + t.Fatal("retired endpoint generation cleared the successor service clock") + } + if got := processor.lastIn[secondKey]; !bytes.Equal(got, []byte{2}) { + t.Fatalf("successor input cache=%v want [2]", got) + } + + processor.next[firstKey] = time.Now() + processor.lastIn[firstKey] = []byte{1} + second.Kind = udecx.OperationEndpointStart + if err := processor.Lifecycle(context.Background(), dev, second); err != nil { + t.Fatal(err) + } + if _, ok := processor.next[firstKey]; ok { + t.Fatal("successor endpoint start retained the retired incarnation clock") + } + if _, ok := processor.lastIn[firstKey]; ok { + t.Fatal("successor endpoint start retained the retired incarnation cache") + } + + sessionKey := nativeSessionKey{deviceID: second.DeviceID, generation: second.Generation} + session := processor.lockSession(sessionKey) + if len(session.active) != 1 { + t.Fatalf("active endpoint incarnations=%d want 1", len(session.active)) + } + if _, ok := session.active[signatureFromOperation(second)]; !ok { + t.Fatal("successor endpoint incarnation was not made authoritative") + } + session.mu.Unlock() + + first.Kind = udecx.OperationEndpointPurge + if err := processor.Lifecycle(context.Background(), dev, first); err != nil { + t.Fatal(err) + } + session = processor.lockSession(sessionKey) + _, successorActive := session.active[signatureFromOperation(second)] + session.mu.Unlock() + if !successorActive { + t.Fatal("retired endpoint purge removed the successor active state") + } + if got := processor.server.getInterfaceAlt(dev, 2); got != 1 { + t.Fatalf("retired endpoint generation changed active alt to %d", got) + } + second.Kind = udecx.OperationEndpointPurge + if err := processor.Lifecycle(context.Background(), dev, second); err != nil { + t.Fatal(err) + } + if got := processor.server.getInterfaceAlt(dev, 2); got != 0 { + t.Fatalf("authoritative endpoint purge left alt %d active", got) + } +} + +type concurrentNativeTestDevice struct { + desc *usbdevice.Descriptor + mu sync.Mutex + altEvents [][2]uint8 + transfers atomic.Uint64 +} + +func (d *concurrentNativeTestDevice) HandleTransfer( + _ context.Context, _, _ uint32, _ []byte, +) []byte { + d.transfers.Add(1) + return nil +} + +func (d *concurrentNativeTestDevice) GetDescriptor() *usbdevice.Descriptor { return d.desc } +func (*concurrentNativeTestDevice) GetDeviceSpecificArgs() map[string]any { return nil } +func (d *concurrentNativeTestDevice) SetInterfaceAltSetting(iface, alt uint8) { + d.mu.Lock() + d.altEvents = append(d.altEvents, [2]uint8{iface, alt}) + d.mu.Unlock() +} + +type lifecycleGateDevice struct { + desc *usbdevice.Descriptor + resetStarted chan struct{} + resetRelease chan struct{} + altChanged chan [2]uint8 +} + +func (*lifecycleGateDevice) HandleTransfer(context.Context, uint32, uint32, []byte) []byte { + return nil +} +func (d *lifecycleGateDevice) GetDescriptor() *usbdevice.Descriptor { return d.desc } +func (*lifecycleGateDevice) GetDeviceSpecificArgs() map[string]any { return nil } +func (d *lifecycleGateDevice) ResetEndpoint(uint8) { + close(d.resetStarted) + <-d.resetRelease +} +func (d *lifecycleGateDevice) SetInterfaceAltSetting(iface, alt uint8) { + d.altChanged <- [2]uint8{iface, alt} +} + +func TestNativeProcessorSerializesEndpointResetWithEndpointStart(t *testing.T) { + desc := &usbdevice.Descriptor{Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2}}, + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 1, BNumEndpoints: 1, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x05, + WMaxPacketSize: 4, BInterval: 1, + }}}, + }} + dev := &lifecycleGateDevice{ + desc: desc, resetStarted: make(chan struct{}), resetRelease: make(chan struct{}), + altChanged: make(chan [2]uint8, 1), + } + processor := nativeProcessorForTest(t) + base := udecx.Operation{ + DeviceID: 92, Generation: 1, EndpointAddress: 0x02, + EndpointAttributes: 0x05, EndpointInterval: 1, EndpointMaxPacketSize: 4, + } + + resetDone := make(chan error, 1) + go func() { + op := base + op.Kind = udecx.OperationEndpointReset + resetDone <- processor.Lifecycle(context.Background(), dev, op) + }() + select { + case <-dev.resetStarted: + case <-time.After(time.Second): + t.Fatal("endpoint reset did not reach the controller engine") + } + + startDone := make(chan error, 1) + go func() { + op := base + op.Kind = udecx.OperationEndpointStart + startDone <- processor.Lifecycle(context.Background(), dev, op) + }() + select { + case event := <-dev.altChanged: + close(dev.resetRelease) + t.Fatalf("endpoint start changed alternate setting during reset: %v", event) + case <-time.After(25 * time.Millisecond): + } + + close(dev.resetRelease) + if err := <-resetDone; err != nil { + t.Fatal(err) + } + if err := <-startDone; err != nil { + t.Fatal(err) + } + select { + case event := <-dev.altChanged: + if event != [2]uint8{2, 1} { + t.Fatalf("alternate setting event=%v want=[2 1]", event) + } + case <-time.After(time.Second): + t.Fatal("endpoint start did not resume after reset") + } +} + +func TestNativeProcessorDoesNotGloballySerializeIndependentDevices(t *testing.T) { + desc := &usbdevice.Descriptor{Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2}}, + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 1, BNumEndpoints: 1, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x05, + WMaxPacketSize: 4, BInterval: 1, + }}}, + }} + blocked := &lifecycleGateDevice{ + desc: desc, resetStarted: make(chan struct{}), resetRelease: make(chan struct{}), + altChanged: make(chan [2]uint8, 1), + } + independent := &lifecycleGateDevice{ + desc: desc, resetStarted: make(chan struct{}), resetRelease: make(chan struct{}), + altChanged: make(chan [2]uint8, 1), + } + processor := nativeProcessorForTest(t) + resetDone := make(chan error, 1) + go func() { + resetDone <- processor.Lifecycle(context.Background(), blocked, udecx.Operation{ + DeviceID: 101, Generation: 3, Kind: udecx.OperationEndpointReset, + EndpointAddress: 0x02, EndpointAttributes: 0x05, + EndpointInterval: 1, EndpointMaxPacketSize: 4, + }) + }() + select { + case <-blocked.resetStarted: + case <-time.After(time.Second): + t.Fatal("first controller did not enter its blocked endpoint reset") + } + + startDone := make(chan error, 1) + go func() { + startDone <- processor.Lifecycle(context.Background(), independent, udecx.Operation{ + DeviceID: 102, Generation: 8, Kind: udecx.OperationEndpointStart, + EndpointAddress: 0x02, EndpointAttributes: 0x05, + EndpointInterval: 1, EndpointMaxPacketSize: 4, + }) + }() + select { + case event := <-independent.altChanged: + if event != [2]uint8{2, 1} { + close(blocked.resetRelease) + t.Fatalf("independent alternate setting event=%v want=[2 1]", event) + } + case <-time.After(100 * time.Millisecond): + close(blocked.resetRelease) + t.Fatal("one controller's reset blocked an independent controller") + } + if err := <-startDone; err != nil { + close(blocked.resetRelease) + t.Fatal(err) + } + close(blocked.resetRelease) + if err := <-resetDone; err != nil { + t.Fatal(err) + } +} + +func TestNativeProcessorConcurrentMediaAndLifecycleSoak(t *testing.T) { + desc := &usbdevice.Descriptor{Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2}}, + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 1, BNumEndpoints: 1, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x05, + WMaxPacketSize: 4, BInterval: 1, + }}}, + }} + dev := &concurrentNativeTestDevice{desc: desc} + processor := nativeProcessorForTest(t) + identity := udecx.DeviceIdentity{DeviceID: 91, Generation: 14} + base := udecx.Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x02, EndpointAttributes: 0x05, + EndpointInterval: 1, EndpointMaxPacketSize: 4, + TransferFlags: udecx.TransferFlagStartIsoASAP, + } + + var wg sync.WaitGroup + for worker := range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for iteration := range 25 { + if (worker+iteration)%2 == 0 { + op := base + op.Kind = udecx.OperationEndpointStart + if err := processor.Lifecycle(context.Background(), dev, op); err != nil { + t.Errorf("endpoint start: %v", err) + return + } + } else { + op := base + op.Kind = udecx.OperationEndpointPurge + if err := processor.Lifecycle(context.Background(), dev, op); err != nil { + t.Errorf("endpoint purge: %v", err) + return + } + } + + op := base + op.Token = uint64(worker*25 + iteration + 1) + op.Kind = udecx.OperationTransfer + op.TransferLength = 4 + op.Payload = []byte{1, 2, 3, 4} + op.IsoPackets = []udecx.IsoPacket{{Offset: 0, Length: 4}} + if _, err := processor.Process(context.Background(), dev, op); err != nil { + t.Errorf("ISO transfer: %v", err) + return + } + } + }() + } + wg.Wait() + processor.Reset(dev, identity) + + if got := dev.transfers.Load(); got != 100 { + t.Fatalf("processed %d transfers, want 100", got) + } + processor.mu.Lock() + defer processor.mu.Unlock() + if len(processor.next) != 0 || len(processor.lastIn) != 0 { + t.Fatalf("reset retained clocks=%d cached-input=%d", len(processor.next), len(processor.lastIn)) + } + if len(processor.sessions) != 0 { + t.Fatalf("reset retained %d native sessions", len(processor.sessions)) + } +} diff --git a/internal/server/usb/native_transport_test.go b/internal/server/usb/native_transport_test.go new file mode 100644 index 00000000..e792c0af --- /dev/null +++ b/internal/server/usb/native_transport_test.go @@ -0,0 +1,496 @@ +package usb + +import ( + "context" + "errors" + "log/slog" + "testing" + "time" + + "github.com/Alia5/VIIPER/internal/transport/udecx" + usbdevice "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/virtualbus" +) + +type nativeTransportTestDriver struct { + createErr error + created []udecx.CreateDevice + destroyed []udecx.DeviceIdentity +} + +type blockingNativeTransportTestDriver struct { + nativeTransportTestDriver + createStarted chan struct{} + allowCreate chan struct{} +} + +type blockingDestroyNativeTransportTestDriver struct { + nativeTransportTestDriver + destroyStarted chan struct{} + allowDestroy chan struct{} +} + +func (d *blockingDestroyNativeTransportTestDriver) DestroyDevice( + ctx context.Context, identity udecx.DeviceIdentity, +) error { + close(d.destroyStarted) + select { + case <-d.allowDestroy: + case <-ctx.Done(): + return ctx.Err() + } + return d.nativeTransportTestDriver.DestroyDevice(ctx, identity) +} + +func (d *blockingNativeTransportTestDriver) CreateDevice( + ctx context.Context, device udecx.CreateDevice, +) (udecx.DeviceRegistration, error) { + close(d.createStarted) + select { + case <-d.allowCreate: + case <-ctx.Done(): + return udecx.DeviceRegistration{}, ctx.Err() + } + return d.nativeTransportTestDriver.CreateDevice(ctx, device) +} + +func (d *nativeTransportTestDriver) CreateDevice(_ context.Context, device udecx.CreateDevice) (udecx.DeviceRegistration, error) { + d.created = append(d.created, device) + if d.createErr != nil { + return udecx.DeviceRegistration{}, d.createErr + } + registration := udecx.DeviceRegistration{ + DeviceIdentity: udecx.DeviceIdentity{DeviceID: device.DeviceID, Generation: device.Generation}, + Speed: device.Speed, ControllerSessionID: 17, + ControllerInstanceID: `ROOT\VIIPERUDE\0000`, + } + if device.Speed == udecx.DeviceSpeedSuper { + registration.USB30PortNumber = udecx.MaxDevices + 1 + } else { + registration.USB20PortNumber = 1 + } + return registration, nil +} +func (d *nativeTransportTestDriver) DestroyDevice(_ context.Context, identity udecx.DeviceIdentity) error { + d.destroyed = append(d.destroyed, identity) + return nil +} +func (*nativeTransportTestDriver) Dequeue(ctx context.Context, _ []byte) (udecx.Operation, error) { + <-ctx.Done() + return udecx.Operation{}, ctx.Err() +} +func (*nativeTransportTestDriver) Complete(context.Context, udecx.Completion) error { return nil } +func (*nativeTransportTestDriver) QueryStats(context.Context) (udecx.Stats, error) { + return udecx.Stats{}, nil +} + +type nativeTransportTestProcessor struct{} + +func (*nativeTransportTestProcessor) Process(context.Context, usbdevice.Device, udecx.Operation) (udecx.Completion, error) { + return udecx.Completion{}, nil +} +func (*nativeTransportTestProcessor) Lifecycle(context.Context, usbdevice.Device, udecx.Operation) error { + return nil +} +func (*nativeTransportTestProcessor) Reset(usbdevice.Device, udecx.DeviceIdentity) {} + +func newNativeTransportTestDevice() usbdevice.Device { + return &altSettingTestDevice{desc: &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{ + BcdUSB: 0x0200, BMaxPacketSize0: 64, IDVendor: 1, IDProduct: 2, + BNumConfigurations: 1, Speed: uint32(udecx.DeviceSpeedHigh), + }, + Interfaces: []usbdevice.InterfaceConfig{{Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 0, BNumEndpoints: 1, BInterfaceClass: 3, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x81, BMAttributes: 3, WMaxPacketSize: 64, BInterval: 4, + }}}}, + }} +} + +func TestNativeTransportPublishesAndUnpublishesWithVirtualBus(t *testing.T) { + driver := &nativeTransportTestDriver{} + host, err := udecx.NewHost(driver, &nativeTransportTestProcessor{}, 1) + if err != nil { + t.Fatal(err) + } + server := New(ServerConfig{ConnectionTimeout: time.Second}, slog.Default(), nil) + if err := server.EnableNativeTransport(host); err != nil { + t.Fatal(err) + } + bus, err := virtualbus.NewWithBusID(98101) + if err != nil { + t.Fatal(err) + } + defer bus.Close() + if err := server.AddBus(bus); err != nil { + t.Fatal(err) + } + + if _, err := server.AddDeviceToBus(context.Background(), bus.BusID(), newNativeTransportTestDevice()); err != nil { + t.Fatal(err) + } + if len(driver.created) != 1 || len(bus.Devices()) != 1 { + t.Fatalf("created=%d bus devices=%d want 1/1", len(driver.created), len(bus.Devices())) + } + if err := server.RemoveDeviceByID(bus.BusID(), "1"); err != nil { + t.Fatal(err) + } + if len(driver.destroyed) != 1 || len(bus.Devices()) != 0 { + t.Fatalf("destroyed=%d bus devices=%d want 1/0", len(driver.destroyed), len(bus.Devices())) + } +} + +func TestNativeTransportExactRemoveCannotDeleteIDReusingSuccessor(t *testing.T) { + driver := &nativeTransportTestDriver{} + host, err := udecx.NewHost(driver, &nativeTransportTestProcessor{}, 1) + if err != nil { + t.Fatal(err) + } + server := New(ServerConfig{ + ConnectionTimeout: time.Second, BusCleanupTimeout: time.Hour, + }, slog.Default(), nil) + if err := server.EnableNativeTransport(host); err != nil { + t.Fatal(err) + } + bus, err := virtualbus.NewWithBusID(98104) + if err != nil { + t.Fatal(err) + } + defer bus.Close() + if err := server.AddBus(bus); err != nil { + t.Fatal(err) + } + + firstDevice := newNativeTransportTestDevice() + firstContext, first, err := server.AddDeviceToBusWithRegistration( + context.Background(), bus.BusID(), firstDevice) + if err != nil || first == nil { + t.Fatalf("add first native device: registration=%v error=%v", first, err) + } + if captured, ok := server.NativeDeviceRegistrationForDevice( + bus.BusID(), 1, firstDevice, firstContext); !ok || + captured.DeviceIdentity != first.DeviceIdentity { + t.Fatalf("exact first lifetime capture: ok=%t captured=%+v want=%+v", + ok, captured.DeviceIdentity, first.DeviceIdentity) + } + unrelatedContext, cancelUnrelated := context.WithCancel(context.Background()) + defer cancelUnrelated() + if _, ok := server.NativeDeviceRegistrationForDevice( + bus.BusID(), 1, firstDevice, unrelatedContext); ok { + t.Fatal("native lifetime capture accepted an unrelated device context") + } + if err := server.RemoveNativeDeviceExact(bus.BusID(), "1", *first); err != nil { + t.Fatalf("remove first native device: %v", err) + } + + successorDevice := newNativeTransportTestDevice() + successorContext, successor, err := server.AddDeviceToBusWithRegistration( + context.Background(), bus.BusID(), successorDevice) + if err != nil || successor == nil { + t.Fatalf("add successor native device: registration=%v error=%v", successor, err) + } + if successor.DeviceID != first.DeviceID || successor.Generation <= first.Generation { + t.Fatalf("successor identity=%+v did not reuse ID after first=%+v", successor.DeviceIdentity, first.DeviceIdentity) + } + + err = server.RemoveNativeDeviceExact(bus.BusID(), "1", *first) + if !errors.Is(err, ErrNativeDeviceCorrelationMismatch) { + t.Fatalf("stale exact remove error=%v want correlation mismatch", err) + } + if len(driver.destroyed) != 1 || len(bus.Devices()) != 1 { + t.Fatalf("stale removal mutated successor: destroyed=%d devices=%d want 1/1", + len(driver.destroyed), len(bus.Devices())) + } + if _, ok := server.NativeDeviceRegistrationForDevice( + bus.BusID(), 1, firstDevice, firstContext); ok { + t.Fatal("retired first device/context captured the successor registration") + } + if captured, ok := server.NativeDeviceRegistrationForDevice( + bus.BusID(), 1, successorDevice, successorContext); !ok || + captured.DeviceIdentity != successor.DeviceIdentity { + t.Fatalf("exact successor lifetime capture: ok=%t captured=%+v want=%+v", + ok, captured.DeviceIdentity, successor.DeviceIdentity) + } + snapshots, err := server.SnapshotBusDevices(bus.BusID()) + if err != nil || len(snapshots) != 1 || snapshots[0].NativeRegistration == nil || + snapshots[0].NativeRegistration.DeviceIdentity != successor.DeviceIdentity { + t.Fatalf("successor registration changed after stale remove: snapshots=%v error=%v want=%+v", + snapshots, err, successor.DeviceIdentity) + } + + if err := server.RemoveNativeDeviceExact(bus.BusID(), "1", *successor); err != nil { + t.Fatalf("remove exact successor: %v", err) + } + if len(driver.destroyed) != 2 || len(bus.Devices()) != 0 { + t.Fatalf("exact successor removal: destroyed=%d devices=%d want 2/0", + len(driver.destroyed), len(bus.Devices())) + } +} + +func TestNativeTransportExactRemoveRejectsMalformedOrStaleReceiptWithoutMutation(t *testing.T) { + mutations := []struct { + name string + edit func(*udecx.DeviceRegistration) + }{ + {"device id", func(r *udecx.DeviceRegistration) { r.DeviceID++ }}, + {"device generation", func(r *udecx.DeviceRegistration) { r.Generation++ }}, + {"controller session", func(r *udecx.DeviceRegistration) { r.ControllerSessionID++ }}, + {"controller root", func(r *udecx.DeviceRegistration) { r.ControllerInstanceID = `ROOT\VIIPERUDE\0001` }}, + {"usb port", func(r *udecx.DeviceRegistration) { r.USB20PortNumber++ }}, + {"two ports", func(r *udecx.DeviceRegistration) { r.USB30PortNumber = udecx.MaxDevices + 1 }}, + } + for _, mutation := range mutations { + t.Run(mutation.name, func(t *testing.T) { + driver := &nativeTransportTestDriver{} + host, err := udecx.NewHost(driver, &nativeTransportTestProcessor{}, 1) + if err != nil { + t.Fatal(err) + } + server := New(ServerConfig{ + ConnectionTimeout: time.Second, BusCleanupTimeout: time.Hour, + }, slog.Default(), nil) + if err := server.EnableNativeTransport(host); err != nil { + t.Fatal(err) + } + bus, err := virtualbus.NewWithBusID(98105) + if err != nil { + t.Fatal(err) + } + defer bus.Close() + if err := server.AddBus(bus); err != nil { + t.Fatal(err) + } + _, registration, err := server.AddDeviceToBusWithRegistration( + context.Background(), bus.BusID(), newNativeTransportTestDevice()) + if err != nil || registration == nil { + t.Fatalf("add native device: registration=%v error=%v", registration, err) + } + stale := *registration + mutation.edit(&stale) + err = server.RemoveNativeDeviceExact(bus.BusID(), "1", stale) + if err == nil { + t.Fatal("malformed/stale exact remove unexpectedly succeeded") + } + if len(driver.destroyed) != 0 || len(bus.Devices()) != 1 { + t.Fatalf("rejected exact remove mutated device: destroyed=%d devices=%d", + len(driver.destroyed), len(bus.Devices())) + } + }) + } +} + +func TestNativeDeviceListSnapshotCannotObserveHalfRemovedRegistration(t *testing.T) { + driver := &blockingDestroyNativeTransportTestDriver{ + destroyStarted: make(chan struct{}), allowDestroy: make(chan struct{}), + } + host, err := udecx.NewHost(driver, &nativeTransportTestProcessor{}, 1) + if err != nil { + t.Fatal(err) + } + server := New(ServerConfig{ + ConnectionTimeout: time.Second, BusCleanupTimeout: time.Hour, + }, slog.Default(), nil) + if err := server.EnableNativeTransport(host); err != nil { + t.Fatal(err) + } + bus, err := virtualbus.NewWithBusID(98108) + if err != nil { + t.Fatal(err) + } + defer bus.Close() + if err := server.AddBus(bus); err != nil { + t.Fatal(err) + } + _, registration, err := server.AddDeviceToBusWithRegistration( + context.Background(), bus.BusID(), newNativeTransportTestDevice()) + if err != nil || registration == nil { + t.Fatalf("add native device: registration=%v error=%v", registration, err) + } + + removeDone := make(chan error, 1) + go func() { + removeDone <- server.RemoveNativeDeviceExact(bus.BusID(), "1", *registration) + }() + <-driver.destroyStarted + + snapshotDone := make(chan struct { + snapshots []BusDeviceSnapshot + err error + }, 1) + go func() { + snapshots, snapshotErr := server.SnapshotBusDevices(bus.BusID()) + snapshotDone <- struct { + snapshots []BusDeviceSnapshot + err error + }{snapshots, snapshotErr} + }() + select { + case result := <-snapshotDone: + t.Fatalf("snapshot crossed in-progress removal: snapshots=%v error=%v", + result.snapshots, result.err) + case <-time.After(25 * time.Millisecond): + } + + close(driver.allowDestroy) + if err := <-removeDone; err != nil { + t.Fatal(err) + } + result := <-snapshotDone + if result.err != nil || len(result.snapshots) != 0 { + t.Fatalf("post-removal snapshot=%v error=%v want empty", result.snapshots, result.err) + } + + _, successor, err := server.AddDeviceToBusWithRegistration( + context.Background(), bus.BusID(), newNativeTransportTestDevice()) + if err != nil || successor == nil { + t.Fatalf("add successor: registration=%v error=%v", successor, err) + } + snapshots, err := server.SnapshotBusDevices(bus.BusID()) + if err != nil || len(snapshots) != 1 || snapshots[0].NativeRegistration == nil || + snapshots[0].NativeRegistration.DeviceIdentity != successor.DeviceIdentity { + t.Fatalf("successor snapshot=%v error=%v want exact %+v", + snapshots, err, successor.DeviceIdentity) + } +} + +func TestEmptyBusCleanupCannotRemoveReusedBusInstance(t *testing.T) { + server := New(ServerConfig{BusCleanupTimeout: 75 * time.Millisecond}, slog.Default(), nil) + oldBus, err := virtualbus.NewWithBusID(98106) + if err != nil { + t.Fatal(err) + } + if err := server.AddBus(oldBus); err != nil { + t.Fatal(err) + } + if _, err := oldBus.Add(newNativeTransportTestDevice()); err != nil { + t.Fatal(err) + } + if err := server.RemoveDeviceByID(oldBus.BusID(), "1"); err != nil { + t.Fatal(err) + } + if err := server.RemoveBus(oldBus.BusID()); err != nil { + t.Fatal(err) + } + + successor, err := virtualbus.NewWithBusID(oldBus.BusID()) + if err != nil { + t.Fatal(err) + } + defer successor.Close() + if err := server.AddBus(successor); err != nil { + t.Fatal(err) + } + time.Sleep(150 * time.Millisecond) + if current := server.GetBus(successor.BusID()); current != successor { + t.Fatalf("stale empty-bus timer removed successor bus: current=%p successor=%p", current, successor) + } +} + +func TestEmptyBusRemovalRechecksNonemptyStateUnderLifecycleLock(t *testing.T) { + server := New(ServerConfig{}, slog.Default(), nil) + bus, err := virtualbus.NewWithBusID(98107) + if err != nil { + t.Fatal(err) + } + defer bus.Close() + if err := server.AddBus(bus); err != nil { + t.Fatal(err) + } + if _, err := bus.Add(newNativeTransportTestDevice()); err != nil { + t.Fatal(err) + } + if err := server.removeCurrentBusIfEmpty(bus.BusID(), bus); !errors.Is(err, ErrBusNotEmpty) { + t.Fatalf("nonempty exact bus cleanup error=%v want ErrBusNotEmpty", err) + } + if current := server.GetBus(bus.BusID()); current != bus || len(bus.Devices()) != 1 { + t.Fatalf("nonempty exact bus cleanup mutated bus: current=%p bus=%p devices=%d", + current, bus, len(bus.Devices())) + } +} + +func TestNativeTransportRollsBackVirtualBusWhenPlugInFails(t *testing.T) { + driver := &nativeTransportTestDriver{createErr: errors.New("driver rejected child")} + host, _ := udecx.NewHost(driver, &nativeTransportTestProcessor{}, 1) + server := New(ServerConfig{}, slog.Default(), nil) + if err := server.EnableNativeTransport(host); err != nil { + t.Fatal(err) + } + bus, err := virtualbus.NewWithBusID(98102) + if err != nil { + t.Fatal(err) + } + defer bus.Close() + if err := server.AddBus(bus); err != nil { + t.Fatal(err) + } + + if _, err := server.AddDeviceToBus(context.Background(), bus.BusID(), newNativeTransportTestDevice()); err == nil { + t.Fatal("native plug-in unexpectedly succeeded") + } + if len(bus.Devices()) != 0 { + t.Fatal("failed native plug-in leaked a virtual bus device") + } +} + +func TestNativeTransportRemovalCannotRaceUncommittedPlugIn(t *testing.T) { + driver := &blockingNativeTransportTestDriver{ + createStarted: make(chan struct{}), + allowCreate: make(chan struct{}), + } + host, err := udecx.NewHost(driver, &nativeTransportTestProcessor{}, 1) + if err != nil { + t.Fatal(err) + } + server := New(ServerConfig{ConnectionTimeout: time.Second}, slog.Default(), nil) + if err = server.EnableNativeTransport(host); err != nil { + t.Fatal(err) + } + bus, err := virtualbus.NewWithBusID(98103) + if err != nil { + t.Fatal(err) + } + defer bus.Close() + if err = server.AddBus(bus); err != nil { + t.Fatal(err) + } + + addDone := make(chan error, 1) + go func() { + _, addErr := server.AddDeviceToBus( + context.Background(), bus.BusID(), newNativeTransportTestDevice()) + addDone <- addErr + }() + <-driver.createStarted + + removeStarted := make(chan struct{}) + removeDone := make(chan error, 1) + go func() { + close(removeStarted) + removeDone <- server.RemoveDeviceByID(bus.BusID(), "1") + }() + <-removeStarted + select { + case err = <-removeDone: + t.Fatalf("remove crossed uncommitted native plug-in: %v", err) + case <-time.After(25 * time.Millisecond): + } + + close(driver.allowCreate) + if err = <-addDone; err != nil { + t.Fatal(err) + } + if err = <-removeDone; err != nil { + t.Fatal(err) + } + if len(driver.created) != 1 || len(driver.destroyed) != 1 || len(bus.Devices()) != 0 { + t.Fatalf("created=%d destroyed=%d bus devices=%d want 1/1/0", + len(driver.created), len(driver.destroyed), len(bus.Devices())) + } + server.nativeMu.Lock() + remaining := len(server.nativeIDs) + server.nativeMu.Unlock() + if remaining != 0 { + t.Fatalf("native identity table retained %d entries", remaining) + } +} diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 4e279da3..c54e4e98 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -17,7 +17,9 @@ import ( "syscall" "time" + "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/internal/log" + "github.com/Alia5/VIIPER/internal/transport/udecx" "github.com/Alia5/VIIPER/usb" "github.com/Alia5/VIIPER/usbip" "github.com/Alia5/VIIPER/virtualbus" @@ -197,15 +199,47 @@ type Server struct { config *ServerConfig logger *slog.Logger rawLogger log.RawLogger - busses map[uint32]*virtualbus.VirtualBus - busesMu sync.Mutex - alts map[usb.Device]map[uint8]uint8 - altsMu sync.Mutex - ready chan struct{} - readyOnce sync.Once - ln net.Listener + // lifecycleMu makes publication into the in-memory bus, native UdeCx + // driver, and native identity table one transaction. Device creation and + // removal are infrequent control-plane operations; serializing them avoids + // an orphaned child if removal races a driver plug-in or bus teardown. + lifecycleMu sync.Mutex + busses map[uint32]*virtualbus.VirtualBus + busesMu sync.Mutex + alts map[usb.Device]map[uint8]uint8 + altsMu sync.Mutex + ready chan struct{} + readyOnce sync.Once + ln net.Listener + nativeMu sync.Mutex + native *udecx.Host + nativeIDs map[nativeDeviceKey]udecx.DeviceRegistration } +type nativeDeviceKey struct { + busID uint32 + devID uint32 +} + +// BusDeviceSnapshot is one topology entry and its exact native mutation +// receipt captured under the server lifecycle lock. NativeRegistration is nil +// only when the server is operating in legacy USB/IP mode. +type BusDeviceSnapshot struct { + DeviceMeta virtualbus.DeviceMeta + NativeRegistration *udecx.DeviceRegistration +} + +var ( + // ErrNativeDeviceCorrelationMismatch means the caller's immutable receipt + // no longer identifies the device currently occupying the requested IDs. + // Callers must treat it as a benign stale lifetime and never retry by ID. + ErrNativeDeviceCorrelationMismatch = errors.New("native UDE device correlation mismatch") + ErrInvalidNativeDeviceCorrelation = errors.New("invalid native UDE device correlation") + ErrBusNotFound = errors.New("bus not found") + ErrBusNotEmpty = errors.New("bus is not empty") + ErrBusInstanceChanged = errors.New("bus instance changed") +) + func New(config ServerConfig, logger *slog.Logger, rawLogger log.RawLogger) *Server { return &Server{ config: &config, @@ -214,12 +248,173 @@ func New(config ServerConfig, logger *slog.Logger, rawLogger log.RawLogger) *Ser busses: make(map[uint32]*virtualbus.VirtualBus), alts: make(map[usb.Device]map[uint8]uint8), ready: make(chan struct{}), + nativeIDs: make(map[nativeDeviceKey]udecx.DeviceRegistration), + } +} + +// EnableNativeTransport binds bus lifecycle to a native UdeCx host. It must be +// called before API handlers can add devices. +func (s *Server) EnableNativeTransport(host *udecx.Host) error { + if host == nil { + return errors.New("native UDE host is nil") + } + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + s.nativeMu.Lock() + defer s.nativeMu.Unlock() + if s.native != nil { + return errors.New("native UDE transport is already enabled") + } + s.native = host + return nil +} + +func (s *Server) NativeTransportEnabled() bool { + s.nativeMu.Lock() + defer s.nativeMu.Unlock() + return s.native != nil +} + +func nativeDeviceID(busID, devID uint32) uint64 { + return uint64(busID)<<32 | uint64(devID) +} + +// AddDeviceToBus publishes a device transactionally. A failed native plug-in +// rolls the in-memory bus back before the device becomes visible to clients. +func (s *Server) AddDeviceToBus(ctx context.Context, busID uint32, dev usb.Device) (context.Context, error) { + deviceCtx, _, err := s.AddDeviceToBusWithRegistration(ctx, busID, dev) + return deviceCtx, err +} + +// AddDeviceToBusWithRegistration returns the source-authoritative native +// correlation receipt atomically with publication. USB/IP mode returns nil. +func (s *Server) AddDeviceToBusWithRegistration( + ctx context.Context, busID uint32, dev usb.Device, +) (context.Context, *udecx.DeviceRegistration, error) { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + + bus := s.GetBus(busID) + if bus == nil { + return nil, nil, fmt.Errorf("bus %d not found", busID) + } + deviceCtx, err := bus.Add(dev) + if err != nil { + return nil, nil, err + } + meta := device.GetDeviceMeta(deviceCtx) + if meta == nil { + _ = bus.Remove(dev) + return nil, nil, errors.New("virtual bus returned no device metadata") + } + + s.nativeMu.Lock() + host := s.native + if host == nil { + s.nativeMu.Unlock() + return deviceCtx, nil, nil + } + registration, err := host.RegisterWithCorrelation(ctx, nativeDeviceID(busID, meta.DevID), dev) + if err != nil { + s.nativeMu.Unlock() + _ = bus.Remove(dev) + return nil, nil, fmt.Errorf("plug native UDE device: %w", err) + } + s.nativeIDs[nativeDeviceKey{busID: busID, devID: meta.DevID}] = registration + s.nativeMu.Unlock() + return deviceCtx, ®istration, nil +} + +// SnapshotBusDevices captures the entire bus topology and native registration +// table as one lifecycle transaction. A list response can therefore never pair +// an old device's metadata with a successor's remove-authority receipt after a +// bus/device ID is reused. +func (s *Server) SnapshotBusDevices(busID uint32) ([]BusDeviceSnapshot, error) { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + + s.busesMu.Lock() + bus := s.busses[busID] + s.busesMu.Unlock() + if bus == nil { + return nil, fmt.Errorf("%w: %d", ErrBusNotFound, busID) + } + metas := bus.GetAllDeviceMetas() + snapshots := make([]BusDeviceSnapshot, 0, len(metas)) + + s.nativeMu.Lock() + defer s.nativeMu.Unlock() + nativeTransport := s.native != nil + for _, meta := range metas { + snapshot := BusDeviceSnapshot{DeviceMeta: meta} + if nativeTransport { + registration, ok := s.nativeIDs[nativeDeviceKey{ + busID: meta.Meta.BusID, devID: meta.Meta.DevID, + }] + if !ok { + return nil, fmt.Errorf("%w: native device %d/%d has no receipt", + ErrNativeDeviceCorrelationMismatch, meta.Meta.BusID, meta.Meta.DevID) + } + copy := registration + snapshot.NativeRegistration = © + } + snapshots = append(snapshots, snapshot) } + return snapshots, nil +} + +// NativeDeviceRegistrationForDevice returns a native registration only when +// the caller's exact device object and bus-owned lifetime context are still the +// current occupant of the requested slot. Holding lifecycleMu across the bus +// and native-table observations closes the interval in which removal has +// retired the registration but has not yet cancelled the old device context. +func (s *Server) NativeDeviceRegistrationForDevice( + busID, devID uint32, expectedDevice usb.Device, expectedContext context.Context, +) (udecx.DeviceRegistration, bool) { + if expectedDevice == nil || expectedContext == nil || expectedContext.Done() == nil { + return udecx.DeviceRegistration{}, false + } + + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + + s.busesMu.Lock() + bus := s.busses[busID] + s.busesMu.Unlock() + if bus == nil { + return udecx.DeviceRegistration{}, false + } + + foundExactDevice := false + for _, meta := range bus.GetAllDeviceMetas() { + if meta.Meta.DevID == devID && meta.Dev == expectedDevice { + foundExactDevice = true + break + } + } + if !foundExactDevice { + return udecx.DeviceRegistration{}, false + } + currentContext := bus.GetDeviceContext(expectedDevice) + if currentContext == nil || currentContext.Done() == nil || + currentContext.Done() != expectedContext.Done() { + return udecx.DeviceRegistration{}, false + } + + s.nativeMu.Lock() + defer s.nativeMu.Unlock() + if s.native == nil { + return udecx.DeviceRegistration{}, false + } + registration, ok := s.nativeIDs[nativeDeviceKey{busID: busID, devID: devID}] + return registration, ok } // AddBus registers a bus with the server. If the bus number is already present, // an error is returned. func (s *Server) AddBus(bus *virtualbus.VirtualBus) error { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() s.busesMu.Lock() defer s.busesMu.Unlock() if bus == nil { @@ -234,6 +429,47 @@ func (s *Server) AddBus(bus *virtualbus.VirtualBus) error { // RemoveBus unregisters a bus from the server. func (s *Server) RemoveBus(busID uint32) error { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + return s.removeBus(busID) +} + +// RemoveBusIfEmpty is the safe public native-transport bus cleanup. The empty +// proof and removal are one lifecycle transaction, so a concurrent device add +// cannot turn an empty cleanup request into a non-empty bus teardown. +func (s *Server) RemoveBusIfEmpty(busID uint32) error { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + return s.removeCurrentBusIfEmptyLocked(busID, nil) +} + +func (s *Server) removeCurrentBusIfEmpty( + busID uint32, expected *virtualbus.VirtualBus, +) error { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + return s.removeCurrentBusIfEmptyLocked(busID, expected) +} + +func (s *Server) removeCurrentBusIfEmptyLocked( + busID uint32, expected *virtualbus.VirtualBus, +) error { + s.busesMu.Lock() + current := s.busses[busID] + s.busesMu.Unlock() + if current == nil { + return fmt.Errorf("%w: %d", ErrBusNotFound, busID) + } + if expected != nil && current != expected { + return fmt.Errorf("%w: %d", ErrBusInstanceChanged, busID) + } + if len(current.Devices()) != 0 { + return fmt.Errorf("%w: %d", ErrBusNotEmpty, busID) + } + return s.removeBus(busID) +} + +func (s *Server) removeBus(busID uint32) error { s.busesMu.Lock() bus, ok := s.busses[busID] if !ok { @@ -246,8 +482,10 @@ func (s *Server) RemoveBus(busID uint32) error { if len(devices) > 0 { s.logger.Warn(fmt.Sprintf("Removing non-empty bus %d with %d device(s) attached; removing devices", busID, len(devices))) - for _, dev := range devices { - _ = bus.Remove(dev) + for _, meta := range bus.GetAllDeviceMetas() { + if err := s.removeDevice(busID, meta.Meta.DevID, false, nil); err != nil { + return err + } } } @@ -260,47 +498,156 @@ func (s *Server) RemoveBus(busID uint32) error { // RemoveDeviceByID removes a device by busId and cancels its connections. func (s *Server) RemoveDeviceByID(busID uint32, deviceID string) error { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + s.busesMu.Lock() bus, ok := s.busses[busID] s.busesMu.Unlock() if !ok { - return fmt.Errorf("bus %d not found", busID) + return fmt.Errorf("%w: %d", ErrBusNotFound, busID) + } + parsedDeviceID, err := strconv.ParseUint(deviceID, 10, 32) + if err != nil { + return fmt.Errorf("invalid device id %q: %w", deviceID, err) } - err := bus.RemoveDeviceByID(deviceID) + err = s.removeDevice(busID, uint32(parsedDeviceID), true, nil) if err != nil { return err } + s.scheduleEmptyBusCleanup(busID, bus) + + return nil +} + +// RemoveNativeDeviceExact removes a native child only when the caller's full +// immutable correlation receipt still matches the registration occupying the +// requested bus/device IDs. Comparison and mutation share lifecycleMu and +// nativeMu, closing the lookup-then-remove race across controller restarts. +func (s *Server) RemoveNativeDeviceExact( + busID uint32, deviceID string, expected udecx.DeviceRegistration, +) error { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + + parsedDeviceID, err := strconv.ParseUint(deviceID, 10, 32) + if err != nil || parsedDeviceID == 0 || strconv.FormatUint(parsedDeviceID, 10) != deviceID { + return fmt.Errorf("%w: non-canonical device id %q", ErrInvalidNativeDeviceCorrelation, deviceID) + } + deviceIDNumber := uint32(parsedDeviceID) + if err := validateExpectedNativeDeviceCorrelation(busID, deviceIDNumber, expected); err != nil { + return err + } + + s.busesMu.Lock() + bus := s.busses[busID] + s.busesMu.Unlock() + if bus == nil { + return fmt.Errorf("%w: %d", ErrBusNotFound, busID) + } + if err := s.removeDevice(busID, deviceIDNumber, true, &expected); err != nil { + return err + } + s.scheduleEmptyBusCleanup(busID, bus) + return nil +} + +func validateExpectedNativeDeviceCorrelation( + busID, deviceID uint32, expected udecx.DeviceRegistration, +) error { + validUSB20 := expected.USB20PortNumber != 0 && + expected.USB20PortNumber <= udecx.MaxDevices && expected.USB30PortNumber == 0 + validUSB30 := expected.USB30PortNumber > udecx.MaxDevices && + expected.USB30PortNumber <= 2*udecx.MaxDevices && expected.USB20PortNumber == 0 + if expected.DeviceID == 0 || expected.DeviceID != nativeDeviceID(busID, deviceID) || + expected.Generation == 0 || expected.ControllerSessionID == 0 || + !udecx.IsCanonicalControllerInstanceID(expected.ControllerInstanceID) || + (!validUSB20 && !validUSB30) { + return fmt.Errorf("%w for bus %d device %d", ErrInvalidNativeDeviceCorrelation, busID, deviceID) + } + return nil +} + +func nativeDeviceCorrelationMatches( + current, expected udecx.DeviceRegistration, +) bool { + return current.DeviceIdentity == expected.DeviceIdentity && + current.ControllerSessionID == expected.ControllerSessionID && + strings.EqualFold(current.ControllerInstanceID, expected.ControllerInstanceID) && + current.USB20PortNumber == expected.USB20PortNumber && + current.USB30PortNumber == expected.USB30PortNumber +} + +func (s *Server) scheduleEmptyBusCleanup(busID uint32, bus *virtualbus.VirtualBus) { + remove := func() { + err := s.removeCurrentBusIfEmpty(busID, bus) + switch { + case err == nil: + s.logger.Info("timeout: removed empty bus", "busID", busID) + case errors.Is(err, ErrBusNotFound), errors.Is(err, ErrBusNotEmpty), + errors.Is(err, ErrBusInstanceChanged): + s.logger.Debug("empty bus cleanup retired without mutation", "busID", busID, "error", err) + default: + s.logger.Error("timeout: failed to remove empty bus", "busID", busID, "error", err) + } + } if emptyCtx := bus.GetBusEmptyContext(); emptyCtx != nil { go func() { - slog.Debug("Started bus cleanup goroutine (RemoveDeviceByID)") + slog.Debug("Started exact bus cleanup goroutine") select { case <-emptyCtx.Done(): // Cancelled - a new device was added return case <-time.After(s.config.BusCleanupTimeout): - if b := s.GetBus(busID); b != nil && len(b.Devices()) == 0 { - if err := s.RemoveBus(busID); err != nil { - s.logger.Error("timeout: failed to remove empty bus", "busID", busID, "error", err) - } else { - s.logger.Info("timeout: removed empty bus", "busID", busID) - } - } + remove() } }() } else { s.logger.Debug("No bus empty context; Cleaning bus immediately") - if b := s.GetBus(busID); b != nil && len(b.Devices()) == 0 { - if err := s.RemoveBus(busID); err != nil { - s.logger.Error("timeout: failed to remove empty bus", "busID", busID, "error", err) - } else { - s.logger.Info("timeout: removed empty bus", "busID", busID) - } + go remove() + } +} + +func (s *Server) removeDevice( + busID, deviceID uint32, requireBus bool, expected *udecx.DeviceRegistration, +) error { + s.busesMu.Lock() + bus := s.busses[busID] + s.busesMu.Unlock() + if bus == nil { + if requireBus { + return fmt.Errorf("%w: %d", ErrBusNotFound, busID) } + return nil } - return nil + key := nativeDeviceKey{busID: busID, devID: deviceID} + s.nativeMu.Lock() + host := s.native + registration, registered := s.nativeIDs[key] + if expected != nil && (host == nil || !registered || + !nativeDeviceCorrelationMatches(registration, *expected)) { + s.nativeMu.Unlock() + return fmt.Errorf("%w for bus %d device %d", ErrNativeDeviceCorrelationMismatch, busID, deviceID) + } + if host != nil && registered { + timeout := s.config.ConnectionTimeout + if timeout <= 0 { + timeout = 30 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + err := host.Unregister(ctx, registration.DeviceIdentity) + cancel() + if err != nil { + s.nativeMu.Unlock() + return fmt.Errorf("unplug native UDE device: %w", err) + } + delete(s.nativeIDs, key) + } + s.nativeMu.Unlock() + return bus.RemoveDeviceByID(strconv.FormatUint(uint64(deviceID), 10)) } // ListBuses returns a snapshot of active bus numbers. @@ -780,33 +1127,7 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { case <-ctx.Done(): s.logger.Info("device removed, closing URB stream") busID := owningBus.BusID() - if emptyCtx := owningBus.GetBusEmptyContext(); emptyCtx != nil { - go func() { - slog.Debug("Started bus cleanup goroutine (HandleUrbStream ctx.Done)") - select { - case <-emptyCtx.Done(): - // Cancelled - a new device was added - return - case <-time.After(s.config.BusCleanupTimeout): - if b := s.GetBus(busID); b != nil && len(b.Devices()) == 0 { - if err := s.RemoveBus(busID); err != nil { - s.logger.Error("timeout: failed to remove empty bus", "busID", busID, "error", err) - } else { - s.logger.Info("timeout: removed empty bus", "busID", busID) - } - } - } - }() - } else { - s.logger.Debug("No bus empty context; Cleaning bus immediately") - if b := s.GetBus(busID); b != nil && len(b.Devices()) == 0 { - if err := s.RemoveBus(busID); err != nil { - s.logger.Error("timeout: failed to remove empty bus", "busID", busID, "error", err) - } else { - s.logger.Info("timeout: removed empty bus", "busID", busID) - } - } - } + s.scheduleEmptyBusCleanup(busID, owningBus) return nil default: } @@ -1272,7 +1593,8 @@ func (s *Server) buildIsoInResponse( // USB/IP removes the padding between ISO packets on the wire. The client // restores each packet at its descriptor offset after receiving the compact // payload. Sending the original offset gaps here makes actual_length differ - // from the sum of packet actual lengths, so usbip-win2 discards capture data. + // from the sum of packet actual lengths, so the receiving stack rejects the + // capture data. respData := make([]byte, 0, maximumLen) nextServiceSlot := serviceStart for i, packet := range submitted { @@ -1626,57 +1948,11 @@ func (s *Server) processSubmit(ctx context.Context, dev usb.Device, ep uint32, d } func (s *Server) buildConfigDescriptor(desc *usb.Descriptor) []byte { - var b bytes.Buffer - configValue := desc.Configuration.BConfigurationValue - if configValue == 0 { - configValue = usbConfigValueDefault - } - attrs := desc.Configuration.BMAttributes - if attrs == 0 { - attrs = usbConfigAttrBusPowered - } - maxPower := desc.Configuration.BMaxPower - if maxPower == 0 { - maxPower = usbConfigMaxPower100mA - } - h := usb.ConfigHeader{ - WTotalLength: 0, // to be patched - BNumInterfaces: desc.NumInterfaces(), - BConfigurationValue: configValue, - IConfiguration: desc.Configuration.IConfiguration, - BMAttributes: attrs, - BMaxPower: maxPower, - } - h.Write(&b) - for _, iface := range desc.Interfaces { - for _, iad := range desc.Associations { - if iad.BFirstInterface == iface.Descriptor.BInterfaceNumber && iface.Descriptor.BAlternateSetting == 0 { - iad.Write(&b) - } - } - iface.Descriptor.Write(&b) - if iface.HID != nil { - hd, err := iface.HID.DescriptorBytes() - if err != nil { - s.logger.Error("failed to build HID descriptor", "iface", iface.Descriptor.BInterfaceNumber, "error", err) - // Stall/return minimal config descriptor. - return nil - } - b.Write([]byte(hd)) - } - for _, cd := range iface.ClassDescriptors { - b.Write([]byte(cd.Bytes())) - } - for _, ep := range iface.Endpoints { - ep.Write(&b) - for _, cd := range ep.ClassDescriptors { - b.Write([]byte(cd.Bytes())) - } - } + data, err := desc.ConfigurationBytes() + if err != nil { + s.logger.Error("failed to build configuration descriptor", "error", err) + return nil } - - data := b.Bytes() - binary.LittleEndian.PutUint16(data[2:4], uint16(len(data))) return data } diff --git a/internal/testsupport/latencytrace/trace_windows.go b/internal/testsupport/latencytrace/trace_windows.go new file mode 100644 index 00000000..a3ea158c --- /dev/null +++ b/internal/testsupport/latencytrace/trace_windows.go @@ -0,0 +1,104 @@ +//go:build windows + +// Package latencytrace supplies the Windows clocks and source-controlled +// TraceLogging markers used to correlate each latency JSON sample with ETL. +package latencytrace + +import ( + "errors" + "fmt" + "strings" + "sync/atomic" + "unsafe" + + "github.com/Alia5/VIIPER/_testing/e2e/latency" + "github.com/Microsoft/go-winio/pkg/etw" + "github.com/Microsoft/go-winio/pkg/guid" + "golang.org/x/sys/windows" +) + +var ( + kernel32QPC = windows.NewLazySystemDLL("kernel32.dll") + queryPerformanceCounter = kernel32QPC.NewProc("QueryPerformanceCounter") + queryPerformanceFrequency = kernel32QPC.NewProc("QueryPerformanceFrequency") +) + +func query(proc *windows.LazyProc) (int64, error) { + var value int64 + ok, _, callErr := proc.Call(uintptr(unsafe.Pointer(&value))) + if ok == 0 { + return 0, fmt.Errorf("%s: %w", proc.Name, callErr) + } + if value <= 0 { + return 0, fmt.Errorf("%s returned %d", proc.Name, value) + } + return value, nil +} + +func Counter() (int64, error) { return query(queryPerformanceCounter) } +func Frequency() (int64, error) { return query(queryPerformanceFrequency) } + +type Provider struct { + provider *etw.Provider + enabled atomic.Bool +} + +func applyProviderState(enabled *atomic.Bool, state etw.ProviderState) { + switch state { + case etw.ProviderStateEnable: + enabled.Store(true) + case etw.ProviderStateDisable: + enabled.Store(false) + case etw.ProviderStateCaptureState: + // A capture-state request asks an already enabled provider to emit + // rundown state; it does not disable the session. + } +} + +func NewProvider() (*Provider, error) { + id, err := guid.FromString(strings.Trim(latency.TraceProviderGUID, "{}")) + if err != nil { + return nil, fmt.Errorf("parse latency provider GUID: %w", err) + } + result := &Provider{} + provider, err := etw.NewProviderWithID(latency.TraceProviderName, id, + func(_ guid.GUID, state etw.ProviderState, _ etw.Level, _, _ uint64, _ uintptr) { + applyProviderState(&result.enabled, state) + }) + if err != nil { + return nil, fmt.Errorf("register latency TraceLogging provider: %w", err) + } + result.provider = provider + return result, nil +} + +func (p *Provider) Close() error { + if p == nil || p.provider == nil { + return nil + } + return p.provider.Close() +} + +func (p *Provider) Enabled() bool { return p != nil && p.enabled.Load() } + +func (p *Provider) WriteSample(controller, transport string, block int, sample latency.Sample) error { + if !p.Enabled() { + return errors.New("latency TraceLogging provider is not enabled by the active WPR profile") + } + return p.provider.WriteEvent("TransitionObserved", + []etw.EventOpt{etw.WithLevel(etw.LevelInfo)}, + etw.WithFields( + etw.StringField("MarkerID", sample.MarkerID), + etw.StringField("Controller", controller), + etw.StringField("Transport", transport), + etw.IntField("TransportBlock", block), + etw.IntField("Sequence", sample.Sequence), + etw.StringField("Transition", string(sample.Transition)), + etw.Int64Field("StartQPCTicks", sample.StartQPCTicks), + etw.Int64Field("EndQPCTicks", sample.EndQPCTicks), + etw.Int64Field("MarkerQPCTicks", sample.MarkerQPCTicks), + etw.Int64Field("LatencyNS", sample.LatencyNS), + etw.Uint64Field("SDLEventTimestampNS", sample.EventTimestampNS), + etw.Uint64Field("SDLFenceTimestampNS", sample.SDLFenceTimestampNS), + )) +} diff --git a/internal/testsupport/latencytrace/trace_windows_test.go b/internal/testsupport/latencytrace/trace_windows_test.go new file mode 100644 index 00000000..e9b9ff11 --- /dev/null +++ b/internal/testsupport/latencytrace/trace_windows_test.go @@ -0,0 +1,23 @@ +//go:build windows + +package latencytrace + +import ( + "sync/atomic" + "testing" + + "github.com/Microsoft/go-winio/pkg/etw" +) + +func TestCaptureStateDoesNotDisableActiveProvider(t *testing.T) { + var enabled atomic.Bool + applyProviderState(&enabled, etw.ProviderStateEnable) + applyProviderState(&enabled, etw.ProviderStateCaptureState) + if !enabled.Load() { + t.Fatal("ETW capture-state request disabled the active marker provider") + } + applyProviderState(&enabled, etw.ProviderStateDisable) + if enabled.Load() { + t.Fatal("ETW disable request left the marker provider enabled") + } +} diff --git a/internal/transport/udecx/client_layout_windows_test.go b/internal/transport/udecx/client_layout_windows_test.go new file mode 100644 index 00000000..07a31033 --- /dev/null +++ b/internal/transport/udecx/client_layout_windows_test.go @@ -0,0 +1,14 @@ +//go:build windows + +package udecx + +import ( + "testing" + "unsafe" +) + +func TestIORequestOverlappedRemainsFirstField(t *testing.T) { + if got := unsafe.Offsetof(ioRequest{}.overlapped); got != 0 { + t.Fatalf("unsafe.Offsetof(ioRequest{}.overlapped)=%d want=0", got) + } +} diff --git a/internal/transport/udecx/client_open_windows_test.go b/internal/transport/udecx/client_open_windows_test.go new file mode 100644 index 00000000..4f013ba2 --- /dev/null +++ b/internal/transport/udecx/client_open_windows_test.go @@ -0,0 +1,401 @@ +//go:build windows + +package udecx + +import ( + "context" + "errors" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +const testNativeInterfacePath = `\\?\VIIPER#native#broker` + +func TestNativeBrokerOpenRemainsExclusive(t *testing.T) { + if nativeBrokerShareMode != 0 { + t.Fatalf("native broker CreateFile share mode=%d, want exclusive mode 0", nativeBrokerShareMode) + } +} + +func TestNativeAcquisitionRediscoversUntilPriorOwnerCleanupCompletes(t *testing.T) { + discoverCalls := 0 + openCalls := 0 + waitCalls := 0 + closeCalls := 0 + wantHandle := windows.Handle(0x1234) + + handle, err := acquireNativeController(context.Background(), nativeAcquisitionOps{ + discover: func(context.Context) ([]string, error) { + discoverCalls++ + if discoverCalls == 1 { + return nil, nil + } + return []string{testNativeInterfacePath}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + openCalls++ + if openCalls == 1 { + return windows.InvalidHandle, windows.ERROR_SHARING_VIOLATION + } + return wantHandle, nil + }, + close: func(windows.Handle) error { + closeCalls++ + return nil + }, + wait: func(context.Context, time.Duration) error { + waitCalls++ + return nil + }, + }, nativeAcquisitionPolicy{attempts: 4, interval: time.Millisecond}) + if err != nil { + t.Fatal(err) + } + if handle != wantHandle { + t.Fatalf("handle=%#x want=%#x", handle, wantHandle) + } + if discoverCalls != 3 || openCalls != 2 || waitCalls != 2 || closeCalls != 0 { + t.Fatalf("calls discover=%d open=%d wait=%d close=%d, want 3/2/2/0", + discoverCalls, openCalls, waitCalls, closeCalls) + } +} + +func TestNativeAcquisitionClassifiesBoundedInterfaceAbsence(t *testing.T) { + discoverCalls := 0 + waitCalls := 0 + + _, err := acquireNativeController(context.Background(), nativeAcquisitionOps{ + discover: func(context.Context) ([]string, error) { + discoverCalls++ + return nil, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + t.Fatal("open called without a discovered interface") + return windows.InvalidHandle, nil + }, + close: func(windows.Handle) error { return nil }, + wait: func(context.Context, time.Duration) error { + waitCalls++ + return nil + }, + }, nativeAcquisitionPolicy{attempts: 4, interval: time.Millisecond}) + + var acquisitionErr *AcquisitionError + if !errors.As(err, &acquisitionErr) { + t.Fatalf("error=%v, want *AcquisitionError", err) + } + if acquisitionErr.Kind != AcquisitionInterfaceUnavailable || acquisitionErr.Attempts != 4 { + t.Fatalf("acquisition error=%+v, want unavailable after 4 attempts", acquisitionErr) + } + if !acquisitionErr.Temporary() || !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { + t.Fatalf("error=%v, want temporary ERROR_FILE_NOT_FOUND", err) + } + if discoverCalls != 4 || waitCalls != 3 { + t.Fatalf("calls discover=%d wait=%d, want 4/3", discoverCalls, waitCalls) + } +} + +func TestNativeAcquisitionClassifiesBoundedOwnerCleanup(t *testing.T) { + openCalls := 0 + waitCalls := 0 + + _, err := acquireNativeController(context.Background(), nativeAcquisitionOps{ + discover: func(context.Context) ([]string, error) { + return []string{testNativeInterfacePath}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + openCalls++ + return windows.InvalidHandle, windows.ERROR_SHARING_VIOLATION + }, + close: func(windows.Handle) error { return nil }, + wait: func(context.Context, time.Duration) error { + waitCalls++ + return nil + }, + }, nativeAcquisitionPolicy{attempts: 3, interval: time.Millisecond}) + + var acquisitionErr *AcquisitionError + if !errors.As(err, &acquisitionErr) { + t.Fatalf("error=%v, want *AcquisitionError", err) + } + if acquisitionErr.Kind != AcquisitionOwnerCleanupInProgress || acquisitionErr.Attempts != 3 { + t.Fatalf("acquisition error=%+v, want owner cleanup after 3 attempts", acquisitionErr) + } + if !errors.Is(err, windows.ERROR_SHARING_VIOLATION) { + t.Fatalf("error=%v, want ERROR_SHARING_VIOLATION", err) + } + if openCalls != 3 || waitCalls != 2 { + t.Fatalf("calls open=%d wait=%d, want 3/2", openCalls, waitCalls) + } +} + +func TestNativeAcquisitionReturnsTerminalErrorsWithoutRetry(t *testing.T) { + tests := []struct { + name string + discover func(context.Context) ([]string, error) + open func(context.Context, string) (windows.Handle, error) + want error + wantOpens int + }{ + { + name: "discovery failure", + discover: func(context.Context) ([]string, error) { + return nil, windows.ERROR_INVALID_DATA + }, + open: func(context.Context, string) (windows.Handle, error) { + return windows.InvalidHandle, nil + }, + want: windows.ERROR_INVALID_DATA, + }, + { + name: "access denied", + discover: func(context.Context) ([]string, error) { + return []string{testNativeInterfacePath}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + return windows.InvalidHandle, windows.ERROR_ACCESS_DENIED + }, + want: windows.ERROR_ACCESS_DENIED, + wantOpens: 1, + }, + { + name: "path not found is not interface absence", + discover: func(context.Context) ([]string, error) { + return []string{testNativeInterfacePath}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + return windows.InvalidHandle, windows.ERROR_PATH_NOT_FOUND + }, + want: windows.ERROR_PATH_NOT_FOUND, + wantOpens: 1, + }, + { + name: "device disconnected", + discover: func(context.Context) ([]string, error) { + return []string{testNativeInterfacePath}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + return windows.InvalidHandle, windows.ERROR_DEVICE_NOT_CONNECTED + }, + want: windows.ERROR_DEVICE_NOT_CONNECTED, + wantOpens: 1, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + openCalls := 0 + waitCalls := 0 + _, err := acquireNativeController(context.Background(), nativeAcquisitionOps{ + discover: test.discover, + open: func(ctx context.Context, path string) (windows.Handle, error) { + openCalls++ + return test.open(ctx, path) + }, + close: func(windows.Handle) error { return nil }, + wait: func(context.Context, time.Duration) error { + waitCalls++ + return nil + }, + }, nativeAcquisitionPolicy{attempts: 5, interval: time.Millisecond}) + if !errors.Is(err, test.want) { + t.Fatalf("error=%v, want %v", err, test.want) + } + var acquisitionErr *AcquisitionError + if errors.As(err, &acquisitionErr) { + t.Fatalf("terminal error was classified transient: %+v", acquisitionErr) + } + if openCalls != test.wantOpens || waitCalls != 0 { + t.Fatalf("calls open=%d wait=%d, want %d/0", openCalls, waitCalls, test.wantOpens) + } + }) + } +} + +func TestNativeAcquisitionRejectsAmbiguousOwnershipWithoutRetry(t *testing.T) { + waitCalls := 0 + openCalls := 0 + _, err := acquireNativeController(context.Background(), nativeAcquisitionOps{ + discover: func(context.Context) ([]string, error) { + return []string{"first", "second"}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + openCalls++ + return windows.InvalidHandle, nil + }, + close: func(windows.Handle) error { return nil }, + wait: func(context.Context, time.Duration) error { + waitCalls++ + return nil + }, + }, nativeAcquisitionPolicy{attempts: 5, interval: time.Millisecond}) + if err == nil || openCalls != 0 || waitCalls != 0 { + t.Fatalf("error=%v open=%d wait=%d, want terminal ambiguity", err, openCalls, waitCalls) + } +} + +func TestNativeAcquisitionCancellationInterruptsRetryWait(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + waitCalls := 0 + _, err := acquireNativeController(ctx, nativeAcquisitionOps{ + discover: func(context.Context) ([]string, error) { return nil, nil }, + open: func(context.Context, string) (windows.Handle, error) { + return windows.InvalidHandle, nil + }, + close: func(windows.Handle) error { return nil }, + wait: func(ctx context.Context, _ time.Duration) error { + waitCalls++ + cancel() + <-ctx.Done() + return ctx.Err() + }, + }, nativeAcquisitionPolicy{attempts: 20, interval: time.Hour}) + if !errors.Is(err, context.Canceled) || waitCalls != 1 { + t.Fatalf("error=%v wait=%d, want prompt cancellation on first wait", err, waitCalls) + } +} + +func TestNativeAcquisitionProductionWaitHonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + done := make(chan error, 1) + go func() { done <- waitForNativeAcquisition(ctx, time.Hour) }() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("error=%v, want context canceled", err) + } + case <-time.After(time.Second): + t.Fatal("canceled native acquisition wait did not return promptly") + } +} + +func TestNativeAcquisitionCancellationAfterDiscoveryDoesNotOpen(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + openCalls := 0 + _, err := acquireNativeController(ctx, nativeAcquisitionOps{ + discover: func(context.Context) ([]string, error) { + cancel() + return []string{testNativeInterfacePath}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + openCalls++ + return windows.Handle(0x1234), nil + }, + close: func(windows.Handle) error { return nil }, + wait: func(context.Context, time.Duration) error { return nil }, + }, nativeAcquisitionPolicy{attempts: 2, interval: time.Millisecond}) + if !errors.Is(err, context.Canceled) || openCalls != 0 { + t.Fatalf("error=%v open=%d, want cancellation before open", err, openCalls) + } +} + +func TestNativeAcquisitionCancellationAfterOpenClosesHandle(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + wantHandle := windows.Handle(0x4321) + closeCalls := 0 + _, err := acquireNativeController(ctx, nativeAcquisitionOps{ + discover: func(context.Context) ([]string, error) { + return []string{testNativeInterfacePath}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + cancel() + return wantHandle, nil + }, + close: func(handle windows.Handle) error { + closeCalls++ + if handle != wantHandle { + t.Fatalf("closed handle=%#x want=%#x", handle, wantHandle) + } + return nil + }, + wait: func(context.Context, time.Duration) error { return nil }, + }, nativeAcquisitionPolicy{attempts: 2, interval: time.Millisecond}) + if !errors.Is(err, context.Canceled) || closeCalls != 1 { + t.Fatalf("error=%v closes=%d, want canceled and one close", err, closeCalls) + } +} + +func TestNativeAcquisitionClosesUnexpectedHandleReturnedWithError(t *testing.T) { + badHandle := windows.Handle(0x1001) + wantHandle := windows.Handle(0x1002) + openCalls := 0 + closed := make([]windows.Handle, 0, 1) + + handle, err := acquireNativeController(context.Background(), nativeAcquisitionOps{ + discover: func(context.Context) ([]string, error) { + return []string{testNativeInterfacePath}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + openCalls++ + if openCalls == 1 { + return badHandle, windows.ERROR_SHARING_VIOLATION + } + return wantHandle, nil + }, + close: func(handle windows.Handle) error { + closed = append(closed, handle) + return nil + }, + wait: func(context.Context, time.Duration) error { return nil }, + }, nativeAcquisitionPolicy{attempts: 2, interval: time.Millisecond}) + if err != nil || handle != wantHandle { + t.Fatalf("handle=%#x error=%v, want %#x", handle, err, wantHandle) + } + if len(closed) != 1 || closed[0] != badHandle { + t.Fatalf("closed=%v, want [%#x]", closed, badHandle) + } +} + +func TestNativeAcquisitionTreatsHandleCleanupFailureAsTerminal(t *testing.T) { + closeErr := errors.New("close failed") + waitCalls := 0 + _, err := acquireNativeController(context.Background(), nativeAcquisitionOps{ + discover: func(context.Context) ([]string, error) { + return []string{testNativeInterfacePath}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + return windows.Handle(0x1001), windows.ERROR_SHARING_VIOLATION + }, + close: func(windows.Handle) error { return closeErr }, + wait: func(context.Context, time.Duration) error { + waitCalls++ + return nil + }, + }, nativeAcquisitionPolicy{attempts: 3, interval: time.Millisecond}) + if !errors.Is(err, closeErr) || !errors.Is(err, windows.ERROR_SHARING_VIOLATION) { + t.Fatalf("error=%v, want joined open and handle-cleanup failures", err) + } + var acquisitionErr *AcquisitionError + if errors.As(err, &acquisitionErr) || waitCalls != 0 { + t.Fatalf("error=%v wait=%d, cleanup failure must be terminal", err, waitCalls) + } +} + +func TestNativeAcquisitionRetryClassificationIsExact(t *testing.T) { + tests := []struct { + err error + wantKind AcquisitionErrorKind + }{ + {windows.ERROR_FILE_NOT_FOUND, AcquisitionInterfaceUnavailable}, + {windows.ERROR_SHARING_VIOLATION, AcquisitionOwnerCleanupInProgress}, + {windows.ERROR_PATH_NOT_FOUND, 0}, + {windows.ERROR_DEVICE_NOT_CONNECTED, 0}, + {windows.ERROR_BUSY, 0}, + {windows.ERROR_ACCESS_DENIED, 0}, + } + for _, test := range tests { + got := classifyNativeAcquisitionError(test.err, 7) + if test.wantKind == 0 { + if got != nil { + t.Errorf("error %v classified as %+v, want terminal", test.err, got) + } + continue + } + if got == nil || got.Kind != test.wantKind || got.Attempts != 7 { + t.Errorf("error %v classified as %+v, want kind=%d attempts=7", test.err, got, test.wantKind) + } + } +} diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go new file mode 100644 index 00000000..8c50ed7d --- /dev/null +++ b/internal/transport/udecx/client_windows.go @@ -0,0 +1,1077 @@ +//go:build windows + +package udecx + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/binary" + "errors" + "fmt" + "log/slog" + "runtime" + "strings" + "sync" + "sync/atomic" + "time" + "unicode/utf16" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + crSuccess = 0 + crBufferSmall = 0x1a + cmGetDeviceInterfaceListPresent = 0 + digcfPresent = 0x00000002 + digcfDeviceInterface = 0x00000010 + fileDeviceUnknown = 0x22 + methodBuffered = 0 + methodInDirect = 1 + methodOutDirect = 2 + fileReadData = 1 + fileWriteData = 2 + ioctlBase = 0x900 + ioctlNegotiate = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 0) << 2) | methodBuffered + ioctlCreateDevice = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 1) << 2) | methodBuffered + ioctlDestroyDevice = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 2) << 2) | methodBuffered + ioctlDequeueOperation = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 3) << 2) | methodOutDirect + ioctlCompleteOperation = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 4) << 2) | methodInDirect + ioctlQueryStats = (fileDeviceUnknown << 16) | (fileReadData << 14) | ((ioctlBase + 5) << 2) | methodBuffered + ioctlSubmitInputReport = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 6) << 2) | methodInDirect + ioctlQueryLifecycleTrace = (fileDeviceUnknown << 16) | (fileReadData << 14) | ((ioctlBase + 7) << 2) | methodBuffered + completionPortCloseKey uintptr = ^uintptr(0) + fileSkipCompletionPortOnSuccess byte = 0x1 + requiredCapabilities = AdvertisedCapabilities + // The kernel rechecks asynchronous UdeCx owner cleanup every 100 ms. Match + // that cadence for at most 1.9 seconds, rediscovering the interface before + // every exclusive CreateFile rather than spinning on a stale symbolic link. + nativeAcquisitionAttempts = 20 + nativeAcquisitionRetryInterval = 100 * time.Millisecond + nativeBrokerShareMode = 0 + // Context expiry requests cancellation; it cannot safely bound completion. + // Surface a stuck driver promptly without releasing memory still owned by + // the Windows I/O manager. + cancellationWatchdogInterval = 5 * time.Second +) + +type spDeviceInterfaceData struct { + CbSize uint32 + InterfaceClassGUID windows.GUID + Flags uint32 + Reserved uintptr +} + +type spDeviceInfoData struct { + CbSize uint32 + ClassGUID windows.GUID + DevInst uint32 + Reserved uintptr +} + +type spDeviceInterfaceDetailData struct { + CbSize uint32 + DevicePath [1]uint16 +} + +// AcquisitionErrorKind identifies the only two controller-open failures that +// can resolve without repairing or reconfiguring the installed driver. Keep +// this set deliberately narrow: permission, ABI, ambiguity, and device faults +// must reach the caller immediately rather than being hidden by reconnect +// polling. +type AcquisitionErrorKind uint8 + +const ( + AcquisitionInterfaceUnavailable AcquisitionErrorKind = iota + 1 + AcquisitionOwnerCleanupInProgress +) + +// AcquisitionError reports a transient native-controller acquisition state. +// Temporary always returns true; every other Open error is terminal. +type AcquisitionError struct { + Kind AcquisitionErrorKind + Attempts int + Err error +} + +func (e *AcquisitionError) Error() string { + switch e.Kind { + case AcquisitionInterfaceUnavailable: + return fmt.Sprintf("VIIPER native UDE interface is temporarily unavailable after %d attempt(s): %v", e.Attempts, e.Err) + case AcquisitionOwnerCleanupInProgress: + return fmt.Sprintf("VIIPER native UDE controller ownership cleanup is still in progress after %d attempt(s): %v", e.Attempts, e.Err) + default: + return fmt.Sprintf("VIIPER native UDE controller acquisition failed after %d attempt(s): %v", e.Attempts, e.Err) + } +} + +func (e *AcquisitionError) Unwrap() error { return e.Err } + +func (e *AcquisitionError) Temporary() bool { + return e != nil && (e.Kind == AcquisitionInterfaceUnavailable || + e.Kind == AcquisitionOwnerCleanupInProgress) +} + +type nativeAcquisitionPolicy struct { + attempts int + interval time.Duration +} + +type nativeAcquisitionOps struct { + discover func(context.Context) ([]string, error) + open func(context.Context, string) (windows.Handle, error) + close func(windows.Handle) error + wait func(context.Context, time.Duration) error +} + +var ( + interfaceGUID = windows.GUID{ + Data1: 0x32d03f48, + Data2: 0x725b, + Data3: 0x4baa, + Data4: [8]byte{0x97, 0x0f, 0x7f, 0x5d, 0xe6, 0xc4, 0x46, 0x87}, + } + cfgmgr32 = windows.NewLazySystemDLL("cfgmgr32.dll") + procCMGetDeviceInterfaceListSize = cfgmgr32.NewProc("CM_Get_Device_Interface_List_SizeW") + procCMGetDeviceInterfaceList = cfgmgr32.NewProc("CM_Get_Device_Interface_ListW") + setupapi = windows.NewLazySystemDLL("setupapi.dll") + procSetupDiGetClassDevsW = setupapi.NewProc("SetupDiGetClassDevsW") + procSetupDiEnumDeviceInterfaces = setupapi.NewProc("SetupDiEnumDeviceInterfaces") + procSetupDiGetDeviceInterfaceDetailW = setupapi.NewProc("SetupDiGetDeviceInterfaceDetailW") + procSetupDiGetDeviceInstanceIdW = setupapi.NewProc("SetupDiGetDeviceInstanceIdW") + procSetupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList") + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + procSetFileCompletionModes = kernel32.NewProc("SetFileCompletionNotificationModes") +) + +type Client struct { + mu sync.RWMutex + inflight sync.WaitGroup + handle windows.Handle + completionPort windows.Handle + pumpDone chan struct{} + pumpErr error + closeDone chan struct{} + closeErr error + requestPool sync.Pool + completionPool sync.Pool + slowCancels atomic.Uint64 + // Windows suppresses IOCP packets only for operations that return success + // inline. Pending operations still use the shared completion pump. This + // removes a scheduler/channel round trip from direct input without changing + // cancellation or lifecycle I/O. + skipCompletionPortOnSuccess bool + // driverNonce is the nonzero negotiated tag for this exact exclusive file + // session. The Client and its Host are one-shot, so it cannot be inherited + // by a successor handle or reused by a later worker/publication graph. + driverNonce uint64 + buildIdentity [BuildIdentitySize]byte + controllerInstanceID string + capabilities Capabilities + limits NegotiateResponse + // pendingObserver is a package-private synchronization seam for the + // Windows IOCP stress harness. Production clients leave it nil. It runs + // only after the overlapped issuer has returned ERROR_IO_PENDING, so tests can + // trigger cancellation and close without scheduler sleeps. + pendingObserver func(*ioRequest) + // overlappedIssuer lets the Windows-only stress harness substitute another + // real overlapped kernel request for DeviceIoControl. Production clients + // leave it nil and always issue the native UDE IOCTL below. + overlappedIssuer func(windows.Handle, *windows.Overlapped) (uint32, error) + // These seams let the Windows IOCP harness deterministically model a driver + // that accepts cancellation but delays completion. Production clients use + // CancelIoEx and a real timer and leave the observer nil. + cancelIssuer func(windows.Handle, *windows.Overlapped) error + cancellationWatchdog func() (<-chan time.Time, func()) + slowCancellationObserver func(code uint32, elapsed time.Duration, count uint64) + // Deterministic seams for the committed-create rollback tests. Production + // leaves both nil and uses the exact driver plug-out plus owner-file close. + destroyForCreateRollback func(context.Context, DeviceIdentity) error + closeForCreateRollback func() error +} + +type ioCompletion struct { + transferred uint32 + err error +} + +// CancellationTelemetry reports client-side cancellation acknowledgements +// that exceeded the watchdog interval. It is deliberately outside the ABI +// 1.8 Stats message, so observing a sick driver does not alter the wire format. +type CancellationTelemetry struct { + SlowAcknowledgements uint64 +} + +// overlapped must remain the first field. Windows returns the exact pointer +// submitted to DeviceIoControl through the completion port, allowing the +// single completion pump to recover the owning request without a map or lock. +type ioRequest struct { + overlapped windows.Overlapped + done chan ioCompletion +} + +func Open(ctx context.Context) (*Client, error) { + var selectedInterfacePath string + handle, err := acquireNativeController(ctx, nativeAcquisitionOps{ + discover: discoverNativeInterfacePaths, + open: func(openCtx context.Context, interfacePath string) (windows.Handle, error) { + handle, openErr := openNativeController(openCtx, interfacePath) + if openErr == nil && isUsableNativeHandle(handle) { + selectedInterfacePath = interfacePath + } + return handle, openErr + }, + close: windows.CloseHandle, + wait: waitForNativeAcquisition, + }, nativeAcquisitionPolicy{ + attempts: nativeAcquisitionAttempts, + interval: nativeAcquisitionRetryInterval, + }) + if err != nil { + return nil, err + } + controllerInstanceID, err := controllerInstanceIDForInterfacePath(selectedInterfacePath) + if err != nil { + _ = windows.CloseHandle(handle) + return nil, fmt.Errorf("resolve native UDE controller identity: %w", err) + } + + completionPort, err := windows.CreateIoCompletionPort(handle, 0, 0, 0) + if err != nil { + _ = windows.CloseHandle(handle) + return nil, fmt.Errorf("associate native UDE controller with I/O completion port: %w", err) + } + client := &Client{ + handle: handle, + completionPort: completionPort, + pumpDone: make(chan struct{}), + skipCompletionPortOnSuccess: enableSkipCompletionPortOnSuccess(handle), + controllerInstanceID: controllerInstanceID, + } + client.requestPool.New = func() any { + return &ioRequest{done: make(chan ioCompletion, 1)} + } + client.completionPool.New = func() any { + // Control/state completions stay inside this initial slab. Larger media + // buffers grow once and are then recycled by capacity. + return make([]byte, 0, 4096) + } + go client.runCompletionPort(completionPort) + if err = client.negotiate(ctx); err != nil { + _ = client.Close() + return nil, err + } + return client, nil +} + +func acquireNativeController( + ctx context.Context, + ops nativeAcquisitionOps, + policy nativeAcquisitionPolicy, +) (windows.Handle, error) { + if err := ctx.Err(); err != nil { + return windows.InvalidHandle, err + } + if policy.attempts <= 0 { + return windows.InvalidHandle, errors.New("native UDE acquisition policy has no attempts") + } + if policy.interval <= 0 { + return windows.InvalidHandle, errors.New("native UDE acquisition retry interval must be positive") + } + + var lastTransient *AcquisitionError + for attempt := 1; attempt <= policy.attempts; attempt++ { + paths, err := ops.discover(ctx) + if err != nil { + return windows.InvalidHandle, fmt.Errorf("discover native UDE controller: %w", err) + } + if err := ctx.Err(); err != nil { + return windows.InvalidHandle, err + } + + if len(paths) > 1 { + return windows.InvalidHandle, fmt.Errorf( + "refusing ambiguous native UDE ownership: found %d controller interfaces", len(paths)) + } + if len(paths) == 0 { + lastTransient = &AcquisitionError{ + Kind: AcquisitionInterfaceUnavailable, + Attempts: attempt, + Err: windows.ERROR_FILE_NOT_FOUND, + } + } else { + handle, openErr := ops.open(ctx, paths[0]) + if openErr == nil && isUsableNativeHandle(handle) { + if err := ctx.Err(); err != nil { + if closeErr := ops.close(handle); closeErr != nil { + return windows.InvalidHandle, errors.Join(err, + fmt.Errorf("close canceled native UDE controller handle: %w", closeErr)) + } + return windows.InvalidHandle, err + } + return handle, nil + } + if isUsableNativeHandle(handle) { + if closeErr := ops.close(handle); closeErr != nil { + return windows.InvalidHandle, errors.Join(openErr, + fmt.Errorf("close failed native UDE controller handle: %w", closeErr)) + } + } + if openErr == nil { + openErr = windows.ERROR_INVALID_HANDLE + } + lastTransient = classifyNativeAcquisitionError(openErr, attempt) + if lastTransient == nil { + return windows.InvalidHandle, fmt.Errorf("open native UDE controller: %w", openErr) + } + } + + if attempt == policy.attempts { + return windows.InvalidHandle, lastTransient + } + if err := ops.wait(ctx, policy.interval); err != nil { + return windows.InvalidHandle, err + } + } + + panic("unreachable native UDE acquisition state") +} + +func classifyNativeAcquisitionError(err error, attempt int) *AcquisitionError { + switch { + case errors.Is(err, windows.ERROR_FILE_NOT_FOUND): + return &AcquisitionError{ + Kind: AcquisitionInterfaceUnavailable, + Attempts: attempt, + Err: err, + } + case errors.Is(err, windows.ERROR_SHARING_VIOLATION): + return &AcquisitionError{ + Kind: AcquisitionOwnerCleanupInProgress, + Attempts: attempt, + Err: err, + } + default: + return nil + } +} + +func isUsableNativeHandle(handle windows.Handle) bool { + return handle != 0 && handle != windows.InvalidHandle +} + +func discoverNativeInterfacePaths(ctx context.Context) ([]string, error) { + return discoverInterfacePaths(ctx) +} + +func openNativeController(ctx context.Context, interfacePath string) (windows.Handle, error) { + if err := ctx.Err(); err != nil { + return windows.InvalidHandle, err + } + path, err := windows.UTF16PtrFromString(interfacePath) + if err != nil { + return windows.InvalidHandle, fmt.Errorf("encode native UDE interface path: %w", err) + } + handle, err := windows.CreateFile( + path, + windows.GENERIC_READ|windows.GENERIC_WRITE, + nativeBrokerShareMode, // One broker owns the driver session; never weaken exclusive sharing. + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OVERLAPPED, + 0) + if err != nil { + return handle, err + } + return handle, nil +} + +func waitForNativeAcquisition(ctx context.Context, interval time.Duration) error { + timer := time.NewTimer(interval) + defer timer.Stop() + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func enableSkipCompletionPortOnSuccess(handle windows.Handle) bool { + result, _, _ := procSetFileCompletionModes.Call( + uintptr(handle), uintptr(fileSkipCompletionPortOnSuccess)) + return result != 0 +} + +func (c *Client) Close() error { + c.mu.Lock() + if c.handle == 0 || c.handle == windows.InvalidHandle { + closeDone := c.closeDone + closeErr := c.closeErr + c.mu.Unlock() + if closeDone == nil { + return closeErr + } + <-closeDone + c.mu.RLock() + defer c.mu.RUnlock() + return c.closeErr + } + handle := c.handle + completionPort := c.completionPort + pumpDone := c.pumpDone + closeDone := make(chan struct{}) + c.closeDone = closeDone + c.handle = windows.InvalidHandle + c.completionPort = windows.InvalidHandle + c.mu.Unlock() + + _ = windows.CancelIoEx(handle, nil) + c.inflight.Wait() + var closeErr error + if err := windows.PostQueuedCompletionStatus( + completionPort, 0, completionPortCloseKey, nil); err != nil { + // Closing the port is the documented escape hatch for a waiter when a + // sentinel cannot be posted. The pump records the abandoned wait. + _ = windows.CloseHandle(completionPort) + <-pumpDone + closeErr = errors.Join(windows.CloseHandle(handle), err) + } else { + <-pumpDone + closeErr = errors.Join(windows.CloseHandle(handle), windows.CloseHandle(completionPort)) + } + + c.mu.Lock() + c.closeErr = closeErr + close(closeDone) + c.mu.Unlock() + return closeErr +} + +func (c *Client) runCompletionPort(completionPort windows.Handle) { + defer close(c.pumpDone) + for { + var transferred uint32 + var key uintptr + var overlapped *windows.Overlapped + err := windows.GetQueuedCompletionStatus( + completionPort, &transferred, &key, &overlapped, windows.INFINITE) + if overlapped == nil { + if key == completionPortCloseKey { + return + } + c.mu.Lock() + if err == nil { + c.pumpErr = errors.New("native UDE I/O completion pump stopped on an unexpected packet") + } else { + c.pumpErr = fmt.Errorf("native UDE I/O completion pump stopped: %w", err) + } + c.mu.Unlock() + return + } + request := (*ioRequest)(unsafe.Pointer(overlapped)) + request.done <- ioCompletion{transferred: transferred, err: err} + } +} + +func (c *Client) completionPumpError() error { + c.mu.RLock() + defer c.mu.RUnlock() + if c.pumpErr != nil { + return c.pumpErr + } + return windows.ERROR_INVALID_HANDLE +} + +func completionAfterCancel(result ioCompletion, contextErr error) (uint32, error) { + // CancelIoEx is advisory: Microsoft explicitly permits the operation to + // complete normally when cancellation loses the race. Preserve that kernel + // outcome so create/destroy state cannot diverge across the ABI boundary. + if result.err == nil { + return result.transferred, nil + } + if errors.Is(result.err, windows.ERROR_OPERATION_ABORTED) { + return 0, contextErr + } + return result.transferred, errors.Join(contextErr, result.err) +} + +func completionAfterPumpStop(handle windows.Handle, request *ioRequest) ioCompletion { + // The pump closes pumpDone only after its last channel send. Drain that + // terminal packet first: if both channels were ready, select may have chosen + // pumpDone and leaving request.done populated would poison pooled reuse. + select { + case result := <-request.done: + return result + default: + } + + _ = windows.CancelIoEx(handle, &request.overlapped) + var transferred uint32 + err := windows.GetOverlappedResult(handle, &request.overlapped, &transferred, true) + return ioCompletion{transferred: transferred, err: err} +} + +func (c *Client) cancelOverlapped(handle windows.Handle, request *ioRequest) error { + if c.cancelIssuer != nil { + return c.cancelIssuer(handle, &request.overlapped) + } + return windows.CancelIoEx(handle, &request.overlapped) +} + +func (c *Client) startCancellationWatchdog() (<-chan time.Time, func()) { + if c.cancellationWatchdog != nil { + return c.cancellationWatchdog() + } + timer := time.NewTimer(cancellationWatchdogInterval) + return timer.C, func() { timer.Stop() } +} + +func (c *Client) recordSlowCancellation(code uint32, elapsed time.Duration) { + count := c.slowCancels.Add(1) + if c.slowCancellationObserver != nil { + c.slowCancellationObserver(code, elapsed, count) + return + } + slog.Warn( + "native UDE driver has not acknowledged overlapped I/O cancellation", + "ioctl", fmt.Sprintf("%#x", code), + "elapsed", elapsed.Round(time.Millisecond), + "slow_cancellations", count, + ) +} + +func (c *Client) CancellationTelemetry() CancellationTelemetry { + return CancellationTelemetry{SlowAcknowledgements: c.slowCancels.Load()} +} + +func (c *Client) Capabilities() Capabilities { + c.mu.RLock() + defer c.mu.RUnlock() + return c.capabilities +} + +func (c *Client) Limits() NegotiateResponse { + c.mu.RLock() + defer c.mu.RUnlock() + return c.limits +} + +// BuildIdentity is the identity returned by the currently loaded kernel +// image and accepted during this client's negotiation. It is not inferred +// from an on-disk driver path or copied from broker build metadata. +func (c *Client) BuildIdentity() [BuildIdentitySize]byte { + c.mu.RLock() + defer c.mu.RUnlock() + return c.buildIdentity +} + +func (c *Client) ControllerInstanceID() string { + return c.controllerInstanceID +} + +// ControllerSessionID is the nonzero kernel-authored nonce for this exact +// exclusive controller file session. It is stable across API and stream +// reconnects through the same broker, and changes whenever that controller +// session is recreated. +func (c *Client) ControllerSessionID() uint64 { + return c.driverNonce +} + +func (c *Client) negotiate(ctx context.Context) error { + expectedBuildIdentity, err := ExpectedBuildIdentity() + if err != nil { + return fmt.Errorf("prepare native UDE negotiation: %w", err) + } + var nonceBytes [8]byte + if _, err = rand.Read(nonceBytes[:]); err != nil { + return fmt.Errorf("create native UDE session nonce: %w", err) + } + nonce := binary.LittleEndian.Uint64(nonceBytes[:]) + if nonce == 0 { + nonce = 1 + } + request, err := (NegotiateRequest{ + ClientNonce: nonce, + RequestedCapabilities: requiredCapabilities, + }).MarshalBinary() + if err != nil { + return err + } + response := make([]byte, NegotiateResponseSize) + written, err := c.ioctl(ctx, ioctlNegotiate, request, response) + if err != nil { + return normalizeNegotiationError(err) + } + if written != NegotiateResponseSize { + return fmt.Errorf("negotiate native UDE ABI: response bytes=%d want=%d", written, NegotiateResponseSize) + } + negotiated, err := ParseNegotiateResponse(response) + if err != nil { + return fmt.Errorf("validate native UDE negotiation: %w", err) + } + if err := validateNegotiation(negotiated, nonce, expectedBuildIdentity); err != nil { + return err + } + c.driverNonce = negotiated.DriverNonce + c.capabilities = negotiated.Capabilities + c.buildIdentity = negotiated.BuildIdentity + c.limits = negotiated + return nil +} + +func normalizeNegotiationError(err error) error { + // ABI 1.8 was the first driver that reported ERROR_REVISION_MISMATCH. Older + // native previews reject this service's otherwise internally generated, + // fixed negotiation request as ERROR_INVALID_PARAMETER. A future fixed + // request-size change can surface as either length error before the driver + // reaches its version check. None of these can be caused by user data, so + // they all mean that the service and installed package must be repaired as + // one version-locked unit. + if errors.Is(err, windows.ERROR_REVISION_MISMATCH) || + errors.Is(err, windows.ERROR_INVALID_PARAMETER) || + errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) || + errors.Is(err, windows.ERROR_BAD_LENGTH) { + return fmt.Errorf( + "%w: service expects ABI %d.%d; install the exact native UDE driver packaged with this VIIPER build: %v", + ErrIncompatibleABI, ABIMajor, ABIMinor, err) + } + return fmt.Errorf("negotiate native UDE ABI: %w", err) +} + +func validateNegotiation(negotiated NegotiateResponse, nonce uint64, expectedBuildIdentity [BuildIdentitySize]byte) error { + if negotiated.ClientNonce != nonce || negotiated.DriverNonce == 0 { + return errors.New("validate native UDE negotiation: session nonce mismatch") + } + if negotiated.Capabilities != requiredCapabilities { + return fmt.Errorf("validate native UDE negotiation: exact capabilities %#x required, driver returned %#x", + requiredCapabilities, negotiated.Capabilities) + } + if subtle.ConstantTimeCompare(negotiated.BuildIdentity[:], expectedBuildIdentity[:]) != 1 { + return fmt.Errorf( + "%w: loaded kernel build identity=%s expected=%s; restart or repair the exact signed native package", + ErrIncompatibleABI, BuildIdentityHex(negotiated.BuildIdentity), BuildIdentityHex(expectedBuildIdentity), + ) + } + if negotiated.MaxDevices == 0 || negotiated.MaxDescriptorBytes == 0 || + negotiated.MaxTransferBytes == 0 || negotiated.MaxIsoPackets == 0 || + negotiated.MaxPendingOperations == 0 { + return errors.New("validate native UDE negotiation: driver returned a zero limit") + } + if negotiated.MaxDevices > MaxDevices || negotiated.MaxDescriptorBytes > MaxDescriptorBytes || + negotiated.MaxTransferBytes > MaxTransferBytes || negotiated.MaxIsoPackets > MaxIsoPackets || + negotiated.MaxPendingOperations > MaxPendingOperations { + return errors.New("validate native UDE negotiation: driver limits exceed this client's ABI bounds") + } + return nil +} + +func (c *Client) CreateDevice(ctx context.Context, device CreateDevice) (DeviceRegistration, error) { + limits := c.Limits() + if uint32(len(device.DescriptorData)) > limits.MaxDescriptorBytes || + device.MaxPendingOperations > limits.MaxPendingOperations { + return DeviceRegistration{}, ErrLimitExceeded + } + request, err := device.MarshalBinary() + if err != nil { + return DeviceRegistration{}, err + } + response := make([]byte, CreateDeviceResultSize) + written, err := c.ioctl(ctx, ioctlCreateDevice, request, response) + if err != nil { + return DeviceRegistration{}, err + } + if written != CreateDeviceResultSize { + return DeviceRegistration{}, c.rollbackCommittedCreate(device, + fmt.Errorf("native UDE create receipt: %w", ErrInvalidSize)) + } + result, err := ParseCreateDeviceResult(response) + if err != nil { + return DeviceRegistration{}, c.rollbackCommittedCreate(device, + fmt.Errorf("parse native UDE create receipt: %w", err)) + } + if result.DeviceID != device.DeviceID || result.Generation != device.Generation || + result.Speed != device.Speed { + return DeviceRegistration{}, c.rollbackCommittedCreate(device, + fmt.Errorf("%w: native UDE create receipt does not match request", ErrInvalidRange)) + } + if c.controllerInstanceID == "" { + return DeviceRegistration{}, c.rollbackCommittedCreate(device, + errors.New("native UDE controller instance identity is unavailable")) + } + controllerSessionID := c.ControllerSessionID() + if controllerSessionID == 0 { + return DeviceRegistration{}, c.rollbackCommittedCreate(device, + errors.New("native UDE controller session identity is unavailable")) + } + return DeviceRegistration{ + DeviceIdentity: DeviceIdentity{DeviceID: result.DeviceID, Generation: result.Generation}, + Speed: result.Speed, USB20PortNumber: result.USB20PortNumber, + USB30PortNumber: result.USB30PortNumber, + ControllerSessionID: controllerSessionID, + ControllerInstanceID: c.controllerInstanceID, + }, nil +} + +func (c *Client) rollbackCommittedCreate(device CreateDevice, receiptErr error) error { + cleanupCtx, cancel := context.WithTimeout(context.Background(), terminalCleanupTimeout) + defer cancel() + identity := DeviceIdentity{DeviceID: device.DeviceID, Generation: device.Generation} + var cleanupErr error + if c.destroyForCreateRollback != nil { + cleanupErr = c.destroyForCreateRollback(cleanupCtx, identity) + } else { + cleanupErr = c.DestroyDevice(cleanupCtx, identity) + } + if cleanupErr != nil { + // A malformed successful receipt is already a terminal session fault. If + // its exact plug-out is rejected, close the exclusive file immediately; + // the kernel's owner-cleanup join is the final authority that prevents an + // unrouteable child from surviving this failed registration. + var closeErr error + if c.closeForCreateRollback != nil { + closeErr = c.closeForCreateRollback() + } else { + closeErr = c.Close() + } + rollbackErr := fmt.Errorf( + "rollback native UDE device after invalid create receipt: %w", cleanupErr) + if closeErr != nil { + return errors.Join(receiptErr, rollbackErr, + fmt.Errorf("close native UDE owner session after uncertain create rollback: %w", closeErr)) + } + return errors.Join(receiptErr, rollbackErr) + } + return receiptErr +} + +func (c *Client) DestroyDevice(ctx context.Context, identity DeviceIdentity) error { + request, err := identity.MarshalBinary() + if err != nil { + return err + } + _, err = c.ioctl(ctx, ioctlDestroyDevice, request, nil) + return err +} + +func (c *Client) Dequeue(ctx context.Context, buffer []byte) (Operation, error) { + if len(buffer) < OperationSize { + return Operation{}, ErrShortMessage + } + written, err := c.ioctl(ctx, ioctlDequeueOperation, nil, buffer) + if err != nil { + return Operation{}, err + } + return parseDequeuedOperation(buffer, written) +} + +func (c *Client) Complete(ctx context.Context, completion Completion) error { + limits := c.Limits() + if uint32(len(completion.Payload)) > limits.MaxTransferBytes || + uint32(len(completion.IsoPackets)) > limits.MaxIsoPackets || + completion.TransferLength > limits.MaxTransferBytes { + return ErrLimitExceeded + } + _, _, total, err := completion.wireLayout() + if err != nil { + return err + } + request := c.acquireCompletionBuffer(total) + defer c.releaseCompletionBuffer(request) + if err = completion.marshalBinaryInto(request); err != nil { + return err + } + // METHOD_IN_DIRECT keeps the fixed metadata in the system buffer and maps + // the variable packet/payload tail read-only into the driver. + _, err = c.ioctl(ctx, ioctlCompleteOperation, request[:CompletionSize], request[CompletionSize:]) + return err +} + +func (c *Client) acquireCompletionBuffer(size int) []byte { + var buffer []byte + if pooled := c.completionPool.Get(); pooled != nil { + buffer = pooled.([]byte) + } + if cap(buffer) < size { + return make([]byte, size) + } + return buffer[:size] +} + +func (c *Client) releaseCompletionBuffer(buffer []byte) { + // A negotiated completion cannot exceed the protocol's bounded maximum. + // Retaining the slab avoids high-frequency ISO completion churn while + // keeping worst-case pool entries bounded by the ABI. + c.completionPool.Put(buffer[:0]) +} + +func (c *Client) SubmitInputReport(ctx context.Context, report InputReport) error { + var metadata [InputReportSize]byte + if err := report.marshalMetadata(metadata[:]); err != nil { + return err + } + _, err := c.ioctl(ctx, ioctlSubmitInputReport, metadata[:], report.Payload) + if errors.Is(err, windows.ERROR_BUSY) { + return ErrInputQueueFull + } + return err +} + +func (c *Client) QueryStats(ctx context.Context) (Stats, error) { + buffer := make([]byte, StatsSize) + written, err := c.ioctl(ctx, ioctlQueryStats, nil, buffer) + if err != nil { + return Stats{}, err + } + if written != StatsSize { + return Stats{}, ErrInvalidSize + } + return ParseStats(buffer) +} + +func (c *Client) QueryLifecycleTrace(ctx context.Context) (LifecycleTrace, error) { + buffer := make([]byte, LifecycleTraceSize) + written, err := c.ioctl(ctx, ioctlQueryLifecycleTrace, nil, buffer) + if err != nil { + return LifecycleTrace{}, err + } + if written != LifecycleTraceSize { + return LifecycleTrace{}, ErrInvalidSize + } + return ParseLifecycleTrace(buffer) +} + +func (c *Client) beginIO() (windows.Handle, error) { + c.mu.RLock() + defer c.mu.RUnlock() + if c.handle == 0 || c.handle == windows.InvalidHandle { + return windows.InvalidHandle, windows.ERROR_INVALID_HANDLE + } + if c.pumpErr != nil { + return windows.InvalidHandle, c.pumpErr + } + c.inflight.Add(1) + return c.handle, nil +} + +func (c *Client) ioctl(ctx context.Context, code uint32, input, output []byte) (uint32, error) { + handle, err := c.beginIO() + if err != nil { + return 0, err + } + defer c.inflight.Done() + + request := c.requestPool.Get().(*ioRequest) + request.overlapped = windows.Overlapped{} + select { + case <-request.done: + panic("native UDE I/O request returned to pool with an unread completion") + default: + } + defer func() { + runtime.KeepAlive(input) + runtime.KeepAlive(output) + runtime.KeepAlive(request) + c.requestPool.Put(request) + }() + var inputPointer *byte + var outputPointer *byte + if len(input) != 0 { + inputPointer = &input[0] + } + if len(output) != 0 { + outputPointer = &output[0] + } + var immediate uint32 + if c.overlappedIssuer != nil { + immediate, err = c.overlappedIssuer(handle, &request.overlapped) + } else { + err = windows.DeviceIoControl( + handle, code, + inputPointer, uint32(len(input)), + outputPointer, uint32(len(output)), + &immediate, &request.overlapped) + } + if err == nil && c.skipCompletionPortOnSuccess { + // FILE_SKIP_COMPLETION_PORT_ON_SUCCESS guarantees that no completion + // packet exists for this exact immediate-success operation. Returning + // inline preserves the direct report-submission path and avoids waking the + // completion pump merely to hand the same result back to this goroutine. + return immediate, nil + } + if err != nil && !errors.Is(err, windows.ERROR_IO_PENDING) { + return 0, err + } + if errors.Is(err, windows.ERROR_IO_PENDING) && c.pendingObserver != nil { + c.pendingObserver(request) + } + + select { + case result := <-request.done: + return result.transferred, result.err + case <-ctx.Done(): + contextErr := ctx.Err() + cancelStarted := time.Now() + _ = c.cancelOverlapped(handle, request) + watchdog, stopWatchdog := c.startCancellationWatchdog() + defer stopWatchdog() + + // CancelIoEx only marks the operation for cancellation and explicitly + // forbids freeing or reusing OVERLAPPED until the final completion: + // https://learn.microsoft.com/windows/win32/api/ioapiset/nf-ioapiset-cancelioex + // DeviceIoControl also still owns input/output buffers. A hard return here + // would therefore permit both use-after-return and pooled OVERLAPPED reuse. + // Lifecycle mutations may also complete successfully after cancellation, + // so wait for and preserve the authoritative kernel outcome. The watchdog + // makes a broken cancellation path observable without violating lifetime. + for { + select { + case result := <-request.done: + return completionAfterCancel(result, contextErr) + case <-c.pumpDone: + result := completionAfterPumpStop(handle, request) + return completionAfterCancel(result, errors.Join(contextErr, c.completionPumpError())) + case <-watchdog: + c.recordSlowCancellation(code, time.Since(cancelStarted)) + watchdog = nil + } + } + case <-c.pumpDone: + result := completionAfterPumpStop(handle, request) + return completionAfterCancel(result, c.completionPumpError()) + } +} + +func discoverInterfacePaths(ctx context.Context) ([]string, error) { + for attempt := 0; attempt < 4; attempt++ { + if err := ctx.Err(); err != nil { + return nil, err + } + var required uint32 + ret, _, _ := procCMGetDeviceInterfaceListSize.Call( + uintptr(unsafe.Pointer(&required)), + uintptr(unsafe.Pointer(&interfaceGUID)), + 0, + cmGetDeviceInterfaceListPresent) + if uint32(ret) != crSuccess { + return nil, fmt.Errorf("CM_Get_Device_Interface_List_SizeW returned CONFIGRET %#x", uint32(ret)) + } + if err := ctx.Err(); err != nil { + return nil, err + } + if required <= 1 { + return nil, nil + } + buffer := make([]uint16, required) + ret, _, _ = procCMGetDeviceInterfaceList.Call( + uintptr(unsafe.Pointer(&interfaceGUID)), + 0, + uintptr(unsafe.Pointer(&buffer[0])), + uintptr(required), + cmGetDeviceInterfaceListPresent) + if uint32(ret) == crBufferSmall { + continue + } + if uint32(ret) != crSuccess { + return nil, fmt.Errorf("CM_Get_Device_Interface_ListW returned CONFIGRET %#x", uint32(ret)) + } + if err := ctx.Err(); err != nil { + return nil, err + } + return parseMultiSZ(buffer), nil + } + return nil, errors.New("native UDE interface list changed repeatedly during discovery") +} + +func controllerInstanceIDForInterfacePath(interfacePath string) (string, error) { + if strings.TrimSpace(interfacePath) == "" { + return "", errors.New("native UDE interface path is empty") + } + setValue, _, setErr := procSetupDiGetClassDevsW.Call( + uintptr(unsafe.Pointer(&interfaceGUID)), 0, 0, + uintptr(digcfPresent|digcfDeviceInterface)) + set := windows.Handle(setValue) + if set == windows.InvalidHandle { + if setErr != nil && !errors.Is(setErr, windows.ERROR_SUCCESS) { + return "", fmt.Errorf("SetupDiGetClassDevsW: %w", setErr) + } + return "", errors.New("SetupDiGetClassDevsW returned an invalid handle") + } + defer procSetupDiDestroyDeviceInfoList.Call(uintptr(set)) + + for index := uint32(0); ; index++ { + interfaceData := spDeviceInterfaceData{CbSize: uint32(unsafe.Sizeof(spDeviceInterfaceData{}))} + ok, _, enumErr := procSetupDiEnumDeviceInterfaces.Call( + uintptr(set), 0, uintptr(unsafe.Pointer(&interfaceGUID)), uintptr(index), + uintptr(unsafe.Pointer(&interfaceData))) + if ok == 0 { + if errors.Is(enumErr, windows.ERROR_NO_MORE_ITEMS) { + break + } + return "", fmt.Errorf("SetupDiEnumDeviceInterfaces(%d): %w", index, enumErr) + } + + var required uint32 + _, _, sizeErr := procSetupDiGetDeviceInterfaceDetailW.Call( + uintptr(set), uintptr(unsafe.Pointer(&interfaceData)), 0, 0, + uintptr(unsafe.Pointer(&required)), 0) + if required < uint32(unsafe.Sizeof(spDeviceInterfaceDetailData{})) || + !errors.Is(sizeErr, windows.ERROR_INSUFFICIENT_BUFFER) { + return "", fmt.Errorf("SetupDiGetDeviceInterfaceDetailW size query: %w", sizeErr) + } + detailBytes := make([]byte, required) + detail := (*spDeviceInterfaceDetailData)(unsafe.Pointer(&detailBytes[0])) + detail.CbSize = uint32(unsafe.Sizeof(spDeviceInterfaceDetailData{})) + deviceInfo := spDeviceInfoData{CbSize: uint32(unsafe.Sizeof(spDeviceInfoData{}))} + ok, _, detailErr := procSetupDiGetDeviceInterfaceDetailW.Call( + uintptr(set), uintptr(unsafe.Pointer(&interfaceData)), + uintptr(unsafe.Pointer(detail)), uintptr(required), 0, + uintptr(unsafe.Pointer(&deviceInfo))) + if ok == 0 { + return "", fmt.Errorf("SetupDiGetDeviceInterfaceDetailW: %w", detailErr) + } + candidate := windows.UTF16PtrToString(&detail.DevicePath[0]) + if !strings.EqualFold(candidate, interfacePath) { + continue + } + + var instanceChars uint32 + _, _, instanceSizeErr := procSetupDiGetDeviceInstanceIdW.Call( + uintptr(set), uintptr(unsafe.Pointer(&deviceInfo)), 0, 0, + uintptr(unsafe.Pointer(&instanceChars))) + if instanceChars < 2 || !errors.Is(instanceSizeErr, windows.ERROR_INSUFFICIENT_BUFFER) { + return "", fmt.Errorf("SetupDiGetDeviceInstanceIdW size query: %w", instanceSizeErr) + } + instanceBuffer := make([]uint16, instanceChars) + ok, _, instanceErr := procSetupDiGetDeviceInstanceIdW.Call( + uintptr(set), uintptr(unsafe.Pointer(&deviceInfo)), + uintptr(unsafe.Pointer(&instanceBuffer[0])), uintptr(instanceChars), 0) + if ok == 0 { + return "", fmt.Errorf("SetupDiGetDeviceInstanceIdW: %w", instanceErr) + } + instanceID := windows.UTF16ToString(instanceBuffer) + if !IsCanonicalControllerInstanceID(instanceID) { + return "", errors.New("native UDE controller returned an invalid instance identity") + } + return instanceID, nil + } + return "", errors.New("opened native UDE interface was not present in the verified SetupAPI set") +} + +func parseMultiSZ(raw []uint16) []string { + result := make([]string, 0, 1) + start := 0 + for i, value := range raw { + if value != 0 { + continue + } + if i == start { + break + } + result = append(result, string(utf16.Decode(raw[start:i]))) + start = i + 1 + } + return result +} diff --git a/internal/transport/udecx/client_windows_stress_test.go b/internal/transport/udecx/client_windows_stress_test.go new file mode 100644 index 00000000..c67cc304 --- /dev/null +++ b/internal/transport/udecx/client_windows_stress_test.go @@ -0,0 +1,388 @@ +//go:build windows + +package udecx + +import ( + "context" + "errors" + "fmt" + "os" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +var pipeHarnessSequence atomic.Uint64 + +type controlledDeadline struct { + context.Context + done chan struct{} +} + +func newControlledDeadline() *controlledDeadline { + return &controlledDeadline{Context: context.Background(), done: make(chan struct{})} +} + +func (c *controlledDeadline) Done() <-chan struct{} { return c.done } + +func (c *controlledDeadline) Err() error { + select { + case <-c.done: + return context.DeadlineExceeded + default: + return nil + } +} + +func (c *controlledDeadline) expire() { close(c.done) } + +type ioctlResult struct { + written uint32 + err error +} + +type pipeIOCPHarness struct { + t *testing.T + client *Client + name string + pending chan *ioRequest +} + +// Named-pipe connection requests are genuine cancellable Windows overlapped +// operations. Substituting only the syscall issuer exercises the production +// request pool, IOCP pump, timeout, cancellation, and close state machine +// without requiring an installed UDE driver on hosted CI. +func newPipeIOCPHarness(t *testing.T, name string) *pipeIOCPHarness { + t.Helper() + if name == "" { + name = fmt.Sprintf(`\\.\pipe\viiper-udecx-iocp-%d-%d`, os.Getpid(), pipeHarnessSequence.Add(1)) + } + namePointer, err := windows.UTF16PtrFromString(name) + if err != nil { + t.Fatal(err) + } + handle, err := windows.CreateNamedPipe( + namePointer, + windows.PIPE_ACCESS_DUPLEX|windows.FILE_FLAG_OVERLAPPED|windows.FILE_FLAG_FIRST_PIPE_INSTANCE, + windows.PIPE_TYPE_BYTE|windows.PIPE_READMODE_BYTE|windows.PIPE_WAIT|windows.PIPE_REJECT_REMOTE_CLIENTS, + 1, 4096, 4096, 0, nil) + if err != nil { + t.Fatalf("create IOCP harness pipe: %v", err) + } + port, err := windows.CreateIoCompletionPort(handle, 0, 0, 1) + if err != nil { + _ = windows.CloseHandle(handle) + t.Fatalf("associate IOCP harness pipe: %v", err) + } + + pending := make(chan *ioRequest, 1) + client := &Client{ + handle: handle, + completionPort: port, + pumpDone: make(chan struct{}), + overlappedIssuer: func(handle windows.Handle, overlapped *windows.Overlapped) (uint32, error) { + return 0, windows.ConnectNamedPipe(handle, overlapped) + }, + pendingObserver: func(request *ioRequest) { + pending <- request + }, + } + client.requestPool.New = func() any { + return &ioRequest{done: make(chan ioCompletion, 1)} + } + go client.runCompletionPort(port) + + harness := &pipeIOCPHarness{t: t, client: client, name: name, pending: pending} + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("close IOCP harness: %v", err) + } + }) + return harness +} + +func (h *pipeIOCPHarness) listen(ctx context.Context) <-chan ioctlResult { + h.t.Helper() + result := make(chan ioctlResult, 1) + go func() { + written, err := h.client.ioctl(ctx, 0, nil, nil) + result <- ioctlResult{written: written, err: err} + }() + return result +} + +func (h *pipeIOCPHarness) waitPending(result <-chan ioctlResult) *ioRequest { + h.t.Helper() + select { + case request := <-h.pending: + return request + case completed := <-result: + h.t.Fatalf("overlapped request completed before becoming pending: (%d, %v)", completed.written, completed.err) + return nil + case <-time.After(5 * time.Second): + h.t.Fatal("overlapped request did not become pending") + return nil + } +} + +func (h *pipeIOCPHarness) waitResult(result <-chan ioctlResult) ioctlResult { + h.t.Helper() + select { + case completed := <-result: + return completed + case <-time.After(5 * time.Second): + h.t.Fatal("overlapped request did not finish") + return ioctlResult{} + } +} + +func (h *pipeIOCPHarness) connect() windows.Handle { + h.t.Helper() + namePointer, err := windows.UTF16PtrFromString(h.name) + if err != nil { + h.t.Fatal(err) + } + handle, err := windows.CreateFile( + namePointer, + windows.GENERIC_READ|windows.GENERIC_WRITE, + 0, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + h.t.Fatalf("connect IOCP harness pipe: %v", err) + } + return handle +} + +func TestWindowsClientIOCPStress(t *testing.T) { + previousProcs := runtime.GOMAXPROCS(1) + t.Cleanup(func() { runtime.GOMAXPROCS(previousProcs) }) + + t.Run("deadline cancellation drains packets before request reuse", func(t *testing.T) { + harness := newPipeIOCPHarness(t, "") + var priorRequest *ioRequest + reused := false + for iteration := 0; iteration < 32; iteration++ { + deadline := newControlledDeadline() + result := harness.listen(deadline) + request := harness.waitPending(result) + if request == priorRequest { + reused = true + } + deadline.expire() + completed := harness.waitResult(result) + if completed.written != 0 || !errors.Is(completed.err, context.DeadlineExceeded) { + t.Fatalf("iteration %d cancellation = (%d, %v), want deadline exceeded", iteration, completed.written, completed.err) + } + select { + case stale := <-request.done: + t.Fatalf("iteration %d left stale completion %+v", iteration, stale) + default: + } + priorRequest = request + } + if !reused { + t.Fatal("stress loop did not reuse an OVERLAPPED request") + } + if got := harness.client.CancellationTelemetry().SlowAcknowledgements; got != 0 { + t.Fatalf("prompt cancellation emitted %d slow acknowledgements", got) + } + + result := harness.listen(context.Background()) + harness.waitPending(result) + peer := harness.connect() + completed := harness.waitResult(result) + if completed.err != nil || completed.written != 0 { + t.Fatalf("completion after cancellation stress = (%d, %v), want success", completed.written, completed.err) + } + if err := windows.CloseHandle(peer); err != nil { + t.Fatal(err) + } + }) + + t.Run("stalled cancellation keeps request live and emits watchdog telemetry", func(t *testing.T) { + harness := newPipeIOCPHarness(t, "") + cancelAttempted := make(chan struct{}, 1) + watchdog := make(chan time.Time, 1) + type observation struct { + code uint32 + elapsed time.Duration + count uint64 + } + observed := make(chan observation, 1) + harness.client.cancelIssuer = func(windows.Handle, *windows.Overlapped) error { + cancelAttempted <- struct{}{} + // Model a driver that accepts the request but has not completed the + // IRP yet. The named-pipe OVERLAPPED remains genuinely pending. + return nil + } + harness.client.cancellationWatchdog = func() (<-chan time.Time, func()) { + return watchdog, func() {} + } + harness.client.slowCancellationObserver = func(code uint32, elapsed time.Duration, count uint64) { + observed <- observation{code: code, elapsed: elapsed, count: count} + } + + deadline := newControlledDeadline() + result := harness.listen(deadline) + request := harness.waitPending(result) + deadline.expire() + select { + case <-cancelAttempted: + case <-time.After(5 * time.Second): + t.Fatal("client did not attempt targeted cancellation") + } + watchdog <- time.Now() + select { + case event := <-observed: + if event.code != 0 || event.elapsed < 0 || event.count != 1 { + t.Fatalf("slow-cancellation observation=%+v", event) + } + case <-time.After(5 * time.Second): + t.Fatal("stalled cancellation did not emit watchdog telemetry") + } + if got := harness.client.CancellationTelemetry().SlowAcknowledgements; got != 1 { + t.Fatalf("slow cancellation telemetry=%d want=1", got) + } + select { + case completed := <-result: + t.Fatalf("deadline returned before OVERLAPPED completion: (%d, %v)", completed.written, completed.err) + default: + } + + peer := harness.connect() + completed := harness.waitResult(result) + if completed.err != nil || completed.written != 0 { + t.Fatalf("completion after delayed cancellation = (%d, %v), want kernel success", completed.written, completed.err) + } + select { + case stale := <-request.done: + t.Fatalf("delayed cancellation left stale completion %+v", stale) + default: + } + if err := windows.CloseHandle(peer); err != nil { + t.Fatal(err) + } + }) + + t.Run("close drains pending IO and serializes callers", func(t *testing.T) { + harness := newPipeIOCPHarness(t, "") + result := harness.listen(context.Background()) + harness.waitPending(result) + + const callers = 32 + start := make(chan struct{}) + closeResults := make(chan error, callers) + var ready sync.WaitGroup + ready.Add(callers) + for range callers { + go func() { + ready.Done() + <-start + closeResults <- harness.client.Close() + }() + } + ready.Wait() + close(start) + for range callers { + if err := <-closeResults; err != nil { + t.Fatalf("concurrent Close: %v", err) + } + } + completed := harness.waitResult(result) + if !errors.Is(completed.err, windows.ERROR_OPERATION_ABORTED) { + t.Fatalf("pending IO after Close = %v, want ERROR_OPERATION_ABORTED", completed.err) + } + select { + case <-harness.client.pumpDone: + default: + t.Fatal("Close returned before the IOCP pump stopped") + } + if _, err := harness.client.ioctl(context.Background(), 0, nil, nil); !errors.Is(err, windows.ERROR_INVALID_HANDLE) { + t.Fatalf("IO after Close error=%v want ERROR_INVALID_HANDLE", err) + } + }) + + t.Run("pump failure drains completion and closes admission", func(t *testing.T) { + buffered := &ioRequest{done: make(chan ioCompletion, 1)} + buffered.done <- ioCompletion{transferred: 547} + if completed := completionAfterPumpStop(windows.InvalidHandle, buffered); completed.err != nil || completed.transferred != 547 { + t.Fatalf("buffered completion after pump stop = %+v, want successful 547 bytes", completed) + } + select { + case stale := <-buffered.done: + t.Fatalf("pump-stop drain left stale completion %+v", stale) + default: + } + + harness := newPipeIOCPHarness(t, "") + releaseIssue := make(chan struct{}) + harness.client.pendingObserver = func(request *ioRequest) { + harness.pending <- request + <-releaseIssue + } + result := harness.listen(context.Background()) + harness.waitPending(result) + peer := harness.connect() + if err := windows.PostQueuedCompletionStatus(harness.client.completionPort, 0, 0, nil); err != nil { + t.Fatal(err) + } + select { + case <-harness.client.pumpDone: + case <-time.After(5 * time.Second): + t.Fatal("forced IOCP pump stop did not complete") + } + close(releaseIssue) + completed := harness.waitResult(result) + if completed.err != nil || completed.written != 0 { + t.Fatalf("kernel completion racing pump stop = (%d, %v), want success", completed.written, completed.err) + } + if err := windows.CloseHandle(peer); err != nil { + t.Fatal(err) + } + + second := harness.listen(context.Background()) + completed = harness.waitResult(second) + if completed.err == nil || !strings.Contains(completed.err.Error(), "completion pump stopped") { + t.Fatalf("new IO after forced pump stop error=%v", completed.err) + } + select { + case request := <-harness.pending: + t.Fatalf("pump failure admitted a new kernel request %p", request) + default: + } + }) + + t.Run("reconnect isolates old completion ports", func(t *testing.T) { + name := fmt.Sprintf(`\\.\pipe\viiper-udecx-reconnect-%d-%d`, os.Getpid(), pipeHarnessSequence.Add(1)) + for iteration := 0; iteration < 16; iteration++ { + oldClient := newPipeIOCPHarness(t, name) + oldResult := oldClient.listen(context.Background()) + oldClient.waitPending(oldResult) + if err := oldClient.client.Close(); err != nil { + t.Fatalf("iteration %d close old connection: %v", iteration, err) + } + if completed := oldClient.waitResult(oldResult); !errors.Is(completed.err, windows.ERROR_OPERATION_ABORTED) { + t.Fatalf("iteration %d old connection result=%v", iteration, completed.err) + } + + newClient := newPipeIOCPHarness(t, name) + newResult := newClient.listen(context.Background()) + newClient.waitPending(newResult) + peer := newClient.connect() + completed := newClient.waitResult(newResult) + if completed.err != nil || completed.written != 0 { + t.Fatalf("iteration %d new connection = (%d, %v), want success", iteration, completed.written, completed.err) + } + if err := windows.CloseHandle(peer); err != nil { + t.Fatal(err) + } + if err := newClient.client.Close(); err != nil { + t.Fatalf("iteration %d close new connection: %v", iteration, err) + } + } + }) +} diff --git a/internal/transport/udecx/client_windows_test.go b/internal/transport/udecx/client_windows_test.go new file mode 100644 index 00000000..ce7ffbc3 --- /dev/null +++ b/internal/transport/udecx/client_windows_test.go @@ -0,0 +1,264 @@ +//go:build windows + +package udecx + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +func TestNegotiationABIMismatchExplainsPackageRepair(t *testing.T) { + for _, transportErr := range []error{ + windows.ERROR_REVISION_MISMATCH, + windows.ERROR_INVALID_PARAMETER, // Native preview before ABI 1.8. + windows.ERROR_INSUFFICIENT_BUFFER, + windows.ERROR_BAD_LENGTH, + } { + err := normalizeNegotiationError(transportErr) + if !errors.Is(err, ErrIncompatibleABI) { + t.Errorf("negotiation error for %v = %v, want ErrIncompatibleABI", transportErr, err) + } + for _, phrase := range []string{fmt.Sprintf("ABI %d.%d", ABIMajor, ABIMinor), "exact native UDE driver", "VIIPER build"} { + if !strings.Contains(err.Error(), phrase) { + t.Errorf("negotiation error %q does not contain %q", err, phrase) + } + } + } +} + +func TestCompletionAfterCancelPreservesKernelOutcome(t *testing.T) { + t.Parallel() + + transferred, err := completionAfterCancel(ioCompletion{transferred: 547}, context.Canceled) + if err != nil || transferred != 547 { + t.Fatalf("normal completion after cancellation = (%d, %v), want (547, nil)", transferred, err) + } + + transferred, err = completionAfterCancel( + ioCompletion{err: windows.ERROR_OPERATION_ABORTED}, context.DeadlineExceeded) + if transferred != 0 || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("cancelled completion = (%d, %v), want deadline exceeded", transferred, err) + } + + transportErr := windows.ERROR_INVALID_DATA + transferred, err = completionAfterCancel( + ioCompletion{transferred: 17, err: transportErr}, context.Canceled) + if transferred != 17 || !errors.Is(err, context.Canceled) || !errors.Is(err, transportErr) { + t.Fatalf("failed completion = (%d, %v), want joined context and transport errors", transferred, err) + } +} + +func validTestNegotiation() NegotiateResponse { + identity, err := DeriveBuildIdentity(strings.Repeat("a", 40), DriverPackageVersion, + ABIMajor, ABIMinor, AdvertisedCapabilities) + if err != nil { + panic(err) + } + return NegotiateResponse{ + ClientNonce: 7, + DriverNonce: 8, + Capabilities: requiredCapabilities, + MaxDevices: MaxDevices, + MaxDescriptorBytes: MaxDescriptorBytes, + MaxTransferBytes: MaxTransferBytes, + MaxIsoPackets: MaxIsoPackets, + MaxPendingOperations: MaxPendingOperations, + BuildIdentity: identity, + } +} + +func TestNegotiationRejectsMissingCapabilitiesAndImpossibleLimits(t *testing.T) { + valid := validTestNegotiation() + if err := validateNegotiation(valid, valid.ClientNonce, valid.BuildIdentity); err != nil { + t.Fatal(err) + } + + missingCapability := valid + missingCapability.Capabilities &^= CapabilityIsochronous + if err := validateNegotiation(missingCapability, valid.ClientNonce, valid.BuildIdentity); err == nil { + t.Fatal("negotiation accepted a driver without isochronous support") + } + + extraCapability := valid + extraCapability.Capabilities |= CapabilityStreams + if err := validateNegotiation(extraCapability, valid.ClientNonce, valid.BuildIdentity); err == nil { + t.Fatal("negotiation accepted capabilities outside the identity-bound exact mask") + } + + oversized := valid + oversized.MaxTransferBytes++ + if err := validateNegotiation(oversized, valid.ClientNonce, valid.BuildIdentity); err == nil { + t.Fatal("negotiation accepted a driver limit outside the client ABI") + } +} + +func TestNegotiationNoncesFenceExactFileSession(t *testing.T) { + valid := validTestNegotiation() + if err := validateNegotiation(valid, valid.ClientNonce+1, valid.BuildIdentity); err == nil { + t.Fatal("negotiation accepted a response from a different client-nonce session") + } + zeroDriverNonce := valid + zeroDriverNonce.DriverNonce = 0 + if err := validateNegotiation( + zeroDriverNonce, valid.ClientNonce, valid.BuildIdentity, + ); err == nil { + t.Fatal("negotiation accepted a session without a kernel nonce tag") + } +} + +func TestNegotiationRejectsStaleLoadedKernelDespiteMatchingOnDiskPackageContract(t *testing.T) { + // acceptedPackageIdentity represents the exact source-bound identity from + // the already validated signed on-disk package and protected manifest. The + // negotiate response is deliberately from an older image still loaded by + // Windows, while ABI, capabilities, nonce, and limits all remain identical. + response := validTestNegotiation() + acceptedPackageIdentity := response.BuildIdentity + response.BuildIdentity[0] ^= 0xff + + if err := validateNegotiation( + response, response.ClientNonce, acceptedPackageIdentity, + ); !errors.Is(err, ErrIncompatibleABI) { + t.Fatalf("same-ABI/capability stale loaded kernel error=%v want ErrIncompatibleABI", err) + } +} + +func TestClientRejectsRequestsOutsideNegotiatedLimitsBeforeKernelIO(t *testing.T) { + client := &Client{limits: validTestNegotiation()} + client.limits.MaxDescriptorBytes = 1 + if _, err := client.CreateDevice(context.Background(), CreateDevice{ + DescriptorData: []byte{1, 2}, + }); !errors.Is(err, ErrLimitExceeded) { + t.Fatalf("CreateDevice error=%v want ErrLimitExceeded", err) + } + + client.limits = validTestNegotiation() + client.limits.MaxTransferBytes = 1 + if err := client.Complete(context.Background(), Completion{ + Payload: []byte{1, 2}, + }); !errors.Is(err, ErrLimitExceeded) { + t.Fatalf("Complete error=%v want ErrLimitExceeded", err) + } +} + +func TestCompletionPoolReusesBoundedMediaBuffer(t *testing.T) { + client := &Client{} + first := client.acquireCompletionBuffer(2048) + if len(first) != 2048 || cap(first) < 2048 { + t.Fatalf("first buffer len=%d cap=%d", len(first), cap(first)) + } + first[0] = 0x5a + client.releaseCompletionBuffer(first) + second := client.acquireCompletionBuffer(1024) + if len(second) != 1024 || cap(second) < 2048 { + t.Fatalf("reused buffer len=%d cap=%d", len(second), cap(second)) + } + client.releaseCompletionBuffer(second) +} + +func TestCommittedCreateRollbackClosesUncertainOwnerSession(t *testing.T) { + receiptErr := errors.New("malformed create receipt") + destroyErr := errors.New("exact plug-out rejected") + closeErr := errors.New("owner close reported failure") + device := CreateDevice{DeviceID: 0x100000002, Generation: 7} + + t.Run("exact destroy settles without closing", func(t *testing.T) { + closed := false + client := &Client{ + destroyForCreateRollback: func(ctx context.Context, identity DeviceIdentity) error { + if _, ok := ctx.Deadline(); !ok || identity != (DeviceIdentity{DeviceID: device.DeviceID, Generation: device.Generation}) { + t.Fatalf("rollback context/identity=(%v, %+v)", ctx, identity) + } + return nil + }, + closeForCreateRollback: func() error { closed = true; return nil }, + } + err := client.rollbackCommittedCreate(device, receiptErr) + if !errors.Is(err, receiptErr) || closed { + t.Fatalf("rollback error=%v closed=%t", err, closed) + } + }) + + t.Run("failed destroy closes and joins every authority", func(t *testing.T) { + closeCalls := 0 + client := &Client{ + destroyForCreateRollback: func(context.Context, DeviceIdentity) error { return destroyErr }, + closeForCreateRollback: func() error { closeCalls++; return closeErr }, + } + err := client.rollbackCommittedCreate(device, receiptErr) + if closeCalls != 1 || !errors.Is(err, receiptErr) || !errors.Is(err, destroyErr) || !errors.Is(err, closeErr) { + t.Fatalf("rollback error=%v closeCalls=%d", err, closeCalls) + } + }) +} + +func TestIOCTLCodesMatchPackedHeader(t *testing.T) { + wants := map[string]struct{ got, want uint32 }{ + "negotiate": {ioctlNegotiate, 0x22e400}, + "create": {ioctlCreateDevice, 0x22e404}, + "destroy": {ioctlDestroyDevice, 0x22e408}, + "dequeue": {ioctlDequeueOperation, 0x22e40e}, + "complete": {ioctlCompleteOperation, 0x22e411}, + "stats": {ioctlQueryStats, 0x226414}, + "input": {ioctlSubmitInputReport, 0x22e419}, + "trace": {ioctlQueryLifecycleTrace, 0x22641c}, + } + for name, pair := range wants { + if pair.got != pair.want { + t.Errorf("%s IOCTL=%#x want=%#x", name, pair.got, pair.want) + } + } +} + +func TestParseMultiSZ(t *testing.T) { + raw := []uint16{'a', 'b', 0, 'c', 0, 0, 'x'} + got := parseMultiSZ(raw) + if len(got) != 2 || got[0] != "ab" || got[1] != "c" { + t.Fatalf("parseMultiSZ=%q", got) + } +} + +func TestCompletionPortRoutesExactOverlappedRequest(t *testing.T) { + port, err := windows.CreateIoCompletionPort(windows.InvalidHandle, 0, 0, 1) + if err != nil { + t.Fatal(err) + } + client := &Client{completionPort: port, pumpDone: make(chan struct{})} + go client.runCompletionPort(port) + + request := &ioRequest{done: make(chan ioCompletion, 1)} + if err := windows.PostQueuedCompletionStatus(port, 547, 0, &request.overlapped); err != nil { + t.Fatal(err) + } + select { + case completion := <-request.done: + if completion.err != nil || completion.transferred != 547 { + t.Fatalf("completion=%+v want 547 successful bytes", completion) + } + case <-time.After(time.Second): + t.Fatal("completion pump did not route the exact OVERLAPPED request") + } + + if err := windows.PostQueuedCompletionStatus(port, 0, completionPortCloseKey, nil); err != nil { + t.Fatal(err) + } + select { + case <-client.pumpDone: + case <-time.After(time.Second): + t.Fatal("completion pump did not stop on its sentinel") + } + if err := windows.CloseHandle(port); err != nil { + t.Fatal(err) + } +} + +func TestEnableSkipCompletionPortOnSuccessRejectsInvalidHandle(t *testing.T) { + if enableSkipCompletionPortOnSuccess(windows.InvalidHandle) { + t.Fatal("SetFileCompletionNotificationModes accepted an invalid handle") + } +} diff --git a/internal/transport/udecx/controller_descriptors_test.go b/internal/transport/udecx/controller_descriptors_test.go new file mode 100644 index 00000000..80c0998b --- /dev/null +++ b/internal/transport/udecx/controller_descriptors_test.go @@ -0,0 +1,117 @@ +package udecx_test + +import ( + "bytes" + "testing" + + "github.com/Alia5/VIIPER/device/dualsense" + "github.com/Alia5/VIIPER/device/dualshock4" + "github.com/Alia5/VIIPER/device/ns2pro" + "github.com/Alia5/VIIPER/device/xbox360" + "github.com/Alia5/VIIPER/internal/transport/udecx" + "github.com/Alia5/VIIPER/usb" +) + +func TestNativeSnapshotsPreserveSupportedControllerTopologies(t *testing.T) { + type factory func() (usb.Device, error) + tests := map[string]factory{ + "DualSense": func() (usb.Device, error) { return dualsense.New(nil) }, + "DualSense Edge": func() (usb.Device, error) { + return dualsense.NewEdge(nil) + }, + "DualShock 4": func() (usb.Device, error) { return dualshock4.New(nil) }, + "Xbox 360": func() (usb.Device, error) { return xbox360.New(nil) }, + "Switch 2 Pro": func() (usb.Device, error) { + return ns2pro.New(nil) + }, + } + + for name, construct := range tests { + t.Run(name, func(t *testing.T) { + dev, err := construct() + if err != nil { + t.Fatal(err) + } + desc := dev.GetDescriptor() + if desc == nil { + t.Fatal("controller returned no descriptor") + } + wantConfig, err := desc.ConfigurationBytes() + if err != nil { + t.Fatal(err) + } + snapshot, err := udecx.SnapshotDevice(0x100, 7, dev) + if err != nil { + t.Fatal(err) + } + + var gotDevice, gotConfig []byte + for _, record := range snapshot.Descriptors { + payload := snapshot.DescriptorData[record.Offset : record.Offset+record.Length] + switch record.Kind { + case udecx.DescriptorDevice: + gotDevice = payload + case udecx.DescriptorConfiguration: + gotConfig = payload + } + } + if !bytes.Equal(gotDevice, desc.Bytes()) { + t.Fatalf("native device descriptor changed: got=%x want=%x", gotDevice, desc.Bytes()) + } + if desc.Device.Speed >= uint32(udecx.DeviceSpeedHigh) { + if !bytes.Equal(gotConfig, wantConfig) { + t.Fatalf("native high-speed configuration changed: got=%x want=%x", gotConfig, wantConfig) + } + } else { + assertFullSpeedUdeCxProjection(t, wantConfig, gotConfig) + } + }) + } +} + +func assertFullSpeedUdeCxProjection(t *testing.T, logical, projected []byte) { + t.Helper() + if len(logical) != len(projected) { + t.Fatalf("projected configuration length=%d want=%d", len(projected), len(logical)) + } + restored := append([]byte(nil), projected...) + for offset := 0; offset < len(logical); { + length := int(logical[offset]) + if length < 2 || offset+length > len(logical) || projected[offset] != logical[offset] || + projected[offset+1] != logical[offset+1] { + t.Fatalf("invalid or reordered descriptor at offset %d", offset) + } + if logical[offset+1] == usb.EndpointDescType { + transferType := logical[offset+3] & 0x03 + logicalMax := uint16(logical[offset+4]) | uint16(logical[offset+5])<<8 + logicalInterval := logical[offset+6] + wantMax, wantInterval := logicalMax, logicalInterval + switch transferType { + case 0x01: + if logicalInterval != 1 { + t.Fatalf("production full-speed ISO interval=%d want=1", logicalInterval) + } + wantInterval = 4 + case 0x02: + wantMax = 512 + case 0x03: + microframes := uint32(logicalInterval) * 8 + wantInterval = 1 + for period := uint32(1); wantInterval < 16 && period < microframes; period <<= 1 { + wantInterval++ + } + } + gotMax := uint16(projected[offset+4]) | uint16(projected[offset+5])<<8 + if gotMax != wantMax || projected[offset+6] != wantInterval { + t.Fatalf("endpoint %#x projected max/interval=%d/%d want=%d/%d", + logical[offset+2], gotMax, projected[offset+6], wantMax, wantInterval) + } + restored[offset+4], restored[offset+5] = logical[offset+4], logical[offset+5] + restored[offset+6] = logicalInterval + } + offset += length + } + if !bytes.Equal(restored, logical) { + t.Fatal("native full-speed projection changed fields other than endpoint scheduling") + } +} diff --git a/internal/transport/udecx/deadline_bench_test.go b/internal/transport/udecx/deadline_bench_test.go new file mode 100644 index 00000000..4922c611 --- /dev/null +++ b/internal/transport/udecx/deadline_bench_test.go @@ -0,0 +1,28 @@ +package udecx + +import ( + "context" + "testing" + "time" +) + +func BenchmarkLegacyInputDeadlineContext(b *testing.B) { + parent := context.Background() + b.ReportAllocs() + for b.Loop() { + ctx, cancel := context.WithTimeout(parent, time.Hour) + _ = ctx + cancel() + } +} + +func BenchmarkReusableInputDeadlineTimer(b *testing.B) { + timer := time.NewTimer(time.Hour) + stopInputDeadlineTimer(timer) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + timer.Reset(time.Hour) + stopInputDeadlineTimer(timer) + } +} diff --git a/internal/transport/udecx/descriptors.go b/internal/transport/udecx/descriptors.go new file mode 100644 index 00000000..b52995df --- /dev/null +++ b/internal/transport/udecx/descriptors.go @@ -0,0 +1,159 @@ +package udecx + +import ( + "fmt" + "sort" + + "github.com/Alia5/VIIPER/usb" +) + +const defaultDevicePendingOperations = 512 + +// EndpointDescriptorForNativeUdeCx translates the scheduling fields which +// USBHUB3 interprets using high-speed rules even when UdeCx is told that the +// emulated device is full speed. Without this presentation translation, +// Windows rejects full-speed audio ISO +// bInterval=1 before an URB ever reaches the client driver. +// +// This is a UdeCx presentation adapter only. The controller's logical USB +// descriptor remains unchanged, so the device engine continues to produce and +// consume the proven media payloads at its original cadence. +func EndpointDescriptorForNativeUdeCx(speed DeviceSpeed, endpoint usb.EndpointDescriptor) (usb.EndpointDescriptor, error) { + if endpoint.BMAttributes&0x03 == 0x01 { + switch speed { + case DeviceSpeedLow: + return usb.EndpointDescriptor{}, fmt.Errorf( + "native UdeCx low-speed endpoint 0x%02x cannot be isochronous", + endpoint.BEndpointAddress) + case DeviceSpeedFull: + // Windows supports full-speed isochronous endpoints only at one + // transfer per frame. UdeCx must see the equivalent high-speed + // exponent (eight microframes = bInterval 4). + if endpoint.BInterval != 1 { + return usb.EndpointDescriptor{}, fmt.Errorf( + "native UdeCx full-speed ISO endpoint 0x%02x has unsupported bInterval %d", + endpoint.BEndpointAddress, endpoint.BInterval) + } + endpoint.BInterval = 4 + return endpoint, nil + case DeviceSpeedHigh, DeviceSpeedSuper: + // The Windows USB stack supports HS/SS ISO periods of one, two, + // four, or eight microframes. Larger exponents are not a safe + // UdeCx contract. + if endpoint.BInterval == 0 || endpoint.BInterval > 4 { + return usb.EndpointDescriptor{}, fmt.Errorf( + "native UdeCx high-speed ISO endpoint 0x%02x has unsupported bInterval %d", + endpoint.BEndpointAddress, endpoint.BInterval) + } + } + } + if speed != DeviceSpeedLow && speed != DeviceSpeedFull { + return endpoint, nil + } + + switch endpoint.BMAttributes & 0x03 { + case 0x02: // Bulk: USBHUB3 validates the pipe as high speed. + endpoint.WMaxPacketSize = 512 + case 0x03: // Interrupt: milliseconds -> the next HS microframe exponent. + if endpoint.BInterval == 0 { + return usb.EndpointDescriptor{}, fmt.Errorf( + "native UdeCx full-speed interrupt endpoint 0x%02x has zero bInterval", + endpoint.BEndpointAddress) + } + microframes := uint32(endpoint.BInterval) * 8 + interval := uint8(1) + period := uint32(1) + for interval < 16 && period < microframes { + interval++ + period <<= 1 + } + endpoint.BInterval = interval + } + return endpoint, nil +} + +func configurationDescriptorForNativeUdeCx(desc *usb.Descriptor) ([]byte, error) { + projected := *desc + projected.Interfaces = append([]usb.InterfaceConfig(nil), desc.Interfaces...) + for interfaceIndex := range projected.Interfaces { + logical := desc.Interfaces[interfaceIndex] + projected.Interfaces[interfaceIndex].Endpoints = append( + []usb.EndpointDescriptor(nil), logical.Endpoints...) + for endpointIndex, endpoint := range logical.Endpoints { + adapted, err := EndpointDescriptorForNativeUdeCx( + DeviceSpeed(desc.Device.Speed), endpoint) + if err != nil { + return nil, err + } + projected.Interfaces[interfaceIndex].Endpoints[endpointIndex] = adapted + } + } + return projected.ConfigurationBytes() +} + +// SnapshotDevice builds the immutable descriptor payload used to create one +// native UdeCx child. It consumes the same logical usb.Descriptor object as the +// existing USB/IP server; only the UdeCx-required full-speed endpoint schedule +// projection above may differ in the immutable native snapshot. +func SnapshotDevice(deviceID uint64, generation uint32, dev usb.Device) (CreateDevice, error) { + if dev == nil || dev.GetDescriptor() == nil { + return CreateDevice{}, fmt.Errorf("snapshot native UDE device: nil USB device") + } + desc := dev.GetDescriptor() + deviceDescriptor := desc.Bytes() + configurationDescriptor, err := configurationDescriptorForNativeUdeCx(desc) + if err != nil { + return CreateDevice{}, fmt.Errorf("snapshot native UDE configuration: %w", err) + } + + message := CreateDevice{ + DeviceID: deviceID, + Generation: generation, + Speed: DeviceSpeed(desc.Device.Speed), + MaxPendingOperations: defaultDevicePendingOperations, + } + appendDescriptor := func(kind DescriptorKind, index, languageID uint16, data []byte) { + offset := uint32(len(message.DescriptorData)) + message.DescriptorData = append(message.DescriptorData, data...) + message.Descriptors = append(message.Descriptors, DescriptorRecord{ + Kind: kind, Index: index, LanguageID: languageID, + Offset: offset, Length: uint32(len(data)), + }) + } + appendDescriptor(DescriptorDevice, 0, 0, deviceDescriptor) + appendDescriptor(DescriptorConfiguration, 0, 0, configurationDescriptor) + + indices := make([]int, 0, len(desc.Strings)) + for index := range desc.Strings { + indices = append(indices, int(index)) + } + sort.Ints(indices) + for _, value := range indices { + index := uint8(value) + // The Microsoft OS 1.0 descriptor owns the reserved 0xEE string + // exactly as it does on the USB/IP control path. Never publish a + // conflicting ordinary string at that index. + if uint16(index) == MicrosoftOS10StringIndex && desc.MicrosoftOS10 != nil { + continue + } + languageID := uint16(0x0409) + if index == 0 { + languageID = 0 + } + appendDescriptor( + DescriptorString, uint16(index), languageID, + usb.EncodeStringDescriptor(desc.Strings[index])) + } + if desc.MicrosoftOS10 != nil { + appendDescriptor( + DescriptorString, + MicrosoftOS10StringIndex, + 0, + desc.MicrosoftOS10.StringDescriptor()) + } + + if _, err := message.MarshalBinary(); err != nil { + return CreateDevice{}, fmt.Errorf("snapshot native UDE descriptors: %w", err) + } + return message, nil +} diff --git a/internal/transport/udecx/descriptors_integration_test.go b/internal/transport/udecx/descriptors_integration_test.go new file mode 100644 index 00000000..a0643610 --- /dev/null +++ b/internal/transport/udecx/descriptors_integration_test.go @@ -0,0 +1,74 @@ +package udecx_test + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/Alia5/VIIPER/device/dualsense" + "github.com/Alia5/VIIPER/device/dualshock4" + "github.com/Alia5/VIIPER/device/keyboard" + "github.com/Alia5/VIIPER/device/mouse" + "github.com/Alia5/VIIPER/device/ns2pro" + "github.com/Alia5/VIIPER/device/xbox360" + "github.com/Alia5/VIIPER/internal/transport/udecx" + "github.com/Alia5/VIIPER/usb" +) + +func TestSnapshotDeviceCoversEveryProductionControllerTopology(t *testing.T) { + tests := []struct { + name string + new func() (usb.Device, error) + }{ + {"Xbox360", func() (usb.Device, error) { return xbox360.New(nil) }}, + {"DualShock4", func() (usb.Device, error) { return dualshock4.New(nil) }}, + {"DualSense", func() (usb.Device, error) { return dualsense.New(nil) }}, + {"DualSenseEdge", func() (usb.Device, error) { return dualsense.NewEdge(nil) }}, + {"Switch2Pro", func() (usb.Device, error) { return ns2pro.New(nil) }}, + {"Keyboard", func() (usb.Device, error) { return keyboard.New(nil) }}, + {"Mouse", func() (usb.Device, error) { return mouse.New(nil) }}, + } + + for index, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dev, err := tc.new() + if err != nil { + t.Fatal(err) + } + desc := dev.GetDescriptor() + if desc == nil || len(desc.Interfaces) == 0 { + t.Fatal("production controller has no USB interfaces") + } + snapshot, err := udecx.SnapshotDevice(uint64(index+1), 1, dev) + if err != nil { + t.Fatal(err) + } + raw, err := snapshot.MarshalBinary() + if err != nil { + t.Fatal(err) + } + if got := binary.LittleEndian.Uint32(raw[8:12]); got != uint32(len(raw)) { + t.Fatalf("native create size=%d want=%d", got, len(raw)) + } + + configuration, err := desc.ConfigurationBytes() + if err != nil { + t.Fatal(err) + } + var nativeConfiguration []byte + for _, record := range snapshot.Descriptors { + if record.Kind == udecx.DescriptorConfiguration { + nativeConfiguration = snapshot.DescriptorData[record.Offset : record.Offset+record.Length] + break + } + } + if desc.Device.Speed >= uint32(udecx.DeviceSpeedHigh) { + if !bytes.Equal(nativeConfiguration, configuration) { + t.Fatal("native UDE snapshot changed a high-speed production USB topology") + } + } else if bytes.Equal(nativeConfiguration, configuration) { + t.Fatal("native UDE snapshot omitted the required USBHUB3 full-speed endpoint projection") + } + }) + } +} diff --git a/internal/transport/udecx/descriptors_test.go b/internal/transport/udecx/descriptors_test.go new file mode 100644 index 00000000..eee71aaa --- /dev/null +++ b/internal/transport/udecx/descriptors_test.go @@ -0,0 +1,211 @@ +package udecx + +import ( + "context" + "encoding/binary" + "reflect" + "testing" + + "github.com/Alia5/VIIPER/usb" +) + +type snapshotDevice struct{ descriptor usb.Descriptor } + +func (d *snapshotDevice) HandleTransfer(context.Context, uint32, uint32, []byte) []byte { + return nil +} +func (d *snapshotDevice) GetDescriptor() *usb.Descriptor { return &d.descriptor } +func (d *snapshotDevice) GetDeviceSpecificArgs() map[string]any { return nil } + +func TestSnapshotDevicePreservesDescriptorBytes(t *testing.T) { + dev := &snapshotDevice{descriptor: usb.Descriptor{ + Device: usb.DeviceDescriptor{ + BcdUSB: 0x0200, BMaxPacketSize0: 64, IDVendor: 0x054c, + IDProduct: 0x0ce6, BNumConfigurations: 1, Speed: uint32(DeviceSpeedHigh), + }, + Interfaces: []usb.InterfaceConfig{{ + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0, BNumEndpoints: 1, BInterfaceClass: 3, + }, + Endpoints: []usb.EndpointDescriptor{{ + BEndpointAddress: 0x84, BMAttributes: 3, WMaxPacketSize: 64, BInterval: 4, + }}, + }}, + Strings: map[uint8]string{0: "\u0409", 2: "Controller"}, + }} + + snapshot, err := SnapshotDevice(7, 3, dev) + if err != nil { + t.Fatal(err) + } + if snapshot.DeviceID != 7 || snapshot.Generation != 3 || snapshot.Speed != DeviceSpeedHigh { + t.Fatalf("unexpected identity: %+v", snapshot) + } + if len(snapshot.Descriptors) != 4 { + t.Fatalf("descriptor count=%d want=4", len(snapshot.Descriptors)) + } + if snapshot.Descriptors[0].Kind != DescriptorDevice || snapshot.Descriptors[1].Kind != DescriptorConfiguration || + snapshot.Descriptors[2].Index != 0 || snapshot.Descriptors[3].Index != 2 { + t.Fatalf("unexpected descriptor ordering: %+v", snapshot.Descriptors) + } + config := snapshot.DescriptorData[snapshot.Descriptors[1].Offset : snapshot.Descriptors[1].Offset+snapshot.Descriptors[1].Length] + if got := binary.LittleEndian.Uint16(config[2:4]); got != uint16(len(config)) { + t.Fatalf("configuration total length=%d want=%d", got, len(config)) + } +} + +func TestEndpointDescriptorForNativeUdeCxMatchesUSBHubSchedulingContract(t *testing.T) { + tests := []struct { + name string + speed DeviceSpeed + endpoint usb.EndpointDescriptor + wantMax uint16 + wantIntvl uint8 + wantError bool + }{ + { + name: "full-speed ISO one frame becomes eight microframes", speed: DeviceSpeedFull, + endpoint: usb.EndpointDescriptor{BEndpointAddress: 0x01, BMAttributes: 0x09, WMaxPacketSize: 132, BInterval: 1}, + wantMax: 132, wantIntvl: 4, + }, + { + name: "full-speed one millisecond interrupt", speed: DeviceSpeedFull, + endpoint: usb.EndpointDescriptor{BEndpointAddress: 0x84, BMAttributes: 0x03, WMaxPacketSize: 64, BInterval: 1}, + wantMax: 64, wantIntvl: 4, + }, + { + name: "full-speed five millisecond interrupt rounds up", speed: DeviceSpeedFull, + endpoint: usb.EndpointDescriptor{BEndpointAddress: 0x03, BMAttributes: 0x03, WMaxPacketSize: 64, BInterval: 5}, + wantMax: 64, wantIntvl: 7, + }, + { + name: "full-speed bulk uses USBHUB3 high-speed packet size", speed: DeviceSpeedFull, + endpoint: usb.EndpointDescriptor{BEndpointAddress: 0x82, BMAttributes: 0x02, WMaxPacketSize: 64}, + wantMax: 512, + }, + { + name: "high-speed DualSense ISO is unchanged", speed: DeviceSpeedHigh, + endpoint: usb.EndpointDescriptor{BEndpointAddress: 0x02, BMAttributes: 0x09, WMaxPacketSize: 196, BInterval: 4}, + wantMax: 196, wantIntvl: 4, + }, + { + name: "low-speed ISO is impossible", speed: DeviceSpeedLow, + endpoint: usb.EndpointDescriptor{BEndpointAddress: 0x81, BMAttributes: 0x01, WMaxPacketSize: 8, BInterval: 1}, + wantError: true, + }, + { + name: "Windows rejects non-one-frame full-speed ISO", speed: DeviceSpeedFull, + endpoint: usb.EndpointDescriptor{BEndpointAddress: 0x01, BMAttributes: 0x01, WMaxPacketSize: 32, BInterval: 2}, + wantError: true, + }, + { + name: "Windows rejects high-speed ISO exponent above four", speed: DeviceSpeedHigh, + endpoint: usb.EndpointDescriptor{BEndpointAddress: 0x81, BMAttributes: 0x01, WMaxPacketSize: 32, BInterval: 5}, + wantError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + original := tc.endpoint + got, err := EndpointDescriptorForNativeUdeCx(tc.speed, tc.endpoint) + if (err != nil) != tc.wantError { + t.Fatalf("error=%v wantError=%v", err, tc.wantError) + } + if tc.wantError { + return + } + if got.WMaxPacketSize != tc.wantMax || got.BInterval != tc.wantIntvl { + t.Fatalf("projected endpoint max/intvl=%d/%d want=%d/%d", + got.WMaxPacketSize, got.BInterval, tc.wantMax, tc.wantIntvl) + } + if !reflect.DeepEqual(tc.endpoint, original) { + t.Fatal("logical endpoint descriptor was mutated") + } + }) + } +} + +func TestSnapshotDeviceProjectsFullSpeedEndpointScheduleWithoutMutatingDevice(t *testing.T) { + dev := &snapshotDevice{descriptor: usb.Descriptor{ + Device: usb.DeviceDescriptor{ + BcdUSB: 0x0200, BMaxPacketSize0: 64, IDVendor: 0x054c, + IDProduct: 0x09cc, BNumConfigurations: 1, Speed: uint32(DeviceSpeedFull), + }, + Interfaces: []usb.InterfaceConfig{{ + Descriptor: usb.InterfaceDescriptor{BInterfaceNumber: 0, BNumEndpoints: 2}, + Endpoints: []usb.EndpointDescriptor{ + {BEndpointAddress: 0x84, BMAttributes: 0x03, WMaxPacketSize: 64, BInterval: 1}, + {BEndpointAddress: 0x01, BMAttributes: 0x09, WMaxPacketSize: 132, BInterval: 1}, + }, + }}, + }} + original, err := dev.descriptor.ConfigurationBytes() + if err != nil { + t.Fatal(err) + } + snapshot, err := SnapshotDevice(9, 4, dev) + if err != nil { + t.Fatal(err) + } + config := snapshot.DescriptorData[snapshot.Descriptors[1].Offset:(snapshot.Descriptors[1].Offset + snapshot.Descriptors[1].Length)] + // Configuration (9) + interface (9), then the two seven-byte endpoints. + if config[18+6] != 4 || config[25+6] != 4 { + t.Fatalf("projected endpoint intervals=%d/%d want=4/4", config[24], config[31]) + } + after, err := dev.descriptor.ConfigurationBytes() + if err != nil { + t.Fatal(err) + } + if string(after) != string(original) { + t.Fatal("native snapshot mutated the controller's logical descriptor") + } +} + +func TestSnapshotDevicePublishesMicrosoftOS10ReservedString(t *testing.T) { + msOS := &usb.MicrosoftOS10Descriptor{VendorCode: 0x20, CompatibleID: "WINUSB"} + dev := &snapshotDevice{descriptor: usb.Descriptor{ + Device: usb.DeviceDescriptor{ + BcdUSB: 0x0200, BMaxPacketSize0: 64, IDVendor: 0x057e, + IDProduct: 0x2073, BNumConfigurations: 1, Speed: uint32(DeviceSpeedHigh), + }, + Interfaces: []usb.InterfaceConfig{{ + Descriptor: usb.InterfaceDescriptor{BInterfaceNumber: 0}, + }}, + MicrosoftOS10: msOS, + Strings: map[uint8]string{ + 0: "\u0409", + 1: "Nintendo", + 0xEE: "must not shadow the Microsoft descriptor", + }, + }} + + snapshot, err := SnapshotDevice(8, 2, dev) + if err != nil { + t.Fatal(err) + } + var matches []DescriptorRecord + for _, record := range snapshot.Descriptors { + if record.Kind == DescriptorString && record.Index == MicrosoftOS10StringIndex { + matches = append(matches, record) + } + } + if len(matches) != 1 { + t.Fatalf("Microsoft OS string count=%d want=1", len(matches)) + } + record := matches[0] + if record.LanguageID != 0 { + t.Fatalf("Microsoft OS string language=%#x want=0", record.LanguageID) + } + got := snapshot.DescriptorData[record.Offset : record.Offset+record.Length] + want := msOS.StringDescriptor() + if len(got) != MicrosoftOS10StringLength || MicrosoftOS10VendorCodeOffset != len(got)-2 { + t.Fatalf("Microsoft OS string layout length=%d vendor-offset=%d", len(got), MicrosoftOS10VendorCodeOffset) + } + if got[MicrosoftOS10VendorCodeOffset] != msOS.EffectiveVendorCode() || got[len(got)-1] != 0 { + t.Fatalf("Microsoft OS string vendor/pad=%#x/%#x", got[MicrosoftOS10VendorCodeOffset], got[len(got)-1]) + } + if string(got) != string(want) { + t.Fatalf("Microsoft OS string=%x want=%x", got, want) + } +} diff --git a/internal/transport/udecx/device_barrier.go b/internal/transport/udecx/device_barrier.go new file mode 100644 index 00000000..180f3be7 --- /dev/null +++ b/internal/transport/udecx/device_barrier.go @@ -0,0 +1,235 @@ +package udecx + +import ( + "context" + "errors" + "fmt" + "sync" +) + +var errSupersededByDeviceBarrier = errors.New("native UDE operation superseded by device lifecycle barrier") + +const ( + usbRequestTypeStandardToDevice = 0x00 + usbRequestSetConfiguration = 0x09 +) + +// deviceSequenceBarrier preserves the kernel's generation-scoped +// DeviceSequence while still allowing operations on independent endpoints to +// execute concurrently. Device-wide lifecycle boundaries announce themselves +// as soon as they are dispatched, cancel every earlier callback, join those +// callbacks, and hold every later sequence until the boundary is applied. +type deviceSequenceBarrier struct { + mu sync.Mutex + next uint64 + changed chan struct{} + active map[uint64]*deviceSequenceLease + pendingBarriers map[uint64]struct{} + activeBarrier *deviceSequenceLease +} + +type deviceSequenceLease struct { + gate *deviceSequenceBarrier + sequence uint64 + barrier bool + cancel context.CancelCauseFunc + once sync.Once +} + +func newDeviceSequenceBarrier() *deviceSequenceBarrier { + return &deviceSequenceBarrier{ + next: 1, + changed: make(chan struct{}), + active: make(map[uint64]*deviceSequenceLease), + pendingBarriers: make(map[uint64]struct{}), + } +} + +func isDeviceBarrierOperation(op Operation) bool { + switch op.Kind { + case OperationDeviceReset, OperationDeviceD0Entry, OperationDeviceD0Exit: + return true + case OperationControl: + return isSetConfigurationOperation(op) + default: + return false + } +} + +func isSetConfigurationOperation(op Operation) bool { + return op.Kind == OperationControl && op.EndpointAddress == 0 && + op.SetupPacket[0] == usbRequestTypeStandardToDevice && + op.SetupPacket[1] == usbRequestSetConfiguration +} + +func (g *deviceSequenceBarrier) signalLocked() { + close(g.changed) + g.changed = make(chan struct{}) +} + +func (g *deviceSequenceBarrier) firstPendingBarrierLocked() uint64 { + var first uint64 + for sequence := range g.pendingBarriers { + if first == 0 || sequence < first { + first = sequence + } + } + return first +} + +func (g *deviceSequenceBarrier) announce(sequence uint64) error { + if sequence == 0 { + return nil + } + var cancels []context.CancelCauseFunc + g.mu.Lock() + if _, announced := g.pendingBarriers[sequence]; announced { + g.mu.Unlock() + return nil + } + if sequence < g.next { + next := g.next + g.mu.Unlock() + return fmt.Errorf("device lifecycle sequence %d arrived after sequence %d was admitted", sequence, next-1) + } + g.pendingBarriers[sequence] = struct{}{} + for activeSequence, lease := range g.active { + if activeSequence < sequence { + cancels = append(cancels, lease.cancel) + } + } + g.signalLocked() + g.mu.Unlock() + for _, cancel := range cancels { + cancel(errSupersededByDeviceBarrier) + } + return nil +} + +func (g *deviceSequenceBarrier) withdraw(sequence uint64) { + g.mu.Lock() + if g.activeBarrier == nil || g.activeBarrier.sequence != sequence { + if _, pending := g.pendingBarriers[sequence]; pending { + delete(g.pendingBarriers, sequence) + g.signalLocked() + } + } + g.mu.Unlock() +} + +func (g *deviceSequenceBarrier) enter( + parent context.Context, op Operation, +) (context.Context, *deviceSequenceLease, bool, error) { + if op.DeviceSequence == 0 { + return parent, &deviceSequenceLease{}, false, nil + } + barrier := isDeviceBarrierOperation(op) + if barrier { + if err := g.announce(op.DeviceSequence); err != nil { + return parent, nil, false, err + } + } + + for { + g.mu.Lock() + if op.DeviceSequence < g.next { + next := g.next + g.mu.Unlock() + if barrier { + g.withdraw(op.DeviceSequence) + } + return parent, nil, false, fmt.Errorf( + "device sequence regressed from %d to %d", next, op.DeviceSequence) + } + if op.DeviceSequence != g.next || g.activeBarrier != nil { + changed := g.changed + g.mu.Unlock() + select { + case <-changed: + continue + case <-parent.Done(): + if barrier { + g.withdraw(op.DeviceSequence) + } + return parent, nil, false, parent.Err() + } + } + + if !barrier { + firstBarrier := g.firstPendingBarrierLocked() + if firstBarrier != 0 && op.DeviceSequence <= firstBarrier { + if op.DeviceSequence == firstBarrier { + g.mu.Unlock() + return parent, nil, false, fmt.Errorf( + "device sequence %d was announced as both lifecycle barrier and ordinary work", + op.DeviceSequence) + } + ctx, cancel := context.WithCancelCause(parent) + lease := &deviceSequenceLease{ + gate: g, sequence: op.DeviceSequence, cancel: cancel, + } + g.active[op.DeviceSequence] = lease + g.next++ + g.signalLocked() + g.mu.Unlock() + // Announcement predated admission, so this callback must never + // enter the processor. Keep a canceled lease active until its host- + // side token/management cleanup joins; the barrier waits on it just + // like a callback that was already running when announced. + cancel(errSupersededByDeviceBarrier) + return ctx, lease, true, nil + } + ctx, cancel := context.WithCancelCause(parent) + lease := &deviceSequenceLease{ + gate: g, sequence: op.DeviceSequence, cancel: cancel, + } + g.active[op.DeviceSequence] = lease + g.next++ + g.signalLocked() + g.mu.Unlock() + return ctx, lease, false, nil + } + + ctx, cancel := context.WithCancelCause(parent) + lease := &deviceSequenceLease{ + gate: g, sequence: op.DeviceSequence, barrier: true, cancel: cancel, + } + g.activeBarrier = lease + g.next++ + g.signalLocked() + for len(g.active) != 0 { + changed := g.changed + g.mu.Unlock() + <-changed + g.mu.Lock() + } + g.mu.Unlock() + if err := parent.Err(); err != nil { + lease.finish() + return parent, nil, false, err + } + return ctx, lease, false, nil + } +} + +func (l *deviceSequenceLease) finish() { + if l == nil || l.gate == nil { + return + } + l.once.Do(func() { + l.cancel(context.Canceled) + g := l.gate + g.mu.Lock() + if l.barrier { + if g.activeBarrier == l { + g.activeBarrier = nil + delete(g.pendingBarriers, l.sequence) + g.signalLocked() + } + } else if g.active[l.sequence] == l { + delete(g.active, l.sequence) + g.signalLocked() + } + g.mu.Unlock() + }) +} diff --git a/internal/transport/udecx/driver_descriptor_contract_test.go b/internal/transport/udecx/driver_descriptor_contract_test.go new file mode 100644 index 00000000..a0ae24ad --- /dev/null +++ b/internal/transport/udecx/driver_descriptor_contract_test.go @@ -0,0 +1,29 @@ +package udecx + +import ( + "strings" + "testing" +) + +func TestNativeDriverValidatesUdeCxEndpointSchedulesBeforePublication(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + validator := normalizedContract(nativeCFunction(t, device, "ViiperValidateEndpointSchedules")) + for _, required := range []string{ + "transferType = item[3] & USB_ENDPOINT_TYPE_MASK;", + "if (transferType == USB_ENDPOINT_TYPE_ISOCHRONOUS)", + "if (Speed == 1) { return FALSE; }", + "if (Speed == 2)", + "if (item[6] != 4) { return FALSE; }", + "else if (item[6] == 0 || item[6] > 4)", + "USB_ENDPOINT_TYPE_INTERRUPT && (item[6] == 0 || item[6] > 16)", + } { + if !strings.Contains(validator, required) { + t.Fatalf("native descriptor schedule gate lost %q in:\n%s", required, validator) + } + } + + create := normalizedContract(nativeCFunction(t, device, "ViiperValidateCreateDevice")) + requireContractOrder(t, create, + "!ViiperValidateDescriptorChain( descriptor, record->Length, USB_CONFIGURATION_DESCRIPTOR_TYPE) ||", + "!ViiperValidateEndpointSchedules( descriptor, record->Length, Input->Speed)") +} diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go new file mode 100644 index 00000000..4c424326 --- /dev/null +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -0,0 +1,1242 @@ +package udecx + +import ( + "regexp" + "strings" + "testing" +) + +func TestNativeControllerNamesDeviceBeforeAssigningSecurity(t *testing.T) { + controller := normalizedContract(nativeContractSource(t, + "native", "udecx", "driver", "Controller.c")) + requireContractOrder(t, controller, + "WdfDeviceInitSetCharacteristics( DeviceInit, FILE_DEVICE_SECURE_OPEN | FILE_AUTOGENERATED_DEVICE_NAME, FALSE);", + "status = WdfDeviceInitAssignSDDLString(DeviceInit, &sddl);") +} + +func TestNativeConfigurationSelectionDoesNotEnterResetProtocol(t *testing.T) { + device := normalizedContract(nativeCFunction(t, + nativeContractSource(t, "native", "udecx", "driver", "Device.c"), + "ViiperEvtEndpointsConfigure")) + requireContractOrder(t, device, + "case UdecxEndpointsConfigureTypeDeviceInitialize:", + "ConfigureParams->EndpointsToConfigureCount", + "ViiperActivateEndpoint( ConfigureParams->EndpointsToConfigure[endpointIndex]);", + "WdfRequestComplete(Request, STATUS_SUCCESS);", + "return;", + "case UdecxEndpointsConfigureTypeDeviceConfigurationChange:", + "ConfigureParams->EndpointsToConfigureCount", + "ViiperActivateEndpoint( ConfigureParams->EndpointsToConfigure[endpointIndex]);", + "WdfRequestComplete(Request, STATUS_SUCCESS);", + "return;", + "case UdecxEndpointsConfigureTypeInterfaceSettingChange:") + if strings.Contains(device, "ViiperUdeOperationDeviceReset") || + strings.Contains(device, "ViiperBeginAcknowledgedDeviceReset") { + t.Fatal("dynamic endpoint configuration can enter the device-reset protocol") + } +} + +func TestNativePostEnumerationResetDoesNotBlockEnumerationOnUserMode(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + create := normalizedContract(nativeCFunction(t, device, "ViiperCreateVirtualDevice")) + + if strings.Contains(create, "callbacks.EvtUsbDeviceReset") || + strings.Contains(device, "ViiperEvtUsbDeviceReset") || + strings.Contains(header, "EVT_UDECX_USB_DEVICE_POST_ENUMERATION_RESET") { + t.Fatal("post-enumeration reset can block child enumeration on a user-mode acknowledgement") + } + configure := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointsConfigure")) + requireContractOrder(t, configure, + "case UdecxEndpointsConfigureTypeDeviceConfigurationChange:", + "WdfRequestComplete(Request, STATUS_SUCCESS);", + "return;") +} + +func TestNativeInitialAttachOpensWorkingStateBeforePlugIn(t *testing.T) { + device := normalizedContract(nativeCFunction(t, + nativeContractSource(t, "native", "udecx", "driver", "Device.c"), + "ViiperCreateVirtualDevice")) + requireContractOrder(t, device, + "InterlockedExchange(&deviceContext->InD0, TRUE);", + "ViiperClaimDeviceSlot(", + "UdecxUsbDevicePlugIn(device, &plugOptions);") +} + +func TestNativeSuperSpeedPortsUseControllerGlobalNumbering(t *testing.T) { + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + device := normalizedContract(nativeCFunction(t, + nativeContractSource(t, "native", "udecx", "driver", "Device.c"), + "ViiperCreateVirtualDevice")) + + for _, required := range []string{ + "#define VIIPER_UDE_USB20_PORT_COUNT VIIPER_UDE_MAX_DEVICES", + "#define VIIPER_UDE_USB30_PORT_COUNT VIIPER_UDE_MAX_DEVICES", + } { + if !strings.Contains(header, required) { + t.Fatalf("native controller topology lost %q", required) + } + } + for _, required := range []string{ + "udeConfig.NumberOfUsb20Ports = (USHORT)VIIPER_UDE_USB20_PORT_COUNT;", + "udeConfig.NumberOfUsb30Ports = (USHORT)VIIPER_UDE_USB30_PORT_COUNT;", + } { + if !strings.Contains(controller, required) { + t.Fatalf("native controller creation lost %q", required) + } + } + requireContractOrder(t, device, + "if (speed == UdecxUsbSuperSpeed)", + "plugOptions.Usb30PortNumber = (USHORT)(VIIPER_UDE_USB20_PORT_COUNT + slot + 1);", + "else", + "plugOptions.Usb20PortNumber = (USHORT)(slot + 1);") + + const usb20Ports = MaxDevices + for slot := 0; slot < MaxDevices; slot++ { + usb20Port := slot + 1 + usb30Port := usb20Ports + slot + 1 + if usb20Port < 1 || usb20Port > MaxDevices || + usb30Port < MaxDevices+1 || usb30Port > 2*MaxDevices { + t.Fatalf("slot %d maps to USB2=%d USB3=%d", slot, usb20Port, usb30Port) + } + } +} + +func TestNativeControllerEstablishesIdlePolicyBeforeUdeCxEmulation(t *testing.T) { + controller := normalizedContract(nativeCFunction(t, + nativeContractSource(t, "native", "udecx", "driver", "Controller.c"), + "ViiperEvtDeviceAdd")) + requireContractOrder(t, controller, + "status = WdfDeviceCreate(&DeviceInit, &attributes, &device);", + "WDF_DEVICE_POWER_POLICY_IDLE_SETTINGS_INIT( &idleSettings, IdleCannotWakeFromS0);", + "status = WdfDeviceAssignS0IdleSettings(device, &idleSettings);", + "status = UdecxWdfDeviceAddUsbDeviceEmulation(device, &udeConfig);") + if strings.Contains(controller, "Start-Sleep") || strings.Contains(controller, "WdfTimer") { + t.Fatal("controller enumeration policy must not hide lifecycle races with timing workarounds") + } +} + +func TestNativeBrokerDispatchUsesIndependentCursorAndEndpointFIFO(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + + for _, required := range []string{ + "LIST_ENTRY AdmissionEntry;", + "BOOLEAN AdmissionLinked;", + "ULONG NextDispatchSlot;", + "LIST_ENTRY AdmissionQueue;", + } { + if !strings.Contains(header, required) { + t.Fatalf("native dispatch contract lost %q", required) + } + } + if !strings.Contains(device, + "InitializeListHead(&endpointContext->AdmissionQueue);") { + t.Fatal("endpoint admission FIFO is not initialized before broker use") + } + if strings.Contains(broker, "ViiperHasEarlierUnpublishedAdmissionLocked") { + t.Fatal("native dispatch still performs the controller-wide admission-order scan") + } + + allocate := normalizedContract(nativeCFunction(t, broker, "ViiperAllocatePendingSlot")) + requireContractOrder(t, allocate, + "pending->AdmissionLinked = TRUE;", + "InsertTailList(&endpointContext->AdmissionQueue, &pending->AdmissionEntry);", + "ControllerContext->NextPendingSlot = (index + 1) % VIIPER_UDE_MAX_PENDING_OPERATIONS;") + + head := normalizedContract(nativeCFunction(t, broker, "ViiperAdmissionCanPublishLocked")) + requireContractOrder(t, head, + "if (!Pending->AdmissionLinked || Pending->Endpoint == WDF_NO_HANDLE)", + "endpointContext = ViiperGetEndpointContext(Pending->Endpoint);", + "return endpointContext->AdmissionQueue.Flink == &Pending->AdmissionEntry;") + + dispatch := normalizedContract(nativeCFunction(t, broker, "ViiperDispatchAvailable")) + if strings.Contains(dispatch, "NextPendingSlot") { + t.Fatal("allocation cursor is still coupled to broker dispatch") + } + requireContractOrder(t, dispatch, + "controllerContext->NextDispatchSlot + index", + "ViiperAdmissionCanPublishLocked(pending)", + "controllerContext->NextDispatchSlot = (candidate + 1)", + "ViiperUnlinkAdmissionLocked(pending)") +} + +func TestNativeBrokerAdmissionRetirementCannotStrandSuccessor(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + + clear := normalizedContract(nativeCFunction(t, broker, "ViiperClearSlotLocked")) + requireContractOrder(t, clear, + "ViiperUnlinkAdmissionLocked(pending);", + "pending->Request = WDF_NO_HANDLE;") + + cancel := normalizedContract(nativeCFunction(t, broker, "ViiperEvtUrbCancel")) + requireContractOrder(t, cancel, + "dispatchSuccessor = pending->AdmissionLinked;", + "ViiperUnlinkAdmissionLocked(pending);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "if (dispatchSuccessor)", + "ViiperDispatchAvailable(controller);") + + queue := normalizedContract(nativeCFunction(t, broker, "ViiperQueueUrb")) + requireContractOrder(t, queue, + "pending->State = ViiperUdePendingDpcCompletion;", + "ViiperUnlinkAdmissionLocked(pending);", + "if (queueCancelledCompletion)", + "ViiperQueueUrbCompletion(", + "ViiperDispatchAvailable(deviceContext->Controller);") + + abort := normalizedContract(nativeCFunction(t, broker, "ViiperAbortMatchingOperations")) + requireContractOrder(t, abort, + "pending->AbortPending = TRUE;", + "ViiperUnlinkAdmissionLocked(pending);") +} + +func TestNativeBrokerPublishingCancelCannotMissDispatchWake(t *testing.T) { + // WdfRequestUnmarkCancelable is allowed to return STATUS_CANCELLED before + // EvtRequestCancel has run. Model the worst ordering: the old dispatcher + // scans while the publishing admission is still linked, finds no eligible + // successor, and returns. The later callback must both unlink and explicitly + // wake a new dispatch pass. + type admission struct { + linked bool + state string + } + head := admission{linked: true, state: "publishing"} + successor := admission{linked: true, state: "queued"} + canPublishSuccessor := func() bool { + return successor.linked && !head.linked + } + + if canPublishSuccessor() { + t.Fatal("successor published ahead of the same-endpoint head") + } + oldDispatchReturned := true // it scanned before EvtRequestCancel ran + dispatchWake := false + if head.linked { + dispatchWake = true + head.linked = false + head.state = "dpc-completion" + } + if !oldDispatchReturned || !dispatchWake || !canPublishSuccessor() { + t.Fatal("publishing-head cancellation failed to wake its queued successor") + } +} + +func TestNativeBrokerIndependentCursorEliminatesCommonFullWrap(t *testing.T) { + const slots = 4096 + scan := func(start, target int) int { + for offset := 0; offset < slots; offset++ { + if (start+offset)%slots == target { + return offset + 1 + } + } + return slots + } + + // The old scheduler advanced the allocation cursor after choosing slot 0, + // then reused it as the dispatch start. Its only queued operation was the + // last slot inspected after wrapping the entire table. + if got := scan(1, 0); got != slots { + t.Fatalf("coupled-cursor baseline inspected %d slots, want %d", got, slots) + } + if got := scan(0, 0); got != 1 { + t.Fatalf("independent dispatch cursor inspected %d slots, want 1", got) + } + + allocationCursor := 0 + dispatchCursor := 0 + for iteration := 0; iteration < slots*2; iteration++ { + allocated := allocationCursor + allocationCursor = (allocated + 1) % slots + if got := scan(dispatchCursor, allocated); got != 1 { + t.Fatalf("iteration %d inspected %d slots, want 1", iteration, got) + } + dispatchCursor = (allocated + 1) % slots + } +} + +func TestNativeBrokerEndpointFIFOModelsPublishAndCancelOrdering(t *testing.T) { + type admission struct { + id int + endpoint int + } + queues := map[int][]admission{} + appendAdmission := func(item admission) { + queues[item.endpoint] = append(queues[item.endpoint], item) + } + canPublish := func(item admission) bool { + queue := queues[item.endpoint] + return len(queue) != 0 && queue[0].id == item.id + } + retire := func(item admission) { + queue := queues[item.endpoint] + if len(queue) == 0 || queue[0].id != item.id { + t.Fatalf("retired non-head admission %+v from %+v", item, queue) + } + queues[item.endpoint] = queue[1:] + } + + a1 := admission{id: 1, endpoint: 0x01} + a2 := admission{id: 2, endpoint: 0x01} + b1 := admission{id: 3, endpoint: 0x82} + appendAdmission(a1) + appendAdmission(a2) + appendAdmission(b1) + if !canPublish(a1) || canPublish(a2) || !canPublish(b1) { + t.Fatal("per-endpoint heads did not preserve FIFO order and cross-endpoint concurrency") + } + + retire(a1) // publication + if !canPublish(a2) { + t.Fatal("publication did not expose the next same-endpoint admission") + } + + a3 := admission{id: 4, endpoint: 0x01} + appendAdmission(a3) + retire(a2) // cancellation/abort uses the same unlink transition + if !canPublish(a3) { + t.Fatal("cancellation did not expose the next same-endpoint admission") + } +} + +func TestNativeFastInputSubmissionAvoidsSecondKMDFQueueHop(t *testing.T) { + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + ioctl := nativeContractSource(t, "native", "udecx", "driver", "Ioctl.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + all := controller + ioctl + header + if strings.Contains(all, "WDFQUEUE InputQueue;") || + strings.Contains(all, "context->InputQueue") || + strings.Contains(all, "ViiperEvtInputIoDeviceControl") { + t.Fatal("native input still crosses the redundant parallel KMDF queue") + } + + queues := normalizedContract(nativeCFunction(t, controller, "ViiperCreateQueues")) + requireContractOrder(t, queues, + "WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(&queueConfig, WdfIoQueueDispatchParallel);", + "queueConfig.EvtIoDeviceControl = ViiperEvtIoDeviceControlRoute;", + "WDF_IO_QUEUE_CONFIG_INIT(&queueConfig, WdfIoQueueDispatchSequential);", + "queueConfig.EvtIoDeviceControl = ViiperEvtIoDeviceControl;") + + route := normalizedContract(nativeCFunction(t, ioctl, "ViiperEvtIoDeviceControlRoute")) + requireContractOrder(t, route, + "InterlockedCompareExchange(&context->ShuttingDown, 0, 0)", + "if (IoControlCode == IOCTL_VIIPER_UDE_SUBMIT_INPUT_REPORT)", + "status = ViiperSubmitInputReport(Queue, Request);", + "WdfRequestComplete(Request, status);", + "return;", + "WdfRequestForwardToIoQueue(Request, context->ControlQueue)") + if strings.Contains(route, "WdfRequestForwardToIoQueue(Request, context->InputQueue)") { + t.Fatal("hot input report is still forwarded before completion") + } +} + +func TestNativeCachedInputReadyUsesCompletionDPCWithoutWorkerHop(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + if strings.Contains(device+header, "InputReadyWorkItem") || + strings.Contains(device+header, "ViiperEvtFastInputWorkItem") { + t.Fatal("cached input delivery still crosses a generic system work item") + } + + createQueue := normalizedContract(nativeCFunction(t, device, "ViiperCreateEndpointQueue")) + if !strings.Contains(createQueue, "attributes.ExecutionLevel = WdfExecutionLevelPassive;") { + t.Fatal("manual fast-input queue no longer pins ReadyNotify to PASSIVE_LEVEL") + } + ready := normalizedContract(nativeCFunction(t, device, "ViiperEvtFastInputQueueReady")) + requireContractOrder(t, ready, + "ViiperEndpointOperationStarted(endpoint);", + "WdfWaitLockAcquire(endpointContext->InputLock, NULL);", + "for (;;)", + "WdfIoQueueRetrieveNextRequest(Queue, &request)", + "ViiperPrepareCachedInputUrb( endpoint, request, &directInputBytes, &directInputSequence);", + "ViiperCompleteRetrievedInputUrb( endpoint, request, completionStatus, directInputBytes, directInputSequence);", + "WdfWaitLockRelease(endpointContext->InputLock);", + "ViiperEndpointOperationCompleted(endpoint);") + if strings.Count(ready, "ViiperEndpointOperationStarted(endpoint);") != 2 { + t.Fatal("ReadyNotify must hold one callback rundown reference and one per queued DPC") + } + if strings.Contains(ready, "WdfWorkItemEnqueue") || + strings.Contains(ready, "UdecxUrbComplete(") { + t.Fatal("ReadyNotify either retains a worker hop or completes a UDE URB synchronously") + } + + complete := normalizedContract(nativeCFunction(t, device, "ViiperCompleteRetrievedInputUrb")) + requireContractOrder(t, complete, + "requestContext->DeviceGeneration = deviceContext->Generation;", + "requestContext->EndpointGeneration = endpointContext->Generation;", + "ViiperQueueUrbCompletion(") +} + +func TestNativeDirectInputStatsCommitAfterTerminalUdeCxCompletion(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + + prepare := normalizedContract(nativeCFunction(t, device, "ViiperPrepareCachedInputUrb")) + if strings.Contains(prepare, "BytesFromDevice") || + strings.Contains(prepare, "InputReportsCompleted") { + t.Fatal("cached input preparation reports completion before the terminal UdeCx call") + } + requireContractOrder(t, prepare, + "*BytesPrepared = 0;", + "*SequencePrepared = 0;", + "UdecxUrbSetBytesCompleted(Request, reportLength);", + "*BytesPrepared = reportLength;", + "*SequencePrepared = reportSequence;") + + queueCompletion := normalizedContract(nativeCFunction(t, broker, "ViiperQueueUrbCompletion")) + requireContractOrder(t, queueCompletion, + "requestContext->DirectInputBytes = DirectInputBytes;", + "requestContext->DirectInputSequence = DirectInputSequence;", + "requestContext->CompletionQueued = TRUE;") + + dpc := normalizedContract(nativeCFunction(t, broker, "ViiperEvtCompletionDpc")) + requireContractOrder(t, dpc, + "directInputBytes = requestContext->DirectInputBytes;", + "directInputSequence = requestContext->DirectInputSequence;", + "deviceGeneration = requestContext->DeviceGeneration;", + "endpointGeneration = requestContext->EndpointGeneration;", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "ViiperGetEndpointContext(endpoint)->Generation != endpointGeneration", + "ViiperGetDeviceContext(ViiperGetEndpointContext(endpoint)->Device)->Generation != deviceGeneration", + "UdecxUrbCompleteWithNtStatus(request, completionStatus);", + "UdecxUrbComplete(request, usbdStatus);", + "directInputSequence != 0", + "InterlockedAdd64(&controllerContext->BytesFromDevice, directInputBytes);", + "InterlockedIncrement64(&controllerContext->InputReportsCompleted);") +} + +func TestNativeFastInputQueuesTransitionsButCoalescesIdleCadence(t *testing.T) { + header := nativeContractSource(t, "native", "udecx", "include", "ViiperUdeProtocol.h") + if !strings.Contains(header, "#define VIIPER_UDE_INPUT_REPORT_TRANSITION 0x01") { + t.Fatal("native ABI does not classify discrete input transitions") + } + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + submit := normalizedContract(nativeCFunction(t, device, "ViiperSubmitInputReport")) + requireContractOrder(t, submit, + "input->EndpointGeneration == 0", + "endpointContext->Generation != input->EndpointGeneration", + "deviceContext->EndpointGenerations[input->EndpointAddress] != input->EndpointGeneration", + "if ((input->Flags & VIIPER_UDE_INPUT_REPORT_TRANSITION) != 0 &&", + "return STATUS_DEVICE_BUSY;", + "RtlCopyMemory(endpointContext->InputReport", + "if ((input->Flags & VIIPER_UDE_INPUT_REPORT_TRANSITION) != 0) {", + "endpointContext->InputTransitionSequences[tail] = input->Sequence;", + "InterlockedExchange64(&endpointContext->LastInputSequence", + "InterlockedExchange(&endpointContext->InputReportValid, TRUE);", + "InterlockedIncrement(&endpointContext->InputTransitionCount);", + "WdfIoQueueRetrieveNextRequest(endpointContext->Queue") + ready := normalizedContract(nativeCFunction(t, device, "ViiperEvtFastInputQueueReady")) + requireContractOrder(t, ready, + "ViiperPrepareCachedInputUrb( endpoint, request, &directInputBytes, &directInputSequence);", + "&endpointContext->CachedDeliveryPending", + "&endpointContext->InputTransitionCount", + "&endpointContext->InputSnapshotPending", + "ViiperCompleteRetrievedInputUrb( endpoint, request, completionStatus, directInputBytes, directInputSequence);") + prepare := normalizedContract(nativeCFunction(t, device, "ViiperPrepareCachedInputUrb")) + requireContractOrder(t, prepare, + "if (InterlockedCompareExchange(&endpointContext->InputTransitionCount", + "report = endpointContext->InputTransitionReports", + "ViiperCopyTransferBuffer(Request, urb, report, reportLength, TRUE)", + "if (!NT_SUCCESS(status))", + "UdecxUrbSetBytesCompleted(Request, reportLength);", + "InterlockedDecrement(&endpointContext->InputTransitionCount)") +} + +func TestNativeCompletionValidatesImmutableIdentityBeforeClaim(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + complete := normalizedContract(nativeCFunction(t, broker, "ViiperCompleteOperation")) + requireContractOrder(t, complete, + "((ULONG)completion->Token & VIIPER_UDE_MANAGEMENT_SLOT_FLAG) == 0 && completion->EndpointGeneration == 0", + "controllerContext->PendingSlots[slot].Token == completion->Token", + "controllerContext->PendingSlots[slot].State == ViiperUdePendingInFlight", + "controllerContext->PendingSlots[slot].DeviceId == completion->DeviceId", + "controllerContext->PendingSlots[slot].DeviceGeneration == completion->Generation", + "controllerContext->PendingSlots[slot].EndpointGeneration == completion->EndpointGeneration", + "controllerContext->PendingSlots[slot].State = ViiperUdePendingCompleting;", + "WdfObjectReference(urbRequest);", + "identityMismatch = TRUE;", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "if (identityMismatch)", + "return STATUS_INVALID_PARAMETER;", + "WdfRequestUnmarkCancelable(urbRequest);", + "completion->Generation != requestContext->DeviceGeneration", + "completion->EndpointGeneration != requestContext->EndpointGeneration", + "completion->EndpointGeneration != ViiperGetEndpointContext(requestContext->Endpoint)->Generation") +} + +func TestNativeBrokerFaultFencesAdmissionAndPublication(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + + allocate := normalizedContract(nativeCFunction(t, broker, "ViiperAllocatePendingSlot")) + requireContractOrder(t, allocate, + "WdfSpinLockAcquire(ControllerContext->BrokerLock);", + "ControllerContext->BrokerFaulted", + "pending->Request = Request;") + + dispatch := normalizedContract(nativeCFunction(t, broker, "ViiperDispatchAvailable")) + requireContractOrder(t, dispatch, + "ViiperDispatchNotificationEvents(Controller);", + "controllerContext->BrokerFaulted", + "controllerContext->NextDispatchSlot + index") + + cancel := normalizedContract(nativeCFunction(t, broker, "ViiperQueueCancelEventLocked")) + requireContractOrder(t, cancel, + "if (!Pending->PublishedToOwner)", + "ControllerContext->BrokerFaulted", + "ControllerContext->NotificationCount") + + for _, function := range []string{ + "ViiperQueueEndpointLifecycleEvent", + "ViiperQueueDeviceLifecycleEvent", + "ViiperQueueInterfaceLifecycleEvent", + } { + lifecycle := normalizedContract(nativeCFunction(t, broker, function)) + requireContractOrder(t, lifecycle, + "active = ownerActive && InterlockedCompareExchange( &controllerContext->BrokerFaulted", + "queued = active &&", + "faulted = ownerActive && InterlockedCompareExchange( &controllerContext->BrokerFaulted", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "if (queued || faulted)", + "ViiperDispatchNotificationEvents(deviceContext->Controller);") + } + + acknowledged := normalizedContract(nativeCFunction(t, broker, "ViiperQueueAcknowledgedLifecycleEvent")) + requireContractOrder(t, acknowledged, + "controllerContext->BrokerFaulted", + "ViiperFaultBrokerLocked(controllerContext)", + "faulted = ownerActive && InterlockedCompareExchange( &controllerContext->BrokerFaulted", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "if (NT_SUCCESS(status) || faulted)", + "ViiperDispatchNotificationEvents(deviceContext->Controller);") +} + +func TestNativeLifecycleNotificationsPublishCanonicalEmptyTail(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + dispatch := normalizedContract(nativeCFunction(t, broker, "ViiperDispatchNotificationEvents")) + requireContractOrder(t, dispatch, + "operation->Header.Size = sizeof(*operation);", + "operation->DeviceSequence = event.DeviceSequence;", + "operation->IsoPacketsOffset = sizeof(*operation);", + "operation->PayloadOffset = sizeof(*operation);", + "WdfRequestSetInformation(dequeueRequest, sizeof(*operation));") +} + +func TestNativeManualDequeueCancellationRetiresAccounting(t *testing.T) { + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + createQueues := normalizedContract(nativeCFunction(t, controller, "ViiperCreateQueues")) + requireContractOrder(t, createQueues, + "WDF_IO_QUEUE_CONFIG_INIT(&queueConfig, WdfIoQueueDispatchManual);", + "queueConfig.EvtIoCanceledOnQueue = ViiperEvtDequeueCanceledOnQueue;", + "WdfIoQueueCreate(Device, &queueConfig") + if !strings.Contains(header, + "EVT_WDF_IO_QUEUE_IO_CANCELED_ON_QUEUE ViiperEvtDequeueCanceledOnQueue;") { + t.Fatal("manual dequeue cancellation callback lost its KMDF declaration") + } + cancel := normalizedContract(nativeCFunction(t, controller, "ViiperEvtDequeueCanceledOnQueue")) + requireContractOrder(t, cancel, + "InterlockedDecrement(&context->WaitingDequeueCount);", + "NT_ASSERT(remaining >= 0);", + "WdfRequestComplete(Request, STATUS_CANCELLED);") +} + +func TestNativeBrokerMixedLaneFairnessModel(t *testing.T) { + // Exercise the exact round-robin slot selection and per-endpoint-head rule + // with control, HID/state, speaker ISO, and microphone ISO traffic from + // several controllers. Deterministic head cancellations model purge/reset + // pressure while proving that an unrelated endpoint is never starved. + const slots = 4096 + type laneKey struct { + device int + endpoint byte + } + type admission struct { + lane laneKey + sequence int + queued bool + linked bool + } + + endpoints := []byte{0x00, 0x01, 0x02, 0x82} + pending := make([]admission, slots) + queues := make(map[laneKey][]int) + allocated := 0 + for round := 1; round <= 8; round++ { + for device := 0; device < 8; device++ { + for _, endpoint := range endpoints { + lane := laneKey{device: device, endpoint: endpoint} + pending[allocated] = admission{ + lane: lane, sequence: round, queued: true, linked: true, + } + queues[lane] = append(queues[lane], allocated) + allocated++ + } + } + } + + // Cancel selected heads before dispatch, exactly like the kernel unlink + // transition: remove the old head and expose its same-endpoint successor. + for lane, queue := range queues { + if (lane.device+int(lane.endpoint))%7 == 0 { + pending[queue[0]].linked = false + pending[queue[0]].queued = false + queues[lane] = queue[1:] + } + } + + cursor := 0 + delivered := make(map[laneKey][]int) + remaining := 0 + for _, queue := range queues { + remaining += len(queue) + } + maxInspections := 0 + totalInspections := 0 + for remaining != 0 { + selected := -1 + inspections := 0 + for offset := 0; offset < slots; offset++ { + inspections++ + candidate := (cursor + offset) % slots + item := pending[candidate] + queue := queues[item.lane] + if item.queued && item.linked && len(queue) != 0 && queue[0] == candidate { + selected = candidate + break + } + } + if selected < 0 { + t.Fatalf("mixed native traffic stranded %d endpoint admissions", remaining) + } + if inspections > maxInspections { + maxInspections = inspections + } + totalInspections += inspections + item := pending[selected] + delivered[item.lane] = append(delivered[item.lane], item.sequence) + queue := queues[item.lane] + queues[item.lane] = queue[1:] + pending[selected].linked = false + pending[selected].queued = false + cursor = (selected + 1) % slots + remaining-- + } + + for lane, sequences := range delivered { + for index := 1; index < len(sequences); index++ { + if sequences[index] != sequences[index-1]+1 { + t.Fatalf("lane %+v lost FIFO order: %v", lane, sequences) + } + } + } + if len(delivered) != 8*len(endpoints) { + t.Fatalf("only %d/%d independent lanes made progress", len(delivered), 8*len(endpoints)) + } + // The independent allocation/dispatch cursors make every healthy admission + // the first inspected slot. Each of the four deliberately canceled heads + // costs one extra inspection, never a controller-table wrap. + if maxInspections != 2 || totalInspections != allocated { + t.Fatalf("mixed native traffic inspected max=%d total=%d, want max=2 total=%d", + maxInspections, totalInspections, allocated) + } +} + +func TestNativeFastInputUsesSharedIndexedLifetimeAdmission(t *testing.T) { + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + inf := nativeContractSource(t, "native", "udecx", "package", "ViiperUde.inf") + + owner := normalizedContract(nativeCFunction(t, broker, "ViiperValidateBrokerOwner")) + if strings.Contains(owner, "WdfWaitLockAcquire") || + strings.Contains(owner, "controllerContext->OwnerFile") || + strings.Contains(owner, "controllerContext->CleanupInProgress") { + t.Fatalf("fast owner validation still joins controller-wide cleanup state: %s", owner) + } + for _, required := range []string{ + "WdfRequestGetFileObject(Request)", + "fileContext->BrokerOwner", + "fileContext->Negotiated", + "fileContext->Closing", + } { + if !strings.Contains(owner, required) { + t.Fatalf("lock-free owner validation lost %q", required) + } + } + + submit := normalizedContract(nativeCFunction(t, device, "ViiperSubmitInputReport")) + for _, forbidden := range []string{ + "ViiperAcquireDeviceLockExclusive", "WdfObjectReference(endpoint)", + "WdfObjectDereference(endpoint)", + "for (index = 0; index < VIIPER_UDE_MAX_DEVICES", + } { + if strings.Contains(submit, forbidden) { + t.Fatalf("fast input retains hot-path work %q: %s", forbidden, submit) + } + } + requireContractOrder(t, submit, + "ViiperAcquireDeviceLockShared(controllerContext);", + "ViiperFindInputDeviceLocked(controllerContext, input->DeviceId);", + "deviceContext->OwnerFile == ownerFile", + "deviceContext->Generation == input->Generation", + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "endpointContext->Purging", + "ViiperEndpointOperationStarted(endpoint);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "ViiperReleaseDeviceLockShared(controllerContext);", + "WdfWaitLockAcquire(endpointContext->InputLock, NULL);") + requireContractOrder(t, submit, + "ViiperPrepareCachedInputUrb( endpoint, urbRequest, &directInputBytes, &directInputSequence);", + "WdfWaitLockRelease(endpointContext->InputLock);", + "ViiperCompleteRetrievedInputUrb( endpoint, urbRequest, status, directInputBytes, directInputSequence);") + requireContractOrder(t, submit, + "RtlCopyMemory(endpointContext->InputReport, payload, input->PayloadLength);", + "endpointContext->InputReportLength = input->PayloadLength;", + "endpointContext->InputTransitionSequences[tail] = input->Sequence;", + "InterlockedExchange64(&endpointContext->LastInputSequence", + "InterlockedExchange(&endpointContext->InputReportValid, TRUE);", + "InterlockedIncrement64(&controllerContext->InputReportsSubmitted);", + "WdfIoQueueRetrieveNextRequest(endpointContext->Queue, &urbRequest);", + "endpointContext->CachedDeliveryPending") + for _, lifecycleGate := range []string{ + "controllerContext->ShuttingDown", + "deviceContext->InD0", + "deviceContext->Purging", + "deviceContext->Resetting", + "endpointContext->Purging", + "endpointContext->Resetting", + } { + if !strings.Contains(submit, lifecycleGate) { + t.Fatalf("fast input admission lost lifecycle gate %q", lifecycleGate) + } + } + + find := normalizedContract(nativeCFunction(t, device, "ViiperFindInputDeviceLocked")) + requireContractOrder(t, find, + "count = ControllerContext->InputDeviceCount;", + "while (count != 0)", + "candidate = first + step;", + "ControllerContext->InputDevices[candidate]", + "return ControllerContext->InputDevices[first];") + if strings.Contains(find, "ControllerContext->Devices[") { + t.Fatalf("input lookup fell back to the physical O(32) table: %s", find) + } + + if !strings.Contains(header, "EX_PUSH_LOCK DeviceLock;") || + !strings.Contains(header, "UDECXUSBDEVICE InputDevices[VIIPER_UDE_MAX_DEVICES];") || + !strings.Contains(controller, "ExInitializePushLock(&context->DeviceLock);") { + t.Fatal("controller lost its shared input index or push-lock initialization") + } + sharedAcquire := normalizedContract(nativeCFunction(t, header, "ViiperAcquireDeviceLockShared")) + sharedRelease := normalizedContract(nativeCFunction(t, header, "ViiperReleaseDeviceLockShared")) + exclusiveAcquire := normalizedContract(nativeCFunction(t, header, "ViiperAcquireDeviceLockExclusive")) + exclusiveRelease := normalizedContract(nativeCFunction(t, header, "ViiperReleaseDeviceLockExclusive")) + requireContractOrder(t, sharedAcquire, + "KeEnterCriticalRegion();", "ExAcquirePushLockShared(&ControllerContext->DeviceLock);") + requireContractOrder(t, sharedRelease, + "ExReleasePushLockShared(&ControllerContext->DeviceLock);", "KeLeaveCriticalRegion();") + requireContractOrder(t, exclusiveAcquire, + "KeEnterCriticalRegion();", "ExAcquirePushLockExclusive(&ControllerContext->DeviceLock);") + requireContractOrder(t, exclusiveRelease, + "ExReleasePushLockExclusive(&ControllerContext->DeviceLock);", "KeLeaveCriticalRegion();") + if !strings.Contains(header, "_IRQL_requires_max_(APC_LEVEL)") || + !strings.Contains(header, "Normal shared acquisition waits behind an exclusive") { + t.Fatal("push-lock IRQL/APC or writer-preference contract is undocumented") + } + queues := normalizedContract(nativeCFunction(t, controller, "ViiperCreateQueues")) + requireContractOrder(t, queues, + "attributes.ExecutionLevel = WdfExecutionLevelPassive;", + "attributes.SynchronizationScope = WdfSynchronizationScopeNone;", + "WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(&queueConfig, WdfIoQueueDispatchParallel);") + if !strings.Contains(inf, "NTamd64.10.0...17763") { + t.Fatal("driver platform floor no longer proves EX_PUSH_LOCK API availability") + } +} + +func TestNativeEndpointRundownPrecedesCleanupAndDPCMayRunImmediately(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + + started := normalizedContract(nativeCFunction(t, broker, "ViiperEndpointOperationStarted")) + requireContractOrder(t, started, + "if (active == 0)", + "KeClearEvent(&endpointContext->OperationsDrained);", + "InterlockedIncrement(&endpointContext->ActiveOperations);") + completedLocked := normalizedContract(nativeCFunction( + t, broker, "ViiperEndpointOperationCompletedLocked")) + requireContractOrder(t, completedLocked, + "InterlockedDecrement(&endpointContext->ActiveOperations);", + "if (remaining == 0)", + "KeSetEvent(&endpointContext->OperationsDrained, IO_NO_INCREMENT, FALSE);") + completed := normalizedContract(nativeCFunction(t, broker, "ViiperEndpointOperationCompleted")) + requireContractOrder(t, completed, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "ViiperEndpointOperationCompletedLocked(Endpoint);", + "WdfSpinLockRelease(controllerContext->BrokerLock);") + if got := strings.Count(broker+device, + "InterlockedIncrement(&endpointContext->ActiveOperations)"); got != 1 { + t.Fatalf("ActiveOperations has %d increment sites, want one BrokerLock-owned transition", got) + } + if got := strings.Count(broker+device, + "InterlockedDecrement(&endpointContext->ActiveOperations)"); got != 1 { + t.Fatalf("ActiveOperations has %d decrement sites, want one BrokerLock-owned transition", got) + } + startedCalls := regexp.MustCompile(`ViiperEndpointOperationStarted\s*\([^)]*\)\s*;`) + if got := len(startedCalls.FindAllString(broker+device, -1)); got != 5 { + t.Fatalf("endpoint rundown has %d admission call sites, want four callback admissions plus the ReadyNotify DPC handoff", got) + } + + for _, name := range []string{ + "ViiperEvtFastInputQueueReady", + "ViiperSubmitInputReport", + } { + admission := normalizedContract(nativeCFunction(t, device, name)) + requireContractOrder(t, admission, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "ViiperEndpointOperationStarted(endpoint);", + "WdfSpinLockRelease(controllerContext->BrokerLock);") + } + ready := normalizedContract(nativeCFunction(t, device, "ViiperEvtFastInputQueueReady")) + requireContractOrder(t, ready, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "ViiperEndpointOperationStarted(endpoint);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "WdfWaitLockAcquire(endpointContext->InputLock, NULL);") + for _, name := range []string{"ViiperEvtUrbCanceledOnQueue", "ViiperQueueUrb"} { + admission := normalizedContract(nativeCFunction(t, broker, name)) + requireContractOrder(t, admission, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "ViiperEndpointOperationStarted(endpoint);", + "WdfSpinLockRelease(controllerContext->BrokerLock);") + } + + purge := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointPurge")) + requireContractOrder(t, purge, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "InterlockedExchange(&endpointContext->Purging, TRUE);", + "InterlockedExchange(&endpointContext->StartAnnounced, FALSE);", + "outstanding = InterlockedIncrement(&endpointContext->PurgeOutstanding);", + "enqueueWorkItem = InterlockedCompareExchange( &endpointContext->PurgeWorkerActive, TRUE, FALSE) == FALSE;", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY);", + "if (enqueueWorkItem)", + "WdfWorkItemEnqueue(endpointContext->PurgeWorkItem);") + createQueue := normalizedContract(nativeCFunction(t, device, "ViiperCreateEndpointQueue")) + if !strings.Contains(createQueue, + "UdecxUsbEndpointSetWdfIoQueue(Endpoint, endpointContext->Queue);") { + t.Fatal("endpoint purge lost its explicitly associated WDF queue") + } + if strings.Contains(device, "WdfIoQueuePurge(") || + strings.Contains(device, "WdfIoQueueStart(") { + t.Fatal("UdeCx-associated endpoint queue state is client-mutated") + } + purgeQuiescence := normalizedContract(nativeCFunction( + t, device, "ViiperWaitForEndpointPurgeQuiescence")) + requireContractOrder(t, purgeQuiescence, + "WdfIoQueueGetState( endpointContext->Queue, &queuedRequests, &driverRequests);", + "endpointContext->PurgeOutstanding", + "endpointContext->Purging", + "WdfIoQueueDriverNoRequests", + "driverRequests == 0", + "endpointContext->ActiveOperations") + for _, forbidden := range []string{ + "WDF_IO_QUEUE_READY", "WdfIoQueueNoRequests", "WDF_IO_QUEUE_IDLE", + "WDF_IO_QUEUE_PURGED", "queuedRequests == 0", + } { + if strings.Contains(purgeQuiescence, forbidden) { + t.Fatalf("endpoint PURGE incorrectly waits on UdeCx-owned queue state %q", forbidden) + } + } + purgeWork := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointPurgeWorkItem")) + requireContractOrder(t, purgeWork, + "ViiperWaitForEndpointPurgeQuiescence( endpoint, &queueState, &queuedRequests, &driverRequests);", + "ViiperInvalidateEndpointInputReport(endpoint);", + "remaining = InterlockedDecrement(&endpointContext->PurgeOutstanding);", + "UdecxUsbEndpointPurgeComplete(endpoint);", + "endpointContext->PurgeOutstanding", + "InterlockedExchange(&endpointContext->PurgeWorkerActive, FALSE);") + resetWork := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointResetWorkItem")) + requireContractOrder(t, resetWork, + "resetCurrent = ViiperQuiesceResetByIdentity(", + "if (!resetCurrent)", + "WdfRequestComplete(request, STATUS_DEVICE_NOT_READY);", + "ViiperInvalidateEndpointInputReport(endpoint);", + "ViiperQueueAcknowledgedEndpointLifecycleEvent(") + start := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointStart")) + if !strings.Contains(start, "ViiperActivateEndpoint(Endpoint);") { + t.Fatal("explicit endpoint START no longer opens VIIPER endpoint admission") + } + activate := normalizedContract(nativeCFunction(t, device, "ViiperActivateEndpoint")) + requireContractOrder(t, activate, + "endpointContext->PurgeOutstanding", + "InterlockedExchange(&endpointContext->Purging, FALSE);", + "endpointContext->StartAnnounced, TRUE, FALSE", + "ViiperQueueEndpointLifecycleEvent( Endpoint, ViiperUdeOperationEndpointStart);") + if strings.Contains(activate, "PurgeWorkerActive") { + t.Fatal("final synchronous START is incorrectly coupled to worker callback return") + } + + cleanup := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointCleanup")) + requireContractOrder(t, cleanup, + "ViiperAcquireDeviceLockExclusive(controllerContext);", + "endpointContext->ActiveOperations", + "endpointContext->PurgeOutstanding", + "endpointContext->PurgeWorkerActive", + "ViiperInvalidateEndpointInputReport(endpoint);", + "deviceContext->Endpoints[address] = WDF_NO_HANDLE;", + "ViiperReleaseDeviceLockExclusive(controllerContext);") + if strings.Contains(cleanup, "KeWaitForSingleObject") { + t.Fatalf("EvtCleanup attempts a late wait after KMDF made the object inaccessible: %s", cleanup) + } + + queueCompletion := normalizedContract(nativeCFunction(t, broker, "ViiperQueueUrbCompletion")) + if strings.Contains(queueCompletion, "WdfObjectReference(Endpoint)") { + t.Fatal("terminal DPC lifetime still assumes a WDF reference postpones EvtCleanup") + } + dpc := normalizedContract(nativeCFunction(t, broker, "ViiperEvtCompletionDpc")) + requireContractOrder(t, dpc, + "UdecxUrbCompleteWithNtStatus(request, completionStatus);", + "ViiperEndpointOperationCompletedLocked(endpoint);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "WdfObjectDereference(request);") + if strings.Contains(dpc, "WdfObjectDereference(endpoint)") { + t.Fatal("completion DPC touches the endpoint after releasing its final rundown owner") + } +} + +func TestNativeUdeHandleRevocationPrecedesPlugOutAndCleanup(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + + destroy := normalizedContract(nativeCFunction(t, device, "ViiperDestroyVirtualDevice")) + requireContractOrder(t, destroy, + "ViiperBeginRemoveDevice(", + "UdecxUsbDevicePlugOutAndDelete(device);") + afterPlugOut := destroy[strings.Index(destroy, "UdecxUsbDevicePlugOutAndDelete(device);")+len("UdecxUsbDevicePlugOutAndDelete(device);"):] + if strings.Contains(afterPlugOut, "ViiperGetDeviceContext(device)") || + strings.Contains(afterPlugOut, "WdfObjectReference(device)") { + t.Fatalf("destroy path accesses a consumed UDE handle after PlugOutAndDelete: %s", afterPlugOut) + } + + shutdown := normalizedContract(nativeCFunction(t, device, "ViiperBeginControllerShutdown")) + requireContractOrder(t, shutdown, + "ViiperRemoveInputDeviceLocked(controllerContext, device);", + "controllerContext->Devices[index] = WDF_NO_HANDLE;", + "ViiperReleaseDeviceLockExclusive(controllerContext);", + "UdecxUsbDevicePlugOutAndDelete(devices[index]);") + cleanup := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointCleanup")) + requireContractOrder(t, cleanup, + "endpointContext->ActiveOperations", + "deviceContext->Endpoints[address] = WDF_NO_HANDLE;") +} + +func TestNativeDeviceAndBrokerLockOrderNeverReverses(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + functionName := regexp.MustCompile(`(?m)^([A-Za-z_][A-Za-z0-9_]*)\(\r?$`) + + // The only permitted nesting is DeviceLock -> BrokerLock. For every direct + // DeviceLock acquisition, prove there is no unmatched BrokerLock acquisition + // earlier in the same function body. + for _, source := range []string{broker, device} { + for _, match := range functionName.FindAllStringSubmatch(source, -1) { + name := match[1] + body := normalizedContract(nativeCFunction(t, source, name)) + for _, acquire := range []string{ + "ViiperAcquireDeviceLockShared(", + "ViiperAcquireDeviceLockExclusive(", + } { + cursor := 0 + for { + offset := strings.Index(body[cursor:], acquire) + if offset < 0 { + break + } + at := cursor + offset + prefix := body[:at] + brokerAcquire := strings.LastIndex(prefix, "WdfSpinLockAcquire(") + brokerRelease := strings.LastIndex(prefix, "WdfSpinLockRelease(") + if brokerAcquire > brokerRelease { + t.Fatalf("%s reverses global lock order BrokerLock -> DeviceLock: %s", name, body) + } + cursor = at + len(acquire) + } + } + } + } + + for _, name := range []string{ + "ViiperBeginRemoveDevice", + "ViiperBeginControllerShutdown", + "ViiperSubmitInputReport", + } { + body := normalizedContract(nativeCFunction(t, device, name)) + deviceAcquire := "ViiperAcquireDeviceLockExclusive(" + deviceRelease := "ViiperReleaseDeviceLockExclusive(" + if name == "ViiperSubmitInputReport" { + deviceAcquire = "ViiperAcquireDeviceLockShared(" + deviceRelease = "ViiperReleaseDeviceLockShared(" + } + requireContractOrder(t, body, + deviceAcquire, + "WdfSpinLockAcquire(", + "WdfSpinLockRelease(", + deviceRelease) + } + + virtualCleanup := normalizedContract(nativeCFunction(t, device, "ViiperEvtVirtualDeviceCleanup")) + requireContractOrder(t, virtualCleanup, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "ViiperReleaseDeviceSlot( controllerContext, device, deviceContext->Slot, deviceContext->PortReservation);") + management := normalizedContract(nativeCFunction(t, broker, "ViiperCompleteManagementOperation")) + firstRelease := strings.Index(management, + "WdfSpinLockRelease(ControllerContext->BrokerLock);") + resetProof := strings.Index(management, "ViiperQuiesceResetByIdentity(") + if firstRelease < 0 || resetProof < firstRelease { + t.Fatalf("management path acquires the reset identity fence before releasing BrokerLock: %s", management) + } +} + +func TestNativeEndpointRundownRejectsOldClearIncrementRace(t *testing.T) { + // Old ordering: Start clears first, Completion wins 1 -> 0 and signals, + // then Start increments. A waiter can observe signaled while active == 1. + oldActive := 1 + oldSignaled := false + oldSignaled = false + oldActive-- + if oldActive == 0 { + oldSignaled = true + } + oldActive++ + if oldActive != 1 || !oldSignaled { + t.Fatalf("old adversarial schedule was not reproduced: active=%d signaled=%t", + oldActive, oldSignaled) + } + + type rundown struct { + active int + signaled bool + } + startLocked := func(state *rundown) { + if state.active == 0 { + state.signaled = false + } + state.active++ + } + completeLocked := func(state *rundown) { + state.active-- + if state.active == 0 { + state.signaled = true + } + } + + for _, completionFirst := range []bool{false, true} { + state := rundown{active: 1, signaled: false} + if completionFirst { + completeLocked(&state) + startLocked(&state) + } else { + startLocked(&state) + completeLocked(&state) + } + if state.active != 1 || state.signaled { + t.Fatalf("serialized schedule completionFirst=%t left active=%d signaled=%t", + completionFirst, state.active, state.signaled) + } + } +} + +func TestNativeFastInputIndexIdentityReuseAndComparisonBound(t *testing.T) { + type identity struct { + deviceID uint64 + owner int + generation uint32 + handle int + } + var index []identity + insert := func(value identity) bool { + position := 0 + for position < len(index) && index[position].deviceID < value.deviceID { + position++ + } + if position < len(index) && index[position].deviceID == value.deviceID { + return false + } + index = append(index, identity{}) + copy(index[position+1:], index[position:]) + index[position] = value + return true + } + removeHandle := func(handle int) { + for position := range index { + if index[position].handle == handle { + copy(index[position:], index[position+1:]) + index = index[:len(index)-1] + return + } + } + } + lookup := func(deviceID uint64) (identity, int, bool) { + first, count, comparisons := 0, len(index), 0 + for count != 0 { + step := count / 2 + candidate := first + step + comparisons++ + if index[candidate].deviceID < deviceID { + first = candidate + 1 + count -= step + 1 + } else { + count = step + } + } + if first == len(index) || index[first].deviceID != deviceID { + return identity{}, comparisons, false + } + return index[first], comparisons, true + } + + // 17 is coprime with 32, producing a deterministic hostile insertion order. + for n := 0; n < 32; n++ { + id := uint64((n*17)%32 + 1) + if !insert(identity{deviceID: id, owner: 7, generation: uint32(id + 100), handle: int(id)}) { + t.Fatalf("unexpected duplicate device ID %d", id) + } + } + maxComparisons := 0 + for id := uint64(1); id <= 32; id++ { + got, comparisons, ok := lookup(id) + if !ok || got.owner != 7 || got.generation != uint32(id+100) { + t.Fatalf("identity lookup %d returned %+v ok=%t", id, got, ok) + } + if comparisons > maxComparisons { + maxComparisons = comparisons + } + } + for _, absent := range []uint64{0, 33, 1 << 63} { + if _, comparisons, ok := lookup(absent); ok || comparisons > 6 { + t.Fatalf("absent lookup %d ok=%t comparisons=%d", absent, ok, comparisons) + } + } + if maxComparisons > 6 || maxComparisons >= 32 { + t.Fatalf("binary lookup comparisons=%d, want <=6 and below O(32) scan", maxComparisons) + } + + // A delayed cleanup removes only its exact handle. It cannot revoke a new + // owner/generation which reused the same logical ID after retirement. + removeHandle(13) + if !insert(identity{deviceID: 13, owner: 9, generation: 900, handle: 113}) { + t.Fatal("retired logical ID could not be reused") + } + removeHandle(13) // stale cleanup for the old handle + got, _, ok := lookup(13) + if !ok || got.handle != 113 || got.owner != 9 || got.generation != 900 { + t.Fatalf("stale cleanup revoked successor identity: %+v ok=%t", got, ok) + } +} + +func TestNativeSubmitPurgeResetCancelAndCleanupInterleavings(t *testing.T) { + type endpoint struct { + open bool + active int + purgeWaiting bool + purgeComplete bool + resetWaiting bool + resetQueued bool + terminalDPCs int + cleaned bool + } + admit := func(state *endpoint) bool { + if !state.open { + return false + } + state.active++ + return true + } + closeForPurge := func(state *endpoint) { + state.open = false + state.purgeWaiting = true + } + tryPurgeComplete := func(state *endpoint) bool { + if !state.purgeWaiting || state.active != 0 { + return false + } + state.purgeComplete = true + return true + } + closeForReset := func(state *endpoint) { + state.open = false + state.resetWaiting = true + } + tryQueueReset := func(state *endpoint) bool { + if !state.resetWaiting || state.active != 0 { + return false + } + state.resetQueued = true + return true + } + runTerminalDPC := func(state *endpoint) { + state.terminalDPCs++ + state.active-- + if state.active < 0 { + t.Fatal("terminal DPC released unowned rundown") + } + } + cleanup := func(state *endpoint) bool { + if !state.purgeComplete || state.active != 0 { + return false + } + state.cleaned = true + return true + } + + // Submit wins BrokerLock. Purge closes subsequent admission but cannot pass + // the mandatory completion DPC which owns the admitted request. + submitFirst := endpoint{open: true} + if !admit(&submitFirst) { + t.Fatal("submit failed before lifecycle closure") + } + closeForPurge(&submitFirst) + if admit(&submitFirst) || tryPurgeComplete(&submitFirst) || cleanup(&submitFirst) { + t.Fatal("purge passed an admitted submit") + } + runTerminalDPC(&submitFirst) + if !tryPurgeComplete(&submitFirst) || !cleanup(&submitFirst) || + submitFirst.terminalDPCs != 1 { + t.Fatalf("submit-first path failed to drain through DPC: %+v", submitFirst) + } + + // Purge/remove wins the exclusive lifecycle boundary. A stale report never + // acquires rundown and cleanup can revoke immediately after PurgeComplete. + purgeFirst := endpoint{open: true} + closeForPurge(&purgeFirst) + if admit(&purgeFirst) || !tryPurgeComplete(&purgeFirst) || !cleanup(&purgeFirst) { + t.Fatalf("purge-first path admitted stale input: %+v", purgeFirst) + } + + // Cancellation still crosses the terminal DPC. Endpoint reset waits for the + // same owner but queues an acknowledged reset instead of PurgeComplete. + resetCancel := endpoint{open: true} + if !admit(&resetCancel) { + t.Fatal("cancelled URB was never admitted") + } + closeForReset(&resetCancel) + if tryQueueReset(&resetCancel) { + t.Fatal("reset publication passed a cancelled request before its DPC") + } + runTerminalDPC(&resetCancel) + if !tryQueueReset(&resetCancel) || resetCancel.terminalDPCs != 1 { + t.Fatalf("reset/cancel path failed to drain deterministically: %+v", resetCancel) + } + + // File cleanup publishes Closing before taking OwnerLock. Either validation + // read false first (the submit-first case above) or it observes this permanent + // close and cannot enter a successor owner's device generation. + closing := true + ownerMatches, generationMatches := true, true + if ownerMatches && generationMatches && !closing { + t.Fatal("post-cleanup owner validation admitted a report") + } +} diff --git a/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go b/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go new file mode 100644 index 00000000..8ce91c14 --- /dev/null +++ b/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go @@ -0,0 +1,1083 @@ +package udecx + +import ( + "strings" + "testing" +) + +func TestNativeEndpointQuiescenceUsesReadOnlyUdeCxQueueState(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + + // UdeCx exclusively owns the associated endpoint queue's START/PURGE + // state. VIIPER may observe that queue, but must never mutate it. + for _, mutation := range []string{ + "WdfIoQueuePurge(", + "WdfIoQueuePurgeSynchronously(", + "WdfIoQueueStart(", + "WdfIoQueueStop(", + "WdfIoQueueStopSynchronously(", + "WdfIoQueueDrain(", + "WdfIoQueueDrainSynchronously(", + } { + if strings.Contains(device, mutation) { + t.Fatalf("UdeCx-associated queue state is client-mutated by %s", mutation) + } + } + createQueue := normalizedContract(nativeCFunction(t, device, "ViiperCreateEndpointQueue")) + if !strings.Contains(createQueue, + "UdecxUsbEndpointSetWdfIoQueue(Endpoint, endpointContext->Queue);") { + t.Fatal("endpoint queue is no longer explicitly associated with UdeCx") + } + + // A WDF callback can be delivered, then preempted before its first + // BrokerLock acquisition. DriverNoRequests closes that otherwise invisible + // window; ActiveOperations joins the callback's terminal DPC afterward. + quiesce := normalizedContract(nativeCFunction(t, device, "ViiperWaitForEndpointQuiescence")) + requireContractOrder(t, quiesce, + "KeWaitForSingleObject( &endpointContext->OperationsDrained", + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "WdfIoQueueGetState(endpointContext->Queue, NULL, NULL);", + "WdfIoQueueDriverNoRequests", + "endpointContext->ActiveOperations", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "if (quiescent)", + "return;", + "KeDelayExecutionThread(") + purgeQuiescence := normalizedContract(nativeCFunction( + t, device, "ViiperWaitForEndpointPurgeQuiescence")) + requireContractOrder(t, purgeQuiescence, + "KeWaitForSingleObject( &endpointContext->OperationsDrained", + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "WdfIoQueueGetState( endpointContext->Queue, &queuedRequests, &driverRequests);", + "endpointContext->PurgeOutstanding", + "endpointContext->Purging", + "WdfIoQueueDriverNoRequests", + "driverRequests == 0", + "endpointContext->ActiveOperations", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "if (quiescent)", + "return;", + "KeDelayExecutionThread(") + for _, forbidden := range []string{ + "WDF_IO_QUEUE_READY", + "WdfIoQueueNoRequests", + "WDF_IO_QUEUE_IDLE", + "WDF_IO_QUEUE_PURGED", + "queuedRequests == 0", + } { + if strings.Contains(purgeQuiescence, forbidden) { + t.Fatalf("purge quiescence incorrectly waits on UdeCx-owned queue state %q", forbidden) + } + } + + queueUrb := normalizedContract(nativeCFunction(t, broker, "ViiperQueueUrb")) + requireContractOrder(t, queueUrb, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "ViiperEndpointOperationStarted(endpoint);", + "controllerContext->ShuttingDown", + "controllerContext->BrokerFaulted", + "deviceContext->InD0", + "deviceContext->Resetting", + "deviceContext->Purging", + "endpointContext->Resetting", + "endpointContext->Purging", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "if (!NT_SUCCESS(status))", + "ViiperAllocatePendingSlot(") + allocate := normalizedContract(nativeCFunction(t, broker, "ViiperAllocatePendingSlot")) + requireContractOrder(t, allocate, + "WdfSpinLockAcquire(ControllerContext->BrokerLock);", + "ControllerContext->ShuttingDown", + "ControllerContext->BrokerFaulted", + "deviceContext->InD0", + "endpointContext->Purging", + "endpointContext->Resetting", + "deviceContext->Resetting", + "deviceContext->Purging", + "pending->Request = Request;") + + purge := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointPurge")) + requireContractOrder(t, purge, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "InterlockedExchange(&endpointContext->Purging, TRUE);", + "InterlockedExchange(&endpointContext->StartAnnounced, FALSE);", + "outstanding = InterlockedIncrement(&endpointContext->PurgeOutstanding);", + "enqueueWorkItem = InterlockedCompareExchange( &endpointContext->PurgeWorkerActive, TRUE, FALSE) == FALSE;", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY);", + "ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointPurge);", + "if (enqueueWorkItem)", + "WdfWorkItemEnqueue(endpointContext->PurgeWorkItem);") + if strings.Contains(purge, "ViiperInvalidateEndpointInputReport") { + t.Fatal("DISPATCH-level endpoint PURGE must defer wait-lock-backed input invalidation to its passive work item") + } + enqueueEnd := strings.Index(purge, "WdfWorkItemEnqueue(endpointContext->PurgeWorkItem);") + if enqueueEnd < 0 || strings.Contains(purge[enqueueEnd+len("WdfWorkItemEnqueue(endpointContext->PurgeWorkItem);"):], "endpointContext") { + t.Fatal("PURGE work-item enqueue must remain the callback's final endpoint-context access") + } + purgeWork := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointPurgeWorkItem")) + requireContractOrder(t, purgeWork, + "WdfWorkItemGetParentObject(WorkItem)", + "for (;;)", + "endpointContext->PurgeOutstanding", + "endpointContext->PurgeWorkerActive", + "ViiperWaitForEndpointPurgeQuiescence( endpoint, &queueState, &queuedRequests, &driverRequests);", + "ViiperInvalidateEndpointInputReport(endpoint);", + "remaining = InterlockedDecrement(&endpointContext->PurgeOutstanding);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "UdecxUsbEndpointPurgeComplete(endpoint);", + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "endpointContext->PurgeOutstanding", + "InterlockedExchange(&endpointContext->PurgeWorkerActive, FALSE);") + decrement := strings.Index(purgeWork, + "remaining = InterlockedDecrement(&endpointContext->PurgeOutstanding);") + complete := strings.Index(purgeWork, "UdecxUsbEndpointPurgeComplete(endpoint);") + workerRelease := strings.LastIndex(purgeWork, + "InterlockedExchange(&endpointContext->PurgeWorkerActive, FALSE);") + if decrement < 0 || complete <= decrement || workerRelease <= complete { + t.Fatal("PURGE worker must decrement before completion and retain worker ownership through synchronous callbacks") + } + if !strings.Contains(header, "WDFWORKITEM PurgeWorkItem;") || + !strings.Contains(header, "volatile LONG PurgeOutstanding;") || + !strings.Contains(header, "volatile LONG PurgeWorkerActive;") || + !strings.Contains(header, "EVT_WDF_WORKITEM ViiperEvtEndpointPurgeWorkItem;") { + t.Fatal("endpoint context lost its counted passive PURGE worker state") + } + endpointAdd := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointAdd")) + requireContractOrder(t, endpointAdd, + "KeInitializeEvent(&endpointContext->OperationsDrained, NotificationEvent, TRUE);", + "WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtEndpointPurgeWorkItem);", + "workItemConfig.AutomaticSerialization = WdfFalse;", + "attributes.ParentObject = endpoint;", + "WdfWorkItemCreate( &workItemConfig, &attributes, &endpointContext->PurgeWorkItem);") + requireContractOrder(t, endpointAdd, + "WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtEndpointResetWorkItem);", + "workItemConfig.AutomaticSerialization = WdfFalse;", + "attributes.ParentObject = endpoint;", + "WdfWorkItemCreate( &workItemConfig, &attributes, &endpointContext->ResetWorkItem);") + start := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointStart")) + if !strings.Contains(start, "ViiperActivateEndpoint(Endpoint);") { + t.Fatal("explicit UdeCx START no longer opens the VIIPER endpoint admission gate") + } + activate := normalizedContract(nativeCFunction(t, device, "ViiperActivateEndpoint")) + requireContractOrder(t, activate, + "endpointContext->PurgeOutstanding", + "InterlockedExchange(&endpointContext->Purging, FALSE);", + "endpointContext->StartAnnounced, TRUE, FALSE", + "ViiperQueueEndpointLifecycleEvent( Endpoint, ViiperUdeOperationEndpointStart);", + "endpointContext->StartAnnounced, FALSE, TRUE") + if strings.Contains(activate, "PurgeWorkerActive") { + t.Fatal("synchronous final START must not wait for the still-executing PURGE worker to return") + } + reset := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointReset")) + if strings.Contains(reset, "ViiperInvalidateEndpointInputReport") { + t.Fatal("DISPATCH-level endpoint RESET must defer wait-lock-backed input invalidation to its passive work item") + } + resetWork := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointResetWorkItem")) + requireContractOrder(t, resetWork, + "resetCurrent = ViiperQuiesceResetByIdentity(", + "deviceContext->DeviceId", + "deviceContext->Generation", + "endpointContext->Descriptor.bEndpointAddress", + "FALSE, FALSE);", + "if (!resetCurrent)", + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "InterlockedExchange(&endpointContext->Resetting, FALSE);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "WdfRequestComplete(request, STATUS_DEVICE_NOT_READY);", + "ViiperInvalidateEndpointInputReport(endpoint);", + "ViiperQueueAcknowledgedEndpointLifecycleEvent(") + + controllerQuiesce := normalizedContract(nativeCFunction(t, device, "ViiperDrainControllerEndpointOperations")) + requireContractOrder(t, controllerQuiesce, + "ViiperAcquireDeviceLockShared(controllerContext);", + "deviceContext->Endpoints[endpointIndex]", + "ViiperWaitForEndpointQuiescence(endpoint);", + "ViiperReleaseDeviceLockShared(controllerContext);") + cleanup := normalizedContract(nativeCFunction(t, controller, "ViiperEvtDeviceSelfManagedIoCleanup")) + requireContractOrder(t, cleanup, + "InterlockedExchange(&context->ShuttingDown, TRUE);", + "ViiperPurgeOwnerOperations(Device, STATUS_DEVICE_REMOVED);", + "ViiperDrainControllerEndpointOperations(Device);", + "ViiperDrainUrbCompletions(Device);", + "context->PendingOperations", + "context->PendingCompletions", + "IsListEmpty(&context->CompletionQueue)", + "context->CompletionDpcActive", + "ViiperBeginControllerShutdown(Device);") + shutdown := normalizedContract(nativeCFunction(t, device, "ViiperBeginControllerShutdown")) + if strings.Contains(shutdown, "WdfIoQueueGetState") || + strings.Contains(shutdown, "KeWaitForSingleObject") { + t.Fatal("controller shutdown consumes children before, or waits after, the queue proof") + } + if !strings.Contains(shutdown, "UdecxUsbDevicePlugOutAndDelete(devices[index]);") { + t.Fatal("controller shutdown no longer consumes the snapshotted UdeCx children") + } +} + +func TestNativeEndpointPurgeWorkerCountsRepeatedAndReentrantCallbacks(t *testing.T) { + type purgeState struct { + purging bool + queueReady bool + driverNoRequest bool + driverRequests int + activeOperations int + queuedHostPolls int + outstanding int + workerActive bool + enqueues int + callbacks int + completions int + } + + beginPurge := func(state *purgeState) { + state.purging = true + // The callback closes upstream delivery. The associated WDF queue may + // retain READY bookkeeping until PurgeComplete acknowledges it. + state.callbacks++ + state.outstanding++ + if !state.workerActive { + state.workerActive = true + state.enqueues++ + } + } + start := func(state *purgeState) bool { + // START is allowed immediately after the final counter decrement, even + // though the completing worker remains active until the callback returns. + if state.outstanding != 0 { + return false + } + state.purging = false + state.queueReady = true + return true + } + quiescent := func(state *purgeState) bool { + // queuedHostPolls is intentionally excluded: those requests remain owned + // by UdeCx while delivery is stopped. + return state.outstanding > 0 && state.purging && + state.driverNoRequest && state.driverRequests == 0 && + state.activeOperations == 0 + } + completeOne := func(state *purgeState, duringComplete func()) bool { + if !state.workerActive || !quiescent(state) { + return false + } + // This is the source contract's decrement-before-PurgeComplete boundary. + state.outstanding-- + state.completions++ + if duringComplete != nil { + duringComplete() + } + if state.outstanding == 0 { + state.workerActive = false + } + return true + } + + state := purgeState{queueReady: true, driverNoRequest: true, queuedHostPolls: 7} + beginPurge(&state) + beginPurge(&state) + if state.outstanding != 2 || state.enqueues != 1 || !state.workerActive { + t.Fatalf("overlapping PURGE callbacks were not coalesced onto one counted worker: %+v", state) + } + if !completeOne(&state, func() { + if start(&state) { + t.Fatal("non-final PURGE completion admitted a synchronous START") + } + if !state.workerActive || state.outstanding != 1 { + t.Fatalf("worker ownership/count changed before non-final completion returned: %+v", state) + } + }) { + t.Fatal("first counted PURGE did not complete after driver quiescence") + } + if !completeOne(&state, func() { + if !start(&state) { + t.Fatal("final counter decrement did not admit synchronous START") + } + if !state.workerActive { + t.Fatal("worker ownership was released before synchronous completion callbacks") + } + beginPurge(&state) // reentrant from PurgeComplete + if state.enqueues != 1 || state.outstanding != 1 || !state.purging { + t.Fatalf("reentrant PURGE was lost or redundantly enqueued: %+v", state) + } + }) { + t.Fatal("second counted PURGE did not complete") + } + if !state.workerActive || state.outstanding != 1 { + t.Fatalf("worker did not retain a reentrant PURGE: %+v", state) + } + if !completeOne(&state, func() { + if !start(&state) || !state.workerActive { + t.Fatalf("final reentrant completion did not expose the intended START boundary: %+v", state) + } + }) { + t.Fatal("reentrant PURGE did not complete") + } + if state.outstanding != 0 || state.workerActive || state.enqueues != 1 || + state.completions != state.callbacks || state.queuedHostPolls != 7 { + t.Fatalf("counted worker lost a callback or consumed UdeCx-owned polls: %+v", state) + } + + for _, test := range []struct { + name string + state purgeState + want bool + }{ + {name: "ready with queued class-owned host polls", state: purgeState{ + purging: true, queueReady: true, driverNoRequest: true, + outstanding: 1, queuedHostPolls: 99}, want: true}, + {name: "non-ready queue is also observational", state: purgeState{ + purging: true, driverNoRequest: true, outstanding: 1, queuedHostPolls: 99}, want: true}, + {name: "framework callback delivered", state: purgeState{ + purging: true, driverNoRequest: false, outstanding: 1}}, + {name: "driver request held", state: purgeState{ + purging: true, driverNoRequest: true, driverRequests: 1, outstanding: 1}}, + {name: "VIIPER operation held", state: purgeState{ + purging: true, driverNoRequest: true, activeOperations: 1, outstanding: 1}}, + {name: "no outstanding callback", state: purgeState{ + purging: true, driverNoRequest: true}}, + {name: "START reopened gate", state: purgeState{ + driverNoRequest: true, outstanding: 1}}, + } { + if got := quiescent(&test.state); got != test.want { + t.Fatalf("%s: quiescent=%v want %v: %+v", test.name, got, test.want, test.state) + } + } +} + +func TestNativeResetQuiescenceIsExactGenerationAndFailClosed(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + + identityProof := normalizedContract(nativeCFunction(t, device, "ViiperQuiesceResetByIdentity")) + requireContractOrder(t, identityProof, + "ViiperAcquireDeviceLockShared(controllerContext);", + "deviceContext->DeviceId != DeviceId", + "deviceContext->Generation != Generation", + "ExpectedResetEpoch", + "deviceContext->Endpoints[EndpointAddress]", + "endpoint == ExpectedEndpoint", + "endpointContext->Resetting", + "ViiperWaitForEndpointQuiescence(endpoint);", + "endpointContext->Resetting", + "if (ReleaseGate)", + "InterlockedExchange(&endpointContext->Resetting, FALSE);", + "ViiperReleaseDeviceLockShared(controllerContext);", + "return found;") + + ack := normalizedContract(nativeCFunction(t, broker, "ViiperCompleteManagementOperation")) + requireContractOrder(t, ack, + "resetEpoch = ControllerContext->ManagementSlots[slot].ResetEpoch;", + "State = ViiperUdePendingCompleting;", + "resetReleased = ViiperQuiesceResetByIdentity(", + "Completion->DeviceId", + "Completion->Generation", + "device", + "TRUE);", + "if (!resetReleased)", + "WdfRequestComplete(request, STATUS_DEVICE_NOT_READY);", + "ViiperClearManagementSlotLocked(", + "return STATUS_DEVICE_NOT_READY;", + "WdfRequestComplete(request, (NTSTATUS)Completion->Status);") + + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + if !strings.Contains(header, "UDECXUSBDEVICE Device;") || + !strings.Contains(header, "UDECXUSBENDPOINT Endpoint;") || + !strings.Contains(header, "volatile LONG64 ResetDeviceEpoch;") { + t.Fatal("management slots lost their exact WDF-object identity pins") + } + queueLifecycle := normalizedContract(nativeCFunction(t, broker, "ViiperQueueAcknowledgedLifecycleEvent")) + requireContractOrder(t, queueLifecycle, + "WdfObjectReference(Device);", + "WdfObjectReference(Endpoint);", + "Kind == ViiperUdeOperationEndpointReset", + "deviceContext->ResetEpoch", + "endpointContext->ResetDeviceEpoch", + "pending->Device = Device;", + "pending->Endpoint = Endpoint;", + "pending->ResetEpoch =", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "if (!NT_SUCCESS(status))", + "ViiperReleaseManagementSlotReferences(Device, Endpoint);") + clearSlot := normalizedContract(nativeCFunction(t, broker, "ViiperClearManagementSlotLocked")) + requireContractOrder(t, clearSlot, + "*DeviceReference = pending->Device;", + "*EndpointReference = pending->Endpoint;", + "pending->Device = WDF_NO_HANDLE;", + "pending->Endpoint = WDF_NO_HANDLE;") + releasePins := normalizedContract(nativeCFunction(t, broker, "ViiperReleaseManagementSlotReferences")) + requireContractOrder(t, releasePins, + "WdfObjectDereference(Endpoint);", + "WdfObjectDereference(Device);") + if strings.Count(broker, "ViiperClearManagementSlotLocked(") != 4 || + strings.Count(broker, "ViiperReleaseManagementSlotReferences(") != 5 { + t.Fatal("a management-slot terminal path can bypass exact-handle release") + } +} + +func TestNativeDeviceResetEpochSupersedesOlderEndpointResets(t *testing.T) { + type endpointReset struct { + capturedEpoch uint64 + gate bool + published bool + } + type device struct { + epoch uint64 + resetGate bool + unavailable bool + } + admitDeviceReset := func(state *device) (uint64, bool) { + if state.unavailable || state.resetGate { + return state.epoch, false + } + state.resetGate = true + state.epoch++ + if state.epoch == 0 { + state.epoch++ + } + return state.epoch, true + } + endpointCurrent := func(state *device, endpoint *endpointReset) bool { + return endpoint.gate && !state.resetGate && + endpoint.capturedEpoch == state.epoch + } + failEndpoint := func(endpoint *endpointReset) { + // This endpoint owns its gate. A device reset/purge/shutdown remains an + // independent blocker and is not touched here. + endpoint.gate = false + } + + // Published endpoint reset, followed by a complete device reset, then a + // delayed endpoint ACK: the logical ID and WDF handle can both still match, + // but the private reset epoch makes the ACK stale. + state := device{} + published := endpointReset{capturedEpoch: state.epoch, gate: true, published: true} + deviceEpoch, admitted := admitDeviceReset(&state) + if !admitted || deviceEpoch != 1 { + t.Fatalf("device reset was not admitted exactly once: %+v", state) + } + state.resetGate = false // acknowledged device reset + if endpointCurrent(&state, &published) { + t.Fatal("stale published endpoint ACK survived a complete device reset") + } + failEndpoint(&published) + if published.gate || state.resetGate { + t.Fatalf("stale endpoint ACK disturbed post-device-reset gates: %+v %+v", state, published) + } + + // Endpoint worker was admitted but not yet published while a full device + // reset starts and completes. Its initial publication proof must fail too. + delayed := endpointReset{capturedEpoch: 4, gate: true} + state = device{epoch: 4} + if _, ok := admitDeviceReset(&state); !ok { + t.Fatal("device reset did not supersede delayed endpoint worker") + } + state.resetGate = false + if endpointCurrent(&state, &delayed) { + t.Fatal("delayed endpoint worker published across a complete device reset") + } + failEndpoint(&delayed) + + // One device reset supersedes every older endpoint transaction, not just + // the endpoint whose worker happened to run first. + state = device{epoch: 9} + left := endpointReset{capturedEpoch: 9, gate: true, published: true} + right := endpointReset{capturedEpoch: 9, gate: true, published: true} + if _, ok := admitDeviceReset(&state); !ok { + t.Fatal("device reset did not supersede two endpoints") + } + state.resetGate = false + for name, endpoint := range map[string]*endpointReset{"left": &left, "right": &right} { + if endpointCurrent(&state, endpoint) { + t.Fatalf("%s endpoint survived superseding device epoch", name) + } + failEndpoint(endpoint) + } + + // Rejected device-reset admission must not invalidate otherwise-current + // endpoint work by consuming an epoch. + state = device{epoch: 15, unavailable: true} + current := endpointReset{capturedEpoch: 15, gate: true} + if epoch, ok := admitDeviceReset(&state); ok || epoch != 15 || state.epoch != 15 { + t.Fatalf("rejected device reset advanced epoch: %+v", state) + } + state.unavailable = false + if !endpointCurrent(&state, ¤t) { + t.Fatal("rejected device reset incorrectly superseded endpoint work") + } +} + +func TestNativeDeviceDestroyAbortsPinnedManagementBeforeConsumingUdeHandle(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + + abortMatching := normalizedContract(nativeCFunction( + t, broker, "ViiperAbortManagementOperationsMatching")) + requireContractOrder(t, abortMatching, + "for (;;)", + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "Device == WDF_NO_HANDLE || controllerContext->ManagementSlots[index].Device == Device", + "matchingSlot = TRUE;", + "ViiperUdePendingCompleting", + "RetiredToken = token;", + "RetiredDeviceId =", + "RetiredDeviceGeneration =", + "RetiredEndpointGeneration =", + "RetiredNotificationPending =", + "ViiperUdePendingQueued;", + "State = ViiperUdePendingCompleting;", + "WdfObjectReference(request);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "WdfRequestComplete(request, Status);", + "ViiperClearManagementSlotLocked(", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "ViiperReleaseManagementSlotReferences(deviceReference, endpointReference);", + "if (!matchingSlot)", + "return;", + "KeDelayExecutionThread(KernelMode, FALSE, &retryInterval);") + dispatch := normalizedContract(nativeCFunction(t, broker, "ViiperDispatchNotificationEvents")) + requireContractOrder(t, dispatch, + "event = controllerContext->Notifications[controllerContext->NotificationHead];", + "RetiredNotificationPending", + "RetiredToken != event.Token", + "RetiredDeviceId != event.DeviceId", + "RetiredDeviceGeneration != event.Generation", + "RetiredEndpointGeneration != event.EndpointGeneration", + "RetiredNotificationPending = FALSE;", + "RetiredToken = 0;", + "event.Kind = ViiperUdeOperationCancel;", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "WdfRequestComplete(dequeueRequest, STATUS_SUCCESS);") + complete := normalizedContract(nativeCFunction(t, broker, "ViiperCompleteManagementOperation")) + requireContractOrder(t, complete, + "!ControllerContext->ManagementSlots[slot].RetiredNotificationPending", + "RetiredToken == Completion->Token", + "RetiredDeviceId == Completion->DeviceId", + "RetiredDeviceGeneration == Completion->Generation", + "RetiredEndpointGeneration == Completion->EndpointGeneration", + "RetiredToken = 0;", + "RetiredDeviceId = 0;", + "RetiredDeviceGeneration = 0;", + "RetiredEndpointGeneration = 0;", + "RetiredOwnerFile = WDF_NO_HANDLE;", + "retiredCompletion = TRUE;", + "return retiredCompletion ? STATUS_SUCCESS : STATUS_NOT_FOUND;") + clearSlot := normalizedContract(nativeCFunction(t, broker, "ViiperClearManagementSlotLocked")) + if strings.Contains(clearSlot, "RetiredToken") { + t.Fatal("terminal slot clear erases the harmless late-ACK tombstone") + } + queueLifecycle := normalizedContract(nativeCFunction( + t, broker, "ViiperQueueAcknowledgedLifecycleEvent")) + requireContractOrder(t, queueLifecycle, + "pending->State != ViiperUdePendingEmpty || pending->RetiredToken != 0", + "continue;", + "pending->OwnerFile = deviceContext->OwnerFile;", + "pending->Token = token;") + retireOwner := normalizedContract(nativeCFunction( + t, broker, "ViiperRetireManagementTombstonesForOwner")) + requireContractOrder(t, retireOwner, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "OwnerFile == WDF_NO_HANDLE || pending->RetiredOwnerFile == OwnerFile", + "pending->RetiredToken = 0;", + "pending->RetiredDeviceId = 0;", + "pending->RetiredDeviceGeneration = 0;", + "pending->RetiredEndpointGeneration = 0;", + "pending->RetiredOwnerFile = WDF_NO_HANDLE;", + "pending->RetiredNotificationPending = FALSE;", + "WdfSpinLockRelease(controllerContext->BrokerLock);") + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + fileConfig := normalizedContract(nativeCFunction(t, controller, "ViiperEvtDeviceAdd")) + if !strings.Contains(fileConfig, + "WDF_FILEOBJECT_CONFIG_INIT( &fileConfig, ViiperEvtFileCreate, ViiperEvtFileClose, ViiperEvtFileCleanup);") { + t.Fatal("owner-session tombstones are not tied to KMDF's post-I/O file-close boundary") + } + fileClose := normalizedContract(nativeCFunction(t, controller, "ViiperEvtFileClose")) + requireContractOrder(t, fileClose, + "ShuttingDown", + "fileContext->BrokerOwner", + "ViiperRetireManagementTombstonesForOwner(", + "WdfFileObjectGetDevice(FileObject), FileObject);") + cleanup := normalizedContract(nativeCFunction( + t, controller, "ViiperEvtDeviceSelfManagedIoCleanup")) + requireContractOrder(t, cleanup, + "WdfIoQueuePurgeSynchronously(context->ControlQueue);", + "ViiperPurgeOwnerOperations(Device, STATUS_DEVICE_REMOVED);", + "ViiperRetireManagementTombstonesForOwner(Device, WDF_NO_HANDLE);", + "ViiperBeginControllerShutdown(Device);") + destroy := normalizedContract(nativeCFunction(t, device, "ViiperDestroyVirtualDevice")) + requireContractOrder(t, destroy, + "ViiperBeginRemoveDevice(", + "ViiperAbortDeviceManagementOperations(controller, device, STATUS_DEVICE_REMOVED);", + "UdecxUsbDevicePlugOutAndDelete(device);") + destroyOwned := normalizedContract(nativeCFunction(t, device, "ViiperDestroyOwnedDevices")) + requireContractOrder(t, destroyOwned, + "ViiperBeginRemoveDevice(", + "plugged = deviceContext->Plugged;", + "ViiperAbortDeviceManagementOperations(Controller, device, STATUS_FILE_CLOSED);", + "if (plugged)", + "UdecxUsbDevicePlugOutAndDelete(device)") + + // Deterministic no-ACK interleaving: the broker holds an endpoint-reset + // request and both exact WDF-object pins. Removing this device must retire + // only that slot and release both pins before the UDE handle is consumed; + // another device's management request remains live. + type managementSlot struct { + devicePin int + endpointPin int + pending bool + completed bool + } + slots := []managementSlot{ + {devicePin: 7, endpointPin: 71, pending: true}, + {devicePin: 8, endpointPin: 81, pending: true}, + } + abortDevice := func(devicePin int) { + for index := range slots { + slot := &slots[index] + if !slot.pending || slot.devicePin != devicePin { + continue + } + slot.completed = true + slot.pending = false + slot.endpointPin = 0 + slot.devicePin = 0 + } + } + deviceTableContainsSeven := true + purgingSeven := false + udeSevenConsumed := false + purgingSeven = true + deviceTableContainsSeven = false + abortDevice(7) // no owner acknowledgement arrives + if slots[0].pending || !slots[0].completed || + slots[0].devicePin != 0 || slots[0].endpointPin != 0 { + t.Fatalf("destroy stranded exact management references: %+v", slots[0]) + } + if !slots[1].pending || slots[1].completed || + slots[1].devicePin != 8 || slots[1].endpointPin != 81 { + t.Fatalf("exact-device abort disturbed an unrelated child: %+v", slots[1]) + } + if !purgingSeven || deviceTableContainsSeven { + t.Fatal("device removal did not close admission before management abort") + } + udeSevenConsumed = true + if !udeSevenConsumed || slots[0].devicePin != 0 { + t.Fatal("UDE handle was consumed before its management pin drained") + } + + // A queued token is retired in O(1), then dispatch converts that one record + // to a benign cancel and preserves the unrelated child's next event. If + // dispatch already won, the exact slot tombstone accepts one late ACK. + type notification struct { + token uint64 + device int + } + queued := []notification{{token: 101, device: 7}, {token: 202, device: 8}} + queuedRetiredToken := uint64(101) + dispatched := queued[0] + queued = queued[1:] + dispatchedAsCancel := dispatched.token == queuedRetiredToken + queuedRetiredToken = 0 + if !dispatchedAsCancel || queuedRetiredToken != 0 || + len(queued) != 1 || queued[0].token != 202 || queued[0].device != 8 { + t.Fatalf("queued abort corrupted unrelated lifecycle FIFO: %+v", queued) + } + retiredToken := uint64(303) // dispatch crossed BrokerLock before abort + acceptLate := func(token uint64) bool { + if retiredToken != token { + return false + } + retiredToken = 0 + return true + } + if !acceptLate(303) || retiredToken != 0 || acceptLate(303) { + t.Fatal("already-delivered teardown token was not consumed exactly once") + } + + // Slot tombstones are device-bound and non-reusable. B must allocate a + // different empty slot, so removing B cannot overwrite A before A's late + // ACK. A malformed device identity cannot consume A's proof. + type retiredManagement struct { + token uint64 + deviceID uint64 + generation uint32 + owner int + } + retired := []retiredManagement{ + {token: 401, deviceID: 41, generation: 4, owner: 1}, + {}, + } + allocateEmpty := func() int { + for index := range retired { + if retired[index].token == 0 { + return index + } + } + return -1 + } + bSlot := allocateEmpty() + if bSlot != 1 { + t.Fatalf("allocator reused A tombstone: slot=%d state=%+v", bSlot, retired) + } + retired[bSlot] = retiredManagement{token: 502, deviceID: 52, generation: 5, owner: 1} + acceptBoundLate := func(slot int, token, deviceID uint64, generation uint32) bool { + proof := &retired[slot] + if proof.token != token || proof.deviceID != deviceID || + proof.generation != generation { + return false + } + *proof = retiredManagement{} + return true + } + if acceptBoundLate(0, 401, 99, 4) || retired[0].token != 401 { + t.Fatal("malformed completion consumed A's device-bound tombstone") + } + if !acceptBoundLate(0, 401, 41, 4) || retired[1].token != 502 { + t.Fatalf("B removal overwrote A tombstone: %+v", retired) + } + if allocateEmpty() != 0 { + t.Fatal("consuming A did not safely release only A's slot") + } + retired[0] = retiredManagement{token: 603, deviceID: 63, generation: 6, owner: 1} + if allocateEmpty() != -1 { + t.Fatal("allocator did not fail closed when every slot held a late-ACK proof") + } + for index := range retired { + if retired[index].owner == 1 { // KMDF EvtFileClose: old I/O drained + retired[index] = retiredManagement{} + } + } + if allocateEmpty() != 0 { + t.Fatal("post-I/O owner close did not release retired slot capacity") + } +} + +func TestNativeDeliveredBeforeRundownInterleavings(t *testing.T) { + type endpoint struct { + open bool + purgeCallback bool + queueAccepting bool + queueDispatching bool + queued int + driverOwned int + driverNoRequests bool + active int + terminalDPCs int + resetOutstanding bool + } + deliverByWDF := func(state *endpoint) bool { + // The asynchronous reset request is the class-extension fence: UdeCx + // cannot deliver a successor transfer until the client completes it. + if state.purgeCallback || state.resetOutstanding || + !state.queueDispatching || state.queued == 0 { + return false + } + state.queued-- + state.driverOwned++ + state.driverNoRequests = false + return true + } + resumeDeliveredCallback := func(state *endpoint) bool { + if state.driverOwned == 0 { + t.Fatal("resumed a callback WDF does not own") + } + state.active++ + // Lifecycle closure is checked in the same BrokerLock transaction as + // rundown entry. A closed request owns only its terminal DPC. + return state.open + } + runTerminalDPC := func(state *endpoint) { + if state.driverOwned == 0 || state.active == 0 { + t.Fatal("terminal DPC released unowned WDF/rundown state") + } + state.terminalDPCs++ + state.active-- + state.driverOwned-- + state.driverNoRequests = state.driverOwned == 0 + } + udeCxBeginPurge := func(state *endpoint) { + state.open = false + state.purgeCallback = true + // UdeCx owns this transition. The visible queue may retain READY state + // (0x0f) until PurgeComplete, but the callback is the upstream boundary: + // no successor transfer may be delivered through this purge instance. + } + queuePurgeComplete := func(state *endpoint) bool { + return state.purgeCallback && state.driverNoRequests && + state.driverOwned == 0 && state.active == 0 && !state.open + } + closeForShutdown := func(state *endpoint) { + // The controller admission gate closes first. Queued host polls remain + // owned by UdeCx until PlugOutAndDelete requests endpoint PURGE. + state.open = false + } + driverQuiescent := func(state *endpoint) bool { + return state.driverOwned == 0 && state.active == 0 + } + closeForReset := func(state *endpoint) { + state.open = false + state.resetOutstanding = true + } + resetQuiescent := func(state *endpoint) bool { + // A parked interrupt poll may remain queued and the associated queue + // may remain ready. Only driver-owned callbacks plus rundown matter. + return state.driverOwned == 0 && state.active == 0 + } + + purge := endpoint{ + open: true, + queueAccepting: true, + queueDispatching: true, + queued: 2, + driverNoRequests: true, + } + if !deliverByWDF(&purge) { + t.Fatal("purge: WDF did not deliver the pre-boundary callback") + } + udeCxBeginPurge(&purge) + if queuePurgeComplete(&purge) { + t.Fatal("purge passed a WDF-delivered callback before rundown entry") + } + if resumeDeliveredCallback(&purge) { + t.Fatal("purge callback allocated/published after lifecycle closure") + } + if queuePurgeComplete(&purge) { + t.Fatal("purge completed before the terminal DPC") + } + runTerminalDPC(&purge) + if !queuePurgeComplete(&purge) || purge.terminalDPCs != 1 || + !purge.queueAccepting || !purge.queueDispatching || purge.queued != 1 { + t.Fatalf("purge consumed queued host polls or failed driver-rundown proof: %+v", purge) + } + + // Direct input is admitted through the controller queue, so endpoint queue + // state alone cannot make PURGE complete. ActiveOperations is the second + // half of the proof and is sampled under the same BrokerLock. + direct := endpoint{ + open: true, + queueAccepting: true, + queueDispatching: true, + driverNoRequests: true, + active: 1, + } + udeCxBeginPurge(&direct) + if queuePurgeComplete(&direct) { + t.Fatal("purge passed direct input while the associated queue was idle") + } + direct.active-- + if !queuePurgeComplete(&direct) { + t.Fatalf("purge did not complete after direct input rundown: %+v", direct) + } + + shutdown := endpoint{ + open: true, + queueAccepting: true, + queueDispatching: true, + queued: 2, + driverNoRequests: true, + } + if !deliverByWDF(&shutdown) { + t.Fatal("shutdown: WDF did not deliver the pre-boundary callback") + } + closeForShutdown(&shutdown) + if driverQuiescent(&shutdown) { + t.Fatal("shutdown passed a WDF-delivered callback before rundown entry") + } + if resumeDeliveredCallback(&shutdown) { + t.Fatal("shutdown callback allocated/published after lifecycle closure") + } + runTerminalDPC(&shutdown) + if !driverQuiescent(&shutdown) || !shutdown.queueDispatching || shutdown.queued != 1 { + t.Fatalf("shutdown waited on class-extension-owned queued polls: %+v", shutdown) + } + udeCxBeginPurge(&shutdown) // UdeCx callback after child consumption. + if !queuePurgeComplete(&shutdown) { + t.Fatalf("post-consumption endpoint purge did not complete: %+v", shutdown) + } + + reset := endpoint{ + open: true, + queueAccepting: true, + queueDispatching: true, + queued: 2, + driverNoRequests: true, + } + if !deliverByWDF(&reset) { + t.Fatal("reset: WDF did not deliver the pre-boundary callback") + } + closeForReset(&reset) + if resetQuiescent(&reset) { + t.Fatal("reset publication passed a WDF-delivered callback before rundown entry") + } + if resumeDeliveredCallback(&reset) { + t.Fatal("reset callback allocated/published after reset closure") + } + if resetQuiescent(&reset) { + t.Fatal("reset publication passed the callback before terminal DPC completion") + } + runTerminalDPC(&reset) + if !resetQuiescent(&reset) || reset.queued != 1 || !reset.queueDispatching { + t.Fatalf("reset failed ready-queue DriverNoRequests proof: %+v", reset) + } + if deliverByWDF(&reset) { + t.Fatal("UdeCx delivered a successor callback before reset acknowledgement") + } + // Owner ACK repeats the proof. Only then may the exact reset gate reopen + // and the asynchronous reset request be completed. + if !resetQuiescent(&reset) { + t.Fatal("reset acknowledgement missed the second quiescence proof") + } + reset.open = true + reset.resetOutstanding = false + if !deliverByWDF(&reset) { + t.Fatal("post-reset queue did not resume after acknowledgement") + } +} + +func TestNativeResetAcknowledgementRejectsRemovalAndIdentityReuse(t *testing.T) { + type identity struct { + deviceID uint64 + generation uint32 + handle uint64 + endpointExists bool + resetting bool + } + ack := func(current *identity, deviceID uint64, generation uint32, handle uint64) bool { + if current == nil || current.deviceID != deviceID || + current.generation != generation || current.handle != handle || + !current.endpointExists || + !current.resetting { + return false + } + current.resetting = false + return true + } + + original := identity{deviceID: 7, generation: 41, handle: 1001, endpointExists: true, resetting: true} + removed := original + removed.endpointExists = false + if ack(&removed, original.deviceID, original.generation, original.handle) || !removed.resetting { + t.Fatal("removed endpoint accepted an acknowledgement or reopened its gate") + } + + // A hostile/raw broker can reuse both logical fields, and generation wraps + // eventually. The pinned old WDF handle cannot be recycled until slot clear, + // so the successor must still reject the delayed acknowledgement. + successor := identity{deviceID: 7, generation: 41, handle: 2002, endpointExists: true, resetting: true} + if ack(&successor, original.deviceID, original.generation, original.handle) || !successor.resetting { + t.Fatal("stale acknowledgement reopened an exact logical identity on a successor handle") + } + if !ack(&successor, successor.deviceID, successor.generation, successor.handle) || successor.resetting { + t.Fatal("exact live generation did not accept its own acknowledgement") + } +} + +func TestNativeOverlappingEndpointAndDeviceResetReleaseOnlyOwnedGate(t *testing.T) { + type gates struct { + deviceReset bool + endpointReset bool + purging bool + shutdown bool + brokerFault bool + } + admissionOpen := func(state gates) bool { + return !state.deviceReset && !state.endpointReset && !state.purging && + !state.shutdown && !state.brokerFault + } + + state := gates{endpointReset: true} + // Device reset wins after endpoint reset admission. The endpoint worker's + // exact proof fails because the device-wide gate is now closed; it must + // release only the endpoint gate before failing its actual reset request. + state.deviceReset = true + state.endpointReset = false + if admissionOpen(state) || !state.deviceReset { + t.Fatalf("endpoint failure disturbed the winning device reset: %+v", state) + } + + // The device reset's own publication proof can then lose to purge. Its + // callback-owned gate is released, while purge remains the independent + // admission blocker. + state.purging = true + state.deviceReset = false + if admissionOpen(state) || !state.purging { + t.Fatalf("device failure disturbed the winning purge: %+v", state) + } +} + +func TestNativeResetPublicationRejectsConcurrentRemoval(t *testing.T) { + type resetBoundary struct { + resetting bool + purging bool + present bool + published bool + } + publish := func(state *resetBoundary) bool { + if !state.present || !state.resetting || state.purging { + return false + } + state.published = true + return true + } + + for _, test := range []struct { + name string + state resetBoundary + }{ + {name: "device removed", state: resetBoundary{resetting: true, present: false}}, + {name: "device purge won", state: resetBoundary{resetting: true, purging: true, present: true}}, + {name: "endpoint retired", state: resetBoundary{resetting: true, present: false}}, + } { + if publish(&test.state) || test.state.published { + t.Fatalf("%s published a reset for a dead lifecycle identity", test.name) + } + } + + live := resetBoundary{resetting: true, present: true} + if !publish(&live) || !live.published { + t.Fatal("live exact reset identity did not publish after quiescence") + } +} + +func TestNativeDuplicateEndpointAddCannotRetireLiveIncarnation(t *testing.T) { + add := normalizedContract(nativeCFunction(t, + nativeContractSource(t, "native", "udecx", "driver", "Device.c"), + "ViiperEvtEndpointAdd")) + + requireContractOrder(t, add, + "deviceContext->Endpoints[descriptor.bEndpointAddress] != WDF_NO_HANDLE", + "status = STATUS_OBJECT_NAME_COLLISION;", + "deviceContext->EndpointGenerations[ descriptor.bEndpointAddress] == MAXULONG", + "deviceContext->EndpointGenerations[descriptor.bEndpointAddress] = generation;") + if !strings.Contains(add, + "descriptor.bEndpointAddress == 0 && deviceContext->DefaultEndpoint != WDF_NO_HANDLE") { + t.Fatal("endpoint-add can advance endpoint zero while a default endpoint is live") + } + + type endpointSlot struct { + generation uint32 + live bool + } + allocate := func(slot *endpointSlot) (uint32, bool) { + if slot.live || slot.generation == ^uint32(0) { + return 0, false + } + slot.generation++ + return slot.generation, true + } + slot := endpointSlot{generation: 1, live: true} + if generation, admitted := allocate(&slot); admitted || generation != 0 || slot.generation != 1 { + t.Fatalf("duplicate add retired live generation: admitted=%t result=%d slot=%+v", + admitted, generation, slot) + } + // Once cleanup retires the exact live object, the successor gets a fresh + // incarnation; the failed duplicate did not create an unobservable hole. + slot.live = false + if generation, admitted := allocate(&slot); !admitted || generation != 2 { + t.Fatalf("successor allocation=(%d,%t) want generation 2", generation, admitted) + } +} diff --git a/internal/transport/udecx/driver_iso_contract_test.go b/internal/transport/udecx/driver_iso_contract_test.go new file mode 100644 index 00000000..020ba6db --- /dev/null +++ b/internal/transport/udecx/driver_iso_contract_test.go @@ -0,0 +1,181 @@ +package udecx + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func nativeDriverBrokerSource(t *testing.T) string { + t.Helper() + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve native driver contract test path") + } + path := filepath.Join(filepath.Dir(currentFile), "..", "..", "..", "native", "udecx", "driver", "Broker.c") + source, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read native UdeCx broker source: %v", err) + } + return string(source) +} + +func TestNativeDriverIsoFrameSpanUsesWindowsPacketUnits(t *testing.T) { + source := nativeDriverBrokerSource(t) + start := strings.Index(source, "ViiperIsoFrameSpan(") + if start < 0 { + t.Fatal("native ISO frame reservation helpers are missing") + } + end := strings.Index(source[start:], "ViiperReserveIsoStartFrame(") + if end < 0 { + t.Fatal("native ISO frame reservation helpers are missing") + } + span := source[start : start+end] + + // A high/super-speed IsoPacket is one service opportunity measured in + // microframes, so the descriptor exponent is converted to 1-ms StartFrame + // units. A full-speed IsoPacket is already one 1-ms frame according to the + // Windows URB contract; multiplying by bInterval here schedules the same + // polling period twice and leaves artificial holes between reservations. + for _, required := range []string{ + "deviceContext->Speed == UdecxUsbHighSpeed", + "deviceContext->Speed == UdecxUsbSuperSpeed", + "PacketCount * ((ULONGLONG)1 << (interval - 1))", + "span = (span + 7) / 8;", + "span = PacketCount;", + } { + if !strings.Contains(span, required) { + t.Fatalf("native ISO frame span is missing %q", required) + } + } + if strings.Contains(span, "PacketCount * interval") { + t.Fatal("full-speed ISO frame span still multiplies Windows packet units by bInterval") + } +} + +func TestNativeDriverIsoFrameSpanPreservesPlayStationCadence(t *testing.T) { + frameSpan := func(highSpeed bool, interval uint8, packets uint32) uint32 { + if packets == 0 { + return 1 + } + if interval == 0 { + return packets + } + if !highSpeed { + return packets + } + if interval > 16 { + return packets + } + microframes := uint64(packets) * (uint64(1) << (interval - 1)) + return uint32((microframes + 7) / 8) + } + + tests := []struct { + name string + highSpeed bool + interval uint8 + packets uint32 + want uint32 + }{ + {name: "DualShock 4 full-speed audio", interval: 1, packets: 32, want: 32}, + {name: "DualSense high-speed one-ms audio", highSpeed: true, interval: 4, packets: 32, want: 32}, + {name: "high-speed 125-us service", highSpeed: true, interval: 1, packets: 32, want: 4}, + {name: "full-speed descriptor interval is not applied twice", interval: 4, packets: 32, want: 32}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := frameSpan(test.highSpeed, test.interval, test.packets); got != test.want { + t.Fatalf("frame span=%d want=%d", got, test.want) + } + }) + } +} + +func TestNativeDriverRejectedExplicitIsoReservationDoesNotAdvanceTail(t *testing.T) { + source := nativeDriverBrokerSource(t) + start := strings.Index(source, "ViiperReserveIsoStartFrame(") + if start < 0 { + t.Fatal("native ISO reservation helper is missing") + } + end := strings.Index(source[start:], "ViiperCopyTransferBuffer(") + if end < 0 { + t.Fatal("native ISO reservation helper boundary is missing") + } + reservation := source[start : start+end] + for _, required := range []string{ + "requestedDelta <= 0", + "requestedDelta >= USBD_ISO_START_FRAME_RANGE", + "(LONG)(RequestedStartFrame - startFrame) < 0", + "InterlockedCompareExchange64(", + } { + if !strings.Contains(reservation, required) { + t.Fatalf("explicit ISO reservation is missing %q", required) + } + } + if strings.Contains(reservation, "InterlockedExchange64(") { + t.Fatal("explicit ISO reservation can still overwrite the endpoint tail unconditionally") + } + + reserve := func(tail, current, requested, span uint32, asap bool) (uint32, uint32) { + if !asap { + delta := int32(requested - current) + if delta <= 0 || delta >= 1024 { + return requested, tail + } + if tail != 0 && int32(tail-current) > 0 && int32(requested-tail) < 0 { + return requested, tail + } + return requested, requested + span + } + startFrame := tail + if tail == 0 || int32(startFrame-current) <= 0 { + startFrame = current + 1 + } + return startFrame, startFrame + span + } + + const current = uint32(90) + const previousTail = uint32(132) + startFrame, tail := reserve(previousTail, current, 110, 32, false) + if startFrame != 110 || tail != previousTail { + t.Fatalf("overlapping explicit reservation start=%d tail=%d want start=110 tail=%d", + startFrame, tail, previousTail) + } + startFrame, tail = reserve(tail, current, 0, 32, true) + if startFrame != previousTail || tail != 164 { + t.Fatalf("ASAP after rejected explicit start=%d tail=%d want start=132 tail=164", + startFrame, tail) + } + _, tail = reserve(previousTail, current, current+1024, 32, false) + if tail != previousTail { + t.Fatalf("out-of-range explicit reservation advanced tail to %d", tail) + } +} + +func TestNativeDriverPreciseIsoClockSuppliesRequiredQpcOutput(t *testing.T) { + source := nativeDriverBrokerSource(t) + start := strings.Index(source, "ViiperReserveIsoStartFrame(") + if start < 0 { + t.Fatal("native ISO reservation helper is missing") + } + end := strings.Index(source[start:], "ViiperCopyTransferBuffer(") + if end < 0 { + t.Fatal("native ISO reservation helper boundary is missing") + } + reservation := source[start : start+end] + + for _, required := range []string{ + "ULONGLONG qpcTimestamp;", + "KeQueryInterruptTimePrecise(&qpcTimestamp)", + } { + if !strings.Contains(reservation, required) { + t.Fatalf("native precise ISO clock is missing %q", required) + } + } + if strings.Contains(reservation, "KeQueryInterruptTimePrecise(NULL)") { + t.Fatal("native precise ISO clock passes a null mandatory QPC output pointer") + } +} diff --git a/internal/transport/udecx/driver_lifecycle_contract_test.go b/internal/transport/udecx/driver_lifecycle_contract_test.go new file mode 100644 index 00000000..e51dbcab --- /dev/null +++ b/internal/transport/udecx/driver_lifecycle_contract_test.go @@ -0,0 +1,918 @@ +package udecx + +import ( + "strings" + "testing" +) + +func nativeCFunction(t *testing.T, source, name string) string { + t.Helper() + start := strings.Index(source, "\n"+name+"(") + if start < 0 { + t.Fatalf("native function %s is missing", name) + } + start++ + openOffset := strings.IndexByte(source[start:], '{') + if openOffset < 0 { + t.Fatalf("native function %s has no body", name) + } + open := start + openOffset + depth := 0 + for index := open; index < len(source); index++ { + switch source[index] { + case '/': + if index+1 >= len(source) { + continue + } + switch source[index+1] { + case '/': + newline := strings.IndexByte(source[index+2:], '\n') + if newline < 0 { + t.Fatalf("native function %s has an unterminated line comment", name) + } + index += newline + 2 + case '*': + closeComment := strings.Index(source[index+2:], "*/") + if closeComment < 0 { + t.Fatalf("native function %s has an unterminated block comment", name) + } + index += closeComment + 3 + } + case '\'', '"': + quote := source[index] + for index++; index < len(source); index++ { + if source[index] == '\\' { + index++ + continue + } + if source[index] == quote { + break + } + } + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return source[start : index+1] + } + } + } + t.Fatalf("native function %s has an unterminated body", name) + return "" +} + +func normalizedContract(source string) string { + return strings.Join(strings.Fields(source), " ") +} + +func requireContractOrder(t *testing.T, source string, fragments ...string) { + t.Helper() + cursor := 0 + for _, fragment := range fragments { + offset := strings.Index(source[cursor:], fragment) + if offset < 0 { + t.Fatalf("native contract lost ordered fragment %q in:\n%s", fragment, source) + } + cursor += offset + len(fragment) + } +} + +func TestKernelOwnerCleanupJoinsFiniteMutationRundown(t *testing.T) { + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + all := controller + device + header + for _, obsolete := range []string{"OwnerCleanupTimer", "ViiperEvtOwnerCleanupRetry"} { + if strings.Contains(all, obsolete) { + t.Fatalf("owner cleanup still depends on unbounded retry state %q", obsolete) + } + } + if !strings.Contains(header, "KEVENT OwnerAdmissionsDrained;") { + t.Fatal("controller lost the finite owner-admission rundown event") + } + if !strings.Contains(controller, + "KeInitializeEvent(&context->OwnerAdmissionsDrained, NotificationEvent, TRUE);") { + t.Fatal("owner-admission rundown must start signaled") + } + + begin := normalizedContract(nativeCFunction(t, device, "ViiperBeginOwnerAdmission")) + requireContractOrder(t, begin, + "WdfWaitLockAcquire(controllerContext->OwnerLock, NULL);", + "InterlockedIncrement(&controllerContext->ActiveOwnerAdmissions) == 1", + "KeClearEvent(&controllerContext->OwnerAdmissionsDrained);", + "WdfWaitLockRelease(controllerContext->OwnerLock);") + end := normalizedContract(nativeCFunction(t, device, "ViiperEndOwnerAdmission")) + requireContractOrder(t, end, + "InterlockedDecrement(&controllerContext->ActiveOwnerAdmissions);", + "if (remaining == 0)", + "KeSetEvent(&controllerContext->OwnerAdmissionsDrained, IO_NO_INCREMENT, FALSE);", + "WdfWaitLockRelease(controllerContext->OwnerLock);", + "WdfObjectDereference(OwnerFile);") + + finish := normalizedContract(nativeCFunction(t, controller, "ViiperFinishOwnerCleanup")) + requireContractOrder(t, finish, + "WdfWaitLockRelease(context->OwnerLock);", + "ViiperWaitForControllerRundown( Device, &context->OwnerAdmissionsDrained", + "ViiperDestroyOwnedDevices(Device, OwnerFile)", + "context->OwnerFile = WDF_NO_HANDLE;", + "WdfObjectDereference(OwnerFile);") + wait := normalizedContract(controller) + requireContractOrder(t, wait, + "VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS", + "KeWaitForSingleObject( Event", + "if (waitStatus != STATUS_TIMEOUT)", + "STATUS_IO_TIMEOUT", + "InterlockedCompareExchange(ActiveCounter, 0, 0)") + + for _, name := range []string{"ViiperCreateVirtualDevice", "ViiperDestroyVirtualDevice"} { + mutation := normalizedContract(nativeCFunction(t, device, name)) + requireContractOrder(t, mutation, + "ViiperBeginOwnerAdmission(controller, Request, &ownerFile)", + "ViiperEndOwnerAdmission(controller, ownerFile);") + } +} + +func TestKernelDelayedCleanupReservesPhysicalPortAndCannotRevokeSuccessor(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + ioctl := nativeContractSource(t, "native", "udecx", "driver", "Ioctl.c") + for _, required := range []string{ + "ULONGLONG PortReservationEpochs[VIIPER_UDE_MAX_DEVICES];", + "BOOLEAN PortReserved[VIIPER_UDE_MAX_DEVICES];", + "volatile LONG ReservedPorts;", + "ULONGLONG PortReservation;", + } { + if !strings.Contains(header, required) { + t.Fatalf("physical port reservation contract missing %q", required) + } + } + + claim := normalizedContract(nativeCFunction(t, device, "ViiperClaimDeviceSlot")) + requireContractOrder(t, claim, + "if (current == WDF_NO_HANDLE)", + "if (!ControllerContext->PortReserved[index] && freeSlot == VIIPER_UDE_MAX_DEVICES)", + "freeSlot = index;", + "ControllerContext->PortReserved[freeSlot] = TRUE;", + "InterlockedIncrement(&ControllerContext->ReservedPorts);", + "ControllerContext->Devices[freeSlot] = Device;", + "*PortReservation = reservation;") + release := normalizedContract(nativeCFunction(t, device, "ViiperReleaseDeviceSlot")) + requireContractOrder(t, release, + "ControllerContext->PortReservationEpochs[Slot] == PortReservation", + "if (ControllerContext->Devices[Slot] == Device)", + "ControllerContext->Devices[Slot] = WDF_NO_HANDLE;", + "ControllerContext->PortReserved[Slot] = FALSE;", + "InterlockedDecrement(&ControllerContext->ReservedPorts);", + "NT_ASSERT(remaining >= 0);") + + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + controllerCleanup := normalizedContract(nativeCFunction( + t, controller, "ViiperEvtControllerCleanup")) + requireContractOrder(t, controllerCleanup, + "context->ActiveDevices", + "context->ReservedPorts", + "context->InputDeviceCount == 0", + "for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index)", + "NT_ASSERT(!context->PortReserved[index]);") + queryStats := normalizedContract(nativeCFunction(t, ioctl, "ViiperHandleQueryStats")) + requireContractOrder(t, queryStats, + "RtlZeroMemory(output, sizeof(*output));", + "output->ReservedPorts =", + "InterlockedCompareExchange(&context->ReservedPorts, 0, 0);", + "WdfRequestSetInformation(Request, sizeof(*output));") + + remove := normalizedContract(nativeCFunction(t, device, "ViiperBeginRemoveDevice")) + requireContractOrder(t, remove, + "InterlockedExchange(&deviceContext->Purging, TRUE);", + "ControllerContext->Devices[index] = WDF_NO_HANDLE;", + "ViiperRetireActiveDevice(ControllerContext, deviceContext);", + "*Device = current;") + if strings.Contains(remove, "PortReserved") { + t.Fatal("logical removal releases the physical port before framework cleanup") + } + cleanup := normalizedContract(nativeCFunction(t, device, "ViiperEvtVirtualDeviceCleanup")) + requireContractOrder(t, cleanup, + "InterlockedExchange(&deviceContext->OwnerReferenced, 0)", + "ViiperReleaseDeviceSlot( controllerContext, device, deviceContext->Slot, deviceContext->PortReservation);", + "ViiperRetireActiveDevice(controllerContext, deviceContext);", + "WdfObjectDereference(ownerFile);") + + destroyOwned := nativeCFunction(t, device, "ViiperDestroyOwnedDevices") + for _, forbidden := range []string{"EvtVirtualDeviceCleanup", "ActiveDevices", "CleanupRetries"} { + if strings.Contains(destroyOwned, forbidden) { + t.Fatalf("logical owner release still waits on physical cleanup state %q", forbidden) + } + } +} + +func TestKernelPlugInPublishesCleanupAccountingBeforeUdeCxExposure(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + + claim := normalizedContract(nativeCFunction(t, device, "ViiperClaimDeviceSlot")) + requireContractOrder(t, claim, + "ViiperAcquireDeviceLockExclusive(ControllerContext);", + "deviceContext->Slot = freeSlot;", + "deviceContext->PortReservation = reservation;", + "deviceContext->Plugged = TRUE;", + "ControllerContext->PortReserved[freeSlot] = TRUE;", + "ControllerContext->Devices[freeSlot] = Device;", + "InterlockedIncrement(&ControllerContext->ActiveDevices);", + "InterlockedExchange(&deviceContext->ActiveCounted, 1);", + "ViiperReleaseDeviceLockExclusive(ControllerContext);") + + create := normalizedContract(nativeCFunction(t, device, "ViiperCreateVirtualDevice")) + requireContractOrder(t, create, + "deviceId = input->DeviceId;", + "ViiperClaimDeviceSlot( controllerContext, device, deviceId, &slot, &portReservation);", + "status = UdecxUsbDevicePlugIn(device, &plugOptions);", + "if (!NT_SUCCESS(status))", + "ViiperReleaseDeviceSlot(controllerContext, device, slot, portReservation);", + "ViiperRetireActiveDevice(controllerContext, deviceContext);", + "WdfObjectDelete(device);", + "goto ExitAdmission;") + + plugIn := "status = UdecxUsbDevicePlugIn(device, &plugOptions);" + postPlugIn := create[strings.Index(create, plugIn)+len(plugIn):] + for _, forbidden := range []string{ + "deviceContext->Plugged", + "deviceContext->ActiveCounted", + "controllerContext->ActiveDevices", + } { + if strings.Contains(postPlugIn, forbidden) { + t.Fatalf("PlugIn publication still mutates %q after UdeCx exposure: %s", + forbidden, postPlugIn) + } + } + failureBoundary := strings.Index(postPlugIn, "if (!NT_SUCCESS(status))") + if failureBoundary < 0 { + t.Fatal("PlugIn failure rollback is missing") + } + if strings.Contains(postPlugIn[:failureBoundary], "deviceContext->") { + t.Fatalf("PlugIn return tracing accesses context after UdeCx exposure: %s", + postPlugIn[:failureBoundary]) + } +} + +func TestKernelCreatePublishesAuthoritativeCorrelationReceipt(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + create := normalizedContract(nativeCFunction(t, device, "ViiperCreateVirtualDevice")) + requireContractOrder(t, create, + "WdfRequestRetrieveInputBuffer(Request, sizeof(*input), (PVOID *)&input, &inputLength);", + "ViiperValidateCreateDevice(input, inputLength)", + "WdfRequestRetrieveOutputBuffer( Request, sizeof(*output), (PVOID *)&output, &outputLength);", + "deviceId = input->DeviceId;", + "generation = input->Generation;", + "requestedSpeed = input->Speed;", + "ViiperBeginOwnerAdmission(controller, Request, &ownerFile);", + "ViiperClaimDeviceSlot(", + "plugOptions.Usb30PortNumber = (USHORT)(VIIPER_UDE_USB20_PORT_COUNT + slot + 1);", + "plugOptions.Usb20PortNumber = (USHORT)(slot + 1);", + "status = UdecxUsbDevicePlugIn(device, &plugOptions);") + + plug := strings.Index(create, "status = UdecxUsbDevicePlugIn(device, &plugOptions);") + if plug < 0 { + t.Fatal("kernel create lost its authoritative UdeCx plug-in boundary") + } + postPlug := create[plug:] + requireContractOrder(t, postPlug, + "status = UdecxUsbDevicePlugIn(device, &plugOptions);", + "if (!NT_SUCCESS(status))", + "goto ExitAdmission;", + "RtlZeroMemory(output, sizeof(*output));", + "output->Header.Magic = VIIPER_UDE_MAGIC;", + "output->Header.Major = VIIPER_UDE_ABI_MAJOR;", + "output->Header.Minor = VIIPER_UDE_ABI_MINOR;", + "output->Header.Size = sizeof(*output);", + "output->DeviceId = deviceId;", + "output->Generation = generation;", + "output->Speed = requestedSpeed;", + "output->Usb20PortNumber = plugOptions.Usb20PortNumber;", + "output->Usb30PortNumber = plugOptions.Usb30PortNumber;", + "WdfRequestSetInformation(Request, sizeof(*output));", + "status = STATUS_SUCCESS;") + if strings.Contains(create[:plug], "WdfRequestSetInformation") { + t.Fatal("kernel create publishes a correlation receipt before UdeCx accepts the device") + } +} + +type modeledPortDevice struct { + slot int + token uint64 + active bool +} + +type modeledPortController struct { + devices [4]*modeledPortDevice + epochs [4]uint64 + reserved [4]bool + active int + reservedPorts int +} + +func (controller *modeledPortController) claim(device *modeledPortDevice) bool { + for slot := range controller.devices { + if controller.devices[slot] != nil || controller.reserved[slot] { + continue + } + controller.epochs[slot]++ + if controller.epochs[slot] == 0 { + controller.epochs[slot]++ + } + device.slot = slot + device.token = controller.epochs[slot] + device.active = true + controller.reserved[slot] = true + controller.reservedPorts++ + controller.devices[slot] = device + controller.active++ + return true + } + return false +} + +func (controller *modeledPortController) retire(device *modeledPortDevice) { + if !device.active { + return + } + device.active = false + controller.active-- +} + +func (controller *modeledPortController) logicalRemove(device *modeledPortDevice) { + if device.slot >= 0 && device.slot < len(controller.devices) && + controller.devices[device.slot] == device { + controller.devices[device.slot] = nil + controller.retire(device) + } +} + +func (controller *modeledPortController) release( + device *modeledPortDevice, + slot int, + token uint64, +) { + if slot < 0 || slot >= len(controller.devices) || token == 0 || + !controller.reserved[slot] || controller.epochs[slot] != token { + return + } + if controller.devices[slot] == device { + controller.devices[slot] = nil + } + controller.reserved[slot] = false + controller.reservedPorts-- +} + +func (controller *modeledPortController) cleanup(device *modeledPortDevice) { + controller.release(device, device.slot, device.token) + controller.retire(device) +} + +func TestPortReservationAndActiveAccountingModel(t *testing.T) { + t.Run("normal remove holds the exact port until cleanup", func(t *testing.T) { + controller := new(modeledPortController) + first := &modeledPortDevice{slot: -1} + second := &modeledPortDevice{slot: -1} + if !controller.claim(first) { + t.Fatal("first claim failed") + } + controller.logicalRemove(first) + if controller.active != 0 || !controller.reserved[first.slot] || + controller.reservedPorts != 1 { + t.Fatalf("logical remove lost teardown state: active=%d reserved=%v ports=%d", + controller.active, controller.reserved[first.slot], controller.reservedPorts) + } + if !controller.claim(second) || second.slot == first.slot { + t.Fatalf("successor reused reserved port: first=%d second=%d", + first.slot, second.slot) + } + controller.cleanup(first) + controller.cleanup(first) + if controller.active != 1 || controller.reservedPorts != 1 || + controller.reserved[first.slot] || + controller.devices[second.slot] != second { + t.Fatalf("exact cleanup disturbed successor: active=%d ports=%d first_reserved=%v", + controller.active, controller.reservedPorts, controller.reserved[first.slot]) + } + }) + + t.Run("failed PlugIn rollback cannot revoke its successor", func(t *testing.T) { + controller := new(modeledPortController) + failed := &modeledPortDevice{slot: -1} + successor := &modeledPortDevice{slot: -1} + if !controller.claim(failed) { + t.Fatal("failed-device claim failed") + } + failedSlot, failedToken := failed.slot, failed.token + controller.release(failed, failedSlot, failedToken) + controller.retire(failed) + if controller.reservedPorts != 0 { + t.Fatalf("failed PlugIn release leaked %d reserved ports", controller.reservedPorts) + } + if !controller.claim(successor) || successor.slot != failedSlot || + successor.token == failedToken { + t.Fatalf("successor identity did not advance: failed=(%d,%d) successor=(%d,%d)", + failedSlot, failedToken, successor.slot, successor.token) + } + controller.cleanup(failed) + if controller.active != 1 || controller.reservedPorts != 1 || + !controller.reserved[successor.slot] || + controller.devices[successor.slot] != successor { + t.Fatal("late failed-device cleanup revoked the successor") + } + }) + + t.Run("controller shutdown retires logic before exact physical cleanup", func(t *testing.T) { + controller := new(modeledPortController) + devices := []*modeledPortDevice{{slot: -1}, {slot: -1}, {slot: -1}} + for _, device := range devices { + if !controller.claim(device) { + t.Fatal("shutdown fixture claim failed") + } + } + for _, device := range devices { + controller.logicalRemove(device) + } + if controller.active != 0 || controller.reservedPorts != len(devices) { + t.Fatalf("logical shutdown state active=%d reserved=%d", + controller.active, controller.reservedPorts) + } + for _, device := range devices { + if !controller.reserved[device.slot] { + t.Fatalf("shutdown released port %d before cleanup", device.slot) + } + controller.cleanup(device) + } + if controller.active != 0 || controller.reservedPorts != 0 { + t.Fatalf("terminal cleanup state active=%d reserved=%d", + controller.active, controller.reservedPorts) + } + }) + + t.Run("unexpected and invalid cleanup is idempotent", func(t *testing.T) { + controller := new(modeledPortController) + device := &modeledPortDevice{slot: -1} + if !controller.claim(device) { + t.Fatal("unexpected-cleanup fixture claim failed") + } + controller.release(device, -1, device.token) + controller.release(device, device.slot, 0) + controller.release(device, device.slot, device.token+1) + if controller.active != 1 || controller.reservedPorts != 1 || + !controller.reserved[device.slot] { + t.Fatal("invalid token mutated live reservation") + } + controller.cleanup(device) + controller.cleanup(device) + if controller.active != 0 || controller.reservedPorts != 0 || + controller.reserved[device.slot] || + controller.devices[device.slot] != nil { + t.Fatalf("unexpected cleanup was not idempotent: active=%d", controller.active) + } + }) +} + +func TestControllerRestartPreservesOutstandingPortReservationEpochs(t *testing.T) { + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + deviceAdd := normalizedContract(nativeCFunction(t, controller, "ViiperEvtDeviceAdd")) + if !strings.Contains(deviceAdd, "RtlZeroMemory(context, sizeof(*context));") { + t.Fatal("controller context is not initialized exactly at device creation") + } + selfManagedInit := nativeCFunction(t, controller, "ViiperEvtDeviceSelfManagedIoInit") + for _, forbidden := range []string{"RtlZeroMemory", "PortReservationEpochs", "PortReserved"} { + if strings.Contains(selfManagedInit, forbidden) { + t.Fatalf("same-object restart resets outstanding reservation state %q: %s", + forbidden, selfManagedInit) + } + } +} + +func TestDispatchLevelPowerAndResetCallbacksDeferPassiveInvalidation(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + + for _, required := range []string{ + "WDFWORKITEM D0ExitWorkItem;", + "volatile LONG D0ExitPending;", + "EVT_WDF_WORKITEM ViiperEvtUsbDeviceD0ExitWorkItem;", + } { + if !strings.Contains(header, required) { + t.Fatalf("device power deferral contract missing %q", required) + } + } + + create := normalizedContract(nativeCFunction(t, device, "ViiperCreateVirtualDevice")) + requireContractOrder(t, create, + "WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtUsbDeviceD0ExitWorkItem);", + "workItemConfig.AutomaticSerialization = WdfFalse;", + "attributes.ParentObject = device;", + "WdfWorkItemCreate( &workItemConfig, &attributes, &deviceContext->D0ExitWorkItem);", + "ViiperClaimDeviceSlot(", + "UdecxUsbDevicePlugIn(device, &plugOptions);") + + d0Exit := normalizedContract(nativeCFunction(t, device, "ViiperEvtUsbDeviceD0Exit")) + requireContractOrder(t, d0Exit, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "InterlockedExchange(&deviceContext->InD0, FALSE);", + "controllerContext->ShuttingDown", + "deviceContext->Purging", + "status = STATUS_SUCCESS;", + "&deviceContext->D0ExitPending, TRUE, FALSE", + "status = STATUS_DEVICE_BUSY;", + "WdfWorkItemEnqueue(deviceContext->D0ExitWorkItem);", + "status = STATUS_PENDING;", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "return status;") + assertNoDispatchLevelWaits(t, "D0 exit", d0Exit) + + reset := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointReset")) + requireContractOrder(t, reset, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "&endpointContext->Resetting, TRUE, FALSE", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY);", + "endpointContext->ResetRequest = Request;", + "WdfWorkItemEnqueue(endpointContext->ResetWorkItem);") + assertNoDispatchLevelWaits(t, "endpoint reset", reset) + + d0Work := normalizedContract(nativeCFunction( + t, device, "ViiperEvtUsbDeviceD0ExitWorkItem")) + requireContractOrder(t, d0Work, + "NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL);", + "ViiperInvalidateDeviceInputReports(device);", + "ViiperQueueDeviceLifecycleEvent( device, ViiperUdeOperationDeviceD0Exit);", + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "InterlockedExchange(&deviceContext->D0ExitPending, FALSE);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "UdecxUsbDeviceLinkPowerExitComplete(device, STATUS_SUCCESS);") + completion := "UdecxUsbDeviceLinkPowerExitComplete(device, STATUS_SUCCESS);" + afterCompletion := d0Work[strings.Index(d0Work, completion)+len(completion):] + for _, forbidden := range []string{ + "deviceContext", "controllerContext", "ViiperGet", "Wdf", "VIIPER_TRACE", + } { + if strings.Contains(afterCompletion, forbidden) { + t.Fatalf("D0-exit work item accesses the device after UdeCx completion via %q: %s", + forbidden, afterCompletion) + } + } + + flush := normalizedContract(nativeCFunction(t, device, "ViiperFlushD0ExitWorkItem")) + requireContractOrder(t, flush, + "NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL);", + "WdfWorkItemFlush(deviceContext->D0ExitWorkItem);", + "deviceContext->D0ExitPending") + if strings.Contains(flush, "if (InterlockedCompareExchange") { + t.Fatal("teardown conditionally skips the D0-exit flush after the pending flag clears") + } + + destroy := normalizedContract(nativeCFunction(t, device, "ViiperDestroyVirtualDevice")) + requireContractOrder(t, destroy, + "ViiperBeginRemoveDevice(", + "ViiperFlushD0ExitWorkItem(device);", + "ViiperAbortDeviceManagementOperations(controller, device, STATUS_DEVICE_REMOVED);", + "UdecxUsbDevicePlugOutAndDelete(device);") + destroyOwned := normalizedContract(nativeCFunction(t, device, "ViiperDestroyOwnedDevices")) + requireContractOrder(t, destroyOwned, + "ViiperBeginRemoveDevice(", + "ViiperFlushD0ExitWorkItem(device);", + "ViiperAbortDeviceManagementOperations(Controller, device, STATUS_FILE_CLOSED);", + "UdecxUsbDevicePlugOutAndDelete(device)") + controllerShutdown := normalizedContract(nativeCFunction(t, device, "ViiperBeginControllerShutdown")) + requireContractOrder(t, controllerShutdown, + "devices[deviceCount++] = device;", + "ViiperReleaseDeviceLockExclusive(controllerContext);", + "ViiperFlushD0ExitWorkItem(devices[index]);", + "UdecxUsbDevicePlugOutAndDelete(devices[index]);") + + resetWork := normalizedContract(nativeCFunction( + t, device, "ViiperEvtEndpointResetWorkItem")) + requireContractOrder(t, resetWork, + "NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL);", + "ViiperQuiesceResetByIdentity(", + "if (!resetCurrent)", + "ViiperInvalidateEndpointInputReport(endpoint);", + "ViiperQueueAcknowledgedEndpointLifecycleEvent(") + + d0Entry := normalizedContract(nativeCFunction(t, device, "ViiperEvtUsbDeviceD0Entry")) + requireContractOrder(t, d0Entry, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "deviceContext->Purging", + "deviceContext->D0ExitPending", + "status = STATUS_DEVICE_BUSY;", + "InterlockedExchange(&deviceContext->InD0, TRUE);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceD0Entry);") + assertNoDispatchLevelWaits(t, "D0 entry", d0Entry) + + activate := normalizedContract(nativeCFunction(t, device, "ViiperActivateEndpoint")) + requireContractOrder(t, activate, + "deviceContext->Purging", + "deviceContext->InD0", + "deviceContext->D0ExitPending", + "InterlockedExchange(&endpointContext->Purging, FALSE);") +} + +func assertNoDispatchLevelWaits(t *testing.T, name, body string) { + t.Helper() + for _, forbidden := range []string{ + "ViiperInvalidateEndpointInputReport", + "ViiperInvalidateDeviceInputReports", + "ViiperAcquireDeviceLock", + "WdfWaitLockAcquire", + "KeWaitForSingleObject", + "KeDelayExecutionThread", + } { + if strings.Contains(body, forbidden) { + t.Fatalf("%s performs passive-only work through %q: %s", name, forbidden, body) + } + } +} + +type modeledDevicePower struct { + inD0 bool + exitPending bool + cacheValid bool + workEnqueues int + exitCompletions int +} + +func (device *modeledDevicePower) beginExit() bool { + if device.exitPending { + return false + } + device.exitPending = true + device.inD0 = false + device.workEnqueues++ + return true +} + +func (device *modeledDevicePower) completeExitWork() bool { + if !device.exitPending { + return false + } + device.cacheValid = false + device.exitPending = false + device.exitCompletions++ + return true +} + +func (device *modeledDevicePower) enterD0() bool { + if device.exitPending { + return false + } + device.inD0 = true + return true +} + +func (device *modeledDevicePower) canAdmitInputOrStart() bool { + return device.inD0 && !device.exitPending +} + +func TestAsyncD0ExitStateModelRejectsReentrancyAndStaleInput(t *testing.T) { + device := &modeledDevicePower{inD0: true, cacheValid: true} + if !device.canAdmitInputOrStart() || !device.beginExit() { + t.Fatal("initial D0 exit was not admitted") + } + if device.beginExit() || device.enterD0() || device.canAdmitInputOrStart() { + t.Fatal("pending D0 exit admitted a duplicate exit, D0 entry, input, or START") + } + if device.workEnqueues != 1 || device.exitCompletions != 0 || !device.cacheValid { + t.Fatalf("DISPATCH phase mutated passive cache state: %+v", *device) + } + if !device.completeExitWork() || device.cacheValid || device.exitPending || + device.exitCompletions != 1 { + t.Fatalf("passive completion did not clear cache and close one transition: %+v", *device) + } + if device.completeExitWork() || !device.enterD0() || !device.canAdmitInputOrStart() { + t.Fatal("completion duplicated or D0 entry failed to reopen admission") + } + if device.workEnqueues != 1 || device.exitCompletions != 1 { + t.Fatalf("one D0-exit callback did not map to one async completion: %+v", *device) + } +} + +func TestAsyncD0ExitTeardownFlushesPastPendingFlagBoundary(t *testing.T) { + type powerTeardown struct { + inD0 bool + purging bool + shuttingDown bool + exitPending bool + workQueued bool + workRunning bool + completionCalls int + flushCalls int + handleConsumed bool + } + beginExit := func(state *powerTeardown) string { + state.inD0 = false + if state.shuttingDown || state.purging { + return "success" + } + if state.exitPending { + return "busy" + } + state.exitPending = true + state.workQueued = true + return "pending" + } + startWorker := func(state *powerTeardown) bool { + if !state.workQueued || state.workRunning { + return false + } + state.workQueued = false + state.workRunning = true + return true + } + completePowerTransition := func(state *powerTeardown) bool { + if !state.workRunning || !state.exitPending { + return false + } + // The real worker clears this immediately before the UdeCx completion. + // It is still executing and using the device until it returns. + state.exitPending = false + state.completionCalls++ + return true + } + returnWorker := func(state *powerTeardown) bool { + if !state.workRunning || state.exitPending { + return false + } + state.workRunning = false + return true + } + flush := func(state *powerTeardown) { + state.flushCalls++ + if state.workQueued { + if !startWorker(state) || !completePowerTransition(state) { + t.Fatal("flush could not run a queued D0-exit worker") + } + } + if state.workRunning && !returnWorker(state) { + t.Fatal("flush returned before the active D0-exit worker") + } + } + consume := func(state *powerTeardown) bool { + if state.workQueued || state.workRunning || state.exitPending { + return false + } + state.handleConsumed = true + return true + } + + // Power exit wins admission, then removal closes the gate. Teardown must + // drain the already-queued callback before consuming the UdeCx handle. + queued := powerTeardown{inD0: true} + if status := beginExit(&queued); status != "pending" { + t.Fatalf("D0 exit did not win its BrokerLock boundary: %s", status) + } + queued.purging = true + flush(&queued) + if !consume(&queued) || queued.completionCalls != 1 || queued.flushCalls != 1 { + t.Fatalf("teardown consumed before its queued power completion returned: %+v", queued) + } + + // The worker can clear D0ExitPending just before its completion call. A + // conditional flag check would miss this still-running callback; an + // unconditional work-item flush joins it. + running := powerTeardown{inD0: true} + if beginExit(&running) != "pending" || !startWorker(&running) || + !completePowerTransition(&running) || running.exitPending || !running.workRunning { + t.Fatalf("model did not reach the cleared-flag/running-worker boundary: %+v", running) + } + running.purging = true + flush(&running) + if !consume(&running) || running.workRunning || running.completionCalls != 1 { + t.Fatalf("unconditional flush failed to join the post-flag callback tail: %+v", running) + } + + // Removal can win first. A later D0-exit callback closes InD0 but completes + // synchronously and must not enqueue work against the soon-consumed handle. + teardownFirst := powerTeardown{inD0: true, purging: true} + if status := beginExit(&teardownFirst); status != "success" || + teardownFirst.inD0 || teardownFirst.workQueued || teardownFirst.exitPending { + t.Fatalf("teardown-owned D0 exit did not finish synchronously: %+v status=%s", + teardownFirst, status) + } + flush(&teardownFirst) + if !consume(&teardownFirst) || teardownFirst.completionCalls != 0 { + t.Fatalf("teardown-first path scheduled an unowned async completion: %+v", teardownFirst) + } +} + +func TestKernelStaleChildCannotNotifySuccessorOwner(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + + ownerGate := normalizedContract(nativeCFunction( + t, broker, "ViiperLifecycleOwnerSessionActiveLocked")) + requireContractOrder(t, ownerGate, + "InterlockedCompareExchange(&DeviceContext->Purging, 0, 0) != 0", + "InterlockedCompareExchange(&DeviceContext->OwnerReferenced, 0, 0) == 0", + "ownerFile = DeviceContext->OwnerFile;", + "if (ownerFile == WDF_NO_HANDLE)", + "fileContext = ViiperGetFileContext(ownerFile);", + "InterlockedCompareExchange(&fileContext->BrokerOwner, 0, 0) != 0", + "InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) != 0", + "InterlockedCompareExchange(&fileContext->Closing, 0, 0) == 0") + if strings.Contains(ownerGate, "WdfWaitLockAcquire(") { + t.Fatalf("lifecycle owner gate reverses cleanup lock order: %s", ownerGate) + } + + insert := normalizedContract(nativeCFunction(t, broker, "ViiperQueueLifecycleEventLocked")) + requireContractOrder(t, insert, + "if (!ViiperLifecycleOwnerSessionActiveLocked(DeviceContext))", + "event = &ControllerContext->Notifications[ControllerContext->NotificationTail];", + "InterlockedIncrement64( &DeviceContext->EndpointSequences[event->EndpointAddress])", + "InterlockedIncrement64( &DeviceContext->DeviceSequence)") + + for _, name := range []string{ + "ViiperQueueEndpointLifecycleEvent", + "ViiperQueueDeviceLifecycleEvent", + "ViiperQueueInterfaceLifecycleEvent", + "ViiperQueueAcknowledgedLifecycleEvent", + } { + producer := normalizedContract(nativeCFunction(t, broker, name)) + requireContractOrder(t, producer, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "ViiperLifecycleOwnerSessionActiveLocked(deviceContext)", + "ViiperQueueLifecycleEventLocked(", + "WdfSpinLockRelease(controllerContext->BrokerLock);") + } + + remove := normalizedContract(nativeCFunction(t, device, "ViiperBeginRemoveDevice")) + requireContractOrder(t, remove, + "WdfSpinLockAcquire(ControllerContext->BrokerLock);", + "InterlockedExchange(&deviceContext->Purging, TRUE);", + "WdfSpinLockRelease(ControllerContext->BrokerLock);", + "ControllerContext->Devices[index] = WDF_NO_HANDLE;") + + cleanup := normalizedContract(nativeCFunction(t, device, "ViiperEvtVirtualDeviceCleanup")) + requireContractOrder(t, cleanup, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "InterlockedExchange(&deviceContext->Purging, TRUE);", + "InterlockedExchange(&deviceContext->OwnerReferenced, 0)", + "ownerFile = deviceContext->OwnerFile;", + "deviceContext->OwnerFile = WDF_NO_HANDLE;", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "ViiperReleaseDeviceSlot( controllerContext, device, deviceContext->Slot, deviceContext->PortReservation);", + "if (ownerFile != WDF_NO_HANDLE)", + "WdfObjectDereference(ownerFile);") +} + +func TestKernelNeverUsesConsumedUDEDeviceHandle(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + + destroy := normalizedContract(nativeCFunction(t, device, "ViiperDestroyVirtualDevice")) + requireContractOrder(t, destroy, + "ViiperBeginRemoveDevice(", + "UdecxUsbDevicePlugOutAndDelete(device);", + "ViiperEndOwnerAdmission(controller, ownerFile);") + if suffix := destroy[strings.Index(destroy, "UdecxUsbDevicePlugOutAndDelete(device);")+len("UdecxUsbDevicePlugOutAndDelete(device);"):]; strings.Contains(suffix, "ViiperGetDeviceContext(device)") || + strings.Contains(suffix, "WdfObjectDelete(device)") { + t.Fatalf("destroy path uses consumed UDE handle after PlugOutAndDelete: %s", suffix) + } + + destroyOwned := normalizedContract(nativeCFunction(t, device, "ViiperDestroyOwnedDevices")) + requireContractOrder(t, destroyOwned, + "deviceContext = ViiperGetDeviceContext(device);", + "plugged = deviceContext->Plugged;", + "ViiperFlushD0ExitWorkItem(device);", + "ViiperAbortDeviceManagementOperations(Controller, device, STATUS_FILE_CLOSED);", + "if (plugged)", + "UdecxUsbDevicePlugOutAndDelete(device)", + "return FALSE;") + assertNoConsumedHandleUse(t, destroyOwned, "UdecxUsbDevicePlugOutAndDelete(device)", "} else {") + shutdown := normalizedContract(nativeCFunction(t, device, "ViiperBeginControllerShutdown")) + requireContractOrder(t, shutdown, + "VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(devices[index]);", + "BOOLEAN plugged = deviceContext->Plugged;", + "ULONGLONG deviceId = deviceContext->DeviceId;", + "ULONG generation = deviceContext->Generation;", + "ViiperFlushD0ExitWorkItem(devices[index]);", + "if (plugged)", + "UdecxUsbDevicePlugOutAndDelete(devices[index]);") + assertNoConsumedHandleUse(t, shutdown, + "UdecxUsbDevicePlugOutAndDelete(devices[index]);", "} else {") +} + +func assertNoConsumedHandleUse(t *testing.T, source, call, branchEnd string) { + t.Helper() + callAt := strings.Index(source, call) + if callAt < 0 { + t.Fatalf("native contract lost consuming call %q", call) + } + afterCall := source[callAt+len(call):] + endAt := strings.Index(afterCall, branchEnd) + if endAt < 0 { + t.Fatalf("native contract lost branch end %q after %q", branchEnd, call) + } + for _, forbidden := range []string{ + "ViiperGetDeviceContext", "WdfObjectDelete", "WdfObjectReference", + "WdfObjectDereference", + } { + if strings.Contains(afterCall[:endAt], forbidden) { + t.Fatalf("consumed UDE handle branch calls %s after %s: %s", + forbidden, call, afterCall[:endAt]) + } + } +} diff --git a/internal/transport/udecx/driver_trace_recorder_contract_test.go b/internal/transport/udecx/driver_trace_recorder_contract_test.go new file mode 100644 index 00000000..a9ea2b48 --- /dev/null +++ b/internal/transport/udecx/driver_trace_recorder_contract_test.go @@ -0,0 +1,193 @@ +package udecx + +import ( + "strings" + "testing" +) + +func TestNativeLifecycleTraceUsesBoundedPerProcessorRecorder(t *testing.T) { + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + trace := nativeContractSource(t, "native", "udecx", "driver", "Trace.c") + ioctl := nativeContractSource(t, "native", "udecx", "driver", "Ioctl.c") + + for _, fragment := range []string{ + "#define VIIPER_UDE_LIFECYCLE_TRACE_MAX_SHARDS 64", + "typedef struct VIIPER_UDE_LIFECYCLE_TRACE_SHARD", + "DECLSPEC_ALIGN(SYSTEM_CACHE_ALIGNMENT_SIZE) volatile LONG64 WriteSequence;", + "volatile LONG64 SlotStates[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY];", + "VIIPER_UDE_LIFECYCLE_TRACE_RECORD Records[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY];", + "WDFMEMORY LifecycleTraceStorage;", + "VIIPER_UDE_LIFECYCLE_TRACE_SHARD *LifecycleTraceShards;", + "ULONG LifecycleTraceShardCount;", + } { + if !strings.Contains(header, fragment) { + t.Fatalf("native lifecycle recorder is missing %q", fragment) + } + } + if strings.Contains(header, + "LifecycleTrace[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY]") { + t.Fatal("controller context retained the contended global lifecycle record array") + } + + initialize := normalizedContract(nativeCFunction(t, trace, + "ViiperInitializeLifecycleTrace")) + requireContractOrder(t, initialize, + "KeQueryMaximumProcessorCountEx(ALL_PROCESSOR_GROUPS)", + "VIIPER_UDE_LIFECYCLE_TRACE_MAX_SHARDS", + "WdfMemoryCreate(", + "NonPagedPoolNx", + "RtlZeroMemory(rawStorage, storageSize);", + "controllerContext->LifecycleTraceShards =", + "controllerContext->LifecycleTraceShardCount = shardCount;") + if !strings.Contains(controller, "status = ViiperInitializeLifecycleTrace(device);") { + t.Fatal("controller publishes emulation without constructing the nonpaged recorder") + } + + hot := normalizedContract(nativeCFunction(t, trace, "ViiperTraceLifecycle")) + requireContractOrder(t, hot, + "KeGetCurrentProcessorNumberEx(&processorNumber);", + "KeGetProcessorIndexFromNumber(&processorNumber);", + "shardIndex = processorIndex % controllerContext->LifecycleTraceShardCount;", + "InterlockedIncrement64(&shard->WriteSequence);", + "slotState = &shard->SlotStates[slotIndex];", + "claimedSlotState = (LONG64)((localSequence << 1) | 1ULL);", + "VIIPER_UDE_LIFECYCLE_TRACE_STATUS_DROPPED_RECORD", + "InterlockedCompareExchange64( slotState, claimedSlotState, observedSlotState)", + "InterlockedIncrement64( &controllerContext->LifecycleTraceSequence);", + "record = &shard->Records[", + "InterlockedExchange64((volatile LONG64 *)&record->PublishedSequence, 0);", + "KeMemoryBarrier();", + "InterlockedExchange64( (volatile LONG64 *)&record->PublishedSequence, (LONG64)sequence);", + "InterlockedExchange64(slotState, (LONG64)(localSequence << 1));") + for _, forbidden := range []string{ + "WdfMemoryCreate", "WdfSpinLockAcquire", "WdfWaitLockAcquire", + "ExAcquirePushLock", "KeWaitForSingleObject", + } { + if strings.Contains(hot, forbidden) { + t.Fatalf("lifecycle recorder hot path contains %q", forbidden) + } + } + + query := normalizedContract(nativeCFunction(t, ioctl, + "ViiperHandleQueryLifecycleTrace")) + requireContractOrder(t, query, + "latestSequence = (ULONGLONG)ViiperReadCounter( &context->LifecycleTraceSequence);", + "shardIndex < context->LifecycleTraceShardCount", + "recordIndex < VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY", + "slotStateBefore = InterlockedCompareExchange64(", + "publishedBefore < firstSequence", + "RtlCopyMemory(&candidate, source, sizeof(candidate));", + "slotStateAfter = InterlockedCompareExchange64(", + "slotStateAfter != slotStateBefore", + "publishedAfter != publishedBefore", + "output->Records[insertIndex] = candidate;", + "++output->RecordCount;", + "output->StatusFlags = (VIIPER_UDE_UINT32)InterlockedCompareExchange(", + "WdfRequestSetInformation(Request, sizeof(*output));") + + for _, name := range []string{ + "ViiperWaitForEndpointQuiescence", + "ViiperWaitForEndpointPurgeQuiescence", + } { + wait := normalizedContract(nativeCFunction(t, device, name)) + requireContractOrder(t, wait, + "watchdogWait.QuadPart = -(LONGLONG)VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS;", + "KeWaitForSingleObject(", + "&watchdogWait);", + "if (quiescent)", + "return;", + "VIIPER_UDE_TRACE_ENDPOINT_QUIESCENCE_WATCHDOG", + "STATUS_IO_TIMEOUT", + "KeDelayExecutionThread(") + if strings.Contains(wait, "UdecxUsbEndpointPurgeComplete") { + t.Fatalf("%s abandons rundown and completes UdeCx from its watchdog path", name) + } + } +} + +func TestLifecycleRecorderSlotClaimRejectsPreemptedStaleWriter(t *testing.T) { + const capacity = LifecycleTraceCapacity + type slot struct { + state uint64 + sequence uint64 + } + var slots [capacity]slot + claim := func(local uint64) bool { + index := (local - 1) % capacity + observed := slots[index].state + if observed&1 != 0 || observed>>1 >= local { + return false + } + slots[index].state = local<<1 | 1 + return true + } + publish := func(local, sequence uint64) { + index := (local - 1) % capacity + slots[index].sequence = sequence + slots[index].state = local << 1 + } + + // Writer 1 is preempted after reserving its local sequence but before its + // atomic slot claim. A complete ring of successors reaches the same slot. + for local := uint64(2); local <= capacity+1; local++ { + if !claim(local) { + t.Fatalf("successor local sequence %d could not claim its slot", local) + } + publish(local, local) + } + if claim(1) { + t.Fatal("preempted writer reclaimed a slot already published by a newer wrap") + } + if got := slots[0].sequence; got != capacity+1 { + t.Fatalf("slot 0 sequence=%d want newest sequence %d", got, capacity+1) + } + + // A writer which already owns the slot cannot be overwritten either. The + // colliding successor is dropped and made sticky by the production path. + slots[0] = slot{state: 1<<1 | 1} + if claim(capacity + 1) { + t.Fatal("colliding successor overwrote an active slot writer") + } + publish(1, 1) + if !claim(capacity + 1) { + t.Fatal("settled old slot did not admit its newer wrap") + } +} + +func TestPerProcessorLifecycleRecorderRetainsGlobalPublicWindow(t *testing.T) { + const ( + shardCount = 7 + capacity = LifecycleTraceCapacity + writes = 10000 + ) + type record struct{ sequence uint64 } + shards := make([][capacity]record, shardCount) + local := make([]uint64, shardCount) + for sequence := uint64(1); sequence <= writes; sequence++ { + // Exercise uneven load and processor-to-shard collisions rather than a + // round-robin distribution. + processor := int((sequence*sequence + sequence*17 + 3) % 97) + shard := processor % shardCount + local[shard]++ + shards[shard][(local[shard]-1)%capacity] = record{sequence: sequence} + } + first := uint64(writes-capacity) + 1 + seen := make(map[uint64]struct{}, capacity) + for shard := range shards { + for _, record := range shards[shard] { + if record.sequence >= first && record.sequence <= writes { + seen[record.sequence] = struct{}{} + } + } + } + if len(seen) != capacity { + t.Fatalf("retained records=%d want complete global suffix=%d", len(seen), capacity) + } + for sequence := first; sequence <= writes; sequence++ { + if _, ok := seen[sequence]; !ok { + t.Fatalf("global retained suffix is missing sequence %d", sequence) + } + } +} diff --git a/internal/transport/udecx/driver_transfer_contract_test.go b/internal/transport/udecx/driver_transfer_contract_test.go new file mode 100644 index 00000000..905dc5dc --- /dev/null +++ b/internal/transport/udecx/driver_transfer_contract_test.go @@ -0,0 +1,47 @@ +package udecx + +import ( + "strings" + "testing" +) + +func TestNativeDriverUsesEndpointDirectionForNonControlTransfers(t *testing.T) { + source := nativeDriverBrokerSource(t) + start := strings.Index(source, "ViiperSerializeOperation(") + if start < 0 { + t.Fatal("native transfer serializer is missing") + } + end := strings.Index(source[start:], "ViiperDispatchAvailable(") + if end < 0 { + t.Fatal("native transfer serializer boundary is missing") + } + serializer := source[start : start+end] + + for _, required := range []string{ + "urb->UrbHeader.Function != URB_FUNCTION_CONTROL_TRANSFER", + "urb->UrbHeader.Function != URB_FUNCTION_CONTROL_TRANSFER_EX", + "endpointContext->Descriptor.bEndpointAddress &", + "USB_ENDPOINT_DIRECTION_MASK", + "transferFlags |= USBD_TRANSFER_DIRECTION_IN;", + "transferFlags &= ~USBD_TRANSFER_DIRECTION_IN;", + "operation->Direction = directionIn ? 1 : 0;", + "operation->TransferFlags = transferFlags;", + } { + if !strings.Contains(serializer, required) { + t.Fatalf("native transfer direction normalization is missing %q", required) + } + } + + metadataStart := strings.Index(source, "ViiperGetTransferMetadata(") + if metadataStart < 0 { + t.Fatal("native transfer metadata helper is missing") + } + metadataEnd := strings.Index(source[metadataStart:], "ViiperSerializeOperation(") + if metadataEnd < 0 { + t.Fatal("native transfer metadata helper boundary is missing") + } + metadata := source[metadataStart : metadataStart+metadataEnd] + if !strings.Contains(metadata, "*DirectionIn = ((SetupPacket[0] & USB_ENDPOINT_DIRECTION_MASK) != 0);") { + t.Fatal("control transfer direction no longer comes from its setup packet") + } +} diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go new file mode 100644 index 00000000..37aa183a --- /dev/null +++ b/internal/transport/udecx/host.go @@ -0,0 +1,1808 @@ +package udecx + +import ( + "context" + "errors" + "fmt" + "log/slog" + "math" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/Alia5/VIIPER/usb" +) + +const ( + defaultDequeueWorkers = 8 + // A child cannot expose more operations than the pending-operation + // contract published to the kernel. Matching that bound here lets a busy + // endpoint absorb every operation the broker can legally own without ever + // making the central dispatcher wait for one controller. + laneQueueDepth = defaultDevicePendingOperations + completionTimeout = 2 * time.Second + terminalCleanupTimeout = 30 * time.Second + completedTokenHistory = MaxPendingOperations * 2 + statusUnsuccessful = int32(-1073741823) // STATUS_UNSUCCESSFUL +) + +var errInputSequenceExhausted = errors.New("native UDE input report sequence is exhausted") + +// Driver is the narrow host-side contract implemented by the overlapped +// Windows UdeCx client. Keeping it as an interface makes ordering, teardown, +// and stale-generation behavior testable without loading a kernel driver. +type Driver interface { + CreateDevice(context.Context, CreateDevice) (DeviceRegistration, error) + // DestroyDevice returns an error only if removal was rejected before the + // kernel transferred ownership to UdeCx. Once accepted, any terminal + // UdeCx removal fault is recovered by restarting the controller and the + // call succeeds so callers never resurrect an invalid device generation. + DestroyDevice(context.Context, DeviceIdentity) error + Dequeue(context.Context, []byte) (Operation, error) + Complete(context.Context, Completion) error + QueryStats(context.Context) (Stats, error) +} + +type LifecycleTraceDriver interface { + QueryLifecycleTrace(context.Context) (LifecycleTrace, error) +} + +// InputReportDriver is an optional, version-negotiated extension used only +// for interrupt-IN reports. Keeping it separate preserves the ordered broker +// contract for control, output, feedback, audio, and lifecycle traffic. +type InputReportDriver interface { + // SubmitInputReport must honor ctx. Endpoint lifecycle cancellation stops + // sampling but deliberately lets an already encoded report commit; ctx is + // cancelled when the owning Host session stops or fails. + SubmitInputReport(context.Context, InputReport) error +} + +// OperationProcessor translates one native USB operation through VIIPER's +// existing usb.Device engines. Implementations must not retain operation +// payload slices after Process returns. +type OperationProcessor interface { + Process(context.Context, usb.Device, Operation) (Completion, error) + Lifecycle(context.Context, usb.Device, Operation) error + Reset(usb.Device, DeviceIdentity) +} + +type registeredDevice struct { + identity DeviceIdentity + device usb.Device + sequence *deviceSequenceBarrier + ctx context.Context + cancel context.CancelFunc + stopping bool + publisherStopping bool + fastInput map[uint8]fastInputEndpoint + publishers map[uint8]*inputPublisher + activeInput map[uint8]uint32 + resettingInput map[uint8]uint32 + endpointGeneration map[uint8]uint32 + inputSequences map[endpointIdentity]*atomic.Uint64 + inD0 bool + resetting bool + powerSequence uint64 +} + +type inputPublisher struct { + endpoint uint8 + endpointGeneration uint32 + reportSize int + interval time.Duration + sequence *atomic.Uint64 + submitCtx context.Context + cancel context.CancelFunc + done chan struct{} +} + +type endpointIdentity struct { + address uint8 + generation uint32 +} + +type fastInputEndpoint struct { + reportSize int + interval time.Duration +} + +type laneKey struct { + deviceID uint64 + generation uint32 + endpoint uint8 + endpointGeneration uint32 +} + +type operationLane struct { + key laneKey + ctx context.Context + cancel context.CancelFunc + input chan Operation + done chan struct{} + stateMu sync.Mutex + terminalErr error +} + +type operationState struct { + deviceID uint64 + generation uint32 + endpoint uint8 + endpointGeneration uint32 + cancel context.CancelFunc + cancelled bool + received bool + processing bool + done bool +} + +type deviceLifecycleGate struct { + mu sync.Mutex + references int +} + +// InputPathDiagnostics makes every slower compatibility path observable. +// These counters are publisher-lifetime events rather than per-report events, +// so collecting them adds no atomic operation to the interrupt-input hot path. +type InputPathDiagnostics struct { + PublisherStarts uint64 + LegacyTransferFallbackStarts uint64 + DeadlineContextFallbackStarts uint64 +} + +// Host owns one exclusive driver session and routes operations concurrently +// across endpoints while preserving strict FIFO within each endpoint. +type Host struct { + driver Driver + input InputReportDriver + processor OperationProcessor + workers int + + lifecycleMu sync.Mutex + lifecycles map[uint64]*deviceLifecycleGate + mu sync.RWMutex + devices map[uint64]*registeredDevice + generations map[uint64]uint32 + controllerSessionID uint64 + controllerInstanceID string + lanes map[laneKey]*operationLane + failedLanes map[laneKey]error + runCtx context.Context + runCancel context.CancelFunc + fatal chan error + started bool + running bool + laneWG sync.WaitGroup + operationMu sync.Mutex + operations map[uint64]*operationState + completed []uint64 + + inputPublisherStarts atomic.Uint64 + legacyTransferFallbackStarts atomic.Uint64 + deadlineContextFallbackStarts atomic.Uint64 + + // inputAttemptContext is a deterministic deadline seam for host tests. + // Production hosts leave it nil and use context.WithTimeout. + inputAttemptContext func(context.Context, time.Duration) (context.Context, context.CancelFunc) +} + +// InputDiagnostics returns a lock-free snapshot of input-publisher path +// selection. A nonzero fallback count is deliberately visible to release +// telemetry instead of silently trading latency for compatibility. +func (h *Host) InputDiagnostics() InputPathDiagnostics { + if h == nil { + return InputPathDiagnostics{} + } + return InputPathDiagnostics{ + PublisherStarts: h.inputPublisherStarts.Load(), + LegacyTransferFallbackStarts: h.legacyTransferFallbackStarts.Load(), + DeadlineContextFallbackStarts: h.deadlineContextFallbackStarts.Load(), + } +} + +func NewHost(driver Driver, processor OperationProcessor, workers int) (*Host, error) { + if driver == nil || processor == nil { + return nil, errors.New("native UDE host requires a driver and operation processor") + } + if workers <= 0 { + workers = defaultDequeueWorkers + } + host := &Host{ + driver: driver, processor: processor, workers: workers, + devices: make(map[uint64]*registeredDevice), + generations: make(map[uint64]uint32), + lanes: make(map[laneKey]*operationLane), + failedLanes: make(map[laneKey]error), + lifecycles: make(map[uint64]*deviceLifecycleGate), + operations: make(map[uint64]*operationState), + } + host.input, _ = driver.(InputReportDriver) + return host, nil +} + +// lockDeviceLifecycle serializes create/remove for one stable device ID while +// allowing independent controllers to enumerate or tear down concurrently. +// References include both the holder and waiters, so a gate cannot be deleted +// and replaced while an older waiter still targets it. +func (h *Host) lockDeviceLifecycle(deviceID uint64) func() { + h.lifecycleMu.Lock() + gate := h.lifecycles[deviceID] + if gate == nil { + gate = &deviceLifecycleGate{} + h.lifecycles[deviceID] = gate + } + gate.references++ + h.lifecycleMu.Unlock() + + gate.mu.Lock() + return func() { + gate.mu.Unlock() + h.lifecycleMu.Lock() + gate.references-- + if gate.references == 0 && h.lifecycles[deviceID] == gate { + delete(h.lifecycles, deviceID) + } + h.lifecycleMu.Unlock() + } +} + +func interruptInputServiceInterval(speed uint32, bInterval uint8) time.Duration { + // Match the proven USB/IP interrupt scheduler in + // internal/server/usb.usbServiceInterval. + if bInterval == 0 { + return 0 + } + if speed >= uint32(DeviceSpeedHigh) { + // USB 2.x/3.x encode interrupt service periods as a power of two + // microframes and reserve values above 16. + if bInterval > 16 { + return 0 + } + return time.Duration(uint64(1)<<(bInterval-1)) * 125 * time.Microsecond + } + return time.Duration(bInterval) * time.Millisecond +} + +func fastInputEndpoints(dev usb.Device) map[uint8]fastInputEndpoint { + result := make(map[uint8]fastInputEndpoint) + if dev == nil || dev.GetDescriptor() == nil { + return result + } + selector, restrictEndpoints := dev.(usb.InterruptInputEndpointSelector) + for _, iface := range dev.GetDescriptor().Interfaces { + for _, endpoint := range iface.Endpoints { + if endpoint.BEndpointAddress&0x80 != 0 && endpoint.BMAttributes&0x03 == 0x03 { + if restrictEndpoints && !selector.SupportsInterruptInputEndpoint( + uint32(endpoint.BEndpointAddress&0x0f)) { + continue + } + // USB 2.0 wMaxPacketSize uses bits 0..10 for bytes and bits + // 11..12 for additional high-bandwidth transactions. Allocate + // the complete service opportunity while enforcing the native + // ABI's hard report bound. + packetBytes := int(endpoint.WMaxPacketSize & 0x07ff) + transactions := 1 + int((endpoint.WMaxPacketSize>>11)&0x03) + reportSize := packetBytes * transactions + if reportSize > 0 && reportSize <= MaxInputReportBytes { + result[endpoint.BEndpointAddress] = fastInputEndpoint{ + reportSize: reportSize, + interval: interruptInputServiceInterval( + dev.GetDescriptor().Device.Speed, endpoint.BInterval), + } + } + } + } + } + return result +} + +// Register preserves the historical lifecycle API for callers that need only +// the exact device/generation identity. +func (h *Host) Register(ctx context.Context, deviceID uint64, dev usb.Device) (DeviceIdentity, error) { + registration, err := h.RegisterWithCorrelation(ctx, deviceID, dev) + return registration.DeviceIdentity, err +} + +// RegisterWithCorrelation publishes a USB device using a fresh generation and +// returns the kernel-authored PnP correlation receipt. The routing entry is +// installed before the driver plugs in the child because Windows can submit +// its first descriptor request before CreateDevice returns. +func (h *Host) RegisterWithCorrelation(ctx context.Context, deviceID uint64, dev usb.Device) (DeviceRegistration, error) { + if deviceID == 0 || dev == nil { + return DeviceRegistration{}, ErrInvalidRange + } + unlockLifecycle := h.lockDeviceLifecycle(deviceID) + defer unlockLifecycle() + + h.mu.Lock() + // One driver file owner is one native UDE host session. Once Serve has + // stopped, operations already dequeued into user mode cannot be replayed or + // reconstructed safely. A fresh Client/Host pair is therefore required + // instead of publishing a child into a terminal owner session. + if h.started && (!h.running || h.runCtx == nil || h.runCtx.Err() != nil) { + h.mu.Unlock() + return DeviceRegistration{}, errors.New("native UDE host session has stopped; open a fresh driver session") + } + if _, exists := h.devices[deviceID]; exists { + h.mu.Unlock() + return DeviceRegistration{}, fmt.Errorf("native UDE device %d is already registered", deviceID) + } + if h.generations[deviceID] == math.MaxUint32 { + h.mu.Unlock() + return DeviceRegistration{}, fmt.Errorf( + "native UDE device %d exhausted its generation space", deviceID) + } + generation := h.generations[deviceID] + 1 + identity := DeviceIdentity{DeviceID: deviceID, Generation: generation} + deviceCtx, cancel := context.WithCancel(context.Background()) + entry := ®isteredDevice{ + identity: identity, device: dev, sequence: newDeviceSequenceBarrier(), + ctx: deviceCtx, cancel: cancel, + fastInput: fastInputEndpoints(dev), publishers: make(map[uint8]*inputPublisher), + activeInput: make(map[uint8]uint32), resettingInput: make(map[uint8]uint32), + endpointGeneration: make(map[uint8]uint32), + inputSequences: make(map[endpointIdentity]*atomic.Uint64), inD0: true, + } + h.devices[deviceID] = entry + h.generations[deviceID] = generation + h.mu.Unlock() + + var registration DeviceRegistration + driverCommitted := false + snapshot, err := SnapshotDevice(deviceID, generation, dev) + if err == nil { + registration, err = h.driver.CreateDevice(ctx, snapshot) + if err == nil { + driverCommitted = true + if !deviceRegistrationMatchesCreate(registration, snapshot) { + err = errors.New("native UDE driver returned an invalid device-correlation receipt") + } else { + h.mu.Lock() + if h.controllerSessionID == 0 { + h.controllerSessionID = registration.ControllerSessionID + h.controllerInstanceID = registration.ControllerInstanceID + } else if h.controllerSessionID != registration.ControllerSessionID || + !strings.EqualFold(h.controllerInstanceID, registration.ControllerInstanceID) { + err = errors.New("native UDE driver changed controller identity within one host session") + } + h.mu.Unlock() + } + } + } + if err != nil { + if driverCommitted { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), terminalCleanupTimeout) + cleanupErr := h.driver.DestroyDevice(cleanupCtx, identity) + cleanupCancel() + if cleanupErr != nil { + err = errors.Join(err, fmt.Errorf( + "rollback native UDE device after invalid correlation receipt: %w", cleanupErr)) + } + } + h.mu.Lock() + if h.devices[deviceID] == entry { + delete(h.devices, deviceID) + } + h.mu.Unlock() + cancel() + return DeviceRegistration{}, err + } + + // CreateDevice is an overlapped PnP transaction and can outlive a fatal or + // cancelled one-shot Serve session. Revalidate after the kernel commits the + // child. Reporting success here would publish a controller into a host that + // can never service its USB requests. Roll the exact generation back while + // the per-device lifecycle gate is still held. + h.mu.RLock() + terminal := h.started && (!h.running || h.runCtx == nil || h.runCtx.Err() != nil) + h.mu.RUnlock() + if terminal { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), terminalCleanupTimeout) + cleanupErr := h.driver.DestroyDevice(cleanupCtx, identity) + cleanupCancel() + h.mu.Lock() + if h.devices[deviceID] == entry { + entry.stopping = true + entry.publisherStopping = true + if cleanupErr == nil { + delete(h.devices, deviceID) + } + } + h.mu.Unlock() + cancel() + if cleanupErr == nil { + h.processor.Reset(dev, identity) + return DeviceRegistration{}, errors.New( + "native UDE host session stopped while controller registration was in flight") + } + return DeviceRegistration{}, errors.Join( + errors.New("native UDE host session stopped while controller registration was in flight"), + fmt.Errorf("rollback native UDE device %d generation %d: %w", + identity.DeviceID, identity.Generation, cleanupErr)) + } + return registration, nil +} + +func deviceRegistrationMatchesCreate(registration DeviceRegistration, requested CreateDevice) bool { + if registration.DeviceIdentity != (DeviceIdentity{ + DeviceID: requested.DeviceID, Generation: requested.Generation, + }) || registration.Speed != requested.Speed || registration.ControllerSessionID == 0 || + !IsCanonicalControllerInstanceID(registration.ControllerInstanceID) { + return false + } + if requested.Speed == DeviceSpeedSuper { + return registration.USB20PortNumber == 0 && + registration.USB30PortNumber > MaxDevices && + registration.USB30PortNumber <= 2*MaxDevices + } + return registration.USB30PortNumber == 0 && registration.USB20PortNumber != 0 && + registration.USB20PortNumber <= MaxDevices +} + +func (h *Host) Unregister(ctx context.Context, identity DeviceIdentity) error { + if identity.DeviceID == 0 || identity.Generation == 0 { + return ErrInvalidRange + } + unlockLifecycle := h.lockDeviceLifecycle(identity.DeviceID) + defer unlockLifecycle() + + h.mu.RLock() + entry := h.devices[identity.DeviceID] + if entry == nil || entry.stopping || entry.identity.Generation != identity.Generation { + h.mu.RUnlock() + return fmt.Errorf("native UDE device %d generation %d is not registered", + identity.DeviceID, identity.Generation) + } + h.mu.RUnlock() + + h.mu.Lock() + entry.publisherStopping = true + h.mu.Unlock() + activePublishers := h.activeInputEndpoints(entry) + h.stopAllInputPublishers(entry) + + // Keep routing live until the driver has transactionally accepted removal. + // Errors occur before UdeCx consumes the device handle, so callers can + // retry without losing the generation or its endpoint lanes. + if err := h.driver.DestroyDevice(ctx, identity); err != nil { + h.mu.Lock() + entry.publisherStopping = false + h.mu.Unlock() + for _, endpoint := range activePublishers { + h.startInputPublisher(entry, endpoint.address, endpoint.generation) + } + return err + } + go h.observeLifecycleRemoval(identity) + + h.mu.Lock() + if h.devices[identity.DeviceID] != entry { + h.mu.Unlock() + return errors.New("native UDE device changed during serialized removal") + } + entry.stopping = true + stoppingLanes := make([]*operationLane, 0, 4) + for key, lane := range h.lanes { + if key.deviceID == identity.DeviceID && key.generation == identity.Generation { + stoppingLanes = append(stoppingLanes, lane) + delete(h.lanes, key) + } + } + for key := range h.failedLanes { + if key.deviceID == identity.DeviceID && key.generation == identity.Generation { + delete(h.failedLanes, key) + } + } + h.mu.Unlock() + + // Mark operations cancelled before their processing contexts are stopped. + // This prevents a processor waking on lane cancellation and racing a + // completion through an intentionally cancelled driver handle. + h.cancelDeviceOperations(identity) + for _, lane := range stoppingLanes { + stopLane(lane) + } + entry.cancel() + for _, lane := range stoppingLanes { + select { + case <-lane.done: + case <-ctx.Done(): + h.reportFatal(fmt.Errorf("stop native UDE device %d generation %d lanes: %w", + identity.DeviceID, identity.Generation, ctx.Err())) + return ctx.Err() + } + } + h.mu.Lock() + if h.devices[identity.DeviceID] == entry { + delete(h.devices, identity.DeviceID) + } + h.mu.Unlock() + h.processor.Reset(entry.device, identity) + return nil +} + +func (h *Host) observeLifecycleRemoval(identity DeviceIdentity) { + driver, ok := h.driver.(LifecycleTraceDriver) + if !ok { + return + } + seen := make(map[uint64]struct{}, LifecycleTraceCapacity) + statusReported := false + for _, delay := range []time.Duration{0, 100 * time.Millisecond, 500 * time.Millisecond, 2 * time.Second, 5 * time.Second} { + if delay != 0 { + timer := time.NewTimer(delay) + <-timer.C + } + queryCtx, cancel := context.WithTimeout(context.Background(), completionTimeout) + trace, err := driver.QueryLifecycleTrace(queryCtx) + cancel() + if err != nil { + slog.Warn("native UDE lifecycle trace query failed", + "device_id", identity.DeviceID, "generation", identity.Generation, + "error", err) + return + } + if trace.StatusFlags != 0 && !statusReported { + statusReported = true + slog.Error("native UDE lifecycle recorder reported sticky failure state", + "device_id", identity.DeviceID, "generation", identity.Generation, + "status_flags", fmt.Sprintf("%#08x", uint32(trace.StatusFlags)), + "latest_sequence", trace.LatestSequence) + } + for _, record := range trace.Records { + if record.DeviceID != identity.DeviceID || record.Generation != identity.Generation { + continue + } + if _, duplicate := seen[record.PublishedSequence]; duplicate { + continue + } + seen[record.PublishedSequence] = struct{}{} + slog.Info("native UDE lifecycle", + "sequence", record.PublishedSequence, + "qpc", record.TimestampQPC, + "qpc_frequency", trace.PerformanceFrequency, + "source", lifecycleTraceSourceName(record.Source), + "event", lifecycleTraceEventName(record.Event), + "line", record.Line, + "caller", fmt.Sprintf("%#x", record.Caller), + "cpu", record.Processor, + "irql", record.IRQL, + "device_id", record.DeviceID, + "generation", record.Generation, + "device_object", fmt.Sprintf("%#x", record.DeviceObject), + "endpoint_object", fmt.Sprintf("%#x", record.EndpointObject), + "endpoint", fmt.Sprintf("%#02x", record.EndpointAddress), + "status", fmt.Sprintf("%#08x", uint32(record.Status)), + "active_operations", record.ActiveOperations, + "pending_operations", record.PendingOperations, + "queue_state", fmt.Sprintf("%#08x", record.QueueState)) + } + } +} + +func lifecycleTraceSourceName(source uint8) string { + switch source { + case TraceSourceDevice: + return "Device.c" + case TraceSourceBroker: + return "Broker.c" + case TraceSourceController: + return "Controller.c" + default: + return fmt.Sprintf("source-%d", source) + } +} + +func lifecycleTraceEventName(event uint16) string { + names := [...]string{ + "", "create-begin", "device-create-returned", "device-slot-claimed", + "plug-in-begin", "plug-in-returned", "remove-claimed", + "management-abort-begin", "management-abort-end", "plug-out-begin", + "plug-out-returned", "endpoint-purge-begin", "endpoint-operations-purged", + "endpoint-queue-purge-requested", "endpoint-driver-quiescent", + "endpoint-drain-begin", "endpoint-drain-end", + "endpoint-purge-complete-begin", "endpoint-purge-complete-end", + "endpoint-cleanup-begin", "endpoint-cleanup-end", "device-cleanup-begin", + "device-cleanup-end", "controller-shutdown-begin", "controller-shutdown-end", + "endpoint-quiescence-watchdog", + "completion-rundown-watchdog", "controller-rundown-watchdog", + "owner-rundown-watchdog", + } + if int(event) < len(names) && names[event] != "" { + return names[event] + } + return fmt.Sprintf("event-%d", event) +} + +func (h *Host) startInputPublisher( + entry *registeredDevice, endpoint uint8, endpointGeneration uint32, +) { + if h.input == nil || endpointGeneration == 0 { + return + } + h.mu.Lock() + if !h.running || entry.stopping || entry.publisherStopping || !entry.inD0 || entry.resetting || + entry.activeInput[endpoint] != endpointGeneration || + entry.endpointGeneration[endpoint] != endpointGeneration || + entry.resettingInput[endpoint] == endpointGeneration || + h.devices[entry.identity.DeviceID] != entry { + h.mu.Unlock() + return + } + endpointContract, fast := entry.fastInput[endpoint] + if !fast || entry.publishers[endpoint] != nil { + h.mu.Unlock() + return + } + identity := endpointIdentity{address: endpoint, generation: endpointGeneration} + sequence := entry.inputSequences[identity] + if sequence == nil { + sequence = &atomic.Uint64{} + entry.inputSequences[identity] = sequence + } + ctx, cancel := context.WithCancel(entry.ctx) + publisher := &inputPublisher{ + endpoint: endpoint, endpointGeneration: endpointGeneration, + reportSize: endpointContract.reportSize, + interval: endpointContract.interval, sequence: sequence, + submitCtx: h.runCtx, + cancel: cancel, done: make(chan struct{}), + } + entry.publishers[endpoint] = publisher + h.mu.Unlock() + + go h.runInputPublisher(ctx, entry, publisher) +} + +func (h *Host) stopInputPublisher( + entry *registeredDevice, endpoint uint8, endpointGeneration uint32, +) bool { + h.mu.Lock() + publisher := entry.publishers[endpoint] + if publisher != nil && publisher.endpointGeneration == endpointGeneration { + delete(entry.publishers, endpoint) + publisher.cancel() + } else { + publisher = nil + } + h.mu.Unlock() + if publisher == nil { + return false + } + <-publisher.done + return true +} + +func (h *Host) stopAllInputPublishers(entry *registeredDevice) []endpointIdentity { + h.mu.RLock() + endpoints := make([]endpointIdentity, 0, len(entry.publishers)) + for endpoint, publisher := range entry.publishers { + endpoints = append(endpoints, endpointIdentity{ + address: endpoint, generation: publisher.endpointGeneration, + }) + } + h.mu.RUnlock() + for _, endpoint := range endpoints { + h.stopInputPublisher(entry, endpoint.address, endpoint.generation) + } + return endpoints +} + +func (h *Host) activeInputEndpoints(entry *registeredDevice) []endpointIdentity { + h.mu.RLock() + defer h.mu.RUnlock() + endpoints := make([]endpointIdentity, 0, len(entry.activeInput)) + for endpoint, generation := range entry.activeInput { + if generation != 0 { + endpoints = append(endpoints, endpointIdentity{ + address: endpoint, generation: generation, + }) + } + } + return endpoints +} + +func (h *Host) withInputAttemptDeadline( + ctx context.Context, interval time.Duration, +) (context.Context, context.CancelFunc) { + if h.inputAttemptContext != nil { + return h.inputAttemptContext(ctx, interval) + } + return context.WithTimeout(ctx, interval) +} + +func stopInputDeadlineTimer(timer *time.Timer) { + if timer.Stop() { + return + } + // Go 1.23+ synchronous timer channels guarantee that Stop prevents a stale + // receive. The nonblocking drain also preserves that invariant if a process + // explicitly restores the legacy buffered timer implementation through + // GODEBUG=asynctimerchan=1. + select { + case <-timer.C: + default: + } +} + +func nextInputReportSequence(previous uint64) (uint64, error) { + // InputReport's wire contract is signed-positive so the kernel can validate + // it with MAXLONGLONG. Never wrap to one: reusing an accepted sequence would + // violate the monotonic endpoint contract. + if previous >= math.MaxInt64 { + return 0, errInputSequenceExhausted + } + return previous + 1, nil +} + +func (h *Host) runInputPublisher(ctx context.Context, entry *registeredDevice, publisher *inputPublisher) { + defer close(publisher.done) + reader, direct := entry.device.(usb.InterruptInputDevice) + scheduledReader, scheduled := entry.device.(usb.ScheduledInterruptInputDevice) + classifiedReader, classified := entry.device.(usb.ClassifiedScheduledInterruptInputDevice) + h.inputPublisherStarts.Add(1) + if !direct { + h.legacyTransferFallbackStarts.Add(1) + slog.Warn("native UDE interrupt input compatibility fallback activated", + "device_id", entry.identity.DeviceID, + "generation", entry.identity.Generation, + "endpoint", fmt.Sprintf("%#02x", publisher.endpoint), + "endpoint_generation", publisher.endpointGeneration, + "fallback", "legacy-handle-transfer", + "reason", "device does not implement InterruptInputDevice") + } else if publisher.interval > 0 && !scheduled { + h.deadlineContextFallbackStarts.Add(1) + slog.Warn("native UDE interrupt input compatibility fallback activated", + "device_id", entry.identity.DeviceID, + "generation", entry.identity.Generation, + "endpoint", fmt.Sprintf("%#02x", publisher.endpoint), + "endpoint_generation", publisher.endpointGeneration, + "fallback", "per-report-deadline-context", + "reason", "device does not implement ScheduledInterruptInputDevice") + } + var reportBuffer []byte + var deadlineTimer *time.Timer + var retryTimer *time.Timer + defer func() { + if retryTimer != nil { + stopInputDeadlineTimer(retryTimer) + } + }() + if direct { + reportBuffer = make([]byte, publisher.reportSize) + if scheduled && publisher.interval > 0 { + // One endpoint owns one timer for its complete lifetime. Resetting it + // after each submitted sample preserves the established relative + // service-deadline contract while removing a timer/context allocation + // from every idle 1 ms controller report. + deadlineTimer = time.NewTimer(time.Hour) + stopInputDeadlineTimer(deadlineTimer) + defer stopInputDeadlineTimer(deadlineTimer) + } + } + for { + var payload []byte + transition := false + if direct { + var written int + var err error + if deadlineTimer != nil && classified { + deadlineTimer.Reset(publisher.interval) + written, transition, err = classifiedReader.ReadClassifiedScheduledInterruptInput( + ctx, deadlineTimer.C, uint32(publisher.endpoint&0x0f), reportBuffer) + stopInputDeadlineTimer(deadlineTimer) + } else if deadlineTimer != nil { + deadlineTimer.Reset(publisher.interval) + written, err = scheduledReader.ReadScheduledInterruptInput( + ctx, deadlineTimer.C, uint32(publisher.endpoint&0x0f), reportBuffer) + stopInputDeadlineTimer(deadlineTimer) + } else { + attemptCtx := ctx + attemptCancel := context.CancelFunc(func() {}) + if publisher.interval > 0 { + attemptCtx, attemptCancel = h.withInputAttemptDeadline(ctx, publisher.interval) + } + written, err = reader.ReadInterruptInput( + attemptCtx, uint32(publisher.endpoint&0x0f), reportBuffer) + attemptCancel() + } + if err != nil { + if ctx.Err() != nil { + return + } + // Event-only devices may decline to synthesize an idle report. + // Cached-state controller implementations return success on the + // same deadline and are submitted below at the endpoint cadence. + if errors.Is(err, context.DeadlineExceeded) { + continue + } + h.reportFatal(fmt.Errorf( + "encode native UDE input report for device %d endpoint 0x%02x: %w", + entry.identity.DeviceID, publisher.endpoint, err)) + return + } + if written <= 0 || written > len(reportBuffer) { + h.reportFatal(fmt.Errorf( + "device %d encoded invalid interrupt-IN length %d for endpoint 0x%02x (capacity %d)", + entry.identity.DeviceID, written, publisher.endpoint, len(reportBuffer))) + return + } + payload = reportBuffer[:written] + } else { + payload = entry.device.HandleTransfer( + ctx, uint32(publisher.endpoint&0x0f), usb.DirectionIn, nil) + } + if len(payload) == 0 { + // The legacy HandleTransfer contract signals a cancelled wait with + // an empty slice. No report was encoded, so there is nothing to commit. + if ctx.Err() != nil || publisher.submitCtx.Err() != nil { + return + } + h.reportFatal(fmt.Errorf( + "device %d returned an empty interrupt-IN report for endpoint 0x%02x", + entry.identity.DeviceID, publisher.endpoint)) + return + } + // Once the controller encoder has returned a report, commit that exact + // state before an endpoint lifecycle boundary joins this publisher. + // Only owner-session shutdown may abort the commit. + if publisher.submitCtx.Err() != nil { + return + } + // The sequence is owned by this endpoint generation and survives a + // purge/start or reset publisher replacement. There is exactly one live + // publisher per endpoint and stopInputPublisher joins it before a + // replacement starts, so reserve the next value without committing it. + // Owner-session cancellation can land between this point and driver + // acceptance; committing the counter only after a successful submit keeps + // accepted reports contiguous without rolling back device encoder state. + previousSequence := publisher.sequence.Load() + sequence, err := nextInputReportSequence(previousSequence) + if err != nil { + h.reportFatal(fmt.Errorf( + "reserve native UDE input sequence for device %d endpoint 0x%02x: %w", + entry.identity.DeviceID, publisher.endpoint, err)) + return + } + report := InputReport{ + DeviceID: entry.identity.DeviceID, Generation: entry.identity.Generation, + EndpointGeneration: publisher.endpointGeneration, + EndpointAddress: publisher.endpoint, Transition: transition, + Sequence: sequence, Payload: payload, + } + for { + err = h.input.SubmitInputReport(publisher.submitCtx, report) + if !errors.Is(err, ErrInputQueueFull) { + break + } + // The kernel retained every earlier transition and rejected this one + // before accepting its sequence. Wait one endpoint interval, then retry + // this exact report. This propagates bounded backpressure without + // dropping an edge or faulting the owner session. + retryInterval := publisher.interval + if retryInterval <= 0 { + retryInterval = time.Millisecond + } + if retryTimer == nil { + retryTimer = time.NewTimer(retryInterval) + } else { + retryTimer.Reset(retryInterval) + } + select { + case <-publisher.submitCtx.Done(): + stopInputDeadlineTimer(retryTimer) + return + case <-retryTimer.C: + } + } + if err != nil { + if publisher.submitCtx.Err() != nil { + return + } + h.reportFatal(fmt.Errorf( + "submit native UDE input report for device %d endpoint 0x%02x: %w", + entry.identity.DeviceID, publisher.endpoint, err)) + return + } + if !publisher.sequence.CompareAndSwap(previousSequence, sequence) { + h.reportFatal(fmt.Errorf( + "commit native UDE input sequence for device %d endpoint 0x%02x: concurrent publisher changed %d", + entry.identity.DeviceID, publisher.endpoint, previousSequence)) + return + } + } +} + +type dequeueResult struct { + op Operation + err error +} + +func (h *Host) Serve(ctx context.Context) error { + h.mu.Lock() + if h.running { + h.mu.Unlock() + return errors.New("native UDE host is already running") + } + if h.started { + h.mu.Unlock() + return errors.New("native UDE host sessions are one-shot; open a fresh driver session") + } + runCtx, cancel := context.WithCancel(ctx) + fatal := make(chan error, 1) + h.runCtx, h.runCancel, h.fatal, h.started, h.running = runCtx, cancel, fatal, true, true + entries := make([]*registeredDevice, 0, len(h.devices)) + for _, entry := range h.devices { + entries = append(entries, entry) + } + h.mu.Unlock() + for _, entry := range entries { + for _, endpoint := range h.activeInputEndpoints(entry) { + h.startInputPublisher(entry, endpoint.address, endpoint.generation) + } + } + defer func() { + cancel() + h.mu.Lock() + stoppingLanes := make([]*operationLane, 0, len(h.lanes)) + for key, lane := range h.lanes { + stoppingLanes = append(stoppingLanes, lane) + delete(h.lanes, key) + } + h.failedLanes = make(map[laneKey]error) + h.mu.Unlock() + for _, lane := range stoppingLanes { + stopLane(lane) + } + h.laneWG.Wait() + h.cancelAllOperations() + h.mu.Lock() + entries = entries[:0] + for _, entry := range h.devices { + entries = append(entries, entry) + } + h.running, h.runCtx, h.runCancel, h.fatal = false, nil, nil, nil + h.mu.Unlock() + for _, entry := range entries { + h.stopAllInputPublishers(entry) + } + }() + + results := make(chan dequeueResult, h.workers*2) + var workers sync.WaitGroup + workers.Add(h.workers) + for i := 0; i < h.workers; i++ { + go func() { + defer workers.Done() + buffer := make([]byte, OperationSize+MaxIsoPackets*IsoPacketSize+MaxTransferBytes) + for runCtx.Err() == nil { + op, err := h.driver.Dequeue(runCtx, buffer) + select { + case results <- dequeueResult{op: op, err: err}: + case <-runCtx.Done(): + return + } + if err != nil { + return + } + } + }() + } + finishFatal := func(err error) error { + cancel() + workers.Wait() + return fmt.Errorf("native UDE host session failed: %w", err) + } + + for { + // Once a lane or publisher reports a fatal error, do not let an always- + // ready dequeue stream win repeated select lotteries. Cancelling here + // also releases every worker before another result is dispatched. + select { + case err := <-fatal: + return finishFatal(err) + default: + } + select { + case <-runCtx.Done(): + workers.Wait() + return nil + case err := <-fatal: + return finishFatal(err) + case result := <-results: + // A worker result and a fatal lane notification can become ready in + // the same scheduling turn. Fatal is terminal; observe it before + // touching the newly dequeued operation. + select { + case err := <-fatal: + return finishFatal(err) + default: + } + if result.err != nil { + cancel() + workers.Wait() + if ctx.Err() != nil || errors.Is(result.err, context.Canceled) { + return nil + } + return fmt.Errorf("dequeue native UDE operation: %w", result.err) + } + if result.op.Kind == OperationBrokerFault { + h.reportFatal(errors.New("native UDE kernel broker reported a lost lifecycle notification")) + continue + } + if result.op.Kind == OperationCancel { + // A management-token cancel is a teardown tombstone for a held + // lifecycle request which the kernel already retired. It has no + // future ordinary operation to match, so accepting it must not + // retain an unbounded cancellation entry in the session map. + if isManagementToken(result.op.Token) { + continue + } + h.cancelOperation(result.op) + continue + } + if !isLifecycleOperation(result.op.Kind) { + if err := h.trackOperation(result.op); err != nil { + h.reportFatal(fmt.Errorf("track operation token %d: %w", result.op.Token, err)) + continue + } + } + if err := h.dispatch(runCtx, result.op); err != nil { + // Saturation terminates the affected lane and publishes fatal + // synchronously. Do not wait up to completionTimeout trying to + // reject that final request before cancelling the owner session. + select { + case fatalErr := <-fatal: + return finishFatal(fatalErr) + default: + } + if isLifecycleOperation(result.op.Kind) && result.op.Token != 0 { + if completeErr := h.completeLifecycle(runCtx, result.op, statusUnsuccessful); completeErr != nil { + h.reportFatal(fmt.Errorf("reject lifecycle token %d after dispatch failure %v: %w", + result.op.Token, err, completeErr)) + } + } else if !isLifecycleOperation(result.op.Kind) { + if completeErr := h.completeFailure(runCtx, result.op); completeErr != nil { + h.reportFatal(fmt.Errorf("reject operation token %d after dispatch failure %v: %w", + result.op.Token, err, completeErr)) + } + } + } + } + } +} + +func (h *Host) Close() { + h.mu.RLock() + cancel := h.runCancel + h.mu.RUnlock() + if cancel != nil { + cancel() + } +} + +func (h *Host) dispatch(ctx context.Context, op Operation) error { + if op.EndpointSequence == 0 { + return errors.New("native UDE operation has zero endpoint sequence") + } + if err := ctx.Err(); err != nil { + return err + } + key := laneKey{ + deviceID: op.DeviceID, generation: op.Generation, + endpoint: op.EndpointAddress, endpointGeneration: op.EndpointGeneration, + } + + h.mu.Lock() + entry := h.devices[op.DeviceID] + if entry == nil || entry.stopping || entry.identity.Generation != op.Generation || entry.ctx.Err() != nil { + h.mu.Unlock() + return errors.New("native UDE operation targets a stale device generation") + } + if terminalErr := h.failedLanes[key]; terminalErr != nil { + h.mu.Unlock() + return terminalErr + } + lane := h.lanes[key] + if lane == nil { + laneCtx, cancel := context.WithCancel(entry.ctx) + lane = &operationLane{ + key: key, ctx: laneCtx, cancel: cancel, + input: make(chan Operation, laneQueueDepth), done: make(chan struct{}), + } + h.lanes[key] = lane + h.laneWG.Add(1) + go h.runLane(lane, entry) + } + h.mu.Unlock() + announcedBarrier := false + if isDeviceBarrierOperation(op) { + if err := entry.sequence.announce(op.DeviceSequence); err != nil { + h.failLane(lane, err) + return err + } + announcedBarrier = op.DeviceSequence != 0 + } + withdrawBarrier := func() { + if announcedBarrier { + entry.sequence.withdraw(op.DeviceSequence) + announcedBarrier = false + } + } + + // Admission is deliberately nonblocking. A queue at the full kernel + // pending-operation contract means either an ABI/driver contract violation + // or a terminal endpoint; waiting here would let that one endpoint stall + // cancellations, lifecycle traffic, and every other controller. + lane.stateMu.Lock() + if lane.terminalErr != nil { + err := lane.terminalErr + lane.stateMu.Unlock() + withdrawBarrier() + return err + } + if err := lane.ctx.Err(); err != nil { + lane.stateMu.Unlock() + withdrawBarrier() + return err + } + if err := ctx.Err(); err != nil { + lane.stateMu.Unlock() + withdrawBarrier() + return err + } + select { + case lane.input <- op: + lane.stateMu.Unlock() + return nil + default: + err := fmt.Errorf( + "native UDE device %d generation %d endpoint 0x%02x generation %d lane is saturated at the %d-operation pending contract", + key.deviceID, key.generation, key.endpoint, key.endpointGeneration, laneQueueDepth) + lane.terminalErr = err + lane.cancel() + lane.stateMu.Unlock() + withdrawBarrier() + h.removeFailedLane(lane, err) + h.reportFatal(err) + return err + } +} + +func stopLane(lane *operationLane) { + lane.stateMu.Lock() + if lane.terminalErr == nil { + lane.terminalErr = context.Canceled + } + lane.cancel() + lane.stateMu.Unlock() +} + +// removeFailedLane installs a tombstone only when lane is still the exact +// routed instance. An older goroutine can therefore never remove or poison a +// replacement lane created for a later lifecycle. +func (h *Host) removeFailedLane(lane *operationLane, err error) { + h.mu.Lock() + if h.lanes[lane.key] == lane { + delete(h.lanes, lane.key) + if h.failedLanes[lane.key] == nil { + h.failedLanes[lane.key] = err + } + } + h.mu.Unlock() +} + +func (h *Host) failLane(lane *operationLane, err error) { + if err == nil { + return + } + lane.stateMu.Lock() + if lane.terminalErr != nil { + lane.stateMu.Unlock() + return + } + lane.terminalErr = err + lane.cancel() + lane.stateMu.Unlock() + + h.removeFailedLane(lane, err) + h.reportFatal(err) +} + +func (h *Host) retireLane(lane *operationLane) { + stopLane(lane) + h.mu.Lock() + if h.lanes[lane.key] == lane { + delete(h.lanes, lane.key) + } + h.mu.Unlock() + close(lane.done) + h.laneWG.Done() +} + +func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { + defer h.retireLane(lane) + expected := uint64(1) + pending := make(map[uint64]Operation) + for { + select { + case <-lane.ctx.Done(): + return + case op := <-lane.input: + if lane.ctx.Err() != nil { + return + } + if op.EndpointSequence < expected { + h.failLane(lane, fmt.Errorf("endpoint 0x%02x sequence regressed from %d to %d", + lane.key.endpoint, expected, op.EndpointSequence)) + return + } + if _, duplicate := pending[op.EndpointSequence]; duplicate { + h.failLane(lane, fmt.Errorf("endpoint 0x%02x repeated pending sequence %d", + lane.key.endpoint, op.EndpointSequence)) + return + } + pending[op.EndpointSequence] = op + if len(pending) > laneQueueDepth { + h.failLane(lane, fmt.Errorf("endpoint 0x%02x exceeded the %d-operation reorder bound while waiting for sequence %d", + lane.key.endpoint, laneQueueDepth, expected)) + return + } + for { + current, ready := pending[expected] + if !ready { + break + } + delete(pending, expected) + if isLifecycleOperation(current.Kind) { + if err := h.processLifecycle(lane.ctx, entry, current); err != nil { + h.failLane(lane, fmt.Errorf("endpoint 0x%02x lifecycle sequence %d: %w", + lane.key.endpoint, current.EndpointSequence, err)) + return + } + } else { + if err := h.process(lane.ctx, entry, current); err != nil { + h.failLane(lane, fmt.Errorf("endpoint 0x%02x complete sequence %d: %w", + lane.key.endpoint, current.EndpointSequence, err)) + return + } + } + expected++ + } + } + } +} + +func isLifecycleOperation(kind OperationKind) bool { + switch kind { + case OperationEndpointStart, OperationEndpointPurge, OperationEndpointReset, + OperationDeviceReset, OperationSetInterface, OperationDeviceD0Entry, OperationDeviceD0Exit: + return true + default: + return false + } +} + +// admitEndpointGeneration establishes the endpoint incarnation before any +// controller callback or direct-input publisher can observe it. Device-wide +// lifecycle operations carry generation zero and deliberately bypass this +// endpoint fence. A higher generation permanently retires the prior address +// incarnation; a lower generation is stale even if its endpoint sequence is +// otherwise locally valid. +func (h *Host) admitEndpointGeneration(entry *registeredDevice, op Operation) bool { + if op.EndpointGeneration == 0 { + return false + } + h.mu.Lock() + current := entry.endpointGeneration[op.EndpointAddress] + if current > op.EndpointGeneration { + h.mu.Unlock() + return true + } + retired := uint32(0) + if current < op.EndpointGeneration { + retired = current + entry.endpointGeneration[op.EndpointAddress] = op.EndpointGeneration + if entry.activeInput[op.EndpointAddress] != op.EndpointGeneration { + delete(entry.activeInput, op.EndpointAddress) + } + if entry.resettingInput[op.EndpointAddress] != op.EndpointGeneration { + delete(entry.resettingInput, op.EndpointAddress) + } + } + h.mu.Unlock() + if retired != 0 { + h.stopInputPublisher(entry, op.EndpointAddress, retired) + } + return false +} + +func (h *Host) processLifecycle(ctx context.Context, entry *registeredDevice, op Operation) error { + gateCtx, lease, superseded, err := entry.sequence.enter(ctx, op) + if err != nil { + if ctx.Err() != nil { + return nil + } + return err + } + if h.admitEndpointGeneration(entry, op) { + defer lease.finish() + if op.Token == 0 { + return nil + } + return h.completeLifecycle(ctx, op, statusUnsuccessful) + } + if superseded { + defer lease.finish() + // Endpoint lifecycle notifications describe durable UdeCx state even + // when their pre-barrier callback must not run. Preserve only the host's + // minimal publisher bookkeeping; the device-wide barrier owns all actual + // controller/processor state from this point forward. + switch op.Kind { + case OperationEndpointStart: + h.mu.Lock() + if entry.endpointGeneration[op.EndpointAddress] == op.EndpointGeneration { + entry.activeInput[op.EndpointAddress] = op.EndpointGeneration + } + h.mu.Unlock() + case OperationEndpointPurge: + h.mu.Lock() + if entry.activeInput[op.EndpointAddress] == op.EndpointGeneration { + delete(entry.activeInput, op.EndpointAddress) + } + if entry.resettingInput[op.EndpointAddress] == op.EndpointGeneration { + delete(entry.resettingInput, op.EndpointAddress) + } + h.mu.Unlock() + case OperationEndpointReset: + h.mu.Lock() + if entry.resettingInput[op.EndpointAddress] == op.EndpointGeneration { + delete(entry.resettingInput, op.EndpointAddress) + } + h.mu.Unlock() + } + return h.completeSupersededLifecycle(ctx, entry, op) + } + defer lease.finish() + + applyPowerTransition := false + applyDeviceReset := false + switch op.Kind { + case OperationEndpointPurge: + h.mu.Lock() + if entry.activeInput[op.EndpointAddress] == op.EndpointGeneration { + delete(entry.activeInput, op.EndpointAddress) + } + if entry.resettingInput[op.EndpointAddress] == op.EndpointGeneration { + delete(entry.resettingInput, op.EndpointAddress) + } + h.mu.Unlock() + h.stopInputPublisher(entry, op.EndpointAddress, op.EndpointGeneration) + case OperationEndpointReset: + h.mu.Lock() + if entry.endpointGeneration[op.EndpointAddress] == op.EndpointGeneration { + entry.resettingInput[op.EndpointAddress] = op.EndpointGeneration + } + h.mu.Unlock() + h.stopInputPublisher(entry, op.EndpointAddress, op.EndpointGeneration) + case OperationDeviceD0Exit: + h.mu.Lock() + if op.DeviceSequence > entry.powerSequence { + entry.powerSequence = op.DeviceSequence + entry.inD0 = false + applyPowerTransition = true + } + h.mu.Unlock() + if applyPowerTransition { + h.stopAllInputPublishers(entry) + } + case OperationDeviceReset: + h.mu.Lock() + if !entry.resetting { + entry.resetting = true + entry.resettingInput = make(map[uint8]uint32) + applyDeviceReset = true + } + h.mu.Unlock() + if applyDeviceReset { + h.stopAllInputPublishers(entry) + } + } + + lifecycleErr := h.processor.Lifecycle(gateCtx, entry.device, op) + if errors.Is(context.Cause(gateCtx), errSupersededByDeviceBarrier) { + return h.completeSupersededLifecycle(ctx, entry, op) + } + if op.Token != 0 { + status := int32(0) + if lifecycleErr != nil { + status = statusUnsuccessful + } + if err := h.completeLifecycle(gateCtx, op, status); err != nil { + if errors.Is(context.Cause(gateCtx), errSupersededByDeviceBarrier) { + return h.completeSupersededLifecycle(ctx, entry, op) + } + return fmt.Errorf("acknowledge lifecycle: %w", err) + } + } + if errors.Is(context.Cause(gateCtx), errSupersededByDeviceBarrier) { + h.discardSupersededLifecycle(entry, op) + return nil + } + if lifecycleErr != nil { + return lifecycleErr + } + + switch op.Kind { + case OperationEndpointStart: + h.mu.Lock() + if entry.endpointGeneration[op.EndpointAddress] == op.EndpointGeneration { + entry.activeInput[op.EndpointAddress] = op.EndpointGeneration + } + h.mu.Unlock() + h.startInputPublisher(entry, op.EndpointAddress, op.EndpointGeneration) + case OperationEndpointReset: + h.mu.Lock() + if entry.resettingInput[op.EndpointAddress] == op.EndpointGeneration { + delete(entry.resettingInput, op.EndpointAddress) + } + restart := entry.activeInput[op.EndpointAddress] == op.EndpointGeneration && + entry.endpointGeneration[op.EndpointAddress] == op.EndpointGeneration + h.mu.Unlock() + if restart { + h.startInputPublisher(entry, op.EndpointAddress, op.EndpointGeneration) + } + case OperationDeviceD0Entry: + h.mu.Lock() + if op.DeviceSequence > entry.powerSequence { + entry.powerSequence = op.DeviceSequence + entry.inD0 = true + applyPowerTransition = true + } + h.mu.Unlock() + if applyPowerTransition { + for _, endpoint := range h.activeInputEndpoints(entry) { + h.startInputPublisher(entry, endpoint.address, endpoint.generation) + } + } + case OperationDeviceReset: + if applyDeviceReset { + h.mu.Lock() + entry.resetting = false + h.mu.Unlock() + for _, endpoint := range h.activeInputEndpoints(entry) { + h.startInputPublisher(entry, endpoint.address, endpoint.generation) + } + } + } + return nil +} + +func (h *Host) discardSupersededLifecycle(entry *registeredDevice, op Operation) { + if op.Kind != OperationEndpointReset { + return + } + h.mu.Lock() + if entry.resettingInput[op.EndpointAddress] == op.EndpointGeneration { + delete(entry.resettingInput, op.EndpointAddress) + } + h.mu.Unlock() +} + +func (h *Host) completeSupersededLifecycle( + ctx context.Context, entry *registeredDevice, op Operation, +) error { + h.discardSupersededLifecycle(entry, op) + if op.Token == 0 { + return nil + } + // A token-bearing lifecycle notification owns a live UdeCx management + // request. Device barriers cancel the old processor callback, but the kernel + // intentionally retains that request until user mode acknowledges it (owner + // teardown is the only kernel-side bulk abort). Complete it outside the + // canceled sequence context while the old lease is still held, so the next + // barrier cannot start with a stranded endpoint-reset/interface request. + if err := h.completeLifecycle(ctx, op, statusUnsuccessful); err != nil { + return fmt.Errorf("cancel superseded lifecycle: %w", err) + } + return nil +} + +func (h *Host) completeLifecycle(ctx context.Context, op Operation, status int32) error { + completionCtx, cancel := context.WithTimeout(ctx, completionTimeout) + defer cancel() + return h.driver.Complete(completionCtx, Completion{ + Token: op.Token, DeviceID: op.DeviceID, Generation: op.Generation, + EndpointGeneration: op.EndpointGeneration, Status: status, + }) +} + +func (h *Host) process(ctx context.Context, entry *registeredDevice, op Operation) error { + gateCtx, lease, superseded, err := entry.sequence.enter(ctx, op) + if err != nil { + if ctx.Err() != nil { + h.cancelOperation(op) + h.finishOperation(op.Token) + return nil + } + h.finishOperation(op.Token) + return err + } + if h.admitEndpointGeneration(entry, op) { + defer lease.finish() + return h.completeFailure(ctx, op) + } + if superseded { + defer lease.finish() + h.cancelOperation(op) + h.finishOperation(op.Token) + return nil + } + defer lease.finish() + configurationBarrier := isSetConfigurationOperation(op) + if configurationBarrier { + // SET_CONFIGURATION replaces the child's active USB configuration. The + // global sequence gate has already joined every brokered endpoint lane; + // close and join the direct interrupt-IN lane as part of the same barrier + // so an old report cannot cross the configuration request either. + h.mu.Lock() + applyConfigurationBarrier := !entry.resetting + if applyConfigurationBarrier { + entry.resetting = true + } + h.mu.Unlock() + if applyConfigurationBarrier { + h.stopAllInputPublishers(entry) + defer func() { + h.mu.Lock() + entry.resetting = false + h.mu.Unlock() + for _, endpoint := range h.activeInputEndpoints(entry) { + h.startInputPublisher(entry, endpoint.address, endpoint.generation) + } + }() + } + } + + opCtx, cancel, active := h.beginOperation(gateCtx, op) + if !active { + h.finishOperation(op.Token) + return nil + } + defer cancel() + + completion, err := h.processor.Process(opCtx, entry.device, op) + if errors.Is(context.Cause(gateCtx), errSupersededByDeviceBarrier) { + h.cancelOperation(op) + h.finishOperation(op.Token) + return nil + } + if err != nil { + completion = processorErrorCompletion(op, err) + } + if h.operationCancelled(op.Token) { + h.finishOperation(op.Token) + return nil + } + completion.Token = op.Token + completion.DeviceID = op.DeviceID + completion.Generation = op.Generation + completion.EndpointGeneration = op.EndpointGeneration + // Keep the completion inside the same cancellable device-sequence lease as + // the controller callback. A reset announced after Process returns must be + // able to cancel a blocked driver completion and join it before the reset is + // applied; using the lane context here would leave that old callback outside + // the barrier. + completionCtx, completionCancel := context.WithTimeout(gateCtx, completionTimeout) + defer completionCancel() + err = h.driver.Complete(completionCtx, completion) + if errors.Is(context.Cause(gateCtx), errSupersededByDeviceBarrier) { + h.cancelOperation(op) + h.finishOperation(op.Token) + return nil + } + h.finishOperation(op.Token) + return err +} + +func (h *Host) completeFailure(ctx context.Context, op Operation) error { + if h.operationCancelled(op.Token) { + h.finishOperation(op.Token) + return nil + } + completionCtx, cancel := context.WithTimeout(ctx, completionTimeout) + defer cancel() + err := h.driver.Complete(completionCtx, failureCompletion(op)) + h.finishOperation(op.Token) + return err +} + +func (h *Host) trackOperation(op Operation) error { + if op.Token == 0 { + return errors.New("native UDE operation has zero token") + } + h.operationMu.Lock() + defer h.operationMu.Unlock() + state := h.operations[op.Token] + if state == nil { + h.operations[op.Token] = &operationState{ + deviceID: op.DeviceID, generation: op.Generation, + endpoint: op.EndpointAddress, endpointGeneration: op.EndpointGeneration, + received: true, + } + return nil + } + if state.done || state.received || state.deviceID != op.DeviceID || + state.generation != op.Generation || state.endpoint != op.EndpointAddress || + state.endpointGeneration != op.EndpointGeneration { + return errors.New("native UDE operation reuses a completed or mismatched token") + } + state.received = true + return nil +} + +func (h *Host) reportFatal(err error) { + if err == nil { + return + } + h.mu.RLock() + fatal := h.fatal + h.mu.RUnlock() + if fatal == nil { + return + } + select { + case fatal <- err: + default: + } +} + +func (h *Host) cancelAllOperations() { + var cancels []context.CancelFunc + h.operationMu.Lock() + for _, state := range h.operations { + if state.cancel != nil { + cancels = append(cancels, state.cancel) + } + } + h.operations = make(map[uint64]*operationState) + h.completed = nil + h.operationMu.Unlock() + for _, cancel := range cancels { + cancel() + } +} + +func (h *Host) beginOperation(parent context.Context, op Operation) (context.Context, context.CancelFunc, bool) { + h.operationMu.Lock() + defer h.operationMu.Unlock() + state := h.operations[op.Token] + if state == nil || state.done || state.cancelled || state.deviceID != op.DeviceID || + state.generation != op.Generation || state.endpoint != op.EndpointAddress || + state.endpointGeneration != op.EndpointGeneration { + return parent, func() {}, false + } + opCtx, cancel := context.WithCancel(parent) + state.cancel = cancel + state.processing = true + return opCtx, cancel, true +} + +func (h *Host) cancelOperation(op Operation) { + if op.Token == 0 || op.DeviceID == 0 || op.Generation == 0 { + return + } + h.mu.RLock() + entry := h.devices[op.DeviceID] + validDevice := entry != nil && entry.identity.Generation == op.Generation + h.mu.RUnlock() + if !validDevice { + return + } + h.operationMu.Lock() + state := h.operations[op.Token] + if state == nil { + state = &operationState{ + deviceID: op.DeviceID, generation: op.Generation, + endpoint: op.EndpointAddress, endpointGeneration: op.EndpointGeneration, + cancelled: true, + } + h.operations[op.Token] = state + } else if !state.done && state.deviceID == op.DeviceID && state.generation == op.Generation && + state.endpoint == op.EndpointAddress && + state.endpointGeneration == op.EndpointGeneration { + state.cancelled = true + } else { + h.operationMu.Unlock() + return + } + cancel := state.cancel + h.operationMu.Unlock() + if cancel != nil { + cancel() + } +} + +func (h *Host) cancelDeviceOperations(identity DeviceIdentity) { + var cancels []context.CancelFunc + h.operationMu.Lock() + for token, state := range h.operations { + if !state.done && state.deviceID == identity.DeviceID && state.generation == identity.Generation { + state.cancelled = true + if state.cancel != nil { + cancels = append(cancels, state.cancel) + } + if !state.processing { + delete(h.operations, token) + } + } + } + h.operationMu.Unlock() + for _, cancel := range cancels { + cancel() + } +} + +func (h *Host) operationCancelled(token uint64) bool { + h.operationMu.Lock() + defer h.operationMu.Unlock() + state := h.operations[token] + return state != nil && state.cancelled +} + +func (h *Host) finishOperation(token uint64) { + h.operationMu.Lock() + defer h.operationMu.Unlock() + state := h.operations[token] + if state == nil || state.done { + return + } + state.cancel = nil + state.processing = false + state.done = true + h.completed = append(h.completed, token) + if len(h.completed) > completedTokenHistory { + oldest := h.completed[0] + h.completed = h.completed[1:] + if old := h.operations[oldest]; old != nil && old.done { + delete(h.operations, oldest) + } + } +} + +func failureCompletion(op Operation) Completion { + return Completion{ + Token: op.Token, DeviceID: op.DeviceID, Generation: op.Generation, + EndpointGeneration: op.EndpointGeneration, + Status: statusUnsuccessful, + } +} + +type usbdCompletionStatusError interface { + error + USBDCompletionStatus() uint32 +} + +func processorErrorCompletion(op Operation, err error) Completion { + var usbdError usbdCompletionStatusError + if errors.As(err, &usbdError) { + if status := usbdError.USBDCompletionStatus(); status != 0 { + // UdeCx consumes USBD protocol failures through UdecxUrbComplete, + // which requires a successful NTSTATUS envelope. A generic + // processor failure still uses the NTSTATUS failure path below. ISO + // completions must retain the submitted packet table even when no + // bytes were serviced; the kernel validates those offsets before it + // can deliver the protocol status to UdeCx. + packets := make([]IsoPacket, len(op.IsoPackets)) + for index, packet := range op.IsoPackets { + packets[index] = IsoPacket{Offset: packet.Offset, Status: int32(status)} + } + return Completion{ + Token: op.Token, DeviceID: op.DeviceID, Generation: op.Generation, + EndpointGeneration: op.EndpointGeneration, + USBDStatus: status, IsoPackets: packets, + } + } + } + return failureCompletion(op) +} diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go new file mode 100644 index 00000000..7d93fa3d --- /dev/null +++ b/internal/transport/udecx/host_test.go @@ -0,0 +1,3940 @@ +package udecx + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Alia5/VIIPER/usb" +) + +type fakeHostDriver struct { + operations chan Operation + completions chan Completion + createErr error + mutateRegistration func(*DeviceRegistration) + mu sync.Mutex + created []CreateDevice + destroyed []DeviceIdentity + destroyErr error + completeErr error +} + +type fastInputDriver struct { + *fakeHostDriver + reports chan InputReport + submitErr error +} + +type inputSubmitGate struct { + started chan InputReport + release chan struct{} +} + +type gatedFastInputDriver struct { + *fastInputDriver + gates chan *inputSubmitGate +} + +type backpressureFastInputDriver struct { + *fastInputDriver + busyRemaining atomic.Int32 + attempts chan InputReport +} + +func (d *backpressureFastInputDriver) SubmitInputReport( + ctx context.Context, report InputReport, +) error { + copyReport := report + copyReport.Payload = append([]byte(nil), report.Payload...) + select { + case d.attempts <- copyReport: + case <-ctx.Done(): + return ctx.Err() + } + if d.busyRemaining.Add(-1) >= 0 { + return ErrInputQueueFull + } + return d.fastInputDriver.SubmitInputReport(ctx, report) +} + +type independentlyBlockingCreateDriver struct { + *fakeHostDriver + blockedDevice uint64 + started chan struct{} + release chan struct{} +} + +func (d *independentlyBlockingCreateDriver) CreateDevice( + ctx context.Context, device CreateDevice, +) (DeviceRegistration, error) { + if device.DeviceID == d.blockedDevice { + close(d.started) + select { + case <-d.release: + case <-ctx.Done(): + return DeviceRegistration{}, ctx.Err() + } + } + return d.fakeHostDriver.CreateDevice(ctx, device) +} + +func (d *fastInputDriver) SubmitInputReport(ctx context.Context, report InputReport) error { + if d.submitErr != nil { + return d.submitErr + } + report.Payload = append([]byte(nil), report.Payload...) + select { + case d.reports <- report: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (d *gatedFastInputDriver) SubmitInputReport(ctx context.Context, report InputReport) error { + select { + case gate := <-d.gates: + report.Payload = append([]byte(nil), report.Payload...) + select { + case gate.started <- report: + case <-ctx.Done(): + return ctx.Err() + } + select { + case <-gate.release: + case <-ctx.Done(): + return ctx.Err() + } + default: + } + return d.fastInputDriver.SubmitInputReport(ctx, report) +} + +func newInputSubmitGate() *inputSubmitGate { + return &inputSubmitGate{started: make(chan InputReport, 1), release: make(chan struct{})} +} + +func TestNextInputReportSequenceFailsClosedAtABICeiling(t *testing.T) { + last, err := nextInputReportSequence(math.MaxInt64 - 1) + if err != nil || last != math.MaxInt64 { + t.Fatalf("last valid sequence=(%d, %v), want (%d, nil)", last, err, uint64(math.MaxInt64)) + } + for _, previous := range []uint64{math.MaxInt64, math.MaxUint64} { + if next, nextErr := nextInputReportSequence(previous); next != 0 || !errors.Is(nextErr, errInputSequenceExhausted) { + t.Fatalf("sequence after %d=(%d, %v), want (0, %v)", previous, next, nextErr, errInputSequenceExhausted) + } + } +} + +func newFakeHostDriver() *fakeHostDriver { + return &fakeHostDriver{ + operations: make(chan Operation, 16), completions: make(chan Completion, 16), + } +} +func (d *fakeHostDriver) CreateDevice(_ context.Context, device CreateDevice) (DeviceRegistration, error) { + d.mu.Lock() + defer d.mu.Unlock() + d.created = append(d.created, device) + if d.createErr != nil { + return DeviceRegistration{}, d.createErr + } + registration := DeviceRegistration{ + DeviceIdentity: DeviceIdentity{DeviceID: device.DeviceID, Generation: device.Generation}, + Speed: device.Speed, ControllerSessionID: 17, + ControllerInstanceID: `ROOT\VIIPERUDE\0000`, + } + port := uint32((device.DeviceID-1)%MaxDevices + 1) + if device.Speed == DeviceSpeedSuper { + registration.USB30PortNumber = MaxDevices + port + } else { + registration.USB20PortNumber = port + } + if d.mutateRegistration != nil { + d.mutateRegistration(®istration) + } + return registration, nil +} +func (d *fakeHostDriver) DestroyDevice(_ context.Context, identity DeviceIdentity) error { + d.mu.Lock() + defer d.mu.Unlock() + d.destroyed = append(d.destroyed, identity) + return d.destroyErr +} +func (d *fakeHostDriver) Dequeue(ctx context.Context, _ []byte) (Operation, error) { + select { + case op := <-d.operations: + // Most host tests construct semantic operations directly instead of + // round-tripping the versioned wire parser. Supply the current endpoint + // incarnation those fixtures would carry on the ABI. + if operationRequiresEndpointGeneration(op) && op.EndpointGeneration == 0 { + op.EndpointGeneration = 1 + } + return op, nil + case <-ctx.Done(): + return Operation{}, ctx.Err() + } +} +func (d *fakeHostDriver) Complete(ctx context.Context, completion Completion) error { + if d.completeErr != nil { + return d.completeErr + } + select { + case d.completions <- completion: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} +func (d *fakeHostDriver) QueryStats(context.Context) (Stats, error) { return Stats{}, nil } + +type recordingProcessor struct { + processed chan uint64 + lifecycle chan uint64 + resets chan DeviceIdentity + lifecycleErr error +} + +func (p *recordingProcessor) Process(_ context.Context, _ usb.Device, op Operation) (Completion, error) { + p.processed <- op.EndpointSequence + return Completion{TransferLength: op.TransferLength}, nil +} +func (p *recordingProcessor) Lifecycle(_ context.Context, _ usb.Device, op Operation) error { + if p.lifecycle != nil { + p.lifecycle <- op.EndpointSequence + } + return p.lifecycleErr +} +func (p *recordingProcessor) Reset(_ usb.Device, identity DeviceIdentity) { p.resets <- identity } + +type lifecycleOperationProcessor struct { + lifecycle chan Operation +} + +func (*lifecycleOperationProcessor) Process( + context.Context, usb.Device, Operation, +) (Completion, error) { + return Completion{}, nil +} + +func (p *lifecycleOperationProcessor) Lifecycle( + _ context.Context, _ usb.Device, op Operation, +) error { + p.lifecycle <- op + return nil +} + +func (*lifecycleOperationProcessor) Reset(usb.Device, DeviceIdentity) {} + +type cancellableProcessor struct { + started chan struct{} + cancelled chan struct{} +} + +func (p *cancellableProcessor) Process(ctx context.Context, _ usb.Device, _ Operation) (Completion, error) { + close(p.started) + <-ctx.Done() + close(p.cancelled) + return Completion{}, ctx.Err() +} +func (*cancellableProcessor) Reset(usb.Device, DeviceIdentity) {} +func (*cancellableProcessor) Lifecycle(context.Context, usb.Device, Operation) error { return nil } + +type unregisterProcessor struct { + started chan struct{} + cancelled chan struct{} + reset chan bool +} + +func (p *unregisterProcessor) Process(ctx context.Context, _ usb.Device, _ Operation) (Completion, error) { + close(p.started) + <-ctx.Done() + close(p.cancelled) + return Completion{}, ctx.Err() +} +func (p *unregisterProcessor) Reset(usb.Device, DeviceIdentity) { + select { + case <-p.cancelled: + p.reset <- true + default: + p.reset <- false + } +} +func (*unregisterProcessor) Lifecycle(context.Context, usb.Device, Operation) error { return nil } + +type stubbornProcessor struct { + started chan struct{} + release chan struct{} +} + +func (p *stubbornProcessor) Process(context.Context, usb.Device, Operation) (Completion, error) { + close(p.started) + <-p.release + return Completion{}, context.Canceled +} +func (*stubbornProcessor) Reset(usb.Device, DeviceIdentity) {} +func (*stubbornProcessor) Lifecycle(context.Context, usb.Device, Operation) error { return nil } + +type noopProcessor struct{} + +func (*noopProcessor) Process(context.Context, usb.Device, Operation) (Completion, error) { + return Completion{}, nil +} +func (*noopProcessor) Lifecycle(context.Context, usb.Device, Operation) error { return nil } +func (*noopProcessor) Reset(usb.Device, DeviceIdentity) {} + +type usbdFailureProcessor struct { + err error +} + +func (p *usbdFailureProcessor) Process(context.Context, usb.Device, Operation) (Completion, error) { + return Completion{}, p.err +} +func (*usbdFailureProcessor) Lifecycle(context.Context, usb.Device, Operation) error { return nil } +func (*usbdFailureProcessor) Reset(usb.Device, DeviceIdentity) {} + +type testUSBDCompletionError struct { + status uint32 +} + +func (e testUSBDCompletionError) Error() string { return "USB protocol failure" } +func (e testUSBDCompletionError) USBDCompletionStatus() uint32 { return e.status } + +type deviceGateProcessor struct { + blockedDevice uint64 + started chan struct{} + independent chan uint64 + startOnce sync.Once +} + +func (p *deviceGateProcessor) Process( + ctx context.Context, _ usb.Device, op Operation, +) (Completion, error) { + if op.DeviceID == p.blockedDevice { + p.startOnce.Do(func() { close(p.started) }) + <-ctx.Done() + return Completion{}, ctx.Err() + } + select { + case p.independent <- op.DeviceID: + case <-ctx.Done(): + return Completion{}, ctx.Err() + } + return Completion{TransferLength: op.TransferLength}, nil +} +func (*deviceGateProcessor) Lifecycle(context.Context, usb.Device, Operation) error { return nil } +func (*deviceGateProcessor) Reset(usb.Device, DeviceIdentity) {} + +type fatalLaneProcessor struct { + failingDevice uint64 + started chan struct{} + release chan struct{} + independent chan uint64 + queuedProcessed chan struct{} + startOnce sync.Once +} + +func (*fatalLaneProcessor) Process(context.Context, usb.Device, Operation) (Completion, error) { + return Completion{}, nil +} +func (p *fatalLaneProcessor) Lifecycle(ctx context.Context, _ usb.Device, op Operation) error { + if op.DeviceID != p.failingDevice { + select { + case p.independent <- op.DeviceID: + case <-ctx.Done(): + return ctx.Err() + } + return nil + } + if op.EndpointSequence != 1 { + select { + case p.queuedProcessed <- struct{}{}: + default: + } + return nil + } + p.startOnce.Do(func() { close(p.started) }) + select { + case <-p.release: + return errors.New("injected lane failure") + case <-ctx.Done(): + return ctx.Err() + } +} +func (*fatalLaneProcessor) Reset(usb.Device, DeviceIdentity) {} + +type cancellationOnlyCompletionDriver struct { + *fakeHostDriver +} + +func (*cancellationOnlyCompletionDriver) Complete(ctx context.Context, _ Completion) error { + <-ctx.Done() + return ctx.Err() +} + +type deviceBarrierCompletionDriver struct { + *fakeHostDriver + started chan struct{} + canceled chan struct{} + release chan struct{} +} + +type managementBarrierDriver struct { + *fakeHostDriver + management chan Completion + release chan struct{} +} + +func (d *managementBarrierDriver) Complete(ctx context.Context, completion Completion) error { + if completion.Token != 1 { + return d.fakeHostDriver.Complete(ctx, completion) + } + select { + case d.management <- completion: + case <-ctx.Done(): + return ctx.Err() + } + select { + case <-d.release: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (d *deviceBarrierCompletionDriver) Complete(ctx context.Context, completion Completion) error { + if completion.Token != 1 { + return d.fakeHostDriver.Complete(ctx, completion) + } + close(d.started) + <-ctx.Done() + close(d.canceled) + <-d.release + return ctx.Err() +} + +type deviceBarrierProcessor struct { + targetDevice uint64 + speakerStarted chan struct{} + speakerCanceled chan struct{} + speakerRelease chan struct{} + barrierStarted chan Operation + barrierRelease chan struct{} + processed chan Operation + speakerOnce sync.Once +} + +func (p *deviceBarrierProcessor) Process( + ctx context.Context, _ usb.Device, op Operation, +) (Completion, error) { + if op.DeviceID != p.targetDevice { + p.processed <- op + return Completion{TransferLength: op.TransferLength}, nil + } + if isDeviceBarrierOperation(op) { + p.barrierStarted <- op + select { + case <-p.barrierRelease: + return Completion{TransferLength: op.TransferLength}, nil + case <-ctx.Done(): + return Completion{}, ctx.Err() + } + } + if op.EndpointAddress == 0x02 { + p.speakerOnce.Do(func() { close(p.speakerStarted) }) + <-ctx.Done() + close(p.speakerCanceled) + <-p.speakerRelease + return Completion{}, ctx.Err() + } + p.processed <- op + return Completion{TransferLength: op.TransferLength}, nil +} + +func (p *deviceBarrierProcessor) Lifecycle(ctx context.Context, _ usb.Device, op Operation) error { + if isDeviceBarrierOperation(op) { + p.barrierStarted <- op + select { + case <-p.barrierRelease: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + p.processed <- op + return nil +} +func (*deviceBarrierProcessor) Reset(usb.Device, DeviceIdentity) {} + +type resetGateProcessor struct { + started chan struct{} + release chan struct{} + kind OperationKind +} + +func (*resetGateProcessor) Process(context.Context, usb.Device, Operation) (Completion, error) { + return Completion{}, nil +} +func (p *resetGateProcessor) Lifecycle(ctx context.Context, _ usb.Device, op Operation) error { + kind := p.kind + if kind == 0 { + kind = OperationDeviceReset + } + if op.Kind != kind { + return nil + } + close(p.started) + select { + case <-p.release: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} +func (*resetGateProcessor) Reset(usb.Device, DeviceIdentity) {} + +type supersededManagementProcessor struct { + endpointStarted chan struct{} + endpointCanceled chan struct{} + endpointRelease chan struct{} + barrierStarted chan struct{} +} + +func (*supersededManagementProcessor) Process( + context.Context, usb.Device, Operation, +) (Completion, error) { + return Completion{}, nil +} + +func (p *supersededManagementProcessor) Lifecycle( + ctx context.Context, _ usb.Device, op Operation, +) error { + switch op.Kind { + case OperationEndpointReset: + close(p.endpointStarted) + <-ctx.Done() + close(p.endpointCanceled) + <-p.endpointRelease + return ctx.Err() + case OperationDeviceReset: + close(p.barrierStarted) + } + return nil +} + +func (*supersededManagementProcessor) Reset(usb.Device, DeviceIdentity) {} + +func hostTestDevice() usb.Device { + return &snapshotDevice{descriptor: usb.Descriptor{ + Device: usb.DeviceDescriptor{ + BcdUSB: 0x0200, BMaxPacketSize0: 64, IDVendor: 1, IDProduct: 2, + BNumConfigurations: 1, Speed: uint32(DeviceSpeedHigh), + }, + Interfaces: []usb.InterfaceConfig{{Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0, BNumEndpoints: 1, BInterfaceClass: 3, + }, Endpoints: []usb.EndpointDescriptor{{ + BEndpointAddress: 0x81, BMAttributes: 3, WMaxPacketSize: 64, BInterval: 4, + }}}}, + }} +} + +func TestInterruptInputServiceIntervalMatchesUSBContract(t *testing.T) { + tests := []struct { + name string + speed uint32 + bInterval uint8 + want time.Duration + }{ + {name: "full-speed frames", speed: uint32(DeviceSpeedFull), bInterval: 5, want: 5 * time.Millisecond}, + {name: "high-speed microframes", speed: uint32(DeviceSpeedHigh), bInterval: 4, want: time.Millisecond}, + {name: "maximum high-speed exponent", speed: uint32(DeviceSpeedSuper), bInterval: 16, want: 4096 * time.Millisecond}, + {name: "zero is unscheduled", speed: uint32(DeviceSpeedHigh), bInterval: 0, want: 0}, + {name: "reserved high-speed exponent", speed: uint32(DeviceSpeedHigh), bInterval: 17, want: 0}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := interruptInputServiceInterval(test.speed, test.bInterval); got != test.want { + t.Fatalf("service interval=%v want=%v", got, test.want) + } + }) + } +} + +func TestHostDoesNotSerializeIndependentControllerRegistration(t *testing.T) { + driver := &independentlyBlockingCreateDriver{ + fakeHostDriver: newFakeHostDriver(), blockedDevice: 81, + started: make(chan struct{}), release: make(chan struct{}), + } + host, err := NewHost(driver, &noopProcessor{}, 2) + if err != nil { + t.Fatal(err) + } + + type registerResult struct { + identity DeviceIdentity + err error + } + blockedDone := make(chan registerResult, 1) + go func() { + identity, registerErr := host.Register(context.Background(), 81, hostTestDevice()) + blockedDone <- registerResult{identity: identity, err: registerErr} + }() + select { + case <-driver.started: + case <-time.After(time.Second): + t.Fatal("first controller registration did not reach the driver") + } + + independentDone := make(chan registerResult, 1) + go func() { + identity, registerErr := host.Register(context.Background(), 82, hostTestDevice()) + independentDone <- registerResult{identity: identity, err: registerErr} + }() + var independent registerResult + select { + case independent = <-independentDone: + if independent.err != nil { + t.Fatalf("independent registration failed: %v", independent.err) + } + case <-time.After(time.Second): + t.Fatal("independent controller registration was blocked by another controller") + } + + close(driver.release) + var blocked registerResult + select { + case blocked = <-blockedDone: + if blocked.err != nil { + t.Fatalf("blocked registration failed after release: %v", blocked.err) + } + case <-time.After(time.Second): + t.Fatal("first controller registration did not finish after release") + } + + if err = host.Unregister(context.Background(), independent.identity); err != nil { + t.Fatal(err) + } + if err = host.Unregister(context.Background(), blocked.identity); err != nil { + t.Fatal(err) + } + host.lifecycleMu.Lock() + remainingGates := len(host.lifecycles) + host.lifecycleMu.Unlock() + if remainingGates != 0 { + t.Fatalf("lifecycle gates=%d want 0", remainingGates) + } +} + +func TestHostSerializesSameControllerRegistration(t *testing.T) { + driver := &independentlyBlockingCreateDriver{ + fakeHostDriver: newFakeHostDriver(), blockedDevice: 83, + started: make(chan struct{}), release: make(chan struct{}), + } + host, err := NewHost(driver, &noopProcessor{}, 2) + if err != nil { + t.Fatal(err) + } + + type registerResult struct { + identity DeviceIdentity + err error + } + firstDone := make(chan registerResult, 1) + go func() { + identity, registerErr := host.Register(context.Background(), 83, hostTestDevice()) + firstDone <- registerResult{identity: identity, err: registerErr} + }() + select { + case <-driver.started: + case <-time.After(time.Second): + t.Fatal("first same-controller registration did not reach the driver") + } + + secondDone := make(chan error, 1) + go func() { + _, registerErr := host.Register(context.Background(), 83, hostTestDevice()) + secondDone <- registerErr + }() + select { + case registerErr := <-secondDone: + t.Fatalf("same-controller registration crossed the in-flight create: %v", registerErr) + case <-time.After(25 * time.Millisecond): + } + + close(driver.release) + var first registerResult + select { + case first = <-firstDone: + if first.err != nil { + t.Fatal(first.err) + } + case <-time.After(time.Second): + t.Fatal("first same-controller registration did not finish") + } + select { + case registerErr := <-secondDone: + if registerErr == nil || !strings.Contains(registerErr.Error(), "already registered") { + t.Fatalf("second same-controller registration error=%v", registerErr) + } + case <-time.After(time.Second): + t.Fatal("second same-controller registration did not revalidate after serialization") + } + if err = host.Unregister(context.Background(), first.identity); err != nil { + t.Fatal(err) + } +} + +func TestHostRollsBackRegistrationThatOutlivesServe(t *testing.T) { + driver := &independentlyBlockingCreateDriver{ + fakeHostDriver: newFakeHostDriver(), blockedDevice: 84, + started: make(chan struct{}), release: make(chan struct{}), + } + host, err := NewHost(driver, &noopProcessor{}, 2) + if err != nil { + t.Fatal(err) + } + serveCtx, cancelServe := context.WithCancel(context.Background()) + serveDone := make(chan error, 1) + go func() { serveDone <- host.Serve(serveCtx) }() + + registerDone := make(chan error, 1) + go func() { + _, registerErr := host.Register(context.Background(), 84, hostTestDevice()) + registerDone <- registerErr + }() + select { + case <-driver.started: + case <-time.After(time.Second): + t.Fatal("registration did not reach blocking PnP create") + } + cancelServe() + select { + case serveErr := <-serveDone: + if serveErr != nil { + t.Fatalf("Serve shutdown: %v", serveErr) + } + case <-time.After(time.Second): + t.Fatal("Serve did not stop around in-flight registration") + } + close(driver.release) + select { + case registerErr := <-registerDone: + if registerErr == nil || !strings.Contains(registerErr.Error(), "registration was in flight") { + t.Fatalf("registration error=%v, want terminal-session rollback", registerErr) + } + case <-time.After(time.Second): + t.Fatal("registration did not finish after PnP create was released") + } + + host.mu.RLock() + _, leaked := host.devices[84] + host.mu.RUnlock() + driver.mu.Lock() + created, destroyed := len(driver.created), len(driver.destroyed) + driver.mu.Unlock() + if leaked || created != 1 || destroyed != 1 { + t.Fatalf("terminal registration leaked=%t created=%d destroyed=%d", leaked, created, destroyed) + } +} + +func TestHostRepeatedCreateRemoveLeavesOnlyGenerationHistory(t *testing.T) { + driver := newFakeHostDriver() + host, err := NewHost(driver, &noopProcessor{}, 4) + if err != nil { + t.Fatal(err) + } + const cycles = 512 + for cycle := 1; cycle <= cycles; cycle++ { + identity, registerErr := host.Register(context.Background(), 72, hostTestDevice()) + if registerErr != nil { + t.Fatalf("cycle %d register: %v", cycle, registerErr) + } + if identity.Generation != uint32(cycle) { + t.Fatalf("cycle %d generation=%d", cycle, identity.Generation) + } + if unregisterErr := host.Unregister(context.Background(), identity); unregisterErr != nil { + t.Fatalf("cycle %d unregister: %v", cycle, unregisterErr) + } + } + + host.mu.RLock() + devices, lanes := len(host.devices), len(host.lanes) + generation := host.generations[72] + host.mu.RUnlock() + host.operationMu.Lock() + operations := len(host.operations) + host.operationMu.Unlock() + driver.mu.Lock() + created, destroyed := len(driver.created), len(driver.destroyed) + driver.mu.Unlock() + if devices != 0 || lanes != 0 || operations != 0 || generation != cycles || + created != cycles || destroyed != cycles { + t.Fatalf("devices=%d lanes=%d operations=%d generation=%d created=%d destroyed=%d", + devices, lanes, operations, generation, created, destroyed) + } +} + +type inputPublisherTestDevice struct { + descriptor usb.Descriptor + reports chan []byte +} + +type directInputPublisherTestDevice struct { + *inputPublisherTestDevice + buffers chan *byte +} + +type cachedDeadlineInputPublisherTestDevice struct { + *inputPublisherTestDevice + cached []byte +} + +type scheduledInputPublisherTestDevice struct { + *inputPublisherTestDevice + deadlines chan (<-chan time.Time) + fallbackRead atomic.Int32 +} + +type staleDeadlineInputPublisherTestDevice struct { + *inputPublisherTestDevice + firstStarted chan struct{} + secondElapsed chan time.Duration + calls atomic.Int32 +} + +type selectedInputPublisherTestDevice struct { + *inputPublisherTestDevice +} + +func (*selectedInputPublisherTestDevice) SupportsInterruptInputEndpoint(endpoint uint32) bool { + return endpoint == 1 +} + +type controlledInputAttempt struct { + context.Context + deadline time.Time + done chan struct{} + once sync.Once + mu sync.Mutex + err error + stopParent func() bool +} + +func newControlledInputAttempt(parent context.Context, interval time.Duration) *controlledInputAttempt { + attempt := &controlledInputAttempt{ + Context: parent, deadline: time.Now().Add(interval), done: make(chan struct{}), + } + attempt.stopParent = context.AfterFunc(parent, func() { attempt.finish(parent.Err()) }) + return attempt +} + +func (c *controlledInputAttempt) Deadline() (time.Time, bool) { return c.deadline, true } +func (c *controlledInputAttempt) Done() <-chan struct{} { return c.done } +func (c *controlledInputAttempt) Err() error { + c.mu.Lock() + defer c.mu.Unlock() + return c.err +} +func (c *controlledInputAttempt) finish(err error) { + c.once.Do(func() { + c.mu.Lock() + c.err = err + c.mu.Unlock() + close(c.done) + }) +} +func (c *controlledInputAttempt) expire() { c.finish(context.DeadlineExceeded) } +func (c *controlledInputAttempt) cancel() { + if c.stopParent != nil { + c.stopParent() + } + c.finish(context.Canceled) +} + +func newInputPublisherTestDevice() *inputPublisherTestDevice { + base := hostTestDevice().GetDescriptor() + return &inputPublisherTestDevice{descriptor: *base, reports: make(chan []byte, 4)} +} + +func TestFastInputEndpointsHonorDeviceEndpointSelection(t *testing.T) { + base := newInputPublisherTestDevice() + base.descriptor.Interfaces[0].Endpoints = append( + base.descriptor.Interfaces[0].Endpoints, + usb.EndpointDescriptor{ + BEndpointAddress: 0x82, BMAttributes: 0x03, + WMaxPacketSize: 32, BInterval: 4, + }, + usb.EndpointDescriptor{ + BEndpointAddress: 0x84, BMAttributes: 0x03, + WMaxPacketSize: 32, BInterval: 16, + }, + ) + device := &selectedInputPublisherTestDevice{inputPublisherTestDevice: base} + endpoints := fastInputEndpoints(device) + if len(endpoints) != 1 { + t.Fatalf("selected fast-input endpoints=%v want only 0x81", endpoints) + } + if _, ok := endpoints[0x81]; !ok { + t.Fatalf("selected fast-input endpoints=%v missing 0x81", endpoints) + } + if unrestricted := fastInputEndpoints(base); len(unrestricted) != 3 { + t.Fatalf("compatibility fast-input endpoints=%v want all three", unrestricted) + } +} + +func newDirectInputPublisherTestDevice() *directInputPublisherTestDevice { + return &directInputPublisherTestDevice{ + inputPublisherTestDevice: newInputPublisherTestDevice(), + buffers: make(chan *byte, 4), + } +} + +func newCachedDeadlineInputPublisherTestDevice(report []byte) *cachedDeadlineInputPublisherTestDevice { + return &cachedDeadlineInputPublisherTestDevice{ + inputPublisherTestDevice: newInputPublisherTestDevice(), + cached: append([]byte(nil), report...), + } +} + +func newScheduledInputPublisherTestDevice() *scheduledInputPublisherTestDevice { + return &scheduledInputPublisherTestDevice{ + inputPublisherTestDevice: newInputPublisherTestDevice(), + deadlines: make(chan (<-chan time.Time), 32), + } +} + +func newStaleDeadlineInputPublisherTestDevice() *staleDeadlineInputPublisherTestDevice { + device := &staleDeadlineInputPublisherTestDevice{ + inputPublisherTestDevice: newInputPublisherTestDevice(), + firstStarted: make(chan struct{}), + secondElapsed: make(chan time.Duration, 1), + } + // A high-speed bInterval of 8 is a 16 ms service period. The longer + // interval gives this deterministic stale-tick test enough scheduling + // margin even on a busy Windows runner. + device.descriptor.Interfaces[0].Endpoints[0].BInterval = 8 + return device +} + +func (d *directInputPublisherTestDevice) ReadInterruptInput( + ctx context.Context, _ uint32, dst []byte, +) (int, error) { + if len(dst) == 0 { + return 0, errors.New("empty native input buffer") + } + select { + case report := <-d.reports: + if len(report) > len(dst) { + return 0, errors.New("native input buffer is too short") + } + d.buffers <- &dst[0] + copy(dst, report) + return len(report), nil + case <-ctx.Done(): + return 0, ctx.Err() + } +} + +func (d *cachedDeadlineInputPublisherTestDevice) ReadInterruptInput( + ctx context.Context, _ uint32, dst []byte, +) (int, error) { + <-ctx.Done() + if !errors.Is(ctx.Err(), context.DeadlineExceeded) { + return 0, ctx.Err() + } + if len(d.cached) > len(dst) { + return 0, errors.New("native input buffer is too short for cached report") + } + copy(dst, d.cached) + return len(d.cached), nil +} + +func (d *scheduledInputPublisherTestDevice) ReadInterruptInput( + context.Context, uint32, []byte, +) (int, error) { + d.fallbackRead.Add(1) + return 0, errors.New("scheduled input used the timer-context fallback") +} + +func (d *scheduledInputPublisherTestDevice) ReadScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, _ uint32, dst []byte, +) (int, error) { + select { + case d.deadlines <- deadline: + default: + } + select { + case report := <-d.reports: + if len(report) > len(dst) { + return 0, errors.New("native input buffer is too short") + } + copy(dst, report) + return len(report), nil + case <-deadline: + return 0, context.DeadlineExceeded + case <-ctx.Done(): + return 0, ctx.Err() + } +} + +func (d *staleDeadlineInputPublisherTestDevice) ReadInterruptInput( + context.Context, uint32, []byte, +) (int, error) { + return 0, errors.New("stale-deadline test used the timer-context fallback") +} + +func (d *staleDeadlineInputPublisherTestDevice) ReadScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, _ uint32, dst []byte, +) (int, error) { + if d.calls.Add(1) == 1 { + close(d.firstStarted) + // Deliberately leave the first deadline unread. This models the hardest + // event/deadline race: a controller event wins after the timer's nominal + // expiry and the host must stop/reset without leaking that old tick into + // the next USB service interval. + select { + case report := <-d.reports: + copy(dst, report) + return len(report), nil + case <-ctx.Done(): + return 0, ctx.Err() + } + } + started := time.Now() + select { + case <-deadline: + d.secondElapsed <- time.Since(started) + dst[0] = 0x7e + return 1, nil + case <-ctx.Done(): + return 0, ctx.Err() + } +} + +func (d *inputPublisherTestDevice) HandleTransfer( + ctx context.Context, _ uint32, _ uint32, _ []byte, +) []byte { + select { + case report := <-d.reports: + return report + case <-ctx.Done(): + return nil + } +} + +func (d *inputPublisherTestDevice) GetDescriptor() *usb.Descriptor { return &d.descriptor } +func (*inputPublisherTestDevice) GetDeviceSpecificArgs() map[string]any { + return nil +} + +func TestHostPublishesInterruptInputDirectlyAfterEndpointStart(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 4), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 44, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointGeneration: 1, + EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + device.reports <- []byte{1, 2, 3, 4} + select { + case report := <-driver.reports: + if report.DeviceID != identity.DeviceID || report.Generation != identity.Generation || + report.EndpointGeneration != 1 || report.EndpointAddress != 0x81 || + report.Sequence != 1 || + string(report.Payload) != string([]byte{1, 2, 3, 4}) { + t.Fatalf("unexpected direct input report: %+v", report) + } + case <-time.After(time.Second): + t.Fatal("interrupt-IN report did not use the direct publisher") + } + if diagnostics := host.InputDiagnostics(); diagnostics.PublisherStarts != 1 || + diagnostics.LegacyTransferFallbackStarts != 1 || + diagnostics.DeadlineContextFallbackStarts != 0 { + t.Fatalf("legacy input fallback diagnostics=%+v want starts=1 legacy=1 deadline=0", + diagnostics) + } + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointGeneration: 1, + EndpointSequence: 2, DeviceSequence: 2, Kind: OperationEndpointPurge, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint purge was not processed") + } + + // Recreating the same endpoint address establishes an independent lane and + // direct-input sequence. A delayed callback for generation 1 must not invoke + // controller state or replace the generation 2 publisher. + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointGeneration: 2, + EndpointSequence: 1, DeviceSequence: 3, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("replacement endpoint start was not processed") + } + device.reports <- []byte{5, 6, 7, 8} + select { + case report := <-driver.reports: + if report.EndpointGeneration != 2 || report.Sequence != 1 || + string(report.Payload) != string([]byte{5, 6, 7, 8}) { + t.Fatalf("replacement direct input report: %+v", report) + } + case <-time.After(time.Second): + t.Fatal("replacement endpoint did not publish direct input") + } + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointGeneration: 1, + EndpointSequence: 3, DeviceSequence: 4, Kind: OperationEndpointStart, + } + select { + case sequence := <-processor.lifecycle: + t.Fatalf("stale endpoint generation reached lifecycle callback at sequence %d", sequence) + case <-time.After(75 * time.Millisecond): + } + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + +func TestHostRetriesExactInputTransitionAfterKernelBackpressure(t *testing.T) { + base := &fastInputDriver{ + fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 2), + } + driver := &backpressureFastInputDriver{ + fastInputDriver: base, attempts: make(chan InputReport, 2), + } + driver.busyRemaining.Store(1) + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 2), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 441, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + device.reports <- []byte{0x11, 0x22, 0x33} + first := <-driver.attempts + second := <-driver.attempts + if first.Sequence != second.Sequence || first.DeviceID != second.DeviceID || + first.Generation != second.Generation || first.EndpointAddress != second.EndpointAddress || + string(first.Payload) != string(second.Payload) { + t.Fatalf("retry changed accepted report: first=%+v second=%+v", first, second) + } + select { + case accepted := <-driver.reports: + if accepted.Sequence != first.Sequence || string(accepted.Payload) != string(first.Payload) { + t.Fatalf("accepted report=%+v first=%+v", accepted, first) + } + case <-time.After(time.Second): + t.Fatal("kernel backpressure was not retried") + } + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + +func TestHostReusesOneDescriptorSizedDirectInputBuffer(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 2), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + device := newDirectInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 45, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + + device.reports <- []byte{1, 2, 3, 4} + first := <-driver.reports + firstBuffer := <-device.buffers + device.reports <- []byte{5, 6} + second := <-driver.reports + secondBuffer := <-device.buffers + if firstBuffer != secondBuffer { + t.Fatal("direct input publisher allocated a replacement endpoint buffer") + } + if string(first.Payload) != string([]byte{1, 2, 3, 4}) || + string(second.Payload) != string([]byte{5, 6}) { + t.Fatalf("direct input payloads first=%v second=%v", first.Payload, second.Payload) + } + if first.Sequence != 1 || second.Sequence != 2 { + t.Fatalf("direct input sequences first=%d second=%d", first.Sequence, second.Sequence) + } + if diagnostics := host.InputDiagnostics(); diagnostics.PublisherStarts != 1 || + diagnostics.LegacyTransferFallbackStarts != 0 || + diagnostics.DeadlineContextFallbackStarts != 1 { + t.Fatalf("deadline input fallback diagnostics=%+v want starts=1 legacy=0 deadline=1", + diagnostics) + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + +func TestHostReusesOneDeadlineTimerForScheduledInterruptInput(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 2), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + device := newScheduledInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 451, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + + device.reports <- []byte{1, 2, 3} + select { + case <-driver.reports: + case <-time.After(time.Second): + t.Fatal("first scheduled input report was not submitted") + } + device.reports <- []byte{4, 5, 6} + select { + case <-driver.reports: + case <-time.After(time.Second): + t.Fatal("second scheduled input report was not submitted") + } + + var first, second <-chan time.Time + select { + case first = <-device.deadlines: + case <-time.After(time.Second): + t.Fatal("scheduled input did not receive a deadline") + } + select { + case second = <-device.deadlines: + case <-time.After(time.Second): + t.Fatal("scheduled input did not receive a second deadline") + } + if first != second { + t.Fatal("scheduled input allocated a replacement endpoint timer") + } + if calls := device.fallbackRead.Load(); calls != 0 { + t.Fatalf("scheduled input used fallback ReadInterruptInput %d time(s)", calls) + } + if diagnostics := host.InputDiagnostics(); diagnostics.PublisherStarts != 1 || + diagnostics.LegacyTransferFallbackStarts != 0 || + diagnostics.DeadlineContextFallbackStarts != 0 { + t.Fatalf("scheduled input diagnostics=%+v want no compatibility fallback", + diagnostics) + } + + // Endpoint reset must synchronously cancel the blocked scheduled read, + // dispose its timer, and start a fresh publisher only after lifecycle ACK. + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 2, DeviceSequence: 2, + Kind: OperationEndpointReset, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint reset did not join the scheduled input publisher") + } + device.reports <- []byte{7, 8, 9} + select { + case <-driver.reports: + case <-time.After(time.Second): + t.Fatal("scheduled input did not resume after endpoint reset") + } + resetDeadline := time.After(time.Second) + for { + select { + case afterReset := <-device.deadlines: + if afterReset != first { + goto resetTimerObserved + } + case <-resetDeadline: + t.Fatal("endpoint reset retained the old publisher timer") + } + } + +resetTimerObserved: + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + +func TestHostTimerResetCannotReplayExpiredDeadlineIntoNextInput(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 2), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + device := newStaleDeadlineInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 452, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + select { + case <-device.firstStarted: + case <-time.After(time.Second): + t.Fatal("first scheduled read did not start") + } + + // Let the first 16 ms timer expire without receiving its tick, then make + // the controller event win. Go 1.23+ Timer.Stop/Reset guarantees that the + // expired value cannot satisfy the next receive. The host also drains a + // buffered tick when the legacy timer implementation is forced by GODEBUG. + time.Sleep(25 * time.Millisecond) + device.reports <- []byte{0x11} + select { + case <-driver.reports: + case <-time.After(time.Second): + t.Fatal("controller event was not submitted") + } + select { + case elapsed := <-device.secondElapsed: + if elapsed < 12*time.Millisecond { + t.Fatalf("expired deadline leaked into next 16 ms interval after %v", elapsed) + } + case <-time.After(time.Second): + t.Fatal("next service deadline did not fire") + } + select { + case report := <-driver.reports: + if string(report.Payload) != string([]byte{0x7e}) { + t.Fatalf("deadline report=%x want=7e", report.Payload) + } + case <-time.After(time.Second): + t.Fatal("deadline report was not submitted") + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop after scheduled deadline race") + } +} + +func TestHostInputPublisherDoesNotWaitForGlobalRoutingLock(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 2), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 441, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + + deadline := time.Now().Add(time.Second) + for { + host.mu.RLock() + entry := host.devices[identity.DeviceID] + publisherReady := entry != nil && entry.publishers[0x81] != nil + host.mu.RUnlock() + if publisherReady { + break + } + if time.Now().After(deadline) { + t.Fatal("direct input publisher did not start") + } + time.Sleep(time.Millisecond) + } + + // Hold the host-wide routing lock exactly while a fresh state is published. + // A per-endpoint input sequence must still reach the direct driver lane; + // otherwise unrelated lifecycle/media work can stall every controller. + host.mu.Lock() + device.reports <- []byte{9, 8, 7, 6} + select { + case report := <-driver.reports: + host.mu.Unlock() + if report.Sequence != 1 || string(report.Payload) != string([]byte{9, 8, 7, 6}) { + t.Fatalf("unexpected lock-independent input report: %+v", report) + } + case <-time.After(250 * time.Millisecond): + host.mu.Unlock() + t.Fatal("direct input waited for the global host routing lock") + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + +func TestHostRestoresInputPublisherAfterFailedTransactionalRemoval(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 1), + resets: make(chan DeviceIdentity, 1), + } + host, _ := NewHost(driver, processor, 2) + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 45, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + device.reports <- []byte{1} + select { + case report := <-driver.reports: + if report.Sequence != 1 { + t.Fatalf("first sequence=%d want=1", report.Sequence) + } + case <-time.After(time.Second): + t.Fatal("first input report was not submitted") + } + + driver.mu.Lock() + driver.destroyErr = errors.New("plug-out still pending") + driver.mu.Unlock() + if err = host.Unregister(context.Background(), identity); err == nil { + t.Fatal("failed removal unexpectedly succeeded") + } + device.reports <- []byte{2} + select { + case report := <-driver.reports: + if report.Sequence != 2 || string(report.Payload) != string([]byte{2}) { + t.Fatalf("restored publisher report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("publisher was not restored after failed removal") + } + + driver.mu.Lock() + driver.destroyErr = nil + driver.mu.Unlock() + if err = host.Unregister(context.Background(), identity); err != nil { + t.Fatal(err) + } + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + +func TestHostRestartsInputPublisherAcrossD0WithoutResettingSequence(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 3), + resets: make(chan DeviceIdentity, 1), + } + host, _ := NewHost(driver, processor, 2) + device := newScheduledInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 46, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + device.reports <- []byte{1} + select { + case report := <-driver.reports: + if report.Sequence != 1 { + t.Fatalf("first sequence=%d want=1", report.Sequence) + } + case <-time.After(time.Second): + t.Fatal("first input report was not submitted") + } + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 1, DeviceSequence: 2, + Kind: OperationDeviceD0Exit, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("D0 exit was not processed") + } + device.reports <- []byte{2} + select { + case report := <-driver.reports: + t.Fatalf("report submitted while device was outside D0: %+v", report) + case <-time.After(25 * time.Millisecond): + } + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 2, DeviceSequence: 3, + Kind: OperationDeviceD0Entry, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("D0 entry was not processed") + } + select { + case report := <-driver.reports: + if report.Sequence != 2 || string(report.Payload) != string([]byte{2}) { + t.Fatalf("D0-restored publisher report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("publisher did not resume after D0 entry") + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + +func TestHostDoesNotResurrectInputFromPreD0ExitEndpointStart(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &lifecycleOperationProcessor{lifecycle: make(chan Operation, 3)} + host, _ := NewHost(driver, processor, 4) + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 47, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + // Multiple dequeue workers may deliver the device-wide D0 exit before an + // older endpoint-start notification. The announced barrier must retire that + // pre-D0 callback without applying it, then process the exit once the device + // sequence is contiguous. + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 1, DeviceSequence: 2, + Kind: OperationDeviceD0Exit, + } + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, + } + deadline := time.After(time.Second) + d0ExitProcessed := false + for !d0ExitProcessed { + select { + case op := <-processor.lifecycle: + switch op.DeviceSequence { + case 1: + // A worker can finish dequeuing the older START before the + // already-delivered D0 barrier is announced centrally. Applying + // START and then D0-exit is safe; the invariant begins when exit + // processing finishes. + case 2: + d0ExitProcessed = true + default: + t.Fatalf("unexpected lifecycle sequence before D0 exit: %+v", op) + } + case <-deadline: + t.Fatal("D0 exit was not processed after the older sequence was retired") + } + } + device.reports <- []byte{1} + select { + case report := <-driver.reports: + t.Fatalf("stale endpoint start resurrected input outside D0: %+v", report) + case <-time.After(25 * time.Millisecond): + } + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 2, DeviceSequence: 3, + Kind: OperationDeviceD0Entry, + } + select { + case op := <-processor.lifecycle: + if op.Kind != OperationDeviceD0Entry || op.DeviceSequence != 3 { + t.Fatalf("unexpected lifecycle after D0 exit: %+v", op) + } + case <-time.After(time.Second): + t.Fatal("D0 entry was not processed") + } + select { + case report := <-driver.reports: + if report.Sequence != 1 || string(report.Payload) != string([]byte{1}) { + t.Fatalf("D0-restored publisher report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("publisher did not resume after the ordered D0 entry") + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + +func TestHostPausesDirectInputAcrossAcknowledgedDeviceReset(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &resetGateProcessor{started: make(chan struct{}), release: make(chan struct{})} + host, _ := NewHost(driver, processor, 4) + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 48, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, + } + device.reports <- []byte{1} + select { + case report := <-driver.reports: + if report.Sequence != 1 { + t.Fatalf("first sequence=%d want=1", report.Sequence) + } + case <-time.After(time.Second): + t.Fatal("first input report was not submitted") + } + + driver.operations <- Operation{ + Token: 0x0000000180000001, + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 1, DeviceSequence: 2, + Kind: OperationDeviceReset, + } + select { + case <-processor.started: + case <-time.After(time.Second): + t.Fatal("device reset did not reach processor") + } + device.reports <- []byte{2} + select { + case report := <-driver.reports: + t.Fatalf("input crossed an unacknowledged device reset: %+v", report) + case <-time.After(25 * time.Millisecond): + } + + close(processor.release) + select { + case completion := <-driver.completions: + if completion.Token != 0x0000000180000001 || completion.Status != 0 { + t.Fatalf("device reset acknowledgement=%+v", completion) + } + case <-time.After(time.Second): + t.Fatal("device reset was not acknowledged") + } + select { + case report := <-driver.reports: + if report.Sequence != 2 || string(report.Payload) != string([]byte{2}) { + t.Fatalf("reset-restored publisher report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("publisher did not resume after device reset acknowledgement") + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + +func TestHostPausesDirectInputAcrossSetConfigurationBarrier(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &deviceBarrierProcessor{ + targetDevice: 50, + speakerStarted: make(chan struct{}), + speakerCanceled: make(chan struct{}), + speakerRelease: make(chan struct{}), + barrierStarted: make(chan Operation, 1), + barrierRelease: make(chan struct{}), + processed: make(chan Operation, 2), + } + host, _ := NewHost(driver, processor, 4) + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), processor.targetDevice, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, + } + select { + case op := <-processor.processed: + if op.Kind != OperationEndpointStart { + t.Fatalf("processed %+v before endpoint start", op) + } + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + device.reports <- []byte{1} + select { + case report := <-driver.reports: + if report.Sequence != 1 { + t.Fatalf("first sequence=%d want=1", report.Sequence) + } + case <-time.After(time.Second): + t.Fatal("first input report was not submitted") + } + + driver.operations <- Operation{ + Token: 2, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 1, DeviceSequence: 2, + Kind: OperationControl, + SetupPacket: [8]byte{ + usbRequestTypeStandardToDevice, usbRequestSetConfiguration, 1, + }, + } + select { + case <-processor.barrierStarted: + case <-time.After(time.Second): + t.Fatal("SET_CONFIGURATION did not reach the processor") + } + device.reports <- []byte{2} + select { + case report := <-driver.reports: + t.Fatalf("input crossed an active SET_CONFIGURATION barrier: %+v", report) + case <-time.After(25 * time.Millisecond): + } + + close(processor.barrierRelease) + select { + case completion := <-driver.completions: + if completion.Token != 2 || completion.Status != 0 { + t.Fatalf("SET_CONFIGURATION completion=%+v", completion) + } + case <-time.After(time.Second): + t.Fatal("SET_CONFIGURATION was not completed") + } + select { + case report := <-driver.reports: + if report.Sequence != 2 || string(report.Payload) != string([]byte{2}) { + t.Fatalf("configuration-restored publisher report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("publisher did not resume after SET_CONFIGURATION") + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + +func TestHostPausesDirectInputAcrossAcknowledgedEndpointReset(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &resetGateProcessor{ + started: make(chan struct{}), release: make(chan struct{}), kind: OperationEndpointReset, + } + host, _ := NewHost(driver, processor, 4) + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 49, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, + } + device.reports <- []byte{1} + select { + case report := <-driver.reports: + if report.Sequence != 1 { + t.Fatalf("first sequence=%d want=1", report.Sequence) + } + case <-time.After(time.Second): + t.Fatal("first input report was not submitted") + } + + driver.operations <- Operation{ + Token: 0x0000000180000002, + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 2, DeviceSequence: 2, + Kind: OperationEndpointReset, + } + select { + case <-processor.started: + case <-time.After(time.Second): + t.Fatal("endpoint reset did not reach processor") + } + device.reports <- []byte{2} + select { + case report := <-driver.reports: + t.Fatalf("input crossed an unacknowledged endpoint reset: %+v", report) + case <-time.After(25 * time.Millisecond): + } + + close(processor.release) + select { + case completion := <-driver.completions: + if completion.Token != 0x0000000180000002 || completion.Status != 0 { + t.Fatalf("endpoint reset acknowledgement=%+v", completion) + } + case <-time.After(time.Second): + t.Fatal("endpoint reset was not acknowledged") + } + select { + case report := <-driver.reports: + if report.Sequence != 2 || string(report.Payload) != string([]byte{2}) { + t.Fatalf("reset-restored publisher report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("publisher did not resume after endpoint reset acknowledgement") + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + +func TestHostRestartsInputPublisherAfterEndpointPurgeWithoutResettingSequence(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 3), + resets: make(chan DeviceIdentity, 1), + } + host, _ := NewHost(driver, processor, 2) + device := newScheduledInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 47, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointStart, + } + <-processor.lifecycle + device.reports <- []byte{1} + select { + case report := <-driver.reports: + if report.Sequence != 1 { + t.Fatalf("first sequence=%d want=1", report.Sequence) + } + case <-time.After(time.Second): + t.Fatal("first input report was not submitted") + } + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 2, Kind: OperationEndpointPurge, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint purge was not processed") + } + device.reports <- []byte{2} + select { + case report := <-driver.reports: + t.Fatalf("report submitted while endpoint was purged: %+v", report) + case <-time.After(25 * time.Millisecond): + } + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 3, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint restart was not processed") + } + select { + case report := <-driver.reports: + if report.Sequence != 2 || string(report.Payload) != string([]byte{2}) { + t.Fatalf("endpoint-restored publisher report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("publisher did not resume after endpoint restart") + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + +func TestHostCommitsEncodedInputBeforePurgeAndResetLifecycleBoundaries(t *testing.T) { + baseDriver := &fastInputDriver{ + fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 8), + } + driver := &gatedFastInputDriver{ + fastInputDriver: baseDriver, gates: make(chan *inputSubmitGate, 2), + } + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 8), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + device := newScheduledInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 472, device) + if err != nil { + t.Fatal(err) + } + serveCtx, cancelServe := context.WithCancel(context.Background()) + defer cancelServe() + serveDone := make(chan error, 1) + go func() { serveDone <- host.Serve(serveCtx) }() + + endpointSequence, deviceSequence := uint64(1), uint64(1) + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: endpointSequence, + DeviceSequence: deviceSequence, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + + device.reports <- []byte{1} + select { + case report := <-driver.reports: + if report.Sequence != 1 || string(report.Payload) != string([]byte{1}) { + t.Fatalf("first accepted report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("first input report was not accepted") + } + + commitAcrossLifecycle := func(kind OperationKind, payload byte, wantSequence uint64) { + t.Helper() + gate := newInputSubmitGate() + driver.gates <- gate + device.reports <- []byte{payload} + select { + case candidate := <-gate.started: + if candidate.Sequence != wantSequence || string(candidate.Payload) != string([]byte{payload}) { + t.Fatalf("gated candidate=%+v want sequence=%d payload=%d", candidate, wantSequence, payload) + } + case <-time.After(time.Second): + t.Fatalf("input %d did not reach the driver commit boundary", payload) + } + + endpointSequence++ + deviceSequence++ + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: endpointSequence, + DeviceSequence: deviceSequence, Kind: kind, + } + select { + case sequence := <-processor.lifecycle: + t.Fatalf("lifecycle sequence %d crossed an uncommitted encoded report", sequence) + case <-time.After(25 * time.Millisecond): + } + select { + case report := <-driver.reports: + t.Fatalf("gated report was accepted before driver release: %+v", report) + default: + } + + close(gate.release) + select { + case report := <-driver.reports: + if report.Sequence != wantSequence || string(report.Payload) != string([]byte{payload}) { + t.Fatalf("committed report=%+v want sequence=%d payload=%d", report, wantSequence, payload) + } + case <-time.After(time.Second): + t.Fatalf("encoded report %d was not committed", payload) + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatalf("lifecycle kind %d did not resume after input commit", kind) + } + } + + commitAcrossLifecycle(OperationEndpointPurge, 2, 2) + endpointSequence++ + deviceSequence++ + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: endpointSequence, + DeviceSequence: deviceSequence, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint restart after purge was not processed") + } + device.reports <- []byte{3} + select { + case report := <-driver.reports: + if report.Sequence != 3 || string(report.Payload) != string([]byte{3}) { + t.Fatalf("post-purge report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("publisher did not resume after purge/start") + } + + commitAcrossLifecycle(OperationEndpointReset, 4, 4) + device.reports <- []byte{5} + select { + case report := <-driver.reports: + if report.Sequence != 5 || string(report.Payload) != string([]byte{5}) { + t.Fatalf("post-reset report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("publisher did not resume after endpoint reset") + } + + cancelServe() + select { + case err = <-serveDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + +func TestHostOwnerCancellationBoundsWedgedEncodedInputCommit(t *testing.T) { + baseDriver := &fastInputDriver{ + fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 2), + } + driver := &gatedFastInputDriver{ + fastInputDriver: baseDriver, gates: make(chan *inputSubmitGate, 1), + } + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 2), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + device := newScheduledInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 473, device) + if err != nil { + t.Fatal(err) + } + serveDone := make(chan error, 1) + go func() { serveDone <- host.Serve(context.Background()) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + + gate := newInputSubmitGate() + driver.gates <- gate + device.reports <- []byte{0x5a} + select { + case <-gate.started: + case <-time.After(time.Second): + t.Fatal("input did not reach wedged driver boundary") + } + unregisterDone := make(chan error, 1) + go func() { unregisterDone <- host.Unregister(context.Background(), identity) }() + select { + case unregisterErr := <-unregisterDone: + t.Fatalf("unregister crossed an uncommitted report: %v", unregisterErr) + case <-time.After(25 * time.Millisecond): + } + driver.mu.Lock() + destroyedBeforeStop := len(driver.destroyed) + driver.mu.Unlock() + if destroyedBeforeStop != 0 { + t.Fatal("driver removal crossed the pending input commit") + } + + // Endpoint lifecycle intentionally joins the commit. Owner-session + // cancellation is the bounded escape hatch for a driver that never accepts + // it, and must release both Serve and a waiting Unregister. + host.Close() + select { + case err = <-serveDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("owner cancellation did not release the wedged publisher") + } + select { + case err = <-unregisterDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("owner cancellation did not release unregister") + } + select { + case report := <-driver.reports: + t.Fatalf("owner-cancelled input was accepted: %+v", report) + default: + } +} + +func TestHostReplaysCachedInputAtServiceDeadlineAcrossPurgeStart(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 8)} + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 4), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + type attempt struct { + context *controlledInputAttempt + interval time.Duration + } + attempts := make(chan attempt, 8) + host.inputAttemptContext = func(parent context.Context, interval time.Duration) (context.Context, context.CancelFunc) { + controlled := newControlledInputAttempt(parent, interval) + attempts <- attempt{context: controlled, interval: interval} + return controlled, controlled.cancel + } + device := newCachedDeadlineInputPublisherTestDevice([]byte{0x11, 0x22, 0x33}) + identity, err := host.Register(context.Background(), 471, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + var firstAttempt attempt + select { + case firstAttempt = <-attempts: + case <-time.After(time.Second): + t.Fatal("publisher did not arm its first service deadline") + } + if firstAttempt.interval != time.Millisecond { + t.Fatalf("high-speed bInterval=4 deadline=%v want=1ms", firstAttempt.interval) + } + firstAttempt.context.expire() + select { + case report := <-driver.reports: + if report.Sequence != 1 || string(report.Payload) != string([]byte{0x11, 0x22, 0x33}) { + t.Fatalf("first cached input report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("idle controller did not publish cached state at its service deadline") + } + + // Join the next blocked read through purge. Once lifecycle processing + // returns, the old publisher cannot submit a late report. + select { + case <-attempts: + case <-time.After(time.Second): + t.Fatal("publisher did not arm its next service deadline") + } + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 2, Kind: OperationEndpointPurge, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint purge was not processed") + } + select { + case report := <-driver.reports: + t.Fatalf("cached report crossed completed purge: %+v", report) + default: + } + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 3, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint restart was not processed") + } + var restartedAttempt attempt + select { + case restartedAttempt = <-attempts: + case <-time.After(time.Second): + t.Fatal("restarted publisher did not arm a service deadline") + } + restartedAttempt.context.expire() + select { + case report := <-driver.reports: + if report.Sequence != 2 || string(report.Payload) != string([]byte{0x11, 0x22, 0x33}) { + t.Fatalf("cached report after endpoint restart=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("restarted publisher did not replay cached controller state") + } + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 4, Kind: OperationEndpointPurge, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("final endpoint purge was not processed") + } + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + +func trackAndDispatch(host *Host, op Operation) error { + if err := host.trackOperation(op); err != nil { + return fmt.Errorf("track token %d: %w", op.Token, err) + } + return host.dispatch(context.Background(), op) +} + +func TestHostDeviceBarriersJoinBlockedSpeakerBeforeLaterMicAndHID(t *testing.T) { + tests := []struct { + name string + kind OperationKind + control bool + }{ + {name: "device_reset", kind: OperationDeviceReset}, + {name: "D0_exit", kind: OperationDeviceD0Exit}, + {name: "D0_entry", kind: OperationDeviceD0Entry}, + {name: "set_configuration", kind: OperationControl, control: true}, + } + + for index, test := range tests { + t.Run(test.name, func(t *testing.T) { + driver := newFakeHostDriver() + processor := &deviceBarrierProcessor{ + targetDevice: uint64(96 + index*2), + speakerStarted: make(chan struct{}), + speakerCanceled: make(chan struct{}), + speakerRelease: make(chan struct{}), + barrierStarted: make(chan Operation, 1), + barrierRelease: make(chan struct{}), + processed: make(chan Operation, 4), + } + host, err := NewHost(driver, processor, 4) + if err != nil { + t.Fatal(err) + } + target, err := host.Register(context.Background(), processor.targetDevice, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + independent, err := host.Register(context.Background(), processor.targetDevice+1, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + host.cancelAllOperations() + _ = host.Unregister(context.Background(), target) + _ = host.Unregister(context.Background(), independent) + }) + + speaker := Operation{ + Token: 1, DeviceID: target.DeviceID, Generation: target.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationTransfer, + } + if err = trackAndDispatch(host, speaker); err != nil { + t.Fatal(err) + } + select { + case <-processor.speakerStarted: + case <-time.After(time.Second): + t.Fatal("speaker callback did not start") + } + + // Multiple dequeue workers may deliver later endpoint work before the + // device-wide boundary. These lanes must remain parked at the global + // device sequence instead of overtaking the reset/configuration change. + for _, op := range []Operation{ + { + Token: 3, DeviceID: target.DeviceID, Generation: target.Generation, + EndpointAddress: 0x83, EndpointSequence: 1, DeviceSequence: 3, + Kind: OperationTransfer, + }, + { + Token: 4, DeviceID: target.DeviceID, Generation: target.Generation, + EndpointAddress: 0x04, EndpointSequence: 1, DeviceSequence: 4, + Kind: OperationTransfer, + }, + } { + if err = trackAndDispatch(host, op); err != nil { + t.Fatal(err) + } + } + + barrier := Operation{ + DeviceID: target.DeviceID, Generation: target.Generation, + EndpointAddress: 0, EndpointSequence: 1, DeviceSequence: 2, + Kind: test.kind, + } + if test.control { + barrier.Token = 2 + barrier.SetupPacket = [8]byte{ + usbRequestTypeStandardToDevice, usbRequestSetConfiguration, 1, + } + err = trackAndDispatch(host, barrier) + } else { + err = host.dispatch(context.Background(), barrier) + } + if err != nil { + t.Fatal(err) + } + select { + case <-processor.speakerCanceled: + case <-time.After(time.Second): + t.Fatal("device barrier did not cancel the older speaker callback") + } + select { + case op := <-processor.barrierStarted: + t.Fatalf("barrier sequence %d ran before the older callback joined", op.DeviceSequence) + case <-time.After(25 * time.Millisecond): + } + + if err = trackAndDispatch(host, Operation{ + Token: 100, DeviceID: independent.DeviceID, Generation: independent.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationTransfer, + }); err != nil { + t.Fatal(err) + } + select { + case op := <-processor.processed: + if op.DeviceID != independent.DeviceID { + t.Fatalf("device barrier leaked target operation %+v before release", op) + } + case <-time.After(time.Second): + t.Fatal("blocked target device serialized an independent controller") + } + + close(processor.speakerRelease) + select { + case op := <-processor.barrierStarted: + if op.DeviceSequence != barrier.DeviceSequence { + t.Fatalf("started barrier sequence=%d want=%d", op.DeviceSequence, barrier.DeviceSequence) + } + case <-time.After(time.Second): + t.Fatal("device barrier did not start after the older callback joined") + } + select { + case op := <-processor.processed: + t.Fatalf("later endpoint 0x%02x overtook the active device barrier", op.EndpointAddress) + case <-time.After(25 * time.Millisecond): + } + + close(processor.barrierRelease) + seen := make(map[uint8]bool) + for len(seen) != 2 { + select { + case op := <-processor.processed: + if op.DeviceID != target.DeviceID || (op.EndpointAddress != 0x83 && op.EndpointAddress != 0x04) { + t.Fatalf("unexpected post-barrier operation %+v", op) + } + seen[op.EndpointAddress] = true + case <-time.After(time.Second): + t.Fatalf("post-barrier endpoints processed=%v want mic 0x83 and HID 0x04", seen) + } + } + + wantCompletions := 3 + if test.control { + wantCompletions++ + } + for range wantCompletions { + select { + case completion := <-driver.completions: + if completion.Token == speaker.Token { + t.Fatal("canceled pre-barrier speaker callback published after the boundary") + } + case <-time.After(time.Second): + t.Fatal("expected post-barrier completion was not published") + } + } + select { + case completion := <-driver.completions: + if completion.Token == speaker.Token { + t.Fatal("canceled pre-barrier speaker callback published late") + } + t.Fatalf("unexpected extra completion %+v", completion) + case <-time.After(25 * time.Millisecond): + } + }) + } +} + +func TestHostDeviceBarrierCancelsAndJoinsBlockedCompletion(t *testing.T) { + driver := &deviceBarrierCompletionDriver{ + fakeHostDriver: newFakeHostDriver(), + started: make(chan struct{}), + canceled: make(chan struct{}), + release: make(chan struct{}), + } + processor := &resetGateProcessor{started: make(chan struct{}), release: make(chan struct{})} + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), 105, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + host.cancelAllOperations() + _ = host.Unregister(context.Background(), identity) + }) + + if err = trackAndDispatch(host, Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationTransfer, + }); err != nil { + t.Fatal(err) + } + select { + case <-driver.started: + case <-time.After(time.Second): + t.Fatal("pre-reset driver completion did not start") + } + if err = host.dispatch(context.Background(), Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 1, DeviceSequence: 2, + Kind: OperationDeviceReset, + }); err != nil { + t.Fatal(err) + } + select { + case <-driver.canceled: + case <-time.After(time.Second): + t.Fatal("device reset did not cancel the older blocked completion") + } + select { + case <-processor.started: + t.Fatal("device reset ran before the canceled completion callback joined") + case <-time.After(25 * time.Millisecond): + } + + close(driver.release) + select { + case <-processor.started: + case <-time.After(time.Second): + t.Fatal("device reset did not run after the completion callback joined") + } + close(processor.release) + select { + case completion := <-driver.completions: + t.Fatalf("canceled pre-reset completion was published: %+v", completion) + case <-time.After(25 * time.Millisecond): + } +} + +func TestHostDeviceBarrierCompletesSupersededManagementRequest(t *testing.T) { + driver := &managementBarrierDriver{ + fakeHostDriver: newFakeHostDriver(), + management: make(chan Completion, 1), + release: make(chan struct{}), + } + processor := &supersededManagementProcessor{ + endpointStarted: make(chan struct{}), + endpointCanceled: make(chan struct{}), + endpointRelease: make(chan struct{}), + barrierStarted: make(chan struct{}), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), 106, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + host.cancelAllOperations() + _ = host.Unregister(context.Background(), identity) + }) + + if err = host.dispatch(context.Background(), Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointReset, + }); err != nil { + t.Fatal(err) + } + select { + case <-processor.endpointStarted: + case <-time.After(time.Second): + t.Fatal("token-bearing endpoint reset did not start") + } + if err = host.dispatch(context.Background(), Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 1, DeviceSequence: 2, + Kind: OperationDeviceReset, + }); err != nil { + t.Fatal(err) + } + select { + case <-processor.endpointCanceled: + case <-time.After(time.Second): + t.Fatal("device reset did not cancel the older endpoint reset callback") + } + select { + case <-processor.barrierStarted: + t.Fatal("device reset ran before the older endpoint reset callback joined") + case <-time.After(25 * time.Millisecond): + } + + close(processor.endpointRelease) + select { + case completion := <-driver.management: + if completion.Token != 1 || completion.Status != statusUnsuccessful { + t.Fatalf("superseded management completion=%+v", completion) + } + case <-time.After(time.Second): + t.Fatal("superseded endpoint reset left its UdeCx management token stranded") + } + select { + case <-processor.barrierStarted: + t.Fatal("device reset ran before the management completion joined") + case <-time.After(25 * time.Millisecond): + } + + close(driver.release) + select { + case <-processor.barrierStarted: + case <-time.After(time.Second): + t.Fatal("device reset did not run after the superseded management request completed") + } +} + +func TestHostDeviceBarrierJoinsQueuedSupersededManagementRequest(t *testing.T) { + driver := &managementBarrierDriver{ + fakeHostDriver: newFakeHostDriver(), + management: make(chan Completion, 1), + release: make(chan struct{}), + } + processor := &supersededManagementProcessor{ + endpointStarted: make(chan struct{}), + endpointCanceled: make(chan struct{}), + endpointRelease: make(chan struct{}), + barrierStarted: make(chan struct{}), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), 108, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + host.cancelAllOperations() + _ = host.Unregister(context.Background(), identity) + }) + + // A multi-worker dequeue may announce the later device reset before the + // earlier endpoint reset reaches its endpoint lane. + if err = host.dispatch(context.Background(), Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 1, DeviceSequence: 2, + Kind: OperationDeviceReset, + }); err != nil { + t.Fatal(err) + } + if err = host.dispatch(context.Background(), Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointReset, + }); err != nil { + t.Fatal(err) + } + select { + case completion := <-driver.management: + if completion.Token != 1 || completion.Status != statusUnsuccessful { + t.Fatalf("queued superseded management completion=%+v", completion) + } + case <-time.After(time.Second): + t.Fatal("queued superseded endpoint reset left its management token stranded") + } + select { + case <-processor.endpointStarted: + t.Fatal("queued pre-barrier endpoint reset reached the processor") + default: + } + select { + case <-processor.barrierStarted: + t.Fatal("device reset ran before queued management cancellation joined") + case <-time.After(25 * time.Millisecond): + } + + close(driver.release) + select { + case <-processor.barrierStarted: + case <-time.After(time.Second): + t.Fatal("device reset did not run after queued management cancellation joined") + } +} + +func TestHostWithdrawsAnnouncedBarrierWhenLaneAdmissionFails(t *testing.T) { + driver := newFakeHostDriver() + host, err := NewHost(driver, &noopProcessor{}, 1) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), 107, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + + host.mu.RLock() + entry := host.devices[identity.DeviceID] + host.mu.RUnlock() + laneCtx, cancelLane := context.WithCancel(entry.ctx) + key := laneKey{deviceID: identity.DeviceID, generation: identity.Generation, endpoint: 0} + lane := &operationLane{ + key: key, ctx: laneCtx, cancel: cancelLane, + input: make(chan Operation, 1), done: make(chan struct{}), + terminalErr: errors.New("injected terminal lane"), + } + host.mu.Lock() + host.lanes[key] = lane + host.mu.Unlock() + + err = host.dispatch(context.Background(), Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationDeviceReset, + }) + if err == nil || !strings.Contains(err.Error(), "injected terminal lane") { + t.Fatalf("barrier admission error=%v want injected terminal lane", err) + } + entry.sequence.mu.Lock() + pendingBarriers := len(entry.sequence.pendingBarriers) + entry.sequence.mu.Unlock() + if pendingBarriers != 0 { + t.Fatalf("failed barrier admission retained %d pending barriers", pendingBarriers) + } + + host.mu.Lock() + if host.lanes[key] == lane { + delete(host.lanes, key) + } + host.mu.Unlock() + cancelLane() + if err = host.Unregister(context.Background(), identity); err != nil { + t.Fatal(err) + } +} + +func TestHostSaturatedLaneDoesNotBlockIndependentController(t *testing.T) { + if laneQueueDepth != defaultDevicePendingOperations { + t.Fatalf("lane queue depth=%d want kernel pending contract=%d", + laneQueueDepth, defaultDevicePendingOperations) + } + driver := newFakeHostDriver() + processor := &deviceGateProcessor{ + blockedDevice: 91, + started: make(chan struct{}), + independent: make(chan uint64, 1), + } + host, err := NewHost(driver, processor, 1) + if err != nil { + t.Fatal(err) + } + blocked, err := host.Register(context.Background(), processor.blockedDevice, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + independent, err := host.Register(context.Background(), 92, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + host.cancelAllOperations() + _ = host.Unregister(context.Background(), blocked) + _ = host.Unregister(context.Background(), independent) + }) + + first := Operation{ + Token: 1, DeviceID: blocked.DeviceID, Generation: blocked.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, Kind: OperationTransfer, + } + if err = trackAndDispatch(host, first); err != nil { + t.Fatal(err) + } + select { + case <-processor.started: + case <-time.After(time.Second): + t.Fatal("blocked lane did not start processing") + } + + for sequence := uint64(2); sequence <= uint64(laneQueueDepth)+1; sequence++ { + op := Operation{ + Token: sequence, DeviceID: blocked.DeviceID, Generation: blocked.Generation, + EndpointAddress: 0x02, EndpointSequence: sequence, Kind: OperationTransfer, + } + if err = trackAndDispatch(host, op); err != nil { + t.Fatalf("fill blocked lane at sequence %d: %v", sequence, err) + } + } + overflow := Operation{ + Token: uint64(laneQueueDepth) + 2, + DeviceID: blocked.DeviceID, Generation: blocked.Generation, + EndpointAddress: 0x02, EndpointSequence: uint64(laneQueueDepth) + 2, + Kind: OperationTransfer, + } + if err = trackAndDispatch(host, overflow); err == nil || !strings.Contains(err.Error(), "lane is saturated") { + t.Fatalf("overflow dispatch error=%v, want terminal saturation", err) + } + + dispatched := make(chan error, 1) + go func() { + dispatched <- trackAndDispatch(host, Operation{ + Token: 10000, DeviceID: independent.DeviceID, Generation: independent.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, Kind: OperationTransfer, + }) + }() + select { + case err = <-dispatched: + if err != nil { + t.Fatalf("independent dispatch failed: %v", err) + } + case <-time.After(250 * time.Millisecond): + t.Fatal("saturated controller blocked the central dispatcher") + } + select { + case deviceID := <-processor.independent: + if deviceID != independent.DeviceID { + t.Fatalf("processed independent device=%d want=%d", deviceID, independent.DeviceID) + } + case <-time.After(time.Second): + t.Fatal("independent controller was not processed") + } +} + +func TestHostFatalLaneWithQueuedWorkStaysTerminal(t *testing.T) { + driver := newFakeHostDriver() + processor := &fatalLaneProcessor{ + failingDevice: 93, + started: make(chan struct{}), + release: make(chan struct{}), + independent: make(chan uint64, 1), + queuedProcessed: make(chan struct{}, 1), + } + host, err := NewHost(driver, processor, 1) + if err != nil { + t.Fatal(err) + } + failing, err := host.Register(context.Background(), processor.failingDevice, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + independent, err := host.Register(context.Background(), 94, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = host.Unregister(context.Background(), failing) + _ = host.Unregister(context.Background(), independent) + }) + + first := Operation{ + DeviceID: failing.DeviceID, Generation: failing.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, Kind: OperationSetInterface, + } + if err = host.dispatch(context.Background(), first); err != nil { + t.Fatal(err) + } + select { + case <-processor.started: + case <-time.After(time.Second): + t.Fatal("failing lane did not enter its lifecycle processor") + } + key := laneKey{ + deviceID: failing.DeviceID, generation: failing.Generation, endpoint: first.EndpointAddress, + } + host.mu.RLock() + failedLane := host.lanes[key] + host.mu.RUnlock() + if failedLane == nil { + t.Fatal("failing lane was not installed") + } + if err = host.dispatch(context.Background(), Operation{ + DeviceID: failing.DeviceID, Generation: failing.Generation, + EndpointAddress: 0x02, EndpointSequence: 2, Kind: OperationSetInterface, + }); err != nil { + t.Fatalf("queue work behind failing operation: %v", err) + } + close(processor.release) + select { + case <-failedLane.done: + case <-time.After(time.Second): + t.Fatal("fatal lane did not cancel and stop") + } + select { + case <-processor.queuedProcessed: + t.Fatal("work queued behind a fatal operation was processed") + default: + } + + host.mu.RLock() + routedLane := host.lanes[key] + terminalErr := host.failedLanes[key] + host.mu.RUnlock() + if routedLane != nil { + t.Fatal("fatal lane remained in the routing map") + } + if terminalErr == nil || !strings.Contains(terminalErr.Error(), "injected lane failure") { + t.Fatalf("terminal lane error=%v, want injected failure", terminalErr) + } + if err = host.dispatch(context.Background(), Operation{ + DeviceID: failing.DeviceID, Generation: failing.Generation, + EndpointAddress: 0x02, EndpointSequence: 3, Kind: OperationSetInterface, + }); err == nil || !strings.Contains(err.Error(), "injected lane failure") { + t.Fatalf("dispatch to terminal lane error=%v, want original failure", err) + } + host.mu.RLock() + recreated := host.lanes[key] + host.mu.RUnlock() + if recreated != nil { + t.Fatal("dispatch recreated a terminal lane") + } + + if err = host.dispatch(context.Background(), Operation{ + DeviceID: independent.DeviceID, Generation: independent.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, Kind: OperationSetInterface, + }); err != nil { + t.Fatalf("independent lifecycle dispatch failed: %v", err) + } + select { + case deviceID := <-processor.independent: + if deviceID != independent.DeviceID { + t.Fatalf("processed independent device=%d want=%d", deviceID, independent.DeviceID) + } + case <-time.After(time.Second): + t.Fatal("independent lane did not run after another lane failed") + } +} + +func TestHostServeReturnsPromptlyOnLaneSaturation(t *testing.T) { + baseDriver := newFakeHostDriver() + baseDriver.operations = make(chan Operation, laneQueueDepth+4) + driver := &cancellationOnlyCompletionDriver{fakeHostDriver: baseDriver} + processor := &deviceGateProcessor{ + blockedDevice: 95, + started: make(chan struct{}), + independent: make(chan uint64, 1), + } + host, err := NewHost(driver, processor, 1) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), processor.blockedDevice, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- host.Serve(context.Background()) }() + + baseDriver.operations <- Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, Kind: OperationTransfer, + } + select { + case <-processor.started: + case <-time.After(time.Second): + t.Fatal("saturation test lane did not start processing") + } + for sequence := uint64(2); sequence <= uint64(laneQueueDepth)+2; sequence++ { + baseDriver.operations <- Operation{ + Token: sequence, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x02, EndpointSequence: sequence, Kind: OperationTransfer, + } + } + + select { + case err = <-done: + if err == nil || !strings.Contains(err.Error(), "lane is saturated") { + t.Fatalf("Serve error=%v, want lane saturation failure", err) + } + case <-time.After(time.Second): + t.Fatal("Serve waited on failure completion instead of promptly observing lane fatal") + } +} + +func TestHostPreservesEndpointSequenceAcrossDequeueWorkers(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{processed: make(chan uint64, 2), resets: make(chan DeviceIdentity, 1)} + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), 9, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + Token: 2, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 2, TransferLength: 8, + } + driver.operations <- Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, TransferLength: 8, + } + + for want := uint64(1); want <= 2; want++ { + select { + case got := <-processor.processed: + if got != want { + t.Fatalf("processed endpoint sequence=%d want=%d", got, want) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for endpoint sequence %d", want) + } + } + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop after context cancellation") + } +} + +func TestHostSessionCannotRestartAfterOperationsWereDequeued(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} + host, err := NewHost(driver, processor, 1) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), 19, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationTransfer, + } + select { + case <-processor.processed: + case <-time.After(time.Second): + t.Fatal("first host session did not process its operation") + } + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("first host session did not stop") + } + + if err = host.Serve(context.Background()); err == nil || !strings.Contains(err.Error(), "one-shot") { + t.Fatalf("second Serve error=%v, want one-shot session rejection", err) + } + if _, err = host.Register(context.Background(), 20, hostTestDevice()); err == nil || + !strings.Contains(err.Error(), "fresh driver session") { + t.Fatalf("Register after Serve error=%v, want terminal session rejection", err) + } +} + +func TestHostOrdersLifecycleBeforeFollowingTransfer(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 1), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), 10, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 2, Kind: OperationTransfer, + } + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointPurge, + } + + select { + case got := <-processor.lifecycle: + if got != 1 { + t.Fatalf("lifecycle endpoint sequence=%d want=1", got) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for lifecycle operation") + } + select { + case got := <-processor.processed: + if got != 2 { + t.Fatalf("transfer endpoint sequence=%d want=2", got) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for transfer after lifecycle") + } + cancel() + <-done +} + +func TestHostAcknowledgesLifecycleOnlyAfterProcessorAppliesIt(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 1), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), 73, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + const token = uint64(0x0000000180000001) + driver.operations <- Operation{ + Token: token, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointReset, + } + select { + case sequence := <-processor.lifecycle: + if sequence != 1 { + t.Fatalf("lifecycle endpoint sequence=%d want=1", sequence) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for acknowledged lifecycle operation") + } + select { + case completion := <-driver.completions: + if completion.Token != token || completion.DeviceID != identity.DeviceID || + completion.Generation != identity.Generation || completion.Status != 0 || + completion.TransferLength != 0 || len(completion.Payload) != 0 || + len(completion.IsoPackets) != 0 { + t.Fatalf("lifecycle acknowledgement=%+v", completion) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for lifecycle acknowledgement") + } + cancel() + <-done +} + +func TestHostDoesNotCompleteAdvisoryLifecycleNotification(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 1), + resets: make(chan DeviceIdentity, 1), + } + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 74, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointPurge, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("timed out waiting for advisory lifecycle operation") + } + select { + case completion := <-driver.completions: + t.Fatalf("advisory lifecycle notification was completed: %+v", completion) + case <-time.After(20 * time.Millisecond): + } + cancel() + <-done +} + +func TestHostRegisterFailureRollsBackButAdvancesGeneration(t *testing.T) { + driver := newFakeHostDriver() + driver.createErr = errors.New("plug failed") + processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} + host, _ := NewHost(driver, processor, 1) + if _, err := host.Register(context.Background(), 4, hostTestDevice()); err == nil { + t.Fatal("register unexpectedly succeeded") + } + driver.createErr = nil + identity, err := host.Register(context.Background(), 4, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + if identity.Generation != 2 { + t.Fatalf("generation=%d want=2 after failed creation", identity.Generation) + } + if err := host.Unregister(context.Background(), identity); err != nil { + t.Fatal(err) + } + select { + case got := <-processor.resets: + if got != identity { + t.Fatalf("reset identity=%+v want=%+v", got, identity) + } + case <-time.After(time.Second): + t.Fatal("processor was not reset during unregister") + } +} + +func TestHostRejectsAndRollsBackMalformedCorrelationReceipt(t *testing.T) { + for name, mutate := range map[string]func(*DeviceRegistration){ + "zero session": func(r *DeviceRegistration) { r.ControllerSessionID = 0 }, + "wrong controller": func(r *DeviceRegistration) { + r.ControllerInstanceID = `ROOT\VIIPERUDE\42` + }, + "wrong device": func(r *DeviceRegistration) { r.DeviceID++ }, + "wrong generation": func(r *DeviceRegistration) { r.Generation++ }, + "wrong speed": func(r *DeviceRegistration) { r.Speed = DeviceSpeedSuper }, + "two ports": func(r *DeviceRegistration) { r.USB30PortNumber = MaxDevices + 1 }, + "USB2 port above range": func(r *DeviceRegistration) { + r.USB20PortNumber = MaxDevices + 1 + }, + } { + t.Run(name, func(t *testing.T) { + driver := newFakeHostDriver() + driver.mutateRegistration = mutate + processor := &recordingProcessor{ + processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 1) + if err != nil { + t.Fatal(err) + } + if _, err = host.RegisterWithCorrelation(context.Background(), 41, hostTestDevice()); err == nil { + t.Fatal("malformed driver receipt was accepted") + } + driver.mu.Lock() + destroyed := append([]DeviceIdentity(nil), driver.destroyed...) + driver.mu.Unlock() + if len(destroyed) != 1 || destroyed[0] != (DeviceIdentity{DeviceID: 41, Generation: 1}) { + t.Fatalf("rollback identities=%+v", destroyed) + } + driver.mutateRegistration = nil + registration, err := host.RegisterWithCorrelation(context.Background(), 41, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + if registration.Generation != 2 || registration.ControllerSessionID != 17 { + t.Fatalf("replacement registration=%+v", registration) + } + }) + } +} + +func TestHostFencesControllerIdentityForItsLifetime(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{ + processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 2), + } + host, err := NewHost(driver, processor, 1) + if err != nil { + t.Fatal(err) + } + first, err := host.RegisterWithCorrelation(context.Background(), 51, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + driver.mutateRegistration = func(registration *DeviceRegistration) { + registration.ControllerSessionID++ + } + if _, err = host.RegisterWithCorrelation(context.Background(), 52, hostTestDevice()); err == nil { + t.Fatal("one Host accepted a device from a different controller session") + } + driver.mu.Lock() + destroyed := append([]DeviceIdentity(nil), driver.destroyed...) + driver.mu.Unlock() + if len(destroyed) != 1 || destroyed[0] != (DeviceIdentity{DeviceID: 52, Generation: 1}) { + t.Fatalf("mismatched-session rollback=%+v", destroyed) + } + driver.mutateRegistration = nil + if err = host.Unregister(context.Background(), first.DeviceIdentity); err != nil { + t.Fatal(err) + } +} + +func TestHostUnregisterFailureKeepsDeviceRetryable(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 11, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + driver.destroyErr = errors.New("plug-out failed") + if err := host.Unregister(context.Background(), identity); err == nil { + t.Fatal("unregister unexpectedly succeeded") + } + host.mu.RLock() + entry := host.devices[identity.DeviceID] + host.mu.RUnlock() + if entry == nil || entry.identity != identity { + t.Fatal("failed unregister discarded the live device generation") + } + driver.destroyErr = nil + if err := host.Unregister(context.Background(), identity); err != nil { + t.Fatal(err) + } +} + +func TestHostRejectsStaleOperationGeneration(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 5, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + err = host.dispatch(context.Background(), Operation{ + Token: 3, DeviceID: identity.DeviceID, Generation: identity.Generation + 1, + EndpointAddress: 0x81, EndpointSequence: 1, + }) + if err == nil { + t.Fatal("stale generation was accepted") + } +} + +func TestHostCompletesTypedUSBDFailureWithoutCollapsingToNTStatus(t *testing.T) { + driver := newFakeHostDriver() + processor := &usbdFailureProcessor{err: testUSBDCompletionError{ + status: USBDStatusBadStartFrame, + }} + host, err := NewHost(driver, processor, 1) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), 109, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, Kind: OperationTransfer, + IsoPackets: []IsoPacket{{Offset: 0, Length: 64}, {Offset: 64, Length: 64}}, + } + select { + case completion := <-driver.completions: + if completion.Status != 0 || completion.USBDStatus != USBDStatusBadStartFrame { + t.Fatalf("typed USBD completion=%+v want NT success and BAD_START_FRAME", completion) + } + if len(completion.IsoPackets) != 2 || + completion.IsoPackets[0].Offset != 0 || + completion.IsoPackets[1].Offset != 64 || + completion.IsoPackets[0].Length != 0 || + uint32(completion.IsoPackets[0].Status) != USBDStatusBadStartFrame { + t.Fatalf("typed USBD ISO packet table=%+v", completion.IsoPackets) + } + case <-time.After(time.Second): + t.Fatal("typed USBD failure was not completed") + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatalf("host returned error after clean cancellation: %v", err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop after cancellation") + } +} + +func TestHostCancelBeforeOperationSkipsProcessingAndCompletion(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 6, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + Token: 44, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointGeneration: 1, Kind: OperationCancel, + } + driver.operations <- Operation{ + Token: 44, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointGeneration: 1, + EndpointSequence: 1, Kind: OperationTransfer, + } + + deadline := time.Now().Add(time.Second) + for { + host.operationMu.Lock() + state := host.operations[44] + finished := state != nil && state.done + host.operationMu.Unlock() + if finished { + break + } + if time.Now().After(deadline) { + t.Fatal("cancelled operation was not retired") + } + time.Sleep(time.Millisecond) + } + select { + case sequence := <-processor.processed: + t.Fatalf("cancelled operation reached processor with sequence %d", sequence) + default: + } + select { + case completion := <-driver.completions: + t.Fatalf("cancelled operation was completed twice: %+v", completion) + default: + } + cancel() + <-done +} + +func TestHostAcceptsDeviceScopedManagementCancelTombstone(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{ + processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1), + } + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 61, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + managementToken := uint64(2)<<32 | uint64(ManagementSlotFlag) | 1 + driver.operations <- Operation{ + Token: managementToken, DeviceID: identity.DeviceID, + Generation: identity.Generation, Kind: OperationCancel, + } + driver.operations <- Operation{ + Token: 62, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointGeneration: 1, + EndpointSequence: 1, DeviceSequence: 1, Kind: OperationTransfer, + } + select { + case completion := <-driver.completions: + if completion.Token != 62 || completion.EndpointGeneration != 1 { + t.Fatalf("post-tombstone completion=%+v", completion) + } + case serveErr := <-done: + t.Fatalf("device-scoped management tombstone faulted host: %v", serveErr) + case <-time.After(time.Second): + t.Fatal("host did not continue after device-scoped management tombstone") + } + host.operationMu.Lock() + _, retained := host.operations[managementToken] + host.operationMu.Unlock() + if retained { + t.Fatal("device-scoped management tombstone retained an unmatched cancellation owner") + } + + cancel() + if err = <-done; err != nil { + t.Fatal(err) + } +} + +func TestHostCancelInterruptsActiveProcessor(t *testing.T) { + driver := newFakeHostDriver() + processor := &cancellableProcessor{started: make(chan struct{}), cancelled: make(chan struct{})} + host, _ := NewHost(driver, processor, 2) + identity, err := host.Register(context.Background(), 7, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + Token: 55, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointGeneration: 2, + EndpointSequence: 1, Kind: OperationTransfer, + } + select { + case <-processor.started: + case <-time.After(time.Second): + t.Fatal("processor did not start") + } + driver.operations <- Operation{ + Token: 55, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointGeneration: 1, Kind: OperationCancel, + } + select { + case <-processor.cancelled: + t.Fatal("stale endpoint generation cancelled the active request") + case <-time.After(50 * time.Millisecond): + } + driver.operations <- Operation{ + Token: 55, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointGeneration: 2, Kind: OperationCancel, + } + select { + case <-processor.cancelled: + case <-time.After(time.Second): + t.Fatal("processor context was not cancelled") + } + select { + case completion := <-driver.completions: + t.Fatalf("cancelled operation was completed twice: %+v", completion) + case <-time.After(20 * time.Millisecond): + } + cancel() + <-done +} + +func TestHostUnregisterCancelsAndJoinsLanesBeforeReset(t *testing.T) { + driver := newFakeHostDriver() + processor := &unregisterProcessor{ + started: make(chan struct{}), cancelled: make(chan struct{}), reset: make(chan bool, 1), + } + host, _ := NewHost(driver, processor, 2) + identity, err := host.Register(context.Background(), 16, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + serveCtx, stopServe := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(serveCtx) }() + driver.operations <- Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationTransfer, + } + select { + case <-processor.started: + case <-time.After(time.Second): + t.Fatal("processor did not start") + } + unregisterCtx, cancelUnregister := context.WithTimeout(context.Background(), time.Second) + defer cancelUnregister() + if err := host.Unregister(unregisterCtx, identity); err != nil { + t.Fatal(err) + } + select { + case cancelledFirst := <-processor.reset: + if !cancelledFirst { + t.Fatal("device reset raced ahead of its active endpoint lane") + } + case <-time.After(time.Second): + t.Fatal("device was not reset after unregister") + } + select { + case completion := <-driver.completions: + t.Fatalf("unregister completed an operation after cancellation: %+v", completion) + default: + } + stopServe() + select { + case err = <-done: + if err != nil { + t.Fatalf("ordinary unregister failed the host session: %v", err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + +func TestHostUnregisterTimeoutKeepsStoppingTombstone(t *testing.T) { + driver := newFakeHostDriver() + processor := &stubbornProcessor{started: make(chan struct{}), release: make(chan struct{})} + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 17, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- host.Serve(context.Background()) }() + driver.operations <- Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationTransfer, + } + select { + case <-processor.started: + case <-time.After(time.Second): + t.Fatal("processor did not start") + } + unregisterCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + err = host.Unregister(unregisterCtx, identity) + cancel() + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Unregister error=%v want deadline", err) + } + if _, err = host.Register(context.Background(), identity.DeviceID, hostTestDevice()); err == nil { + t.Fatal("stopping device ID was reused after irreversible plug-out") + } + close(processor.release) + select { + case err = <-done: + if err == nil || !strings.Contains(err.Error(), "stop native UDE device") { + t.Fatalf("Serve error=%v want teardown-timeout session failure", err) + } + case <-time.After(time.Second): + t.Fatal("teardown timeout did not fail the host session") + } +} + +func TestHostDuplicateTokenFailsSessionWithoutCompletingWrongOperation(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 12, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- host.Serve(context.Background()) }() + + // Sequence 1 is deliberately absent, so the first token remains a valid, + // pending kernel request when the corrupt duplicate arrives. + for _, endpoint := range []uint8{0x81, 0x82} { + driver.operations <- Operation{ + Token: 77, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: endpoint, EndpointSequence: 2, Kind: OperationTransfer, + } + } + + select { + case err = <-done: + if err == nil || !strings.Contains(err.Error(), "reuses a completed or mismatched token") { + t.Fatalf("Serve error=%v, want duplicate-token session failure", err) + } + case <-time.After(time.Second): + t.Fatal("duplicate operation token did not fail the host session") + } + select { + case completion := <-driver.completions: + t.Fatalf("duplicate token completed an ambiguous kernel request: %+v", completion) + default: + } +} + +func TestHostDuplicateEndpointSequenceFailsSession(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 13, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- host.Serve(context.Background()) }() + + for token := uint64(1); token <= 2; token++ { + driver.operations <- Operation{ + Token: token, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 2, Kind: OperationTransfer, + } + } + select { + case err = <-done: + if err == nil || !strings.Contains(err.Error(), "repeated pending sequence 2") { + t.Fatalf("Serve error=%v, want duplicate-sequence session failure", err) + } + case <-time.After(time.Second): + t.Fatal("duplicate endpoint sequence did not fail the host session") + } +} + +func TestHostLifecycleFailureFailsSession(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 1), + resets: make(chan DeviceIdentity, 1), lifecycleErr: errors.New("reset rejected"), + } + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 14, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- host.Serve(context.Background()) }() + driver.operations <- Operation{ + Token: 0x0000000180000001, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointReset, + } + select { + case completion := <-driver.completions: + if completion.Status != statusUnsuccessful { + t.Fatalf("failed lifecycle completion status=%d want=%d", + completion.Status, statusUnsuccessful) + } + case <-time.After(time.Second): + t.Fatal("failed lifecycle was not acknowledged to the driver") + } + select { + case err = <-done: + if err == nil || !strings.Contains(err.Error(), "reset rejected") { + t.Fatalf("Serve error=%v, want lifecycle session failure", err) + } + case <-time.After(time.Second): + t.Fatal("lifecycle failure did not fail the host session") + } +} + +func TestHostCompletionFailureFailsSession(t *testing.T) { + driver := newFakeHostDriver() + driver.completeErr = errors.New("completion handle lost") + processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 15, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- host.Serve(context.Background()) }() + driver.operations <- Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationTransfer, + } + select { + case <-processor.processed: + case <-time.After(time.Second): + t.Fatal("processor did not receive transfer") + } + select { + case err = <-done: + if err == nil || !strings.Contains(err.Error(), "completion handle lost") { + t.Fatalf("Serve error=%v, want completion session failure", err) + } + case <-time.After(time.Second): + t.Fatal("completion failure did not fail the host session") + } +} + +func TestHostDirectInputFailureFailsSession(t *testing.T) { + driver := &fastInputDriver{ + fakeHostDriver: newFakeHostDriver(), + reports: make(chan InputReport, 1), + submitErr: errors.New("direct input handle lost"), + } + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 1), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 16, device) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- host.Serve(context.Background()) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + device.reports <- []byte{1, 2, 3, 4} + select { + case err = <-done: + if err == nil || !strings.Contains(err.Error(), "direct input handle lost") { + t.Fatalf("Serve error=%v, want direct-input session failure", err) + } + case <-time.After(time.Second): + t.Fatal("direct input submission failure did not fail the host session") + } +} + +func TestHostBrokerFaultFailsSessionWithoutDispatchingAnOperation(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 1), + resets: make(chan DeviceIdentity, 1), + } + host, _ := NewHost(driver, processor, 1) + done := make(chan error, 1) + go func() { done <- host.Serve(context.Background()) }() + driver.operations <- Operation{Kind: OperationBrokerFault} + + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "lost lifecycle notification") { + t.Fatalf("Serve error=%v, want broker-fault session failure", err) + } + case <-time.After(time.Second): + t.Fatal("kernel broker fault did not fail the host session") + } + select { + case sequence := <-processor.processed: + t.Fatalf("broker fault reached transfer processor with sequence %d", sequence) + case sequence := <-processor.lifecycle: + t.Fatalf("broker fault reached lifecycle processor with sequence %d", sequence) + default: + } +} diff --git a/internal/transport/udecx/live_validation_contract_test.go b/internal/transport/udecx/live_validation_contract_test.go new file mode 100644 index 00000000..d72f52ac --- /dev/null +++ b/internal/transport/udecx/live_validation_contract_test.go @@ -0,0 +1,187 @@ +package udecx + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNativeLiveReleaseGateRequiresCompleteEvidence(t *testing.T) { + root := filepath.Join("..", "..", "..", "native", "udecx", "tools") + script, err := os.ReadFile(filepath.Join(root, "Invoke-ViiperUdeLiveValidation.ps1")) + if err != nil { + t.Fatalf("read native live validator: %v", err) + } + contract := strings.ReplaceAll(string(script), "\r\n", "\n") + for _, required := range []string{ + "[switch]$ReleaseGate", + "$SignatureValidationMode -ne 'Production'", + "-RequireDriverVerifier is required", + "-MediaProbePath is required", + "-InputProbePath is required", + "-ProbeManifestPath is required", + "-RestartRootDevice is required", + "-DisposableTestMachine is required", + "-ManageInstalledBrokerService is required", + "$Iterations -lt 3", + "$MediaDurationSeconds -lt 180", + "VIIPER_UDE_LIVE_MEDIA_SECONDS", + "Confirm-SecureBootUEFI", + "$build -lt 22000", + "0x001209BB", + "Driver Verifier must target only ViiperUde.sys", + "Test-LiveProbeManifest", + "sourceRevision", + "@(Compare-Object -ReferenceObject $expectedNames -DifferenceObject $actualNames).Count", + "Get-FileHash -LiteralPath $path -Algorithm SHA256", + "-ProbeManifestPath is required whenever a source-bound live probe is used", + "[ValidateSet('LocalTest', 'ControlledTest', 'Production')]", + "$SignatureValidationMode -eq 'LocalTest'", + "testsigning Yes", + "-LocalTestCertificatePath $LocalTestCertificatePath", + "rev-parse --verify HEAD", + "status --porcelain=v1 --untracked-files=all", + "submodule status --recursive", + "$env:GOFLAGS = '-mod=readonly'", + "$env:GOWORK = 'off'", + "$env:GOENV = 'off'", + "$env:GOTOOLCHAIN = 'local'", + "$env:GOOS = 'windows'", + "$env:GOARCH = 'amd64'", + "$env:CGO_ENABLED = '0'", + "$go.Source env GOMOD", + "$nativeIdentityLdflags", + "internal/transport/udecx.nativeSourceRevision=", + "$ExpectedSourceRevision.ToLowerInvariant()", + "Win32_PnPEntity", + "@($_.HardwareID) -contains 'ROOT\\VIIPER\\UDE'", + "$ownedRootDevices[0].PNPDeviceID", + "$ownedRootDevices[0].ConfigManagerErrorCode", + "$infName -cnotmatch '^oem[0-9]+\\.inf$'", + "$packageInfHash -cne $installedInfHash", + "if ($SignatureValidationMode -ne 'LocalTest')", + "$devnodes[0].IsSigned", + "$devnodes[0].Signer -notmatch '(?i)Microsoft'", + "Stop-Service -Name $brokerService.Name", + "Start-Service -Name $brokerService.Name", + "ServiceControllerStatus]::Stopped", + "ServiceControllerStatus]::Running", + "$ErrorActionPreference = 'Continue'", + "./internal/server/usb 2>&1", + "Go reported success without executing required live test", + } { + if !strings.Contains(contract, required) { + t.Fatalf("native release gate omitted %q", required) + } + } + if strings.Contains(contract, "DeviceID -like 'ROOT\\VIIPER\\UDE*'") { + t.Fatal("native release gate confuses the INF hardware ID with the generated PnP instance ID") + } +} + +func TestNativeMediaProbeRejectsObservableDiscontinuity(t *testing.T) { + path := filepath.Join("..", "..", "..", "native", "udecx", "tools", + "ViiperUdeMediaProbe.cpp") + source, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read native media probe: %v", err) + } + contract := strings.ReplaceAll(string(source), "\r\n", "\n") + for _, required := range []string{ + "AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY", + "AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR", + "positionRegressions", + "qpcRegressions", + "renderStats.underruns != 0", + "ValidateFrameCount(\"render\"", + "ValidateFrameCount(\"capture\"", + "captureStats.nonSilentFrames < captureStats.frames / 2", + "seconds > 300", + } { + if !strings.Contains(contract, required) { + t.Fatalf("native media probe omitted %q", required) + } + } +} + +func TestNativeLiveSoakKeepsMediaInputAndFeedbackConcurrent(t *testing.T) { + path := filepath.Join("..", "..", "server", "usb", "native_live_windows_test.go") + source, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read native live integration test: %v", err) + } + contract := strings.ReplaceAll(string(source), "\r\n", "\n") + for _, required := range []string{ + "startLiveProbe(", + "mediaCtx, mediaProbe, \"exercise\"", + "armLiveNativeMediaWitness(dev)", + "mediaWitness.startMicrophone(mediaCtx)", + "mediaWitness.validate(mediaDuration)", + "publishInput(sequence)", + "if feedbackController {\n\t\t\t\t\t\tverifyFeedback()", + "3*mediaDuration+2*time.Minute", + "controller.name == \"DualSenseEdge\"", + } { + if !strings.Contains(contract, required) { + t.Fatalf("native concurrent media soak omitted %q", required) + } + } +} + +func TestNativePerformanceTraceCapturesAttributableCriticalPath(t *testing.T) { + path := filepath.Join("..", "..", "..", "native", "udecx", "tools", + "Invoke-ViiperUdePerformanceValidation.ps1") + source, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read native performance validator: %v", err) + } + contract := strings.ReplaceAll(string(source), "\r\n", "\n") + for _, required := range []string{ + "[string]$ProbeManifestPath", + "ProbeManifestPath = $ProbeManifestPath", + "[switch]$ManageInstalledBrokerService", + "$validationArguments.ManageInstalledBrokerService = $true", + "[int]$MediaDurationSeconds = 3", + "MediaDurationSeconds = $MediaDurationSeconds", + "$profile = 'GeneralProfile.Verbose'", + "GeneralProfile\\.Verbose\\.Memory", + "@('DPC', 'Interrupt', 'WDFDPC', 'WDFInterrupt')", + "@('CSwitch', 'ReadyThread', 'SampledProfile')", + "Count -lt 2", + "Dropped Event\\s*:\\s*(?\\d+)", + "$resolvedOutput.evidence.json", + "analysisRequired = $true", + "Performance acceptance still requires WPA analysis", + } { + if !strings.Contains(contract, required) { + t.Fatalf("native performance trace contract omitted %q", required) + } + } +} + +func TestNativeWorkflowPublishesSourceBoundLiveProbes(t *testing.T) { + path := filepath.Join("..", "..", "..", ".github", "workflows", "native-ude.yml") + source, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read native workflow: %v", err) + } + contract := strings.ReplaceAll(string(source), "\r\n", "\n") + for _, required := range []string{ + "schemaVersion = 1", + "sourceRevision = $env:GITHUB_SHA.ToLowerInvariant()", + "'ViiperUdeMediaProbe.exe' = (Get-FileHash", + "'ViiperUdeInputProbe.exe' = (Get-FileHash", + "ViiperUdeLiveProbes.manifest.json", + "ViiperUdeLiveProbes-windows-amd64-${{ github.sha }}", + "$nmLines = @($nmMatches | ForEach-Object { $_.Line })", + "@($nmPatterns | Where-Object { @($nmLines -match $_).Count -eq 0 }).Count", + } { + if !strings.Contains(contract, required) { + t.Fatalf("native workflow source-bound probes omitted %q", required) + } + } + if strings.Contains(contract, "$nmText = $nmMatches -join") { + t.Fatal("native workflow joins distinct symbol lines before applying end-anchored checks") + } +} diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go new file mode 100644 index 00000000..c21f0f36 --- /dev/null +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -0,0 +1,526 @@ +package udecx + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { + root := filepath.Join("..", "..", "..") + read := func(path ...string) string { + t.Helper() + contents, err := os.ReadFile(filepath.Join(append([]string{root}, path...)...)) + if err != nil { + t.Fatalf("read %s: %v", filepath.Join(path...), err) + } + return strings.ReplaceAll(string(contents), "\r\n", "\n") + } + + workflow := read(".github", "workflows", "native-ude.yml") + for _, required := range []string{ + "workflow_dispatch:", + "New-ViiperUdeLocalTestPackage.ps1", + "[Security.Cryptography.X509Certificates.X509Store]::new(", + "ViiperNativeCertificateStore", + "CertAddEncodedCertificateToStore(", + "CertFindCertificateInStore(", + "CertDeleteCertificateFromStore(found)", + "CERT_STORE_ADD_NEW", + "$addedTrust += $storeName", + "CERT_SYSTEM_STORE_LOCAL_MACHINE", + "[Security.Cryptography.X509Certificates.StoreName]::Root", + "[Security.Cryptography.X509Certificates.StoreName]::TrustedPublisher", + "[Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine", + "foreach ($storeName in $addedTrust)", + "$cleanupErrors.Add(", + "$certificate.Dispose()", + "-BrokerPath native/udecx/x64/Release/viiper.exe", + "ViiperUde-x64-local-test-${{ github.sha }}", + "path: native/udecx/x64/Release/ViiperUdeLocalTest/**", + "retention-days: 7", + } { + if !strings.Contains(workflow, required) { + t.Fatalf("local-test workflow omitted %q", required) + } + } + for _, forbidden := range []string{ + "native/udecx/x64/Release/**", + "native/udecx/driver/x64/Release/**", + "native/udecx/package/x64/Release/**", + "$store.Add($certificate)", + "$store.Remove($exactMatch[0])", + "certutil.exe", + "Invoke-BoundedCertUtil", + "CERT_SYSTEM_STORE_CURRENT_USER", + "[Security.Cryptography.X509Certificates.StoreLocation]::CurrentUser", + } { + if strings.Contains(workflow, forbidden) { + t.Fatalf("local-test workflow uploads broad build tree %q", forbidden) + } + } + + composer := read("native", "udecx", "tools", "New-ViiperUdeLocalTestPackage.ps1") + for _, required := range []string{ + "[string]$BrokerPath", + "[string]$TestCertificatePath", + "$certificateSha256 = Get-CertificateSha256 $expectedCertificate", + "Resolve-ExactInput $BrokerPath 'viiper.exe'", + "signingRoute = 'LocalTest'", + "releaseEligible = $false", + "testSignerCertificateSha256", + "installerScriptSha256", + "-ValidationMode LocalTest", + "-RequireLocalTestToolchainValidation", + "local-test-package.lock.json", + "Local test package lock SHA-256: $lockSha256", + "$broker native-package-install --help", + "$expectedBrokerFlags", + "$broker native-package-broker-commit --help", + "$expectedBrokerCommitFlags", + "'--expected-token-sha-256'", + "'--expected-broker-sha-256'", + "$helper verify (Join-Path $driverDirectory 'ViiperUde.inf')", + "result=success operation=verify changed=0 rebootRequired=0 rollback=not-needed exitCode=0", + } { + if !strings.Contains(composer, required) { + t.Fatalf("local-test composer omitted %q", required) + } + } + + installer := read("native", "udecx", "tools", "Install-ViiperUdeLocalTest.ps1") + for _, required := range []string{ + "[string]$TargetUserSID", + "[string]$ExpectedPackageLockSHA256", + "$installerScriptStream", + "$lock.installerScriptSha256 -cne $actualInstallerScriptSha256", + "$lockAlgorithm.ComputeHash($lockBytes)", + "@(Compare-Object -ReferenceObject $wanted -DifferenceObject $actual -CaseSensitive).Count", + "out-of-band workflow digest", + "O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)", + "[IO.Directory]::CreateDirectory($Path, $expectedSecurity)", + "$directory.SetAccessControl($expectedSecurity)", + "Assert-ProtectedStagingDirectory", + "$actualSecurity.AreAccessRulesProtected", + "$actualSecurity.GetOwner([Security.Principal.SecurityIdentifier])", + "$actualSecurity.GetAccessRules(", + "@('S-1-5-18', 'S-1-5-32-544')", + "[Security.AccessControl.FileSystemRights]::FullControl", + "[Security.AccessControl.InheritanceFlags]::ContainerInherit", + "[Security.AccessControl.InheritanceFlags]::ObjectInherit", + "Copy-ExactBrokerToProtectedStage", + "[IO.FileShare]::Read", + "[IO.FileOptions]::WriteThrough", + "$lockByPath['viiper.exe']", + "Remove-ProtectedStagingDirectory", + "Remove-PreBootProtectedStagingDirectories", + "public static class ViiperWindowsUptime", + "public static extern ulong GetTickCount64();", + "Get-WindowsBootBoundaryUtc", + "$_.LastWriteTimeUtc -lt $bootBoundaryUtc", + "Invoke-JoinedNativeProcess", + "if (-not $process.Start())", + "$Started.Value = $true", + "$process.WaitForExit()", + "$retainTrustOnFailure = $processStarted", + "'--expected-broker-sha-256', $brokerHash", + "'--expected-helper-sha-256', $helperHash", + "'--expected-manifest-sha-256', $manifestHash", + "'--expected-inf-sha-256', $infHash", + "'--expected-sys-sha-256', $sysHash", + "'--expected-cat-sha-256', $catHash", + "'--target-user-sid', $TargetUserSID", + "'--driver-validation-mode', 'local-test'", + "-AcknowledgeDisposableTestMachine", + "testsigning\\s+Yes", + "Restart, rerun this identical install command", + "[switch]$PreflightOnly", + "operation=local-test-preflight", + "ViiperLocalTestCertificateStore", + "CertAddEncodedCertificateToStore(", + "CertFindCertificateInStore(", + "CertDeleteCertificateFromStore(found)", + "CERT_STORE_ADD_NEW", + "CRYPT_E_NOT_FOUND", + "Get-ExactLocalTestTrustState", + "$addedStores.Add($storeName)", + "[Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly", + "action=verify-add result=present", + "action=verify-cleanup result=absent", + "LocalMachine\\$storeName trust cleanup failed during $cleanupAction.", + "ExactSpelling = true", + "[Parameter(Mandatory = $true)][int]$ProcessExitCode", + "[string]::Join([Environment]::NewLine, [string[]]$Lines)", + "[int]::TryParse($match.Groups['exit'].Value, [ref]$proofExitCode)", + "$proofExitCode -ne $ProcessExitCode", + "-Lines $output -ProcessExitCode $exitCode", + "$certificateStoreOpenMethod = [ViiperLocalTestCertificateStore].GetMethod(", + "$certificateStoreOpenImport.ExactSpelling", + "does not bind the exact CertOpenStore entry point", + } { + if !strings.Contains(installer, required) { + t.Fatalf("local-test installer omitted %q", required) + } + } + for _, required := range []string{ + "System32\\WindowsPowerShell\\v1.0\\powershell.exe", + "-PreflightOnly", + "Windows PowerShell 5.1 local-test installer preflight failed", + } { + if !strings.Contains(composer, required) { + t.Fatalf("local-test composer omitted Windows PowerShell preflight contract %q", required) + } + } + for _, forbidden := range []string{ + "& $helperPath install", + "Test-ViiperUdeSignedPackage.ps1", + "git.exe", + "status --porcelain", + "'--expected-broker-sha256'", + "'--expected-helper-sha256'", + "'--expected-manifest-sha256'", + "'--expected-inf-sha256'", + "'--expected-sys-sha256'", + "'--expected-cat-sha256'", + "GetSecurityDescriptorBinaryForm", + "BinaryLength", + "$store.Add($certificate)", + "$store.Remove(", + "[Environment]::TickCount64", + "[Environment]::TickCount", + } { + if strings.Contains(installer, forbidden) { + t.Fatalf("local-test elevated path retained unsafe dependency %q", forbidden) + } + } + + cleanupStart := strings.Index(installer, "function Remove-NewLocalTestTrust") + cleanupEnd := strings.Index(installer, "function Test-SettledLocalTestFailure") + if cleanupStart < 0 || cleanupEnd <= cleanupStart { + t.Fatal("local-test installer trust cleanup function is missing or malformed") + } + cleanup := installer[cleanupStart:cleanupEnd] + remove := strings.Index(cleanup, "[ViiperLocalTestCertificateStore]::Remove(") + verify := strings.LastIndex(cleanup, "Get-ExactLocalTestTrustState -StoreName $storeName") + absence := strings.Index(cleanup, "if ($cleanupState.ExactCount -ne 0)") + if remove < 0 || verify <= remove || absence <= verify { + t.Fatal("local-test installer does not verify persisted exact-certificate absence after native removal") + } + if strings.Count(cleanup, "catch {") != 1 || + strings.Index(cleanup, "$removalErrors.Add(") < strings.Index(cleanup, "catch {") { + t.Fatal("local-test installer does not independently aggregate per-store cleanup failures") + } + preflightStart := strings.Index(installer, "if ($PreflightOnly) {") + interopCompile := strings.Index(installer, "if (-not ('ViiperLocalTestCertificateStore' -as [type])) {") + interopVerify := strings.Index(installer, "$certificateStoreOpenMethod = [ViiperLocalTestCertificateStore].GetMethod(") + preflightSuccess := strings.Index(installer, + "Write-Output 'result=success operation=local-test-preflight changed=0 rebootRequired=0 rollback=not-needed exitCode=0'") + trustAddCall := strings.Index(installer, "[ViiperLocalTestCertificateStore]::Add(") + trustRemoveCall := strings.Index(installer, "[ViiperLocalTestCertificateStore]::Remove(") + if preflightStart < 0 || interopCompile <= preflightStart || interopVerify <= interopCompile || + preflightSuccess <= interopVerify || trustAddCall <= preflightSuccess || + trustRemoveCall <= preflightSuccess { + t.Fatal("local-test preflight can return success before compiling and inspecting the exact certificate-store interop") + } + if strings.Contains(installer[preflightStart:interopCompile], "return") { + t.Fatal("local-test preflight can return before compiling the exact certificate-store interop") + } + preflightCleanup := strings.Index(installer[preflightStart:preflightSuccess], + "Remove-PreBootProtectedStagingDirectories") + preflightOldAssertion := strings.Index(installer[preflightStart:preflightSuccess], + "Pre-boot protected staging cleanup did not remove its test directory.") + preflightCurrentAssertion := strings.Index(installer[preflightStart:preflightSuccess], + "Pre-boot protected staging cleanup removed a same-boot test directory.") + if preflightCleanup < 0 || preflightOldAssertion <= preflightCleanup || + preflightCurrentAssertion <= preflightOldAssertion { + t.Fatal("local-test preflight does not execute both sides of pre-boot staging cleanup") + } + settledStart := strings.Index(installer, "function Test-SettledLocalTestFailure") + settledEnd := strings.Index(installer, "$trustCommitted = $false") + if settledStart < 0 || settledEnd <= settledStart { + t.Fatal("local-test installer settled-failure predicate is missing or malformed") + } + settled := installer[settledStart:settledEnd] + if strings.Contains(settled, "$Lines | Out-String") { + t.Fatal("local-test installer formats and host-wraps native settled-failure proof before parsing") + } + joinLines := strings.Index(settled, "[string]::Join([Environment]::NewLine, [string[]]$Lines)") + parseProof := strings.Index(settled, "[regex]::Matches($proofText, $pattern)") + parseExit := strings.Index(settled, "[int]::TryParse($match.Groups['exit'].Value, [ref]$proofExitCode)") + bindExit := strings.Index(settled, "$proofExitCode -ne $ProcessExitCode") + classify := strings.Index(settled, "$match.Groups['changed'].Value -ceq '0'") + if joinLines < 0 || parseProof <= joinLines || parseExit <= parseProof || + bindExit <= parseExit || classify <= bindExit { + t.Fatal("local-test installer classifies settled proof before binding it to the observed child exit") + } + + packageCommand := read("internal", "cmd", "native_package.go") + packageWindows := read("internal", "cmd", "native_package_windows.go") + helperSource := read("native", "udecx", "tools", "ViiperUdeCtl.cpp") + for _, required := range []string{ + "BuildBrokerCommitCommandLine(", + `L" --expected-token-sha-256 "`, + `L" --expected-broker-sha-256 "`, + `L"self-test-broker-command"`, + } { + if !strings.Contains(helperSource, required) { + t.Fatalf("native helper omitted nested broker command contract %q", required) + } + } + for _, obsolete := range []string{ + `L" --expected-token-sha256 "`, + `L" --expected-broker-sha256 "`, + } { + if strings.Contains(helperSource, obsolete) { + t.Fatalf("native helper retained obsolete nested broker option %q", obsolete) + } + } + for _, required := range []string{ + `default:"production" enum:"production,local-test"`, + `r.driverValidationMode != "production" && r.driverValidationMode != "local-test"`, + } { + if !strings.Contains(packageCommand, required) { + t.Fatalf("native package command omitted %q", required) + } + } + if !strings.Contains(packageWindows, + `"--validation-mode", t.request.driverValidationMode`) { + t.Fatal("native package transaction does not pass the validated signature route to its retained helper") + } + if !strings.Contains(helperSource, + `if (!SetupGetStringFieldW(&context, field, nullptr, 0, &required) ||`) { + t.Fatal("native helper does not honor SetupGetStringFieldW's successful size-query contract") + } + if strings.Contains(helperSource, + "SetupGetStringFieldW(&context, field, nullptr, 0, &required);\n"+ + " if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER)") { + t.Fatal("native helper still treats a successful SetupGetStringFieldW size query as failure") + } + if strings.Count(helperSource, + "code != ERROR_AUTHENTICODE_TRUSTED_PUBLISHER") != 1 { + t.Fatal("native helper does not retain SetupAPI's exact trusted-Authenticode classification for installed packages") + } + for _, required := range []string{ + "bool allowUntrustedLocalTestRoot", + "allowUntrustedLocalTestRoot &&", + "status == static_cast(CERT_E_UNTRUSTEDROOT)", + "VerifyDriverCatalogMember(catalogPath, infPath, true, error)", + "VerifyDriverCatalogMember(catalogPath, infPath, false, error)", + } { + if !strings.Contains(helperSource, required) { + t.Fatalf("native helper omitted scoped pre-trust catalog policy %q", required) + } + } + if strings.Contains(helperSource, + "ERROR_AUTHENTICODE_TRUST_NOT_ESTABLISHED") { + t.Fatal("native helper accepts an Authenticode publisher that is not in TrustedPublisher") + } + if !strings.Contains(helperSource, + "GUID action = WINTRUST_ACTION_GENERIC_VERIFY_V2;") { + t.Fatal("native helper does not use Authenticode policy for exact catalog-member verification") + } + if strings.Contains(helperSource, + "GUID action = DRIVER_ACTION_VERIFY;") { + t.Fatal("native helper incorrectly uses the WHQL-only policy for test catalog membership") + } +} + +func TestLocalTestSettledFailureRequiresObservedExitMatch(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows PowerShell contract") + } + + root := filepath.Join("..", "..", "..") + installer, err := filepath.Abs(filepath.Join( + root, "native", "udecx", "tools", "Install-ViiperUdeLocalTest.ps1")) + if err != nil { + t.Fatalf("resolve local-test installer: %v", err) + } + powerShell := filepath.Join( + os.Getenv("SystemRoot"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe") + if _, err := os.Stat(powerShell); err != nil { + t.Fatalf("locate Windows PowerShell: %v", err) + } + + const behaviorContract = ` +$ErrorActionPreference = 'Stop' +$source = Get-Content -LiteralPath $env:VIIPER_INSTALLER_CONTRACT_PATH -Raw +$csharpBlocks = @([regex]::Matches( + $source, "(?s)Add-Type -Language CSharp -TypeDefinition @'\r?\n(?.*?)\r?\n'@") | + ForEach-Object { $_.Groups['source'].Value } | + Where-Object { $_ -match 'public static class ViiperLocalTestCertificateStore' }) +if ($csharpBlocks.Count -ne 1) { throw 'Embedded certificate-store source was not found exactly once.' } +Add-Type -Language CSharp -TypeDefinition $csharpBlocks[0] +$openStore = [ViiperLocalTestCertificateStore].GetMethod( + 'CertOpenStore', [Reflection.BindingFlags]'NonPublic,Static') +$import = $openStore.GetCustomAttributes( + [Runtime.InteropServices.DllImportAttribute], $false)[0] +if ($import.Value -cne 'crypt32.dll' -or -not $import.ExactSpelling -or + $import.CharSet -ne [Runtime.InteropServices.CharSet]::Unicode) { + throw 'CertOpenStore P/Invoke metadata does not name the exact native entry point.' +} + +$start = $source.IndexOf('function Test-SettledLocalTestFailure') +$end = $source.IndexOf('$trustCommitted = $false', $start) +if ($start -lt 0 -or $end -le $start) { throw 'Settled-failure predicate was not found.' } +Invoke-Expression $source.Substring($start, $end - $start) +$settled = @( + 'VIIPER: error: install native driver and broker transaction: native driver helper failed with exit 1: exit status 1:', + ('result=error operation=install changed=1 rebootRequired=0 rollback=succeeded exitCode=1 ' + + 'phase="broker-preflight" win32Error=1603 nestedExitCode=4 ' + + 'message="nested broker transaction failed after proving a settled state; nested diagnostic: ' + + 'lock package transaction token: The process cannot access the file because it is being used by another process."') +) +if ($settled[1].Length -le 120) { + throw 'Settled proof fixture does not exceed the live host width.' +} +if (-not (Test-SettledLocalTestFailure -Lines $settled -ProcessExitCode 1)) { + throw 'Matching long settled proof was rejected.' +} +$retainTrustOnFailure = $true +if (Test-SettledLocalTestFailure -Lines $settled -ProcessExitCode 1) { + $retainTrustOnFailure = $false +} +if ($retainTrustOnFailure) { + throw 'Matching long settled proof did not authorize trust removal.' +} +$cleanupCalls = 0 +$trustCommitted = $false +try { + throw 'simulated post-process transaction failure' +} +catch { + if (-not $trustCommitted -and -not $retainTrustOnFailure) { + $cleanupCalls++ + } +} +if ($cleanupCalls -ne 1) { + throw 'Settled rollback did not enter the trust-cleanup branch exactly once.' +} +$retainTrustOnFailure = $true +if (Test-SettledLocalTestFailure -Lines $settled -ProcessExitCode 4) { + $retainTrustOnFailure = $false +} +if (-not $retainTrustOnFailure) { + throw 'Mismatched proof exit incorrectly authorized trust removal.' +} +$preflight = @( + 'result=error operation=install changed=0 rebootRequired=0 rollback=not-needed exitCode=4 phase="preflight"' +) +if (-not (Test-SettledLocalTestFailure -Lines $preflight -ProcessExitCode 4)) { + throw 'Matching settled preflight proof was rejected.' +} +if (Test-SettledLocalTestFailure -Lines $preflight -ProcessExitCode 1) { + throw 'Mismatched preflight proof was accepted.' +} +` + command := exec.Command( + powerShell, "-NoProfile", "-NonInteractive", "-Command", behaviorContract) + command.Env = append(os.Environ(), "VIIPER_INSTALLER_CONTRACT_PATH="+installer) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("settled-failure behavior contract failed: %v\n%s", err, output) + } +} + +func TestLocalTestBootBoundaryRunsOnWindowsPowerShell51(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows PowerShell contract") + } + + root := filepath.Join("..", "..", "..") + installer, err := filepath.Abs(filepath.Join( + root, "native", "udecx", "tools", "Install-ViiperUdeLocalTest.ps1")) + if err != nil { + t.Fatalf("resolve local-test installer: %v", err) + } + powerShell := filepath.Join( + os.Getenv("SystemRoot"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe") + if _, err := os.Stat(powerShell); err != nil { + t.Fatalf("locate Windows PowerShell: %v", err) + } + + const behaviorContract = ` +$ErrorActionPreference = 'Stop' +$source = Get-Content -LiteralPath $env:VIIPER_INSTALLER_CONTRACT_PATH -Raw +$csharpBlocks = @([regex]::Matches( + $source, "(?s)Add-Type -Language CSharp -TypeDefinition @'\r?\n(?.*?)\r?\n'@") | + ForEach-Object { $_.Groups['source'].Value } | + Where-Object { $_ -match 'public static class ViiperWindowsUptime' }) +if ($csharpBlocks.Count -ne 1) { throw 'Embedded Windows-uptime source was not found exactly once.' } +Add-Type -Language CSharp -TypeDefinition $csharpBlocks[0] +$start = $source.IndexOf('function Get-WindowsBootBoundaryUtc') +$end = $source.IndexOf('function Remove-PreBootProtectedStagingDirectories', $start) +if ($start -lt 0 -or $end -le $start) { throw 'Windows boot-boundary function was not found.' } +Invoke-Expression $source.Substring($start, $end - $start) +$before = [DateTime]::UtcNow +$boundary = Get-WindowsBootBoundaryUtc +$after = [DateTime]::UtcNow +$uptime = [TimeSpan]::FromMilliseconds([double][ViiperWindowsUptime]::GetTickCount64()) +$lower = $before.Subtract($uptime).AddSeconds(-2) +$upper = $after.Subtract($uptime).AddSeconds(2) +if ($boundary.Kind -ne [DateTimeKind]::Utc -or + $boundary -lt $lower -or $boundary -gt $upper) { + throw "Windows boot boundary was outside the native uptime interval: $boundary" +} +if ($PSVersionTable.PSEdition -cne 'Desktop' -or $PSVersionTable.PSVersion.Major -ne 5) { + throw "Expected Windows PowerShell 5.1, got $($PSVersionTable.PSVersion)." +} +` + command := exec.Command( + powerShell, "-NoProfile", "-NonInteractive", "-Command", behaviorContract) + command.Env = append(os.Environ(), "VIIPER_INSTALLER_CONTRACT_PATH="+installer) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("Windows PowerShell boot-boundary contract failed: %v\n%s", err, output) + } +} + +func TestLocalTestValidationCannotWeakenProduction(t *testing.T) { + root := filepath.Join("..", "..", "..", "native", "udecx", "tools") + contents, err := os.ReadFile(filepath.Join(root, "Test-ViiperUdeSignedPackage.ps1")) + if err != nil { + t.Fatalf("read signed-package validator: %v", err) + } + contract := strings.ReplaceAll(string(contents), "\r\n", "\n") + for _, required := range []string{ + "[ValidateSet('LocalTest', 'ControlledTest', 'Production')]", + "Invoke-BoundedValidationTool", + "Get-BoundedAuthenticodeSignature", + "'-NoProfile', '-NonInteractive', '-EncodedCommand'", + "CREATE_SUSPENDED | CREATE_NO_WINDOW", + "JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE", + "new AnonymousPipeServerStream(", + "QueryInformationJobObject(", + "AssignProcessToJobObject(job, process.hProcess)", + "ResumeThread(process.hThread)", + "TerminateJobObject(job, 1)", + "WaitForJobEmpty(job, remaining)", + "Task.WaitAll(outputTasks, 10000)", + "$ValidationMode -eq 'LocalTest'", + "testSignerCertificateSha256", + "Production validation requires a release-eligible HLK/WHCP", + "HLK/WHCP", + "Assert-DriverSignature", + "Microsoft Corporation", + "$requireExternalTools = $ValidationMode -ne 'LocalTest' -or $RequireLocalTestToolchainValidation", + } { + if !strings.Contains(contract, required) { + t.Fatalf("signature route separation omitted %q", required) + } + } + assign := strings.Index(contract, "AssignProcessToJobObject(job, process.hProcess)") + resume := strings.Index(contract, "ResumeThread(process.hThread)") + if assign < 0 || resume < 0 || assign > resume { + t.Fatal("validation child is not assigned to its private job while still suspended") + } + for _, forbidden := range []string{ + "& $signTool.Source verify", + "& $infVerif.Source", + } { + if strings.Contains(contract, forbidden) { + t.Fatalf("signature validation retained unbounded child execution %q", forbidden) + } + } +} diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go new file mode 100644 index 00000000..6c752192 --- /dev/null +++ b/internal/transport/udecx/protocol.go @@ -0,0 +1,959 @@ +// Package udecx defines the user-mode half of the native VIIPER UdeCx ABI. +// It intentionally has no Windows dependency so layout and fuzz tests run on +// every supported development host. +package udecx + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "math" + "strconv" + "strings" +) + +const ( + Magic uint32 = 0x45445556 + ABIMajor uint16 = 1 + ABIMinor uint16 = 14 + // DriverPackageVersion is the native driver package version built and + // shipped with this service. Runtime negotiation proves the loaded driver + // carries this version in its source-bound build identity; package + // installation additionally verifies DriverVer and the signed catalog. + DriverPackageVersion = "0.1.0.38" + BuildIdentitySize = sha256.Size + + HeaderSize = 16 + NegotiateRequestSize = 32 + NegotiateResponseSize = 88 + DescriptorRecordSize = 16 + CreateDeviceSize = 56 + CreateDeviceResultSize = 40 + DeviceIdentitySize = 32 + IsoPacketSize = 16 + OperationSize = 108 + CompletionSize = 72 + InputReportSize = 52 + StatsSize = 152 + LifecycleTraceRecordSize = 80 + LifecycleTraceSize = 41008 + LifecycleTraceCapacity = 512 + + MaxDevices = 32 + MaxDescriptorBytes = 256 * 1024 + MaxTransferBytes = 1024 * 1024 + MaxIsoPackets = 1024 + MaxInputReportBytes = 4096 + MaxPendingOperations = 4096 + ManagementSlotFlag uint32 = 0x80000000 + InputReportTransition uint8 = 0x01 + // TransferFlagDirectionIn is the wire value of + // USBD_TRANSFER_DIRECTION_IN from usb.h. + TransferFlagDirectionIn uint32 = 0x00000001 + // TransferFlagStartIsoASAP is the wire value of + // USBD_START_ISO_TRANSFER_ASAP from usb.h. + TransferFlagStartIsoASAP uint32 = 0x00000004 + // USBDStatusBadStartFrame is the wire value of + // USBD_STATUS_BAD_START_FRAME from the Microsoft WDK usb.h contract. + USBDStatusBadStartFrame uint32 = 0xC0000A00 + + MicrosoftOS10StringIndex = 0x00EE + MicrosoftOS10StringLength = 18 + MicrosoftOS10VendorCodeOffset = 16 +) + +var ( + ErrShortMessage = errors.New("native UDE message is shorter than its fixed header") + ErrBadMagic = errors.New("native UDE message has an invalid magic value") + ErrIncompatibleMajor = errors.New("native UDE ABI major version is incompatible") + ErrIncompatibleMinor = errors.New("native UDE ABI minor version is incompatible") + ErrIncompatibleABI = errors.New("native UDE service and driver ABIs are incompatible") + ErrInvalidSize = errors.New("native UDE message size is invalid") + ErrInvalidRange = errors.New("native UDE message contains an invalid range") + ErrLimitExceeded = errors.New("native UDE message exceeds a negotiated limit") + ErrBuildIdentity = errors.New("native UDE build identity is unavailable or invalid") + ErrInputQueueFull = errors.New("native UDE input transition queue is full") +) + +type Capabilities uint32 + +const ( + CapabilityIsochronous Capabilities = 1 << iota + CapabilityStreams + CapabilityDeviceLifecycle + CapabilityInputReports + CapabilityLifecycleTrace + CapabilityDeviceCorrelation +) + +const AdvertisedCapabilities = CapabilityIsochronous | CapabilityDeviceLifecycle | + CapabilityInputReports | CapabilityLifecycleTrace | CapabilityDeviceCorrelation + +const ( + TraceSourceDevice uint8 = iota + 1 + TraceSourceBroker + TraceSourceController +) + +const ( + TraceCreateBegin uint16 = iota + 1 + TraceDeviceCreateReturned + TraceDeviceSlotClaimed + TracePlugInBegin + TracePlugInReturned + TraceRemoveClaimed + TraceManagementAbortBegin + TraceManagementAbortEnd + TracePlugOutBegin + TracePlugOutReturned + TraceEndpointPurgeBegin + TraceEndpointOperationsPurged + TraceEndpointQueuePurgeRequested + TraceEndpointDriverQuiescent + TraceEndpointDrainBegin + TraceEndpointDrainEnd + TraceEndpointPurgeCompleteBegin + TraceEndpointPurgeCompleteEnd + TraceEndpointCleanupBegin + TraceEndpointCleanupEnd + TraceDeviceCleanupBegin + TraceDeviceCleanupEnd + TraceControllerShutdownBegin + TraceControllerShutdownEnd + TraceEndpointQuiescenceWatchdog + TraceCompletionRundownWatchdog + TraceControllerRundownWatchdog + TraceOwnerRundownWatchdog +) + +type LifecycleTraceStatus uint32 + +const ( + LifecycleTraceStatusDroppedRecord LifecycleTraceStatus = 1 << iota + LifecycleTraceStatusWatchdogFired + lifecycleTraceStatusValidMask = LifecycleTraceStatusDroppedRecord | + LifecycleTraceStatusWatchdogFired +) + +// nativeSourceRevision must be injected by the production build. Native +// transport startup deliberately has no VCS/on-disk fallback: the broker and +// loaded kernel image must derive their identities from the same explicit +// source-bound build input. +var nativeSourceRevision string + +// DeriveBuildIdentity returns the source/package/ABI/capability identity that +// is embedded in the native driver and compared during negotiation. The exact +// UTF-8 preimage is also implemented by Get-ViiperUdeBuildIdentity.ps1 and the +// package helper; changing it requires another ABI revision. +func DeriveBuildIdentity(sourceRevision, driverPackageVersion string, abiMajor, abiMinor uint16, capabilities Capabilities) ([BuildIdentitySize]byte, error) { + var zero [BuildIdentitySize]byte + if sourceRevision != strings.TrimSpace(sourceRevision) { + return zero, fmt.Errorf("%w: source revision must not contain surrounding whitespace", ErrBuildIdentity) + } + revision := strings.ToLower(sourceRevision) + if len(revision) != 40 && len(revision) != 64 { + return zero, fmt.Errorf("%w: source revision must be exactly 40 or 64 hexadecimal digits", ErrBuildIdentity) + } + if _, err := hex.DecodeString(revision); err != nil { + return zero, fmt.Errorf("%w: source revision: %v", ErrBuildIdentity, err) + } + versionParts := strings.Split(driverPackageVersion, ".") + if len(versionParts) != 4 { + return zero, fmt.Errorf("%w: driver package version must contain four numeric parts", ErrBuildIdentity) + } + for _, part := range versionParts { + if part == "" { + return zero, fmt.Errorf("%w: driver package version contains an empty part", ErrBuildIdentity) + } + for _, character := range part { + if character < '0' || character > '9' { + return zero, fmt.Errorf("%w: driver package version is not numeric", ErrBuildIdentity) + } + } + } + if abiMajor == 0 || capabilities == 0 { + return zero, fmt.Errorf("%w: ABI major and capabilities must be nonzero", ErrBuildIdentity) + } + preimage := fmt.Sprintf( + "VIIPER-UDE-BUILD-IDENTITY/v1\nsourceRevision=%s\ndriverPackageVersion=%s\nabi=%d.%d\ncapabilities=0x%08x\n", + revision, driverPackageVersion, abiMajor, abiMinor, uint32(capabilities), + ) + return sha256.Sum256([]byte(preimage)), nil +} + +func ExpectedBuildIdentity() ([BuildIdentitySize]byte, error) { + if strings.TrimSpace(nativeSourceRevision) == "" { + return [BuildIdentitySize]byte{}, fmt.Errorf( + "%w: production build did not inject VIIPER native source revision", ErrBuildIdentity) + } + return DeriveBuildIdentity(nativeSourceRevision, DriverPackageVersion, + ABIMajor, ABIMinor, AdvertisedCapabilities) +} + +func BuildIdentityHex(identity [BuildIdentitySize]byte) string { + return hex.EncodeToString(identity[:]) +} + +func IsCanonicalControllerInstanceID(value string) bool { + const prefix = `ROOT\VIIPERUDE\` + if len(value) != len(prefix)+4 || !strings.EqualFold(value[:len(prefix)], prefix) { + return false + } + for _, digit := range value[len(prefix):] { + if digit < '0' || digit > '9' { + return false + } + } + return true +} + +// IsCanonicalControllerSessionID accepts only the exact decimal encoding +// emitted by strconv.FormatUint for a nonzero kernel session nonce. This +// avoids lossy JSON-number handling and alternate textual identities. +func IsCanonicalControllerSessionID(value string) bool { + parsed, err := strconv.ParseUint(value, 10, 64) + return err == nil && parsed != 0 && strconv.FormatUint(parsed, 10) == value +} + +type Header struct { + Magic uint32 + Major uint16 + Minor uint16 + Size uint32 + Flags uint32 +} + +func NewHeader(size int) (Header, error) { + if size < HeaderSize || uint64(size) > math.MaxUint32 { + return Header{}, ErrInvalidSize + } + return Header{Magic: Magic, Major: ABIMajor, Minor: ABIMinor, Size: uint32(size)}, nil +} + +func ParseHeader(src []byte) (Header, error) { + if len(src) < HeaderSize { + return Header{}, ErrShortMessage + } + h := Header{ + Magic: binary.LittleEndian.Uint32(src[0:4]), + Major: binary.LittleEndian.Uint16(src[4:6]), + Minor: binary.LittleEndian.Uint16(src[6:8]), + Size: binary.LittleEndian.Uint32(src[8:12]), + Flags: binary.LittleEndian.Uint32(src[12:16]), + } + if h.Magic != Magic { + return Header{}, ErrBadMagic + } + if h.Major != ABIMajor { + return Header{}, fmt.Errorf("%w: driver=%d client=%d", ErrIncompatibleMajor, h.Major, ABIMajor) + } + if h.Minor != ABIMinor { + return Header{}, fmt.Errorf("%w: driver=%d client=%d", ErrIncompatibleMinor, h.Minor, ABIMinor) + } + if h.Flags != 0 { + return Header{}, fmt.Errorf("%w: unsupported header flags %#x", ErrInvalidRange, h.Flags) + } + if h.Size < HeaderSize || uint64(h.Size) > uint64(len(src)) { + return Header{}, ErrInvalidSize + } + return h, nil +} + +func putHeader(dst []byte, h Header) { + binary.LittleEndian.PutUint32(dst[0:4], h.Magic) + binary.LittleEndian.PutUint16(dst[4:6], h.Major) + binary.LittleEndian.PutUint16(dst[6:8], h.Minor) + binary.LittleEndian.PutUint32(dst[8:12], h.Size) + binary.LittleEndian.PutUint32(dst[12:16], h.Flags) +} + +type NegotiateRequest struct { + ClientNonce uint64 + RequestedCapabilities Capabilities +} + +func (m NegotiateRequest) MarshalBinary() ([]byte, error) { + h, err := NewHeader(NegotiateRequestSize) + if err != nil { + return nil, err + } + dst := make([]byte, NegotiateRequestSize) + putHeader(dst, h) + binary.LittleEndian.PutUint64(dst[16:24], m.ClientNonce) + binary.LittleEndian.PutUint32(dst[24:28], uint32(m.RequestedCapabilities)) + return dst, nil +} + +type NegotiateResponse struct { + ClientNonce uint64 + DriverNonce uint64 + Capabilities Capabilities + MaxDevices uint32 + MaxDescriptorBytes uint32 + MaxTransferBytes uint32 + MaxIsoPackets uint32 + MaxPendingOperations uint32 + BuildIdentity [BuildIdentitySize]byte +} + +func ParseNegotiateResponse(src []byte) (NegotiateResponse, error) { + h, err := ParseHeader(src) + if err != nil { + return NegotiateResponse{}, err + } + if h.Size != NegotiateResponseSize { + return NegotiateResponse{}, ErrInvalidSize + } + response := NegotiateResponse{ + ClientNonce: binary.LittleEndian.Uint64(src[16:24]), + DriverNonce: binary.LittleEndian.Uint64(src[24:32]), + Capabilities: Capabilities(binary.LittleEndian.Uint32(src[32:36])), + MaxDevices: binary.LittleEndian.Uint32(src[36:40]), + MaxDescriptorBytes: binary.LittleEndian.Uint32(src[40:44]), + MaxTransferBytes: binary.LittleEndian.Uint32(src[44:48]), + MaxIsoPackets: binary.LittleEndian.Uint32(src[48:52]), + MaxPendingOperations: binary.LittleEndian.Uint32(src[52:56]), + } + copy(response.BuildIdentity[:], src[56:88]) + return response, nil +} + +type DescriptorKind uint16 + +const ( + DescriptorDevice DescriptorKind = iota + 1 + DescriptorConfiguration + DescriptorBOS + DescriptorString +) + +type DescriptorRecord struct { + Kind DescriptorKind + Index uint16 + LanguageID uint16 + Offset uint32 + Length uint32 +} + +type DeviceSpeed uint32 + +const ( + DeviceSpeedLow DeviceSpeed = iota + 1 + DeviceSpeedFull + DeviceSpeedHigh + DeviceSpeedSuper +) + +type DeviceIdentity struct { + DeviceID uint64 + Generation uint32 +} + +// CreateDeviceResult is the kernel-authored receipt for the exact successful +// UdeCx plug-in. Exactly one port number is nonzero. It is deliberately not +// inferred from descriptors or Windows enumeration order. +type CreateDeviceResult struct { + DeviceID uint64 + Generation uint32 + Speed DeviceSpeed + USB20PortNumber uint32 + USB30PortNumber uint32 +} + +// DeviceRegistration binds the kernel receipt to the exact controller +// devnode whose exclusive interface handle produced it. ControllerInstanceID +// is queried from that interface while the handle is held, so callers can +// correlate HID and UAC descendants without a VID/PID or enumeration-order +// fallback. +type DeviceRegistration struct { + DeviceIdentity + Speed DeviceSpeed + USB20PortNumber uint32 + USB30PortNumber uint32 + ControllerSessionID uint64 + ControllerInstanceID string +} + +func ParseCreateDeviceResult(src []byte) (CreateDeviceResult, error) { + h, err := ParseHeader(src) + if err != nil { + return CreateDeviceResult{}, err + } + if h.Size != CreateDeviceResultSize || len(src) != CreateDeviceResultSize { + return CreateDeviceResult{}, ErrInvalidSize + } + result := CreateDeviceResult{ + DeviceID: binary.LittleEndian.Uint64(src[16:24]), + Generation: binary.LittleEndian.Uint32(src[24:28]), + Speed: DeviceSpeed(binary.LittleEndian.Uint32(src[28:32])), + USB20PortNumber: binary.LittleEndian.Uint32(src[32:36]), + USB30PortNumber: binary.LittleEndian.Uint32(src[36:40]), + } + if result.DeviceID == 0 || result.Generation == 0 || + result.Speed < DeviceSpeedLow || result.Speed > DeviceSpeedSuper || + (result.USB20PortNumber == 0) == (result.USB30PortNumber == 0) { + return CreateDeviceResult{}, ErrInvalidRange + } + if result.Speed == DeviceSpeedSuper { + if result.USB20PortNumber != 0 || result.USB30PortNumber <= MaxDevices || + result.USB30PortNumber > 2*MaxDevices { + return CreateDeviceResult{}, ErrInvalidRange + } + } else if result.USB30PortNumber != 0 || result.USB20PortNumber > MaxDevices { + return CreateDeviceResult{}, ErrInvalidRange + } + return result, nil +} + +func (m DeviceIdentity) MarshalBinary() ([]byte, error) { + if m.DeviceID == 0 || m.Generation == 0 { + return nil, fmt.Errorf("%w: zero device identity", ErrInvalidRange) + } + h, err := NewHeader(DeviceIdentitySize) + if err != nil { + return nil, err + } + dst := make([]byte, DeviceIdentitySize) + putHeader(dst, h) + binary.LittleEndian.PutUint64(dst[16:24], m.DeviceID) + binary.LittleEndian.PutUint32(dst[24:28], m.Generation) + return dst, nil +} + +type CreateDevice struct { + DeviceID uint64 + Generation uint32 + Speed DeviceSpeed + MaxPendingOperations uint32 + Descriptors []DescriptorRecord + DescriptorData []byte +} + +func (m CreateDevice) MarshalBinary() ([]byte, error) { + if m.DeviceID == 0 || m.Generation == 0 { + return nil, fmt.Errorf("%w: zero device identity", ErrInvalidRange) + } + if len(m.Descriptors) == 0 || len(m.DescriptorData) == 0 { + return nil, fmt.Errorf("%w: empty descriptor set", ErrInvalidRange) + } + if len(m.DescriptorData) > MaxDescriptorBytes || len(m.Descriptors) > MaxDescriptorBytes/DescriptorRecordSize { + return nil, ErrLimitExceeded + } + recordBytes := len(m.Descriptors) * DescriptorRecordSize + total := CreateDeviceSize + recordBytes + len(m.DescriptorData) + if uint64(total) > math.MaxUint32 { + return nil, ErrLimitExceeded + } + h, err := NewHeader(total) + if err != nil { + return nil, err + } + dst := make([]byte, total) + putHeader(dst, h) + binary.LittleEndian.PutUint64(dst[16:24], m.DeviceID) + binary.LittleEndian.PutUint32(dst[24:28], m.Generation) + binary.LittleEndian.PutUint32(dst[28:32], uint32(m.Speed)) + binary.LittleEndian.PutUint32(dst[32:36], uint32(len(m.Descriptors))) + binary.LittleEndian.PutUint32(dst[36:40], CreateDeviceSize) + binary.LittleEndian.PutUint32(dst[40:44], uint32(CreateDeviceSize+recordBytes)) + binary.LittleEndian.PutUint32(dst[44:48], uint32(len(m.DescriptorData))) + binary.LittleEndian.PutUint32(dst[48:52], m.MaxPendingOperations) + + for i, record := range m.Descriptors { + if !validRange(record.Offset, record.Length, uint32(len(m.DescriptorData))) { + return nil, fmt.Errorf("%w: descriptor %d", ErrInvalidRange, i) + } + off := CreateDeviceSize + i*DescriptorRecordSize + binary.LittleEndian.PutUint16(dst[off:off+2], uint16(record.Kind)) + binary.LittleEndian.PutUint16(dst[off+2:off+4], record.Index) + binary.LittleEndian.PutUint16(dst[off+4:off+6], record.LanguageID) + binary.LittleEndian.PutUint32(dst[off+8:off+12], record.Offset) + binary.LittleEndian.PutUint32(dst[off+12:off+16], record.Length) + } + copy(dst[CreateDeviceSize+recordBytes:], m.DescriptorData) + return dst, nil +} + +type OperationKind uint32 + +const ( + OperationControl OperationKind = iota + 1 + OperationTransfer + OperationEndpointStart + OperationEndpointPurge + OperationEndpointReset + OperationDeviceReset + OperationSetInterface + OperationDeviceD0Entry + OperationDeviceD0Exit + OperationCancel + OperationBrokerFault +) + +type IsoPacket struct { + Offset uint32 + Length uint32 + Status int32 +} + +type Operation struct { + Token uint64 + DeviceID uint64 + Generation uint32 + Kind OperationKind + EndpointAddress uint8 + Direction uint8 + InterfaceNumber uint8 + InterfaceSetting uint8 + EndpointAttributes uint8 + EndpointInterval uint8 + EndpointMaxPacketSize uint16 + URBFunction uint32 + TransferFlags uint32 + StartFrame uint32 + TransferLength uint32 + SetupPacket [8]byte + IsoPackets []IsoPacket + Payload []byte + EndpointSequence uint64 + DeviceSequence uint64 + EndpointGeneration uint32 +} + +func operationUsesEndpointGeneration(kind OperationKind) bool { + switch kind { + case OperationControl, OperationTransfer, OperationEndpointStart, + OperationEndpointPurge, OperationEndpointReset, OperationCancel: + return true + default: + return false + } +} + +func isManagementToken(token uint64) bool { + return uint32(token)&ManagementSlotFlag != 0 +} + +func operationRequiresEndpointGeneration(op Operation) bool { + if op.Kind == OperationCancel { + return !isManagementToken(op.Token) + } + return operationUsesEndpointGeneration(op.Kind) +} + +func validateOperationIdentity(op Operation) error { + if op.Kind < OperationControl || op.Kind > OperationBrokerFault { + return fmt.Errorf("%w: unknown operation kind %d", ErrInvalidRange, op.Kind) + } + if op.Kind == OperationBrokerFault { + if op.Token != 0 || op.DeviceID != 0 || op.Generation != 0 || + op.EndpointGeneration != 0 { + return fmt.Errorf("%w: broker-fault operation carries an identity", ErrInvalidRange) + } + return nil + } + if op.DeviceID == 0 || op.Generation == 0 { + return fmt.Errorf("%w: zero operation device identity", ErrInvalidRange) + } + if op.Kind == OperationCancel && op.Token == 0 { + return fmt.Errorf("%w: zero cancellation token", ErrInvalidRange) + } + if operationRequiresEndpointGeneration(op) { + if op.EndpointGeneration == 0 { + return fmt.Errorf("%w: zero operation endpoint generation", ErrInvalidRange) + } + } else if op.Kind != OperationCancel && op.EndpointGeneration != 0 { + return fmt.Errorf("%w: device-scoped operation carries an endpoint generation", ErrInvalidRange) + } + return nil +} + +func ParseOperation(src []byte) (Operation, error) { + h, err := ParseHeader(src) + if err != nil { + return Operation{}, err + } + if h.Size < OperationSize || h.Size > MaxTransferBytes+OperationSize+MaxIsoPackets*IsoPacketSize { + return Operation{}, ErrInvalidSize + } + // DeviceIoControl returns an independent byte count. Accepting an embedded + // size smaller than that count silently discards an unvalidated tail and can + // desynchronize the operation stream. A dequeued operation is one exact + // message, never a prefix of one. + if uint64(h.Size) != uint64(len(src)) { + return Operation{}, ErrInvalidSize + } + packetCount := binary.LittleEndian.Uint32(src[56:60]) + transferLength := binary.LittleEndian.Uint32(src[60:64]) + payloadOffset := binary.LittleEndian.Uint32(src[64:68]) + payloadLength := binary.LittleEndian.Uint32(src[68:72]) + isoOffset := binary.LittleEndian.Uint32(src[72:76]) + if packetCount > MaxIsoPackets || transferLength > MaxTransferBytes || payloadLength > MaxTransferBytes { + return Operation{}, ErrLimitExceeded + } + isoBytes := packetCount * IsoPacketSize + expectedPayloadOffset := uint32(OperationSize) + isoBytes + // The kernel serializer emits a single canonical tail: ISO metadata first, + // then payload, with neither gaps nor aliases. Rejecting alternate layouts + // closes overlap/header-alias ambiguity before any slice is formed. + if isoOffset != OperationSize || payloadOffset != expectedPayloadOffset || + uint64(expectedPayloadOffset)+uint64(payloadLength) != uint64(h.Size) || + !validArrayRange(isoOffset, packetCount, IsoPacketSize, h.Size) || + !validRange(payloadOffset, payloadLength, h.Size) { + return Operation{}, ErrInvalidRange + } + op := Operation{ + Token: binary.LittleEndian.Uint64(src[16:24]), + DeviceID: binary.LittleEndian.Uint64(src[24:32]), + Generation: binary.LittleEndian.Uint32(src[32:36]), + Kind: OperationKind(binary.LittleEndian.Uint32(src[36:40])), + EndpointAddress: src[40], + Direction: src[41], + InterfaceNumber: src[42], + InterfaceSetting: src[43], + EndpointAttributes: src[84], + EndpointInterval: src[85], + EndpointMaxPacketSize: binary.LittleEndian.Uint16(src[86:88]), + URBFunction: binary.LittleEndian.Uint32(src[44:48]), + TransferFlags: binary.LittleEndian.Uint32(src[48:52]), + StartFrame: binary.LittleEndian.Uint32(src[52:56]), + TransferLength: transferLength, + EndpointSequence: binary.LittleEndian.Uint64(src[88:96]), + DeviceSequence: binary.LittleEndian.Uint64(src[96:104]), + EndpointGeneration: binary.LittleEndian.Uint32(src[104:108]), + IsoPackets: make([]IsoPacket, int(packetCount)), + Payload: append([]byte(nil), src[payloadOffset:payloadOffset+payloadLength]...), + } + copy(op.SetupPacket[:], src[76:84]) + for i := range op.IsoPackets { + off := int(isoOffset) + i*IsoPacketSize + packet := IsoPacket{ + Offset: binary.LittleEndian.Uint32(src[off : off+4]), + Length: binary.LittleEndian.Uint32(src[off+4 : off+8]), + Status: int32(binary.LittleEndian.Uint32(src[off+8 : off+12])), + } + if binary.LittleEndian.Uint32(src[off+12:off+16]) != 0 || + !validRange(packet.Offset, packet.Length, transferLength) { + return Operation{}, fmt.Errorf("%w: ISO packet %d", ErrInvalidRange, i) + } + op.IsoPackets[i] = packet + } + if err := validateOperationIdentity(op); err != nil { + return Operation{}, err + } + return op, nil +} + +// parseDequeuedOperation binds the kernel's independent bytes-returned value +// to the embedded wire size before ParseOperation sees the message. Keeping +// this validation platform-neutral makes malformed completion fixtures +// deterministic on every CI host. +func parseDequeuedOperation(buffer []byte, bytesReturned uint32) (Operation, error) { + if bytesReturned < OperationSize || uint64(bytesReturned) > uint64(len(buffer)) { + return Operation{}, ErrInvalidSize + } + if binary.LittleEndian.Uint32(buffer[8:12]) != bytesReturned { + return Operation{}, ErrInvalidSize + } + return ParseOperation(buffer[:bytesReturned]) +} + +type Completion struct { + Token uint64 + DeviceID uint64 + Generation uint32 + EndpointGeneration uint32 + Status int32 + USBDStatus uint32 + // TransferLength is the number of bytes completed. For ISO-IN transfers, + // Payload may span the original gapped transfer buffer and therefore be + // larger than this sum of packet actual lengths. + TransferLength uint32 + IsoPackets []IsoPacket + Payload []byte +} + +// InputReport is the low-overhead fast path for interrupt-IN endpoints. The +// host parks the Windows polling request in the kernel and user mode submits +// only a fresh, already encoded report. Audio, control, output, and lifecycle +// traffic deliberately remain on the ordered operation broker. +type InputReport struct { + DeviceID uint64 + Generation uint32 + EndpointGeneration uint32 + EndpointAddress uint8 + Transition bool + Sequence uint64 + Payload []byte +} + +func (m InputReport) marshalMetadata(dst []byte) error { + if m.DeviceID == 0 || m.Generation == 0 || m.EndpointGeneration == 0 || + m.EndpointAddress&0x80 == 0 || + m.Sequence == 0 || m.Sequence > math.MaxInt64 { + return fmt.Errorf("%w: invalid input-report identity", ErrInvalidRange) + } + if len(m.Payload) == 0 || len(m.Payload) > MaxInputReportBytes { + return ErrLimitExceeded + } + total := InputReportSize + len(m.Payload) + h, err := NewHeader(total) + if err != nil { + return err + } + if len(dst) != InputReportSize { + return ErrInvalidSize + } + putHeader(dst, h) + binary.LittleEndian.PutUint64(dst[16:24], m.DeviceID) + binary.LittleEndian.PutUint32(dst[24:28], m.Generation) + dst[28] = m.EndpointAddress + dst[29], dst[30], dst[31] = 0, 0, 0 + if m.Transition { + dst[29] = InputReportTransition + } + binary.LittleEndian.PutUint32(dst[32:36], InputReportSize) + binary.LittleEndian.PutUint32(dst[36:40], uint32(len(m.Payload))) + binary.LittleEndian.PutUint64(dst[40:48], m.Sequence) + binary.LittleEndian.PutUint32(dst[48:52], m.EndpointGeneration) + return nil +} + +func (m InputReport) MarshalBinary() ([]byte, error) { + var metadata [InputReportSize]byte + if err := m.marshalMetadata(metadata[:]); err != nil { + return nil, err + } + dst := make([]byte, InputReportSize+len(m.Payload)) + copy(dst[:InputReportSize], metadata[:]) + copy(dst[InputReportSize:], m.Payload) + return dst, nil +} + +type Stats struct { + OperationsDequeued uint64 + OperationsCompleted uint64 + OperationsCancelled uint64 + OperationsPurged uint64 + LateCompletions uint64 + InvalidMessages uint64 + QueueExhaustions uint64 + IsoPackets uint64 + BytesToDevice uint64 + BytesFromDevice uint64 + NotificationEvents uint64 + NotificationEventOverflows uint64 + ActiveDevices uint32 + PendingOperations uint32 + WaitingDequeues uint32 + CleanupRetries uint32 + InputReportsSubmitted uint64 + InputReportsCompleted uint64 + ReservedPorts uint32 +} + +func ParseStats(src []byte) (Stats, error) { + h, err := ParseHeader(src) + if err != nil { + return Stats{}, err + } + if h.Size != StatsSize { + return Stats{}, ErrInvalidSize + } + reservedPorts := binary.LittleEndian.Uint32(src[144:148]) + if reservedPorts > MaxDevices || binary.LittleEndian.Uint32(src[148:152]) != 0 { + return Stats{}, ErrInvalidRange + } + return Stats{ + OperationsDequeued: binary.LittleEndian.Uint64(src[16:24]), + OperationsCompleted: binary.LittleEndian.Uint64(src[24:32]), + OperationsCancelled: binary.LittleEndian.Uint64(src[32:40]), + OperationsPurged: binary.LittleEndian.Uint64(src[40:48]), + LateCompletions: binary.LittleEndian.Uint64(src[48:56]), + InvalidMessages: binary.LittleEndian.Uint64(src[56:64]), + QueueExhaustions: binary.LittleEndian.Uint64(src[64:72]), + IsoPackets: binary.LittleEndian.Uint64(src[72:80]), + BytesToDevice: binary.LittleEndian.Uint64(src[80:88]), + BytesFromDevice: binary.LittleEndian.Uint64(src[88:96]), + NotificationEvents: binary.LittleEndian.Uint64(src[96:104]), + NotificationEventOverflows: binary.LittleEndian.Uint64(src[104:112]), + ActiveDevices: binary.LittleEndian.Uint32(src[112:116]), + PendingOperations: binary.LittleEndian.Uint32(src[116:120]), + WaitingDequeues: binary.LittleEndian.Uint32(src[120:124]), + CleanupRetries: binary.LittleEndian.Uint32(src[124:128]), + InputReportsSubmitted: binary.LittleEndian.Uint64(src[128:136]), + InputReportsCompleted: binary.LittleEndian.Uint64(src[136:144]), + ReservedPorts: reservedPorts, + }, nil +} + +type LifecycleTraceRecord struct { + PublishedSequence uint64 + TimestampQPC uint64 + Caller uint64 + DeviceID uint64 + DeviceObject uint64 + EndpointObject uint64 + Generation uint32 + Line uint32 + Status int32 + ActiveOperations int32 + PendingOperations int32 + QueueState uint32 + Event uint16 + Processor uint16 + Source uint8 + IRQL uint8 + EndpointAddress uint8 +} + +type LifecycleTrace struct { + LatestSequence uint64 + PerformanceFrequency uint64 + StatusFlags LifecycleTraceStatus + Records []LifecycleTraceRecord +} + +func ParseLifecycleTrace(src []byte) (LifecycleTrace, error) { + h, err := ParseHeader(src) + if err != nil { + return LifecycleTrace{}, err + } + if h.Size != LifecycleTraceSize || len(src) != LifecycleTraceSize || + binary.LittleEndian.Uint32(src[36:40]) != LifecycleTraceRecordSize || + binary.LittleEndian.Uint32(src[40:44]) != LifecycleTraceCapacity { + return LifecycleTrace{}, ErrInvalidSize + } + statusFlags := LifecycleTraceStatus(binary.LittleEndian.Uint32(src[44:48])) + if statusFlags&^lifecycleTraceStatusValidMask != 0 { + return LifecycleTrace{}, ErrInvalidRange + } + recordCount := binary.LittleEndian.Uint32(src[32:36]) + if recordCount > LifecycleTraceCapacity { + return LifecycleTrace{}, ErrInvalidRange + } + trace := LifecycleTrace{ + LatestSequence: binary.LittleEndian.Uint64(src[16:24]), + PerformanceFrequency: binary.LittleEndian.Uint64(src[24:32]), + StatusFlags: statusFlags, + Records: make([]LifecycleTraceRecord, 0, recordCount), + } + for index := uint32(0); index < recordCount; index++ { + offset := 48 + int(index)*LifecycleTraceRecordSize + record := src[offset : offset+LifecycleTraceRecordSize] + trace.Records = append(trace.Records, LifecycleTraceRecord{ + PublishedSequence: binary.LittleEndian.Uint64(record[0:8]), + TimestampQPC: binary.LittleEndian.Uint64(record[8:16]), + Caller: binary.LittleEndian.Uint64(record[16:24]), + DeviceID: binary.LittleEndian.Uint64(record[24:32]), + DeviceObject: binary.LittleEndian.Uint64(record[32:40]), + EndpointObject: binary.LittleEndian.Uint64(record[40:48]), + Generation: binary.LittleEndian.Uint32(record[48:52]), + Line: binary.LittleEndian.Uint32(record[52:56]), + Status: int32(binary.LittleEndian.Uint32(record[56:60])), + ActiveOperations: int32(binary.LittleEndian.Uint32(record[60:64])), + PendingOperations: int32(binary.LittleEndian.Uint32(record[64:68])), + QueueState: binary.LittleEndian.Uint32(record[68:72]), + Event: binary.LittleEndian.Uint16(record[72:74]), + Processor: binary.LittleEndian.Uint16(record[74:76]), + Source: record[76], + IRQL: record[77], + EndpointAddress: record[78], + }) + } + return trace, nil +} + +func (m Completion) wireLayout() (transferLength uint32, isoBytes int, total int, err error) { + if m.Token == 0 || m.DeviceID == 0 || m.Generation == 0 { + return 0, 0, 0, fmt.Errorf("%w: zero completion identity", ErrInvalidRange) + } + if !isManagementToken(m.Token) && m.EndpointGeneration == 0 { + return 0, 0, 0, fmt.Errorf("%w: zero completion endpoint generation", ErrInvalidRange) + } + if len(m.Payload) > MaxTransferBytes || len(m.IsoPackets) > MaxIsoPackets { + return 0, 0, 0, ErrLimitExceeded + } + transferLength = m.TransferLength + // Non-isochronous IN completions historically infer the completed byte + // count from their contiguous payload. Isochronous payloads are different: + // the buffer preserves the host packet offsets, including sparse gaps, while + // TransferLength is the sum of the packets' actual lengths. In particular, + // an all-zero ISO completion can legitimately carry a full sparse buffer and + // still complete zero bytes. + if transferLength == 0 && len(m.Payload) != 0 && len(m.IsoPackets) == 0 { + transferLength = uint32(len(m.Payload)) + } + if transferLength > MaxTransferBytes { + return 0, 0, 0, ErrLimitExceeded + } + isoBytes = len(m.IsoPackets) * IsoPacketSize + total = CompletionSize + isoBytes + len(m.Payload) + if _, err = NewHeader(total); err != nil { + return 0, 0, 0, err + } + return transferLength, isoBytes, total, nil +} + +func (m Completion) marshalBinaryInto(dst []byte) error { + transferLength, isoBytes, total, err := m.wireLayout() + if err != nil { + return err + } + if len(dst) != total { + return ErrInvalidSize + } + h, err := NewHeader(total) + if err != nil { + return err + } + putHeader(dst, h) + binary.LittleEndian.PutUint64(dst[16:24], m.Token) + binary.LittleEndian.PutUint64(dst[24:32], m.DeviceID) + binary.LittleEndian.PutUint32(dst[32:36], m.Generation) + binary.LittleEndian.PutUint32(dst[36:40], uint32(m.Status)) + binary.LittleEndian.PutUint32(dst[40:44], m.USBDStatus) + binary.LittleEndian.PutUint32(dst[44:48], transferLength) + binary.LittleEndian.PutUint32(dst[48:52], uint32(len(m.IsoPackets))) + binary.LittleEndian.PutUint32(dst[52:56], uint32(CompletionSize+isoBytes)) + binary.LittleEndian.PutUint32(dst[56:60], uint32(len(m.Payload))) + binary.LittleEndian.PutUint32(dst[60:64], CompletionSize) + binary.LittleEndian.PutUint32(dst[64:68], m.EndpointGeneration) + // CompletionSize retains one explicit reserved word. Caller-owned and + // pooled buffers must restore its zero wire invariant on every use. + clear(dst[68:CompletionSize]) + for i, packet := range m.IsoPackets { + off := CompletionSize + i*IsoPacketSize + binary.LittleEndian.PutUint32(dst[off:off+4], packet.Offset) + binary.LittleEndian.PutUint32(dst[off+4:off+8], packet.Length) + binary.LittleEndian.PutUint32(dst[off+8:off+12], uint32(packet.Status)) + binary.LittleEndian.PutUint32(dst[off+12:off+16], 0) + } + copy(dst[CompletionSize+isoBytes:], m.Payload) + return nil +} + +func (m Completion) MarshalBinary() ([]byte, error) { + _, _, total, err := m.wireLayout() + if err != nil { + return nil, err + } + dst := make([]byte, total) + if err := m.marshalBinaryInto(dst); err != nil { + return nil, err + } + return dst, nil +} + +func validRange(offset, length, total uint32) bool { + return offset <= total && length <= total-offset +} + +func validArrayRange(offset, count uint32, elementSize uint32, total uint32) bool { + if count != 0 && elementSize > math.MaxUint32/count { + return false + } + return validRange(offset, count*elementSize, total) +} diff --git a/internal/transport/udecx/protocol_contract_test.go b/internal/transport/udecx/protocol_contract_test.go new file mode 100644 index 00000000..b2c1ebf3 --- /dev/null +++ b/internal/transport/udecx/protocol_contract_test.go @@ -0,0 +1,617 @@ +package udecx + +import ( + "bytes" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "reflect" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/Alia5/VIIPER/usb" +) + +// These mirrors are never serialized through unsafe. They let CI prove that +// Go's view of every packed native field size and offset still matches the C +// header that the KMDF driver compiles. +type contractHeader struct { + Magic uint32 + Major uint16 + Minor uint16 + Size uint32 + Flags uint32 +} + +type contractNegotiateRequest struct { + Header contractHeader + ClientNonce uint64 + RequestedCapabilities uint32 + Reserved uint32 +} + +type contractNegotiateResponse struct { + Header contractHeader + ClientNonce uint64 + DriverNonce uint64 + Capabilities uint32 + MaxDevices uint32 + MaxDescriptorBytes uint32 + MaxTransferBytes uint32 + MaxIsoPackets uint32 + MaxPendingOperations uint32 + BuildIdentity [BuildIdentitySize]uint8 +} + +type contractDescriptorRecord struct { + Kind uint16 + Index uint16 + LanguageId uint16 + Reserved uint16 + Offset uint32 + Length uint32 +} + +type contractCreateDevice struct { + Header contractHeader + DeviceId uint64 + Generation uint32 + Speed uint32 + DescriptorCount uint32 + DescriptorRecordsOffset uint32 + DescriptorDataOffset uint32 + DescriptorDataLength uint32 + MaxPendingOperations uint32 + Reserved uint32 +} + +type contractCreateDeviceResult struct { + Header contractHeader + DeviceId uint64 + Generation uint32 + Speed uint32 + Usb20PortNumber uint32 + Usb30PortNumber uint32 +} + +type contractDeviceIdentity struct { + Header contractHeader + DeviceId uint64 + Generation uint32 + Reserved uint32 +} + +type contractISOPacket struct { + Offset uint32 + Length uint32 + Status int32 + Reserved uint32 +} + +type contractOperation struct { + Header contractHeader + Token uint64 + DeviceId uint64 + Generation uint32 + Kind uint32 + EndpointAddress uint8 + Direction uint8 + InterfaceNumber uint8 + InterfaceSetting uint8 + UrbFunction uint32 + TransferFlags uint32 + StartFrame uint32 + IsoPacketCount uint32 + TransferLength uint32 + PayloadOffset uint32 + PayloadLength uint32 + IsoPacketsOffset uint32 + SetupPacket [8]uint8 + EndpointAttributes uint8 + EndpointInterval uint8 + EndpointMaxPacketSize uint16 + EndpointSequence uint64 + DeviceSequence uint64 + EndpointGeneration uint32 +} + +type contractCompletion struct { + Header contractHeader + Token uint64 + DeviceId uint64 + Generation uint32 + Status int32 + UsbdStatus uint32 + TransferLength uint32 + IsoPacketCount uint32 + PayloadOffset uint32 + PayloadLength uint32 + IsoPacketsOffset uint32 + EndpointGeneration uint32 + Reserved uint32 +} + +type contractInputReport struct { + Header contractHeader + DeviceId uint64 + Generation uint32 + EndpointAddress uint8 + Flags uint8 + Reserved1 [2]uint8 + PayloadOffset uint32 + PayloadLength uint32 + Sequence uint64 + EndpointGeneration uint32 +} + +type contractStats struct { + Header contractHeader + OperationsDequeued uint64 + OperationsCompleted uint64 + OperationsCancelled uint64 + OperationsPurged uint64 + LateCompletions uint64 + InvalidMessages uint64 + QueueExhaustions uint64 + IsoPackets uint64 + BytesToDevice uint64 + BytesFromDevice uint64 + NotificationEvents uint64 + NotificationEventOverflows uint64 + ActiveDevices uint32 + PendingOperations uint32 + WaitingDequeues uint32 + CleanupRetries uint32 + InputReportsSubmitted uint64 + InputReportsCompleted uint64 + ReservedPorts uint32 + Reserved uint32 +} + +type contractLifecycleTraceRecord struct { + PublishedSequence uint64 + TimestampQpc uint64 + Caller uint64 + DeviceId uint64 + DeviceObject uint64 + EndpointObject uint64 + Generation uint32 + Line uint32 + Status int32 + ActiveOperations int32 + PendingOperations int32 + QueueState uint32 + Event uint16 + Processor uint16 + Source uint8 + Irql uint8 + EndpointAddress uint8 + Reserved uint8 +} + +type contractLifecycleTrace struct { + Header contractHeader + LatestSequence uint64 + PerformanceFrequency uint64 + RecordCount uint32 + RecordSize uint32 + Capacity uint32 + StatusFlags uint32 + Records [LifecycleTraceCapacity]contractLifecycleTraceRecord +} + +func packedContractSize(contract reflect.Type) uintptr { + var size uintptr + for index := 0; index < contract.NumField(); index++ { + size += contract.Field(index).Type.Size() + } + return size +} + +func packedContractFieldOffset(contract reflect.Type, name string) (uintptr, bool) { + var offset uintptr + for index := 0; index < contract.NumField(); index++ { + field := contract.Field(index) + if field.Name == name { + return offset, true + } + offset += field.Type.Size() + } + return 0, false +} + +func nativeContractSource(t *testing.T, name ...string) string { + t.Helper() + parts := append([]string{"..", "..", ".."}, name...) + raw, err := os.ReadFile(filepath.Join(parts...)) + if err != nil { + t.Fatalf("read native contract source: %v", err) + } + return normalizeNativeContractSource(string(raw)) +} + +func normalizeNativeContractSource(source string) string { + return strings.ReplaceAll(source, "\r\n", "\n") +} + +func TestNormalizeNativeContractSource(t *testing.T) { + t.Parallel() + + const windowsHeader = "#define FIRST 1\r\n#define SECOND 2\r\n" + const normalizedHeader = "#define FIRST 1\n#define SECOND 2\n" + if got := normalizeNativeContractSource(windowsHeader); got != normalizedHeader { + t.Fatalf("normalized native source = %q, want %q", got, normalizedHeader) + } +} + +func cDefineNumber(t *testing.T, source, name string) uint64 { + t.Helper() + pattern := `(?m)^#define\s+` + regexp.QuoteMeta(name) + + `\s+(?:VIIPER_UDE_UINT(?:16|32)_C\()?((?:0x)?[0-9A-Fa-f]+)\)?(?:\s|$)` + match := regexp.MustCompile(pattern).FindStringSubmatch(source) + if match == nil { + t.Fatalf("C contract does not define %s", name) + } + value, err := strconv.ParseUint(match[1], 0, 64) + if err != nil { + t.Fatalf("parse C contract %s=%q: %v", name, match[1], err) + } + return value +} + +func TestNativeProtocolHeaderMatchesGoContract(t *testing.T) { + header := nativeContractSource(t, "native", "udecx", "include", "ViiperUdeProtocol.h") + + numbers := map[string]uint64{ + "VIIPER_UDE_MAGIC": uint64(Magic), + "VIIPER_UDE_ABI_MAJOR": uint64(ABIMajor), + "VIIPER_UDE_ABI_MINOR": uint64(ABIMinor), + "VIIPER_UDE_BUILD_IDENTITY_BYTES": BuildIdentitySize, + "VIIPER_UDE_MAX_DEVICES": MaxDevices, + "VIIPER_UDE_MAX_DESCRIPTOR_BYTES": MaxDescriptorBytes, + "VIIPER_UDE_MAX_TRANSFER_BYTES": MaxTransferBytes, + "VIIPER_UDE_MAX_ISO_PACKETS": MaxIsoPackets, + "VIIPER_UDE_MAX_INPUT_REPORT_BYTES": MaxInputReportBytes, + "VIIPER_UDE_MAX_PENDING_OPERATIONS": MaxPendingOperations, + "VIIPER_UDE_MANAGEMENT_SLOT_FLAG": uint64(ManagementSlotFlag), + "VIIPER_UDE_INPUT_REPORT_TRANSITION": uint64(InputReportTransition), + "VIIPER_UDE_MS_OS_10_STRING_INDEX": uint64(MicrosoftOS10StringIndex), + "VIIPER_UDE_MS_OS_10_STRING_LENGTH": MicrosoftOS10StringLength, + "VIIPER_UDE_MS_OS_10_VENDOR_CODE_OFFSET": MicrosoftOS10VendorCodeOffset, + "VIIPER_UDE_CAP_ISOCHRONOUS": uint64(CapabilityIsochronous), + "VIIPER_UDE_CAP_STREAMS": uint64(CapabilityStreams), + "VIIPER_UDE_CAP_DEVICE_LIFECYCLE": uint64(CapabilityDeviceLifecycle), + "VIIPER_UDE_CAP_INPUT_REPORTS": uint64(CapabilityInputReports), + "VIIPER_UDE_CAP_LIFECYCLE_TRACE": uint64(CapabilityLifecycleTrace), + "VIIPER_UDE_CAP_DEVICE_CORRELATION": uint64(CapabilityDeviceCorrelation), + "VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY": LifecycleTraceCapacity, + "VIIPER_UDE_TRACE_ENDPOINT_QUIESCENCE_WATCHDOG": uint64(TraceEndpointQuiescenceWatchdog), + "VIIPER_UDE_TRACE_COMPLETION_RUNDOWN_WATCHDOG": uint64(TraceCompletionRundownWatchdog), + "VIIPER_UDE_TRACE_CONTROLLER_RUNDOWN_WATCHDOG": uint64(TraceControllerRundownWatchdog), + "VIIPER_UDE_TRACE_OWNER_RUNDOWN_WATCHDOG": uint64(TraceOwnerRundownWatchdog), + "VIIPER_UDE_LIFECYCLE_TRACE_STATUS_DROPPED_RECORD": uint64( + LifecycleTraceStatusDroppedRecord), + "VIIPER_UDE_LIFECYCLE_TRACE_STATUS_WATCHDOG_FIRED": uint64( + LifecycleTraceStatusWatchdogFired), + } + for name, want := range numbers { + if got := cDefineNumber(t, header, name); got != want { + t.Errorf("%s=%#x want Go %#x", name, got, want) + } + } + + types := map[string]reflect.Type{ + "HEADER": reflect.TypeOf(contractHeader{}), + "NEGOTIATE_REQUEST": reflect.TypeOf(contractNegotiateRequest{}), + "NEGOTIATE_RESPONSE": reflect.TypeOf(contractNegotiateResponse{}), + "DESCRIPTOR_RECORD": reflect.TypeOf(contractDescriptorRecord{}), + "CREATE_DEVICE": reflect.TypeOf(contractCreateDevice{}), + "CREATE_DEVICE_RESULT": reflect.TypeOf(contractCreateDeviceResult{}), + "DEVICE_IDENTITY": reflect.TypeOf(contractDeviceIdentity{}), + "ISO_PACKET": reflect.TypeOf(contractISOPacket{}), + "OPERATION": reflect.TypeOf(contractOperation{}), + "COMPLETION": reflect.TypeOf(contractCompletion{}), + "INPUT_REPORT": reflect.TypeOf(contractInputReport{}), + "STATS": reflect.TypeOf(contractStats{}), + "LIFECYCLE_TRACE_RECORD": reflect.TypeOf(contractLifecycleTraceRecord{}), + "LIFECYCLE_TRACE": reflect.TypeOf(contractLifecycleTrace{}), + } + wantSizes := map[string]uintptr{ + "HEADER": HeaderSize, "NEGOTIATE_REQUEST": NegotiateRequestSize, + "NEGOTIATE_RESPONSE": NegotiateResponseSize, "DESCRIPTOR_RECORD": DescriptorRecordSize, + "CREATE_DEVICE": CreateDeviceSize, "CREATE_DEVICE_RESULT": CreateDeviceResultSize, + "DEVICE_IDENTITY": DeviceIdentitySize, + "ISO_PACKET": IsoPacketSize, "OPERATION": OperationSize, "COMPLETION": CompletionSize, + "INPUT_REPORT": InputReportSize, "STATS": StatsSize, + "LIFECYCLE_TRACE_RECORD": LifecycleTraceRecordSize, + "LIFECYCLE_TRACE": LifecycleTraceSize, + } + sizePattern := regexp.MustCompile(`static_assert\(sizeof\(VIIPER_UDE_([A-Z_]+)\) == ([0-9]+),`) + seenSizes := make(map[string]bool) + for _, match := range sizePattern.FindAllStringSubmatch(header, -1) { + name := match[1] + wireType, ok := types[name] + if !ok { + t.Fatalf("C contract added unmodeled type VIIPER_UDE_%s", name) + } + declared, _ := strconv.ParseUint(match[2], 10, 64) + if got := packedContractSize(wireType); uint64(got) != declared || got != wantSizes[name] { + t.Errorf("VIIPER_UDE_%s size: C=%d Go=%d contract=%d", name, declared, got, wantSizes[name]) + } + seenSizes[name] = true + } + if len(seenSizes) != len(types) { + t.Fatalf("C size contracts found=%d want=%d", len(seenSizes), len(types)) + } + + offsetPattern := regexp.MustCompile(`VIIPER_UDE_ASSERT_OFFSET\(VIIPER_UDE_([A-Z_]+),\s*([A-Za-z0-9_]+),\s*([0-9]+)\);`) + seenOffsets := 0 + for _, match := range offsetPattern.FindAllStringSubmatch(header, -1) { + wireType, ok := types[match[1]] + if !ok { + t.Fatalf("C contract added offsets for unmodeled type VIIPER_UDE_%s", match[1]) + } + fieldOffset, ok := packedContractFieldOffset(wireType, match[2]) + if !ok { + t.Fatalf("Go contract type %s has no field %s", match[1], match[2]) + } + want, _ := strconv.ParseUint(match[3], 10, 64) + if uint64(fieldOffset) != want { + t.Errorf("VIIPER_UDE_%s.%s offset: C=%d Go=%d", match[1], match[2], want, fieldOffset) + } + seenOffsets++ + } + if seenOffsets == 0 { + t.Fatal("C field-offset contracts were not found") + } + + enums := map[string]uint64{ + "ViiperUdeDescriptorDevice": uint64(DescriptorDevice), + "ViiperUdeDescriptorConfiguration": uint64(DescriptorConfiguration), + "ViiperUdeDescriptorBos": uint64(DescriptorBOS), + "ViiperUdeDescriptorString": uint64(DescriptorString), + "ViiperUdeOperationControl": uint64(OperationControl), + "ViiperUdeOperationTransfer": uint64(OperationTransfer), + "ViiperUdeOperationEndpointStart": uint64(OperationEndpointStart), + "ViiperUdeOperationEndpointPurge": uint64(OperationEndpointPurge), + "ViiperUdeOperationEndpointReset": uint64(OperationEndpointReset), + "ViiperUdeOperationDeviceReset": uint64(OperationDeviceReset), + "ViiperUdeOperationSetInterface": uint64(OperationSetInterface), + "ViiperUdeOperationDeviceD0Entry": uint64(OperationDeviceD0Entry), + "ViiperUdeOperationDeviceD0Exit": uint64(OperationDeviceD0Exit), + "ViiperUdeOperationCancel": uint64(OperationCancel), + "ViiperUdeOperationBrokerFault": uint64(OperationBrokerFault), + } + enumPattern := regexp.MustCompile(`(?m)^\s*(ViiperUde[A-Za-z0-9]+)\s*=\s*([0-9]+)[,\s]`) + seenEnums := make(map[string]bool) + for _, match := range enumPattern.FindAllStringSubmatch(header, -1) { + want, ok := enums[match[1]] + if !ok { + t.Fatalf("C contract added unmodeled enum %s", match[1]) + } + got, _ := strconv.ParseUint(match[2], 10, 64) + if got != want { + t.Errorf("%s=%d want Go %d", match[1], got, want) + } + seenEnums[match[1]] = true + } + if len(seenEnums) != len(enums) { + t.Fatalf("C enum contracts found=%d want=%d", len(seenEnums), len(enums)) + } + + verifyGUIDAndIOCTLContract(t, header) + if !strings.Contains(header, `#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "`+DriverPackageVersion+`"`) { + t.Fatalf("C driver package version does not match Go %q", DriverPackageVersion) + } + advertised := regexp.MustCompile(`(?s)#define\s+VIIPER_UDE_ADVERTISED_CAPABILITIES\s+\\\s*\(VIIPER_UDE_CAP_ISOCHRONOUS\s*\|\s*VIIPER_UDE_CAP_DEVICE_LIFECYCLE\s*\|\s*\\?\s*VIIPER_UDE_CAP_INPUT_REPORTS\s*\|\s*VIIPER_UDE_CAP_LIFECYCLE_TRACE\s*\|\s*\\?\s*VIIPER_UDE_CAP_DEVICE_CORRELATION\)`).MatchString(header) + if !advertised { + t.Fatal("C advertised capability identity tuple does not match Go") + } + ioctl := nativeContractSource(t, "native", "udecx", "driver", "Ioctl.c") + negotiable := regexp.MustCompile(`(?s)input->RequestedCapabilities\s*&\s*~\(.*?VIIPER_UDE_CAP_ISOCHRONOUS.*?VIIPER_UDE_CAP_DEVICE_LIFECYCLE.*?VIIPER_UDE_CAP_INPUT_REPORTS.*?VIIPER_UDE_CAP_LIFECYCLE_TRACE.*?VIIPER_UDE_CAP_DEVICE_CORRELATION\)`).MatchString(ioctl) + if !negotiable { + t.Fatal("kernel negotiation rejects one or more advertised Go capabilities") + } +} + +func TestKernelMicrosoftOS10StringExceptionMatchesGoContract(t *testing.T) { + driver := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + prefixMatch := regexp.MustCompile( + `(?s)static const UCHAR microsoftOS10StringPrefix\[\]\s*=\s*\{([^}]*)\};`, + ).FindStringSubmatch(driver) + if prefixMatch == nil { + t.Fatal("kernel Microsoft OS 1.0 string prefix is missing") + } + var prefix []byte + for _, token := range regexp.MustCompile(`0x([0-9A-Fa-f]{2})`).FindAllStringSubmatch(prefixMatch[1], -1) { + value, err := strconv.ParseUint(token[1], 16, 8) + if err != nil { + t.Fatalf("parse kernel Microsoft OS 1.0 prefix byte %q: %v", token[1], err) + } + prefix = append(prefix, byte(value)) + } + want := (usb.MicrosoftOS10Descriptor{VendorCode: 0x20}).StringDescriptor() + if len(want) != MicrosoftOS10StringLength || + MicrosoftOS10VendorCodeOffset != len(want)-2 || + !bytes.Equal(prefix, want[:MicrosoftOS10VendorCodeOffset]) { + t.Fatalf("kernel Microsoft OS 1.0 prefix=%x want=%x", prefix, want[:MicrosoftOS10VendorCodeOffset]) + } + + // Keep the exception exact: only the reserved index, LANGID zero, the + // canonical MSFT100 descriptor, a usable vendor code, and a zero pad pass. + // The final check also proves all other nonzero-index/LANGID-zero strings + // still take the original rejection path. + normalized := strings.Join(strings.Fields(driver), " ") + checks := []string{ + "Record->Index == VIIPER_UDE_MS_OS_10_STRING_INDEX", + "Record->LanguageId == 0", + "Record->Length == VIIPER_UDE_MS_OS_10_STRING_LENGTH", + "Descriptor[VIIPER_UDE_MS_OS_10_VENDOR_CODE_OFFSET] != 0", + "Descriptor[VIIPER_UDE_MS_OS_10_STRING_LENGTH - 1] == 0", + "record->Index != 0 && record->LanguageId == 0 && !isMicrosoftOS10String", + "if (foundMicrosoftOS10String) { return FALSE; }", + } + for _, check := range checks { + if !strings.Contains(normalized, check) { + t.Errorf("kernel Microsoft OS 1.0 validation lost contract %q", check) + } + } +} + +func evalGoInteger(expr ast.Expr, values map[string]uint64) (uint64, bool) { + switch value := expr.(type) { + case *ast.BasicLit: + parsed, err := strconv.ParseUint(value.Value, 0, 64) + return parsed, err == nil + case *ast.Ident: + parsed, ok := values[value.Name] + return parsed, ok + case *ast.ParenExpr: + return evalGoInteger(value.X, values) + case *ast.BinaryExpr: + left, leftOK := evalGoInteger(value.X, values) + right, rightOK := evalGoInteger(value.Y, values) + if !leftOK || !rightOK { + return 0, false + } + switch value.Op { + case token.ADD: + return left + right, true + case token.OR: + return left | right, true + case token.SHL: + return left << right, true + } + } + return 0, false +} + +func goWindowsContract(t *testing.T) (map[string]uint64, [11]uint64) { + t.Helper() + sourcePath := filepath.Join("client_windows.go") + file, err := parser.ParseFile(token.NewFileSet(), sourcePath, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", sourcePath, err) + } + values := make(map[string]uint64) + var guid [11]uint64 + for _, declaration := range file.Decls { + general, ok := declaration.(*ast.GenDecl) + if !ok { + continue + } + for _, rawSpec := range general.Specs { + spec, ok := rawSpec.(*ast.ValueSpec) + if !ok { + continue + } + for index, name := range spec.Names { + if index < len(spec.Values) { + if value, ok := evalGoInteger(spec.Values[index], values); ok { + values[name.Name] = value + } + } + if name.Name != "interfaceGUID" || len(spec.Values) == 0 { + continue + } + literal, ok := spec.Values[0].(*ast.CompositeLit) + if !ok { + t.Fatal("interfaceGUID is not a composite literal") + } + for _, rawElement := range literal.Elts { + element := rawElement.(*ast.KeyValueExpr) + key := element.Key.(*ast.Ident).Name + switch key { + case "Data1", "Data2", "Data3": + value, ok := evalGoInteger(element.Value, values) + if !ok { + t.Fatalf("evaluate interfaceGUID.%s", key) + } + position := map[string]int{"Data1": 0, "Data2": 1, "Data3": 2}[key] + guid[position] = value + case "Data4": + array := element.Value.(*ast.CompositeLit) + if len(array.Elts) != 8 { + t.Fatalf("interfaceGUID.Data4 elements=%d want=8", len(array.Elts)) + } + for byteIndex, byteExpression := range array.Elts { + value, ok := evalGoInteger(byteExpression, values) + if !ok { + t.Fatalf("evaluate interfaceGUID.Data4[%d]", byteIndex) + } + guid[3+byteIndex] = value + } + } + } + } + } + } + return values, guid +} + +func verifyGUIDAndIOCTLContract(t *testing.T, header string) { + t.Helper() + goValues, goGUID := goWindowsContract(t) + guidNames := []string{ + "VIIPER_UDE_INTERFACE_GUID_DATA1", "VIIPER_UDE_INTERFACE_GUID_DATA2", + "VIIPER_UDE_INTERFACE_GUID_DATA3", "VIIPER_UDE_INTERFACE_GUID_DATA4_0", + "VIIPER_UDE_INTERFACE_GUID_DATA4_1", "VIIPER_UDE_INTERFACE_GUID_DATA4_2", + "VIIPER_UDE_INTERFACE_GUID_DATA4_3", "VIIPER_UDE_INTERFACE_GUID_DATA4_4", + "VIIPER_UDE_INTERFACE_GUID_DATA4_5", "VIIPER_UDE_INTERFACE_GUID_DATA4_6", + "VIIPER_UDE_INTERFACE_GUID_DATA4_7", + } + for index, name := range guidNames { + if got := cDefineNumber(t, header, name); got != goGUID[index] { + t.Errorf("%s=%#x want Go interface GUID component %#x", name, got, goGUID[index]) + } + } + + type ioctlSpec struct { + goName string + offset uint64 + method string + access string + } + specs := map[string]ioctlSpec{ + "NEGOTIATE": {"ioctlNegotiate", 0, "METHOD_BUFFERED", "FILE_READ_DATA | FILE_WRITE_DATA"}, + "CREATE_DEVICE": {"ioctlCreateDevice", 1, "METHOD_BUFFERED", "FILE_READ_DATA | FILE_WRITE_DATA"}, + "DESTROY_DEVICE": {"ioctlDestroyDevice", 2, "METHOD_BUFFERED", "FILE_READ_DATA | FILE_WRITE_DATA"}, + "DEQUEUE_OPERATION": {"ioctlDequeueOperation", 3, "METHOD_OUT_DIRECT", "FILE_READ_DATA | FILE_WRITE_DATA"}, + "COMPLETE_OPERATION": {"ioctlCompleteOperation", 4, "METHOD_IN_DIRECT", "FILE_READ_DATA | FILE_WRITE_DATA"}, + "QUERY_STATS": {"ioctlQueryStats", 5, "METHOD_BUFFERED", "FILE_READ_DATA"}, + "SUBMIT_INPUT_REPORT": {"ioctlSubmitInputReport", 6, "METHOD_IN_DIRECT", "FILE_READ_DATA | FILE_WRITE_DATA"}, + "QUERY_LIFECYCLE_TRACE": {"ioctlQueryLifecycleTrace", 7, "METHOD_BUFFERED", "FILE_READ_DATA"}, + } + pattern := regexp.MustCompile(`(?m)^#define IOCTL_VIIPER_UDE_([A-Z_]+) CTL_CODE\(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE \+ ([0-9]+), (METHOD_[A-Z_]+), ([^)]+)\)$`) + seen := make(map[string]bool) + methods := map[string]uint64{"METHOD_BUFFERED": 0, "METHOD_IN_DIRECT": 1, "METHOD_OUT_DIRECT": 2} + for _, match := range pattern.FindAllStringSubmatch(header, -1) { + spec, ok := specs[match[1]] + if !ok { + t.Fatalf("C contract added unmodeled IOCTL %s", match[1]) + } + offset, _ := strconv.ParseUint(match[2], 10, 64) + accessText := strings.Join(strings.Fields(match[4]), " ") + if offset != spec.offset || match[3] != spec.method || accessText != spec.access { + t.Errorf("IOCTL %s C definition=(%d,%s,%s) want=(%d,%s,%s)", + match[1], offset, match[3], accessText, spec.offset, spec.method, spec.access) + } + access := goValues["fileReadData"] | goValues["fileWriteData"] + if spec.access == "FILE_READ_DATA" { + access = goValues["fileReadData"] + } + computed := goValues["fileDeviceUnknown"]<<16 | (access << 14) | + (goValues["ioctlBase"]+offset)<<2 | methods[spec.method] + if got, ok := goValues[spec.goName]; !ok || got != computed { + t.Errorf("%s=%#x present=%v want C CTL_CODE %#x", spec.goName, got, ok, computed) + } + seen[match[1]] = true + } + if len(seen) != len(specs) { + t.Fatalf("C IOCTL contracts found=%d want=%d", len(seen), len(specs)) + } +} diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go new file mode 100644 index 00000000..8da1c0db --- /dev/null +++ b/internal/transport/udecx/protocol_test.go @@ -0,0 +1,907 @@ +package udecx + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "errors" + "strings" + "testing" +) + +func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { + t.Parallel() + + const revision = "0123456789abcdef0123456789abcdef01234567" + const wantHex = "9a8c5a75d8c54569f3a8f7e1b2c9a68b8b40bf06494285fa93b56895a98ba3fe" + identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, + ABIMajor, ABIMinor, AdvertisedCapabilities) + if err != nil { + t.Fatal(err) + } + if got := BuildIdentityHex(identity); got != wantHex { + t.Fatalf("build identity=%s want canonical PowerShell/C++ vector %s", got, wantHex) + } + want, _ := hex.DecodeString(wantHex) + if !bytes.Equal(identity[:], want) { + t.Fatal("build identity bytes do not match their canonical hex encoding") + } + upper, err := DeriveBuildIdentity(strings.ToUpper(revision), DriverPackageVersion, + ABIMajor, ABIMinor, AdvertisedCapabilities) + if err != nil || upper != identity { + t.Fatalf("uppercase source revision did not normalize canonically: identity=%x error=%v", upper, err) + } + + for name, revision := range map[string]string{ + "missing": "", + "short": strings.Repeat("a", 39), + "odd": strings.Repeat("a", 41), + "not hex": strings.Repeat("z", 40), + "spaced": " " + strings.Repeat("a", 40), + } { + t.Run(name, func(t *testing.T) { + if _, err := DeriveBuildIdentity(revision, DriverPackageVersion, + ABIMajor, ABIMinor, AdvertisedCapabilities); !errors.Is(err, ErrBuildIdentity) { + t.Fatalf("error=%v want ErrBuildIdentity", err) + } + }) + } +} + +func TestExpectedBuildIdentityFailsClosedWithoutBuildInjection(t *testing.T) { + previous := nativeSourceRevision + nativeSourceRevision = "" + t.Cleanup(func() { nativeSourceRevision = previous }) + + if _, err := ExpectedBuildIdentity(); !errors.Is(err, ErrBuildIdentity) { + t.Fatalf("error=%v want ErrBuildIdentity", err) + } +} + +func TestParseNegotiationReturnsLoadedKernelBuildIdentity(t *testing.T) { + raw := make([]byte, NegotiateResponseSize) + header, err := NewHeader(NegotiateResponseSize) + if err != nil { + t.Fatal(err) + } + putHeader(raw, header) + for index := 0; index < BuildIdentitySize; index++ { + raw[56+index] = byte(index) + } + + response, err := ParseNegotiateResponse(raw) + if err != nil { + t.Fatal(err) + } + for index, got := range response.BuildIdentity { + if got != byte(index) { + t.Fatalf("build identity byte %d=%#x want %#x", index, got, byte(index)) + } + } +} + +func TestABISizes(t *testing.T) { + for name, got := range map[string]int{ + "header": HeaderSize, "negotiate request": NegotiateRequestSize, + "negotiate response": NegotiateResponseSize, "descriptor": DescriptorRecordSize, + "create device": CreateDeviceSize, "create device result": CreateDeviceResultSize, + "identity": DeviceIdentitySize, + "iso packet": IsoPacketSize, "operation": OperationSize, + "completion": CompletionSize, "input report": InputReportSize, + "stats": StatsSize, + } { + if got%4 != 0 { + t.Fatalf("%s ABI size %d is not 32-bit aligned", name, got) + } + } +} + +func TestCreateDeviceResultRequiresExactPortCorrelation(t *testing.T) { + makeResult := func(speed DeviceSpeed, usb20, usb30 uint32) []byte { + raw := make([]byte, CreateDeviceResultSize) + header, _ := NewHeader(CreateDeviceResultSize) + putHeader(raw, header) + binary.LittleEndian.PutUint64(raw[16:24], 0x100000002) + binary.LittleEndian.PutUint32(raw[24:28], 7) + binary.LittleEndian.PutUint32(raw[28:32], uint32(speed)) + binary.LittleEndian.PutUint32(raw[32:36], usb20) + binary.LittleEndian.PutUint32(raw[36:40], usb30) + return raw + } + + for _, tc := range []struct { + name string + speed DeviceSpeed + usb20 uint32 + usb30 uint32 + wantErr bool + }{ + {name: "USB2", speed: DeviceSpeedHigh, usb20: 3}, + {name: "USB3", speed: DeviceSpeedSuper, usb30: MaxDevices + 3}, + {name: "no port", speed: DeviceSpeedHigh, wantErr: true}, + {name: "two ports", speed: DeviceSpeedHigh, usb20: 1, usb30: 33, wantErr: true}, + {name: "USB2 on USB3 field", speed: DeviceSpeedHigh, usb30: 33, wantErr: true}, + {name: "USB3 on USB2 field", speed: DeviceSpeedSuper, usb20: 1, wantErr: true}, + {name: "USB2 port above controller range", speed: DeviceSpeedHigh, usb20: MaxDevices + 1, wantErr: true}, + {name: "USB3 port below controller range", speed: DeviceSpeedSuper, usb30: MaxDevices, wantErr: true}, + {name: "USB3 port above controller range", speed: DeviceSpeedSuper, usb30: 2*MaxDevices + 1, wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + result, err := ParseCreateDeviceResult(makeResult(tc.speed, tc.usb20, tc.usb30)) + if tc.wantErr { + if !errors.Is(err, ErrInvalidRange) { + t.Fatalf("error=%v want ErrInvalidRange", err) + } + return + } + if err != nil || result.DeviceID != 0x100000002 || result.Generation != 7 || + result.USB20PortNumber != tc.usb20 || result.USB30PortNumber != tc.usb30 { + t.Fatalf("result=%+v error=%v", result, err) + } + }) + } +} + +func TestCanonicalControllerSessionID(t *testing.T) { + for value, want := range map[string]bool{ + "1": true, + "18446744073709551615": true, + "": false, + "0": false, + "01": false, + "+1": false, + " 1": false, + "18446744073709551616": false, + } { + if got := IsCanonicalControllerSessionID(value); got != want { + t.Errorf("IsCanonicalControllerSessionID(%q)=%t want %t", value, got, want) + } + } +} + +func TestCanonicalControllerInstanceID(t *testing.T) { + for value, want := range map[string]bool{ + `ROOT\VIIPERUDE\0000`: true, + `root\viiperude\0042`: true, + `ROOT\VIIPERUDE\42`: false, + `ROOT\VIIPERUDE\000A`: false, + `ROOT\OTHER\0000`: false, + ` ROOT\VIIPERUDE\0000`: false, + } { + if got := IsCanonicalControllerInstanceID(value); got != want { + t.Errorf("IsCanonicalControllerInstanceID(%q)=%t want %t", value, got, want) + } + } +} + +func TestHeaderRejectsMalformedInput(t *testing.T) { + valid, err := NewHeader(HeaderSize) + if err != nil { + t.Fatal(err) + } + raw := make([]byte, HeaderSize) + putHeader(raw, valid) + + tests := []struct { + name string + edit func([]byte) []byte + want error + }{ + {"short", func(b []byte) []byte { return b[:15] }, ErrShortMessage}, + {"magic", func(b []byte) []byte { binary.LittleEndian.PutUint32(b, 0); return b }, ErrBadMagic}, + {"major", func(b []byte) []byte { binary.LittleEndian.PutUint16(b[4:6], ABIMajor+1); return b }, ErrIncompatibleMajor}, + {"minor", func(b []byte) []byte { binary.LittleEndian.PutUint16(b[6:8], ABIMinor+1); return b }, ErrIncompatibleMinor}, + {"flags", func(b []byte) []byte { binary.LittleEndian.PutUint32(b[12:16], 1); return b }, ErrInvalidRange}, + {"size below header", func(b []byte) []byte { binary.LittleEndian.PutUint32(b[8:12], 15); return b }, ErrInvalidSize}, + {"size beyond buffer", func(b []byte) []byte { binary.LittleEndian.PutUint32(b[8:12], 17); return b }, ErrInvalidSize}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + candidate := append([]byte(nil), raw...) + _, got := ParseHeader(tc.edit(candidate)) + if !errors.Is(got, tc.want) { + t.Fatalf("got %v, want %v", got, tc.want) + } + }) + } +} + +func TestCreateDeviceMarshallingBoundsDescriptors(t *testing.T) { + msg := CreateDevice{ + DeviceID: 7, Generation: 2, Speed: DeviceSpeedHigh, + MaxPendingOperations: 128, + DescriptorData: []byte{0x12, 0x01, 0xaa, 0xbb}, + Descriptors: []DescriptorRecord{ + {Kind: DescriptorDevice, Offset: 0, Length: 2}, + {Kind: DescriptorConfiguration, Offset: 2, Length: 2}, + }, + } + raw, err := msg.MarshalBinary() + if err != nil { + t.Fatal(err) + } + if got, want := len(raw), CreateDeviceSize+2*DescriptorRecordSize+4; got != want { + t.Fatalf("size=%d want=%d", got, want) + } + if got := binary.LittleEndian.Uint32(raw[8:12]); got != uint32(len(raw)) { + t.Fatalf("header size=%d want=%d", got, len(raw)) + } + + msg.Descriptors[1].Offset = 4 + msg.Descriptors[1].Length = 1 + if _, err = msg.MarshalBinary(); !errors.Is(err, ErrInvalidRange) { + t.Fatalf("invalid descriptor range: got %v", err) + } +} + +func TestParseOperationCopiesPayloadAndPackets(t *testing.T) { + payload := []byte{1, 2, 3, 4} + total := OperationSize + IsoPacketSize + len(payload) + h, _ := NewHeader(total) + raw := make([]byte, total) + putHeader(raw, h) + binary.LittleEndian.PutUint64(raw[16:24], 99) + binary.LittleEndian.PutUint64(raw[24:32], 4) + binary.LittleEndian.PutUint32(raw[32:36], 8) + binary.LittleEndian.PutUint32(raw[36:40], uint32(OperationTransfer)) + raw[40], raw[41] = 0x84, 1 + raw[42], raw[43] = 2, 1 + raw[84], raw[85] = 0x05, 4 + binary.LittleEndian.PutUint16(raw[86:88], 196) + binary.LittleEndian.PutUint32(raw[56:60], 1) + binary.LittleEndian.PutUint32(raw[60:64], uint32(len(payload))) + binary.LittleEndian.PutUint32(raw[64:68], OperationSize+IsoPacketSize) + binary.LittleEndian.PutUint32(raw[68:72], uint32(len(payload))) + binary.LittleEndian.PutUint32(raw[72:76], OperationSize) + binary.LittleEndian.PutUint64(raw[88:96], 17) + binary.LittleEndian.PutUint64(raw[96:104], 23) + binary.LittleEndian.PutUint32(raw[104:108], 29) + binary.LittleEndian.PutUint32(raw[OperationSize:OperationSize+4], 0) + binary.LittleEndian.PutUint32(raw[OperationSize+4:OperationSize+8], uint32(len(payload))) + copy(raw[OperationSize+IsoPacketSize:], payload) + + op, err := ParseOperation(raw) + if err != nil { + t.Fatal(err) + } + if op.Token != 99 || op.DeviceID != 4 || op.Generation != 8 || + op.EndpointSequence != 17 || op.DeviceSequence != 23 || op.EndpointGeneration != 29 || + op.InterfaceNumber != 2 || + op.InterfaceSetting != 1 || op.EndpointAttributes != 0x05 || + op.EndpointInterval != 4 || op.EndpointMaxPacketSize != 196 || + len(op.IsoPackets) != 1 { + t.Fatalf("unexpected operation: %+v", op) + } + raw[len(raw)-1] = 0xff + if op.Payload[3] != 4 { + t.Fatal("operation retained mutable caller payload") + } +} + +func TestParseOperationRejectsMalformedCanonicalTail(t *testing.T) { + valid := dualSenseIsoOperationFixture(1, 4) + tests := []struct { + name string + edit func([]byte) []byte + want error + }{ + { + name: "bytes after embedded size", + edit: func(raw []byte) []byte { return append(raw, 0xaa) }, + want: ErrInvalidSize, + }, + { + name: "embedded size omits returned byte", + edit: func(raw []byte) []byte { + binary.LittleEndian.PutUint32(raw[8:12], uint32(len(raw)-1)) + return raw + }, + want: ErrInvalidSize, + }, + { + name: "ISO table aliases fixed header", + edit: func(raw []byte) []byte { + binary.LittleEndian.PutUint32(raw[72:76], OperationSize-4) + return raw + }, + want: ErrInvalidRange, + }, + { + name: "payload aliases fixed header", + edit: func(raw []byte) []byte { + binary.LittleEndian.PutUint32(raw[64:68], OperationSize-1) + return raw + }, + want: ErrInvalidRange, + }, + { + name: "payload overlaps ISO table", + edit: func(raw []byte) []byte { + binary.LittleEndian.PutUint32(raw[64:68], OperationSize) + return raw + }, + want: ErrInvalidRange, + }, + { + name: "gap before payload", + edit: func(raw []byte) []byte { + binary.LittleEndian.PutUint32(raw[64:68], OperationSize+IsoPacketSize+1) + return raw + }, + want: ErrInvalidRange, + }, + { + name: "gap before ISO table", + edit: func(raw []byte) []byte { + binary.LittleEndian.PutUint32(raw[72:76], OperationSize+1) + return raw + }, + want: ErrInvalidRange, + }, + { + name: "unclaimed canonical tail byte", + edit: func(raw []byte) []byte { + raw = append(raw, 0) + binary.LittleEndian.PutUint32(raw[8:12], uint32(len(raw))) + return raw + }, + want: ErrInvalidRange, + }, + { + name: "nonzero ISO reserved word", + edit: func(raw []byte) []byte { + binary.LittleEndian.PutUint32(raw[OperationSize+12:OperationSize+16], 1) + return raw + }, + want: ErrInvalidRange, + }, + { + name: "ISO packet exceeds transfer length", + edit: func(raw []byte) []byte { + binary.LittleEndian.PutUint32(raw[OperationSize:OperationSize+4], 3) + binary.LittleEndian.PutUint32(raw[OperationSize+4:OperationSize+8], 2) + return raw + }, + want: ErrInvalidRange, + }, + { + name: "ISO packet extent overflows", + edit: func(raw []byte) []byte { + binary.LittleEndian.PutUint32(raw[OperationSize:OperationSize+4], ^uint32(0)) + binary.LittleEndian.PutUint32(raw[OperationSize+4:OperationSize+8], 2) + return raw + }, + want: ErrInvalidRange, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + raw := append([]byte(nil), valid...) + _, err := ParseOperation(test.edit(raw)) + if !errors.Is(err, test.want) { + t.Fatalf("ParseOperation error=%v want=%v", err, test.want) + } + }) + } +} + +func TestParseOperationAcceptsCanonicalEmptyTail(t *testing.T) { + raw := make([]byte, OperationSize) + header, err := NewHeader(OperationSize) + if err != nil { + t.Fatal(err) + } + putHeader(raw, header) + binary.LittleEndian.PutUint64(raw[16:24], 7) + binary.LittleEndian.PutUint64(raw[24:32], 9) + binary.LittleEndian.PutUint32(raw[32:36], 2) + binary.LittleEndian.PutUint32(raw[36:40], uint32(OperationEndpointStart)) + binary.LittleEndian.PutUint32(raw[64:68], OperationSize) + binary.LittleEndian.PutUint32(raw[72:76], OperationSize) + binary.LittleEndian.PutUint32(raw[104:108], 3) + + op, err := ParseOperation(raw) + if err != nil { + t.Fatalf("ParseOperation canonical empty tail: %v", err) + } + if op.Token != 7 || op.DeviceID != 9 || op.Generation != 2 || + op.Kind != OperationEndpointStart || len(op.IsoPackets) != 0 || len(op.Payload) != 0 { + t.Fatalf("unexpected empty-tail operation: %+v", op) + } + + binary.LittleEndian.PutUint32(raw[72:76], 0) + if _, err = ParseOperation(raw); !errors.Is(err, ErrInvalidRange) { + t.Fatalf("ParseOperation zero ISO offset error=%v want=%v", err, ErrInvalidRange) + } +} + +func TestParseOperationRequiresKindScopedEndpointGeneration(t *testing.T) { + raw := make([]byte, OperationSize) + header, err := NewHeader(OperationSize) + if err != nil { + t.Fatal(err) + } + putHeader(raw, header) + binary.LittleEndian.PutUint64(raw[16:24], 7) + binary.LittleEndian.PutUint64(raw[24:32], 9) + binary.LittleEndian.PutUint32(raw[32:36], 2) + binary.LittleEndian.PutUint32(raw[36:40], uint32(OperationEndpointStart)) + binary.LittleEndian.PutUint32(raw[64:68], OperationSize) + binary.LittleEndian.PutUint32(raw[72:76], OperationSize) + + if _, err = ParseOperation(raw); !errors.Is(err, ErrInvalidRange) { + t.Fatalf("zero endpoint generation error=%v want ErrInvalidRange", err) + } + binary.LittleEndian.PutUint32(raw[104:108], 3) + if _, err = ParseOperation(raw); err != nil { + t.Fatalf("endpoint-scoped identity: %v", err) + } + binary.LittleEndian.PutUint32(raw[36:40], uint32(OperationDeviceD0Exit)) + if _, err = ParseOperation(raw); !errors.Is(err, ErrInvalidRange) { + t.Fatalf("device-scoped endpoint generation error=%v want ErrInvalidRange", err) + } + binary.LittleEndian.PutUint32(raw[104:108], 0) + if _, err = ParseOperation(raw); err != nil { + t.Fatalf("device-scoped zero endpoint generation: %v", err) + } + binary.LittleEndian.PutUint32(raw[36:40], uint32(OperationCancel)) + if _, err = ParseOperation(raw); !errors.Is(err, ErrInvalidRange) { + t.Fatalf("ordinary cancellation zero endpoint generation error=%v want ErrInvalidRange", err) + } + managementToken := uint64(2)<<32 | uint64(ManagementSlotFlag) | 1 + binary.LittleEndian.PutUint64(raw[16:24], managementToken) + if _, err = ParseOperation(raw); err != nil { + t.Fatalf("device-scoped management cancellation: %v", err) + } + binary.LittleEndian.PutUint32(raw[104:108], 3) + if _, err = ParseOperation(raw); err != nil { + t.Fatalf("endpoint-scoped management cancellation: %v", err) + } +} + +func TestParseDequeuedOperationRequiresExactBytesReturned(t *testing.T) { + valid := dualSenseIsoOperationFixture(1, 4) + if _, err := parseDequeuedOperation(valid, uint32(len(valid))); err != nil { + t.Fatalf("valid dequeued operation: %v", err) + } + + tests := []struct { + name string + buffer []byte + written uint32 + }{ + {"short return", valid, OperationSize - 1}, + {"return exceeds buffer", valid, uint32(len(valid) + 1)}, + {"return truncates embedded size", valid, uint32(len(valid) - 1)}, + {"return includes trailing byte", append(append([]byte(nil), valid...), 0), uint32(len(valid) + 1)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := parseDequeuedOperation(test.buffer, test.written); !errors.Is(err, ErrInvalidSize) { + t.Fatalf("parseDequeuedOperation error=%v want ErrInvalidSize", err) + } + }) + } + + headerMismatch := append([]byte(nil), valid...) + binary.LittleEndian.PutUint32(headerMismatch[8:12], uint32(len(headerMismatch)-1)) + if _, err := parseDequeuedOperation(headerMismatch, uint32(len(headerMismatch))); !errors.Is(err, ErrInvalidSize) { + t.Fatalf("header/bytes-returned mismatch error=%v want ErrInvalidSize", err) + } +} + +func TestCompletionMarshalling(t *testing.T) { + raw, err := (Completion{ + Token: 3, DeviceID: 9, Generation: 4, EndpointGeneration: 5, + Status: -1, USBDStatus: 0xc0000001, + IsoPackets: []IsoPacket{{Offset: 0, Length: 3}}, Payload: []byte{7, 8, 9}, + }).MarshalBinary() + if err != nil { + t.Fatal(err) + } + if got, want := len(raw), CompletionSize+IsoPacketSize+3; got != want { + t.Fatalf("size=%d want=%d", got, want) + } + if got := binary.LittleEndian.Uint32(raw[52:56]); got != CompletionSize+IsoPacketSize { + t.Fatalf("payload offset=%d", got) + } + if got := binary.LittleEndian.Uint32(raw[64:68]); got != 5 { + t.Fatalf("endpoint generation=%d want=5", got) + } +} + +func TestCompletionMarshallingPreservesZeroLengthSparseISO(t *testing.T) { + payload := make([]byte, 64) + raw, err := (Completion{ + Token: 3, + DeviceID: 9, + Generation: 4, + EndpointGeneration: 1, + TransferLength: 0, + IsoPackets: []IsoPacket{{Offset: 0, Length: 0}}, + Payload: payload, + }).MarshalBinary() + if err != nil { + t.Fatal(err) + } + if got := binary.LittleEndian.Uint32(raw[44:48]); got != 0 { + t.Fatalf("transfer length=%d want=0", got) + } + if got := binary.LittleEndian.Uint32(raw[56:60]); got != uint32(len(payload)) { + t.Fatalf("payload length=%d want=%d", got, len(payload)) + } +} + +func TestCompletionEncodingIntoCallerBufferDoesNotAllocate(t *testing.T) { + completion := Completion{ + Token: 1, DeviceID: 2, Generation: 3, EndpointGeneration: 1, + TransferLength: 4 * 196, + IsoPackets: []IsoPacket{ + {Offset: 0, Length: 196}, {Offset: 196, Length: 196}, + {Offset: 392, Length: 196}, {Offset: 588, Length: 196}, + }, + Payload: make([]byte, 4*196), + } + _, _, total, err := completion.wireLayout() + if err != nil { + t.Fatal(err) + } + dst := make([]byte, total) + for index := range dst { + dst[index] = 0xff + } + allocations := testing.AllocsPerRun(1000, func() { + if err := completion.marshalBinaryInto(dst); err != nil { + panic(err) + } + }) + if allocations != 0 { + t.Fatalf("caller-buffer completion encoding allocated %.2f objects", allocations) + } + if got := binary.LittleEndian.Uint32(dst[64:68]); got != completion.EndpointGeneration { + t.Fatalf("endpoint generation=%d want=%d", got, completion.EndpointGeneration) + } + for index, value := range dst[68:CompletionSize] { + if value != 0 { + t.Fatalf("completion reserved byte %d retained %#x", 68+index, value) + } + } + for packet := range completion.IsoPackets { + offset := CompletionSize + packet*IsoPacketSize + 12 + if value := binary.LittleEndian.Uint32(dst[offset : offset+4]); value != 0 { + t.Fatalf("ISO packet %d reserved word retained %#x", packet, value) + } + } +} + +func TestCompletionEndpointGenerationScopedByToken(t *testing.T) { + ordinary := Completion{Token: 1, DeviceID: 2, Generation: 3} + if _, err := ordinary.MarshalBinary(); !errors.Is(err, ErrInvalidRange) { + t.Fatalf("ordinary zero endpoint generation error=%v want ErrInvalidRange", err) + } + deviceManagement := Completion{ + Token: uint64(2)<<32 | uint64(ManagementSlotFlag) | 1, + DeviceID: 2, Generation: 3, + } + raw, err := deviceManagement.MarshalBinary() + if err != nil { + t.Fatalf("device-scoped management completion: %v", err) + } + if got := binary.LittleEndian.Uint32(raw[64:68]); got != 0 { + t.Fatalf("device-scoped endpoint generation=%d want=0", got) + } + deviceManagement.EndpointGeneration = 7 + if _, err = deviceManagement.MarshalBinary(); err != nil { + t.Fatalf("endpoint-scoped management completion: %v", err) + } +} + +func TestInputReportMarshalling(t *testing.T) { + raw, err := (InputReport{ + DeviceID: 5, Generation: 7, EndpointGeneration: 9, EndpointAddress: 0x81, + Transition: true, Sequence: 11, Payload: []byte{1, 2, 3}, + }).MarshalBinary() + if err != nil { + t.Fatal(err) + } + if len(raw) != InputReportSize+3 || + raw[29] != InputReportTransition || raw[30] != 0 || raw[31] != 0 || + binary.LittleEndian.Uint32(raw[32:36]) != InputReportSize || + binary.LittleEndian.Uint32(raw[36:40]) != 3 || + binary.LittleEndian.Uint64(raw[40:48]) != 11 || + binary.LittleEndian.Uint32(raw[48:52]) != 9 || + string(raw[InputReportSize:]) != string([]byte{1, 2, 3}) { + t.Fatalf("invalid input-report wire layout: %x", raw) + } +} + +func TestInputReportMetadataEncodingDoesNotAllocate(t *testing.T) { + report := InputReport{ + DeviceID: 5, Generation: 7, EndpointGeneration: 9, EndpointAddress: 0x81, + Sequence: 11, Payload: []byte{1, 2, 3}, + } + var metadata [InputReportSize]byte + allocations := testing.AllocsPerRun(1000, func() { + if err := report.marshalMetadata(metadata[:]); err != nil { + panic(err) + } + }) + if allocations != 0 { + t.Fatalf("input-report metadata encoding allocated %.2f objects per call", allocations) + } +} + +func TestInputReportMetadataClearsReusedTransitionFlag(t *testing.T) { + report := InputReport{ + DeviceID: 5, Generation: 7, EndpointGeneration: 9, EndpointAddress: 0x81, + Transition: true, Sequence: 11, Payload: []byte{1}, + } + var metadata [InputReportSize]byte + if err := report.marshalMetadata(metadata[:]); err != nil { + t.Fatal(err) + } + report.Transition = false + report.Sequence++ + if err := report.marshalMetadata(metadata[:]); err != nil { + t.Fatal(err) + } + if metadata[29] != 0 || metadata[30] != 0 || metadata[31] != 0 { + t.Fatalf("reused input metadata retained flags/reserved bytes: %x", metadata[29:32]) + } +} + +func TestIdentityAndStatsLayout(t *testing.T) { + identity, err := (DeviceIdentity{DeviceID: 0x1122334455667788, Generation: 7}).MarshalBinary() + if err != nil { + t.Fatal(err) + } + if len(identity) != DeviceIdentitySize || binary.LittleEndian.Uint64(identity[16:24]) != 0x1122334455667788 { + t.Fatalf("invalid identity layout: %x", identity) + } + + raw := make([]byte, StatsSize) + h, _ := NewHeader(StatsSize) + putHeader(raw, h) + binary.LittleEndian.PutUint64(raw[16:24], 11) + binary.LittleEndian.PutUint64(raw[88:96], 29) + binary.LittleEndian.PutUint64(raw[96:104], 31) + binary.LittleEndian.PutUint64(raw[104:112], 0) + binary.LittleEndian.PutUint32(raw[112:116], 3) + binary.LittleEndian.PutUint32(raw[116:120], 5) + binary.LittleEndian.PutUint32(raw[120:124], 7) + binary.LittleEndian.PutUint64(raw[128:136], 37) + binary.LittleEndian.PutUint64(raw[136:144], 41) + binary.LittleEndian.PutUint32(raw[144:148], 9) + stats, err := ParseStats(raw) + if err != nil { + t.Fatal(err) + } + if stats.OperationsDequeued != 11 || stats.BytesFromDevice != 29 || stats.NotificationEvents != 31 || + stats.ActiveDevices != 3 || stats.PendingOperations != 5 || stats.WaitingDequeues != 7 || + stats.InputReportsSubmitted != 37 || stats.InputReportsCompleted != 41 || stats.ReservedPorts != 9 { + t.Fatalf("unexpected stats: %+v", stats) + } + binary.LittleEndian.PutUint32(raw[148:152], 1) + if _, err := ParseStats(raw); !errors.Is(err, ErrInvalidRange) { + t.Fatalf("nonzero reserved stats word error=%v want ErrInvalidRange", err) + } + binary.LittleEndian.PutUint32(raw[148:152], 0) + binary.LittleEndian.PutUint32(raw[144:148], MaxDevices+1) + if _, err := ParseStats(raw); !errors.Is(err, ErrInvalidRange) { + t.Fatalf("out-of-range reserved-port count error=%v want ErrInvalidRange", err) + } +} + +func TestParseLifecycleTracePreservesDebugState(t *testing.T) { + raw := make([]byte, LifecycleTraceSize) + h, _ := NewHeader(LifecycleTraceSize) + putHeader(raw, h) + binary.LittleEndian.PutUint64(raw[16:24], 23) + binary.LittleEndian.PutUint64(raw[24:32], 10_000_000) + binary.LittleEndian.PutUint32(raw[32:36], 1) + binary.LittleEndian.PutUint32(raw[36:40], LifecycleTraceRecordSize) + binary.LittleEndian.PutUint32(raw[40:44], LifecycleTraceCapacity) + binary.LittleEndian.PutUint32(raw[44:48], uint32( + LifecycleTraceStatusDroppedRecord|LifecycleTraceStatusWatchdogFired)) + + record := raw[48 : 48+LifecycleTraceRecordSize] + binary.LittleEndian.PutUint64(record[0:8], 23) + binary.LittleEndian.PutUint64(record[8:16], 1_234_567) + binary.LittleEndian.PutUint64(record[16:24], 0xfffff80212345678) + binary.LittleEndian.PutUint64(record[24:32], 41) + binary.LittleEndian.PutUint64(record[32:40], 0xffff808000001000) + binary.LittleEndian.PutUint64(record[40:48], 0xffff808000002000) + binary.LittleEndian.PutUint32(record[48:52], 7) + binary.LittleEndian.PutUint32(record[52:56], 2225) + binary.LittleEndian.PutUint32(record[56:60], 0xc0000184) + binary.LittleEndian.PutUint32(record[60:64], 2) + binary.LittleEndian.PutUint32(record[64:68], 3) + binary.LittleEndian.PutUint32(record[68:72], 4) + binary.LittleEndian.PutUint16(record[72:74], TraceEndpointPurgeCompleteEnd) + binary.LittleEndian.PutUint16(record[74:76], 9) + record[76], record[77], record[78] = TraceSourceDevice, 0, 0x84 + + trace, err := ParseLifecycleTrace(raw) + if err != nil { + t.Fatal(err) + } + if trace.LatestSequence != 23 || trace.PerformanceFrequency != 10_000_000 || + trace.StatusFlags != LifecycleTraceStatusDroppedRecord|LifecycleTraceStatusWatchdogFired || + len(trace.Records) != 1 { + t.Fatalf("unexpected lifecycle trace header: %+v", trace) + } + got := trace.Records[0] + if got.PublishedSequence != 23 || got.TimestampQPC != 1_234_567 || + got.Caller != 0xfffff80212345678 || got.DeviceID != 41 || got.Generation != 7 || + got.Line != 2225 || uint32(got.Status) != 0xc0000184 || got.ActiveOperations != 2 || + got.PendingOperations != 3 || got.QueueState != 4 || + got.Event != TraceEndpointPurgeCompleteEnd || got.Processor != 9 || + got.Source != TraceSourceDevice || got.IRQL != 0 || got.EndpointAddress != 0x84 { + t.Fatalf("unexpected lifecycle trace record: %+v", got) + } + binary.LittleEndian.PutUint32(raw[44:48], 0x80000000) + if _, err = ParseLifecycleTrace(raw); !errors.Is(err, ErrInvalidRange) { + t.Fatalf("unknown lifecycle trace status error=%v want ErrInvalidRange", err) + } +} + +func FuzzParseOperation(f *testing.F) { + f.Add([]byte{}) + valid := make([]byte, OperationSize) + h, _ := NewHeader(OperationSize) + putHeader(valid, h) + binary.LittleEndian.PutUint32(valid[64:68], OperationSize) + binary.LittleEndian.PutUint32(valid[72:76], OperationSize) + f.Add(valid) + iso := dualSenseIsoOperationFixture(1, 4) + f.Add(iso) + trailing := append(append([]byte(nil), iso...), 0xaa) + f.Add(trailing) + reserved := append([]byte(nil), iso...) + binary.LittleEndian.PutUint32(reserved[OperationSize+12:OperationSize+16], 1) + f.Add(reserved) + extent := append([]byte(nil), iso...) + binary.LittleEndian.PutUint32(extent[OperationSize:OperationSize+4], 4) + binary.LittleEndian.PutUint32(extent[OperationSize+4:OperationSize+8], 1) + f.Add(extent) + f.Fuzz(func(t *testing.T, raw []byte) { + op, err := ParseOperation(raw) + if err != nil { + return + } + if len(raw) != int(binary.LittleEndian.Uint32(raw[8:12])) { + t.Fatal("accepted bytes after embedded operation size") + } + packetCount := binary.LittleEndian.Uint32(raw[56:60]) + isoOffset := binary.LittleEndian.Uint32(raw[72:76]) + payloadOffset := binary.LittleEndian.Uint32(raw[64:68]) + if isoOffset != OperationSize || payloadOffset != OperationSize+packetCount*IsoPacketSize { + t.Fatal("accepted noncanonical operation tails") + } + for index, packet := range op.IsoPackets { + offset := int(isoOffset) + index*IsoPacketSize + if binary.LittleEndian.Uint32(raw[offset+12:offset+16]) != 0 || + !validRange(packet.Offset, packet.Length, op.TransferLength) { + t.Fatalf("accepted invalid ISO packet %d", index) + } + } + }) +} + +func FuzzProtocolDecoders(f *testing.F) { + f.Add([]byte{}) + negotiation := make([]byte, NegotiateResponseSize) + h, _ := NewHeader(len(negotiation)) + putHeader(negotiation, h) + f.Add(negotiation) + stats := make([]byte, StatsSize) + h, _ = NewHeader(len(stats)) + putHeader(stats, h) + f.Add(stats) + trace := make([]byte, LifecycleTraceSize) + h, _ = NewHeader(len(trace)) + putHeader(trace, h) + binary.LittleEndian.PutUint32(trace[36:40], LifecycleTraceRecordSize) + binary.LittleEndian.PutUint32(trace[40:44], LifecycleTraceCapacity) + f.Add(trace) + f.Fuzz(func(t *testing.T, raw []byte) { + _, _ = ParseHeader(raw) + _, _ = ParseNegotiateResponse(raw) + _, _ = ParseStats(raw) + _, _ = ParseOperation(raw) + _, _ = ParseLifecycleTrace(raw) + }) +} + +func dualSenseIsoOperationFixture(packetCount, packetLength int) []byte { + total := OperationSize + packetCount*IsoPacketSize + packetCount*packetLength + h, _ := NewHeader(total) + raw := make([]byte, total) + putHeader(raw, h) + binary.LittleEndian.PutUint64(raw[16:24], 1) + binary.LittleEndian.PutUint64(raw[24:32], 2) + binary.LittleEndian.PutUint32(raw[32:36], 3) + binary.LittleEndian.PutUint32(raw[36:40], uint32(OperationTransfer)) + raw[40], raw[41], raw[84], raw[85] = 0x04, 0, 0x05, 4 + binary.LittleEndian.PutUint16(raw[86:88], uint16(packetLength)) + binary.LittleEndian.PutUint32(raw[56:60], uint32(packetCount)) + binary.LittleEndian.PutUint32(raw[60:64], uint32(packetCount*packetLength)) + binary.LittleEndian.PutUint32(raw[64:68], uint32(OperationSize+packetCount*IsoPacketSize)) + binary.LittleEndian.PutUint32(raw[68:72], uint32(packetCount*packetLength)) + binary.LittleEndian.PutUint32(raw[72:76], OperationSize) + binary.LittleEndian.PutUint64(raw[88:96], 1) + binary.LittleEndian.PutUint32(raw[104:108], 1) + for index := 0; index < packetCount; index++ { + offset := OperationSize + index*IsoPacketSize + binary.LittleEndian.PutUint32(raw[offset:offset+4], uint32(index*packetLength)) + binary.LittleEndian.PutUint32(raw[offset+4:offset+8], uint32(packetLength)) + } + return raw +} + +func BenchmarkParseDualSenseIsoOperation(b *testing.B) { + raw := dualSenseIsoOperationFixture(4, 196) + b.ReportAllocs() + b.SetBytes(int64(len(raw))) + b.ResetTimer() + for range b.N { + operation, err := ParseOperation(raw) + if err != nil || len(operation.Payload) != 4*196 || len(operation.IsoPackets) != 4 { + b.Fatalf("ParseOperation: operation=%+v err=%v", operation, err) + } + } +} + +func BenchmarkMarshalDualSenseIsoCompletion(b *testing.B) { + completion := Completion{ + Token: 1, DeviceID: 2, Generation: 3, EndpointGeneration: 1, + TransferLength: 4 * 196, + IsoPackets: []IsoPacket{ + {Offset: 0, Length: 196}, {Offset: 196, Length: 196}, + {Offset: 392, Length: 196}, {Offset: 588, Length: 196}, + }, + Payload: make([]byte, 4*196), + } + b.ReportAllocs() + b.SetBytes(int64(CompletionSize + len(completion.IsoPackets)*IsoPacketSize + len(completion.Payload))) + b.ResetTimer() + for range b.N { + raw, err := completion.MarshalBinary() + if err != nil || len(raw) != CompletionSize+4*IsoPacketSize+4*196 { + b.Fatalf("MarshalBinary: bytes=%d err=%v", len(raw), err) + } + } +} + +func TestDualSenseIsoProtocolAllocationBudget(t *testing.T) { + raw := dualSenseIsoOperationFixture(4, 196) + parseAllocations := testing.AllocsPerRun(1000, func() { + operation, err := ParseOperation(raw) + if err != nil || len(operation.Payload) != 4*196 { + panic("parse representative DualSense ISO operation") + } + }) + if parseAllocations > 2 { + t.Fatalf("DualSense ISO parse allocated %.2f objects, budget is 2", parseAllocations) + } + + completion := Completion{ + Token: 1, DeviceID: 2, Generation: 3, EndpointGeneration: 1, + TransferLength: 4 * 196, + IsoPackets: []IsoPacket{ + {Offset: 0, Length: 196}, {Offset: 196, Length: 196}, + {Offset: 392, Length: 196}, {Offset: 588, Length: 196}, + }, + Payload: make([]byte, 4*196), + } + marshalAllocations := testing.AllocsPerRun(1000, func() { + encoded, err := completion.MarshalBinary() + if err != nil || len(encoded) != CompletionSize+4*IsoPacketSize+4*196 { + panic("marshal representative DualSense ISO completion") + } + }) + if marshalAllocations > 1 { + t.Fatalf("DualSense ISO completion allocated %.2f objects, budget is 1", marshalAllocations) + } +} diff --git a/internal/transport/udecx/release_contract_test.go b/internal/transport/udecx/release_contract_test.go new file mode 100644 index 00000000..6c7f590b --- /dev/null +++ b/internal/transport/udecx/release_contract_test.go @@ -0,0 +1,83 @@ +package udecx + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +func TestExpectedDriverPackageVersionMatchesProject(t *testing.T) { + projectPath := filepath.Join("..", "..", "..", "native", "udecx", "driver", "ViiperUde.vcxproj") + project, err := os.ReadFile(projectPath) + if err != nil { + t.Fatalf("read native driver project: %v", err) + } + matches := regexp.MustCompile(`([^<]+)`).FindAllSubmatch(project, -1) + if len(matches) != 1 || len(matches[0]) != 2 { + t.Fatal("native driver project has no single release version contract") + } + if got := string(matches[0][1]); got != DriverPackageVersion { + t.Fatalf("native package version drift: Go=%q project=%q", DriverPackageVersion, got) + } +} + +func TestNativeReleaseBuildIdentityIsExplicitAndSourceBound(t *testing.T) { + root := filepath.Join("..", "..", "..") + read := func(parts ...string) string { + t.Helper() + contents, err := os.ReadFile(filepath.Join(append([]string{root}, parts...)...)) + if err != nil { + t.Fatalf("read %s: %v", filepath.Join(parts...), err) + } + return string(contents) + } + + project := read("native", "udecx", "driver", "ViiperUde.vcxproj") + for _, required := range []string{ + "$(VIIPER_NATIVE_SOURCE_REVISION)", + "GenerateViiperUdeBuildIdentity", + `BeforeTargets="ClCompile"`, + "Get-ViiperUdeBuildIdentity.ps1", + `-OutputHeaderPath "$(IntDir)ViiperUdeBuildIdentity.g.h"`, + `$(IntDir);`, + "fails closed without an explicit source revision", + } { + if !strings.Contains(project, required) { + t.Fatalf("driver build omits fail-closed identity contract %q", required) + } + } + + for _, workflow := range []string{"build_base.yml", "native-ude.yml"} { + contents := read(".github", "workflows", workflow) + if !strings.Contains(contents, "VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}") { + t.Fatalf("%s does not inject the exact workflow source SHA", workflow) + } + } + + justfile := read("justfile") + if !strings.Contains(justfile, "Release builds require explicit VIIPER_NATIVE_SOURCE_REVISION.") || + !strings.Contains(justfile, "internal/transport/udecx.nativeSourceRevision=") { + t.Fatal("release broker build can silently omit its source-bound native identity") + } + + ioctl := read("native", "udecx", "driver", "Ioctl.c") + if !strings.Contains(ioctl, "ViiperUdeBuildIdentity.g.h") || + !strings.Contains(ioctl, "output->BuildIdentity") { + t.Fatal("kernel negotiation does not return the generated loaded-image identity") + } + + for _, script := range []string{ + "New-ViiperUdeAttestationPackage.ps1", + "Test-ViiperUdeSignedPackage.ps1", + "Test-ViiperUdeReleaseBundle.ps1", + } { + contents := read("native", "udecx", "tools", script) + for _, required := range []string{"Get-ViiperUdeBuildIdentity.ps1", "driverBuildIdentity"} { + if !strings.Contains(contents, required) { + t.Fatalf("%s omits package identity binding %q", script, required) + } + } + } +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go index cce42ad7..e1189a1d 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -93,8 +93,14 @@ type release struct { } func CheckUpdate(currentVersion string, notify config.UpdateNotify) { + // Source-bound local validation binaries are intentionally not release + // channels. They must never open update UI or emit a false installer error + // while an elevated package transaction is still running. + if currentVersion == "dev" || strings.HasSuffix(currentVersion, "-local-test") { + return + } cur, ok := parseVersion(currentVersion) - if !ok && currentVersion != "dev" { + if !ok { slog.Error("failed to parse current version", "version", currentVersion) return } diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index 768acd0b..48380d67 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -1,8 +1,13 @@ package updater import ( + "log/slog" + "net/http" "strings" "testing" + "time" + + "github.com/Alia5/VIIPER/internal/config" ) func TestRuntimeURLsUseHbashtonRepository(t *testing.T) { @@ -24,6 +29,39 @@ func TestRuntimeURLsUseHbashtonRepository(t *testing.T) { } } +func TestLocalTestBuildSkipsReleaseNetworkAndParseErrors(t *testing.T) { + previousClient := client + t.Cleanup(func() { client = previousClient }) + transportCalled := make(chan struct{}, 1) + client = &http.Client{ + Timeout: time.Second, + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + transportCalled <- struct{}{} + return nil, nil + }), + } + previousLogger := slog.Default() + t.Cleanup(func() { slog.SetDefault(previousLogger) }) + var records strings.Builder + slog.SetDefault(slog.New(slog.NewTextHandler(&records, nil))) + + CheckUpdate("0.1.0-local-test", config.UpdateNotifyStable) + select { + case <-transportCalled: + t.Fatal("local-test update check reached the network") + default: + } + if records.Len() != 0 { + t.Fatalf("local-test update log=%q", records.String()) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + func TestReleaseURLEscapesTag(t *testing.T) { t.Parallel() diff --git a/justfile b/justfile index de2c4fa3..7cf3d85f 100644 --- a/justfile +++ b/justfile @@ -13,6 +13,10 @@ rm_f := if os_family() == "windows" { "Remove-Item -Force -ErrorAction 0" } else version := env_var_or_default("VERSION", `git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always`) commit := `git rev-parse --short HEAD` +native_source_revision_explicit := env_var_or_default("VIIPER_NATIVE_SOURCE_REVISION", "") +# Debug/developer builds may bind the current checkout explicitly. Release +# recipes reject the absence of VIIPER_NATIVE_SOURCE_REVISION before compiling. +native_source_revision := if native_source_revision_explicit != "" { native_source_revision_explicit } else { `git rev-parse HEAD` } build_time := if os_family() == "windows" { `Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ'` } else { @@ -29,7 +33,7 @@ licenses_dir := join(dist_dir, "libVIIPER") licenses_out := join(dist_dir, "licenses.txt") lib_licenses_out := join(licenses_dir, "licenses.txt") -ldflags_common := "-X main.Version=" + version + " -X main.Commit=" + commit + " -X main.Date=" + build_time + " -X github.com/Alia5/VIIPER/internal/codegen/common.Version=" + version +ldflags_common := "-X main.Version=" + version + " -X main.Commit=" + commit + " -X main.Date=" + build_time + " -X github.com/Alia5/VIIPER/internal/codegen/common.Version=" + version + " -X github.com/Alia5/VIIPER/internal/transport/udecx.nativeSourceRevision=" + native_source_revision ldflags_release := "-s -w " + ldflags_common default: @@ -49,7 +53,7 @@ test-coverage: [windows] generate-versioninfo: - go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest + go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@v1.7.0 pwsh -NoProfile -NonInteractive -File scripts/inject-version.ps1 "{{ version }}" "versioninfo.json" "versioninfo.tmp.json" {{ if target_goarch == "amd64" { @@ -74,6 +78,7 @@ clean-versioninfo: [arg("type", pattern="Debug|Release")] [windows] build type=build_type: generate-versioninfo + if ("{{ type }}" -eq "Release" -and [string]::IsNullOrWhiteSpace($env:VIIPER_NATIVE_SOURCE_REVISION)) { throw "Release builds require explicit VIIPER_NATIVE_SOURCE_REVISION." } {{ mkdir_p }} {{ dist_dir }} $env:CGO_ENABLED='0'; go build {{ if type == "Release" { "-tags release" } else { "" } }} -trimpath -ldflags "{{ if type == "Release" { ldflags_release } else { ldflags_common } }}" -o {{ build_path }} {{ main_pkg }} just licenses @@ -81,6 +86,7 @@ build type=build_type: generate-versioninfo [arg("type", pattern="Debug|Release")] [unix] build type=build_type: + if [ "{{ type }}" = "Release" ] && [ -z "${VIIPER_NATIVE_SOURCE_REVISION:-}" ]; then echo "Release builds require explicit VIIPER_NATIVE_SOURCE_REVISION." >&2; exit 1; fi {{ mkdir_p }} {{ dist_dir }} CGO_ENABLED=0 go build {{ if type == "Release" { "-tags release" } else { "" } }} -trimpath -ldflags "{{ if type == "Release" { ldflags_release } else { ldflags_common } }}" -o {{ build_path }} {{ main_pkg }} just licenses @@ -89,7 +95,7 @@ build type=build_type: [windows] build-libVIIPER type=build_type: {{ mkdir_p }} dist/libVIIPER - go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest + go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@v1.7.0 pwsh -NoProfile -NonInteractive -File scripts/inject-version.ps1 "{{ version }}" "lib/viiper/versioninfo.json" "libviiper.versioninfo.tmp.json" goversioninfo -64 -o lib/viiper/resource.syso libviiper.versioninfo.tmp.json $env:CGO_ENABLED='1'; go build -buildmode=c-shared -trimpath {{ if type == "Release" { "-ldflags \"-s -w\"" } else { "" } }} -o dist/libVIIPER/libVIIPER.dll ./lib/viiper @@ -120,22 +126,22 @@ lint: [windows] licenses: - go install github.com/google/go-licenses/v2@latest + go install github.com/google/go-licenses/v2@v2.0.1 {{ mkdir_p }} {{ dist_dir }}; $template = (Get-Content {{ licenses_template }} -Raw).Replace('VERSION_PLACEHOLDER', '{{ version }}'); [System.IO.File]::WriteAllText("{{ licenses_template_work }}", $template, [System.Text.UTF8Encoding]::new($false)); $env:GOOS = ''; $env:GOARCH = ''; {{ go_licenses_cmd }} report {{ main_pkg }} --ignore {{ licenses_ignore }} --template {{ licenses_template_work }} | Set-Content -Encoding utf8 {{ licenses_out }}; Remove-Item -Force {{ licenses_template_work }} -ErrorAction SilentlyContinue [windows] licenses-libVIIPER: - go install github.com/google/go-licenses/v2@latest + go install github.com/google/go-licenses/v2@v2.0.1 {{ mkdir_p }} {{ licenses_dir }}; $template = (Get-Content {{ licenses_template }} -Raw).Replace('VERSION_PLACEHOLDER', '{{ version }}'); [System.IO.File]::WriteAllText("{{ licenses_template_work }}", $template, [System.Text.UTF8Encoding]::new($false)); $env:GOOS = ''; $env:GOARCH = ''; {{ go_licenses_cmd }} report ./lib/viiper --ignore {{ licenses_ignore }} --template {{ licenses_template_work }} | Set-Content -Encoding utf8 {{ lib_licenses_out }}; Remove-Item -Force {{ licenses_template_work }} -ErrorAction SilentlyContinue [unix] licenses: - go install github.com/google/go-licenses/v2@latest + go install github.com/google/go-licenses/v2@v2.0.1 {{ mkdir_p }} {{ dist_dir }} && sed "s/VERSION_PLACEHOLDER/{{ version }}/g" {{ licenses_template }} > {{ licenses_template_work }} && GOOS= GOARCH= {{ go_licenses_cmd }} report {{ main_pkg }} --ignore {{ licenses_ignore }} --template {{ licenses_template_work }} > {{ licenses_out }} && rm -f {{ licenses_template_work }} [unix] licenses-libVIIPER: - go install github.com/google/go-licenses/v2@latest + go install github.com/google/go-licenses/v2@v2.0.1 {{ mkdir_p }} {{ licenses_dir }} && sed "s/VERSION_PLACEHOLDER/{{ version }}/g" {{ licenses_template }} > {{ licenses_template_work }} && GOOS= GOARCH= {{ go_licenses_cmd }} report ./lib/viiper --ignore {{ licenses_ignore }} --template {{ licenses_template_work }} > {{ lib_licenses_out }} && rm -f {{ licenses_template_work }} run *args: build diff --git a/native/udecx/README.md b/native/udecx/README.md new file mode 100644 index 00000000..9bd940b1 --- /dev/null +++ b/native/udecx/README.md @@ -0,0 +1,360 @@ +# VIIPER native UdeCx bus + +This directory contains the Windows native USB device-emulation layer. It is +developed on `feature/native-udecx-bus`; it does not alter the supported USB/IP +implementation on `main` while the native path is incomplete. + +Directory contract: + +- `include/` is the stable C ABI shared by the driver and Go broker. +- `driver/` is the KMDF/UdeCx controller driver. +- `package/` contains INF and installation metadata. + +Release builds must receive `VIIPER_NATIVE_SOURCE_REVISION` from the protected +build job; both driver and broker fail closed when it is absent. `just build +Debug` is the only convenience path that may bind the current checkout HEAD +into a local debug broker when that variable is omitted. Direct driver builds +always require the explicit property/environment input. The debug fallback is +never accepted by a Release recipe or production workflow. + +- `tools/ViiperUdeCtl.cpp` installs, verifies, or removes the exact root + controller as a driver-store transaction. Installation requires the + source-revision submission manifest, verifies the catalog signature and + four-part `DriverVer`, rejects same-version replacement and implicit + downgrade, add-only stages and verifies a missing candidate before + quiescence, records the prior published INF, and negotiates the ABI plus the + source-bound identity embedded in the currently loaded kernel image after + start. A stale same-ABI driver cannot satisfy health. Fixed protected, + write-through install and remove journals record exact identities, backups, + mutation receipts, reboot epochs, and hash-chained cut points before each + boundary, so restart reconciliation can finish forward or restore narrowly + without adopting concurrent state. Removal backs up every exact signed + VIIPER package before deleting only the captured owned devnode and packages; + unrelated driver-store entries are never force-deleted. +- `tools/Test-ViiperUdeCtlTransaction.ps1` deterministically guards the + transaction, rollback, ownership, downgrade, and structured-reboot source + contracts. Passing a compiled tool through `-BinaryPath` also runs its pure + parser/version self-test without changing driver state. +- `tools/New-ViiperUdeAttestationPackage.ps1` creates and hash-verifies the + exact controlled-test Hardware Dev Center CAB structure and requires an + explicit testing-only acknowledgement. Its schema-2 manifest binds the + source revision, DriverVer, ABI, exact capability mask, and loaded-image + build identity. Microsoft currently restricts + attestation to testing scenarios; production release requires HLK/WHCP. +- `tools/New-ViiperUdeLocalTestPackage.ps1` composes the exact WDK test-signed + driver, source-bound broker/helper, and live probes into a compact + short-retention artifact. `tools/Install-ViiperUdeLocalTest.ps1` accepts it + only on an elevated disposable machine whose current boot entry has + `TESTSIGNING` enabled, imports its exact hash-bound test certificate, and + runs the same driver-plus-broker transaction and authenticated health proof + used by production. This route is never release-eligible. +- `tools/Test-ViiperUdeSignedPackage.ps1` validates the Microsoft-returned + driver and catalog against kernel signing policy, proves that INF and SYS are + members of that exact catalog, distinguishes testing-only attestation from + production HLK/WHCP signatures, and binds the returned INF/PDB to the + reviewed source-revision manifest. The PDB stays in that certification + evidence artifact; the installable runtime bundle contains only the + validated INF/SYS/CAT plus the pinned manifest. +- `tools/Invoke-ViiperUdeLiveValidation.ps1` hash-binds that verified package + to the installed service image and root devnode, then exercises every + production controller through the real UdeCx host, direct interrupt-input + path, generation teardown, and driver fault counters. It never installs or + changes a driver. +- `tools/Invoke-ViiperUdePerformanceValidation.ps1` wraps that exact signed + live gate in a uniquely named, bounded-memory Windows Performance Recorder + session. It preserves an ETL on success or workload failure without stopping + another recorder instance, enabling CPU sampled/precise, ready-thread, + context-switch, WDF DPC, interrupt, and ISR analysis before performance code + is changed. +- `tools/Enable-ViiperUdeVerifierForNextBoot.ps1` stages Microsoft standard + Driver Verifier checks for `ViiperUde.sys` for exactly one boot. It refuses + daily-use machines unless the disposable-machine acknowledgement is given, + refuses to replace another driver's verifier configuration, and never + restarts the machine. +- `tools/ViiperUdeMediaProbe.cpp` is a dependency-free CoreAudio live probe. + It snapshots active endpoints before a virtual PlayStation controller is + created, opens exactly the new render/capture pair concurrently through + WASAPI, and lets the signed-driver test require real ISO traffic and bytes in + both directions rather than treating endpoint enumeration as media success. +- `tools/ViiperUdeInputProbe.cpp` follows Microsoft's HIDClass discovery and + continuous `ReadFile`/`WriteFile` contracts. It snapshots existing HID + collections, opens only the newly enumerated matching gamepad, timestamps + unique state markers with the system-wide performance counter, and writes a + versioned feedback marker containing rumble, LEDs, and adaptive-trigger + state. The signed live gate therefore measures the complete + publisher-to-Windows-HID path and proves the reverse HIDClass-to-device + path instead of trusting internal queue approximations. +- ABI, lifecycle, descriptor, cancellation, and fault tests live beside the Go + broker packages and in the native-driver CI gates. + +The interrupt-IN path follows a proven pending-read principle without +copying its target-specific implementation. Each endpoint owns a preallocated, +sequence-checked latest-state cache. A report arriving before a Windows poll is +retained and completed after KMDF's manual-queue ready notification crosses a +preallocated passive work-item boundary; the notification itself can run +synchronously on UdeCx's submitter thread and therefore never completes the URB. +One token permits exactly one later cached completion, so the successor poll is +left parked for the next producer instead of replaying the cache in a busy loop. +Reset, purge, D0 exit, and device reset invalidate both the cache and token +behind the same admission barriers used by the direct producer. This removes +the old lost-rendezvous window and never requires an extra feeder update to wake +the first already-posted host poll. +The Go publisher likewise owns one descriptor-sized buffer per active endpoint. +Every production HID engine encodes directly into that buffer, which is reused +only after the overlapped IOCTL has completed and the kernel has copied the +report. Allocation gates enforce zero heap allocations in those report +encoders; USB/IP and third-party device engines retain their existing ownership +contract through the optional interface. +DualSense and DualShock 4 microphone engines use the same optional +caller-buffer rule for native isochronous IN. The broker invokes them only at +the endpoint's reserved service time, and they write directly into the current +URB packet region without a second timer or packet allocation. Nominal-only URB +capacity never causes an adaptive long packet to be consumed and truncated. +`InputReportsSubmitted` counts accepted state publications and +`InputReportsCompleted` counts host polls served from them. Multiple publications +can coalesce into one latest state before Windows polls, but one publication can +never manufacture multiple completions. + +Input path selection is observable at publisher activation. `Host.InputDiagnostics` +counts publisher starts, legacy transfer fallbacks, and per-report deadline-context +fallbacks without adding per-report atomics. Either compatibility path also emits +a structured warning containing the device, device generation, endpoint, endpoint +generation, fallback name, and reason, so a production run cannot silently claim +the scheduled direct-input path. + +The kernel lifecycle recorder uses bounded nonpaged, cache-isolated shards and +retains the globally latest 512 stable records whenever its sticky status is +clean, without locks, allocation, or waits on the trace hot path. Monotonic +slot claims prevent a preempted writer from +overwriting a newer wrap; any active-slot collision is dropped and made sticky. +Two-second endpoint, completion, controller, and owner rundown watchdog records +preserve the active count and queue state while the driver continues the +safety-required join. Sticky drop/watchdog status survives record-window rollover, +and the signed live teardown audit treats either status as a failure. + +The design and release gates are in +`docs/architecture/native-udecx.md`. The Microsoft signing boundary is in +`docs/architecture/native-udecx-signing.md`. + +Production installation is intentionally available only through the signed +package orchestrator, which binds the broker/helper/manifest hashes and keeps +the driver rollback snapshot alive through authenticated broker health. Driver +and broker recovery are separately journaled under protected fixed ProgramData +roots. A two-phase receipt binds both transaction IDs, both pending and final +journal digests, the package token, candidate identity, settlement nonce, and +request hash before either side retires authoritative evidence. An interrupted +`nested-ready`, pending acknowledgement, or final settlement is replayed +idempotently before any new package child may start. An operator can run the +same read-only production preflight without mutation: + +```powershell +$manifest = 'C:\ViiperUde\ViiperUde.cab.sha256.json' +$deadline = [DateTimeOffset]::UtcNow.AddMinutes(4).ToUnixTimeMilliseconds() +.\ViiperUdeCtl.exe verify C:\ViiperUde\Signed\ViiperUde.inf ` + --manifest $manifest ` + --manifest-sha256 (Get-FileHash -Algorithm SHA256 -LiteralPath $manifest).Hash ` + --source-revision 0123456789abcdef0123456789abcdef01234567 ` + --validation-mode production ` + --transaction-deadline-unix-ms $deadline +``` + +The only forced selection available to an operator is an intentional downgrade +guarded by the exact currently installed version, for example +`--allow-controlled-downgrade 0.2.0.0`. Rollback may internally force the exact +previously captured signed INF because returning to that known state is the +transaction's recovery operation. Exit `0` means verified success, `3010` +means verified installation/removal requires a restart, `4` is a preflight +rejection, and `3` means rollback itself failed. Every command emits one final +key/value result line including `rebootRequired` and rollback status. + +For an exact branch build on a disposable local-test machine, download the +`ViiperUde-x64-local-test-` artifact from a manually dispatched +native workflow. Copy the `Local test package lock SHA-256` value from that +exact workflow log as the out-of-band artifact binding, then run from the +matching source checkout. The installer holds its own script file deny-write +and deny-delete and requires its SHA-256 to match that authenticated lock; it +does not execute Git hooks or another repository PowerShell script while +elevated: + +```powershell +.\native\udecx\tools\Install-ViiperUdeLocalTest.ps1 ` + -PackageRoot C:\ViiperUdeLocalTest ` + -ExpectedSourceRevision 0123456789abcdef0123456789abcdef01234567 ` + -ExpectedPackageLockSHA256 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef ` + -TargetUserSID S-1-5-21-111111111-222222222-333333333-1001 ` + -AcknowledgeDisposableTestMachine +``` + +The local route does not bypass driver signing: the SYS and catalog must carry +the exact WDK test signature sealed into the artifact lock, and Windows must +trust that certificate while `TESTSIGNING` is active. It does not change the +production Microsoft HLK/WHCP gate. + +Exit `3010` means the attempted native transaction was safely rolled back: +reboot, rerun the identical installer command, and do not start live validation +until that retry returns exit `0`. After verified installation, the same +artifact supplies the source-bound evidence and probes for the real UdeCx test: + +```powershell +.\native\udecx\tools\Invoke-ViiperUdeLiveValidation.ps1 ` + -SignedPackageDirectory C:\ViiperUdeLocalTest\signed-package ` + -SubmissionManifestPath C:\ViiperUdeLocalTest\submission-manifest.json ` + -ExpectedSourceRevision 0123456789abcdef0123456789abcdef01234567 ` + -SignatureValidationMode LocalTest ` + -LocalTestCertificatePath C:\ViiperUdeLocalTest\ViiperUdeTest.cer ` + -MediaProbePath C:\ViiperUdeLocalTest\ViiperUdeMediaProbe.exe ` + -InputProbePath C:\ViiperUdeLocalTest\ViiperUdeInputProbe.exe ` + -ProbeManifestPath C:\ViiperUdeLocalTest\ViiperUdeLiveProbes.manifest.json ` + -Iterations 10 -MediaDurationSeconds 30 -DisposableTestMachine ` + -ManageInstalledBrokerService +``` + +Production uninstall is similarly owned by the signed installer. It calls +`viiper uninstall` with the packaged `ViiperUdeCtl.exe`, the installer-bound +helper SHA-256, and the target-user SID. The broker is only stopped while the +helper transaction runs; its SCM registration, credential, and managed files +remain available for exact restart unless removal succeeds. For a direct +operator inspection of the helper boundary, use a cooperative deadline: + +```powershell +$deadline = [DateTimeOffset]::UtcNow.AddMinutes(4).ToUnixTimeMilliseconds() +.\ViiperUdeCtl.exe remove --transaction-deadline-unix-ms $deadline +``` + +Only exit `0` or `3010` authorizes exact broker/credential/file cleanup. A +preflight rejection or verified no-reboot `rollback=succeeded` preserves the +prior broker run-state; a reboot-pending or unverified rollback leaves it +stopped for explicit reconciliation. +The production outer command never removes legacy tasks, Run registrations, or +USB/IP state. + +After a Microsoft-signed native driver package has been installed and verified, +the developer-only standalone registration can persist the preview transport: + +```powershell +$env:VIIPER_DEVELOPER_STANDALONE = '1' +.\viiper.exe install --transport native-ude +``` + +This skips the USB/IP runtime prerequisite and records +`server --transport native-ude` in the startup command. It does not install or +trust an unsigned kernel driver. The default remains `usbip` until the signed +live-driver gates in the architecture document pass. + +On a disposable elevated test machine, validate an already-installed +Microsoft-signed package with: + +```powershell +.\native\udecx\tools\Invoke-ViiperUdeLiveValidation.ps1 ` + -SignedPackageDirectory C:\ViiperUde\MicrosoftSigned ` + -SubmissionManifestPath C:\ViiperUde\ViiperUde.cab.sha256.json ` + -ExpectedSourceRevision 0123456789abcdef0123456789abcdef01234567 ` + -SignatureValidationMode Production ` + -Iterations 10 ` + -MediaDurationSeconds 30 ` + -MediaProbePath .\native\udecx\x64\Release\ViiperUdeMediaProbe.exe ` + -InputProbePath .\native\udecx\x64\Release\ViiperUdeInputProbe.exe ` + -ProbeManifestPath .\native\udecx\x64\Release\ViiperUdeLiveProbes.manifest.json ` + -ManageInstalledBrokerService +``` + +The command refuses an unsigned package, a package/service hash mismatch, a +non-Microsoft root devnode, a dirty driver session, or any increase in invalid +messages, queue exhaustion, notification overflow, late completion, or cleanup +retry counters. Production mode also requires this repository to be an exact, +clean checkout of `-ExpectedSourceRevision` and runs the Go harness with module, +workspace, environment, and toolchain overrides disabled. After validating +each controller and repeated generation +rollover independently, it enumerates the complete production controller set, +publishes input, and removes every child concurrently. A subprocess then exits +without running cleanup; the driver must remove its child, drain pending URBs, +release exclusive ownership, and accept a fresh session. Normal CI never opts +into this live test. +When `-MediaProbePath` is supplied, the first DualShock 4 and DualSense +generation must also create one new render/capture endpoint pair. Simultaneous +WASAPI render/capture must preserve the controller's declared format and frame +cadence, keep the render buffer nonempty, preserve monotonic capture clocks, +report no capture discontinuity/timestamp flags, and increase native ISO, +host-to-device, and device-to-host byte counters. The baseline snapshot prevents +a connected physical controller from being mistaken for the virtual device. +When `-InputProbePath` is supplied, the first DualShock 4, DualSense, and +DualSense Edge generations each publish 256 alternating stick markers. QPC is +sampled immediately before publication and when a continuous HID `ReadFile` +observes the matching report. The release gate requires p95 <= 4 ms, +p99 <= 8 ms, and maximum <= 20 ms, including user-mode scheduling, the native +IOCTL, UdeCx, and HIDClass. These are measured long-tail limits, not claims +derived from the nominal USB polling interval. The same newly enumerated HID +collection must then accept a full-length overlapped `WriteFile`; exact +DualShock 4 rumble/lightbar data or exact DualSense rumble, lightbar, player +LED, and left/right adaptive-trigger data must arrive at the corresponding +VIIPER device callback, and the driver's completion and host-to-device byte +counters must advance. +On Windows 10 2004 or newer, `-RestartRootDevice -DisposableTestMachine` +restarts the exact signed root devnode with a live DualSense child and input +publisher. The invalidated owner must terminate, the restarted controller must +return with zero children and pending requests, and a fresh exclusive session +must re-enumerate, service input, and tear down cleanly. No wildcard or +hardware-ID-wide PnP operation is used. + +The Driver Verifier pass is a separate, explicit disposable-machine gate: + +```powershell +.\native\udecx\tools\Enable-ViiperUdeVerifierForNextBoot.ps1 ` + -SignedPackageDirectory C:\ViiperUde\MicrosoftSigned ` + -SubmissionManifestPath C:\ViiperUde\ViiperUde.cab.sha256.json ` + -ExpectedSourceRevision 0123456789abcdef0123456789abcdef01234567 ` + -SignatureValidationMode Production ` + -DisposableTestMachine +# Restart once, then: +.\native\udecx\tools\Invoke-ViiperUdeLiveValidation.ps1 ` + -SignedPackageDirectory C:\ViiperUde\MicrosoftSigned ` + -SubmissionManifestPath C:\ViiperUde\ViiperUde.cab.sha256.json ` + -ExpectedSourceRevision 0123456789abcdef0123456789abcdef01234567 ` + -SignatureValidationMode Production ` + -Iterations 3 ` + -ReleaseGate ` + -RequireDriverVerifier ` + -RestartRootDevice ` + -DisposableTestMachine ` + -MediaDurationSeconds 180 ` + -MediaProbePath .\native\udecx\x64\Release\ViiperUdeMediaProbe.exe ` + -InputProbePath .\native\udecx\x64\Release\ViiperUdeInputProbe.exe ` + -ProbeManifestPath .\native\udecx\x64\Release\ViiperUdeLiveProbes.manifest.json +``` + +`-ReleaseGate` is fail-closed: it requires a 64-bit Windows 11 client with +Secure Boot, a production Microsoft signature, Driver Verifier `/standard` +including KMDF verification, three lifecycle generations, both independent +source/hash-bound probes, an active root-device restart, the disposable-machine +acknowledgement, and a three-minute clean duplex media run for each PlayStation +controller, including DualSense Edge. Omitting any one of those inputs cannot +print a production-pass result. + +Microsoft warns that Driver Verifier can intentionally bugcheck a machine; +this workflow is never run by ordinary CI, an installer, or DS4Windows. + +For evidence-based CPU and scheduler analysis, run the same signed workload +inside WPR's bounded `GeneralProfile.Verbose` memory profile. The verbose form +is required because the light form records scheduler events but omits the +CSwitch, ReadyThread, and sampled-profile stacks needed to attribute latency: + +```powershell +.\native\udecx\tools\Invoke-ViiperUdePerformanceValidation.ps1 ` + -SignedPackageDirectory C:\ViiperUde\MicrosoftSigned ` + -SubmissionManifestPath C:\ViiperUde\ViiperUde.cab.sha256.json ` + -ExpectedSourceRevision 0123456789abcdef0123456789abcdef01234567 ` + -SignatureValidationMode Production ` + -OutputPath C:\ViiperUde\Traces\native-ude.etl ` + -MediaProbePath .\native\udecx\x64\Release\ViiperUdeMediaProbe.exe ` + -InputProbePath .\native\udecx\x64\Release\ViiperUdeInputProbe.exe ` + -ProbeManifestPath .\native\udecx\x64\Release\ViiperUdeLiveProbes.manifest.json +``` + +Open the ETL in Windows Performance Analyzer and inspect CPU Usage (Sampled), +CPU Usage (Precise), scheduler stacks, and DPC/ISR module activity. A non-empty +ETL is evidence capture, not a performance pass: acceptance still requires WPA +analysis against the architecture thresholds. The adjacent `.evidence.json` +hash-binds the ETL, signed-package manifest, exact source revision, and both +probes. The script rejects dropped events, never uses WPR file mode (which +Microsoft documents as unbounded), and never mutates an unnamed or foreign +recording session. diff --git a/native/udecx/THIRD_PARTY_NOTICES.md b/native/udecx/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..3b6e8dfa --- /dev/null +++ b/native/udecx/THIRD_PARTY_NOTICES.md @@ -0,0 +1,15 @@ +# Native UDE development references + +The VIIPER native UdeCx implementation is original project code. The following +projects are used as protocol and architecture references: + +- **ViGEmBus**, BSD 3-Clause: target lifecycle, request ownership, manual queues, + concurrency, cancellation, and version negotiation. +- **usbip-win2**, BSD 2-Clause: documented UdeCx endpoint lifecycle and proof of + bidirectional isochronous operation on Windows. +- **Microsoft Windows Driver Samples**, MIT: supported KMDF project and CI + patterns. + +No third-party binary is redistributed by this directory. Any source adapted in +the future must retain its applicable license notice in the affected file and +in packaged notices. diff --git a/native/udecx/ViiperUde.sln b/native/udecx/ViiperUde.sln new file mode 100644 index 00000000..2e2cb92b --- /dev/null +++ b/native/udecx/ViiperUde.sln @@ -0,0 +1,19 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{BC8A1FFA-BEE3-4634-8014-F334798102B3}") = "ViiperUde", "driver\ViiperUde.vcxproj", "{74754772-2AA1-4CE6-B251-0A3DD40A46E1}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|x64 = Debug|x64 + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {74754772-2AA1-4CE6-B251-0A3DD40A46E1}.Debug|x64.ActiveCfg = Debug|x64 + {74754772-2AA1-4CE6-B251-0A3DD40A46E1}.Debug|x64.Build.0 = Debug|x64 + {74754772-2AA1-4CE6-B251-0A3DD40A46E1}.Release|x64.ActiveCfg = Release|x64 + {74754772-2AA1-4CE6-B251-0A3DD40A46E1}.Release|x64.Build.0 = Release|x64 + EndGlobalSection +EndGlobal + diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c new file mode 100644 index 00000000..b084eaef --- /dev/null +++ b/native/udecx/driver/Broker.c @@ -0,0 +1,3003 @@ +/* + * Bounded, single-owner user/kernel transfer broker. + * + * Every submitted URB occupies one preallocated slot. Tokens encode the slot + * and a monotonically increasing generation, which makes completion lookup + * O(1) and rejects stale/duplicate replies without allocating on the media + * path. Cancellation is handed between WDF and the broker with an explicit + * unmark/remark boundary while a request is serialized to user mode. + */ + +#include "ViiperUde.h" + +EVT_WDF_REQUEST_CANCEL ViiperEvtUrbCancel; + +static VOID ViiperDispatchAvailable(_In_ WDFDEVICE Controller); + +VOID +ViiperCompleteUnownedUrb( + _In_ WDFDEVICE Controller, + _In_ WDFREQUEST Request, + _In_ NTSTATUS Status + ) +{ + VIIPER_UDE_REQUEST_CONTEXT *requestContext = ViiperGetRequestContext(Request); + BOOLEAN queued; + + // ViiperQueueUrb starts endpoint rundown before it can reject admission, + // so even an untracked failure remains owned until the DPC completes it. + NT_ASSERT(requestContext->Controller == Controller); + NT_ASSERT(requestContext->Endpoint != WDF_NO_HANDLE); + queued = ViiperQueueUrbCompletion( + Controller, + requestContext->Endpoint, + Request, + VIIPER_UDE_MAX_PENDING_OPERATIONS, + 0, + Status, + USBD_STATUS_INTERNAL_HC_ERROR, + TRUE, + 0, + 0); + if (!queued) { + NT_ASSERT(FALSE); + } +} + +static +BOOLEAN +ViiperFaultBrokerLocked( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext + ) +{ + VIIPER_UDE_NOTIFICATION *event; + + InterlockedIncrement64(&ControllerContext->NotificationEventOverflows); + if (InterlockedCompareExchange(&ControllerContext->BrokerFaulted, TRUE, FALSE) != FALSE) { + return FALSE; + } + if (ControllerContext->NotificationCount >= VIIPER_UDE_MAX_PENDING_OPERATIONS) { + return FALSE; + } + + event = &ControllerContext->Notifications[ControllerContext->NotificationTail]; + RtlZeroMemory(event, sizeof(*event)); + event->Kind = ViiperUdeOperationBrokerFault; + ControllerContext->NotificationTail = (ControllerContext->NotificationTail + 1) % + VIIPER_UDE_MAX_PENDING_OPERATIONS; + ++ControllerContext->NotificationCount; + return TRUE; +} + +static +BOOLEAN +ViiperQueueCancelEventLocked( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ const VIIPER_UDE_PENDING_SLOT *Pending + ) +{ + VIIPER_UDE_NOTIFICATION *event; + + if (!Pending->PublishedToOwner) { + return FALSE; + } + // BrokerFaulted is a terminal owner-session boundary. The one broker + // fault notification already tells user mode to tear down; admitting more + // cancel records after it can only delay that terminal record and consume + // the queue capacity reserved for lifecycle ordering. + if (InterlockedCompareExchange( + &ControllerContext->BrokerFaulted, FALSE, FALSE) != FALSE) { + return FALSE; + } + // Keep one slot reserved for a broker-fault event. Losing cancellation or + // lifecycle state is not recoverable within the current owner session. + if (ControllerContext->NotificationCount >= VIIPER_UDE_MAX_PENDING_OPERATIONS - 1) { + return ViiperFaultBrokerLocked(ControllerContext); + } + + event = &ControllerContext->Notifications[ControllerContext->NotificationTail]; + event->Token = Pending->Token; + event->DeviceId = Pending->DeviceId; + event->EndpointSequence = 0; + event->DeviceSequence = 0; + event->Generation = Pending->DeviceGeneration; + event->EndpointGeneration = Pending->EndpointGeneration; + event->Kind = ViiperUdeOperationCancel; + event->EndpointAddress = Pending->EndpointAddress; + event->InterfaceNumber = 0; + event->InterfaceSetting = 0; + ControllerContext->NotificationTail = (ControllerContext->NotificationTail + 1) % + VIIPER_UDE_MAX_PENDING_OPERATIONS; + ++ControllerContext->NotificationCount; + return TRUE; +} + +static +VOID +ViiperDispatchNotificationEvents( + _In_ WDFDEVICE Controller + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + + for (;;) { + WDFREQUEST dequeueRequest = WDF_NO_HANDLE; + VIIPER_UDE_OPERATION *operation = NULL; + VIIPER_UDE_NOTIFICATION event = {0}; + NTSTATUS status; + + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0) { + break; + } + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0) { + WdfSpinLockRelease(controllerContext->BrokerLock); + break; + } + if (controllerContext->NotificationCount == 0) { + WdfSpinLockRelease(controllerContext->BrokerLock); + break; + } + status = WdfIoQueueRetrieveNextRequest( + controllerContext->WaitingDequeues, &dequeueRequest); + if (!NT_SUCCESS(status)) { + WdfSpinLockRelease(controllerContext->BrokerLock); + break; + } + InterlockedDecrement(&controllerContext->WaitingDequeueCount); + status = WdfRequestRetrieveOutputBuffer( + dequeueRequest, sizeof(*operation), (PVOID *)&operation, NULL); + if (NT_SUCCESS(status)) { + event = controllerContext->Notifications[controllerContext->NotificationHead]; + controllerContext->NotificationHead = (controllerContext->NotificationHead + 1) % + VIIPER_UDE_MAX_PENDING_OPERATIONS; + --controllerContext->NotificationCount; + if (((ULONG)event.Token & VIIPER_UDE_MANAGEMENT_SLOT_FLAG) != 0) { + ULONG managementSlot = ((ULONG)event.Token & + ~VIIPER_UDE_MANAGEMENT_SLOT_FLAG) - 1; + if (managementSlot >= VIIPER_UDE_MAX_PENDING_MANAGEMENT || + (controllerContext->ManagementSlots[managementSlot].Token != event.Token || + controllerContext->ManagementSlots[managementSlot].State != + ViiperUdePendingQueued || + controllerContext->ManagementSlots[managementSlot].DeviceId != + event.DeviceId || + controllerContext->ManagementSlots[managementSlot].DeviceGeneration != + event.Generation || + controllerContext->ManagementSlots[managementSlot].EndpointGeneration != + event.EndpointGeneration) && + (!controllerContext->ManagementSlots[managementSlot].RetiredNotificationPending || + controllerContext->ManagementSlots[managementSlot].RetiredToken != event.Token || + controllerContext->ManagementSlots[managementSlot].RetiredDeviceId != + event.DeviceId || + controllerContext->ManagementSlots[managementSlot].RetiredDeviceGeneration != + event.Generation || + controllerContext->ManagementSlots[managementSlot].RetiredEndpointGeneration != + event.EndpointGeneration)) { + status = STATUS_INVALID_DEVICE_STATE; + } else if (controllerContext->ManagementSlots[ + managementSlot].RetiredNotificationPending) { + // Teardown retired the held UdeCx request before this + // queued notification crossed to user mode. Consume its + // WDF-free tombstone in O(1) and publish a benign cancel + // record, which the host handles before lane tracking. + controllerContext->ManagementSlots[ + managementSlot].RetiredNotificationPending = FALSE; + controllerContext->ManagementSlots[managementSlot].RetiredToken = 0; + controllerContext->ManagementSlots[managementSlot].RetiredDeviceId = 0; + controllerContext->ManagementSlots[ + managementSlot].RetiredDeviceGeneration = 0; + controllerContext->ManagementSlots[ + managementSlot].RetiredEndpointGeneration = 0; + controllerContext->ManagementSlots[ + managementSlot].RetiredOwnerFile = WDF_NO_HANDLE; + event.Kind = ViiperUdeOperationCancel; + } else { + controllerContext->ManagementSlots[managementSlot].State = + ViiperUdePendingInFlight; + } + } + } + WdfSpinLockRelease(controllerContext->BrokerLock); + + if (!NT_SUCCESS(status)) { + WdfRequestComplete(dequeueRequest, status); + continue; + } + + RtlZeroMemory(operation, sizeof(*operation)); + operation->Header.Magic = VIIPER_UDE_MAGIC; + operation->Header.Major = VIIPER_UDE_ABI_MAJOR; + operation->Header.Minor = VIIPER_UDE_ABI_MINOR; + operation->Header.Size = sizeof(*operation); + operation->Token = event.Token; + operation->DeviceId = event.DeviceId; + operation->Generation = event.Generation; + operation->Kind = event.Kind; + operation->EndpointAddress = event.EndpointAddress; + operation->InterfaceNumber = event.InterfaceNumber; + operation->InterfaceSetting = event.InterfaceSetting; + operation->EndpointAttributes = event.EndpointAttributes; + operation->EndpointInterval = event.EndpointInterval; + operation->EndpointMaxPacketSize = event.EndpointMaxPacketSize; + operation->EndpointSequence = event.EndpointSequence; + operation->DeviceSequence = event.DeviceSequence; + operation->EndpointGeneration = event.EndpointGeneration; + // Lifecycle and cancel notifications have an empty canonical tail. + // Keep both offsets at the first byte after the fixed header so the + // same strict parser contract applies to notifications and URBs. + operation->IsoPacketsOffset = sizeof(*operation); + operation->PayloadOffset = sizeof(*operation); + WdfRequestSetInformation(dequeueRequest, sizeof(*operation)); + InterlockedIncrement64(&controllerContext->NotificationEventsDelivered); + WdfRequestComplete(dequeueRequest, STATUS_SUCCESS); + } +} + +static +VOID +ViiperPendingOperationStartedLocked( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext + ) +{ + // All callers hold BrokerLock. Clear before publishing the 0 -> 1 + // transition so teardown can never observe a stale signaled event. + if (InterlockedCompareExchange(&ControllerContext->PendingOperations, 0, 0) == 0) { + KeClearEvent(&ControllerContext->BrokerOperationsDrained); + } + (VOID)InterlockedIncrement(&ControllerContext->PendingOperations); +} + +static +VOID +ViiperPendingOperationCompletedLocked( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext + ) +{ + LONG remaining = InterlockedDecrement(&ControllerContext->PendingOperations); + + NT_ASSERT(remaining >= 0); + if (remaining == 0) { + KeSetEvent(&ControllerContext->BrokerOperationsDrained, IO_NO_INCREMENT, FALSE); + } +} + +static +VOID +ViiperClearManagementSlotLocked( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ ULONG Slot, + _Out_ UDECXUSBDEVICE *DeviceReference, + _Out_ UDECXUSBENDPOINT *EndpointReference + ) +{ + VIIPER_UDE_MANAGEMENT_SLOT *pending = &ControllerContext->ManagementSlots[Slot]; + + *DeviceReference = pending->Device; + *EndpointReference = pending->Endpoint; + pending->Request = WDF_NO_HANDLE; + pending->Device = WDF_NO_HANDLE; + pending->Endpoint = WDF_NO_HANDLE; + pending->OwnerFile = WDF_NO_HANDLE; + pending->Token = 0; + pending->DeviceId = 0; + pending->ResetEpoch = 0; + pending->DeviceGeneration = 0; + pending->EndpointGeneration = 0; + pending->State = ViiperUdePendingEmpty; + pending->Kind = 0; + pending->EndpointAddress = 0; + ViiperPendingOperationCompletedLocked(ControllerContext); +} + +static +VOID +ViiperReleaseManagementSlotReferences( + _In_opt_ UDECXUSBDEVICE Device, + _In_opt_ UDECXUSBENDPOINT Endpoint + ) +{ + // Dereferencing can make framework cleanup runnable. Never do it under + // BrokerLock: endpoint/device cleanup also uses that lock to close + // admission, and WDF references postpone destruction rather than the + // documented EvtCleanup no-access boundary. + if (Endpoint != WDF_NO_HANDLE) { + WdfObjectDereference(Endpoint); + } + if (Device != WDF_NO_HANDLE) { + WdfObjectDereference(Device); + } +} + +static +VOID +ViiperUnlinkAdmissionLocked( + _In_ VIIPER_UDE_PENDING_SLOT *Pending + ) +{ + if (!Pending->AdmissionLinked) { + return; + } + RemoveEntryList(&Pending->AdmissionEntry); + InitializeListHead(&Pending->AdmissionEntry); + Pending->AdmissionLinked = FALSE; +} + +static +BOOLEAN +ViiperAdmissionCanPublishLocked( + _In_ const VIIPER_UDE_PENDING_SLOT *Pending + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext; + + if (!Pending->AdmissionLinked || Pending->Endpoint == WDF_NO_HANDLE) { + return FALSE; + } + endpointContext = ViiperGetEndpointContext(Pending->Endpoint); + if (Pending->EndpointGeneration == 0 || + Pending->EndpointGeneration != endpointContext->Generation) { + return FALSE; + } + return endpointContext->AdmissionQueue.Flink == &Pending->AdmissionEntry; +} + +static +VOID +ViiperEndpointOperationCompletedLocked( + _In_ UDECXUSBENDPOINT Endpoint + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + LONG remaining = InterlockedDecrement(&endpointContext->ActiveOperations); + NT_ASSERT(remaining >= 0); + if (remaining == 0) { + KeSetEvent(&endpointContext->OperationsDrained, IO_NO_INCREMENT, FALSE); + } +} + +static +VOID +ViiperClearSlotLocked( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ ULONG Slot + ) +{ + VIIPER_UDE_PENDING_SLOT *pending = &ControllerContext->PendingSlots[Slot]; + UDECXUSBENDPOINT endpoint = pending->Endpoint; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = NULL; + + if (endpoint != WDF_NO_HANDLE) { + deviceContext = ViiperGetDeviceContext(ViiperGetEndpointContext(endpoint)->Device); + } + + ViiperUnlinkAdmissionLocked(pending); + pending->Request = WDF_NO_HANDLE; + pending->Endpoint = WDF_NO_HANDLE; + pending->Token = 0; + pending->DeviceId = 0; + pending->AdmissionSequence = 0; + pending->DeviceGeneration = 0; + pending->EndpointGeneration = 0; + pending->State = ViiperUdePendingEmpty; + pending->AbortPending = FALSE; + pending->PublishedToOwner = FALSE; + pending->AdmissionLinked = FALSE; + pending->EndpointAddress = 0; + pending->AbortStatus = STATUS_SUCCESS; + pending->CompletionStatus = STATUS_SUCCESS; + pending->CompletionUsbdStatus = USBD_STATUS_SUCCESS; + pending->CompleteWithNtStatus = FALSE; + ViiperPendingOperationCompletedLocked(ControllerContext); + if (deviceContext != NULL) { + InterlockedDecrement(&deviceContext->PendingOperations); + } + if (endpoint != WDF_NO_HANDLE) { + ViiperEndpointOperationCompletedLocked(endpoint); + } +} + +_IRQL_requires_max_(DISPATCH_LEVEL) +VOID +ViiperEndpointOperationStarted( + _In_ UDECXUSBENDPOINT Endpoint + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + LONG active = InterlockedCompareExchange(&endpointContext->ActiveOperations, 0, 0); + + // Every caller holds the controller BrokerLock. The same lock owns the + // final decrement below, so clearing before the 0 -> 1 increment is one + // linearized transaction. Without BrokerLock a concurrent 1 -> 0 + // completion could signal between those steps; incrementing first instead + // would expose active == 1 while the drain event was still signaled. + NT_ASSERT(active >= 0); + if (active == 0) { + KeClearEvent(&endpointContext->OperationsDrained); + } + active = InterlockedIncrement(&endpointContext->ActiveOperations); + NT_ASSERT(active > 0); +} + +_IRQL_requires_max_(DISPATCH_LEVEL) +VOID +ViiperEndpointOperationCompleted( + _In_ UDECXUSBENDPOINT Endpoint + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + + // External callers retain an admitted endpoint operation while reaching + // this wrapper. Do not touch Endpoint after the locked decrement can expose + // zero to the purge worker and ultimately allow UdeCx cleanup to begin. + WdfSpinLockAcquire(controllerContext->BrokerLock); + ViiperEndpointOperationCompletedLocked(Endpoint); + WdfSpinLockRelease(controllerContext->BrokerLock); +} + +static +BOOLEAN +ViiperSlotMatches( + _In_ const VIIPER_UDE_PENDING_SLOT *Pending, + _In_ WDFREQUEST Request, + _In_ ULONGLONG Token + ) +{ + return Pending->Request == Request && Pending->Token == Token && + Pending->State != ViiperUdePendingEmpty; +} + +NTSTATUS +ViiperValidateBrokerOwner( + _In_ WDFDEVICE Controller, + _In_ WDFREQUEST Request + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + WDFFILEOBJECT fileObject = WdfRequestGetFileObject(Request); + VIIPER_UDE_FILE_CONTEXT *fileContext; + NTSTATUS status = STATUS_SUCCESS; + + if (fileObject == WDF_NO_HANDLE) { + return STATUS_INVALID_HANDLE; + } + fileContext = ViiperGetFileContext(fileObject); + // EvtFileCleanup can run with this request outstanding, but KMDF keeps the + // request-associated file object alive. BrokerOwner and Negotiated only + // transition to TRUE, while Closing is set through InterlockedExchange + // before cleanup takes OwnerLock or admits a successor. A request which + // wins before that permanent close boundary may finish against its exact + // device owner/generation; one which loses it must fail without joining + // unrelated input publishers at the controller-wide OwnerLock. + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&fileContext->BrokerOwner, 0, 0) == 0 || + InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) == 0 || + InterlockedCompareExchange(&fileContext->Closing, 0, 0) != 0) { + status = STATUS_INVALID_DEVICE_STATE; + } + return status; +} + +NTSTATUS +ViiperInitializeBroker( + _In_ WDFDEVICE Device + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Device); + WDF_OBJECT_ATTRIBUTES attributes; + WDF_DPC_CONFIG dpcConfig; + NTSTATUS status; + + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = Device; + status = WdfSpinLockCreate(&attributes, &controllerContext->BrokerLock); + if (!NT_SUCCESS(status)) { + return status; + } + + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = Device; + status = WdfMemoryCreate( + &attributes, + NonPagedPoolNx, + 0x56495542, + sizeof(VIIPER_UDE_PENDING_SLOT) * VIIPER_UDE_MAX_PENDING_OPERATIONS, + &controllerContext->PendingStorage, + (PVOID *)&controllerContext->PendingSlots); + if (!NT_SUCCESS(status)) { + controllerContext->PendingStorage = WDF_NO_HANDLE; + controllerContext->PendingSlots = NULL; + return status; + } + + RtlZeroMemory( + controllerContext->PendingSlots, + sizeof(VIIPER_UDE_PENDING_SLOT) * VIIPER_UDE_MAX_PENDING_OPERATIONS); + + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = Device; + status = WdfMemoryCreate( + &attributes, + NonPagedPoolNx, + 0x56495543, + sizeof(VIIPER_UDE_NOTIFICATION) * VIIPER_UDE_MAX_PENDING_OPERATIONS, + &controllerContext->NotificationStorage, + (PVOID *)&controllerContext->Notifications); + if (!NT_SUCCESS(status)) { + controllerContext->NotificationStorage = WDF_NO_HANDLE; + controllerContext->Notifications = NULL; + return status; + } + RtlZeroMemory( + controllerContext->Notifications, + sizeof(VIIPER_UDE_NOTIFICATION) * VIIPER_UDE_MAX_PENDING_OPERATIONS); + + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = Device; + status = WdfMemoryCreate( + &attributes, + NonPagedPoolNx, + 0x56495544, + sizeof(VIIPER_UDE_MANAGEMENT_SLOT) * VIIPER_UDE_MAX_PENDING_MANAGEMENT, + &controllerContext->ManagementStorage, + (PVOID *)&controllerContext->ManagementSlots); + if (!NT_SUCCESS(status)) { + controllerContext->ManagementStorage = WDF_NO_HANDLE; + controllerContext->ManagementSlots = NULL; + return status; + } + RtlZeroMemory( + controllerContext->ManagementSlots, + sizeof(VIIPER_UDE_MANAGEMENT_SLOT) * VIIPER_UDE_MAX_PENDING_MANAGEMENT); + + WDF_DPC_CONFIG_INIT(&dpcConfig, ViiperEvtCompletionDpc); + dpcConfig.AutomaticSerialization = WdfFalse; + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = Device; + return WdfDpcCreate( + &dpcConfig, &attributes, &controllerContext->CompletionDpc); +} + +_IRQL_requires_max_(DISPATCH_LEVEL) +BOOLEAN +ViiperQueueUrbCompletion( + _In_ WDFDEVICE Controller, + _In_ UDECXUSBENDPOINT Endpoint, + _In_ WDFREQUEST Request, + _In_ ULONG PendingSlot, + _In_ ULONGLONG Token, + _In_ NTSTATUS Status, + _In_ USBD_STATUS UsbdStatus, + _In_ BOOLEAN CompleteWithNtStatus, + _In_ ULONG DirectInputBytes, + _In_ ULONGLONG DirectInputSequence + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + VIIPER_UDE_REQUEST_CONTEXT *requestContext = ViiperGetRequestContext(Request); + BOOLEAN enqueueDpc = FALSE; + + NT_ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL); + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (requestContext->CompletionQueued) { + WdfSpinLockRelease(controllerContext->BrokerLock); + NT_ASSERT(FALSE); + return FALSE; + } + NT_ASSERT(requestContext->DeviceGeneration != 0); + NT_ASSERT(requestContext->EndpointGeneration != 0); + NT_ASSERT(requestContext->Endpoint == Endpoint); + + WdfObjectReference(Request); + requestContext->CompletionRequest = Request; + requestContext->Controller = Controller; + requestContext->Endpoint = Endpoint; + requestContext->PendingSlot = PendingSlot; + requestContext->Token = Token; + requestContext->CompletionStatus = Status; + requestContext->CompletionUsbdStatus = UsbdStatus; + requestContext->CompleteWithNtStatus = CompleteWithNtStatus; + requestContext->DirectInputBytes = DirectInputBytes; + requestContext->DirectInputSequence = DirectInputSequence; + requestContext->CompletionQueued = TRUE; + if (InterlockedCompareExchange(&controllerContext->PendingCompletions, 0, 0) == 0) { + KeClearEvent(&controllerContext->CompletionOperationsDrained); + } + (VOID)InterlockedIncrement(&controllerContext->PendingCompletions); + InsertTailList(&controllerContext->CompletionQueue, &requestContext->CompletionEntry); + if (!controllerContext->CompletionDpcActive) { + controllerContext->CompletionDpcActive = TRUE; + enqueueDpc = TRUE; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + + if (enqueueDpc) { + // A running KDPC has already left the system queue, so it can be + // requeued during its final empty-queue handoff. A FALSE result only + // means another invocation is already queued. + (VOID)WdfDpcEnqueue(controllerContext->CompletionDpc); + } + return TRUE; +} + +VOID +ViiperEvtCompletionDpc( + _In_ WDFDPC Dpc + ) +{ + WDFDEVICE controller = (WDFDEVICE)WdfDpcGetParentObject(Dpc); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(controller); + + // The authored UDE host-compatibility contract requires terminal URB + // completion at DISPATCH_LEVEL and on a separate DPC when processing began + // synchronously. Keep this boundary even though generated per-function + // documentation has carried conflicting IRQL metadata: the shipped class- + // extension helpers are nonpaged wrappers which complete at caller IRQL. + NT_ASSERT(KeGetCurrentIrql() == DISPATCH_LEVEL); + + for (;;) { + WDFREQUEST request = WDF_NO_HANDLE; + UDECXUSBENDPOINT endpoint = WDF_NO_HANDLE; + ULONGLONG token = 0; + ULONG slot = VIIPER_UDE_MAX_PENDING_OPERATIONS; + NTSTATUS completionStatus = STATUS_SUCCESS; + USBD_STATUS usbdStatus = USBD_STATUS_SUCCESS; + BOOLEAN completeWithNtStatus = FALSE; + ULONG directInputBytes = 0; + ULONGLONG directInputSequence = 0; + ULONG deviceGeneration = 0; + ULONG endpointGeneration = 0; + BOOLEAN ownershipReleased = FALSE; + PLIST_ENTRY entry; + VIIPER_UDE_REQUEST_CONTEXT *requestContext; + LONG remaining; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (IsListEmpty(&controllerContext->CompletionQueue)) { + controllerContext->CompletionDpcActive = FALSE; + WdfSpinLockRelease(controllerContext->BrokerLock); + break; + } + entry = RemoveHeadList(&controllerContext->CompletionQueue); + requestContext = CONTAINING_RECORD( + entry, VIIPER_UDE_REQUEST_CONTEXT, CompletionEntry); + request = requestContext->CompletionRequest; + endpoint = requestContext->Endpoint; + token = requestContext->Token; + slot = requestContext->PendingSlot; + completionStatus = requestContext->CompletionStatus; + usbdStatus = requestContext->CompletionUsbdStatus; + completeWithNtStatus = requestContext->CompleteWithNtStatus; + directInputBytes = requestContext->DirectInputBytes; + directInputSequence = requestContext->DirectInputSequence; + deviceGeneration = requestContext->DeviceGeneration; + endpointGeneration = requestContext->EndpointGeneration; + requestContext->CompletionRequest = WDF_NO_HANDLE; + requestContext->CompletionQueued = FALSE; + if (slot < VIIPER_UDE_MAX_PENDING_OPERATIONS) { + BOOLEAN slotOwned = + ViiperSlotMatches(&controllerContext->PendingSlots[slot], request, token) && + controllerContext->PendingSlots[slot].State == + ViiperUdePendingDpcCompletion; + NT_ASSERT(slotOwned); + if (slotOwned) { + controllerContext->PendingSlots[slot].State = ViiperUdePendingCompleting; + } + } else { + NT_ASSERT(token == 0); + } + WdfSpinLockRelease(controllerContext->BrokerLock); + + if (endpoint == WDF_NO_HANDLE || deviceGeneration == 0 || endpointGeneration == 0 || + ViiperGetEndpointContext(endpoint)->Generation != endpointGeneration || + ViiperGetDeviceContext(ViiperGetEndpointContext(endpoint)->Device)->Generation != + deviceGeneration) { + NT_ASSERT(FALSE); + completionStatus = STATUS_DEVICE_NOT_READY; + completeWithNtStatus = TRUE; + directInputBytes = 0; + directInputSequence = 0; + } + + if (completeWithNtStatus) { + UdecxUrbCompleteWithNtStatus(request, completionStatus); + } else { + UdecxUrbComplete(request, usbdStatus); + } + + if (directInputBytes != 0 && + !completeWithNtStatus && + usbdStatus == USBD_STATUS_SUCCESS && + directInputSequence != 0) { + InterlockedAdd64(&controllerContext->BytesFromDevice, directInputBytes); + InterlockedIncrement64(&controllerContext->InputReportsCompleted); + } else if (directInputBytes != 0) { + NT_ASSERT(FALSE); + } + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && + ViiperSlotMatches(&controllerContext->PendingSlots[slot], request, token) && + controllerContext->PendingSlots[slot].State == ViiperUdePendingCompleting) { + ViiperClearSlotLocked(controllerContext, slot); + ownershipReleased = TRUE; + } else if (slot >= VIIPER_UDE_MAX_PENDING_OPERATIONS && + endpoint != WDF_NO_HANDLE) { + ViiperEndpointOperationCompletedLocked(endpoint); + ownershipReleased = TRUE; + } + if (!ownershipReleased) { + NT_ASSERT(FALSE); + } + remaining = InterlockedDecrement(&controllerContext->PendingCompletions); + NT_ASSERT(remaining >= 0); + if (remaining == 0) { + KeSetEvent( + &controllerContext->CompletionOperationsDrained, + IO_NO_INCREMENT, + FALSE); + } + WdfSpinLockRelease(controllerContext->BrokerLock); + // Endpoint rundown, not a WDF reference, is the lifetime fence. UdeCx + // cannot pass PurgeComplete until the locked decrement above, and this + // DPC performs no endpoint access after that final release. + WdfObjectDereference(request); + } +} + +_IRQL_requires_(PASSIVE_LEVEL) +VOID +ViiperDrainUrbCompletions( + _In_ WDFDEVICE Controller + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + LARGE_INTEGER watchdogWait; + + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + watchdogWait.QuadPart = + -(LONGLONG)VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS; + for (;;) { + BOOLEAN drained; + NTSTATUS waitStatus; + + waitStatus = KeWaitForSingleObject( + &controllerContext->CompletionOperationsDrained, + Executive, + KernelMode, + FALSE, + &watchdogWait); + if (waitStatus == STATUS_TIMEOUT) { + LONG pendingCompletions; + ULONG completionDpcActive; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + pendingCompletions = InterlockedCompareExchange( + &controllerContext->PendingCompletions, 0, 0); + completionDpcActive = + controllerContext->CompletionDpcActive ? 1U : 0U; + WdfSpinLockRelease(controllerContext->BrokerLock); + VIIPER_TRACE_LIFECYCLE( + Controller, + VIIPER_UDE_TRACE_SOURCE_BROKER, + VIIPER_UDE_TRACE_COMPLETION_RUNDOWN_WATCHDOG, + 0, + 0, + WDF_NO_HANDLE, + WDF_NO_HANDLE, + 0, + STATUS_IO_TIMEOUT, + pendingCompletions, + completionDpcActive); + } else { + NT_ASSERT(waitStatus == STATUS_SUCCESS); + } + (VOID)WdfDpcCancel(controllerContext->CompletionDpc, TRUE); + + // Closing the device's I/O queues precedes this join. If cancellation + // won the narrow interval before a queued DPC began, re-arm that + // already-owned list instead of abandoning its request references. + WdfSpinLockAcquire(controllerContext->BrokerLock); + drained = IsListEmpty(&controllerContext->CompletionQueue) && + InterlockedCompareExchange(&controllerContext->PendingCompletions, 0, 0) == 0; + if (!drained) { + controllerContext->CompletionDpcActive = TRUE; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (drained) { + break; + } + (VOID)WdfDpcEnqueue(controllerContext->CompletionDpc); + } +} + +static +BOOLEAN +ViiperLifecycleOwnerSessionActiveLocked( + _In_ VIIPER_UDE_DEVICE_CONTEXT *DeviceContext + ) +{ + WDFFILEOBJECT ownerFile; + VIIPER_UDE_FILE_CONTEXT *fileContext; + + // Device removal is asynchronous in UdeCx. The old child can therefore + // deliver endpoint/power callbacks after its logical table slot has been + // released and a successor broker has connected. The child retains its + // creating file object until EvtCleanup, so that file's permanent Closing + // transition is the generation fence which prevents those callbacks from + // entering the controller-wide notification FIFO of the new session. + // + // BrokerLock is the lifecycle admission linearization point. Do not take + // OwnerLock here: cleanup takes OwnerLock before BrokerLock and reversing + // that order would deadlock. Closing is set before cleanup takes either + // lock, while Purging is set under BrokerLock before the logical slot is + // released. + if (InterlockedCompareExchange(&DeviceContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&DeviceContext->OwnerReferenced, 0, 0) == 0) { + return FALSE; + } + ownerFile = DeviceContext->OwnerFile; + if (ownerFile == WDF_NO_HANDLE) { + return FALSE; + } + fileContext = ViiperGetFileContext(ownerFile); + return InterlockedCompareExchange(&fileContext->BrokerOwner, 0, 0) != 0 && + InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) != 0 && + InterlockedCompareExchange(&fileContext->Closing, 0, 0) == 0; +} + +static +BOOLEAN +ViiperQueueLifecycleEventLocked( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ VIIPER_UDE_DEVICE_CONTEXT *DeviceContext, + _In_opt_ const USB_ENDPOINT_DESCRIPTOR *EndpointDescriptor, + _In_ ULONG EndpointGeneration, + _In_ VIIPER_UDE_OPERATION_KIND Kind, + _In_ UCHAR InterfaceNumber, + _In_ UCHAR InterfaceSetting, + _In_ ULONGLONG Token + ) +{ + VIIPER_UDE_NOTIFICATION *event; + + // Keep this defensive check in the common insertion primitive so a future + // lifecycle producer cannot bypass the old-owner generation fence. It is + // deliberately before both sequence increments: a stale child must leave + // no observable hole in its successor's lifecycle stream. + if (!ViiperLifecycleOwnerSessionActiveLocked(DeviceContext)) { + return FALSE; + } + if (ControllerContext->NotificationCount >= VIIPER_UDE_MAX_PENDING_OPERATIONS - 1) { + (VOID)ViiperFaultBrokerLocked(ControllerContext); + return FALSE; + } + + event = &ControllerContext->Notifications[ControllerContext->NotificationTail]; + RtlZeroMemory(event, sizeof(*event)); + event->Token = Token; + event->DeviceId = DeviceContext->DeviceId; + event->Generation = DeviceContext->Generation; + event->EndpointGeneration = EndpointGeneration; + event->Kind = Kind; + if (EndpointDescriptor != NULL) { + event->EndpointAddress = EndpointDescriptor->bEndpointAddress; + event->EndpointAttributes = EndpointDescriptor->bmAttributes; + event->EndpointInterval = EndpointDescriptor->bInterval; + event->EndpointMaxPacketSize = EndpointDescriptor->wMaxPacketSize; + } + event->InterfaceNumber = InterfaceNumber; + event->InterfaceSetting = InterfaceSetting; + if (EndpointGeneration != 0) { + event->EndpointSequence = (ULONGLONG)InterlockedIncrement64( + &DeviceContext->EndpointSequences[event->EndpointAddress]); + } else { + event->EndpointSequence = (ULONGLONG)InterlockedIncrement64( + &DeviceContext->DeviceLifecycleSequence); + } + event->DeviceSequence = (ULONGLONG)InterlockedIncrement64( + &DeviceContext->DeviceSequence); + ControllerContext->NotificationTail = (ControllerContext->NotificationTail + 1) % + VIIPER_UDE_MAX_PENDING_OPERATIONS; + ++ControllerContext->NotificationCount; + return TRUE; +} + +NTSTATUS +ViiperQueueEndpointLifecycleEvent( + _In_ UDECXUSBENDPOINT Endpoint, + _In_ VIIPER_UDE_OPERATION_KIND Kind + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + BOOLEAN ownerActive; + BOOLEAN active; + BOOLEAN queued; + BOOLEAN faulted; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + ownerActive = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + ViiperLifecycleOwnerSessionActiveLocked(deviceContext); + active = ownerActive && InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE; + queued = active && + ViiperQueueLifecycleEventLocked( + controllerContext, + deviceContext, + &endpointContext->Descriptor, + endpointContext->Generation, + Kind, + 0, + 0, + 0); + faulted = ownerActive && InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE; + WdfSpinLockRelease(controllerContext->BrokerLock); + if (queued || faulted) { + // Queue overflow can publish the terminal broker-fault record instead + // of this lifecycle event. Dispatch that record even though the + // original insertion failed, otherwise already-waiting dequeue IOCTLs + // can remain parked forever with the fault hidden behind them. + // Lifecycle publication has priority over ordinary URBs. Wake only + // the notification path here so a purge/reset callback cannot also + // publish unrelated media merely because it reported a boundary. + ViiperDispatchNotificationEvents(deviceContext->Controller); + } + if (!active) { + return STATUS_DEVICE_NOT_READY; + } + if (!queued) { + return STATUS_INSUFFICIENT_RESOURCES; + } + return STATUS_SUCCESS; +} + +NTSTATUS +ViiperQueueDeviceLifecycleEvent( + _In_ UDECXUSBDEVICE Device, + _In_ VIIPER_UDE_OPERATION_KIND Kind + ) +{ + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + BOOLEAN ownerActive; + BOOLEAN active; + BOOLEAN queued; + BOOLEAN faulted; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + ownerActive = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + ViiperLifecycleOwnerSessionActiveLocked(deviceContext); + active = ownerActive && InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE; + queued = active && + ViiperQueueLifecycleEventLocked( + controllerContext, deviceContext, NULL, 0, Kind, 0, 0, 0); + faulted = ownerActive && InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE; + WdfSpinLockRelease(controllerContext->BrokerLock); + if (queued || faulted) { + ViiperDispatchNotificationEvents(deviceContext->Controller); + } + if (!active) { + return STATUS_DEVICE_NOT_READY; + } + if (!queued) { + return STATUS_INSUFFICIENT_RESOURCES; + } + return STATUS_SUCCESS; +} + +NTSTATUS +ViiperQueueInterfaceLifecycleEvent( + _In_ UDECXUSBDEVICE Device, + _In_ UCHAR InterfaceNumber, + _In_ UCHAR InterfaceSetting + ) +{ + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + BOOLEAN ownerActive; + BOOLEAN active; + BOOLEAN queued; + BOOLEAN faulted; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + ownerActive = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + ViiperLifecycleOwnerSessionActiveLocked(deviceContext); + active = ownerActive && InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE; + queued = active && + ViiperQueueLifecycleEventLocked( + controllerContext, + deviceContext, + NULL, + 0, + ViiperUdeOperationSetInterface, + InterfaceNumber, + InterfaceSetting, + 0); + faulted = ownerActive && InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE; + WdfSpinLockRelease(controllerContext->BrokerLock); + if (queued || faulted) { + ViiperDispatchNotificationEvents(deviceContext->Controller); + } + if (!active) { + return STATUS_DEVICE_NOT_READY; + } + if (!queued) { + return STATUS_INSUFFICIENT_RESOURCES; + } + return STATUS_SUCCESS; +} + +static +NTSTATUS +ViiperQueueAcknowledgedLifecycleEvent( + _In_ UDECXUSBDEVICE Device, + _In_opt_ UDECXUSBENDPOINT Endpoint, + _In_ WDFREQUEST Request, + _In_ VIIPER_UDE_OPERATION_KIND Kind, + _In_ UCHAR InterfaceNumber, + _In_ UCHAR InterfaceSetting + ) +{ + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + const USB_ENDPOINT_DESCRIPTOR *descriptor = NULL; + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = NULL; + ULONG offset; + NTSTATUS status = STATUS_INSUFFICIENT_RESOURCES; + BOOLEAN canAllocate = TRUE; + BOOLEAN ownerActive = FALSE; + BOOLEAN faulted = FALSE; + + // The management slot owns these generic references until every terminal + // clear path snapshots and releases them outside BrokerLock. Besides + // retaining the opaque values, this prevents WDF from recycling either + // handle while a delayed acknowledgement is compared with the live table. + WdfObjectReference(Device); + if (Endpoint != WDF_NO_HANDLE) { + WdfObjectReference(Endpoint); + } + if (Endpoint != WDF_NO_HANDLE) { + endpointContext = ViiperGetEndpointContext(Endpoint); + descriptor = &endpointContext->Descriptor; + } + + WdfSpinLockAcquire(controllerContext->BrokerLock); + ownerActive = ViiperLifecycleOwnerSessionActiveLocked(deviceContext); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE || + !ownerActive) { + status = STATUS_DEVICE_NOT_READY; + canAllocate = FALSE; + } else if (Kind == ViiperUdeOperationDeviceReset && + (InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) == 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0)) { + status = STATUS_DEVICE_NOT_READY; + canAllocate = FALSE; + } else if (Kind == ViiperUdeOperationEndpointReset && + (endpointContext == NULL || + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) == 0 || + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange64(&deviceContext->ResetEpoch, 0, 0) != + InterlockedCompareExchange64(&endpointContext->ResetDeviceEpoch, 0, 0))) { + // A device reset admitted after the endpoint worker's first proof + // supersedes that endpoint transaction before it can be published. + status = STATUS_DEVICE_NOT_READY; + canAllocate = FALSE; + } else if (controllerContext->NotificationCount >= + VIIPER_UDE_MAX_PENDING_OPERATIONS - 1) { + (VOID)ViiperFaultBrokerLocked(controllerContext); + status = STATUS_INSUFFICIENT_RESOURCES; + canAllocate = FALSE; + } + for (offset = 0; canAllocate && status == STATUS_INSUFFICIENT_RESOURCES && + offset < VIIPER_UDE_MAX_PENDING_MANAGEMENT; ++offset) { + ULONG index = (controllerContext->NextManagementSlot + offset) % + VIIPER_UDE_MAX_PENDING_MANAGEMENT; + VIIPER_UDE_MANAGEMENT_SLOT *pending = &controllerContext->ManagementSlots[index]; + ULONGLONG token; + + if (pending->State != ViiperUdePendingEmpty || pending->RetiredToken != 0 || + pending->Generation == MAXULONG) { + continue; + } + ++pending->Generation; + token = ((ULONGLONG)pending->Generation << 32) | + VIIPER_UDE_MANAGEMENT_SLOT_FLAG | (index + 1); + pending->Request = Request; + pending->Device = Device; + pending->Endpoint = Endpoint; + pending->OwnerFile = deviceContext->OwnerFile; + pending->Token = token; + pending->DeviceId = deviceContext->DeviceId; + pending->ResetEpoch = endpointContext != NULL + ? (ULONGLONG)InterlockedCompareExchange64( + &endpointContext->ResetDeviceEpoch, 0, 0) + : (ULONGLONG)InterlockedCompareExchange64( + &deviceContext->ResetEpoch, 0, 0); + pending->DeviceGeneration = deviceContext->Generation; + pending->EndpointGeneration = endpointContext != NULL + ? endpointContext->Generation + : 0; + pending->State = ViiperUdePendingQueued; + pending->Kind = Kind; + pending->EndpointAddress = descriptor != NULL ? descriptor->bEndpointAddress : 0; + if (!ViiperQueueLifecycleEventLocked( + controllerContext, + deviceContext, + descriptor, + pending->EndpointGeneration, + Kind, + InterfaceNumber, + InterfaceSetting, + token)) { + pending->Request = WDF_NO_HANDLE; + pending->Device = WDF_NO_HANDLE; + pending->Endpoint = WDF_NO_HANDLE; + pending->OwnerFile = WDF_NO_HANDLE; + pending->Token = 0; + pending->DeviceId = 0; + pending->ResetEpoch = 0; + pending->DeviceGeneration = 0; + pending->EndpointGeneration = 0; + pending->State = ViiperUdePendingEmpty; + pending->Kind = 0; + pending->EndpointAddress = 0; + break; + } + controllerContext->NextManagementSlot = (index + 1) % + VIIPER_UDE_MAX_PENDING_MANAGEMENT; + ViiperPendingOperationStartedLocked(controllerContext); + status = STATUS_SUCCESS; + break; + } + faulted = ownerActive && InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE; + WdfSpinLockRelease(controllerContext->BrokerLock); + + if (status == STATUS_INSUFFICIENT_RESOURCES) { + InterlockedIncrement64(&controllerContext->QueueExhaustions); + } + if (NT_SUCCESS(status) || faulted) { + ViiperDispatchNotificationEvents(deviceContext->Controller); + } + if (!NT_SUCCESS(status)) { + // No caller-owned context access is permitted after the last generic + // reference can make deferred WDF destruction runnable. + ViiperReleaseManagementSlotReferences(Device, Endpoint); + } + return status; +} + +NTSTATUS +ViiperQueueAcknowledgedEndpointLifecycleEvent( + _In_ UDECXUSBENDPOINT Endpoint, + _In_ WDFREQUEST Request, + _In_ VIIPER_UDE_OPERATION_KIND Kind + ) +{ + return ViiperQueueAcknowledgedLifecycleEvent( + ViiperGetEndpointContext(Endpoint)->Device, Endpoint, Request, Kind, 0, 0); +} + +NTSTATUS +ViiperQueueAcknowledgedDeviceLifecycleEvent( + _In_ UDECXUSBDEVICE Device, + _In_ WDFREQUEST Request, + _In_ VIIPER_UDE_OPERATION_KIND Kind + ) +{ + return ViiperQueueAcknowledgedLifecycleEvent( + Device, WDF_NO_HANDLE, Request, Kind, 0, 0); +} + +NTSTATUS +ViiperQueueAcknowledgedInterfaceLifecycleEvent( + _In_ UDECXUSBDEVICE Device, + _In_ WDFREQUEST Request, + _In_ UCHAR InterfaceNumber, + _In_ UCHAR InterfaceSetting + ) +{ + return ViiperQueueAcknowledgedLifecycleEvent( + Device, + WDF_NO_HANDLE, + Request, + ViiperUdeOperationSetInterface, + InterfaceNumber, + InterfaceSetting); +} + +static +NTSTATUS +ViiperAllocatePendingSlot( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ WDFREQUEST Request, + _In_ UDECXUSBENDPOINT Endpoint, + _Out_ ULONG *Slot, + _Out_ ULONGLONG *Token + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + ULONG offset; + NTSTATUS status = STATUS_INSUFFICIENT_RESOURCES; + + WdfSpinLockAcquire(ControllerContext->BrokerLock); + if (InterlockedCompareExchange(&ControllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&ControllerContext->BrokerFaulted, FALSE, FALSE) != FALSE || + InterlockedCompareExchange(&deviceContext->InD0, 0, 0) == 0 || + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { + status = STATUS_DEVICE_NOT_READY; + } else if ((ULONG)InterlockedCompareExchange( + &deviceContext->PendingOperations, 0, 0) >= + deviceContext->MaxPendingOperations) { + status = STATUS_QUOTA_EXCEEDED; + } + for (offset = 0; status == STATUS_INSUFFICIENT_RESOURCES && + offset < VIIPER_UDE_MAX_PENDING_OPERATIONS; ++offset) { + ULONG index = (ControllerContext->NextPendingSlot + offset) % + VIIPER_UDE_MAX_PENDING_OPERATIONS; + VIIPER_UDE_PENDING_SLOT *pending = &ControllerContext->PendingSlots[index]; + if (pending->State != ViiperUdePendingEmpty || pending->Generation == MAXULONG) { + continue; + } + NT_ASSERT(!pending->AdmissionLinked); + ++pending->Generation; + pending->Request = Request; + pending->Endpoint = Endpoint; + pending->Token = ((ULONGLONG)pending->Generation << 32) | (index + 1); + pending->DeviceId = deviceContext->DeviceId; + ++endpointContext->NextAdmissionSequence; + if (endpointContext->NextAdmissionSequence == 0) { + ++endpointContext->NextAdmissionSequence; + } + pending->AdmissionSequence = endpointContext->NextAdmissionSequence; + pending->DeviceGeneration = deviceContext->Generation; + pending->EndpointGeneration = endpointContext->Generation; + pending->State = ViiperUdePendingPreparing; + pending->AbortPending = FALSE; + pending->PublishedToOwner = FALSE; + pending->AdmissionLinked = TRUE; + pending->EndpointAddress = endpointContext->Descriptor.bEndpointAddress; + pending->AbortStatus = STATUS_SUCCESS; + InsertTailList(&endpointContext->AdmissionQueue, &pending->AdmissionEntry); + ControllerContext->NextPendingSlot = (index + 1) % VIIPER_UDE_MAX_PENDING_OPERATIONS; + ViiperPendingOperationStartedLocked(ControllerContext); + InterlockedIncrement(&deviceContext->PendingOperations); + *Slot = index; + *Token = pending->Token; + status = STATUS_SUCCESS; + break; + } + WdfSpinLockRelease(ControllerContext->BrokerLock); + + if (status == STATUS_INSUFFICIENT_RESOURCES || status == STATUS_QUOTA_EXCEEDED) { + InterlockedIncrement64(&ControllerContext->QueueExhaustions); + } + return status; +} + +VOID +ViiperEvtUrbCanceledOnQueue( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request + ) +{ + WDFDEVICE controller = WdfIoQueueGetDevice(Queue); + UDECXUSBENDPOINT endpoint = *ViiperGetQueueEndpoint(Queue); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(controller); + VIIPER_UDE_REQUEST_CONTEXT *requestContext = ViiperGetRequestContext(Request); + BOOLEAN queued; + + // KMDF has removed this request from the endpoint queue and transferred + // ownership to this callback. Count that ownership before deferring the + // terminal call so endpoint purge cannot pass the queued DPC. + RtlZeroMemory(requestContext, sizeof(*requestContext)); + requestContext->Controller = controller; + requestContext->Endpoint = endpoint; + requestContext->PendingSlot = VIIPER_UDE_MAX_PENDING_OPERATIONS; + requestContext->DeviceGeneration = + ViiperGetDeviceContext(ViiperGetEndpointContext(endpoint)->Device)->Generation; + requestContext->EndpointGeneration = ViiperGetEndpointContext(endpoint)->Generation; + WdfSpinLockAcquire(controllerContext->BrokerLock); + ViiperEndpointOperationStarted(endpoint); + WdfSpinLockRelease(controllerContext->BrokerLock); + + queued = ViiperQueueUrbCompletion( + controller, + endpoint, + Request, + VIIPER_UDE_MAX_PENDING_OPERATIONS, + 0, + STATUS_CANCELLED, + USBD_STATUS_CANCELED, + TRUE, + 0, + 0); + if (!queued) { + NT_ASSERT(FALSE); + } + InterlockedIncrement64(&controllerContext->OperationsCancelled); +} + +VOID +ViiperEvtUrbCancel( + _In_ WDFREQUEST Request + ) +{ + VIIPER_UDE_REQUEST_CONTEXT *requestContext = ViiperGetRequestContext(Request); + WDFDEVICE controller = requestContext->Controller; + UDECXUSBENDPOINT endpoint = requestContext->Endpoint; + ULONG slot = requestContext->PendingSlot; + ULONGLONG token = requestContext->Token; + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(controller); + BOOLEAN ownsRequest = FALSE; + BOOLEAN notifyOwner = FALSE; + BOOLEAN dispatchSuccessor = FALSE; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (slot < VIIPER_UDE_MAX_PENDING_OPERATIONS) { + VIIPER_UDE_PENDING_SLOT *pending = + &controllerContext->PendingSlots[slot]; + if (ViiperSlotMatches(pending, Request, token)) { + dispatchSuccessor = pending->AdmissionLinked; + notifyOwner = ViiperQueueCancelEventLocked(controllerContext, pending); + pending->CompletionStatus = STATUS_CANCELLED; + pending->CompletionUsbdStatus = USBD_STATUS_CANCELED; + pending->CompleteWithNtStatus = TRUE; + pending->State = ViiperUdePendingDpcCompletion; + ViiperUnlinkAdmissionLocked(pending); + ownsRequest = TRUE; + } + } + WdfSpinLockRelease(controllerContext->BrokerLock); + + if (ownsRequest) { + InterlockedIncrement64(&controllerContext->OperationsCancelled); + (VOID)ViiperQueueUrbCompletion( + controller, + endpoint, + Request, + slot, + token, + STATUS_CANCELLED, + USBD_STATUS_CANCELED, + TRUE, + 0, + 0); + if (notifyOwner) { + ViiperDispatchNotificationEvents(controller); + } + if (dispatchSuccessor) { + // An endpoint head can be canceled without another broker IOCTL + // arriving to restart publication. WdfRequestUnmarkCancelable may + // return STATUS_CANCELLED before this callback runs, so even a + // Publishing head cannot rely on its old dispatch loop to observe + // the unlink. Wake dispatch after retiring every linked head so an + // already-waiting dequeue cannot strand its successor. + ViiperDispatchAvailable(controller); + } + } +} + +PURB +ViiperGetUrb( + _In_ WDFREQUEST Request + ) +{ + PIRP irp = WdfRequestWdmGetIrp(Request); + if (irp == NULL) { + return NULL; + } + return (PURB)URB_FROM_IRP(irp); +} + +static +PMDL +ViiperGetTransferMdl( + _In_ PURB Urb + ) +{ + switch (Urb->UrbHeader.Function) { + case URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER: + case URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL: + return Urb->UrbBulkOrInterruptTransfer.TransferBufferMDL; + case URB_FUNCTION_ISOCH_TRANSFER: + case URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL: + return Urb->UrbIsochronousTransfer.TransferBufferMDL; + case URB_FUNCTION_CONTROL_TRANSFER: + return Urb->UrbControlTransfer.TransferBufferMDL; + case URB_FUNCTION_CONTROL_TRANSFER_EX: + return Urb->UrbControlTransferEx.TransferBufferMDL; + default: + return NULL; + } +} + +ULONG +ViiperGetTransferBufferLength( + _In_ PURB Urb + ) +{ + switch (Urb->UrbHeader.Function) { + case URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER: + case URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL: + return Urb->UrbBulkOrInterruptTransfer.TransferBufferLength; + case URB_FUNCTION_ISOCH_TRANSFER: + case URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL: + return Urb->UrbIsochronousTransfer.TransferBufferLength; + case URB_FUNCTION_CONTROL_TRANSFER: + return Urb->UrbControlTransfer.TransferBufferLength; + case URB_FUNCTION_CONTROL_TRANSFER_EX: + return Urb->UrbControlTransferEx.TransferBufferLength; + default: + return 0; + } +} + +static +ULONG +ViiperIsoFrameSpan( + _In_ const VIIPER_UDE_ENDPOINT_CONTEXT *EndpointContext, + _In_ ULONG PacketCount + ) +{ + UCHAR interval = EndpointContext->Descriptor.bInterval; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = + ViiperGetDeviceContext(EndpointContext->Device); + ULONGLONG span; + + if (PacketCount == 0 || interval == 0) { + return PacketCount == 0 ? 1 : PacketCount; + } + if (deviceContext->Speed == UdecxUsbHighSpeed || + deviceContext->Speed == UdecxUsbSuperSpeed) { + if (interval > 16) { + // UdeCx should reject an invalid high-speed descriptor before an + // URB reaches us. Keep the fallback bounded if it does not. + return PacketCount; + } + // High/SuperSpeed bInterval is an exponent in 125-us microframes, + // while URB StartFrame is expressed in one-millisecond USB frames. + span = (ULONGLONG)PacketCount * ((ULONGLONG)1 << (interval - 1)); + span = (span + 7) / 8; + } else { + // Windows defines each full-speed IsoPacket entry as one 1-ms frame. + // bInterval describes the endpoint's polling contract; it must not be + // multiplied into the URB packet-array span a second time. In + // particular, doing so creates holes in the virtual StartFrame clock + // after the USB stack has already expressed the schedule as one packet + // entry per frame. Production DS4 audio uses bInterval=1, so this + // correction preserves its proven cadence while making the generic + // UdeCx clock obey the Windows full-speed URB contract. + span = PacketCount; + } + if (span == 0) { + return 1; + } + return span > MAXULONG ? MAXULONG : (ULONG)span; +} + +static +ULONG +ViiperReserveIsoStartFrame( + _In_ VIIPER_UDE_ENDPOINT_CONTEXT *EndpointContext, + _In_ ULONG TransferFlags, + _In_ ULONG RequestedStartFrame, + _In_ ULONG PacketCount + ) +{ + LONG64 observed; + ULONGLONG qpcTimestamp; + ULONG currentFrame; + LONG requestedDelta; + ULONG startFrame; + ULONG nextFrame; + ULONG span; + + span = ViiperIsoFrameSpan(EndpointContext, PacketCount); + currentFrame = (ULONG)(KeQueryInterruptTimePrecise(&qpcTimestamp) / 10000ULL); + if ((TransferFlags & USBD_START_ISO_TRANSFER_ASAP) == 0) { + // An explicit URB is valid only in the future 1024-frame window. Do + // not let a rejected request advance the shared endpoint tail: doing + // so makes the next valid ASAP URB inherit a silent hole. + requestedDelta = (LONG)(RequestedStartFrame - currentFrame); + if (requestedDelta <= 0 || + requestedDelta >= USBD_ISO_START_FRAME_RANGE) { + return RequestedStartFrame; + } + for (;;) { + observed = InterlockedCompareExchange64( + &EndpointContext->NextIsoStartFrame, 0, 0); + startFrame = (ULONG)observed; + if (observed != 0 && + (LONG)(startFrame - currentFrame) > 0 && + (LONG)(RequestedStartFrame - startFrame) < 0) { + // This explicit window overlaps a reservation already + // published for the same endpoint. Leave the tail unchanged; + // user mode will return USBD_STATUS_BAD_START_FRAME. + return RequestedStartFrame; + } + nextFrame = RequestedStartFrame + span; + if (InterlockedCompareExchange64( + &EndpointContext->NextIsoStartFrame, + (LONG64)(ULONGLONG)nextFrame, + observed) == observed) { + return RequestedStartFrame; + } + } + } + + for (;;) { + observed = InterlockedCompareExchange64( + &EndpointContext->NextIsoStartFrame, 0, 0); + startFrame = (ULONG)observed; + if (observed == 0 || (LONG)(startFrame - currentFrame) <= 0) { + startFrame = currentFrame + 1; + } + nextFrame = startFrame + span; + if (InterlockedCompareExchange64( + &EndpointContext->NextIsoStartFrame, + (LONG64)(ULONGLONG)nextFrame, + observed) == observed) { + return startFrame; + } + } +} + +NTSTATUS +ViiperCopyTransferBuffer( + _In_ WDFREQUEST Request, + _In_ PURB Urb, + _Inout_updates_bytes_(Length) UCHAR *Buffer, + _In_ ULONG Length, + _In_ BOOLEAN ToUrb + ) +{ + UCHAR *contiguous = NULL; + ULONG contiguousLength = 0; + ULONG transferBufferLength; + PMDL mdl; + ULONG copied = 0; + NTSTATUS status; + + if (Length == 0) { + return STATUS_SUCCESS; + } + + transferBufferLength = ViiperGetTransferBufferLength(Urb); + if (Length > transferBufferLength) { + return STATUS_BUFFER_TOO_SMALL; + } + + status = UdecxUrbRetrieveBuffer(Request, &contiguous, &contiguousLength); + if (NT_SUCCESS(status) && contiguous != NULL && contiguousLength >= Length) { + // The pointer returned by UdecxUrbRetrieveBuffer is valid for exactly + // the reported span. A chained MDL can legitimately expose a first + // mapped segment that is shorter than the URB's total transfer length; + // in that case use the MDL walk below instead of copying beyond this + // mapping. The URB length remains the transfer contract, but it does + // not enlarge an individual mapped buffer. + if (ToUrb) { + RtlCopyMemory(contiguous, Buffer, Length); + } else { + RtlCopyMemory(Buffer, contiguous, Length); + } + return STATUS_SUCCESS; + } + + mdl = ViiperGetTransferMdl(Urb); + while (mdl != NULL && copied < Length) { + ULONG mdlLength = MmGetMdlByteCount(mdl); + ULONG chunk = min(mdlLength, Length - copied); + UCHAR *mapped; + + if (chunk != 0) { + mapped = (UCHAR *)MmGetSystemAddressForMdlSafe( + mdl, (MM_PAGE_PRIORITY)(NormalPagePriority | MdlMappingNoExecute)); + if (mapped == NULL) { + return STATUS_INSUFFICIENT_RESOURCES; + } + if (ToUrb) { + RtlCopyMemory(mapped, Buffer + copied, chunk); + } else { + RtlCopyMemory(Buffer + copied, mapped, chunk); + } + copied += chunk; + } + mdl = mdl->Next; + } + + if (copied != Length) { + return NT_SUCCESS(status) ? STATUS_BUFFER_TOO_SMALL : status; + } + return STATUS_SUCCESS; +} + +static +NTSTATUS +ViiperGetTransferMetadata( + _In_ WDFREQUEST Request, + _In_ PURB Urb, + _Out_ ULONG *TransferFlags, + _Out_ ULONG *TransferLength, + _Out_ ULONG *StartFrame, + _Out_ ULONG *IsoPacketCount, + _Out_ BOOLEAN *DirectionIn, + _Out_writes_bytes_(8) UCHAR SetupPacket[8] + ) +{ + WDF_USB_CONTROL_SETUP_PACKET setup; + NTSTATUS status; + + *StartFrame = 0; + *IsoPacketCount = 0; + RtlZeroMemory(SetupPacket, 8); + + switch (Urb->UrbHeader.Function) { + case URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER: + case URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL: + *TransferFlags = Urb->UrbBulkOrInterruptTransfer.TransferFlags; + *TransferLength = Urb->UrbBulkOrInterruptTransfer.TransferBufferLength; + break; + case URB_FUNCTION_ISOCH_TRANSFER: + case URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL: + *TransferFlags = Urb->UrbIsochronousTransfer.TransferFlags; + *TransferLength = Urb->UrbIsochronousTransfer.TransferBufferLength; + *StartFrame = Urb->UrbIsochronousTransfer.StartFrame; + *IsoPacketCount = Urb->UrbIsochronousTransfer.NumberOfPackets; + if (*IsoPacketCount > VIIPER_UDE_MAX_ISO_PACKETS) { + return STATUS_INVALID_BUFFER_SIZE; + } + break; + case URB_FUNCTION_CONTROL_TRANSFER: + *TransferFlags = Urb->UrbControlTransfer.TransferFlags; + *TransferLength = Urb->UrbControlTransfer.TransferBufferLength; + status = UdecxUrbRetrieveControlSetupPacket(Request, &setup); + if (!NT_SUCCESS(status)) { + return status; + } + RtlCopyMemory(SetupPacket, &setup, 8); + break; + case URB_FUNCTION_CONTROL_TRANSFER_EX: + *TransferFlags = Urb->UrbControlTransferEx.TransferFlags; + *TransferLength = Urb->UrbControlTransferEx.TransferBufferLength; + status = UdecxUrbRetrieveControlSetupPacket(Request, &setup); + if (!NT_SUCCESS(status)) { + return status; + } + RtlCopyMemory(SetupPacket, &setup, 8); + break; + default: + return STATUS_NOT_SUPPORTED; + } + + if (*TransferLength > VIIPER_UDE_MAX_TRANSFER_BYTES) { + return STATUS_INVALID_BUFFER_SIZE; + } + *DirectionIn = ((*TransferFlags & USBD_TRANSFER_DIRECTION_IN) != 0); + if (Urb->UrbHeader.Function == URB_FUNCTION_CONTROL_TRANSFER || + Urb->UrbHeader.Function == URB_FUNCTION_CONTROL_TRANSFER_EX) { + *DirectionIn = ((SetupPacket[0] & USB_ENDPOINT_DIRECTION_MASK) != 0); + } + return STATUS_SUCCESS; +} + +static +NTSTATUS +ViiperSerializeOperation( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ WDFREQUEST UrbRequest, + _In_ UDECXUSBENDPOINT Endpoint, + _In_ ULONGLONG Token, + _In_ WDFREQUEST DequeueRequest, + _Out_ VIIPER_UDE_OPERATION **SerializedOperation + ) +{ + PURB urb = ViiperGetUrb(UrbRequest); + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_REQUEST_CONTEXT *requestContext = ViiperGetRequestContext(UrbRequest); + VIIPER_UDE_OPERATION *operation; + VIIPER_UDE_ISO_PACKET *packets; + UCHAR *payload; + ULONG transferFlags; + ULONG transferLength; + ULONG startFrame; + ULONG packetCount; + ULONG isoBytes; + ULONG payloadLength; + ULONG totalLength; + ULONG index; + BOOLEAN directionIn; + UCHAR setupPacket[8]; + NTSTATUS status; + + if (urb == NULL) { + return STATUS_INVALID_DEVICE_REQUEST; + } + status = ViiperGetTransferMetadata( + UrbRequest, urb, &transferFlags, &transferLength, &startFrame, + &packetCount, &directionIn, setupPacket); + if (!NT_SUCCESS(status)) { + return status; + } + if (urb->UrbHeader.Function != URB_FUNCTION_CONTROL_TRANSFER && + urb->UrbHeader.Function != URB_FUNCTION_CONTROL_TRANSFER_EX) { + // Windows can supply stale or inconsistent direction bits in + // TransferFlags for bulk URBs. The endpoint descriptor is authoritative + // for every non-control pipe; only a control setup packet owns its + // direction. Normalize both ABI fields + // together so user mode never rejects or inverts an otherwise valid + // media/output transfer. + directionIn = (endpointContext->Descriptor.bEndpointAddress & + USB_ENDPOINT_DIRECTION_MASK) != 0; + if (directionIn) { + transferFlags |= USBD_TRANSFER_DIRECTION_IN; + } else { + transferFlags &= ~USBD_TRANSFER_DIRECTION_IN; + } + } + if (packetCount != 0) { + startFrame = ViiperReserveIsoStartFrame( + endpointContext, transferFlags, startFrame, packetCount); + } + + isoBytes = packetCount * sizeof(VIIPER_UDE_ISO_PACKET); + payloadLength = directionIn ? 0 : transferLength; + if (isoBytes > MAXULONG - sizeof(*operation) || + payloadLength > MAXULONG - sizeof(*operation) - isoBytes) { + return STATUS_INTEGER_OVERFLOW; + } + totalLength = sizeof(*operation) + isoBytes + payloadLength; + status = WdfRequestRetrieveOutputBuffer( + DequeueRequest, totalLength, (PVOID *)&operation, NULL); + if (!NT_SUCCESS(status)) { + return status; + } + + RtlZeroMemory(operation, totalLength); + operation->Header.Magic = VIIPER_UDE_MAGIC; + operation->Header.Major = VIIPER_UDE_ABI_MAJOR; + operation->Header.Minor = VIIPER_UDE_ABI_MINOR; + operation->Header.Size = totalLength; + operation->Token = Token; + operation->DeviceId = deviceContext->DeviceId; + operation->Generation = deviceContext->Generation; + operation->Kind = (urb->UrbHeader.Function == URB_FUNCTION_CONTROL_TRANSFER || + urb->UrbHeader.Function == URB_FUNCTION_CONTROL_TRANSFER_EX) + ? ViiperUdeOperationControl : ViiperUdeOperationTransfer; + operation->EndpointAddress = endpointContext->Descriptor.bEndpointAddress; + operation->EndpointAttributes = endpointContext->Descriptor.bmAttributes; + operation->EndpointInterval = endpointContext->Descriptor.bInterval; + operation->EndpointMaxPacketSize = endpointContext->Descriptor.wMaxPacketSize; + operation->EndpointGeneration = endpointContext->Generation; + operation->Direction = directionIn ? 1 : 0; + operation->UrbFunction = urb->UrbHeader.Function; + operation->TransferFlags = transferFlags; + operation->StartFrame = startFrame; + operation->IsoPacketCount = packetCount; + operation->TransferLength = transferLength; + operation->IsoPacketsOffset = sizeof(*operation); + operation->PayloadOffset = sizeof(*operation) + isoBytes; + operation->PayloadLength = payloadLength; + RtlCopyMemory(operation->SetupPacket, setupPacket, sizeof(setupPacket)); + + packets = (VIIPER_UDE_ISO_PACKET *)((UCHAR *)operation + operation->IsoPacketsOffset); + for (index = 0; index < packetCount; ++index) { + ULONG offset = urb->UrbIsochronousTransfer.IsoPacket[index].Offset; + ULONG nextOffset = index + 1 < packetCount + ? urb->UrbIsochronousTransfer.IsoPacket[index + 1].Offset + : transferLength; + if (offset > nextOffset || nextOffset > transferLength) { + return STATUS_INVALID_PARAMETER; + } + packets[index].Offset = offset; + packets[index].Length = nextOffset - offset; + packets[index].Status = urb->UrbIsochronousTransfer.IsoPacket[index].Status; + } + payload = (UCHAR *)operation + operation->PayloadOffset; + if (payloadLength > 0) { + status = ViiperCopyTransferBuffer( + UrbRequest, urb, payload, payloadLength, FALSE); + if (!NT_SUCCESS(status)) { + return status; + } + } + + requestContext->TransferLength = transferLength; + requestContext->IsoPacketCount = packetCount; + requestContext->IsoStartFrame = startFrame; + requestContext->DirectionIn = directionIn; + WdfRequestSetInformation(DequeueRequest, totalLength); + InterlockedIncrement64(&ControllerContext->OperationsDequeued); + if (!directionIn) { + InterlockedAdd64(&ControllerContext->BytesToDevice, transferLength); + } + *SerializedOperation = operation; + return STATUS_SUCCESS; +} + +static +BOOLEAN +ViiperQueueOwnedCompletion( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ ULONG Slot, + _In_ WDFREQUEST Request, + _In_ ULONGLONG Token, + _In_ NTSTATUS Status, + _In_ USBD_STATUS UsbdStatus, + _In_ BOOLEAN CompleteWithNtStatus + ) +{ + BOOLEAN queued = FALSE; + VIIPER_UDE_REQUEST_CONTEXT *requestContext = ViiperGetRequestContext(Request); + WDFDEVICE controller = requestContext->Controller; + UDECXUSBENDPOINT endpoint = requestContext->Endpoint; + NTSTATUS completionStatus = Status; + USBD_STATUS completionUsbdStatus = UsbdStatus; + BOOLEAN completionWithNtStatus = CompleteWithNtStatus; + + WdfSpinLockAcquire(ControllerContext->BrokerLock); + if (Slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && + ViiperSlotMatches(&ControllerContext->PendingSlots[Slot], Request, Token) && + ControllerContext->PendingSlots[Slot].State == ViiperUdePendingCompleting) { + VIIPER_UDE_PENDING_SLOT *pending = &ControllerContext->PendingSlots[Slot]; + if (pending->AbortPending) { + pending->CompletionStatus = pending->AbortStatus; + pending->CompletionUsbdStatus = USBD_STATUS_CANCELED; + pending->CompleteWithNtStatus = TRUE; + } else { + pending->CompletionStatus = Status; + pending->CompletionUsbdStatus = UsbdStatus; + pending->CompleteWithNtStatus = CompleteWithNtStatus; + } + completionStatus = pending->CompletionStatus; + completionUsbdStatus = pending->CompletionUsbdStatus; + completionWithNtStatus = pending->CompleteWithNtStatus; + pending->State = ViiperUdePendingDpcCompletion; + queued = TRUE; + } + WdfSpinLockRelease(ControllerContext->BrokerLock); + if (queued) { + queued = ViiperQueueUrbCompletion( + controller, + endpoint, + Request, + Slot, + Token, + completionStatus, + completionUsbdStatus, + completionWithNtStatus, + 0, + 0); + } + return queued; +} + +static +BOOLEAN +ViiperExpectedLateAbortLocked( + _In_ const VIIPER_UDE_PENDING_SLOT *Pending, + _In_ ULONGLONG Token + ) +{ + NTSTATUS abortStatus; + + if (Pending->Token != Token || + (Pending->State != ViiperUdePendingCompleting && + Pending->State != ViiperUdePendingDpcCompletion) || + (!Pending->AbortPending && !Pending->CompleteWithNtStatus)) { + return FALSE; + } + + abortStatus = Pending->AbortPending + ? Pending->AbortStatus + : Pending->CompletionStatus; + return abortStatus == STATUS_CANCELLED || + abortStatus == STATUS_DEVICE_REMOVED || + abortStatus == STATUS_DEVICE_NOT_READY || + abortStatus == STATUS_FILE_CLOSED; +} + +static +VOID +ViiperRemovePublishingRequest( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ ULONG Slot, + _In_ WDFREQUEST Request, + _In_ ULONGLONG Token, + _In_ NTSTATUS Status, + _In_ BOOLEAN NotifyOwner + ) +{ + BOOLEAN ownsRequest = FALSE; + BOOLEAN notifyOwner = FALSE; + VIIPER_UDE_REQUEST_CONTEXT *requestContext = ViiperGetRequestContext(Request); + WDFDEVICE controller = requestContext->Controller; + UDECXUSBENDPOINT endpoint = requestContext->Endpoint; + + WdfSpinLockAcquire(ControllerContext->BrokerLock); + if (Slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && + ViiperSlotMatches(&ControllerContext->PendingSlots[Slot], Request, Token)) { + if (NotifyOwner) { + notifyOwner = ViiperQueueCancelEventLocked( + ControllerContext, &ControllerContext->PendingSlots[Slot]); + } + ControllerContext->PendingSlots[Slot].CompletionStatus = Status; + ControllerContext->PendingSlots[Slot].CompletionUsbdStatus = USBD_STATUS_CANCELED; + ControllerContext->PendingSlots[Slot].CompleteWithNtStatus = TRUE; + ControllerContext->PendingSlots[Slot].State = ViiperUdePendingDpcCompletion; + ViiperUnlinkAdmissionLocked(&ControllerContext->PendingSlots[Slot]); + ownsRequest = TRUE; + } + WdfSpinLockRelease(ControllerContext->BrokerLock); + if (ownsRequest) { + (VOID)ViiperQueueUrbCompletion( + controller, + endpoint, + Request, + Slot, + Token, + Status, + USBD_STATUS_CANCELED, + TRUE, + 0, + 0); + if (notifyOwner) { + ViiperDispatchNotificationEvents(controller); + } + } +} + +static +VOID +ViiperDispatchAvailable( + _In_ WDFDEVICE Controller + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + + for (;;) { + WDFREQUEST urbRequest = WDF_NO_HANDLE; + WDFREQUEST dequeueRequest = WDF_NO_HANDLE; + UDECXUSBENDPOINT endpoint = WDF_NO_HANDLE; + VIIPER_UDE_OPERATION *serializedOperation = NULL; + ULONGLONG token = 0; + ULONG slot = VIIPER_UDE_MAX_PENDING_OPERATIONS; + ULONG index; + NTSTATUS status; + BOOLEAN abortPending = FALSE; + BOOLEAN cancelClaimed = FALSE; + NTSTATUS abortStatus = STATUS_CANCELLED; + + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0) { + break; + } + ViiperDispatchNotificationEvents(Controller); + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0) { + WdfSpinLockRelease(controllerContext->BrokerLock); + break; + } + // Once lifecycle notification loss faults the owner session, only the + // notification FIFO may drain. Publishing another control/media URB + // would cross a reset or power boundary which user mode can no longer + // reconstruct. + if (InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE) { + WdfSpinLockRelease(controllerContext->BrokerLock); + break; + } + for (index = 0; index < VIIPER_UDE_MAX_PENDING_OPERATIONS; ++index) { + ULONG candidate = (controllerContext->NextDispatchSlot + index) % + VIIPER_UDE_MAX_PENDING_OPERATIONS; + VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[candidate]; + if (pending->State == ViiperUdePendingQueued && + ViiperAdmissionCanPublishLocked(pending)) { + status = WdfIoQueueRetrieveNextRequest( + controllerContext->WaitingDequeues, &dequeueRequest); + if (!NT_SUCCESS(status)) { + dequeueRequest = WDF_NO_HANDLE; + break; + } + pending->State = ViiperUdePendingPublishing; + urbRequest = pending->Request; + endpoint = pending->Endpoint; + token = pending->Token; + slot = candidate; + controllerContext->NextDispatchSlot = (candidate + 1) % + VIIPER_UDE_MAX_PENDING_OPERATIONS; + WdfObjectReference(urbRequest); + InterlockedDecrement(&controllerContext->WaitingDequeueCount); + break; + } + } + WdfSpinLockRelease(controllerContext->BrokerLock); + + if (urbRequest == WDF_NO_HANDLE || dequeueRequest == WDF_NO_HANDLE) { + break; + } + + status = WdfRequestUnmarkCancelable(urbRequest); + if (status == STATUS_CANCELLED) { + WdfRequestComplete(dequeueRequest, STATUS_CANCELLED); + WdfObjectDereference(urbRequest); + continue; + } + if (!NT_SUCCESS(status)) { + ViiperRemovePublishingRequest( + controllerContext, slot, urbRequest, token, status, FALSE); + WdfRequestComplete(dequeueRequest, status); + WdfObjectDereference(urbRequest); + continue; + } + + status = ViiperSerializeOperation( + controllerContext, urbRequest, endpoint, token, dequeueRequest, + &serializedOperation); + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && + ViiperSlotMatches(&controllerContext->PendingSlots[slot], urbRequest, token)) { + VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[slot]; + abortPending = pending->AbortPending; + abortStatus = pending->AbortStatus; + if (pending->DeviceGeneration != + ViiperGetRequestContext(urbRequest)->DeviceGeneration || + pending->EndpointGeneration != + ViiperGetRequestContext(urbRequest)->EndpointGeneration || + pending->EndpointGeneration != + ViiperGetEndpointContext(endpoint)->Generation) { + status = STATUS_DEVICE_NOT_READY; + } + } else { + status = STATUS_CANCELLED; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + + if (!NT_SUCCESS(status) || abortPending) { + NTSTATUS completionStatus = abortPending ? abortStatus : status; + ViiperRemovePublishingRequest( + controllerContext, slot, urbRequest, token, completionStatus, FALSE); + WdfRequestComplete(dequeueRequest, completionStatus); + WdfObjectDereference(urbRequest); + continue; + } + + status = WdfRequestMarkCancelableEx(urbRequest, ViiperEvtUrbCancel); + if (!NT_SUCCESS(status)) { + ViiperRemovePublishingRequest( + controllerContext, slot, urbRequest, token, STATUS_CANCELLED, FALSE); + WdfRequestComplete(dequeueRequest, STATUS_CANCELLED); + WdfObjectDereference(urbRequest); + continue; + } + + abortPending = FALSE; + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && + ViiperSlotMatches(&controllerContext->PendingSlots[slot], urbRequest, token)) { + VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[slot]; + if (pending->State != ViiperUdePendingPublishing) { + // MarkCancelableEx may invoke the cancel callback before this + // thread reacquires BrokerLock. That callback owns the URB and + // its completion state must never be resurrected here. + cancelClaimed = TRUE; + } else { + abortPending = pending->AbortPending; + abortStatus = pending->AbortStatus; + if (pending->DeviceGeneration != + ViiperGetRequestContext(urbRequest)->DeviceGeneration || + pending->EndpointGeneration != + ViiperGetRequestContext(urbRequest)->EndpointGeneration || + pending->EndpointGeneration != + ViiperGetEndpointContext(endpoint)->Generation) { + abortPending = TRUE; + abortStatus = STATUS_DEVICE_NOT_READY; + } + pending->State = abortPending + ? ViiperUdePendingCompleting + : ViiperUdePendingInFlight; + // Publication or terminal abort retires the FIFO head. The + // next same-endpoint admission may now be selected without a + // controller-wide slot scan. + ViiperUnlinkAdmissionLocked(pending); + if (!abortPending) { + serializedOperation->EndpointSequence = + (ULONGLONG)InterlockedIncrement64( + &ViiperGetDeviceContext( + ViiperGetEndpointContext(endpoint)->Device)->EndpointSequences[ + ViiperGetEndpointContext(endpoint)->Descriptor.bEndpointAddress]); + serializedOperation->DeviceSequence = + (ULONGLONG)InterlockedIncrement64( + &ViiperGetDeviceContext( + ViiperGetEndpointContext(endpoint)->Device)->DeviceSequence); + pending->PublishedToOwner = TRUE; + } + } + } else { + cancelClaimed = TRUE; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (cancelClaimed) { + WdfRequestComplete(dequeueRequest, STATUS_CANCELLED); + WdfObjectDereference(urbRequest); + continue; + } + if (!NT_SUCCESS(status) || abortPending) { + NTSTATUS completionStatus = abortPending ? abortStatus : STATUS_CANCELLED; + NTSTATUS unmarkStatus = WdfRequestUnmarkCancelable(urbRequest); + if (NT_SUCCESS(unmarkStatus)) { + ViiperRemovePublishingRequest( + controllerContext, slot, urbRequest, token, completionStatus, FALSE); + } + WdfRequestComplete(dequeueRequest, completionStatus); + WdfObjectDereference(urbRequest); + continue; + } + + WdfRequestComplete(dequeueRequest, STATUS_SUCCESS); + WdfObjectDereference(urbRequest); + } +} + +NTSTATUS +ViiperQueueDequeueOperation( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request + ) +{ + WDFDEVICE controller = WdfIoQueueGetDevice(Queue); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(controller); + WDFFILEOBJECT fileObject = WdfRequestGetFileObject(Request); + VIIPER_UDE_FILE_CONTEXT *fileContext; + NTSTATUS status = STATUS_SUCCESS; + + if (fileObject == WDF_NO_HANDLE) { + return STATUS_INVALID_HANDLE; + } + fileContext = ViiperGetFileContext(fileObject); + + // File cleanup closes admission and purges WaitingDequeues while holding + // OwnerLock. Keep validation, accounting, and the manual-queue handoff in + // that same ownership transaction so a request cannot be forwarded after + // cleanup has already finished purging the queue. + WdfWaitLockAcquire(controllerContext->OwnerLock, NULL); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + controllerContext->OwnerFile != fileObject || + controllerContext->CleanupInProgress || + InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) == 0 || + InterlockedCompareExchange(&fileContext->Closing, 0, 0) != 0) { + status = STATUS_INVALID_DEVICE_STATE; + } else if (InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE) { + status = STATUS_DATA_ERROR; + } else { + InterlockedIncrement(&controllerContext->WaitingDequeueCount); + status = WdfRequestForwardToIoQueue(Request, controllerContext->WaitingDequeues); + if (!NT_SUCCESS(status)) { + InterlockedDecrement(&controllerContext->WaitingDequeueCount); + } + } + WdfWaitLockRelease(controllerContext->OwnerLock); + if (!NT_SUCCESS(status)) { + return status; + } + + ViiperDispatchAvailable(controller); + return STATUS_PENDING; +} + +NTSTATUS +ViiperQueueUrb( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request + ) +{ + UDECXUSBENDPOINT endpoint = *ViiperGetQueueEndpoint(Queue); + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + VIIPER_UDE_REQUEST_CONTEXT *requestContext = ViiperGetRequestContext(Request); + ULONG slot; + ULONGLONG token; + NTSTATUS status; + BOOLEAN abortPending = FALSE; + BOOLEAN cancelClaimed = FALSE; + BOOLEAN queueCancelledCompletion = FALSE; + NTSTATUS abortStatus = STATUS_CANCELLED; + + RtlZeroMemory(requestContext, sizeof(*requestContext)); + requestContext->Controller = deviceContext->Controller; + requestContext->Endpoint = endpoint; + requestContext->PendingSlot = VIIPER_UDE_MAX_PENDING_OPERATIONS; + requestContext->DeviceGeneration = deviceContext->Generation; + requestContext->EndpointGeneration = endpointContext->Generation; + // KMDF has already delivered this UdeCx request to the driver. Enter + // endpoint rundown and decide whether it may reach the broker in the same + // BrokerLock transaction. A request delivered immediately before PURGE, + // reset, D0 exit, or controller shutdown still owns its mandatory terminal + // DPC, but it must never allocate or publish a broker slot after that + // boundary. + WdfSpinLockAcquire(controllerContext->BrokerLock); + ViiperEndpointOperationStarted(endpoint); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE || + InterlockedCompareExchange(&deviceContext->InD0, 0, 0) == 0 || + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0) { + status = STATUS_DEVICE_NOT_READY; + } else { + status = STATUS_SUCCESS; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!NT_SUCCESS(status)) { + return status; + } + status = ViiperAllocatePendingSlot( + controllerContext, Request, endpoint, &slot, &token); + if (!NT_SUCCESS(status)) { + return status; + } + requestContext->PendingSlot = slot; + requestContext->Token = token; + + status = WdfRequestMarkCancelableEx(Request, ViiperEvtUrbCancel); + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && + ViiperSlotMatches(&controllerContext->PendingSlots[slot], Request, token)) { + if (NT_SUCCESS(status)) { + VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[slot]; + if (pending->State != ViiperUdePendingPreparing) { + // An immediate cancel callback already moved this slot to its + // DPC completion state and owns the request. + cancelClaimed = TRUE; + } else { + abortPending = pending->AbortPending; + abortStatus = pending->AbortStatus; + pending->State = abortPending + ? ViiperUdePendingCompleting + : ViiperUdePendingQueued; + if (abortPending) { + ViiperUnlinkAdmissionLocked(pending); + } + } + } else { + VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[slot]; + pending->CompletionStatus = STATUS_CANCELLED; + pending->CompletionUsbdStatus = USBD_STATUS_CANCELED; + pending->CompleteWithNtStatus = TRUE; + pending->State = ViiperUdePendingDpcCompletion; + ViiperUnlinkAdmissionLocked(pending); + queueCancelledCompletion = TRUE; + } + } else { + // Only the cancel callback/DPC can retire this just-allocated identity + // before the mark handoff reacquires BrokerLock. + cancelClaimed = TRUE; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!NT_SUCCESS(status)) { + if (queueCancelledCompletion) { + (VOID)ViiperQueueUrbCompletion( + deviceContext->Controller, + endpoint, + Request, + slot, + token, + STATUS_CANCELLED, + USBD_STATUS_CANCELED, + TRUE, + 0, + 0); + InterlockedIncrement64(&controllerContext->OperationsCancelled); + // MarkCancelableEx can reject a request before it ever reaches + // dispatch. Retiring that admission exposes the next endpoint + // head, so consume any dequeue that was already waiting. + ViiperDispatchAvailable(deviceContext->Controller); + } else if (!cancelClaimed) { + NT_ASSERT(FALSE); + } + return STATUS_PENDING; + } + if (cancelClaimed) { + return STATUS_PENDING; + } + if (abortPending) { + status = WdfRequestUnmarkCancelable(Request); + if (NT_SUCCESS(status)) { + ViiperRemovePublishingRequest( + controllerContext, slot, Request, token, abortStatus, FALSE); + } + return STATUS_PENDING; + } + + ViiperDispatchAvailable(deviceContext->Controller); + return STATUS_PENDING; +} + +static +BOOLEAN +ViiperRangeValid( + _In_ ULONG Offset, + _In_ ULONG Length, + _In_ ULONG Total + ) +{ + return Offset <= Total && Length <= Total - Offset; +} + +static +NTSTATUS +ViiperCompleteManagementOperation( + _In_ WDFDEVICE Controller, + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ const VIIPER_UDE_COMPLETION *Completion + ) +{ + ULONG encodedSlot = (ULONG)Completion->Token; + ULONG slot = (encodedSlot & ~VIIPER_UDE_MANAGEMENT_SLOT_FLAG) - 1; + WDFREQUEST request = WDF_NO_HANDLE; + UDECXUSBDEVICE device = WDF_NO_HANDLE; + UDECXUSBENDPOINT endpoint = WDF_NO_HANDLE; + UDECXUSBDEVICE deviceReference = WDF_NO_HANDLE; + UDECXUSBENDPOINT endpointReference = WDF_NO_HANDLE; + ULONG kind = 0; + UCHAR endpointAddress = 0; + ULONGLONG resetEpoch = 0; + BOOLEAN resetReleased = TRUE; + BOOLEAN retiredCompletion = FALSE; + + if ((encodedSlot & VIIPER_UDE_MANAGEMENT_SLOT_FLAG) == 0 || + slot >= VIIPER_UDE_MAX_PENDING_MANAGEMENT || + Completion->TransferLength != 0 || Completion->IsoPacketCount != 0 || + Completion->PayloadLength != 0 || Completion->UsbdStatus != 0 || + (NTSTATUS)Completion->Status == STATUS_PENDING) { + InterlockedIncrement64(&ControllerContext->InvalidMessages); + return STATUS_INVALID_PARAMETER; + } + + WdfSpinLockAcquire(ControllerContext->BrokerLock); + if (ControllerContext->ManagementSlots[slot].Token == Completion->Token && + ControllerContext->ManagementSlots[slot].State == ViiperUdePendingInFlight && + ControllerContext->ManagementSlots[slot].DeviceId == Completion->DeviceId && + ControllerContext->ManagementSlots[slot].DeviceGeneration == Completion->Generation && + ControllerContext->ManagementSlots[slot].EndpointGeneration == + Completion->EndpointGeneration) { + request = ControllerContext->ManagementSlots[slot].Request; + device = ControllerContext->ManagementSlots[slot].Device; + endpoint = ControllerContext->ManagementSlots[slot].Endpoint; + resetEpoch = ControllerContext->ManagementSlots[slot].ResetEpoch; + kind = ControllerContext->ManagementSlots[slot].Kind; + endpointAddress = ControllerContext->ManagementSlots[slot].EndpointAddress; + ControllerContext->ManagementSlots[slot].State = ViiperUdePendingCompleting; + WdfObjectReference(request); + } else if (!ControllerContext->ManagementSlots[slot].RetiredNotificationPending && + ControllerContext->ManagementSlots[slot].RetiredToken == + Completion->Token && + ControllerContext->ManagementSlots[slot].RetiredDeviceId == + Completion->DeviceId && + ControllerContext->ManagementSlots[slot].RetiredDeviceGeneration == + Completion->Generation && + ControllerContext->ManagementSlots[slot].RetiredEndpointGeneration == + Completion->EndpointGeneration) { + // The corresponding request and WDF-object pins were synchronously + // retired by child teardown after this token crossed to user mode. + // Consume the tombstone as a harmless expected-late ACK. + ControllerContext->ManagementSlots[slot].RetiredToken = 0; + ControllerContext->ManagementSlots[slot].RetiredDeviceId = 0; + ControllerContext->ManagementSlots[slot].RetiredDeviceGeneration = 0; + ControllerContext->ManagementSlots[slot].RetiredEndpointGeneration = 0; + ControllerContext->ManagementSlots[slot].RetiredOwnerFile = WDF_NO_HANDLE; + retiredCompletion = TRUE; + } + WdfSpinLockRelease(ControllerContext->BrokerLock); + if (request == WDF_NO_HANDLE) { + InterlockedIncrement64(&ControllerContext->LateCompletions); + return retiredCompletion ? STATUS_SUCCESS : STATUS_NOT_FOUND; + } + + if (kind == ViiperUdeOperationDeviceReset) { + // Endpoint RESET is asynchronous: UdeCx cannot resume endpoint I/O + // until this reset Request is completed. Repeat the read-only queue / + // rundown proof for the exact device generation at owner ack so even + // a terminal callback admitted after initial reset publication is + // joined. Reopen kernel admission immediately before completing the + // UdeCx request so a synchronously resumed URB sees post-reset state. + resetReleased = ViiperQuiesceResetByIdentity( + Controller, + Completion->DeviceId, + Completion->Generation, + device, + WDF_NO_HANDLE, + 0, + resetEpoch, + 0, + TRUE, + TRUE); + } else if (kind == ViiperUdeOperationEndpointReset) { + // Endpoint reset is a distinct UdeCx boundary, not a purge/start + // cycle. The second exact-generation proof closes the delivered- + // before-rundown window without changing UdeCx-owned queue state. + // Reopen only this endpoint immediately before completing the reset + // request; completion is the boundary at which UdeCx may resume I/O. + resetReleased = ViiperQuiesceResetByIdentity( + Controller, + Completion->DeviceId, + Completion->Generation, + device, + endpoint, + Completion->EndpointGeneration, + resetEpoch, + endpointAddress, + FALSE, + TRUE); + } + if (!resetReleased) { + // Removal or identity reuse won after this management request was + // published. Never apply an acknowledgement to a different child and + // never reopen a missing endpoint. Fail the held UdeCx reset request, + // then retire this completing slot here so removal cannot strand it. + WdfRequestComplete(request, STATUS_DEVICE_NOT_READY); + WdfSpinLockAcquire(ControllerContext->BrokerLock); + if (ControllerContext->ManagementSlots[slot].Request == request && + ControllerContext->ManagementSlots[slot].Token == Completion->Token && + ControllerContext->ManagementSlots[slot].State == ViiperUdePendingCompleting) { + ViiperClearManagementSlotLocked( + ControllerContext, slot, &deviceReference, &endpointReference); + } + WdfSpinLockRelease(ControllerContext->BrokerLock); + ViiperReleaseManagementSlotReferences(deviceReference, endpointReference); + WdfObjectDereference(request); + InterlockedIncrement64(&ControllerContext->OperationsPurged); + return STATUS_DEVICE_NOT_READY; + } + WdfRequestComplete(request, (NTSTATUS)Completion->Status); + WdfSpinLockAcquire(ControllerContext->BrokerLock); + if (ControllerContext->ManagementSlots[slot].Request == request && + ControllerContext->ManagementSlots[slot].Token == Completion->Token && + ControllerContext->ManagementSlots[slot].State == ViiperUdePendingCompleting) { + ViiperClearManagementSlotLocked( + ControllerContext, slot, &deviceReference, &endpointReference); + } + WdfSpinLockRelease(ControllerContext->BrokerLock); + ViiperReleaseManagementSlotReferences(deviceReference, endpointReference); + WdfObjectDereference(request); + InterlockedIncrement64(&ControllerContext->OperationsCompleted); + return STATUS_SUCCESS; +} + +NTSTATUS +ViiperCompleteOperation( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST CompletionRequest + ) +{ + WDFDEVICE controller = WdfIoQueueGetDevice(Queue); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(controller); + VIIPER_UDE_COMPLETION *completion; + VIIPER_UDE_REQUEST_CONTEXT *requestContext; + VIIPER_UDE_ISO_PACKET *packets = NULL; + UCHAR *tail = NULL; + UCHAR *payload = NULL; + WDFREQUEST urbRequest = WDF_NO_HANDLE; + PURB urb; + size_t inputLength; + size_t tailLength = 0; + ULONG slot; + ULONG index; + ULONG isoPayloadLimit; + ULONG isoBytes; + ULONG expectedSize; + ULONG packetTotal = 0; + ULONG isoErrorCount = 0; + NTSTATUS status; + BOOLEAN expectedLateAbort = FALSE; + BOOLEAN identityMismatch = FALSE; + BOOLEAN queued; + + status = ViiperValidateBrokerOwner(controller, CompletionRequest); + if (!NT_SUCCESS(status)) { + return status; + } + status = WdfRequestRetrieveInputBuffer( + CompletionRequest, sizeof(*completion), (PVOID *)&completion, &inputLength); + if (!NT_SUCCESS(status)) { + return status; + } + if (inputLength != sizeof(*completion) || completion->Header.Magic != VIIPER_UDE_MAGIC || + completion->Header.Major != VIIPER_UDE_ABI_MAJOR || + completion->Header.Minor != VIIPER_UDE_ABI_MINOR || + completion->Header.Flags != 0 || + completion->Header.Size < sizeof(*completion) || completion->Token == 0 || + completion->DeviceId == 0 || completion->Generation == 0 || + completion->TransferLength > VIIPER_UDE_MAX_TRANSFER_BYTES || + completion->PayloadLength > VIIPER_UDE_MAX_TRANSFER_BYTES || + completion->IsoPacketCount > VIIPER_UDE_MAX_ISO_PACKETS || + (((ULONG)completion->Token & VIIPER_UDE_MANAGEMENT_SLOT_FLAG) == 0 && + completion->EndpointGeneration == 0) || + completion->Reserved != 0) { + InterlockedIncrement64(&controllerContext->InvalidMessages); + return STATUS_INVALID_PARAMETER; + } + isoBytes = completion->IsoPacketCount * sizeof(VIIPER_UDE_ISO_PACKET); + if (completion->PayloadLength > MAXULONG - sizeof(*completion) - isoBytes) { + InterlockedIncrement64(&controllerContext->InvalidMessages); + return STATUS_INTEGER_OVERFLOW; + } + expectedSize = sizeof(*completion) + isoBytes + completion->PayloadLength; + if (completion->Header.Size != expectedSize || + completion->IsoPacketsOffset != sizeof(*completion) || + completion->PayloadOffset != sizeof(*completion) + isoBytes) { + InterlockedIncrement64(&controllerContext->InvalidMessages); + return STATUS_INVALID_PARAMETER; + } + tailLength = completion->Header.Size - sizeof(*completion); + if (tailLength > 0) { + status = WdfRequestRetrieveOutputBuffer( + CompletionRequest, tailLength, (PVOID *)&tail, NULL); + if (!NT_SUCCESS(status)) { + return status; + } + } + if (!ViiperRangeValid( + completion->IsoPacketsOffset, + isoBytes, + completion->Header.Size) || + !ViiperRangeValid( + completion->PayloadOffset, completion->PayloadLength, completion->Header.Size) || + (completion->IsoPacketCount != 0 && completion->IsoPacketsOffset < sizeof(*completion)) || + (completion->PayloadLength != 0 && completion->PayloadOffset < sizeof(*completion))) { + InterlockedIncrement64(&controllerContext->InvalidMessages); + return STATUS_INVALID_PARAMETER; + } + if (completion->IsoPacketCount != 0) { + packets = (VIIPER_UDE_ISO_PACKET *)( + tail + completion->IsoPacketsOffset - sizeof(*completion)); + } + if (completion->PayloadLength != 0) { + payload = tail + completion->PayloadOffset - sizeof(*completion); + } + if (((ULONG)completion->Token & VIIPER_UDE_MANAGEMENT_SLOT_FLAG) != 0) { + return ViiperCompleteManagementOperation(controller, controllerContext, completion); + } + + slot = (ULONG)(completion->Token & MAXULONG); + if (slot == 0 || slot > VIIPER_UDE_MAX_PENDING_OPERATIONS) { + InterlockedIncrement64(&controllerContext->LateCompletions); + return STATUS_NOT_FOUND; + } + --slot; + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (controllerContext->PendingSlots[slot].Token == completion->Token && + controllerContext->PendingSlots[slot].State == ViiperUdePendingInFlight) { + if (controllerContext->PendingSlots[slot].DeviceId == completion->DeviceId && + controllerContext->PendingSlots[slot].DeviceGeneration == completion->Generation && + controllerContext->PendingSlots[slot].EndpointGeneration == + completion->EndpointGeneration) { + urbRequest = controllerContext->PendingSlots[slot].Request; + controllerContext->PendingSlots[slot].State = ViiperUdePendingCompleting; + WdfObjectReference(urbRequest); + } else { + // A correct token with conflicting immutable identity must not + // claim the request. The owner will fault on this reply and file + // cleanup remains the authoritative retirement path. + identityMismatch = TRUE; + } + } else { + expectedLateAbort = ViiperExpectedLateAbortLocked( + &controllerContext->PendingSlots[slot], completion->Token); + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (identityMismatch) { + InterlockedIncrement64(&controllerContext->InvalidMessages); + return STATUS_INVALID_PARAMETER; + } + if (urbRequest == WDF_NO_HANDLE) { + InterlockedIncrement64(&controllerContext->LateCompletions); + return expectedLateAbort ? STATUS_SUCCESS : STATUS_NOT_FOUND; + } + + status = WdfRequestUnmarkCancelable(urbRequest); + if (!NT_SUCCESS(status)) { + WdfObjectDereference(urbRequest); + InterlockedIncrement64(&controllerContext->LateCompletions); + return status; + } + + requestContext = ViiperGetRequestContext(urbRequest); + urb = ViiperGetUrb(urbRequest); + if (urb == NULL || completion->DeviceId != + ViiperGetDeviceContext(ViiperGetEndpointContext(requestContext->Endpoint)->Device)->DeviceId || + completion->Generation != + ViiperGetDeviceContext(ViiperGetEndpointContext(requestContext->Endpoint)->Device)->Generation || + completion->Generation != requestContext->DeviceGeneration || + completion->EndpointGeneration != requestContext->EndpointGeneration || + completion->EndpointGeneration != + ViiperGetEndpointContext(requestContext->Endpoint)->Generation || + completion->TransferLength > requestContext->TransferLength) { + status = STATUS_INVALID_PARAMETER; + InterlockedIncrement64(&controllerContext->InvalidMessages); + goto CompleteWithNtStatus; + } + if (!NT_SUCCESS((NTSTATUS)completion->Status)) { + status = (NTSTATUS)completion->Status; + queued = ViiperQueueOwnedCompletion( + controllerContext, + slot, + urbRequest, + completion->Token, + status, + USBD_STATUS_INTERNAL_HC_ERROR, + TRUE); + WdfObjectDereference(urbRequest); + if (!queued) { + InterlockedIncrement64(&controllerContext->LateCompletions); + return STATUS_NOT_FOUND; + } + InterlockedIncrement64(&controllerContext->OperationsCompleted); + return STATUS_SUCCESS; + } + + if (completion->IsoPacketCount != requestContext->IsoPacketCount || + (completion->IsoPacketCount == 0 && requestContext->DirectionIn && + completion->PayloadLength != completion->TransferLength) || + (completion->IsoPacketCount == 0 && !requestContext->DirectionIn && + completion->PayloadLength != 0) || + (completion->IsoPacketCount != 0 && requestContext->DirectionIn && + completion->PayloadLength > requestContext->TransferLength) || + (completion->IsoPacketCount != 0 && !requestContext->DirectionIn && + completion->PayloadLength != 0)) { + status = STATUS_INVALID_PARAMETER; + InterlockedIncrement64(&controllerContext->InvalidMessages); + goto CompleteWithNtStatus; + } + + if (requestContext->DirectionIn && completion->PayloadLength > 0) { + status = ViiperCopyTransferBuffer( + urbRequest, urb, payload, completion->PayloadLength, TRUE); + if (!NT_SUCCESS(status)) { + goto CompleteWithNtStatus; + } + InterlockedAdd64(&controllerContext->BytesFromDevice, completion->TransferLength); + } + if (completion->IsoPacketCount != 0) { + isoPayloadLimit = requestContext->DirectionIn + ? completion->PayloadLength + : requestContext->TransferLength; + for (index = 0; index < completion->IsoPacketCount; ++index) { + ULONG originalOffset = urb->UrbIsochronousTransfer.IsoPacket[index].Offset; + ULONG nextOriginalOffset = index + 1 < completion->IsoPacketCount + ? urb->UrbIsochronousTransfer.IsoPacket[index + 1].Offset + : requestContext->TransferLength; + + if (packets[index].Reserved != 0 || + originalOffset > nextOriginalOffset || + nextOriginalOffset > requestContext->TransferLength || + packets[index].Offset != originalOffset || + packets[index].Offset > isoPayloadLimit || + packets[index].Length > isoPayloadLimit - packets[index].Offset || + packets[index].Length > nextOriginalOffset - originalOffset || + packets[index].Length > MAXULONG - packetTotal) { + status = STATUS_INVALID_PARAMETER; + InterlockedIncrement64(&controllerContext->InvalidMessages); + goto CompleteWithNtStatus; + } + packetTotal += packets[index].Length; + urb->UrbIsochronousTransfer.IsoPacket[index].Length = packets[index].Length; + urb->UrbIsochronousTransfer.IsoPacket[index].Status = packets[index].Status; + if ((USBD_STATUS)packets[index].Status != USBD_STATUS_SUCCESS) { + ++isoErrorCount; + } + } + if (packetTotal != completion->TransferLength) { + status = STATUS_INVALID_PARAMETER; + InterlockedIncrement64(&controllerContext->InvalidMessages); + goto CompleteWithNtStatus; + } + urb->UrbIsochronousTransfer.ErrorCount = isoErrorCount; + if ((urb->UrbIsochronousTransfer.TransferFlags & + USBD_START_ISO_TRANSFER_ASAP) != 0) { + urb->UrbIsochronousTransfer.StartFrame = requestContext->IsoStartFrame; + } + InterlockedAdd64(&controllerContext->IsoPackets, completion->IsoPacketCount); + } + + UdecxUrbSetBytesCompleted(urbRequest, completion->TransferLength); + queued = ViiperQueueOwnedCompletion( + controllerContext, + slot, + urbRequest, + completion->Token, + STATUS_SUCCESS, + (USBD_STATUS)completion->UsbdStatus, + FALSE); + WdfObjectDereference(urbRequest); + if (!queued) { + InterlockedIncrement64(&controllerContext->LateCompletions); + return STATUS_NOT_FOUND; + } + InterlockedIncrement64(&controllerContext->OperationsCompleted); + return STATUS_SUCCESS; + +CompleteWithNtStatus: + queued = ViiperQueueOwnedCompletion( + controllerContext, + slot, + urbRequest, + completion->Token, + status, + USBD_STATUS_INTERNAL_HC_ERROR, + TRUE); + WdfObjectDereference(urbRequest); + if (!queued) { + InterlockedIncrement64(&controllerContext->LateCompletions); + return STATUS_NOT_FOUND; + } + return status; +} + +static +VOID +ViiperAbortMatchingOperations( + _In_ WDFDEVICE Controller, + _In_opt_ UDECXUSBENDPOINT Endpoint, + _In_ NTSTATUS Status + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + ULONG index; + + if (controllerContext->BrokerLock == WDF_NO_HANDLE || + controllerContext->PendingSlots == NULL) { + return; + } + + for (index = 0; index < VIIPER_UDE_MAX_PENDING_OPERATIONS; ++index) { + WDFREQUEST request = WDF_NO_HANDLE; + ULONGLONG token = 0; + NTSTATUS unmarkStatus; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (controllerContext->PendingSlots[index].State != ViiperUdePendingEmpty && + (Endpoint == WDF_NO_HANDLE || controllerContext->PendingSlots[index].Endpoint == Endpoint)) { + VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[index]; + if (pending->State == ViiperUdePendingPublishing) { + pending->AbortPending = TRUE; + pending->AbortStatus = Status; + } else if (pending->State == ViiperUdePendingDpcCompletion) { + /* The request is already owned by the completion DPC. */ + } else if (pending->State != ViiperUdePendingPreparing && + pending->State != ViiperUdePendingCompleting) { + request = pending->Request; + token = pending->Token; + pending->AbortPending = TRUE; + pending->AbortStatus = Status; + pending->State = ViiperUdePendingCompleting; + WdfObjectReference(request); + } else { + pending->AbortPending = TRUE; + pending->AbortStatus = Status; + } + // AbortPending admissions were deliberately ignored by the old + // full-table ordering scan. Retire the equivalent FIFO node now; + // request/DPC ownership remains unchanged until terminal clear. + if (pending->AbortPending) { + ViiperUnlinkAdmissionLocked(pending); + } + } + WdfSpinLockRelease(controllerContext->BrokerLock); + + if (request == WDF_NO_HANDLE) { + continue; + } + unmarkStatus = WdfRequestUnmarkCancelable(request); + if (NT_SUCCESS(unmarkStatus)) { + ViiperRemovePublishingRequest( + controllerContext, index, request, token, Status, TRUE); + InterlockedIncrement64(&controllerContext->OperationsPurged); + } + WdfObjectDereference(request); + } +} + +static +VOID +ViiperAbortManagementOperationsMatching( + _In_ WDFDEVICE Controller, + _In_opt_ UDECXUSBDEVICE Device, + _In_ NTSTATUS Status + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + LARGE_INTEGER retryInterval; + + if (controllerContext->BrokerLock == WDF_NO_HANDLE || + controllerContext->ManagementSlots == NULL) { + return; + } + retryInterval.QuadPart = -10 * 1000; // one millisecond, relative + for (;;) { + BOOLEAN matchingSlot = FALSE; + ULONG index; + + for (index = 0; index < VIIPER_UDE_MAX_PENDING_MANAGEMENT; ++index) { + WDFREQUEST request = WDF_NO_HANDLE; + UDECXUSBDEVICE deviceReference = WDF_NO_HANDLE; + UDECXUSBENDPOINT endpointReference = WDF_NO_HANDLE; + ULONGLONG token = 0; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (controllerContext->ManagementSlots[index].State != ViiperUdePendingEmpty && + (Device == WDF_NO_HANDLE || + controllerContext->ManagementSlots[index].Device == Device)) { + matchingSlot = TRUE; + if (controllerContext->ManagementSlots[index].State != + ViiperUdePendingCompleting) { + request = controllerContext->ManagementSlots[index].Request; + token = controllerContext->ManagementSlots[index].Token; + controllerContext->ManagementSlots[index].RetiredToken = token; + controllerContext->ManagementSlots[index].RetiredDeviceId = + controllerContext->ManagementSlots[index].DeviceId; + controllerContext->ManagementSlots[index].RetiredDeviceGeneration = + controllerContext->ManagementSlots[index].DeviceGeneration; + controllerContext->ManagementSlots[index].RetiredEndpointGeneration = + controllerContext->ManagementSlots[index].EndpointGeneration; + controllerContext->ManagementSlots[index].RetiredOwnerFile = + controllerContext->ManagementSlots[index].OwnerFile; + controllerContext->ManagementSlots[index].RetiredNotificationPending = + controllerContext->ManagementSlots[index].State == + ViiperUdePendingQueued; + controllerContext->ManagementSlots[index].State = + ViiperUdePendingCompleting; + WdfObjectReference(request); + } + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (request == WDF_NO_HANDLE) { + continue; + } + + WdfRequestComplete(request, Status); + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (controllerContext->ManagementSlots[index].Request == request && + controllerContext->ManagementSlots[index].Token == token && + controllerContext->ManagementSlots[index].State == + ViiperUdePendingCompleting) { + ViiperClearManagementSlotLocked( + controllerContext, index, &deviceReference, &endpointReference); + } + WdfSpinLockRelease(controllerContext->BrokerLock); + ViiperReleaseManagementSlotReferences(deviceReference, endpointReference); + WdfObjectDereference(request); + InterlockedIncrement64(&controllerContext->OperationsPurged); + } + + if (!matchingSlot) { + return; + } + // A matching Completing slot is owned by another finite kernel + // callback. Join it before child consumption; no new slot can be + // admitted after ShuttingDown/OwnerFile closing or Device.Purging. + (VOID)KeDelayExecutionThread(KernelMode, FALSE, &retryInterval); + } +} + +static +VOID +ViiperAbortManagementOperations( + _In_ WDFDEVICE Controller, + _In_ NTSTATUS Status + ) +{ + ViiperAbortManagementOperationsMatching(Controller, WDF_NO_HANDLE, Status); +} + +VOID +ViiperAbortDeviceManagementOperations( + _In_ WDFDEVICE Controller, + _In_ UDECXUSBDEVICE Device, + _In_ NTSTATUS Status + ) +{ + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + + VIIPER_TRACE_LIFECYCLE( + Controller, VIIPER_UDE_TRACE_SOURCE_BROKER, + VIIPER_UDE_TRACE_MANAGEMENT_ABORT_BEGIN, deviceContext->DeviceId, + deviceContext->Generation, Device, WDF_NO_HANDLE, 0, Status, + deviceContext->PendingOperations, 0); + // Device removal has already closed Purging and retired the DeviceLock + // table entry. Complete every still-published management request while + // the UDE handle is valid, then release the slot's exact device/endpoint + // pins before PlugOutAndDelete consumes that handle. The shared abort + // helper also stably joins a slot already owned by a completing kernel + // callback, including file cleanup racing the serialized control queue. + ViiperAbortManagementOperationsMatching(Controller, Device, Status); + VIIPER_TRACE_LIFECYCLE( + Controller, VIIPER_UDE_TRACE_SOURCE_BROKER, + VIIPER_UDE_TRACE_MANAGEMENT_ABORT_END, deviceContext->DeviceId, + deviceContext->Generation, Device, WDF_NO_HANDLE, 0, Status, + deviceContext->PendingOperations, 0); +} + +VOID +ViiperRetireManagementTombstonesForOwner( + _In_ WDFDEVICE Controller, + _In_opt_ WDFFILEOBJECT OwnerFile + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(Controller); + ULONG index; + + if (controllerContext->BrokerLock == WDF_NO_HANDLE || + controllerContext->ManagementSlots == NULL) { + return; + } + // EvtFileClose runs only after this exact file object's I/O is fully + // drained. Terminal self-managed cleanup passes WDF_NO_HANDLE only after + // synchronously purging the entire control queue. Exact owner identity in + // the ordinary case prevents a delayed close from erasing a successor + // broker's independent late-ACK tombstone. + WdfSpinLockAcquire(controllerContext->BrokerLock); + for (index = 0; index < VIIPER_UDE_MAX_PENDING_MANAGEMENT; ++index) { + VIIPER_UDE_MANAGEMENT_SLOT *pending = + &controllerContext->ManagementSlots[index]; + + if (OwnerFile == WDF_NO_HANDLE || pending->RetiredOwnerFile == OwnerFile) { + pending->RetiredToken = 0; + pending->RetiredDeviceId = 0; + pending->RetiredDeviceGeneration = 0; + pending->RetiredEndpointGeneration = 0; + pending->RetiredOwnerFile = WDF_NO_HANDLE; + pending->RetiredNotificationPending = FALSE; + } + } + WdfSpinLockRelease(controllerContext->BrokerLock); +} + +VOID +ViiperPurgeEndpointOperations( + _In_ UDECXUSBENDPOINT Endpoint, + _In_ NTSTATUS Status + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + ViiperAbortMatchingOperations(deviceContext->Controller, Endpoint, Status); +} + +VOID +ViiperPurgeOwnerOperations( + _In_ WDFDEVICE Controller, + _In_ NTSTATUS Status + ) +{ + ViiperAbortMatchingOperations(Controller, WDF_NO_HANDLE, Status); + ViiperAbortManagementOperations(Controller, Status); +} diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c new file mode 100644 index 00000000..72670801 --- /dev/null +++ b/native/udecx/driver/Controller.c @@ -0,0 +1,658 @@ +#include +#include "ViiperUde.h" + +DEFINE_GUID( + GUID_DEVINTERFACE_VIIPER_UDE, + VIIPER_UDE_INTERFACE_GUID_DATA1, + VIIPER_UDE_INTERFACE_GUID_DATA2, + VIIPER_UDE_INTERFACE_GUID_DATA3, + VIIPER_UDE_INTERFACE_GUID_DATA4_0, + VIIPER_UDE_INTERFACE_GUID_DATA4_1, + VIIPER_UDE_INTERFACE_GUID_DATA4_2, + VIIPER_UDE_INTERFACE_GUID_DATA4_3, + VIIPER_UDE_INTERFACE_GUID_DATA4_4, + VIIPER_UDE_INTERFACE_GUID_DATA4_5, + VIIPER_UDE_INTERFACE_GUID_DATA4_6, + VIIPER_UDE_INTERFACE_GUID_DATA4_7); + +static +BOOLEAN +ViiperFinishOwnerCleanup( + _In_ WDFDEVICE Device, + _In_ WDFFILEOBJECT OwnerFile + ); + +static +VOID +ViiperWaitForControllerRundown( + _In_ WDFDEVICE Device, + _Inout_ PKEVENT Event, + _In_ USHORT WatchdogEvent, + _Inout_ volatile LONG *ActiveCounter + ); + +#ifdef ALLOC_PRAGMA +#pragma alloc_text(PAGE, ViiperEvtDeviceAdd) +#pragma alloc_text(PAGE, ViiperEvtDeviceSelfManagedIoInit) +#pragma alloc_text(PAGE, ViiperWaitForControllerRundown) +#pragma alloc_text(PAGE, ViiperFinishOwnerCleanup) +#pragma alloc_text(PAGE, ViiperEvtFileCreate) +#pragma alloc_text(PAGE, ViiperEvtFileClose) +#pragma alloc_text(PAGE, ViiperCreateQueues) +#endif + +static +BOOLEAN +ViiperFinishOwnerCleanup( + _In_ WDFDEVICE Device, + _In_ WDFFILEOBJECT OwnerFile + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *context = ViiperGetControllerContext(Device); + BOOLEAN releaseOwner = FALSE; + + PAGED_CODE(); + if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) != 0) { + return FALSE; + } + WdfWaitLockAcquire(context->OwnerLock, NULL); + if (context->OwnerFile != OwnerFile || !context->CleanupInProgress) { + WdfWaitLockRelease(context->OwnerLock); + return TRUE; + } + WdfWaitLockRelease(context->OwnerLock); + + // EvtFileCleanup can run while a create/destroy IOCTL is still dispatched. + // Closing and CleanupInProgress prevent a successor from entering; join + // only those finite UdeCx API calls here. Child EvtCleanup is deliberately + // not part of this rundown because PlugOutAndDelete consumes its handle + // before KMDF necessarily destroys the object. + ViiperWaitForControllerRundown( + Device, + &context->OwnerAdmissionsDrained, + VIIPER_UDE_TRACE_OWNER_RUNDOWN_WATCHDOG, + &context->ActiveOwnerAdmissions); + if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) != 0) { + return FALSE; + } + + if (!ViiperDestroyOwnedDevices(Device, OwnerFile)) { + return FALSE; + } + + WdfWaitLockAcquire(context->OwnerLock, NULL); + if (context->OwnerFile == OwnerFile && context->CleanupInProgress) { + context->OwnerFile = WDF_NO_HANDLE; + context->CleanupInProgress = FALSE; + releaseOwner = InterlockedExchange(&context->OwnerReferenced, FALSE) != FALSE; + } + WdfWaitLockRelease(context->OwnerLock); + if (releaseOwner) { + WdfObjectDereference(OwnerFile); + } + return TRUE; +} + +static +VOID +ViiperWaitForControllerRundown( + _In_ WDFDEVICE Device, + _Inout_ PKEVENT Event, + _In_ USHORT WatchdogEvent, + _Inout_ volatile LONG *ActiveCounter + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *context = + ViiperGetControllerContext(Device); + LARGE_INTEGER watchdogWait; + + PAGED_CODE(); + watchdogWait.QuadPart = + -(LONGLONG)VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS; + for (;;) { + NTSTATUS waitStatus = KeWaitForSingleObject( + Event, + Executive, + KernelMode, + FALSE, + &watchdogWait); + if (waitStatus != STATUS_TIMEOUT) { + NT_ASSERT(waitStatus == STATUS_SUCCESS); + return; + } + VIIPER_TRACE_LIFECYCLE( + Device, + VIIPER_UDE_TRACE_SOURCE_CONTROLLER, + WatchdogEvent, + 0, + 0, + WDF_NO_HANDLE, + WDF_NO_HANDLE, + 0, + STATUS_IO_TIMEOUT, + InterlockedCompareExchange(ActiveCounter, 0, 0), + (ULONG)InterlockedCompareExchange( + &context->PendingOperations, 0, 0)); + } +} + +NTSTATUS +ViiperEvtQueryUsbCapability( + _In_ WDFDEVICE UdecxWdfDevice, + _In_ GUID *CapabilityType, + _In_ ULONG OutputBufferLength, + _Out_writes_to_opt_(OutputBufferLength, *ResultLength) PVOID OutputBuffer, + _Out_ PULONG ResultLength + ) +{ + UNREFERENCED_PARAMETER(UdecxWdfDevice); + UNREFERENCED_PARAMETER(OutputBufferLength); + UNREFERENCED_PARAMETER(OutputBuffer); + + *ResultLength = 0; + if (RtlEqualMemory(CapabilityType, &GUID_USB_CAPABILITY_CHAINED_MDLS, sizeof(GUID)) || + RtlEqualMemory(CapabilityType, &GUID_USB_CAPABILITY_SELECTIVE_SUSPEND, sizeof(GUID)) || + RtlEqualMemory( + CapabilityType, + &GUID_USB_CAPABILITY_DEVICE_CONNECTION_HIGH_SPEED_COMPATIBLE, + sizeof(GUID)) || + RtlEqualMemory( + CapabilityType, + &GUID_USB_CAPABILITY_DEVICE_CONNECTION_SUPER_SPEED_COMPATIBLE, + sizeof(GUID))) { + return STATUS_SUCCESS; + } + + return STATUS_NOT_SUPPORTED; +} + +NTSTATUS +ViiperEvtDeviceAdd( + _In_ WDFDRIVER Driver, + _Inout_ PWDFDEVICE_INIT DeviceInit + ) +{ + NTSTATUS status; + WDFDEVICE device; + WDF_OBJECT_ATTRIBUTES attributes; + WDF_OBJECT_ATTRIBUTES fileAttributes; + WDF_OBJECT_ATTRIBUTES requestAttributes; + WDF_FILEOBJECT_CONFIG fileConfig; + UDECX_WDF_DEVICE_CONFIG udeConfig; + WDF_DEVICE_POWER_POLICY_IDLE_SETTINGS idleSettings; + WDF_PNPPOWER_EVENT_CALLBACKS pnpCallbacks; + VIIPER_UDE_CONTROLLER_CONTEXT *context; + UNICODE_STRING sddl = RTL_CONSTANT_STRING(L"D:P(A;;GA;;;SY)(A;;GA;;;BA)"); + UNICODE_STRING brokerReference; + + PAGED_CODE(); + UNREFERENCED_PARAMETER(Driver); + + // WdfDeviceInitAssignSDDLString requires a named device object. Let KMDF + // generate the private NT name; user mode opens only the device interface. + WdfDeviceInitSetCharacteristics( + DeviceInit, + FILE_DEVICE_SECURE_OPEN | FILE_AUTOGENERATED_DEVICE_NAME, + FALSE); + status = WdfDeviceInitAssignSDDLString(DeviceInit, &sddl); + if (!NT_SUCCESS(status)) { + return status; + } + + WDF_FILEOBJECT_CONFIG_INIT( + &fileConfig, + ViiperEvtFileCreate, + ViiperEvtFileClose, + ViiperEvtFileCleanup); + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&fileAttributes, VIIPER_UDE_FILE_CONTEXT); + fileAttributes.ExecutionLevel = WdfExecutionLevelPassive; + WdfDeviceInitSetFileObjectConfig(DeviceInit, &fileConfig, &fileAttributes); + + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&requestAttributes, VIIPER_UDE_REQUEST_CONTEXT); + WdfDeviceInitSetRequestAttributes(DeviceInit, &requestAttributes); + + WDF_PNPPOWER_EVENT_CALLBACKS_INIT(&pnpCallbacks); + pnpCallbacks.EvtDeviceSelfManagedIoInit = ViiperEvtDeviceSelfManagedIoInit; + pnpCallbacks.EvtDeviceSelfManagedIoCleanup = ViiperEvtDeviceSelfManagedIoCleanup; + WdfDeviceInitSetPnpPowerEventCallbacks(DeviceInit, &pnpCallbacks); + + status = UdecxInitializeWdfDeviceInit(DeviceInit); + if (!NT_SUCCESS(status)) { + return status; + } + + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, VIIPER_UDE_CONTROLLER_CONTEXT); + attributes.EvtCleanupCallback = ViiperEvtControllerCleanup; + status = WdfDeviceCreate(&DeviceInit, &attributes, &device); + if (!NT_SUCCESS(status)) { + return status; + } + + context = ViiperGetControllerContext(device); + RtlZeroMemory(context, sizeof(*context)); + ExInitializePushLock(&context->DeviceLock); + InitializeListHead(&context->CompletionQueue); + KeInitializeEvent(&context->BrokerOperationsDrained, NotificationEvent, TRUE); + KeInitializeEvent(&context->CompletionOperationsDrained, NotificationEvent, TRUE); + KeInitializeEvent(&context->OwnerAdmissionsDrained, NotificationEvent, TRUE); + KeInitializeEvent(&context->FileCleanupsDrained, NotificationEvent, TRUE); + + status = ViiperInitializeLifecycleTrace(device); + if (!NT_SUCCESS(status)) { + return status; + } + + // UdeCx owns the controller's USB root-hub power policy. Establish the + // proven non-wakeable S0 idle contract before publishing emulation so a + // port connect cannot race an implicit hub-suspend transition. This is a + // cold controller-lifecycle setting; it adds no work to the input path. + WDF_DEVICE_POWER_POLICY_IDLE_SETTINGS_INIT( + &idleSettings, IdleCannotWakeFromS0); + status = WdfDeviceAssignS0IdleSettings(device, &idleSettings); + if (!NT_SUCCESS(status)) { + return status; + } + + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = device; + status = WdfWaitLockCreate(&attributes, &context->OwnerLock); + if (!NT_SUCCESS(status)) { + return status; + } + status = ViiperInitializeBroker(device); + if (!NT_SUCCESS(status)) { + return status; + } + + RtlInitUnicodeString(&brokerReference, VIIPER_UDE_BROKER_REFERENCE_STRING); + status = WdfDeviceCreateDeviceInterface( + device, &GUID_DEVINTERFACE_VIIPER_UDE, &brokerReference); + if (!NT_SUCCESS(status)) { + return status; + } + status = WdfDeviceCreateDeviceInterface( + device, + (LPGUID)&GUID_DEVINTERFACE_USB_HOST_CONTROLLER, + NULL); + if (!NT_SUCCESS(status)) { + return status; + } + + UDECX_WDF_DEVICE_CONFIG_INIT(&udeConfig, ViiperEvtQueryUsbCapability); + udeConfig.NumberOfUsb20Ports = (USHORT)VIIPER_UDE_USB20_PORT_COUNT; + udeConfig.NumberOfUsb30Ports = (USHORT)VIIPER_UDE_USB30_PORT_COUNT; + status = UdecxWdfDeviceAddUsbDeviceEmulation(device, &udeConfig); + if (!NT_SUCCESS(status)) { + return status; + } + + return ViiperCreateQueues(device); +} + +VOID +ViiperEvtControllerCleanup( + _In_ WDFOBJECT ControllerObject + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *context; + ULONG index; + + context = ViiperGetControllerContext((WDFDEVICE)ControllerObject); + // Every active operation belongs in SelfManagedIoCleanup, while the + // controller's child queues, locks, memory objects, and work item are + // still callable. WDF invokes child cleanup before parent cleanup, so this + // callback is deliberately limited to invariant checks over context data. + NT_ASSERT(InterlockedCompareExchange(&context->PendingOperations, 0, 0) == 0); + NT_ASSERT(InterlockedCompareExchange(&context->PendingCompletions, 0, 0) == 0); + NT_ASSERT(IsListEmpty(&context->CompletionQueue)); + NT_ASSERT(!context->CompletionDpcActive); + NT_ASSERT(InterlockedCompareExchange(&context->ActiveOwnerAdmissions, 0, 0) == 0); + NT_ASSERT(InterlockedCompareExchange(&context->ActiveFileCleanups, 0, 0) == 0); + NT_ASSERT(InterlockedCompareExchange(&context->ActiveDevices, 0, 0) == 0); + NT_ASSERT(InterlockedCompareExchange(&context->ReservedPorts, 0, 0) == 0); + NT_ASSERT(InterlockedCompareExchange(&context->OwnerReferenced, 0, 0) == 0); + NT_ASSERT(context->InputDeviceCount == 0); + for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { + NT_ASSERT(!context->PortReserved[index]); + } +} + +NTSTATUS +ViiperEvtDeviceSelfManagedIoInit( + _In_ WDFDEVICE Device + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *context = ViiperGetControllerContext(Device); + + PAGED_CODE(); + InterlockedExchange(&context->ShuttingDown, FALSE); + return STATUS_SUCCESS; +} + +VOID +ViiperEvtDeviceSelfManagedIoCleanup( + _In_ WDFDEVICE Device + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *context = ViiperGetControllerContext(Device); + WDFFILEOBJECT ownerFile = WDF_NO_HANDLE; + BOOLEAN releaseOwner = FALSE; + + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + + // Close every user/UdeCx admission path before draining work that already + // crossed the boundary. Interlocked operations also provide the ordering + // barrier consumed by the queue and broker callbacks. + InterlockedExchange(&context->ShuttingDown, TRUE); + + if (context->OwnerLock != WDF_NO_HANDLE) { + WdfWaitLockAcquire(context->OwnerLock, NULL); + ownerFile = context->OwnerFile; + if (ownerFile != WDF_NO_HANDLE) { + InterlockedExchange(&ViiperGetFileContext(ownerFile)->Closing, TRUE); + context->CleanupInProgress = TRUE; + } + WdfWaitLockRelease(context->OwnerLock); + } + + // A file cleanup that crossed OwnerLock before ShuttingDown may still be + // using the controller's queue and lock children. The gate prevents any + // successor, so this event is a finite rundown join before those objects + // are purged. A cleanup that reaches OwnerLock after the gate never enters. + ViiperWaitForControllerRundown( + Device, + &context->FileCleanupsDrained, + VIIPER_UDE_TRACE_CONTROLLER_RUNDOWN_WATCHDOG, + &context->ActiveFileCleanups); + + // These queues are non-power-managed. KMDF purges them before this + // callback on normal removal, but an explicit idempotent purge also covers + // initialization failure and documents the driver's teardown boundary. + if (context->DefaultQueue != WDF_NO_HANDLE) { + WdfIoQueuePurgeSynchronously(context->DefaultQueue); + } + if (context->ControlQueue != WDF_NO_HANDLE) { + WdfIoQueuePurgeSynchronously(context->ControlQueue); + } + if (context->WaitingDequeues != WDF_NO_HANDLE) { + WdfIoQueuePurgeSynchronously(context->WaitingDequeues); + InterlockedExchange(&context->WaitingDequeueCount, 0); + } + // Create/destroy owner admissions execute on ControlQueue and therefore + // must have returned before its synchronous purge completes. + NT_ASSERT(InterlockedCompareExchange(&context->ActiveOwnerAdmissions, 0, 0) == 0); + + ViiperPurgeOwnerOperations(Device, STATUS_DEVICE_REMOVED); + // Close and join only operations already delivered into VIIPER. Queued host + // polls remain owned by the associated endpoint queues; PlugOutAndDelete + // causes UdeCx to stop those queues and issue PURGE. That callback drains + // only VIIPER-owned forwarded work before acknowledging the extension. + ViiperDrainControllerEndpointOperations(Device); + if (context->CompletionDpc != WDF_NO_HANDLE) { + for (;;) { + BOOLEAN stable; + + if (InterlockedCompareExchange(&context->PendingOperations, 0, 0) != 0) { + ViiperWaitForControllerRundown( + Device, + &context->BrokerOperationsDrained, + VIIPER_UDE_TRACE_CONTROLLER_RUNDOWN_WATCHDOG, + &context->PendingOperations); + } + // BrokerOperationsDrained covers tracked slots. The second join + // also covers rejected and fast-input URBs, then cancels/joins the + // reusable DPC only after its intrusive request list is empty. + ViiperDrainUrbCompletions(Device); + + // Endpoint driver-operation proof precedes this observation, so no + // callback can newly enter rundown after ShuttingDown. Recheck all + // controller-owned terminal state to join the final DPC handoff. + WdfSpinLockAcquire(context->BrokerLock); + stable = InterlockedCompareExchange(&context->PendingOperations, 0, 0) == 0 && + InterlockedCompareExchange(&context->PendingCompletions, 0, 0) == 0 && + IsListEmpty(&context->CompletionQueue) && + !context->CompletionDpcActive; + WdfSpinLockRelease(context->BrokerLock); + if (stable) { + break; + } + } + } + + if (context->BrokerLock != WDF_NO_HANDLE) { + WdfSpinLockAcquire(context->BrokerLock); + context->NotificationHead = 0; + context->NotificationTail = 0; + context->NotificationCount = 0; + InterlockedExchange(&context->BrokerFaulted, FALSE); + WdfSpinLockRelease(context->BrokerLock); + } + // ControlQueue has been synchronously purged and all management slots were + // joined above, so no old owner completion can consume a tombstone now. + // Release any owner generation's retained capacity before a possible PnP + // restart of this same controller object. + ViiperRetireManagementTombstonesForOwner(Device, WDF_NO_HANDLE); + + // Only after every VIIPER-owned endpoint operation and completion owner is + // quiescent may UdecxUsbDevicePlugOutAndDelete consume the child handles. + // Deletion remains asynchronous; never use a consumed device handle or wait + // for child EvtCleanup on this PnP worker. + ViiperBeginControllerShutdown(Device); + + if (context->OwnerLock != WDF_NO_HANDLE) { + WdfWaitLockAcquire(context->OwnerLock, NULL); + if (context->OwnerFile == ownerFile) { + context->OwnerFile = WDF_NO_HANDLE; + } + context->CleanupInProgress = FALSE; + releaseOwner = InterlockedExchange(&context->OwnerReferenced, FALSE) != FALSE; + WdfWaitLockRelease(context->OwnerLock); + } + if (releaseOwner && ownerFile != WDF_NO_HANDLE) { + WdfObjectDereference(ownerFile); + } +} + +VOID +ViiperEvtFileCreate( + _In_ WDFDEVICE Device, + _In_ WDFREQUEST Request, + _In_ WDFFILEOBJECT FileObject + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *context; + VIIPER_UDE_FILE_CONTEXT *fileContext; + PUNICODE_STRING fileName; + UNICODE_STRING brokerReference; + BOOLEAN isBrokerClient = FALSE; + NTSTATUS status = STATUS_SUCCESS; + + PAGED_CODE(); + context = ViiperGetControllerContext(Device); + fileContext = ViiperGetFileContext(FileObject); + RtlZeroMemory(fileContext, sizeof(*fileContext)); + + fileName = WdfFileObjectGetFileName(FileObject); + RtlInitUnicodeString(&brokerReference, VIIPER_UDE_BROKER_REFERENCE_STRING); + if (fileName != NULL && + fileName->Length == brokerReference.Length + sizeof(WCHAR) && + fileName->Buffer[0] == L'\\' && + RtlEqualMemory( + fileName->Buffer + 1, + brokerReference.Buffer, + brokerReference.Length)) { + isBrokerClient = TRUE; + } + + if (!isBrokerClient) { + WdfRequestComplete(Request, STATUS_SUCCESS); + return; + } + + WdfWaitLockAcquire(context->OwnerLock, NULL); + if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) != 0) { + status = STATUS_DEVICE_REMOVED; + } else if (context->OwnerFile != WDF_NO_HANDLE || context->CleanupInProgress) { + status = STATUS_SHARING_VIOLATION; + } else { + InterlockedExchange(&fileContext->BrokerOwner, TRUE); + WdfObjectReference(FileObject); + InterlockedExchange(&context->OwnerReferenced, TRUE); + context->OwnerFile = FileObject; + InterlockedExchange(&context->BrokerFaulted, FALSE); + WdfIoQueueStart(context->WaitingDequeues); + } + WdfWaitLockRelease(context->OwnerLock); + WdfRequestComplete(Request, status); +} + +VOID +ViiperEvtFileCleanup( + _In_ WDFFILEOBJECT FileObject + ) +{ + WDFDEVICE device; + VIIPER_UDE_CONTROLLER_CONTEXT *context; + VIIPER_UDE_FILE_CONTEXT *fileContext; + BOOLEAN ownsController = FALSE; + BOOLEAN cleanupAdmitted = FALSE; + LONG remainingCleanups; + + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + device = WdfFileObjectGetDevice(FileObject); + context = ViiperGetControllerContext(device); + fileContext = ViiperGetFileContext(FileObject); + InterlockedExchange(&fileContext->Closing, TRUE); + + // Self-managed cleanup owns controller-wide rundown once this gate closes. + // In particular, do not reach through sibling WDF lock/queue children from + // a file cleanup callback that can outlive their normal I/O lifetime. + if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) != 0) { + return; + } + if (InterlockedCompareExchange(&fileContext->BrokerOwner, 0, 0) == 0) { + return; + } + + WdfWaitLockAcquire(context->OwnerLock, NULL); + if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) == 0 && + context->OwnerFile == FileObject) { + if (InterlockedCompareExchange(&context->ActiveFileCleanups, 0, 0) == 0) { + KeClearEvent(&context->FileCleanupsDrained); + } + (VOID)InterlockedIncrement(&context->ActiveFileCleanups); + context->CleanupInProgress = TRUE; + ownsController = TRUE; + cleanupAdmitted = TRUE; + } + WdfWaitLockRelease(context->OwnerLock); + + if (ownsController) { + ViiperPurgeOwnerOperations(device, STATUS_FILE_CLOSED); + if (context->WaitingDequeues != WDF_NO_HANDLE) { + WdfIoQueuePurgeSynchronously(context->WaitingDequeues); + InterlockedExchange(&context->WaitingDequeueCount, 0); + } + WdfSpinLockAcquire(context->BrokerLock); + context->NotificationHead = 0; + context->NotificationTail = 0; + context->NotificationCount = 0; + WdfSpinLockRelease(context->BrokerLock); + } + if (ownsController) { + (VOID)ViiperFinishOwnerCleanup(device, FileObject); + } + if (cleanupAdmitted) { + WdfWaitLockAcquire(context->OwnerLock, NULL); + remainingCleanups = InterlockedDecrement(&context->ActiveFileCleanups); + NT_ASSERT(remainingCleanups >= 0); + if (remainingCleanups == 0) { + KeSetEvent(&context->FileCleanupsDrained, IO_NO_INCREMENT, FALSE); + } + WdfWaitLockRelease(context->OwnerLock); + } +} + +VOID +ViiperEvtFileClose( + _In_ WDFFILEOBJECT FileObject + ) +{ + VIIPER_UDE_FILE_CONTEXT *fileContext; + + PAGED_CODE(); + fileContext = ViiperGetFileContext(FileObject); + if (InterlockedCompareExchange( + &ViiperGetControllerContext( + WdfFileObjectGetDevice(FileObject))->ShuttingDown, 0, 0) != 0) { + // Terminal self-managed cleanup drained the whole control queue and + // cleared every tombstone while controller children were valid. + return; + } + if (InterlockedCompareExchange(&fileContext->BrokerOwner, 0, 0) == 0) { + return; + } + // Unlike EvtFileCleanup, KMDF invokes EvtFileClose only after all I/O for + // this file object is complete. That is the safe owner-session boundary + // for freeing unconsumed late-ACK tombstones without racing an old + // completion or erasing a successor broker's slots. + ViiperRetireManagementTombstonesForOwner( + WdfFileObjectGetDevice(FileObject), FileObject); +} + +NTSTATUS +ViiperCreateQueues( + _In_ WDFDEVICE Device + ) +{ + NTSTATUS status; + WDF_IO_QUEUE_CONFIG queueConfig; + WDF_OBJECT_ATTRIBUTES attributes; + VIIPER_UDE_CONTROLLER_CONTEXT *context = ViiperGetControllerContext(Device); + + PAGED_CODE(); + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = Device; + attributes.ExecutionLevel = WdfExecutionLevelPassive; + attributes.SynchronizationScope = WdfSynchronizationScopeNone; + + WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(&queueConfig, WdfIoQueueDispatchParallel); + queueConfig.PowerManaged = WdfFalse; + queueConfig.EvtIoDeviceControl = ViiperEvtIoDeviceControlRoute; + status = WdfIoQueueCreate(Device, &queueConfig, &attributes, &context->DefaultQueue); + if (!NT_SUCCESS(status)) { + return status; + } + + WDF_IO_QUEUE_CONFIG_INIT(&queueConfig, WdfIoQueueDispatchSequential); + queueConfig.PowerManaged = WdfFalse; + queueConfig.EvtIoDeviceControl = ViiperEvtIoDeviceControl; + status = WdfIoQueueCreate(Device, &queueConfig, &attributes, &context->ControlQueue); + if (!NT_SUCCESS(status)) { + return status; + } + + WDF_IO_QUEUE_CONFIG_INIT(&queueConfig, WdfIoQueueDispatchManual); + queueConfig.PowerManaged = WdfFalse; + // Overlapped dequeue IOCTLs are routinely cancelled when a host worker is + // retired. KMDF removes those requests from a manual queue without a + // retrieve call, so account for that ownership path explicitly instead of + // leaving WaitingDequeueCount permanently inflated for the owner session. + queueConfig.EvtIoCanceledOnQueue = ViiperEvtDequeueCanceledOnQueue; + return WdfIoQueueCreate(Device, &queueConfig, &attributes, &context->WaitingDequeues); +} + +VOID +ViiperEvtDequeueCanceledOnQueue( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *context = + ViiperGetControllerContext(WdfIoQueueGetDevice(Queue)); + LONG remaining = InterlockedDecrement(&context->WaitingDequeueCount); + + NT_ASSERT(remaining >= 0); + UNREFERENCED_PARAMETER(remaining); + WdfRequestComplete(Request, STATUS_CANCELLED); +} diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c new file mode 100644 index 00000000..0396d393 --- /dev/null +++ b/native/udecx/driver/Device.c @@ -0,0 +1,2833 @@ +/* + * Dynamic UdeCx device and endpoint lifecycle. + * + * The endpoint creation and purge order follows the documented UdeCx contract. + * VIIPER-specific ownership, identity, and broker semantics are implemented + * here. + */ + +#include "ViiperUde.h" + +#ifdef ALLOC_PRAGMA +#pragma alloc_text(PAGE, ViiperCreateVirtualDevice) +#pragma alloc_text(PAGE, ViiperDestroyVirtualDevice) +#pragma alloc_text(PAGE, ViiperDestroyOwnedDevices) +#pragma alloc_text(PAGE, ViiperEvtEndpointAdd) +#pragma alloc_text(PAGE, ViiperEvtDefaultEndpointAdd) +#pragma alloc_text(PAGE, ViiperEvtEndpointCleanup) +#endif + +static +BOOLEAN +ViiperRangeValid( + _In_ ULONG Offset, + _In_ ULONG Length, + _In_ ULONG Total + ) +{ + return Offset <= Total && Length <= Total - Offset; +} + +static +BOOLEAN +ViiperValidateDescriptorChain( + _In_reads_bytes_(Length) const UCHAR *Descriptor, + _In_ ULONG Length, + _In_ UCHAR ExpectedType + ) +{ + const UCHAR *cursor = Descriptor; + ULONG remaining = Length; + + if (remaining < 2 || cursor[1] != ExpectedType) { + return FALSE; + } + while (remaining != 0) { + ULONG itemLength; + if (remaining < 2) { + return FALSE; + } + itemLength = cursor[0]; + if (itemLength < 2 || itemLength > remaining) { + return FALSE; + } + cursor += itemLength; + remaining -= itemLength; + } + return TRUE; +} + +static +BOOLEAN +ViiperValidateEndpointSchedules( + _In_reads_bytes_(Length) const UCHAR *Descriptor, + _In_ ULONG Length, + _In_ ULONG Speed + ) +{ + ULONG offset = 0; + + // The owner sends the UdeCx-facing descriptor, not the controller's + // logical full-speed descriptor. USBHUB3 schedules every UDE endpoint + // using high-speed interval rules. Reject an old or malformed privileged + // owner here, before UdecxUsbDeviceInitAddDescriptor can expose an unsafe + // ISO pipe to a client driver. + while (offset < Length) { + const UCHAR *item; + ULONG itemLength; + UCHAR transferType; + + if (Length - offset < 2) { + return FALSE; + } + item = Descriptor + offset; + itemLength = item[0]; + if (itemLength < 2 || itemLength > Length - offset) { + return FALSE; + } + if (item[1] != USB_ENDPOINT_DESCRIPTOR_TYPE) { + offset += itemLength; + continue; + } + if (itemLength < sizeof(USB_ENDPOINT_DESCRIPTOR)) { + return FALSE; + } + + transferType = item[3] & USB_ENDPOINT_TYPE_MASK; + if (transferType == USB_ENDPOINT_TYPE_ISOCHRONOUS) { + if (Speed == 1) { + return FALSE; + } + if (Speed == 2) { + // Full-speed one-frame ISO is projected to the equivalent + // high-speed exponent before crossing this ABI. + if (item[6] != 4) { + return FALSE; + } + } else if (item[6] == 0 || item[6] > 4) { + // Windows supports HS/SS ISO polling periods only through + // eight microframes. Client I/O above that may bugcheck. + return FALSE; + } + } else if (transferType == USB_ENDPOINT_TYPE_INTERRUPT && + (item[6] == 0 || item[6] > 16)) { + return FALSE; + } + offset += itemLength; + } + return offset == Length; +} + +static const UCHAR microsoftOS10StringPrefix[] = { + 0x12, 0x03, + 0x4d, 0x00, 0x53, 0x00, 0x46, 0x00, 0x54, 0x00, + 0x31, 0x00, 0x30, 0x00, 0x30, 0x00 +}; + +static +BOOLEAN +ViiperIsMicrosoftOS10StringDescriptor( + _In_ const VIIPER_UDE_DESCRIPTOR_RECORD *Record, + _In_reads_bytes_(Record->Length) const UCHAR *Descriptor + ) +{ + return Record->Index == VIIPER_UDE_MS_OS_10_STRING_INDEX && + Record->LanguageId == 0 && + Record->Length == VIIPER_UDE_MS_OS_10_STRING_LENGTH && + sizeof(microsoftOS10StringPrefix) == VIIPER_UDE_MS_OS_10_VENDOR_CODE_OFFSET && + RtlCompareMemory( + Descriptor, + microsoftOS10StringPrefix, + sizeof(microsoftOS10StringPrefix)) == sizeof(microsoftOS10StringPrefix) && + Descriptor[VIIPER_UDE_MS_OS_10_VENDOR_CODE_OFFSET] != 0 && + Descriptor[VIIPER_UDE_MS_OS_10_STRING_LENGTH - 1] == 0; +} + +static +BOOLEAN +ViiperValidateCreateDevice( + _In_reads_bytes_(InputLength) const VIIPER_UDE_CREATE_DEVICE *Input, + _In_ size_t InputLength + ) +{ + const VIIPER_UDE_DESCRIPTOR_RECORD *records; + ULONG recordsLength; + ULONG index; + BOOLEAN foundDevice = FALSE; + BOOLEAN foundConfiguration = FALSE; + BOOLEAN foundBos = FALSE; + BOOLEAN foundLanguageTable = FALSE; + BOOLEAN foundLocalizedString = FALSE; + BOOLEAN foundMicrosoftOS10String = FALSE; + + if (InputLength < sizeof(*Input) || + InputLength > (size_t)VIIPER_UDE_MAX_DESCRIPTOR_BYTES * 2 + sizeof(*Input) || + Input->Header.Magic != VIIPER_UDE_MAGIC || + Input->Header.Major != VIIPER_UDE_ABI_MAJOR || + Input->Header.Minor != VIIPER_UDE_ABI_MINOR || + Input->Header.Flags != 0 || + Input->Header.Size != InputLength || + Input->DeviceId == 0 || Input->Generation == 0 || + Input->Speed < 1 || Input->Speed > 4 || + Input->DescriptorCount == 0 || + Input->DescriptorCount > VIIPER_UDE_MAX_DESCRIPTOR_BYTES / sizeof(*records) || + Input->DescriptorDataLength == 0 || + Input->DescriptorDataLength > VIIPER_UDE_MAX_DESCRIPTOR_BYTES || + Input->MaxPendingOperations == 0 || + Input->MaxPendingOperations > VIIPER_UDE_MAX_PENDING_OPERATIONS || + Input->Reserved != 0) { + return FALSE; + } + + if (Input->DescriptorCount > MAXULONG / sizeof(*records)) { + return FALSE; + } + recordsLength = Input->DescriptorCount * sizeof(*records); + if (Input->DescriptorRecordsOffset < sizeof(*Input) || + !ViiperRangeValid(Input->DescriptorRecordsOffset, recordsLength, Input->Header.Size) || + !ViiperRangeValid(Input->DescriptorDataOffset, Input->DescriptorDataLength, Input->Header.Size) || + Input->DescriptorDataOffset < Input->DescriptorRecordsOffset || + Input->DescriptorDataOffset - Input->DescriptorRecordsOffset < recordsLength || + Input->DescriptorDataOffset + Input->DescriptorDataLength != Input->Header.Size) { + return FALSE; + } + + records = (const VIIPER_UDE_DESCRIPTOR_RECORD *) + ((const UCHAR *)Input + Input->DescriptorRecordsOffset); + for (index = 0; index < Input->DescriptorCount; ++index) { + const VIIPER_UDE_DESCRIPTOR_RECORD *record = &records[index]; + const UCHAR *descriptor; + if (record->Length < 2 || record->Length > MAXUSHORT || + record->Reserved != 0 || + !ViiperRangeValid(record->Offset, record->Length, Input->DescriptorDataLength)) { + return FALSE; + } + descriptor = (const UCHAR *)Input + Input->DescriptorDataOffset + record->Offset; + switch (record->Kind) { + case ViiperUdeDescriptorDevice: + if (foundDevice || record->Index != 0 || + record->Length != sizeof(USB_DEVICE_DESCRIPTOR) || + descriptor[0] != sizeof(USB_DEVICE_DESCRIPTOR) || + descriptor[1] != USB_DEVICE_DESCRIPTOR_TYPE) { + return FALSE; + } + foundDevice = TRUE; + break; + case ViiperUdeDescriptorConfiguration: + if (foundConfiguration || record->Index != 0 || + record->Length < sizeof(USB_CONFIGURATION_DESCRIPTOR) || + descriptor[0] != sizeof(USB_CONFIGURATION_DESCRIPTOR) || + descriptor[1] != USB_CONFIGURATION_DESCRIPTOR_TYPE || + ((USHORT)descriptor[2] | ((USHORT)descriptor[3] << 8)) != (USHORT)record->Length || + !ViiperValidateDescriptorChain( + descriptor, record->Length, USB_CONFIGURATION_DESCRIPTOR_TYPE) || + !ViiperValidateEndpointSchedules( + descriptor, record->Length, Input->Speed)) { + return FALSE; + } + foundConfiguration = TRUE; + break; + case ViiperUdeDescriptorBos: + if (foundBos || record->Index != 0 || + record->Length < sizeof(USB_BOS_DESCRIPTOR) || + descriptor[0] != sizeof(USB_BOS_DESCRIPTOR) || + descriptor[1] != USB_BOS_DESCRIPTOR_TYPE || + ((USHORT)descriptor[2] | ((USHORT)descriptor[3] << 8)) != (USHORT)record->Length || + !ViiperValidateDescriptorChain( + descriptor, record->Length, USB_BOS_DESCRIPTOR_TYPE)) { + return FALSE; + } + foundBos = TRUE; + break; + case ViiperUdeDescriptorString: + { + BOOLEAN isMicrosoftOS10String = + ViiperIsMicrosoftOS10StringDescriptor(record, descriptor); + if (record->Index > MAXUCHAR || record->Length > MAXUCHAR || + descriptor[0] != record->Length || descriptor[1] != USB_STRING_DESCRIPTOR_TYPE || + (record->Length & 1) != 0 || + (record->Index == 0 && record->LanguageId != 0) || + (record->Index == 0 && record->Length < 4) || + (record->Index != 0 && record->LanguageId == 0 && + !isMicrosoftOS10String)) { + return FALSE; + } + if (record->Index == 0) { + if (foundLanguageTable) { + return FALSE; + } + foundLanguageTable = TRUE; + } else if (isMicrosoftOS10String) { + if (foundMicrosoftOS10String) { + return FALSE; + } + foundMicrosoftOS10String = TRUE; + } else { + foundLocalizedString = TRUE; + } + break; + } + default: + return FALSE; + } + } + + return foundDevice && foundConfiguration && + (!foundLocalizedString || foundLanguageTable); +} + +static +NTSTATUS +ViiperAddDeviceDescriptors( + _Inout_ PUDECXUSBDEVICE_INIT DeviceInit, + _In_ const VIIPER_UDE_CREATE_DEVICE *Input + ) +{ + const VIIPER_UDE_DESCRIPTOR_RECORD *records = + (const VIIPER_UDE_DESCRIPTOR_RECORD *) + ((const UCHAR *)Input + Input->DescriptorRecordsOffset); + const UCHAR *data = (const UCHAR *)Input + Input->DescriptorDataOffset; + ULONG index; + + for (index = 0; index < Input->DescriptorCount; ++index) { + const VIIPER_UDE_DESCRIPTOR_RECORD *record = &records[index]; + PUCHAR descriptor = (PUCHAR)(data + record->Offset); + NTSTATUS status; + + switch (record->Kind) { + case ViiperUdeDescriptorDevice: + case ViiperUdeDescriptorConfiguration: + case ViiperUdeDescriptorBos: + status = UdecxUsbDeviceInitAddDescriptor( + DeviceInit, descriptor, (USHORT)record->Length); + break; + case ViiperUdeDescriptorString: + if (record->Index == 0) { + status = UdecxUsbDeviceInitAddDescriptorWithIndex( + DeviceInit, descriptor, (USHORT)record->Length, 0); + } else { + status = UdecxUsbDeviceInitAddStringDescriptorRaw( + DeviceInit, + descriptor, + (USHORT)record->Length, + (UCHAR)record->Index, + record->LanguageId); + } + break; + default: + status = STATUS_INVALID_PARAMETER; + break; + } + if (!NT_SUCCESS(status)) { + return status; + } + } + return STATUS_SUCCESS; +} + +static +NTSTATUS +ViiperBeginOwnerAdmission( + _In_ WDFDEVICE Controller, + _In_ WDFREQUEST Request, + _Out_ WDFFILEOBJECT *OwnerFile + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + VIIPER_UDE_FILE_CONTEXT *fileContext; + WDFFILEOBJECT fileObject = WdfRequestGetFileObject(Request); + NTSTATUS status = STATUS_SUCCESS; + + if (fileObject == WDF_NO_HANDLE) { + return STATUS_INVALID_HANDLE; + } + fileContext = ViiperGetFileContext(fileObject); + WdfWaitLockAcquire(controllerContext->OwnerLock, NULL); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + controllerContext->OwnerFile != fileObject || controllerContext->CleanupInProgress || + InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) == 0 || + InterlockedCompareExchange(&fileContext->Closing, 0, 0) != 0) { + status = STATUS_INVALID_DEVICE_STATE; + } else { + // Keep both the owner object and cleanup boundary alive while a child + // is created or destroyed. UdeCx lifecycle calls may invoke callbacks, + // so do not hold OwnerLock across them. + WdfObjectReference(fileObject); + if (InterlockedIncrement(&controllerContext->ActiveOwnerAdmissions) == 1) { + KeClearEvent(&controllerContext->OwnerAdmissionsDrained); + } + *OwnerFile = fileObject; + } + WdfWaitLockRelease(controllerContext->OwnerLock); + return status; +} + +static +VOID +ViiperEndOwnerAdmission( + _In_ WDFDEVICE Controller, + _In_ WDFFILEOBJECT OwnerFile + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + LONG remaining; + + WdfWaitLockAcquire(controllerContext->OwnerLock, NULL); + remaining = InterlockedDecrement(&controllerContext->ActiveOwnerAdmissions); + NT_ASSERT(remaining >= 0); + if (remaining == 0) { + KeSetEvent(&controllerContext->OwnerAdmissionsDrained, IO_NO_INCREMENT, FALSE); + } + WdfWaitLockRelease(controllerContext->OwnerLock); + WdfObjectDereference(OwnerFile); +} + +static +UDECX_USB_DEVICE_SPEED +ViiperMapSpeed( + _In_ ULONG Speed + ) +{ + switch (Speed) { + case 1: + return UdecxUsbLowSpeed; + case 2: + return UdecxUsbFullSpeed; + case 3: + return UdecxUsbHighSpeed; + case 4: + return UdecxUsbSuperSpeed; + default: + return (UDECX_USB_DEVICE_SPEED)0; + } +} + +static +UDECXUSBDEVICE +ViiperFindInputDeviceLocked( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ ULONGLONG DeviceId + ) +{ + ULONG first = 0; + ULONG count = ControllerContext->InputDeviceCount; + + // InputDevices is a cold-lifecycle index: mutations keep it sorted while + // the report producer performs at most log2(32) identity comparisons. + while (count != 0) { + ULONG step = count / 2; + ULONG candidate = first + step; + UDECXUSBDEVICE device = ControllerContext->InputDevices[candidate]; + ULONGLONG candidateId = ViiperGetDeviceContext(device)->DeviceId; + + if (candidateId < DeviceId) { + first = candidate + 1; + count -= step + 1; + } else { + count = step; + } + } + if (first >= ControllerContext->InputDeviceCount || + ViiperGetDeviceContext(ControllerContext->InputDevices[first])->DeviceId != DeviceId) { + return WDF_NO_HANDLE; + } + return ControllerContext->InputDevices[first]; +} + +static +NTSTATUS +ViiperInsertInputDeviceLocked( + _Inout_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ UDECXUSBDEVICE Device + ) +{ + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + ULONG position = 0; + + if (ControllerContext->InputDeviceCount >= VIIPER_UDE_MAX_DEVICES) { + return STATUS_INSUFFICIENT_RESOURCES; + } + while (position < ControllerContext->InputDeviceCount && + ViiperGetDeviceContext(ControllerContext->InputDevices[position])->DeviceId < + deviceContext->DeviceId) { + ++position; + } + if (position < ControllerContext->InputDeviceCount && + ViiperGetDeviceContext(ControllerContext->InputDevices[position])->DeviceId == + deviceContext->DeviceId) { + return STATUS_OBJECT_NAME_COLLISION; + } + if (position < ControllerContext->InputDeviceCount) { + RtlMoveMemory( + &ControllerContext->InputDevices[position + 1], + &ControllerContext->InputDevices[position], + sizeof(ControllerContext->InputDevices[0]) * + (ControllerContext->InputDeviceCount - position)); + } + ControllerContext->InputDevices[position] = Device; + ++ControllerContext->InputDeviceCount; + return STATUS_SUCCESS; +} + +static +VOID +ViiperRemoveInputDeviceLocked( + _Inout_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ UDECXUSBDEVICE Device + ) +{ + ULONG position; + + for (position = 0; position < ControllerContext->InputDeviceCount; ++position) { + if (ControllerContext->InputDevices[position] == Device) { + break; + } + } + if (position == ControllerContext->InputDeviceCount) { + return; + } + --ControllerContext->InputDeviceCount; + if (position < ControllerContext->InputDeviceCount) { + RtlMoveMemory( + &ControllerContext->InputDevices[position], + &ControllerContext->InputDevices[position + 1], + sizeof(ControllerContext->InputDevices[0]) * + (ControllerContext->InputDeviceCount - position)); + } + ControllerContext->InputDevices[ControllerContext->InputDeviceCount] = WDF_NO_HANDLE; +} + +static +NTSTATUS +ViiperClaimDeviceSlot( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ UDECXUSBDEVICE Device, + _In_ ULONGLONG DeviceId, + _Out_ ULONG *Slot, + _Out_ ULONGLONG *PortReservation + ) +{ + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + ULONG index; + ULONG freeSlot = VIIPER_UDE_MAX_DEVICES; + NTSTATUS status = STATUS_INSUFFICIENT_RESOURCES; + + ViiperAcquireDeviceLockExclusive(ControllerContext); + if (InterlockedCompareExchange(&ControllerContext->ShuttingDown, 0, 0) != 0) { + status = STATUS_DEVICE_REMOVED; + goto Exit; + } + for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { + UDECXUSBDEVICE current = ControllerContext->Devices[index]; + if (current == WDF_NO_HANDLE) { + if (!ControllerContext->PortReserved[index] && + freeSlot == VIIPER_UDE_MAX_DEVICES) { + freeSlot = index; + } + continue; + } + if (ViiperGetDeviceContext(current)->DeviceId == DeviceId && + InterlockedCompareExchange( + &ViiperGetDeviceContext(current)->Purging, 0, 0) == 0) { + status = STATUS_OBJECT_NAME_COLLISION; + goto Exit; + } + } + if (freeSlot != VIIPER_UDE_MAX_DEVICES) { + status = ViiperInsertInputDeviceLocked(ControllerContext, Device); + if (NT_SUCCESS(status)) { + ULONGLONG reservation = ++ControllerContext->PortReservationEpochs[freeSlot]; + if (reservation == 0) { + reservation = ++ControllerContext->PortReservationEpochs[freeSlot]; + } + NT_ASSERT(InterlockedCompareExchange( + &deviceContext->ActiveCounted, 0, 0) == 0); + deviceContext->Slot = freeSlot; + deviceContext->PortReservation = reservation; + // Publish a complete lifecycle record before PlugIn can expose + // the object to UdeCx. For a claimed object, Plugged means that + // successful exposure must be unwound with PlugOutAndDelete. + deviceContext->Plugged = TRUE; + ControllerContext->PortReserved[freeSlot] = TRUE; + InterlockedIncrement(&ControllerContext->ReservedPorts); + ControllerContext->Devices[freeSlot] = Device; + InterlockedIncrement(&ControllerContext->ActiveDevices); + InterlockedExchange(&deviceContext->ActiveCounted, 1); + *Slot = freeSlot; + *PortReservation = reservation; + } + } + +Exit: + ViiperReleaseDeviceLockExclusive(ControllerContext); + return status; +} + +static +VOID +ViiperReleaseDeviceSlot( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ UDECXUSBDEVICE Device, + _In_ ULONG Slot, + _In_ ULONGLONG PortReservation + ) +{ + ViiperAcquireDeviceLockExclusive(ControllerContext); + if (Slot < VIIPER_UDE_MAX_DEVICES && PortReservation != 0 && + ControllerContext->PortReserved[Slot] && + ControllerContext->PortReservationEpochs[Slot] == PortReservation) { + if (ControllerContext->Devices[Slot] == Device) { + ViiperRemoveInputDeviceLocked(ControllerContext, Device); + ControllerContext->Devices[Slot] = WDF_NO_HANDLE; + } + ControllerContext->PortReserved[Slot] = FALSE; + { + LONG remaining = InterlockedDecrement(&ControllerContext->ReservedPorts); + NT_ASSERT(remaining >= 0); + (VOID)remaining; + } + } + ViiperReleaseDeviceLockExclusive(ControllerContext); +} + +static +VOID +ViiperRetireActiveDevice( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ VIIPER_UDE_DEVICE_CONTEXT *DeviceContext + ) +{ + LONG remaining; + + if (InterlockedExchange(&DeviceContext->ActiveCounted, 0) == 0) { + return; + } + remaining = InterlockedDecrement(&ControllerContext->ActiveDevices); + NT_ASSERT(remaining >= 0); +} + +_IRQL_requires_(PASSIVE_LEVEL) +static +VOID +ViiperFlushD0ExitWorkItem( + _In_ UDECXUSBDEVICE Device + ) +{ + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + NT_ASSERT(deviceContext->D0ExitWorkItem != WDF_NO_HANDLE); + // Flush unconditionally: the worker clears D0ExitPending immediately + // before its final UdeCx completion call, so a false flag does not prove + // that the callback has returned and stopped using the device handle. + WdfWorkItemFlush(deviceContext->D0ExitWorkItem); + NT_ASSERT(InterlockedCompareExchange( + &deviceContext->D0ExitPending, 0, 0) == 0); +} + +NTSTATUS +ViiperCreateVirtualDevice( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request + ) +{ + NTSTATUS status; + WDFDEVICE controller = WdfIoQueueGetDevice(Queue); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(controller); + VIIPER_UDE_CREATE_DEVICE *input; + size_t inputLength; + VIIPER_UDE_CREATE_DEVICE_RESULT *output; + size_t outputLength; + WDFFILEOBJECT ownerFile; + PUDECXUSBDEVICE_INIT deviceInit; + UDECX_USB_DEVICE_STATE_CHANGE_CALLBACKS callbacks; + UDECX_USB_DEVICE_SPEED speed; + WDF_OBJECT_ATTRIBUTES attributes; + WDF_WORKITEM_CONFIG workItemConfig; + UDECXUSBDEVICE device = WDF_NO_HANDLE; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext; + UDECX_USB_DEVICE_PLUG_IN_OPTIONS plugOptions; + ULONG slot; + ULONG generation; + ULONG requestedSpeed; + ULONGLONG deviceId; + ULONGLONG portReservation; + + PAGED_CODE(); + status = WdfRequestRetrieveInputBuffer(Request, sizeof(*input), (PVOID *)&input, &inputLength); + if (!NT_SUCCESS(status)) { + return status; + } + if (!ViiperValidateCreateDevice(input, inputLength)) { + InterlockedIncrement64(&controllerContext->InvalidMessages); + return STATUS_INVALID_PARAMETER; + } + // Validate the complete output contract before acquiring ownership or + // mutating UdeCx. METHOD_BUFFERED aliases the input and output system + // buffer, so do not write the receipt until every input descriptor has + // been consumed and PlugIn has committed successfully. + status = WdfRequestRetrieveOutputBuffer( + Request, sizeof(*output), (PVOID *)&output, &outputLength); + if (!NT_SUCCESS(status)) { + return status; + } + deviceId = input->DeviceId; + generation = input->Generation; + requestedSpeed = input->Speed; + speed = ViiperMapSpeed(input->Speed); + if (speed == (UDECX_USB_DEVICE_SPEED)0) { + return STATUS_NOT_SUPPORTED; + } + status = ViiperBeginOwnerAdmission(controller, Request, &ownerFile); + if (!NT_SUCCESS(status)) { + return status; + } + VIIPER_TRACE_LIFECYCLE( + controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, VIIPER_UDE_TRACE_CREATE_BEGIN, + deviceId, generation, WDF_NO_HANDLE, WDF_NO_HANDLE, 0, + STATUS_SUCCESS, 0, 0); + + deviceInit = UdecxUsbDeviceInitAllocate(controller); + if (deviceInit == NULL) { + status = STATUS_INSUFFICIENT_RESOURCES; + goto ExitAdmission; + } + + UDECX_USB_DEVICE_CALLBACKS_INIT(&callbacks); + callbacks.EvtUsbDeviceLinkPowerEntry = ViiperEvtUsbDeviceD0Entry; + callbacks.EvtUsbDeviceLinkPowerExit = ViiperEvtUsbDeviceD0Exit; + if (speed == UdecxUsbSuperSpeed) { + callbacks.EvtUsbDeviceSetFunctionSuspendAndWake = + ViiperEvtUsbDeviceSetFunctionSuspendAndWake; + } + callbacks.EvtUsbDeviceDefaultEndpointAdd = ViiperEvtDefaultEndpointAdd; + callbacks.EvtUsbDeviceEndpointAdd = ViiperEvtEndpointAdd; + callbacks.EvtUsbDeviceEndpointsConfigure = ViiperEvtEndpointsConfigure; + UdecxUsbDeviceInitSetStateChangeCallbacks(deviceInit, &callbacks); + UdecxUsbDeviceInitSetSpeed(deviceInit, speed); + UdecxUsbDeviceInitSetEndpointsType(deviceInit, UdecxEndpointTypeDynamic); + status = ViiperAddDeviceDescriptors(deviceInit, input); + if (!NT_SUCCESS(status)) { + UdecxUsbDeviceInitFree(deviceInit); + goto ExitAdmission; + } + + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, VIIPER_UDE_DEVICE_CONTEXT); + attributes.ParentObject = controller; + attributes.EvtCleanupCallback = ViiperEvtVirtualDeviceCleanup; + // Keep ordinary WDF cleanup and child-owned callbacks passive. This object + // attribute does not narrow the documented <= DISPATCH_LEVEL contract of + // UdeCx state callbacks; those paths remain independently dispatch-safe. + attributes.ExecutionLevel = WdfExecutionLevelPassive; + status = UdecxUsbDeviceCreate(&deviceInit, &attributes, &device); + VIIPER_TRACE_LIFECYCLE( + controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_DEVICE_CREATE_RETURNED, deviceId, + generation, device, WDF_NO_HANDLE, 0, status, 0, 0); + if (!NT_SUCCESS(status)) { + UdecxUsbDeviceInitFree(deviceInit); + goto ExitAdmission; + } + + deviceContext = ViiperGetDeviceContext(device); + RtlZeroMemory(deviceContext, sizeof(*deviceContext)); + deviceContext->Controller = controller; + deviceContext->OwnerFile = ownerFile; + deviceContext->DeviceId = deviceId; + deviceContext->Generation = generation; + deviceContext->Slot = VIIPER_UDE_MAX_DEVICES; + deviceContext->Speed = speed; + deviceContext->MaxPendingOperations = input->MaxPendingOperations; + // A newly attached virtual USB device is already in working link state. + // UdeCx invokes LinkPowerEntry only when a later request resumes the child + // from low power, so waiting for that callback leaves the first selected + // endpoints permanently closed on systems which never suspend them. + InterlockedExchange(&deviceContext->InD0, TRUE); + WdfObjectReference(ownerFile); + InterlockedExchange(&deviceContext->OwnerReferenced, 1); + + WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtUsbDeviceD0ExitWorkItem); + workItemConfig.AutomaticSerialization = WdfFalse; + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = device; + status = WdfWorkItemCreate( + &workItemConfig, &attributes, &deviceContext->D0ExitWorkItem); + if (!NT_SUCCESS(status)) { + WdfObjectDelete(device); + goto ExitAdmission; + } + + status = ViiperClaimDeviceSlot( + controllerContext, device, deviceId, &slot, &portReservation); + if (!NT_SUCCESS(status)) { + WdfObjectDelete(device); + goto ExitAdmission; + } + VIIPER_TRACE_LIFECYCLE( + controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_DEVICE_SLOT_CLAIMED, deviceId, + generation, device, WDF_NO_HANDLE, 0, STATUS_SUCCESS, + InterlockedCompareExchange(&controllerContext->ReservedPorts, 0, 0), slot); + + UDECX_USB_DEVICE_PLUG_IN_OPTIONS_INIT(&plugOptions); + if (speed == UdecxUsbSuperSpeed) { + // UdeCx uses one controller-global namespace: USB 3 ports begin + // immediately after NumberOfUsb20Ports, not again at port one. + plugOptions.Usb30PortNumber = + (USHORT)(VIIPER_UDE_USB20_PORT_COUNT + slot + 1); + } else { + plugOptions.Usb20PortNumber = (USHORT)(slot + 1); + } + VIIPER_TRACE_LIFECYCLE( + controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, VIIPER_UDE_TRACE_PLUG_IN_BEGIN, + deviceId, generation, device, WDF_NO_HANDLE, + 0, STATUS_SUCCESS, 0, 0); + status = UdecxUsbDevicePlugIn(device, &plugOptions); + VIIPER_TRACE_LIFECYCLE( + controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_PLUG_IN_RETURNED, deviceId, + generation, device, WDF_NO_HANDLE, 0, status, 0, 0); + if (!NT_SUCCESS(status)) { + ViiperReleaseDeviceSlot(controllerContext, device, slot, portReservation); + ViiperRetireActiveDevice(controllerContext, deviceContext); + WdfObjectDelete(device); + goto ExitAdmission; + } + + RtlZeroMemory(output, sizeof(*output)); + output->Header.Magic = VIIPER_UDE_MAGIC; + output->Header.Major = VIIPER_UDE_ABI_MAJOR; + output->Header.Minor = VIIPER_UDE_ABI_MINOR; + output->Header.Size = sizeof(*output); + output->DeviceId = deviceId; + output->Generation = generation; + output->Speed = requestedSpeed; + output->Usb20PortNumber = plugOptions.Usb20PortNumber; + output->Usb30PortNumber = plugOptions.Usb30PortNumber; + WdfRequestSetInformation(Request, sizeof(*output)); + status = STATUS_SUCCESS; + +ExitAdmission: + ViiperEndOwnerAdmission(controller, ownerFile); + return status; +} + +static +NTSTATUS +ViiperBeginRemoveDevice( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ WDFFILEOBJECT OwnerFile, + _In_ ULONGLONG DeviceId, + _In_ ULONG Generation, + _In_ BOOLEAN MatchGeneration, + _Out_ UDECXUSBDEVICE *Device + ) +{ + NTSTATUS status = STATUS_NOT_FOUND; + ULONG index; + + ViiperAcquireDeviceLockExclusive(ControllerContext); + for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { + UDECXUSBDEVICE current = ControllerContext->Devices[index]; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext; + if (current == WDF_NO_HANDLE) { + continue; + } + deviceContext = ViiperGetDeviceContext(current); + if (deviceContext->OwnerFile != OwnerFile || deviceContext->DeviceId != DeviceId || + (MatchGeneration && deviceContext->Generation != Generation)) { + continue; + } + if (InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { + continue; + } + // DeviceLock owns the table slot; BrokerLock is the admission + // linearization point shared with forwarded URBs and direct input. + // Set Purging through both before revoking the table entry so no + // request that already referenced this generation can start late. + WdfSpinLockAcquire(ControllerContext->BrokerLock); + InterlockedExchange(&deviceContext->Purging, TRUE); + WdfSpinLockRelease(ControllerContext->BrokerLock); + ViiperRemoveInputDeviceLocked(ControllerContext, current); + // Revoke logical ownership immediately, but keep the physical port + // reserved until this exact object's cleanup callback. Reusing a port + // while its prior child is still disappearing can strand that child + // and prevent the successor from enumerating. + ControllerContext->Devices[index] = WDF_NO_HANDLE; + ViiperRetireActiveDevice(ControllerContext, deviceContext); + *Device = current; + status = STATUS_SUCCESS; + break; + } + ViiperReleaseDeviceLockExclusive(ControllerContext); + return status; +} + +NTSTATUS +ViiperDestroyVirtualDevice( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request + ) +{ + NTSTATUS status; + WDFDEVICE controller = WdfIoQueueGetDevice(Queue); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(controller); + VIIPER_UDE_DEVICE_IDENTITY *input; + size_t inputLength; + WDFFILEOBJECT ownerFile; + UDECXUSBDEVICE device; + + PAGED_CODE(); + status = ViiperBeginOwnerAdmission(controller, Request, &ownerFile); + if (!NT_SUCCESS(status)) { + return status; + } + status = WdfRequestRetrieveInputBuffer(Request, sizeof(*input), (PVOID *)&input, &inputLength); + if (!NT_SUCCESS(status)) { + goto ExitAdmission; + } + if (inputLength != sizeof(*input) || input->Header.Magic != VIIPER_UDE_MAGIC || + input->Header.Major != VIIPER_UDE_ABI_MAJOR || + input->Header.Minor != VIIPER_UDE_ABI_MINOR || + input->Header.Flags != 0 || + input->Header.Size != sizeof(*input) || + input->DeviceId == 0 || input->Generation == 0 || input->Reserved != 0) { + InterlockedIncrement64(&controllerContext->InvalidMessages); + status = STATUS_INVALID_PARAMETER; + goto ExitAdmission; + } + + status = ViiperBeginRemoveDevice( + controllerContext, ownerFile, input->DeviceId, input->Generation, TRUE, &device); + if (!NT_SUCCESS(status)) { + goto ExitAdmission; + } + VIIPER_TRACE_LIFECYCLE( + controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, VIIPER_UDE_TRACE_REMOVE_CLAIMED, + input->DeviceId, input->Generation, device, WDF_NO_HANDLE, 0, + STATUS_SUCCESS, 0, 0); + ViiperFlushD0ExitWorkItem(device); + ViiperAbortDeviceManagementOperations(controller, device, STATUS_DEVICE_REMOVED); + VIIPER_TRACE_LIFECYCLE( + controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, VIIPER_UDE_TRACE_PLUG_OUT_BEGIN, + input->DeviceId, input->Generation, device, WDF_NO_HANDLE, 0, + STATUS_SUCCESS, 0, 0); + status = UdecxUsbDevicePlugOutAndDelete(device); + VIIPER_TRACE_LIFECYCLE( + controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_PLUG_OUT_RETURNED, input->DeviceId, input->Generation, + device, WDF_NO_HANDLE, 0, status, 0, 0); + if (!NT_SUCCESS(status)) { + // PlugOutAndDelete consumes the UDE handle even when it reports a + // failure. The request was nevertheless accepted at our ABI boundary; + // attempting to restore or retry this handle would be a use-after- + // invalidation. Restart the controller so PnP owns final recovery. + WdfDeviceSetFailed(controller, WdfDeviceFailedAttemptRestart); + status = STATUS_SUCCESS; + } + +ExitAdmission: + ViiperEndOwnerAdmission(controller, ownerFile); + return status; +} + +BOOLEAN +ViiperDestroyOwnedDevices( + _In_ WDFDEVICE Controller, + _In_ WDFFILEOBJECT OwnerFile + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + + PAGED_CODE(); + for (;;) { + UDECXUSBDEVICE device; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext; + BOOLEAN plugged; + ULONGLONG deviceId = 0; + ULONG index; + + ViiperAcquireDeviceLockExclusive(controllerContext); + for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { + device = controllerContext->Devices[index]; + if (device != WDF_NO_HANDLE && + ViiperGetDeviceContext(device)->OwnerFile == OwnerFile && + InterlockedCompareExchange( + &ViiperGetDeviceContext(device)->Purging, 0, 0) == 0) { + deviceId = ViiperGetDeviceContext(device)->DeviceId; + break; + } + } + ViiperReleaseDeviceLockExclusive(controllerContext); + if (deviceId == 0) { + return TRUE; + } + + if (!NT_SUCCESS(ViiperBeginRemoveDevice( + controllerContext, OwnerFile, deviceId, 0, FALSE, &device))) { + // The logical table is authoritative. A framework-owned deletion + // can revoke the snapshot before this claim; rescan instead of + // pinning the exclusive owner to an object that is already gone. + continue; + } + deviceContext = ViiperGetDeviceContext(device); + plugged = deviceContext->Plugged; + ViiperFlushD0ExitWorkItem(device); + ViiperAbortDeviceManagementOperations(Controller, device, STATUS_FILE_CLOSED); + // Completing a held UdeCx management request can make framework + // cleanup runnable once the slot pins are released. Do not access the + // device context after the exact-device management drain. + if (plugged) { + if (!NT_SUCCESS(UdecxUsbDevicePlugOutAndDelete(device))) { + WdfDeviceSetFailed(Controller, WdfDeviceFailedAttemptRestart); + return FALSE; + } + } else { + WdfObjectDelete(device); + } + } +} + +VOID +ViiperBeginControllerShutdown( + _In_ WDFDEVICE Controller + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + UDECXUSBDEVICE devices[VIIPER_UDE_MAX_DEVICES] = {0}; + ULONG deviceCount = 0; + ULONG index; + + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + + VIIPER_TRACE_LIFECYCLE( + Controller, VIIPER_UDE_TRACE_SOURCE_CONTROLLER, + VIIPER_UDE_TRACE_CONTROLLER_SHUTDOWN_BEGIN, 0, 0, WDF_NO_HANDLE, + WDF_NO_HANDLE, 0, STATUS_SUCCESS, 0, 0); + + // Revoke all table handles in one transaction. PlugOutAndDelete can invoke + // asynchronous UdeCx cleanup, so no controller lock may be held across it. + ViiperAcquireDeviceLockExclusive(controllerContext); + for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { + UDECXUSBDEVICE device = controllerContext->Devices[index]; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext; + + if (device == WDF_NO_HANDLE) { + continue; + } + deviceContext = ViiperGetDeviceContext(device); + WdfSpinLockAcquire(controllerContext->BrokerLock); + InterlockedExchange(&deviceContext->Purging, TRUE); + WdfSpinLockRelease(controllerContext->BrokerLock); + ViiperRemoveInputDeviceLocked(controllerContext, device); + controllerContext->Devices[index] = WDF_NO_HANDLE; + ViiperRetireActiveDevice(controllerContext, deviceContext); + devices[deviceCount++] = device; + } + ViiperReleaseDeviceLockExclusive(controllerContext); + + for (index = 0; index < deviceCount; ++index) { + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(devices[index]); + BOOLEAN plugged = deviceContext->Plugged; + ULONGLONG deviceId = deviceContext->DeviceId; + ULONG generation = deviceContext->Generation; + + ViiperFlushD0ExitWorkItem(devices[index]); + if (plugged) { + NTSTATUS status; + + // A successful call starts UdeCx-owned asynchronous deletion. If + // UdeCx rejects the request during controller removal, ordinary + // parent teardown still owns and deletes the child object. + VIIPER_TRACE_LIFECYCLE( + Controller, VIIPER_UDE_TRACE_SOURCE_CONTROLLER, + VIIPER_UDE_TRACE_PLUG_OUT_BEGIN, deviceId, generation, + devices[index], WDF_NO_HANDLE, 0, STATUS_SUCCESS, 0, 0); + status = UdecxUsbDevicePlugOutAndDelete(devices[index]); + VIIPER_TRACE_LIFECYCLE( + Controller, VIIPER_UDE_TRACE_SOURCE_CONTROLLER, + VIIPER_UDE_TRACE_PLUG_OUT_RETURNED, deviceId, generation, + devices[index], WDF_NO_HANDLE, 0, status, 0, 0); + } else { + WdfObjectDelete(devices[index]); + } + } + VIIPER_TRACE_LIFECYCLE( + Controller, VIIPER_UDE_TRACE_SOURCE_CONTROLLER, + VIIPER_UDE_TRACE_CONTROLLER_SHUTDOWN_END, 0, 0, WDF_NO_HANDLE, + WDF_NO_HANDLE, 0, STATUS_SUCCESS, + InterlockedCompareExchange(&controllerContext->ReservedPorts, 0, 0), 0); +} + +VOID +ViiperEvtVirtualDeviceCleanup( + _In_ WDFOBJECT DeviceObject + ) +{ + UDECXUSBDEVICE device = (UDECXUSBDEVICE)DeviceObject; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext; + WDFFILEOBJECT ownerFile = WDF_NO_HANDLE; + + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + if (deviceContext->Controller == WDF_NO_HANDLE) { + return; + } + controllerContext = ViiperGetControllerContext(deviceContext->Controller); + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_DEVICE_CLEANUP_BEGIN, deviceContext->DeviceId, + deviceContext->Generation, device, WDF_NO_HANDLE, 0, STATUS_SUCCESS, + deviceContext->PendingOperations, 0); + + // Lifecycle notification admission reads OwnerFile while holding + // BrokerLock. Revoke both that admission and the reference which pins the + // file context under the same lock, then release the reference outside the + // lock. An atomic OwnerReferenced test alone is not a lifetime pin: cleanup + // could otherwise dereference the file after the test and before the + // notifier reads its context. + WdfSpinLockAcquire(controllerContext->BrokerLock); + InterlockedExchange(&deviceContext->Purging, TRUE); + if (InterlockedExchange(&deviceContext->OwnerReferenced, 0) != 0) { + ownerFile = deviceContext->OwnerFile; + deviceContext->OwnerFile = WDF_NO_HANDLE; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + + ViiperReleaseDeviceSlot( + controllerContext, device, deviceContext->Slot, + deviceContext->PortReservation); + // Normal removal retired the logical count before PlugOutAndDelete. This + // is only the fallback for an unexpected framework-owned deletion. + ViiperRetireActiveDevice(controllerContext, deviceContext); + if (ownerFile != WDF_NO_HANDLE) { + WdfObjectDereference(ownerFile); + } + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_DEVICE_CLEANUP_END, deviceContext->DeviceId, + deviceContext->Generation, device, WDF_NO_HANDLE, 0, STATUS_SUCCESS, + deviceContext->PendingOperations, + InterlockedCompareExchange(&controllerContext->ReservedPorts, 0, 0)); +} + +static +VOID +ViiperClearEndpointInputReportLocked( + _In_ VIIPER_UDE_ENDPOINT_CONTEXT *EndpointContext + ) +{ + InterlockedExchange(&EndpointContext->InputReportValid, FALSE); + InterlockedExchange(&EndpointContext->CachedDeliveryPending, FALSE); + InterlockedExchange(&EndpointContext->InputSnapshotPending, FALSE); + InterlockedExchange(&EndpointContext->InputTransitionHead, 0); + InterlockedExchange(&EndpointContext->InputTransitionCount, 0); +} + +_IRQL_requires_(PASSIVE_LEVEL) +static +VOID +ViiperInvalidateEndpointInputReport( + _In_ UDECXUSBENDPOINT Endpoint + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + if (endpointContext->InputLock != WDF_NO_HANDLE) { + WdfWaitLockAcquire(endpointContext->InputLock, NULL); + } + ViiperClearEndpointInputReportLocked(endpointContext); + if (endpointContext->InputLock != WDF_NO_HANDLE) { + WdfWaitLockRelease(endpointContext->InputLock); + } +} + +static +VOID +ViiperInvalidateInputIfLifecycleClosed( + _In_ UDECXUSBENDPOINT Endpoint + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&deviceContext->InD0, 0, 0) == 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0) { + // Both callers already own InputLock. Clearing under that same lock + // makes lifecycle invalidation atomic with FIFO append/dequeue. + ViiperClearEndpointInputReportLocked(endpointContext); + } + WdfSpinLockRelease(controllerContext->BrokerLock); +} + +_IRQL_requires_(PASSIVE_LEVEL) +static +VOID +ViiperInvalidateDeviceInputReports( + _In_ UDECXUSBDEVICE Device + ) +{ + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + ULONG index; + + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + // Device power/reset admission is already closed before this helper is + // called, so no new report can become valid. Keep endpoint lookup and the + // final atomic invalidation inside one shared index acquisition; a WDF + // reference would postpone destruction but cannot postpone EvtCleanup. + ViiperAcquireDeviceLockShared(controllerContext); + for (index = 0; index < RTL_NUMBER_OF(deviceContext->Endpoints); ++index) { + UDECXUSBENDPOINT endpoint = deviceContext->Endpoints[index]; + if (endpoint != WDF_NO_HANDLE) { + ViiperInvalidateEndpointInputReport(endpoint); + } + } + ViiperReleaseDeviceLockShared(controllerContext); +} + +NTSTATUS +ViiperEvtUsbDeviceD0Entry( + _In_ WDFDEVICE Controller, + _In_ UDECXUSBDEVICE Device + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(Controller); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + NTSTATUS status = STATUS_SUCCESS; + + // This callback is the exact UdeCx power boundary. Open direct input + // admission before publishing the ordered advisory event to user mode. + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0) { + status = STATUS_DEVICE_REMOVED; + } else if (InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->D0ExitPending, 0, 0) != 0) { + status = STATUS_DEVICE_BUSY; + } else { + InterlockedExchange(&deviceContext->InD0, TRUE); + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!NT_SUCCESS(status)) { + return status; + } + (VOID)ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceD0Entry); + return STATUS_SUCCESS; +} + +NTSTATUS +ViiperEvtUsbDeviceD0Exit( + _In_ WDFDEVICE Controller, + _In_ UDECXUSBDEVICE Device, + _In_ UDECX_USB_DEVICE_WAKE_SETTING WakeSetting + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(Controller); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + NTSTATUS status; + + UNREFERENCED_PARAMETER(WakeSetting); + // Close direct input admission synchronously. Waiting for the user-mode + // notification would leave a scheduler window in which a fresh report + // could complete a Windows poll after the child had left D0. + WdfSpinLockAcquire(controllerContext->BrokerLock); + InterlockedExchange(&deviceContext->InD0, FALSE); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { + // Teardown already owns cache destruction and will consume the handle. + // No asynchronous power completion is owed when this callback returns + // success synchronously. + status = STATUS_SUCCESS; + } else if (InterlockedCompareExchange( + &deviceContext->D0ExitPending, TRUE, FALSE) != FALSE) { + NT_ASSERT(FALSE); + status = STATUS_DEVICE_BUSY; + } else { + // Enqueue before releasing the same gate which removal uses to set + // Purging. Teardown therefore either observes and flushes this work or + // wins first and prevents a late enqueue against a consumed handle. + WdfWorkItemEnqueue(deviceContext->D0ExitWorkItem); + status = STATUS_PENDING; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + return status; +} + +VOID +ViiperEvtUsbDeviceD0ExitWorkItem( + _In_ WDFWORKITEM WorkItem + ) +{ + UDECXUSBDEVICE device = + (UDECXUSBDEVICE)WdfWorkItemGetParentObject(WorkItem); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + ViiperInvalidateDeviceInputReports(device); + (VOID)ViiperQueueDeviceLifecycleEvent( + device, ViiperUdeOperationDeviceD0Exit); + WdfSpinLockAcquire(controllerContext->BrokerLock); + NT_ASSERT(InterlockedCompareExchange( + &deviceContext->D0ExitPending, 0, 0) != 0); + InterlockedExchange(&deviceContext->D0ExitPending, FALSE); + WdfSpinLockRelease(controllerContext->BrokerLock); + // UdeCx may synchronously advance lifecycle or cleanup once this returns. + // Do not access the device or any of its contexts after completion. + UdecxUsbDeviceLinkPowerExitComplete(device, STATUS_SUCCESS); +} + +NTSTATUS +ViiperEvtUsbDeviceSetFunctionSuspendAndWake( + _In_ WDFDEVICE Controller, + _In_ UDECXUSBDEVICE Device, + _In_ ULONG Interface, + _In_ UDECX_USB_DEVICE_FUNCTION_POWER FunctionPower + ) +{ + UNREFERENCED_PARAMETER(Controller); + UNREFERENCED_PARAMETER(Device); + UNREFERENCED_PARAMETER(Interface); + UNREFERENCED_PARAMETER(FunctionPower); + + // VIIPER's production controller set is low/full/high-speed, so UdeCx + // never invokes this SuperSpeed-only callback for a supported child. A + // virtual child has no physical function to power down; acknowledge the + // host's bookkeeping transition without mutating endpoint/media state + // behind UdeCx's queue lifecycle. If VIIPER adds a SuperSpeed controller + // with real remote-wake behavior, that device must add an explicit + // per-interface state contract + // rather than repurposing endpoint purge/start implicitly. + return STATUS_SUCCESS; +} + +static +NTSTATUS +ViiperCreateEndpointQueue( + _In_ UDECXUSBENDPOINT Endpoint, + _In_ WDF_IO_QUEUE_DISPATCH_TYPE DispatchType + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + WDF_IO_QUEUE_CONFIG queueConfig; + WDF_OBJECT_ATTRIBUTES attributes; + UDECXUSBENDPOINT *queueEndpoint; + NTSTATUS status; + + WDF_IO_QUEUE_CONFIG_INIT(&queueConfig, DispatchType); + queueConfig.PowerManaged = WdfFalse; + // KMDF's default queued-cancellation path completes synchronously. UDE + // requires an explicit callback so even a never-dispatched URB can cross + // the shared completion DPC. + queueConfig.EvtIoCanceledOnQueue = ViiperEvtUrbCanceledOnQueue; + if (DispatchType != WdfIoQueueDispatchManual) { + queueConfig.EvtIoInternalDeviceControl = ViiperEvtEndpointIoInternalControl; + } + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, UDECXUSBENDPOINT); + attributes.ParentObject = Endpoint; + attributes.ExecutionLevel = WdfExecutionLevelPassive; + status = WdfIoQueueCreate(deviceContext->Controller, &queueConfig, &attributes, &endpointContext->Queue); + if (!NT_SUCCESS(status)) { + return status; + } + queueEndpoint = ViiperGetQueueEndpoint(endpointContext->Queue); + *queueEndpoint = Endpoint; + UdecxUsbEndpointSetWdfIoQueue(Endpoint, endpointContext->Queue); + return STATUS_SUCCESS; +} + +VOID +ViiperEvtEndpointCleanup( + _In_ WDFOBJECT EndpointObject + ) +{ + UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)EndpointObject; + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext; + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext; + UCHAR address; + + PAGED_CODE(); + if (endpointContext->Device == WDF_NO_HANDLE) { + return; + } + deviceContext = ViiperGetDeviceContext(endpointContext->Device); + if (deviceContext->Controller == WDF_NO_HANDLE) { + return; + } + controllerContext = ViiperGetControllerContext(deviceContext->Controller); + address = endpointContext->Descriptor.bEndpointAddress; + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_CLEANUP_BEGIN, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, endpoint, address, + STATUS_SUCCESS, endpointContext->ActiveOperations, 0); + ViiperAcquireDeviceLockExclusive(controllerContext); + // Microsoft permits no ordinary object access after EvtCleanup is called, + // even when a WDF reference postpones destruction. UdeCx therefore owns + // the lifetime ordering: EvtEndpointPurge closes BrokerLock admission while + // UdeCx owns its associated queue state. The passive work item acknowledges + // UdeCx only after all framework-delivered and VIIPER-owned requests end. + // Endpoint creation failure has no published users. Cleanup must never be + // used as a late wait for an operation which can still access this context. + NT_ASSERT(InterlockedCompareExchange( + &endpointContext->ActiveOperations, 0, 0) == 0); + NT_ASSERT(InterlockedCompareExchange( + &endpointContext->PurgeOutstanding, 0, 0) == 0); + NT_ASSERT(InterlockedCompareExchange( + &endpointContext->PurgeWorkerActive, 0, 0) == 0); + ViiperInvalidateEndpointInputReport(endpoint); + if (deviceContext->DefaultEndpoint == endpoint) { + deviceContext->DefaultEndpoint = WDF_NO_HANDLE; + } + if (deviceContext->Endpoints[address] == endpoint) { + deviceContext->Endpoints[address] = WDF_NO_HANDLE; + // The user-mode latest-state publisher is stopped by the ordered + // endpoint-purge notification. It can race this asynchronous object + // cleanup by one already-built report. Preserve an address-scoped + // tombstone so that report is distinguishable from a report for an + // endpoint that never existed in this device generation. + deviceContext->RetiredEndpoints[address] = TRUE; + } + ViiperReleaseDeviceLockExclusive(controllerContext); + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_CLEANUP_END, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, endpoint, address, + STATUS_SUCCESS, endpointContext->ActiveOperations, 0); +} + +NTSTATUS +ViiperEvtEndpointAdd( + _In_ UDECXUSBDEVICE Device, + _In_ UDECX_USB_ENDPOINT_INIT_AND_METADATA *EndpointData + ) +{ + USB_ENDPOINT_DESCRIPTOR descriptor; + UDECX_USB_ENDPOINT_CALLBACKS callbacks; + WDF_WORKITEM_CONFIG workItemConfig; + WDF_OBJECT_ATTRIBUTES attributes; + UDECXUSBENDPOINT endpoint; + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + WDF_IO_QUEUE_DISPATCH_TYPE dispatchType; + NTSTATUS status; + + PAGED_CODE(); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0) { + return STATUS_DEVICE_REMOVED; + } + RtlZeroMemory(&descriptor, sizeof(descriptor)); + if (EndpointData->EndpointDescriptor != NULL) { + if (EndpointData->EndpointDescriptorBufferLength < sizeof(USB_ENDPOINT_DESCRIPTOR)) { + return STATUS_INVALID_PARAMETER; + } + RtlCopyMemory(&descriptor, EndpointData->EndpointDescriptor, sizeof(descriptor)); + } + UdecxUsbEndpointInitSetEndpointAddress( + EndpointData->UdecxUsbEndpointInit, descriptor.bEndpointAddress); + + UDECX_USB_ENDPOINT_CALLBACKS_INIT(&callbacks, ViiperEvtEndpointReset); + callbacks.EvtUsbEndpointStart = ViiperEvtEndpointStart; + callbacks.EvtUsbEndpointPurge = ViiperEvtEndpointPurge; + UdecxUsbEndpointInitSetCallbacks(EndpointData->UdecxUsbEndpointInit, &callbacks); + + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, VIIPER_UDE_ENDPOINT_CONTEXT); + attributes.ParentObject = Device; + attributes.EvtCleanupCallback = ViiperEvtEndpointCleanup; + attributes.ExecutionLevel = WdfExecutionLevelPassive; + status = UdecxUsbEndpointCreate(&EndpointData->UdecxUsbEndpointInit, &attributes, &endpoint); + if (!NT_SUCCESS(status)) { + return status; + } + endpointContext = ViiperGetEndpointContext(endpoint); + RtlZeroMemory(endpointContext, sizeof(*endpointContext)); + endpointContext->Device = Device; + endpointContext->Descriptor = descriptor; + InitializeListHead(&endpointContext->AdmissionQueue); + KeInitializeEvent(&endpointContext->OperationsDrained, NotificationEvent, TRUE); + // Allocate an address-scoped incarnation before any queue or work-item can + // publish ownership. Failed creations deliberately consume a generation; + // no future endpoint may reuse an identity observed by a delayed callback. + ViiperAcquireDeviceLockExclusive(controllerContext); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { + status = STATUS_DEVICE_REMOVED; + } else if (deviceContext->Endpoints[descriptor.bEndpointAddress] != WDF_NO_HANDLE || + (descriptor.bEndpointAddress == 0 && + deviceContext->DefaultEndpoint != WDF_NO_HANDLE)) { + // A duplicate add must not advance the address generation while the + // published incarnation is still live. Direct-input validation treats + // EndpointGenerations[address] as the live endpoint's exact identity. + status = STATUS_OBJECT_NAME_COLLISION; + } else if (deviceContext->EndpointGenerations[ + descriptor.bEndpointAddress] == MAXULONG) { + status = STATUS_INTEGER_OVERFLOW; + } else { + ULONG generation = deviceContext->EndpointGenerations[ + descriptor.bEndpointAddress] + 1; + deviceContext->EndpointGenerations[descriptor.bEndpointAddress] = generation; + endpointContext->Generation = generation; + status = STATUS_SUCCESS; + } + ViiperReleaseDeviceLockExclusive(controllerContext); + if (!NT_SUCCESS(status)) { + return status; + } + WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtEndpointPurgeWorkItem); + workItemConfig.AutomaticSerialization = WdfFalse; + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = endpoint; + status = WdfWorkItemCreate( + &workItemConfig, &attributes, &endpointContext->PurgeWorkItem); + if (!NT_SUCCESS(status)) { + return status; + } + WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtEndpointResetWorkItem); + workItemConfig.AutomaticSerialization = WdfFalse; + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = endpoint; + status = WdfWorkItemCreate( + &workItemConfig, &attributes, &endpointContext->ResetWorkItem); + if (!NT_SUCCESS(status)) { + return status; + } + if (descriptor.bEndpointAddress == 0) { + dispatchType = WdfIoQueueDispatchSequential; + } else if ((descriptor.bEndpointAddress & USB_ENDPOINT_DIRECTION_MASK) != 0 && + (descriptor.bmAttributes & USB_ENDPOINT_TYPE_MASK) == USB_ENDPOINT_TYPE_INTERRUPT) { + ULONG packetBytes = descriptor.wMaxPacketSize & 0x07ff; + ULONG transactions = 1 + ((descriptor.wMaxPacketSize >> 11) & 0x03); + SIZE_T transitionBytes; + + endpointContext->FastInput = TRUE; + dispatchType = WdfIoQueueDispatchManual; + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = endpoint; + status = WdfWaitLockCreate(&attributes, &endpointContext->InputLock); + if (!NT_SUCCESS(status)) { + return status; + } + endpointContext->InputTransitionStride = packetBytes * transactions; + if (endpointContext->InputTransitionStride == 0 || + endpointContext->InputTransitionStride > VIIPER_UDE_MAX_INPUT_REPORT_BYTES) { + return STATUS_INVALID_PARAMETER; + } + endpointContext->InputTransitionCapacity = min( + VIIPER_UDE_MAX_INPUT_TRANSITIONS, + VIIPER_UDE_MAX_INPUT_TRANSITION_BYTES / endpointContext->InputTransitionStride); + if (endpointContext->InputTransitionCapacity == 0) { + return STATUS_INVALID_PARAMETER; + } + transitionBytes = (SIZE_T)endpointContext->InputTransitionStride * + endpointContext->InputTransitionCapacity; + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = endpoint; + status = WdfMemoryCreate( + &attributes, + NonPagedPoolNx, + 0x56495549, + transitionBytes, + &endpointContext->InputTransitionMemory, + (PVOID *)&endpointContext->InputTransitionReports); + if (!NT_SUCCESS(status)) { + endpointContext->InputTransitionMemory = WDF_NO_HANDLE; + endpointContext->InputTransitionReports = NULL; + return status; + } + RtlZeroMemory(endpointContext->InputTransitionReports, transitionBytes); + } else { + dispatchType = WdfIoQueueDispatchParallel; + } + status = ViiperCreateEndpointQueue(endpoint, dispatchType); + if (!NT_SUCCESS(status)) { + return status; + } + if (endpointContext->FastInput) { + // A direct report can arrive just before Windows posts its interrupt + // poll. Preserve that latest state and service the poll when the + // manual endpoint queue changes from empty to non-empty. Keep this + // pending-read/cache path separate from the ordered control/media + // broker. + status = WdfIoQueueReadyNotify( + endpointContext->Queue, ViiperEvtFastInputQueueReady, endpoint); + if (!NT_SUCCESS(status)) { + return status; + } + } + + { + UCHAR address = descriptor.bEndpointAddress; + ViiperAcquireDeviceLockExclusive(controllerContext); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0 && + endpointContext->Generation != 0 && + deviceContext->EndpointGenerations[address] == endpointContext->Generation && + deviceContext->Endpoints[address] == WDF_NO_HANDLE) { + if (descriptor.bEndpointAddress == 0) { + deviceContext->DefaultEndpoint = endpoint; + } + deviceContext->Endpoints[address] = endpoint; + deviceContext->RetiredEndpoints[address] = FALSE; + InterlockedExchange64(&deviceContext->EndpointSequences[address], 0); + status = STATUS_SUCCESS; + } else { + // UdeCx owns the just-created child and will reclaim it when this + // endpoint-add callback rejects publication at the removal gate. + status = STATUS_DEVICE_REMOVED; + } + ViiperReleaseDeviceLockExclusive(controllerContext); + } + return status; +} + +NTSTATUS +ViiperEvtDefaultEndpointAdd( + _In_ UDECXUSBDEVICE Device, + _In_ PUDECXUSBENDPOINT_INIT EndpointInit + ) +{ + UDECX_USB_ENDPOINT_INIT_AND_METADATA endpointData; + + PAGED_CODE(); + RtlZeroMemory(&endpointData, sizeof(endpointData)); + endpointData.UdecxUsbEndpointInit = EndpointInit; + return ViiperEvtEndpointAdd(Device, &endpointData); +} + +static +VOID +ViiperCompleteRetrievedInputUrb( + _In_ UDECXUSBENDPOINT Endpoint, + _In_ WDFREQUEST Request, + _In_ NTSTATUS Status, + _In_ ULONG DirectInputBytes, + _In_ ULONGLONG DirectInputSequence + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = + ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_REQUEST_CONTEXT *requestContext = ViiperGetRequestContext(Request); + BOOLEAN queued; + + // The passive caller owns buffer validation/copying. Terminal completion + // and the endpoint rundown release are transferred together to the DPC. + RtlZeroMemory(requestContext, sizeof(*requestContext)); + requestContext->Controller = deviceContext->Controller; + requestContext->Endpoint = Endpoint; + requestContext->PendingSlot = VIIPER_UDE_MAX_PENDING_OPERATIONS; + requestContext->DeviceGeneration = deviceContext->Generation; + requestContext->EndpointGeneration = endpointContext->Generation; + queued = ViiperQueueUrbCompletion( + deviceContext->Controller, + Endpoint, + Request, + VIIPER_UDE_MAX_PENDING_OPERATIONS, + 0, + Status, + NT_SUCCESS(Status) ? USBD_STATUS_SUCCESS : USBD_STATUS_INTERNAL_HC_ERROR, + !NT_SUCCESS(Status), + NT_SUCCESS(Status) ? DirectInputBytes : 0, + NT_SUCCESS(Status) ? DirectInputSequence : 0); + if (!queued) { + NT_ASSERT(FALSE); + } +} + +static +NTSTATUS +ViiperPrepareCachedInputUrb( + _In_ UDECXUSBENDPOINT Endpoint, + _In_ WDFREQUEST Request, + _Out_ ULONG *BytesPrepared, + _Out_ ULONGLONG *SequencePrepared + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + PURB urb = ViiperGetUrb(Request); + PUCHAR report = endpointContext->InputReport; + ULONG reportLength = endpointContext->InputReportLength; + ULONG transitionHead = 0; + ULONGLONG reportSequence; + BOOLEAN transition = FALSE; + ULONG transferLength; + NTSTATUS status; + + *BytesPrepared = 0; + *SequencePrepared = 0; + reportSequence = (ULONGLONG)InterlockedCompareExchange64( + &endpointContext->LastInputSequence, 0, 0); + + if (InterlockedCompareExchange(&endpointContext->InputTransitionCount, 0, 0) > 0) { + transitionHead = (ULONG)InterlockedCompareExchange( + &endpointContext->InputTransitionHead, 0, 0); + report = endpointContext->InputTransitionReports + + ((SIZE_T)transitionHead * endpointContext->InputTransitionStride); + reportLength = endpointContext->InputTransitionLengths[transitionHead]; + reportSequence = endpointContext->InputTransitionSequences[transitionHead]; + transition = TRUE; + } + + if (urb == NULL || + (urb->UrbHeader.Function != URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER && + urb->UrbHeader.Function != URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL) || + (urb->UrbBulkOrInterruptTransfer.TransferFlags & USBD_TRANSFER_DIRECTION_IN) == 0) { + return STATUS_INVALID_DEVICE_REQUEST; + } + transferLength = urb->UrbBulkOrInterruptTransfer.TransferBufferLength; + if (reportLength > transferLength) { + status = STATUS_BUFFER_TOO_SMALL; + } else { + status = ViiperCopyTransferBuffer(Request, urb, report, reportLength, TRUE); + } + if (!NT_SUCCESS(status)) { + return status; + } + + urb->UrbBulkOrInterruptTransfer.TransferBufferLength = reportLength; + UdecxUrbSetBytesCompleted(Request, reportLength); + if (transition) { + InterlockedExchange( + &endpointContext->InputTransitionHead, + (LONG)((transitionHead + 1) % endpointContext->InputTransitionCapacity)); + InterlockedDecrement(&endpointContext->InputTransitionCount); + } else { + InterlockedExchange(&endpointContext->InputSnapshotPending, FALSE); + } + *BytesPrepared = reportLength; + *SequencePrepared = reportSequence; + return STATUS_SUCCESS; +} + +VOID +ViiperEvtFastInputQueueReady( + _In_ WDFQUEUE Queue, + _In_ WDFCONTEXT Context + ) +{ + UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)Context; + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = + ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + WDFREQUEST request = WDF_NO_HANDLE; + NTSTATUS completionStatus = STATUS_SUCCESS; + ULONG directInputBytes = 0; + ULONGLONG directInputSequence = 0; + BOOLEAN admitted = FALSE; + BOOLEAN deliveryReady = FALSE; + BOOLEAN completionAdmitted = FALSE; + + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + // KMDF explicitly permits a passive ReadyNotify callback to retrieve the + // request that made a manual queue non-empty. Copy the already-cached + // latest state here, then transfer terminal ownership to the driver's + // completion DPC. The DPC is the separate DISPATCH_LEVEL boundary required + // by the UDE/host-controller completion contract; a system work item before + // that DPC only adds scheduler latency to the first poll after idle/resume. + // Register endpoint rundown before any endpoint-local wait. A producer can + // own InputLock while UdeCx begins PURGE; entering rundown first prevents + // the passive purge worker from observing zero while this ReadyNotify + // callback is already waiting to inspect the cached report. + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && + InterlockedCompareExchange(&deviceContext->InD0, 0, 0) != 0 && + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0 && + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) == 0) { + ViiperEndpointOperationStarted(endpoint); + admitted = TRUE; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!admitted) { + return; + } + + WdfWaitLockAcquire(endpointContext->InputLock, NULL); + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && + InterlockedCompareExchange(&deviceContext->InD0, 0, 0) != 0 && + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0 && + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->InputReportValid, 0, 0) != 0 && + InterlockedCompareExchange(&endpointContext->CachedDeliveryPending, 0, 0) != 0) { + deliveryReady = TRUE; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!deliveryReady) { + WdfWaitLockRelease(endpointContext->InputLock); + ViiperEndpointOperationCompleted(endpoint); + return; + } + + // ReadyNotify is edge-triggered on empty -> non-empty. Microsoft requires + // manual-queue callbacks to retrieve in a loop, otherwise multiple host + // polls which arrived together can remain stranded while retained input + // also remains pending. The initial operation is a callback-lifetime hold; + // each retrieved URB receives its own rundown count transferred to the DPC. + for (;;) { + completionAdmitted = FALSE; + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && + InterlockedCompareExchange(&deviceContext->InD0, 0, 0) != 0 && + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0 && + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->InputReportValid, 0, 0) != 0 && + (InterlockedCompareExchange(&endpointContext->InputTransitionCount, 0, 0) > 0 || + InterlockedCompareExchange(&endpointContext->InputSnapshotPending, 0, 0) != 0)) { + ViiperEndpointOperationStarted(endpoint); + completionAdmitted = TRUE; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!completionAdmitted) { + break; + } + + request = WDF_NO_HANDLE; + if (!NT_SUCCESS(WdfIoQueueRetrieveNextRequest(Queue, &request))) { + ViiperEndpointOperationCompleted(endpoint); + break; + } + ViiperInvalidateInputIfLifecycleClosed(endpoint); + completionStatus = ViiperPrepareCachedInputUrb( + endpoint, request, &directInputBytes, &directInputSequence); + InterlockedExchange( + &endpointContext->CachedDeliveryPending, + InterlockedCompareExchange(&endpointContext->InputTransitionCount, 0, 0) > 0 || + InterlockedCompareExchange(&endpointContext->InputSnapshotPending, 0, 0) != 0); + ViiperCompleteRetrievedInputUrb( + endpoint, request, completionStatus, + directInputBytes, directInputSequence); + } + WdfWaitLockRelease(endpointContext->InputLock); + // Release the callback-lifetime hold only after the final endpoint access. + // Every queued completion owns a separate count until its DPC completes. + ViiperEndpointOperationCompleted(endpoint); +} + +NTSTATUS +ViiperSubmitInputReport( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request + ) +{ + WDFDEVICE controller = WdfIoQueueGetDevice(Queue); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(controller); + VIIPER_UDE_INPUT_REPORT *input; + UCHAR *payload; + size_t inputLength; + size_t payloadLength; + WDFFILEOBJECT ownerFile; + UDECXUSBDEVICE device = WDF_NO_HANDLE; + UDECXUSBENDPOINT endpoint = WDF_NO_HANDLE; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = NULL; + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = NULL; + WDFREQUEST urbRequest = WDF_NO_HANDLE; + NTSTATUS status; + ULONG directInputBytes = 0; + ULONGLONG directInputSequence = 0; + BOOLEAN admitted = FALSE; + BOOLEAN lifecycleDrop = FALSE; + + status = ViiperValidateBrokerOwner(controller, Request); + if (!NT_SUCCESS(status)) { + return status; + } + // A broker fault means an ordered lifecycle notification was lost. The + // completion path must remain available so already-published URBs can be + // drained, but accepting a new direct interrupt-IN state after that point + // could apply it to a generation whose reset/power boundary user mode did + // not observe. Fail the producer lane and let Host terminate this one-shot + // owner session when it dequeues ViiperUdeOperationBrokerFault. + if (InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE) { + return STATUS_DATA_ERROR; + } + ownerFile = WdfRequestGetFileObject(Request); + status = WdfRequestRetrieveInputBuffer( + Request, sizeof(*input), (PVOID *)&input, &inputLength); + if (!NT_SUCCESS(status)) { + return status; + } + status = WdfRequestRetrieveOutputBuffer( + Request, 1, (PVOID *)&payload, &payloadLength); + if (!NT_SUCCESS(status)) { + return status; + } + if (inputLength != sizeof(*input) || + input->Header.Magic != VIIPER_UDE_MAGIC || + input->Header.Major != VIIPER_UDE_ABI_MAJOR || + input->Header.Minor != VIIPER_UDE_ABI_MINOR || + input->Header.Flags != 0 || + input->Header.Size != sizeof(*input) + input->PayloadLength || + input->DeviceId == 0 || input->Generation == 0 || + input->EndpointGeneration == 0 || input->Sequence == 0 || + input->Sequence > MAXLONGLONG || + (input->EndpointAddress & USB_ENDPOINT_DIRECTION_MASK) == 0 || + input->PayloadOffset != sizeof(*input) || input->PayloadLength == 0 || + input->PayloadLength > VIIPER_UDE_MAX_INPUT_REPORT_BYTES || + payloadLength != input->PayloadLength || + (input->Flags & ~VIIPER_UDE_INPUT_REPORT_TRANSITION) != 0 || + input->Reserved1[0] != 0 || input->Reserved1[1] != 0) { + InterlockedIncrement64(&controllerContext->InvalidMessages); + return STATUS_INVALID_PARAMETER; + } + + status = STATUS_NOT_FOUND; + ViiperAcquireDeviceLockShared(controllerContext); + device = ViiperFindInputDeviceLocked(controllerContext, input->DeviceId); + if (device != WDF_NO_HANDLE) { + deviceContext = ViiperGetDeviceContext(device); + if (deviceContext->OwnerFile == ownerFile && + deviceContext->Generation == input->Generation) { + if (InterlockedCompareExchange(&deviceContext->InD0, 0, 0) == 0 || + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { + lifecycleDrop = TRUE; + status = STATUS_SUCCESS; + } else { + endpoint = deviceContext->Endpoints[input->EndpointAddress]; + if (endpoint == WDF_NO_HANDLE) { + if (deviceContext->RetiredEndpoints[input->EndpointAddress] || + (deviceContext->EndpointGenerations[input->EndpointAddress] != 0 && + input->EndpointGeneration <= + deviceContext->EndpointGenerations[input->EndpointAddress])) { + lifecycleDrop = TRUE; + status = STATUS_SUCCESS; + } + } else { + endpointContext = ViiperGetEndpointContext(endpoint); + if (endpointContext->Generation != input->EndpointGeneration || + deviceContext->EndpointGenerations[input->EndpointAddress] != + input->EndpointGeneration) { + if (input->EndpointGeneration < endpointContext->Generation) { + lifecycleDrop = TRUE; + status = STATUS_SUCCESS; + } + } else if (!endpointContext->FastInput || + endpointContext->InputLock == WDF_NO_HANDLE) { + status = STATUS_INVALID_DEVICE_STATE; + } else { + // The shared index pins the published endpoint through + // admission. BrokerLock is also the linearization point + // for lifecycle closure and every ActiveOperations + // 0 <-> 1 event transition. Once counted, UdeCx purge + // must drain this operation before cleanup may revoke + // the endpoint context. + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange( + &controllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->InD0, 0, 0) == 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0) { + lifecycleDrop = TRUE; + status = STATUS_SUCCESS; + } else { + ViiperEndpointOperationStarted(endpoint); + admitted = TRUE; + status = STATUS_SUCCESS; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + } + } + } + } + } + ViiperReleaseDeviceLockShared(controllerContext); + if (lifecycleDrop) { + // A report already submitted by the owner may cross the D0/unplug + // boundary before the ordered lifecycle notification cancels its + // publisher. It is stale latest-state data, not a broken owner + // session. Acknowledge and discard it exactly at that boundary. + return STATUS_SUCCESS; + } + if (!admitted) { + return status; + } + + // The default IOCTL queue is parallel so independent controllers never + // block one another. Serialize only this endpoint, preserving report order + // even if a faulty or hostile owner submits concurrent updates for one pad. + WdfWaitLockAcquire(endpointContext->InputLock, NULL); + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->InD0, 0, 0) == 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || + endpointContext->Generation != input->EndpointGeneration || + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0) { + WdfSpinLockRelease(controllerContext->BrokerLock); + WdfWaitLockRelease(endpointContext->InputLock); + ViiperEndpointOperationCompleted(endpoint); + // Endpoint purge/start and endpoint reset preserve the device + // generation. A publisher can have one already-built latest-state + // report crossing either callback; acknowledge and discard it rather + // than faulting the otherwise valid owner session. + return STATUS_SUCCESS; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (input->Sequence <= (ULONGLONG)InterlockedCompareExchange64( + &endpointContext->LastInputSequence, 0, 0)) { + WdfWaitLockRelease(endpointContext->InputLock); + ViiperEndpointOperationCompleted(endpoint); + return STATUS_INVALID_DEVICE_STATE; + } + if (input->PayloadLength > endpointContext->InputTransitionStride) { + WdfWaitLockRelease(endpointContext->InputLock); + ViiperEndpointOperationCompleted(endpoint); + InterlockedIncrement64(&controllerContext->InvalidMessages); + return STATUS_INVALID_BUFFER_SIZE; + } + if ((input->Flags & VIIPER_UDE_INPUT_REPORT_TRANSITION) != 0 && + InterlockedCompareExchange(&endpointContext->InputTransitionCount, 0, 0) >= + (LONG)endpointContext->InputTransitionCapacity) { + WdfWaitLockRelease(endpointContext->InputLock); + ViiperEndpointOperationCompleted(endpoint); + InterlockedIncrement64(&controllerContext->QueueExhaustions); + return STATUS_DEVICE_BUSY; + } + // Every accepted sample refreshes the cadence snapshot. Only a newly + // queued controller state is also appended to the bounded transition FIFO; + // deadline-generated idle samples therefore cannot crowd out press/release + // edges while Windows has no interrupt poll parked. + RtlCopyMemory(endpointContext->InputReport, payload, input->PayloadLength); + endpointContext->InputReportLength = input->PayloadLength; + if ((input->Flags & VIIPER_UDE_INPUT_REPORT_TRANSITION) != 0) { + ULONG count = (ULONG)InterlockedCompareExchange( + &endpointContext->InputTransitionCount, 0, 0); + ULONG head = (ULONG)InterlockedCompareExchange( + &endpointContext->InputTransitionHead, 0, 0); + ULONG tail = (head + count) % endpointContext->InputTransitionCapacity; + RtlCopyMemory( + endpointContext->InputTransitionReports + + ((SIZE_T)tail * endpointContext->InputTransitionStride), + payload, + input->PayloadLength); + endpointContext->InputTransitionLengths[tail] = (USHORT)input->PayloadLength; + endpointContext->InputTransitionSequences[tail] = input->Sequence; + } + // The interlocked sequence publication is the release boundary for both + // the latest snapshot and optional transition payload. Consumers take the + // same endpoint lock, while the explicit payload-before-sequence order + // keeps the cache contract correct if that synchronization is later + // narrowed for latency. + InterlockedExchange64(&endpointContext->LastInputSequence, (LONG64)input->Sequence); + InterlockedExchange(&endpointContext->InputReportValid, TRUE); + if ((input->Flags & VIIPER_UDE_INPUT_REPORT_TRANSITION) != 0) { + InterlockedIncrement(&endpointContext->InputTransitionCount); + InterlockedExchange(&endpointContext->InputSnapshotPending, FALSE); + } else { + InterlockedExchange(&endpointContext->InputSnapshotPending, TRUE); + } + InterlockedIncrement64(&controllerContext->InputReportsSubmitted); + status = WdfIoQueueRetrieveNextRequest(endpointContext->Queue, &urbRequest); + if (!NT_SUCCESS(status)) { + InterlockedExchange( + &endpointContext->CachedDeliveryPending, + InterlockedCompareExchange( + &endpointContext->InputTransitionCount, 0, 0) > 0 || + InterlockedCompareExchange( + &endpointContext->InputSnapshotPending, 0, 0) != 0); + ViiperInvalidateInputIfLifecycleClosed(endpoint); + WdfWaitLockRelease(endpointContext->InputLock); + ViiperEndpointOperationCompleted(endpoint); + // The cached report now owns this state. Queue-ready delivery services + // the next Windows poll even if the physical feeder becomes idle. + return STATUS_SUCCESS; + } + InterlockedExchange(&endpointContext->CachedDeliveryPending, FALSE); + // Lifecycle admission can close after this operation was admitted. The + // pre-boundary poll may finish, but its cached state must never survive the + // reset/purge/D0 boundary. Revalidate under the same admission lock so + // either this path or the lifecycle callback performs the final clear. + ViiperInvalidateInputIfLifecycleClosed(endpoint); + status = ViiperPrepareCachedInputUrb( + endpoint, urbRequest, &directInputBytes, &directInputSequence); + InterlockedExchange( + &endpointContext->CachedDeliveryPending, + InterlockedCompareExchange(&endpointContext->InputTransitionCount, 0, 0) > 0 || + InterlockedCompareExchange(&endpointContext->InputSnapshotPending, 0, 0) != 0); + WdfWaitLockRelease(endpointContext->InputLock); + // This call is the active-operation handoff. It performs every remaining + // endpoint lookup before enqueuing the DPC; the caller performs no endpoint + // access after a concurrently running DPC can release rundown. + ViiperCompleteRetrievedInputUrb( + endpoint, urbRequest, status, directInputBytes, directInputSequence); + // The producer publication was accepted before servicing this host poll. + // A malformed/short URB fails through its own DPC but does not make user + // mode retry an already-accepted sequence or discard the retained edge. + return STATUS_SUCCESS; +} + +_IRQL_requires_(PASSIVE_LEVEL) +static +VOID +ViiperWaitForEndpointQuiescence( + _In_ UDECXUSBENDPOINT Endpoint + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + LARGE_INTEGER retryInterval; + LARGE_INTEGER watchdogWait; + ULONGLONG nextWatchdog; + + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + // One millisecond is used only on the cold purge/reset path. The ordinary + // case returns after one event wait and one read-only queue-state sample. + retryInterval.QuadPart = -10 * 1000; + watchdogWait.QuadPart = + -(LONGLONG)VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS; + nextWatchdog = KeQueryInterruptTime() + + VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS; + for (;;) { + WDF_IO_QUEUE_STATE queueState; + BOOLEAN quiescent; + LONG activeOperations; + ULONGLONG now; + + (VOID)KeWaitForSingleObject( + &endpointContext->OperationsDrained, + Executive, + KernelMode, + FALSE, + &watchdogWait); + + // WdfIoQueueDriverNoRequests closes the interval in which a callback + // was delivered and then preempted before its first BrokerLock + // acquisition. The BrokerLock-owned rundown joins that callback's + // terminal DPC. Queued host polls are intentionally allowed here: + // reset keeps the queue active, and terminal shutdown lets UdeCx issue + // the corresponding endpoint-purge callback after child consumption. + WdfSpinLockAcquire(controllerContext->BrokerLock); + queueState = WdfIoQueueGetState(endpointContext->Queue, NULL, NULL); + quiescent = (queueState & WdfIoQueueDriverNoRequests) != 0 && + InterlockedCompareExchange( + &endpointContext->ActiveOperations, 0, 0) == 0; + activeOperations = InterlockedCompareExchange( + &endpointContext->ActiveOperations, 0, 0); + WdfSpinLockRelease(controllerContext->BrokerLock); + if (quiescent) { + return; + } + now = KeQueryInterruptTime(); + if (now >= nextWatchdog) { + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, + VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_QUIESCENCE_WATCHDOG, + deviceContext->DeviceId, + deviceContext->Generation, + endpointContext->Device, + Endpoint, + endpointContext->Descriptor.bEndpointAddress, + STATUS_IO_TIMEOUT, + activeOperations, + (ULONG)queueState); + nextWatchdog = now + VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS; + } + + // A callback can be between KMDF delivery and its first BrokerLock + // acquisition. It will either enter rundown and re-arm the event or + // finish its terminal DPC and return the request to framework + // ownership. Avoid spinning while that passive callback is scheduled. + (VOID)KeDelayExecutionThread(KernelMode, FALSE, &retryInterval); + } +} + +_IRQL_requires_(PASSIVE_LEVEL) +static +VOID +ViiperWaitForEndpointPurgeQuiescence( + _In_ UDECXUSBENDPOINT Endpoint, + _Out_ WDF_IO_QUEUE_STATE *FinalQueueState, + _Out_ ULONG *FinalQueuedRequests, + _Out_ ULONG *FinalDriverRequests + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + LARGE_INTEGER retryInterval; + LARGE_INTEGER watchdogWait; + ULONGLONG nextWatchdog; + + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + retryInterval.QuadPart = -10 * 1000; + watchdogWait.QuadPart = + -(LONGLONG)VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS; + nextWatchdog = KeQueryInterruptTime() + + VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS; + for (;;) { + WDF_IO_QUEUE_STATE queueState; + ULONG queuedRequests; + ULONG driverRequests; + BOOLEAN quiescent; + LONG activeOperations; + ULONGLONG now; + + (VOID)KeWaitForSingleObject( + &endpointContext->OperationsDrained, + Executive, + KernelMode, + FALSE, + &watchdogWait); + + // UdeCx exclusively owns the associated queue's START/PURGE state. + // The PURGE callback is the upstream stop/cancel boundary even when + // the associated queue retains its READY bookkeeping until the client + // acknowledges the transition. DriverNoRequests joins callbacks + // already delivered across that boundary; VIIPER's rundown joins + // their forwarded and terminal-DPC ownership. Sample both under the + // BrokerLock without waiting for UdeCx-owned queued host polls. + WdfSpinLockAcquire(controllerContext->BrokerLock); + queueState = WdfIoQueueGetState( + endpointContext->Queue, &queuedRequests, &driverRequests); + quiescent = InterlockedCompareExchange( + &endpointContext->PurgeOutstanding, 0, 0) > 0 && + InterlockedCompareExchange( + &endpointContext->Purging, 0, 0) != 0 && + (queueState & WdfIoQueueDriverNoRequests) != 0 && + driverRequests == 0 && + InterlockedCompareExchange( + &endpointContext->ActiveOperations, 0, 0) == 0; + activeOperations = InterlockedCompareExchange( + &endpointContext->ActiveOperations, 0, 0); + if (quiescent) { + *FinalQueueState = queueState; + *FinalQueuedRequests = queuedRequests; + *FinalDriverRequests = driverRequests; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (quiescent) { + return; + } + now = KeQueryInterruptTime(); + if (now >= nextWatchdog) { + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, + VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_QUIESCENCE_WATCHDOG, + deviceContext->DeviceId, + deviceContext->Generation, + endpointContext->Device, + Endpoint, + endpointContext->Descriptor.bEndpointAddress, + STATUS_IO_TIMEOUT, + activeOperations, + (ULONG)queueState); + nextWatchdog = now + VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS; + } + + // This runs only during an endpoint lifecycle transition. A short + // passive wait lets any callback already dispatched by KMDF reach its + // terminal DPC without consuming CPU or touching the input hot path. + (VOID)KeDelayExecutionThread(KernelMode, FALSE, &retryInterval); + } +} + +VOID +ViiperDrainControllerEndpointOperations( + _In_ WDFDEVICE Controller + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(Controller); + ULONG deviceIndex; + + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + // Hold the shared device index while observing every endpoint so cleanup + // cannot invalidate a handle between lookup and the final driver-owned + // operation proof. ShuttingDown is already set, so no broker or direct + // input admission can reopen. UdeCx remains free to deliver its endpoint + // purge callbacks after PlugOutAndDelete consumes the child handles. + ViiperAcquireDeviceLockShared(controllerContext); + for (deviceIndex = 0; deviceIndex < VIIPER_UDE_MAX_DEVICES; ++deviceIndex) { + UDECXUSBDEVICE device = controllerContext->Devices[deviceIndex]; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext; + ULONG endpointIndex; + + if (device == WDF_NO_HANDLE) { + continue; + } + deviceContext = ViiperGetDeviceContext(device); + for (endpointIndex = 0; + endpointIndex < RTL_NUMBER_OF(deviceContext->Endpoints); + ++endpointIndex) { + UDECXUSBENDPOINT endpoint = deviceContext->Endpoints[endpointIndex]; + + if (endpoint != WDF_NO_HANDLE) { + ViiperWaitForEndpointQuiescence(endpoint); + } + } + } + ViiperReleaseDeviceLockShared(controllerContext); +} + +BOOLEAN +ViiperQuiesceResetByIdentity( + _In_ WDFDEVICE Controller, + _In_ ULONGLONG DeviceId, + _In_ ULONG Generation, + _In_ UDECXUSBDEVICE ExpectedDevice, + _In_opt_ UDECXUSBENDPOINT ExpectedEndpoint, + _In_ ULONG ExpectedEndpointGeneration, + _In_ ULONGLONG ExpectedResetEpoch, + _In_ UCHAR EndpointAddress, + _In_ BOOLEAN WholeDevice, + _In_ BOOLEAN ReleaseGate + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(Controller); + BOOLEAN found = FALSE; + ULONG deviceIndex; + + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + // The asynchronous UdeCx reset request keeps the child alive. Retain the + // shared index as an additional cleanup fence while joining any terminal + // callback admitted after the reset event was first published. + ViiperAcquireDeviceLockShared(controllerContext); + for (deviceIndex = 0; deviceIndex < VIIPER_UDE_MAX_DEVICES; ++deviceIndex) { + UDECXUSBDEVICE device = controllerContext->Devices[deviceIndex]; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext; + + if (device == WDF_NO_HANDLE || device != ExpectedDevice) { + continue; + } + deviceContext = ViiperGetDeviceContext(device); + if (deviceContext->DeviceId != DeviceId || + deviceContext->Generation != Generation) { + continue; + } + + if (WholeDevice) { + ULONG endpointIndex; + ULONGLONG currentResetEpoch; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + NT_ASSERT(ExpectedEndpointGeneration == 0); + currentResetEpoch = (ULONGLONG)InterlockedCompareExchange64( + &deviceContext->ResetEpoch, 0, 0); + found = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && + currentResetEpoch == ExpectedResetEpoch && + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 && + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0; + if (!found && ReleaseGate && currentResetEpoch == ExpectedResetEpoch) { + InterlockedExchange(&deviceContext->Resetting, FALSE); + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!found) { + break; + } + + for (endpointIndex = 0; + endpointIndex < RTL_NUMBER_OF(deviceContext->Endpoints); + ++endpointIndex) { + UDECXUSBENDPOINT endpoint = deviceContext->Endpoints[endpointIndex]; + + if (endpoint != WDF_NO_HANDLE) { + ViiperWaitForEndpointQuiescence(endpoint); + } + } + // Revalidate the lifecycle gate after every queue/rundown proof. + // BrokerLock is the admission linearization point; clearing here + // cannot target a reused identity because DeviceLock still pins + // this exact table entry and generation. + WdfSpinLockAcquire(controllerContext->BrokerLock); + currentResetEpoch = (ULONGLONG)InterlockedCompareExchange64( + &deviceContext->ResetEpoch, 0, 0); + found = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && + currentResetEpoch == ExpectedResetEpoch && + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 && + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0; + if (ReleaseGate && currentResetEpoch == ExpectedResetEpoch) { + InterlockedExchange(&deviceContext->Resetting, FALSE); + } + WdfSpinLockRelease(controllerContext->BrokerLock); + } else { + UDECXUSBENDPOINT endpoint = deviceContext->Endpoints[EndpointAddress]; + + if (endpoint != WDF_NO_HANDLE && endpoint == ExpectedEndpoint && + ExpectedEndpointGeneration != 0 && + deviceContext->EndpointGenerations[EndpointAddress] == + ExpectedEndpointGeneration) { + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = + ViiperGetEndpointContext(endpoint); + + WdfSpinLockAcquire(controllerContext->BrokerLock); + found = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && + (ULONGLONG)InterlockedCompareExchange64( + &deviceContext->ResetEpoch, 0, 0) == ExpectedResetEpoch && + endpointContext->Generation == ExpectedEndpointGeneration && + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0 && + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0 && + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) == 0; + if (!found && ReleaseGate) { + InterlockedExchange(&endpointContext->Resetting, FALSE); + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!found) { + break; + } + ViiperWaitForEndpointQuiescence(endpoint); + WdfSpinLockAcquire(controllerContext->BrokerLock); + found = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && + (ULONGLONG)InterlockedCompareExchange64( + &deviceContext->ResetEpoch, 0, 0) == ExpectedResetEpoch && + endpointContext->Generation == ExpectedEndpointGeneration && + deviceContext->EndpointGenerations[EndpointAddress] == + ExpectedEndpointGeneration && + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0 && + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0 && + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) == 0; + if (ReleaseGate) { + InterlockedExchange(&endpointContext->Resetting, FALSE); + } + WdfSpinLockRelease(controllerContext->BrokerLock); + } + } + break; + } + ViiperReleaseDeviceLockShared(controllerContext); + return found; +} + +VOID +ViiperEvtEndpointReset( + _In_ UDECXUSBENDPOINT Endpoint, + _In_ WDFREQUEST Request + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + NTSTATUS status; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Resetting, TRUE, FALSE) != FALSE) { + status = STATUS_DEVICE_BUSY; + } else { + InterlockedExchange64( + &endpointContext->ResetDeviceEpoch, + InterlockedCompareExchange64(&deviceContext->ResetEpoch, 0, 0)); + status = STATUS_SUCCESS; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!NT_SUCCESS(status)) { + WdfRequestComplete(Request, status); + return; + } + + InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); + ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); + endpointContext->ResetRequest = Request; + // A forwarded broker operation or direct input copy may have won + // admission immediately before Resetting was raised. Defer publication of + // the reset request until those owners have actually completed; otherwise + // user mode could clear controller state while the old transfer is still + // writing into the endpoint. + WdfWorkItemEnqueue(endpointContext->ResetWorkItem); +} + +VOID +ViiperEvtEndpointResetWorkItem( + _In_ WDFWORKITEM WorkItem + ) +{ + UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)WdfWorkItemGetParentObject(WorkItem); + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + WDFREQUEST request; + NTSTATUS status; + BOOLEAN resetCurrent; + + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + resetCurrent = ViiperQuiesceResetByIdentity( + deviceContext->Controller, + deviceContext->DeviceId, + deviceContext->Generation, + endpointContext->Device, + endpoint, + endpointContext->Generation, + (ULONGLONG)InterlockedCompareExchange64( + &endpointContext->ResetDeviceEpoch, 0, 0), + endpointContext->Descriptor.bEndpointAddress, + FALSE, + FALSE); + // The unresolved asynchronous reset Request keeps this endpoint unable to + // process transfers. No successor callback can be delivered between the + // read-only queue/rundown proof and publication; a callback delivered just + // before reset closure is included by DriverNoRequests and the terminal + // DPC-owned rundown. Owner acknowledgement repeats the proof. An input + // publisher admitted immediately before Resetting was raised may finish, + // then this barrier performs the final invalidation. + request = endpointContext->ResetRequest; + endpointContext->ResetRequest = WDF_NO_HANDLE; + if (!resetCurrent) { + // A device reset may have won after this endpoint reset closed its own + // gate. Release only the endpoint-reset gate; the device reset, purge, + // shutdown, or broker-fault predicate independently keeps admission + // closed until its owner finishes recovery. + WdfSpinLockAcquire(controllerContext->BrokerLock); + InterlockedExchange(&endpointContext->Resetting, FALSE); + WdfSpinLockRelease(controllerContext->BrokerLock); + WdfRequestComplete(request, STATUS_DEVICE_NOT_READY); + return; + } + ViiperInvalidateEndpointInputReport(endpoint); + status = ViiperQueueAcknowledgedEndpointLifecycleEvent( + endpoint, request, ViiperUdeOperationEndpointReset); + if (!NT_SUCCESS(status)) { + WdfSpinLockAcquire(controllerContext->BrokerLock); + InterlockedExchange(&endpointContext->Resetting, FALSE); + WdfSpinLockRelease(controllerContext->BrokerLock); + WdfRequestComplete(request, status); + } +} + +VOID +ViiperEvtEndpointPurgeWorkItem( + _In_ WDFWORKITEM WorkItem + ) +{ + UDECXUSBENDPOINT endpoint = + (UDECXUSBENDPOINT)WdfWorkItemGetParentObject(WorkItem); + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = + ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + WDFDEVICE controller = deviceContext->Controller; + UDECXUSBDEVICE device = endpointContext->Device; + ULONGLONG deviceId = deviceContext->DeviceId; + ULONG generation = deviceContext->Generation; + UCHAR endpointAddress = endpointContext->Descriptor.bEndpointAddress; + + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + for (;;) { + WDF_IO_QUEUE_STATE queueState; + ULONG queuedRequests; + ULONG driverRequests; + LONG remaining; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange( + &endpointContext->PurgeOutstanding, 0, 0) <= 0) { + InterlockedExchange(&endpointContext->PurgeWorkerActive, FALSE); + WdfSpinLockRelease(controllerContext->BrokerLock); + return; + } + NT_ASSERT(InterlockedCompareExchange( + &endpointContext->PurgeWorkerActive, 0, 0) != 0); + WdfSpinLockRelease(controllerContext->BrokerLock); + + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_DRAIN_BEGIN, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, endpoint, + endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, + endpointContext->ActiveOperations, + (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); + ViiperWaitForEndpointPurgeQuiescence( + endpoint, &queueState, &queuedRequests, &driverRequests); + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_DRIVER_QUIESCENT, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, endpoint, + endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, + endpointContext->ActiveOperations, (ULONG)queueState); + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_DRAIN_END, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, endpoint, + endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, + endpointContext->ActiveOperations, (ULONG)queueState); + NT_ASSERT(InterlockedCompareExchange( + &endpointContext->ActiveOperations, 0, 0) == 0); + ViiperInvalidateEndpointInputReport(endpoint); + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_PURGE_COMPLETE_BEGIN, + deviceContext->DeviceId, deviceContext->Generation, + endpointContext->Device, endpoint, + endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, + endpointContext->ActiveOperations, + (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); + + // Retire exactly one callback before completing it. A synchronous + // final START observes zero and may reopen admission; an earlier START + // sees another outstanding PURGE and remains closed. Keep worker + // ownership through the completion so a reentrant PURGE is drained by + // this same invocation instead of being lost to work-item coalescing. + WdfSpinLockAcquire(controllerContext->BrokerLock); + NT_ASSERT(InterlockedCompareExchange( + &endpointContext->PurgeWorkerActive, 0, 0) != 0); + remaining = InterlockedDecrement(&endpointContext->PurgeOutstanding); + NT_ASSERT(remaining >= 0); + WdfSpinLockRelease(controllerContext->BrokerLock); + + UdecxUsbEndpointPurgeComplete(endpoint); + VIIPER_TRACE_LIFECYCLE( + controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_PURGE_COMPLETE_END, deviceId, generation, + device, endpoint, endpointAddress, STATUS_SUCCESS, remaining, 0); + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange( + &endpointContext->PurgeOutstanding, 0, 0) == 0) { + InterlockedExchange(&endpointContext->PurgeWorkerActive, FALSE); + WdfSpinLockRelease(controllerContext->BrokerLock); + return; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + } +} + +VOID +ViiperEvtEndpointPurge( + _In_ UDECXUSBENDPOINT Endpoint + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + BOOLEAN enqueueWorkItem; + LONG outstanding; + + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_PURGE_BEGIN, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, Endpoint, + endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, + endpointContext->ActiveOperations, + (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); + + // Serialize the admission gate with pending-slot allocation and direct + // input before the queue begins cancellation. + WdfSpinLockAcquire(controllerContext->BrokerLock); + InterlockedExchange(&endpointContext->Purging, TRUE); + InterlockedExchange(&endpointContext->StartAnnounced, FALSE); + outstanding = InterlockedIncrement(&endpointContext->PurgeOutstanding); + enqueueWorkItem = InterlockedCompareExchange( + &endpointContext->PurgeWorkerActive, TRUE, FALSE) == FALSE; + WdfSpinLockRelease(controllerContext->BrokerLock); + NT_ASSERT(outstanding > 0); + (VOID)outstanding; + InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); + ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_OPERATIONS_PURGED, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, Endpoint, + endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, + endpointContext->ActiveOperations, + (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); + (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointPurge); + // UdeCx owns the associated queue's state. Only the operations forwarded + // into VIIPER-owned paths are ours to cancel and join. The passive work + // item observes the class-extension state without changing it, performs a + // final cache clear, and acknowledges each outstanding callback only after + // all framework-delivered and VIIPER-owned work drains. + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_QUEUE_PURGE_REQUESTED, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, Endpoint, + endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, + endpointContext->ActiveOperations, + (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); + // This must remain the final endpoint access. Once the worker completes a + // PURGE, UdeCx can synchronously advance lifecycle or delete the endpoint. + if (enqueueWorkItem) { + WdfWorkItemEnqueue(endpointContext->PurgeWorkItem); + } +} + +static +VOID +ViiperActivateEndpoint( + _In_ UDECXUSBENDPOINT Endpoint + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + BOOLEAN active = FALSE; + BOOLEAN announce = FALSE; + NTSTATUS status = STATUS_SUCCESS; + + // Device configuration is the hardware-selection boundary for dynamic + // endpoints. UdeCx does not issue a separate START callback for every + // newly selected endpoint on all supported Windows builds, so publish that + // selection before completing the configuration request. A later explicit + // START still opens VIIPER's forwarded-operation admission gate, while its + // user-mode activation is deduplicated by StartAnnounced. UdeCx alone owns + // the associated KMDF queue transition. PURGE closes both VIIPER gates. + InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0 && + InterlockedCompareExchange(&deviceContext->InD0, 0, 0) != 0 && + InterlockedCompareExchange(&deviceContext->D0ExitPending, 0, 0) == 0 && + InterlockedCompareExchange( + &endpointContext->PurgeOutstanding, 0, 0) == 0) { + InterlockedExchange(&endpointContext->Purging, FALSE); + active = TRUE; + announce = InterlockedCompareExchange( + &endpointContext->StartAnnounced, TRUE, FALSE) == FALSE; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!active) { + return; + } + if (announce) { + status = ViiperQueueEndpointLifecycleEvent( + Endpoint, ViiperUdeOperationEndpointStart); + if (!NT_SUCCESS(status)) { + // Permit a later explicit START to retry publication. Compare- + // exchange preserves a PURGE which may already have cleared the + // announcement while the notification path was being dispatched. + (VOID)InterlockedCompareExchange( + &endpointContext->StartAnnounced, FALSE, TRUE); + } + } +} + +VOID +ViiperEvtEndpointStart( + _In_ UDECXUSBENDPOINT Endpoint + ) +{ + ViiperActivateEndpoint(Endpoint); +} + +VOID +ViiperEvtEndpointsConfigure( + _In_ UDECXUSBDEVICE Device, + _In_ WDFREQUEST Request, + _In_ UDECX_ENDPOINTS_CONFIGURE_PARAMS *ConfigureParams + ) +{ + NTSTATUS status = STATUS_SUCCESS; + ULONG endpointIndex; + + switch (ConfigureParams->ConfigureType) { + case UdecxEndpointsConfigureTypeDeviceInitialize: + // DeviceInitialize is UdeCx's endpoint-publication boundary, not a + // post-enumeration device reset. It can run more than once while the + // child is being initialized. Completing it synchronously avoids + // introducing a user-mode reset dependency before Windows can finish + // enumerating the child. Announce only the exact endpoint handles UdeCx + // selected; this also covers Windows builds which do not follow a new + // dynamic endpoint with a separate START callback. + for (endpointIndex = 0; + endpointIndex < ConfigureParams->EndpointsToConfigureCount; + ++endpointIndex) { + ViiperActivateEndpoint( + ConfigureParams->EndpointsToConfigure[endpointIndex]); + } + WdfRequestComplete(Request, STATUS_SUCCESS); + return; + case UdecxEndpointsConfigureTypeDeviceConfigurationChange: + // Selecting a configuration is the boundary that makes the newly + // created dynamic endpoint queues eligible for START. It is not a USB + // device reset. Holding this request for a user-mode reset round trip + // leaves every non-default endpoint in UdeCx's preceding PURGE state. + // Publish the selected endpoints before completing this asynchronous + // configuration boundary. PURGE remains authoritative for release and + // a later explicit START reopens only VIIPER-owned forwarding paths. + for (endpointIndex = 0; + endpointIndex < ConfigureParams->EndpointsToConfigureCount; + ++endpointIndex) { + ViiperActivateEndpoint( + ConfigureParams->EndpointsToConfigure[endpointIndex]); + } + WdfRequestComplete(Request, STATUS_SUCCESS); + return; + case UdecxEndpointsConfigureTypeInterfaceSettingChange: + status = ViiperQueueAcknowledgedInterfaceLifecycleEvent( + Device, + Request, + ConfigureParams->InterfaceNumber, + ConfigureParams->NewInterfaceSetting); + break; + case UdecxEndpointsConfigureTypeEndpointsReleasedOnly: + WdfRequestComplete(Request, STATUS_SUCCESS); + return; + break; + default: + status = STATUS_INVALID_PARAMETER; + break; + } + if (!NT_SUCCESS(status)) { + WdfRequestComplete(Request, status); + } +} + +VOID +ViiperEvtEndpointIoInternalControl( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request, + _In_ size_t OutputBufferLength, + _In_ size_t InputBufferLength, + _In_ ULONG IoControlCode + ) +{ + UNREFERENCED_PARAMETER(Queue); + UNREFERENCED_PARAMETER(OutputBufferLength); + UNREFERENCED_PARAMETER(InputBufferLength); + if (IoControlCode == IOCTL_INTERNAL_USB_SUBMIT_URB) { + NTSTATUS status = ViiperQueueUrb(Queue, Request); + if (status != STATUS_PENDING) { + ViiperCompleteUnownedUrb( + WdfIoQueueGetDevice(Queue), Request, status); + } + } else { + WdfRequestComplete(Request, STATUS_INVALID_DEVICE_REQUEST); + } +} diff --git a/native/udecx/driver/Driver.c b/native/udecx/driver/Driver.c new file mode 100644 index 00000000..2082de7b --- /dev/null +++ b/native/udecx/driver/Driver.c @@ -0,0 +1,41 @@ +#include "ViiperUde.h" + +#ifdef ALLOC_PRAGMA +#pragma alloc_text(INIT, DriverEntry) +#pragma alloc_text(PAGE, ViiperEvtDeviceAdd) +#pragma alloc_text(PAGE, ViiperEvtDriverCleanup) +#endif + +NTSTATUS +DriverEntry( + _In_ PDRIVER_OBJECT DriverObject, + _In_ PUNICODE_STRING RegistryPath + ) +{ + WDF_DRIVER_CONFIG config; + WDF_OBJECT_ATTRIBUTES attributes; + + ExInitializeDriverRuntime(DrvRtPoolNxOptIn); + WDF_DRIVER_CONFIG_INIT(&config, ViiperEvtDeviceAdd); + config.DriverPoolTag = 'eUiV'; + + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.EvtCleanupCallback = ViiperEvtDriverCleanup; + + return WdfDriverCreate( + DriverObject, + RegistryPath, + &attributes, + &config, + WDF_NO_HANDLE); +} + +VOID +ViiperEvtDriverCleanup( + _In_ WDFOBJECT DriverObject + ) +{ + PAGED_CODE(); + UNREFERENCED_PARAMETER(DriverObject); +} + diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c new file mode 100644 index 00000000..5a679859 --- /dev/null +++ b/native/udecx/driver/Ioctl.c @@ -0,0 +1,397 @@ +#include "ViiperUde.h" +#include "ViiperUdeBuildIdentity.g.h" + +static +BOOLEAN +ViiperValidateHeader( + _In_ const VIIPER_UDE_HEADER *Header, + _In_ size_t BufferLength, + _In_ size_t ExpectedSize + ) +{ + return BufferLength == ExpectedSize && + Header->Magic == VIIPER_UDE_MAGIC && + Header->Major == VIIPER_UDE_ABI_MAJOR && + Header->Minor == VIIPER_UDE_ABI_MINOR && + Header->Flags == 0 && + Header->Size == ExpectedSize; +} + +static +LONG64 +ViiperReadCounter( + _In_ volatile LONG64 *Counter + ) +{ + return InterlockedCompareExchange64(Counter, 0, 0); +} + +static +NTSTATUS +ViiperHandleNegotiate( + _In_ WDFREQUEST Request + ) +{ + NTSTATUS status; + VIIPER_UDE_NEGOTIATE_REQUEST *input; + VIIPER_UDE_NEGOTIATE_RESPONSE *output; + size_t inputLength; + size_t outputLength; + WDFFILEOBJECT fileObject; + VIIPER_UDE_FILE_CONTEXT *fileContext; + LARGE_INTEGER ticks; + + status = WdfRequestRetrieveInputBuffer( + Request, sizeof(*input), (PVOID *)&input, &inputLength); + if (!NT_SUCCESS(status)) { + return status; + } + status = WdfRequestRetrieveOutputBuffer( + Request, sizeof(*output), (PVOID *)&output, &outputLength); + if (!NT_SUCCESS(status)) { + return status; + } + if (outputLength < sizeof(*output)) { + return STATUS_BUFFER_TOO_SMALL; + } + if (inputLength != sizeof(*input) || + input->Header.Magic != VIIPER_UDE_MAGIC || + input->Header.Flags != 0 || + input->Header.Size != sizeof(*input) || + input->ClientNonce == 0 || input->Reserved != 0 || + (input->RequestedCapabilities & ~(VIIPER_UDE_CAP_ISOCHRONOUS | + VIIPER_UDE_CAP_STREAMS | VIIPER_UDE_CAP_DEVICE_LIFECYCLE | + VIIPER_UDE_CAP_INPUT_REPORTS | VIIPER_UDE_CAP_LIFECYCLE_TRACE | + VIIPER_UDE_CAP_DEVICE_CORRELATION)) != 0) { + return STATUS_INVALID_PARAMETER; + } + if (input->Header.Major != VIIPER_UDE_ABI_MAJOR || + input->Header.Minor != VIIPER_UDE_ABI_MINOR) { + return STATUS_REVISION_MISMATCH; + } + + fileObject = WdfRequestGetFileObject(Request); + if (fileObject == WDF_NO_HANDLE) { + return STATUS_INVALID_HANDLE; + } + fileContext = ViiperGetFileContext(fileObject); + if (InterlockedCompareExchange(&fileContext->Closing, 0, 0) != 0) { + return STATUS_FILE_CLOSED; + } + if (InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) != 0 && + fileContext->ClientNonce != input->ClientNonce) { + return STATUS_INVALID_DEVICE_STATE; + } + + if (InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) == 0) { + ticks = KeQueryPerformanceCounter(NULL); + fileContext->ClientNonce = input->ClientNonce; + fileContext->DriverNonce = ((ULONGLONG)ticks.QuadPart) ^ + ((ULONGLONG)(ULONG_PTR)fileObject << 13) ^ input->ClientNonce; + if (fileContext->DriverNonce == 0) { + fileContext->DriverNonce = 1; + } + InterlockedExchange(&fileContext->Negotiated, TRUE); + } + + RtlZeroMemory(output, sizeof(*output)); + output->Header.Magic = VIIPER_UDE_MAGIC; + output->Header.Major = VIIPER_UDE_ABI_MAJOR; + output->Header.Minor = VIIPER_UDE_ABI_MINOR; + output->Header.Size = sizeof(*output); + output->ClientNonce = fileContext->ClientNonce; + output->DriverNonce = fileContext->DriverNonce; + output->Capabilities = VIIPER_UDE_ADVERTISED_CAPABILITIES; + output->MaxDevices = VIIPER_UDE_MAX_DEVICES; + output->MaxDescriptorBytes = VIIPER_UDE_MAX_DESCRIPTOR_BYTES; + output->MaxTransferBytes = VIIPER_UDE_MAX_TRANSFER_BYTES; + output->MaxIsoPackets = VIIPER_UDE_MAX_ISO_PACKETS; + output->MaxPendingOperations = VIIPER_UDE_MAX_PENDING_OPERATIONS; + RtlCopyMemory(output->BuildIdentity, ViiperUdeBuildIdentity, + sizeof(output->BuildIdentity)); + WdfRequestSetInformation(Request, sizeof(*output)); + return STATUS_SUCCESS; +} + +static +NTSTATUS +ViiperHandleQueryStats( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request + ) +{ + NTSTATUS status; + VIIPER_UDE_STATS *output; + VIIPER_UDE_CONTROLLER_CONTEXT *context; + VIIPER_UDE_FILE_CONTEXT *fileContext; + WDFFILEOBJECT fileObject = WdfRequestGetFileObject(Request); + + if (fileObject == WDF_NO_HANDLE) { + return STATUS_INVALID_HANDLE; + } + fileContext = ViiperGetFileContext(fileObject); + if (InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) == 0 || + InterlockedCompareExchange(&fileContext->Closing, 0, 0) != 0) { + return STATUS_INVALID_DEVICE_STATE; + } + status = WdfRequestRetrieveOutputBuffer( + Request, sizeof(*output), (PVOID *)&output, NULL); + if (!NT_SUCCESS(status)) { + return status; + } + + context = ViiperGetControllerContext(WdfIoQueueGetDevice(Queue)); + RtlZeroMemory(output, sizeof(*output)); + output->Header.Magic = VIIPER_UDE_MAGIC; + output->Header.Major = VIIPER_UDE_ABI_MAJOR; + output->Header.Minor = VIIPER_UDE_ABI_MINOR; + output->Header.Size = sizeof(*output); + output->OperationsDequeued = (ULONGLONG)ViiperReadCounter(&context->OperationsDequeued); + output->OperationsCompleted = (ULONGLONG)ViiperReadCounter(&context->OperationsCompleted); + output->OperationsCancelled = (ULONGLONG)ViiperReadCounter(&context->OperationsCancelled); + output->OperationsPurged = (ULONGLONG)ViiperReadCounter(&context->OperationsPurged); + output->LateCompletions = (ULONGLONG)ViiperReadCounter(&context->LateCompletions); + output->InvalidMessages = (ULONGLONG)ViiperReadCounter(&context->InvalidMessages); + output->QueueExhaustions = (ULONGLONG)ViiperReadCounter(&context->QueueExhaustions); + output->IsoPackets = (ULONGLONG)ViiperReadCounter(&context->IsoPackets); + output->BytesToDevice = (ULONGLONG)ViiperReadCounter(&context->BytesToDevice); + output->BytesFromDevice = (ULONGLONG)ViiperReadCounter(&context->BytesFromDevice); + output->NotificationEvents = (ULONGLONG)ViiperReadCounter(&context->NotificationEventsDelivered); + output->NotificationEventOverflows = + (ULONGLONG)ViiperReadCounter(&context->NotificationEventOverflows); + output->ActiveDevices = (ULONG)InterlockedCompareExchange(&context->ActiveDevices, 0, 0); + output->PendingOperations = (ULONG)InterlockedCompareExchange(&context->PendingOperations, 0, 0); + output->WaitingDequeues = (ULONG)InterlockedCompareExchange(&context->WaitingDequeueCount, 0, 0); + output->CleanupRetries = (ULONG)InterlockedCompareExchange(&context->CleanupRetries, 0, 0); + output->InputReportsSubmitted = + (ULONGLONG)ViiperReadCounter(&context->InputReportsSubmitted); + output->InputReportsCompleted = + (ULONGLONG)ViiperReadCounter(&context->InputReportsCompleted); + output->ReservedPorts = + (ULONG)InterlockedCompareExchange(&context->ReservedPorts, 0, 0); + WdfRequestSetInformation(Request, sizeof(*output)); + return STATUS_SUCCESS; +} + +static +NTSTATUS +ViiperHandleQueryLifecycleTrace( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request + ) +{ + NTSTATUS status; + VIIPER_UDE_LIFECYCLE_TRACE *output; + VIIPER_UDE_CONTROLLER_CONTEXT *context; + VIIPER_UDE_FILE_CONTEXT *fileContext; + WDFFILEOBJECT fileObject = WdfRequestGetFileObject(Request); + LARGE_INTEGER frequency; + ULONGLONG latestSequence; + ULONGLONG firstSequence; + ULONG shardIndex; + ULONG recordIndex; + + if (fileObject == WDF_NO_HANDLE) { + return STATUS_INVALID_HANDLE; + } + fileContext = ViiperGetFileContext(fileObject); + if (InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) == 0 || + InterlockedCompareExchange(&fileContext->Closing, 0, 0) != 0) { + return STATUS_INVALID_DEVICE_STATE; + } + status = WdfRequestRetrieveOutputBuffer( + Request, sizeof(*output), (PVOID *)&output, NULL); + if (!NT_SUCCESS(status)) { + return status; + } + + context = ViiperGetControllerContext(WdfIoQueueGetDevice(Queue)); + RtlZeroMemory(output, sizeof(*output)); + output->Header.Magic = VIIPER_UDE_MAGIC; + output->Header.Major = VIIPER_UDE_ABI_MAJOR; + output->Header.Minor = VIIPER_UDE_ABI_MINOR; + output->Header.Size = sizeof(*output); + output->RecordSize = sizeof(output->Records[0]); + output->Capacity = VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY; + (VOID)KeQueryPerformanceCounter(&frequency); + output->PerformanceFrequency = (ULONGLONG)frequency.QuadPart; + + latestSequence = (ULONGLONG)ViiperReadCounter( + &context->LifecycleTraceSequence); + output->LatestSequence = latestSequence; + firstSequence = latestSequence > VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY + ? latestSequence - VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY + 1 + : 1; + for (shardIndex = 0; + shardIndex < context->LifecycleTraceShardCount; + ++shardIndex) { + VIIPER_UDE_LIFECYCLE_TRACE_SHARD *shard = + &context->LifecycleTraceShards[shardIndex]; + for (recordIndex = 0; + recordIndex < VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY; + ++recordIndex) { + VIIPER_UDE_LIFECYCLE_TRACE_RECORD *source = + &shard->Records[recordIndex]; + VIIPER_UDE_LIFECYCLE_TRACE_RECORD candidate; + LONG64 slotStateBefore = InterlockedCompareExchange64( + &shard->SlotStates[recordIndex], 0, 0); + ULONGLONG publishedBefore = + (ULONGLONG)InterlockedCompareExchange64( + (volatile LONG64 *)&source->PublishedSequence, 0, 0); + ULONGLONG publishedAfter; + LONG64 slotStateAfter; + ULONG insertIndex; + + if ((slotStateBefore & 1) != 0 || + publishedBefore < firstSequence || + publishedBefore > latestSequence) { + continue; + } + KeMemoryBarrier(); + RtlCopyMemory(&candidate, source, sizeof(candidate)); + KeMemoryBarrier(); + publishedAfter = (ULONGLONG)InterlockedCompareExchange64( + (volatile LONG64 *)&source->PublishedSequence, 0, 0); + slotStateAfter = InterlockedCompareExchange64( + &shard->SlotStates[recordIndex], 0, 0); + if (slotStateAfter != slotStateBefore || + (slotStateAfter & 1) != 0 || + publishedAfter != publishedBefore || + candidate.PublishedSequence != publishedBefore || + output->RecordCount >= VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY) { + continue; + } + + insertIndex = output->RecordCount; + while (insertIndex > 0 && + output->Records[insertIndex - 1].PublishedSequence > + candidate.PublishedSequence) { + --insertIndex; + } + if ((insertIndex > 0 && + output->Records[insertIndex - 1].PublishedSequence == + candidate.PublishedSequence) || + (insertIndex < output->RecordCount && + output->Records[insertIndex].PublishedSequence == + candidate.PublishedSequence)) { + continue; + } + if (insertIndex < output->RecordCount) { + RtlMoveMemory( + &output->Records[insertIndex + 1], + &output->Records[insertIndex], + (output->RecordCount - insertIndex) * + sizeof(output->Records[0])); + } + output->Records[insertIndex] = candidate; + ++output->RecordCount; + } + } + + // Status is monotonic. Sample it only after the complete record scan so a + // watchdog or contended writer observed during the scan cannot be omitted + // from an otherwise successful release-gate snapshot. + output->StatusFlags = (VIIPER_UDE_UINT32)InterlockedCompareExchange( + &context->LifecycleTraceStatus, 0, 0); + + WdfRequestSetInformation(Request, sizeof(*output)); + return STATUS_SUCCESS; +} + +VOID +ViiperEvtIoDeviceControlRoute( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request, + _In_ size_t OutputBufferLength, + _In_ size_t InputBufferLength, + _In_ ULONG IoControlCode + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *context = + ViiperGetControllerContext(WdfIoQueueGetDevice(Queue)); + NTSTATUS status; + + UNREFERENCED_PARAMETER(OutputBufferLength); + UNREFERENCED_PARAMETER(InputBufferLength); + + if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) != 0) { + WdfRequestComplete(Request, STATUS_DEVICE_REMOVED); + return; + } + + if (IoControlCode == IOCTL_VIIPER_UDE_SUBMIT_INPUT_REPORT) { + // The default queue already has parallel/passive/no-synchronization + // semantics. Complete the hot interrupt-IN submission here instead of + // forwarding it through a second identically configured KMDF queue. + // Control, lifecycle, and media IOCTLs still move to the serialized + // control queue and therefore cannot head-of-line block fresh input. + status = ViiperSubmitInputReport(Queue, Request); + WdfRequestComplete(Request, status); + return; + } + + status = WdfRequestForwardToIoQueue(Request, context->ControlQueue); + if (!NT_SUCCESS(status)) { + WdfRequestComplete(Request, status); + } +} + +VOID +ViiperEvtIoDeviceControl( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request, + _In_ size_t OutputBufferLength, + _In_ size_t InputBufferLength, + _In_ ULONG IoControlCode + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *context = + ViiperGetControllerContext(WdfIoQueueGetDevice(Queue)); + NTSTATUS status; + + UNREFERENCED_PARAMETER(OutputBufferLength); + UNREFERENCED_PARAMETER(InputBufferLength); + + if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) != 0) { + WdfRequestComplete(Request, STATUS_DEVICE_REMOVED); + return; + } + + switch (IoControlCode) { + case IOCTL_VIIPER_UDE_NEGOTIATE: + status = ViiperHandleNegotiate(Request); + break; + case IOCTL_VIIPER_UDE_QUERY_STATS: + status = ViiperHandleQueryStats(Queue, Request); + break; + case IOCTL_VIIPER_UDE_QUERY_LIFECYCLE_TRACE: + status = ViiperHandleQueryLifecycleTrace(Queue, Request); + break; + case IOCTL_VIIPER_UDE_CREATE_DEVICE: + status = ViiperCreateVirtualDevice(Queue, Request); + break; + case IOCTL_VIIPER_UDE_DESTROY_DEVICE: + status = ViiperDestroyVirtualDevice(Queue, Request); + break; + case IOCTL_VIIPER_UDE_DEQUEUE_OPERATION: + status = ViiperQueueDequeueOperation(Queue, Request); + break; + case IOCTL_VIIPER_UDE_COMPLETE_OPERATION: + status = ViiperCompleteOperation(Queue, Request); + break; + case IOCTL_VIIPER_UDE_SUBMIT_INPUT_REPORT: + // The parallel default queue completes this hot-path IOCTL directly. + // Reject it here rather than silently restoring head-of-line blocking. + status = STATUS_INVALID_DEVICE_REQUEST; + break; + default: + status = UdecxWdfDeviceTryHandleUserIoctl(WdfIoQueueGetDevice(Queue), Request) + ? STATUS_PENDING + : STATUS_INVALID_DEVICE_REQUEST; + break; + } + + if (status != STATUS_PENDING) { + WdfRequestComplete(Request, status); + } +} diff --git a/native/udecx/driver/Trace.c b/native/udecx/driver/Trace.c new file mode 100644 index 00000000..68549ec4 --- /dev/null +++ b/native/udecx/driver/Trace.c @@ -0,0 +1,161 @@ +#include + +#include "ViiperUde.h" + +#pragma intrinsic(_ReturnAddress) + +#ifdef ALLOC_PRAGMA +#pragma alloc_text(PAGE, ViiperInitializeLifecycleTrace) +#endif + +NTSTATUS +ViiperInitializeLifecycleTrace( + _In_ WDFDEVICE Controller + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext; + WDF_OBJECT_ATTRIBUTES attributes; + ULONG maximumProcessors; + ULONG shardCount; + SIZE_T storageSize; + PVOID rawStorage; + ULONG_PTR alignedStorage; + NTSTATUS status; + + PAGED_CODE(); + controllerContext = ViiperGetControllerContext(Controller); + maximumProcessors = KeQueryMaximumProcessorCountEx(ALL_PROCESSOR_GROUPS); + if (maximumProcessors == 0) { + return STATUS_DEVICE_CONFIGURATION_ERROR; + } + shardCount = maximumProcessors > VIIPER_UDE_LIFECYCLE_TRACE_MAX_SHARDS + ? VIIPER_UDE_LIFECYCLE_TRACE_MAX_SHARDS + : maximumProcessors; + storageSize = sizeof(VIIPER_UDE_LIFECYCLE_TRACE_SHARD) * shardCount + + SYSTEM_CACHE_ALIGNMENT_SIZE - 1U; + + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = Controller; + status = WdfMemoryCreate( + &attributes, + NonPagedPoolNx, + 0x56495554, + storageSize, + &controllerContext->LifecycleTraceStorage, + &rawStorage); + if (!NT_SUCCESS(status)) { + controllerContext->LifecycleTraceStorage = WDF_NO_HANDLE; + return status; + } + RtlZeroMemory(rawStorage, storageSize); + alignedStorage = ((ULONG_PTR)rawStorage + SYSTEM_CACHE_ALIGNMENT_SIZE - 1U) & + ~((ULONG_PTR)SYSTEM_CACHE_ALIGNMENT_SIZE - 1U); + controllerContext->LifecycleTraceShards = + (VIIPER_UDE_LIFECYCLE_TRACE_SHARD *)alignedStorage; + controllerContext->LifecycleTraceShardCount = shardCount; + return STATUS_SUCCESS; +} + +VOID +ViiperTraceLifecycle( + _In_ WDFDEVICE Controller, + _In_ UCHAR Source, + _In_ USHORT Event, + _In_ ULONGLONG DeviceId, + _In_ ULONG Generation, + _In_opt_ UDECXUSBDEVICE Device, + _In_opt_ UDECXUSBENDPOINT Endpoint, + _In_ UCHAR EndpointAddress, + _In_ NTSTATUS Status, + _In_ LONG ActiveOperations, + _In_ ULONG QueueState, + _In_ ULONG Line + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext; + VIIPER_UDE_LIFECYCLE_TRACE_SHARD *shard; + VIIPER_UDE_LIFECYCLE_TRACE_RECORD *record; + PROCESSOR_NUMBER processorNumber; + LARGE_INTEGER timestamp; + ULONGLONG localSequence; + ULONGLONG sequence; + volatile LONG64 *slotState; + LONG64 observedSlotState; + LONG64 claimedSlotState; + ULONG processorIndex; + ULONG shardIndex; + ULONG slotIndex; + + controllerContext = ViiperGetControllerContext(Controller); + if (Event >= VIIPER_UDE_TRACE_ENDPOINT_QUIESCENCE_WATCHDOG && + Event <= VIIPER_UDE_TRACE_OWNER_RUNDOWN_WATCHDOG) { + (VOID)InterlockedOr( + &controllerContext->LifecycleTraceStatus, + VIIPER_UDE_LIFECYCLE_TRACE_STATUS_WATCHDOG_FIRED); + } + if (controllerContext->LifecycleTraceShards == NULL || + controllerContext->LifecycleTraceShardCount == 0) { + return; + } + KeGetCurrentProcessorNumberEx(&processorNumber); + processorIndex = KeGetProcessorIndexFromNumber(&processorNumber); + if (processorIndex == INVALID_PROCESSOR_INDEX) { + processorIndex = processorNumber.Group * MAXIMUM_PROC_PER_GROUP + + processorNumber.Number; + } + shardIndex = processorIndex % controllerContext->LifecycleTraceShardCount; + shard = &controllerContext->LifecycleTraceShards[shardIndex]; + localSequence = (ULONGLONG)InterlockedIncrement64(&shard->WriteSequence); + slotIndex = (ULONG)( + (localSequence - 1) % VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY); + slotState = &shard->SlotStates[slotIndex]; + claimedSlotState = (LONG64)((localSequence << 1) | 1ULL); + for (;;) { + observedSlotState = InterlockedCompareExchange64(slotState, 0, 0); + if ((observedSlotState & 1) != 0 || + ((ULONGLONG)observedSlotState >> 1) >= localSequence) { + (VOID)InterlockedOr( + &controllerContext->LifecycleTraceStatus, + VIIPER_UDE_LIFECYCLE_TRACE_STATUS_DROPPED_RECORD); + return; + } + if (InterlockedCompareExchange64( + slotState, claimedSlotState, observedSlotState) == + observedSlotState) { + break; + } + } + sequence = (ULONGLONG)InterlockedIncrement64( + &controllerContext->LifecycleTraceSequence); + record = &shard->Records[slotIndex]; + + (VOID)InterlockedExchange64((volatile LONG64 *)&record->PublishedSequence, 0); + KeMemoryBarrier(); + + timestamp = KeQueryPerformanceCounter(NULL); + record->TimestampQpc = (ULONGLONG)timestamp.QuadPart; + record->Caller = (ULONGLONG)(ULONG_PTR)_ReturnAddress(); + record->DeviceId = DeviceId; + record->DeviceObject = (ULONGLONG)(ULONG_PTR)Device; + record->EndpointObject = (ULONGLONG)(ULONG_PTR)Endpoint; + record->Generation = Generation; + record->Line = Line; + record->Status = Status; + record->ActiveOperations = ActiveOperations; + record->PendingOperations = InterlockedCompareExchange( + &controllerContext->PendingOperations, 0, 0); + record->QueueState = QueueState; + record->Event = Event; + record->Processor = (VIIPER_UDE_UINT16)( + processorNumber.Group * MAXIMUM_PROC_PER_GROUP + processorNumber.Number); + record->Source = Source; + record->Irql = (VIIPER_UDE_UINT8)KeGetCurrentIrql(); + record->EndpointAddress = EndpointAddress; + record->Reserved = 0; + + KeMemoryBarrier(); + (VOID)InterlockedExchange64( + (volatile LONG64 *)&record->PublishedSequence, (LONG64)sequence); + KeMemoryBarrier(); + (VOID)InterlockedExchange64(slotState, (LONG64)(localSequence << 1)); +} diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h new file mode 100644 index 00000000..2115880d --- /dev/null +++ b/native/udecx/driver/ViiperUde.h @@ -0,0 +1,504 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "..\include\ViiperUdeProtocol.h" + +EXTERN_C const GUID GUID_DEVINTERFACE_VIIPER_UDE; + +// Only VIIPER's private interface receives a reference string. The standard +// host-controller interface must retain UdeCx's canonical unqualified path. +#define VIIPER_UDE_BROKER_REFERENCE_STRING L"broker" +#define VIIPER_UDE_MAX_PENDING_MANAGEMENT 256 +#define VIIPER_UDE_MAX_INPUT_TRANSITIONS 256 +#define VIIPER_UDE_MAX_INPUT_TRANSITION_BYTES 65536 +// Keep one cache-isolated recorder shard per logical processor on ordinary +// client systems. Very large systems hash processors into this fixed ceiling; +// every shard still retains the complete public trace window, so collisions +// cannot discard a record merely because another processor used the shard. +#define VIIPER_UDE_LIFECYCLE_TRACE_MAX_SHARDS 64 +#define VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS (2ULL * 1000ULL * 1000ULL * 10ULL) +// UdeCx numbers USB 3 ports after every USB 2 port on the controller. Keep +// the topology constants shared by controller creation and child plug-in so +// fixed slot-to-port identity cannot drift between those two boundaries. +#define VIIPER_UDE_USB20_PORT_COUNT VIIPER_UDE_MAX_DEVICES +#define VIIPER_UDE_USB30_PORT_COUNT VIIPER_UDE_MAX_DEVICES + +typedef enum VIIPER_UDE_PENDING_STATE { + ViiperUdePendingEmpty = 0, + ViiperUdePendingPreparing, + ViiperUdePendingQueued, + ViiperUdePendingPublishing, + ViiperUdePendingInFlight, + ViiperUdePendingCompleting, + ViiperUdePendingDpcCompletion +} VIIPER_UDE_PENDING_STATE; + +typedef struct VIIPER_UDE_PENDING_SLOT { + LIST_ENTRY AdmissionEntry; + WDFREQUEST Request; + UDECXUSBENDPOINT Endpoint; + ULONGLONG Token; + ULONGLONG DeviceId; + ULONGLONG AdmissionSequence; + ULONG Generation; + ULONG DeviceGeneration; + ULONG EndpointGeneration; + VIIPER_UDE_PENDING_STATE State; + BOOLEAN AbortPending; + BOOLEAN PublishedToOwner; + BOOLEAN AdmissionLinked; + UCHAR EndpointAddress; + NTSTATUS AbortStatus; + NTSTATUS CompletionStatus; + USBD_STATUS CompletionUsbdStatus; + BOOLEAN CompleteWithNtStatus; +} VIIPER_UDE_PENDING_SLOT; + +typedef struct VIIPER_UDE_NOTIFICATION { + ULONGLONG Token; + ULONGLONG DeviceId; + ULONGLONG EndpointSequence; + ULONGLONG DeviceSequence; + ULONG Generation; + ULONG EndpointGeneration; + ULONG Kind; + UCHAR EndpointAddress; + UCHAR InterfaceNumber; + UCHAR InterfaceSetting; + UCHAR EndpointAttributes; + UCHAR EndpointInterval; + USHORT EndpointMaxPacketSize; +} VIIPER_UDE_NOTIFICATION; + +typedef struct VIIPER_UDE_MANAGEMENT_SLOT { + WDFREQUEST Request; + // Private framework-object pins, never exposed on the broker ABI. They + // prevent a deleted WDF handle value from being recycled while a delayed + // lifecycle acknowledgement still names this slot. Callers compare these + // only with handles in the live DeviceLock-protected table; they never + // access an object's context after its cleanup callback. + UDECXUSBDEVICE Device; + UDECXUSBENDPOINT Endpoint; + WDFFILEOBJECT OwnerFile; + ULONGLONG Token; + // One harmless success tombstone for an operation already delivered to + // user mode when kernel teardown completed its held UdeCx request. This + // never retains WDF objects and is consumed by the first late ACK. + ULONGLONG RetiredToken; + ULONGLONG RetiredDeviceId; + WDFFILEOBJECT RetiredOwnerFile; + ULONGLONG DeviceId; + ULONGLONG ResetEpoch; + ULONG Generation; + ULONG DeviceGeneration; + ULONG EndpointGeneration; + ULONG RetiredDeviceGeneration; + ULONG RetiredEndpointGeneration; + VIIPER_UDE_PENDING_STATE State; + BOOLEAN RetiredNotificationPending; + ULONG Kind; + UCHAR EndpointAddress; +} VIIPER_UDE_MANAGEMENT_SLOT; + +typedef struct VIIPER_UDE_REQUEST_CONTEXT { + WDFDEVICE Controller; + UDECXUSBENDPOINT Endpoint; + ULONG PendingSlot; + ULONGLONG Token; + ULONG DeviceGeneration; + ULONG EndpointGeneration; + ULONG TransferLength; + ULONG IsoPacketCount; + ULONG IsoStartFrame; + BOOLEAN DirectionIn; + // Nonzero only for a cached direct-input delivery. The completion DPC + // snapshots these before UdeCx can recycle the request context, so stats + // and a crash dump describe an OS-visible completion rather than a copy + // which had not yet crossed the virtual host-controller boundary. + ULONG DirectInputBytes; + ULONGLONG DirectInputSequence; + // Protected by the controller BrokerLock. The DPC removes and snapshots + // these fields before UdeCx may recycle this request context. + LIST_ENTRY CompletionEntry; + WDFREQUEST CompletionRequest; + NTSTATUS CompletionStatus; + USBD_STATUS CompletionUsbdStatus; + BOOLEAN CompleteWithNtStatus; + BOOLEAN CompletionQueued; +} VIIPER_UDE_REQUEST_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_REQUEST_CONTEXT, ViiperGetRequestContext) + +typedef struct VIIPER_UDE_LIFECYCLE_TRACE_SHARD { + DECLSPEC_ALIGN(SYSTEM_CACHE_ALIGNMENT_SIZE) volatile LONG64 WriteSequence; + UCHAR SequencePadding[SYSTEM_CACHE_ALIGNMENT_SIZE - sizeof(LONG64)]; + // A slot state is (local sequence << 1) | writer-active. The monotonically + // increasing claim prevents a preempted old writer from overwriting a + // newer wrap of the same ring slot, while the low bit makes collisions + // fail closed without a lock or wait. + volatile LONG64 SlotStates[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY]; + VIIPER_UDE_LIFECYCLE_TRACE_RECORD Records[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY]; +} VIIPER_UDE_LIFECYCLE_TRACE_SHARD; + +C_ASSERT((SYSTEM_CACHE_ALIGNMENT_SIZE & (SYSTEM_CACHE_ALIGNMENT_SIZE - 1)) == 0); +C_ASSERT(sizeof(VIIPER_UDE_LIFECYCLE_TRACE_SHARD) % + SYSTEM_CACHE_ALIGNMENT_SIZE == 0); + +typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { + WDFWAITLOCK OwnerLock; + // UdeCx endpoint/device cleanup can run while the framework is deleting + // sibling controller children, but the parent context remains alive until + // every child cleanup callback has returned. A push lock is supported by + // the driver's Windows 10 1809 floor and is optimized for this shared-heavy + // identity index. Normal shared acquisition waits behind an exclusive + // lifecycle writer, so continuous reports cannot starve handle revocation. + EX_PUSH_LOCK DeviceLock; + WDFSPINLOCK BrokerLock; + WDFMEMORY PendingStorage; + VIIPER_UDE_PENDING_SLOT *PendingSlots; + ULONG NextPendingSlot; + ULONG NextDispatchSlot; + ULONG NextManagementSlot; + WDFMEMORY NotificationStorage; + VIIPER_UDE_NOTIFICATION *Notifications; + WDFMEMORY ManagementStorage; + VIIPER_UDE_MANAGEMENT_SLOT *ManagementSlots; + // One nonpageable FIFO owns every terminal UdeCx URB completion. Request + // contexts provide its entries, so the completion boundary never allocates. + WDFDPC CompletionDpc; + LIST_ENTRY CompletionQueue; + BOOLEAN CompletionDpcActive; + ULONG NotificationHead; + ULONG NotificationTail; + ULONG NotificationCount; + WDFFILEOBJECT OwnerFile; + WDFQUEUE DefaultQueue; + WDFQUEUE ControlQueue; + WDFQUEUE WaitingDequeues; + KEVENT BrokerOperationsDrained; + KEVENT CompletionOperationsDrained; + KEVENT OwnerAdmissionsDrained; + KEVENT FileCleanupsDrained; + BOOLEAN CleanupInProgress; + volatile LONG ShuttingDown; + volatile LONG BrokerFaulted; + volatile LONG OwnerReferenced; + volatile LONG ActiveOwnerAdmissions; + volatile LONG ActiveFileCleanups; + volatile LONG CleanupRetries; + volatile LONG ActiveDevices; + volatile LONG ReservedPorts; + volatile LONG PendingOperations; + volatile LONG PendingCompletions; + volatile LONG WaitingDequeueCount; + volatile LONG64 OperationsDequeued; + volatile LONG64 OperationsCompleted; + volatile LONG64 OperationsCancelled; + volatile LONG64 OperationsPurged; + volatile LONG64 LateCompletions; + volatile LONG64 InvalidMessages; + volatile LONG64 QueueExhaustions; + volatile LONG64 NotificationEventsDelivered; + volatile LONG64 NotificationEventOverflows; + volatile LONG64 InputReportsSubmitted; + volatile LONG64 InputReportsCompleted; + volatile LONG64 IsoPackets; + volatile LONG64 BytesToDevice; + volatile LONG64 BytesFromDevice; + volatile LONG64 LifecycleTraceSequence; + volatile LONG LifecycleTraceStatus; + WDFMEMORY LifecycleTraceStorage; + VIIPER_UDE_LIFECYCLE_TRACE_SHARD *LifecycleTraceShards; + ULONG LifecycleTraceShardCount; + // Sorted by DeviceId and protected by DeviceLock. The input producer uses + // a shared binary lookup while lifecycle mutations retain exclusive access + // to the physical UDE port table below. + ULONG InputDeviceCount; + UDECXUSBDEVICE InputDevices[VIIPER_UDE_MAX_DEVICES]; + // A logical device leaves Devices[] as soon as removal wins admission, but + // its physical port cannot be reused until the matching framework cleanup + // callback runs. The epoch prevents a delayed cleanup from releasing a + // later reservation after an early create/plug-in failure. + ULONGLONG PortReservationEpochs[VIIPER_UDE_MAX_DEVICES]; + BOOLEAN PortReserved[VIIPER_UDE_MAX_DEVICES]; + UDECXUSBDEVICE Devices[VIIPER_UDE_MAX_DEVICES]; +} VIIPER_UDE_CONTROLLER_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_CONTROLLER_CONTEXT, ViiperGetControllerContext) + +_IRQL_requires_max_(APC_LEVEL) +FORCEINLINE +VOID +ViiperAcquireDeviceLockExclusive( + _Inout_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext + ) +{ + // Push-lock callers must suppress normal kernel APC delivery from acquire + // through release and must run at IRQL <= APC_LEVEL. + KeEnterCriticalRegion(); + ExAcquirePushLockExclusive(&ControllerContext->DeviceLock); +} + +_IRQL_requires_max_(APC_LEVEL) +FORCEINLINE +VOID +ViiperAcquireDeviceLockShared( + _Inout_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext + ) +{ + KeEnterCriticalRegion(); + ExAcquirePushLockShared(&ControllerContext->DeviceLock); +} + +_IRQL_requires_max_(APC_LEVEL) +FORCEINLINE +VOID +ViiperReleaseDeviceLockExclusive( + _Inout_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext + ) +{ + ExReleasePushLockExclusive(&ControllerContext->DeviceLock); + KeLeaveCriticalRegion(); +} + +_IRQL_requires_max_(APC_LEVEL) +FORCEINLINE +VOID +ViiperReleaseDeviceLockShared( + _Inout_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext + ) +{ + ExReleasePushLockShared(&ControllerContext->DeviceLock); + KeLeaveCriticalRegion(); +} + +typedef struct VIIPER_UDE_FILE_CONTEXT { + // The reference-pinned WDFFILEOBJECT containing this context is the + // kernel session incarnation: KMDF cannot recycle that object while any + // owner/request callback retains it, and Closing makes retirement a + // permanent one-way generation fence. DriverNonce is the corresponding + // nonzero user-visible session tag established by negotiation. + volatile LONG Negotiated; + volatile LONG Closing; + volatile LONG BrokerOwner; + ULONGLONG ClientNonce; + ULONGLONG DriverNonce; +} VIIPER_UDE_FILE_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_FILE_CONTEXT, ViiperGetFileContext) + +typedef struct VIIPER_UDE_DEVICE_CONTEXT { + WDFDEVICE Controller; + WDFWORKITEM D0ExitWorkItem; + WDFFILEOBJECT OwnerFile; + ULONGLONG DeviceId; + ULONG Generation; + ULONG Slot; + ULONGLONG PortReservation; + UDECX_USB_DEVICE_SPEED Speed; + BOOLEAN Plugged; + volatile LONG InD0; + volatile LONG D0ExitPending; + volatile LONG Resetting; + volatile LONG64 ResetEpoch; + volatile LONG Purging; + volatile LONG ActiveCounted; + volatile LONG OwnerReferenced; + ULONG MaxPendingOperations; + volatile LONG PendingOperations; + UDECXUSBENDPOINT DefaultEndpoint; + UDECXUSBENDPOINT Endpoints[256]; + BOOLEAN RetiredEndpoints[256]; + ULONG EndpointGenerations[256]; + volatile LONG64 EndpointSequences[256]; + volatile LONG64 DeviceLifecycleSequence; + volatile LONG64 DeviceSequence; +} VIIPER_UDE_DEVICE_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_DEVICE_CONTEXT, ViiperGetDeviceContext) + +typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { + UDECXUSBDEVICE Device; + ULONG Generation; + WDFQUEUE Queue; + WDFWAITLOCK InputLock; + WDFWORKITEM PurgeWorkItem; + WDFWORKITEM ResetWorkItem; + WDFREQUEST ResetRequest; + KEVENT OperationsDrained; + USB_ENDPOINT_DESCRIPTOR Descriptor; + volatile LONG Purging; + volatile LONG PurgeOutstanding; + volatile LONG PurgeWorkerActive; + volatile LONG StartAnnounced; + volatile LONG Resetting; + volatile LONG64 ResetDeviceEpoch; + volatile LONG ActiveOperations; + volatile LONG64 LastInputSequence; + volatile LONG64 NextIsoStartFrame; + volatile LONG InputReportValid; + volatile LONG CachedDeliveryPending; + volatile LONG InputSnapshotPending; + ULONG InputReportLength; + UCHAR InputReport[VIIPER_UDE_MAX_INPUT_REPORT_BYTES]; + WDFMEMORY InputTransitionMemory; + PUCHAR InputTransitionReports; + ULONG InputTransitionStride; + ULONG InputTransitionCapacity; + volatile LONG InputTransitionHead; + volatile LONG InputTransitionCount; + USHORT InputTransitionLengths[VIIPER_UDE_MAX_INPUT_TRANSITIONS]; + ULONGLONG InputTransitionSequences[VIIPER_UDE_MAX_INPUT_TRANSITIONS]; + // BrokerLock protects this FIFO and every slot AdmissionEntry. It keeps + // same-endpoint publication ordered without scanning the controller-wide + // 4096-slot table on every USB transfer. + LIST_ENTRY AdmissionQueue; + ULONGLONG NextAdmissionSequence; + BOOLEAN FastInput; +} VIIPER_UDE_ENDPOINT_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_ENDPOINT_CONTEXT, ViiperGetEndpointContext) +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(UDECXUSBENDPOINT, ViiperGetQueueEndpoint) + +DRIVER_INITIALIZE DriverEntry; +EVT_WDF_DRIVER_DEVICE_ADD ViiperEvtDeviceAdd; +EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtDriverCleanup; +EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtControllerCleanup; +EVT_WDF_DEVICE_SELF_MANAGED_IO_INIT ViiperEvtDeviceSelfManagedIoInit; +EVT_WDF_DEVICE_SELF_MANAGED_IO_CLEANUP ViiperEvtDeviceSelfManagedIoCleanup; +EVT_WDF_DEVICE_FILE_CREATE ViiperEvtFileCreate; +EVT_WDF_FILE_CLEANUP ViiperEvtFileCleanup; +EVT_WDF_FILE_CLOSE ViiperEvtFileClose; +EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL ViiperEvtIoDeviceControlRoute; +EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL ViiperEvtIoDeviceControl; +EVT_UDECX_WDF_DEVICE_QUERY_USB_CAPABILITY ViiperEvtQueryUsbCapability; +EVT_UDECX_USB_DEVICE_D0_ENTRY ViiperEvtUsbDeviceD0Entry; +EVT_UDECX_USB_DEVICE_D0_EXIT ViiperEvtUsbDeviceD0Exit; +EVT_WDF_WORKITEM ViiperEvtUsbDeviceD0ExitWorkItem; +EVT_UDECX_USB_DEVICE_SET_FUNCTION_SUSPEND_AND_WAKE ViiperEvtUsbDeviceSetFunctionSuspendAndWake; +EVT_UDECX_USB_DEVICE_DEFAULT_ENDPOINT_ADD ViiperEvtDefaultEndpointAdd; +EVT_UDECX_USB_DEVICE_ENDPOINT_ADD ViiperEvtEndpointAdd; +EVT_UDECX_USB_DEVICE_ENDPOINTS_CONFIGURE ViiperEvtEndpointsConfigure; +EVT_UDECX_USB_ENDPOINT_RESET ViiperEvtEndpointReset; +EVT_UDECX_USB_ENDPOINT_PURGE ViiperEvtEndpointPurge; +EVT_UDECX_USB_ENDPOINT_START ViiperEvtEndpointStart; +EVT_WDF_IO_QUEUE_IO_INTERNAL_DEVICE_CONTROL ViiperEvtEndpointIoInternalControl; +EVT_WDF_IO_QUEUE_IO_CANCELED_ON_QUEUE ViiperEvtUrbCanceledOnQueue; +EVT_WDF_IO_QUEUE_STATE ViiperEvtFastInputQueueReady; +EVT_WDF_IO_QUEUE_IO_CANCELED_ON_QUEUE ViiperEvtDequeueCanceledOnQueue; +EVT_WDF_WORKITEM ViiperEvtEndpointPurgeWorkItem; +EVT_WDF_WORKITEM ViiperEvtEndpointResetWorkItem; +EVT_WDF_DPC ViiperEvtCompletionDpc; +EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtVirtualDeviceCleanup; +EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtEndpointCleanup; + +NTSTATUS ViiperCreateQueues(_In_ WDFDEVICE Device); +NTSTATUS ViiperInitializeBroker(_In_ WDFDEVICE Device); +NTSTATUS ViiperCreateVirtualDevice(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); +NTSTATUS ViiperDestroyVirtualDevice(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); +BOOLEAN ViiperDestroyOwnedDevices(_In_ WDFDEVICE Controller, _In_ WDFFILEOBJECT OwnerFile); +VOID ViiperDrainControllerEndpointOperations(_In_ WDFDEVICE Controller); +BOOLEAN ViiperQuiesceResetByIdentity( + _In_ WDFDEVICE Controller, + _In_ ULONGLONG DeviceId, + _In_ ULONG Generation, + _In_ UDECXUSBDEVICE ExpectedDevice, + _In_opt_ UDECXUSBENDPOINT ExpectedEndpoint, + _In_ ULONG ExpectedEndpointGeneration, + _In_ ULONGLONG ExpectedResetEpoch, + _In_ UCHAR EndpointAddress, + _In_ BOOLEAN WholeDevice, + _In_ BOOLEAN ReleaseGate); +VOID ViiperBeginControllerShutdown(_In_ WDFDEVICE Controller); +NTSTATUS ViiperQueueDequeueOperation(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); +NTSTATUS ViiperCompleteOperation(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); +NTSTATUS ViiperQueueUrb(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); +VOID ViiperCompleteUnownedUrb( + _In_ WDFDEVICE Controller, + _In_ WDFREQUEST Request, + _In_ NTSTATUS Status); +_IRQL_requires_max_(DISPATCH_LEVEL) +BOOLEAN ViiperQueueUrbCompletion( + _In_ WDFDEVICE Controller, + _In_ UDECXUSBENDPOINT Endpoint, + _In_ WDFREQUEST Request, + _In_ ULONG PendingSlot, + _In_ ULONGLONG Token, + _In_ NTSTATUS Status, + _In_ USBD_STATUS UsbdStatus, + _In_ BOOLEAN CompleteWithNtStatus, + _In_ ULONG DirectInputBytes, + _In_ ULONGLONG DirectInputSequence); +_IRQL_requires_(PASSIVE_LEVEL) +VOID ViiperDrainUrbCompletions(_In_ WDFDEVICE Controller); +NTSTATUS ViiperSubmitInputReport(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); +NTSTATUS ViiperValidateBrokerOwner(_In_ WDFDEVICE Controller, _In_ WDFREQUEST Request); +PURB ViiperGetUrb(_In_ WDFREQUEST Request); +NTSTATUS ViiperCopyTransferBuffer( + _In_ WDFREQUEST Request, + _In_ PURB Urb, + _Inout_updates_bytes_(Length) UCHAR *Buffer, + _In_ ULONG Length, + _In_ BOOLEAN ToUrb); +VOID ViiperPurgeEndpointOperations(_In_ UDECXUSBENDPOINT Endpoint, _In_ NTSTATUS Status); +VOID ViiperAbortDeviceManagementOperations( + _In_ WDFDEVICE Controller, + _In_ UDECXUSBDEVICE Device, + _In_ NTSTATUS Status); +VOID ViiperRetireManagementTombstonesForOwner( + _In_ WDFDEVICE Controller, + _In_opt_ WDFFILEOBJECT OwnerFile); +_IRQL_requires_max_(DISPATCH_LEVEL) +VOID ViiperEndpointOperationStarted(_In_ UDECXUSBENDPOINT Endpoint); +_IRQL_requires_max_(DISPATCH_LEVEL) +VOID ViiperEndpointOperationCompleted(_In_ UDECXUSBENDPOINT Endpoint); +VOID ViiperPurgeOwnerOperations(_In_ WDFDEVICE Controller, _In_ NTSTATUS Status); +NTSTATUS ViiperInitializeLifecycleTrace(_In_ WDFDEVICE Controller); +VOID ViiperTraceLifecycle( + _In_ WDFDEVICE Controller, + _In_ UCHAR Source, + _In_ USHORT Event, + _In_ ULONGLONG DeviceId, + _In_ ULONG Generation, + _In_opt_ UDECXUSBDEVICE Device, + _In_opt_ UDECXUSBENDPOINT Endpoint, + _In_ UCHAR EndpointAddress, + _In_ NTSTATUS Status, + _In_ LONG ActiveOperations, + _In_ ULONG QueueState, + _In_ ULONG Line); +#define VIIPER_TRACE_LIFECYCLE(Controller, Source, Event, DeviceId, Generation, Device, Endpoint, EndpointAddress, Status, ActiveOperations, QueueState) \ + ViiperTraceLifecycle((Controller), (Source), (Event), (DeviceId), (Generation), \ + (Device), (Endpoint), (EndpointAddress), (Status), (ActiveOperations), \ + (QueueState), __LINE__) +NTSTATUS ViiperQueueEndpointLifecycleEvent( + _In_ UDECXUSBENDPOINT Endpoint, + _In_ VIIPER_UDE_OPERATION_KIND Kind); +NTSTATUS ViiperQueueDeviceLifecycleEvent( + _In_ UDECXUSBDEVICE Device, + _In_ VIIPER_UDE_OPERATION_KIND Kind); +NTSTATUS ViiperQueueAcknowledgedEndpointLifecycleEvent( + _In_ UDECXUSBENDPOINT Endpoint, + _In_ WDFREQUEST Request, + _In_ VIIPER_UDE_OPERATION_KIND Kind); +NTSTATUS ViiperQueueAcknowledgedDeviceLifecycleEvent( + _In_ UDECXUSBDEVICE Device, + _In_ WDFREQUEST Request, + _In_ VIIPER_UDE_OPERATION_KIND Kind); +NTSTATUS ViiperQueueAcknowledgedInterfaceLifecycleEvent( + _In_ UDECXUSBDEVICE Device, + _In_ WDFREQUEST Request, + _In_ UCHAR InterfaceNumber, + _In_ UCHAR InterfaceSetting); +NTSTATUS ViiperQueueInterfaceLifecycleEvent( + _In_ UDECXUSBDEVICE Device, + _In_ UCHAR InterfaceNumber, + _In_ UCHAR InterfaceSetting); diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj new file mode 100644 index 00000000..13b33664 --- /dev/null +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -0,0 +1,121 @@ + + + + + + + Debugx64 + Releasex64 + + + {74754772-2AA1-4CE6-B251-0A3DD40A46E1} + ViiperUde + ViiperUde + 17.0 + x64 + 08/15/2026 + 0.1.0.38 + $(VIIPER_NATIVE_SOURCE_REVISION) + + + + Driver + KMDF + Universal + WindowsKernelModeDriver10.0 + true + Windows10 + true + 1 + 1 + 1 + 27 + Spectre + + + Driver + KMDF + Universal + WindowsKernelModeDriver10.0 + false + Windows10 + true + 1 + 1 + 1 + 27 + Spectre + true + true + + + + + + + + + Level4 + true + stdc17 + $(IntDir);..\include;%(AdditionalIncludeDirectories) + POOL_ZERO_DOWN_LEVEL_SUPPORT;%(PreprocessorDefinitions) + true + ProgramDatabase + false + /Zo %(AdditionalOptions) + + + %(AdditionalDependencies);usbd.lib + /DEBUG:FULL /PDBALTPATH:%_PDB% %(AdditionalOptions) + true + true + true + + + certHash + + + + + + + + + + + + + + + + + true + $(ViiperUdeDriverDate) + true + $(ViiperUdeDriverVersion) + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/native/udecx/driver/packages.config b/native/udecx/driver/packages.config new file mode 100644 index 00000000..c18d5965 --- /dev/null +++ b/native/udecx/driver/packages.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h new file mode 100644 index 00000000..e400e34e --- /dev/null +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -0,0 +1,566 @@ +#pragma once + +#include + +#if defined(_KERNEL_MODE) +#include +#include +typedef UCHAR VIIPER_UDE_UINT8; +typedef USHORT VIIPER_UDE_UINT16; +typedef ULONG VIIPER_UDE_UINT32; +typedef ULONGLONG VIIPER_UDE_UINT64; +typedef LONG VIIPER_UDE_INT32; +#define VIIPER_UDE_UINT16_C(value) value##U +#define VIIPER_UDE_UINT32_C(value) value##UL +#elif defined(_WIN32) +#include +#include +typedef uint8_t VIIPER_UDE_UINT8; +typedef uint16_t VIIPER_UDE_UINT16; +typedef uint32_t VIIPER_UDE_UINT32; +typedef uint64_t VIIPER_UDE_UINT64; +typedef int32_t VIIPER_UDE_INT32; +#define VIIPER_UDE_UINT16_C(value) UINT16_C(value) +#define VIIPER_UDE_UINT32_C(value) UINT32_C(value) +#else +#include +typedef uint8_t VIIPER_UDE_UINT8; +typedef uint16_t VIIPER_UDE_UINT16; +typedef uint32_t VIIPER_UDE_UINT32; +typedef uint64_t VIIPER_UDE_UINT64; +typedef int32_t VIIPER_UDE_INT32; +#define VIIPER_UDE_UINT16_C(value) UINT16_C(value) +#define VIIPER_UDE_UINT32_C(value) UINT32_C(value) +#endif + +#define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ +#define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) +#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(14) +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.38" +#define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) + +/* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ +#define VIIPER_UDE_INTERFACE_GUID_DATA1 VIIPER_UDE_UINT32_C(0x32d03f48) +#define VIIPER_UDE_INTERFACE_GUID_DATA2 VIIPER_UDE_UINT16_C(0x725b) +#define VIIPER_UDE_INTERFACE_GUID_DATA3 VIIPER_UDE_UINT16_C(0x4baa) +#define VIIPER_UDE_INTERFACE_GUID_DATA4_0 0x97 +#define VIIPER_UDE_INTERFACE_GUID_DATA4_1 0x0f +#define VIIPER_UDE_INTERFACE_GUID_DATA4_2 0x7f +#define VIIPER_UDE_INTERFACE_GUID_DATA4_3 0x5d +#define VIIPER_UDE_INTERFACE_GUID_DATA4_4 0xe6 +#define VIIPER_UDE_INTERFACE_GUID_DATA4_5 0xc4 +#define VIIPER_UDE_INTERFACE_GUID_DATA4_6 0x46 +#define VIIPER_UDE_INTERFACE_GUID_DATA4_7 0x87 + +#define VIIPER_UDE_MAX_DEVICES VIIPER_UDE_UINT32_C(32) +#define VIIPER_UDE_MAX_DESCRIPTOR_BYTES VIIPER_UDE_UINT32_C(262144) +#define VIIPER_UDE_MAX_TRANSFER_BYTES VIIPER_UDE_UINT32_C(1048576) +#define VIIPER_UDE_MAX_ISO_PACKETS VIIPER_UDE_UINT32_C(1024) +#define VIIPER_UDE_MAX_INPUT_REPORT_BYTES VIIPER_UDE_UINT32_C(4096) +#define VIIPER_UDE_MAX_PENDING_OPERATIONS VIIPER_UDE_UINT32_C(4096) +#define VIIPER_UDE_MANAGEMENT_SLOT_FLAG VIIPER_UDE_UINT32_C(0x80000000) +#define VIIPER_UDE_INPUT_REPORT_TRANSITION 0x01 + +/* Microsoft OS 1.0 defines this reserved string outside normal LANGID rules. */ +#define VIIPER_UDE_MS_OS_10_STRING_INDEX VIIPER_UDE_UINT16_C(0x00ee) +#define VIIPER_UDE_MS_OS_10_STRING_LENGTH VIIPER_UDE_UINT32_C(18) +#define VIIPER_UDE_MS_OS_10_VENDOR_CODE_OFFSET VIIPER_UDE_UINT32_C(16) + +#define VIIPER_UDE_CAP_ISOCHRONOUS VIIPER_UDE_UINT32_C(0x00000001) +#define VIIPER_UDE_CAP_STREAMS VIIPER_UDE_UINT32_C(0x00000002) +#define VIIPER_UDE_CAP_DEVICE_LIFECYCLE VIIPER_UDE_UINT32_C(0x00000004) +#define VIIPER_UDE_CAP_INPUT_REPORTS VIIPER_UDE_UINT32_C(0x00000008) +#define VIIPER_UDE_CAP_LIFECYCLE_TRACE VIIPER_UDE_UINT32_C(0x00000010) +#define VIIPER_UDE_CAP_DEVICE_CORRELATION VIIPER_UDE_UINT32_C(0x00000020) +#define VIIPER_UDE_ADVERTISED_CAPABILITIES \ + (VIIPER_UDE_CAP_ISOCHRONOUS | VIIPER_UDE_CAP_DEVICE_LIFECYCLE | \ + VIIPER_UDE_CAP_INPUT_REPORTS | VIIPER_UDE_CAP_LIFECYCLE_TRACE | \ + VIIPER_UDE_CAP_DEVICE_CORRELATION) + +#define VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY VIIPER_UDE_UINT32_C(512) + +#define VIIPER_UDE_TRACE_SOURCE_DEVICE 1 +#define VIIPER_UDE_TRACE_SOURCE_BROKER 2 +#define VIIPER_UDE_TRACE_SOURCE_CONTROLLER 3 + +#define VIIPER_UDE_TRACE_CREATE_BEGIN 1 +#define VIIPER_UDE_TRACE_DEVICE_CREATE_RETURNED 2 +#define VIIPER_UDE_TRACE_DEVICE_SLOT_CLAIMED 3 +#define VIIPER_UDE_TRACE_PLUG_IN_BEGIN 4 +#define VIIPER_UDE_TRACE_PLUG_IN_RETURNED 5 +#define VIIPER_UDE_TRACE_REMOVE_CLAIMED 6 +#define VIIPER_UDE_TRACE_MANAGEMENT_ABORT_BEGIN 7 +#define VIIPER_UDE_TRACE_MANAGEMENT_ABORT_END 8 +#define VIIPER_UDE_TRACE_PLUG_OUT_BEGIN 9 +#define VIIPER_UDE_TRACE_PLUG_OUT_RETURNED 10 +#define VIIPER_UDE_TRACE_ENDPOINT_PURGE_BEGIN 11 +#define VIIPER_UDE_TRACE_ENDPOINT_OPERATIONS_PURGED 12 +#define VIIPER_UDE_TRACE_ENDPOINT_QUEUE_PURGE_REQUESTED 13 +#define VIIPER_UDE_TRACE_ENDPOINT_DRIVER_QUIESCENT 14 +#define VIIPER_UDE_TRACE_ENDPOINT_DRAIN_BEGIN 15 +#define VIIPER_UDE_TRACE_ENDPOINT_DRAIN_END 16 +#define VIIPER_UDE_TRACE_ENDPOINT_PURGE_COMPLETE_BEGIN 17 +#define VIIPER_UDE_TRACE_ENDPOINT_PURGE_COMPLETE_END 18 +#define VIIPER_UDE_TRACE_ENDPOINT_CLEANUP_BEGIN 19 +#define VIIPER_UDE_TRACE_ENDPOINT_CLEANUP_END 20 +#define VIIPER_UDE_TRACE_DEVICE_CLEANUP_BEGIN 21 +#define VIIPER_UDE_TRACE_DEVICE_CLEANUP_END 22 +#define VIIPER_UDE_TRACE_CONTROLLER_SHUTDOWN_BEGIN 23 +#define VIIPER_UDE_TRACE_CONTROLLER_SHUTDOWN_END 24 +#define VIIPER_UDE_TRACE_ENDPOINT_QUIESCENCE_WATCHDOG 25 +#define VIIPER_UDE_TRACE_COMPLETION_RUNDOWN_WATCHDOG 26 +#define VIIPER_UDE_TRACE_CONTROLLER_RUNDOWN_WATCHDOG 27 +#define VIIPER_UDE_TRACE_OWNER_RUNDOWN_WATCHDOG 28 + +#define VIIPER_UDE_LIFECYCLE_TRACE_STATUS_DROPPED_RECORD VIIPER_UDE_UINT32_C(0x00000001) +#define VIIPER_UDE_LIFECYCLE_TRACE_STATUS_WATCHDOG_FIRED VIIPER_UDE_UINT32_C(0x00000002) +#define VIIPER_UDE_LIFECYCLE_TRACE_STATUS_VALID_MASK \ + (VIIPER_UDE_LIFECYCLE_TRACE_STATUS_DROPPED_RECORD | \ + VIIPER_UDE_LIFECYCLE_TRACE_STATUS_WATCHDOG_FIRED) + +#if defined(_WIN32) +#define VIIPER_UDE_IOCTL_BASE 0x900 +#define IOCTL_VIIPER_UDE_NEGOTIATE CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 0, METHOD_BUFFERED, FILE_READ_DATA | FILE_WRITE_DATA) +#define IOCTL_VIIPER_UDE_CREATE_DEVICE CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 1, METHOD_BUFFERED, FILE_READ_DATA | FILE_WRITE_DATA) +#define IOCTL_VIIPER_UDE_DESTROY_DEVICE CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 2, METHOD_BUFFERED, FILE_READ_DATA | FILE_WRITE_DATA) +#define IOCTL_VIIPER_UDE_DEQUEUE_OPERATION CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 3, METHOD_OUT_DIRECT, FILE_READ_DATA | FILE_WRITE_DATA) +#define IOCTL_VIIPER_UDE_COMPLETE_OPERATION CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 4, METHOD_IN_DIRECT, FILE_READ_DATA | FILE_WRITE_DATA) +#define IOCTL_VIIPER_UDE_QUERY_STATS CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 5, METHOD_BUFFERED, FILE_READ_DATA) +#define IOCTL_VIIPER_UDE_SUBMIT_INPUT_REPORT CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 6, METHOD_IN_DIRECT, FILE_READ_DATA | FILE_WRITE_DATA) +#define IOCTL_VIIPER_UDE_QUERY_LIFECYCLE_TRACE CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 7, METHOD_BUFFERED, FILE_READ_DATA) +#endif + +#pragma pack(push, 1) + +typedef struct VIIPER_UDE_HEADER { + VIIPER_UDE_UINT32 Magic; + VIIPER_UDE_UINT16 Major; + VIIPER_UDE_UINT16 Minor; + VIIPER_UDE_UINT32 Size; + VIIPER_UDE_UINT32 Flags; +} VIIPER_UDE_HEADER; + +typedef struct VIIPER_UDE_NEGOTIATE_REQUEST { + VIIPER_UDE_HEADER Header; + VIIPER_UDE_UINT64 ClientNonce; + VIIPER_UDE_UINT32 RequestedCapabilities; + VIIPER_UDE_UINT32 Reserved; +} VIIPER_UDE_NEGOTIATE_REQUEST; + +typedef struct VIIPER_UDE_NEGOTIATE_RESPONSE { + VIIPER_UDE_HEADER Header; + VIIPER_UDE_UINT64 ClientNonce; + VIIPER_UDE_UINT64 DriverNonce; + VIIPER_UDE_UINT32 Capabilities; + VIIPER_UDE_UINT32 MaxDevices; + VIIPER_UDE_UINT32 MaxDescriptorBytes; + VIIPER_UDE_UINT32 MaxTransferBytes; + VIIPER_UDE_UINT32 MaxIsoPackets; + VIIPER_UDE_UINT32 MaxPendingOperations; + /* + * SHA-256 of the source/package/ABI/capability build tuple embedded in + * the currently loaded kernel image. This is intentionally returned by + * the driver rather than inferred from an on-disk SYS path. + */ + VIIPER_UDE_UINT8 BuildIdentity[VIIPER_UDE_BUILD_IDENTITY_BYTES]; +} VIIPER_UDE_NEGOTIATE_RESPONSE; + +typedef enum VIIPER_UDE_DESCRIPTOR_KIND { + ViiperUdeDescriptorDevice = 1, + ViiperUdeDescriptorConfiguration = 2, + ViiperUdeDescriptorBos = 3, + ViiperUdeDescriptorString = 4 +} VIIPER_UDE_DESCRIPTOR_KIND; + +typedef struct VIIPER_UDE_DESCRIPTOR_RECORD { + VIIPER_UDE_UINT16 Kind; + VIIPER_UDE_UINT16 Index; + VIIPER_UDE_UINT16 LanguageId; + VIIPER_UDE_UINT16 Reserved; + VIIPER_UDE_UINT32 Offset; + VIIPER_UDE_UINT32 Length; +} VIIPER_UDE_DESCRIPTOR_RECORD; + +typedef struct VIIPER_UDE_CREATE_DEVICE { + VIIPER_UDE_HEADER Header; + VIIPER_UDE_UINT64 DeviceId; + VIIPER_UDE_UINT32 Generation; + VIIPER_UDE_UINT32 Speed; + VIIPER_UDE_UINT32 DescriptorCount; + VIIPER_UDE_UINT32 DescriptorRecordsOffset; + VIIPER_UDE_UINT32 DescriptorDataOffset; + VIIPER_UDE_UINT32 DescriptorDataLength; + VIIPER_UDE_UINT32 MaxPendingOperations; + VIIPER_UDE_UINT32 Reserved; +} VIIPER_UDE_CREATE_DEVICE; + +/* + * Authoritative receipt for a successful UdecxUsbDevicePlugIn call. Exactly + * one port field is nonzero. Returning the actual plug-in options prevents + * user mode from guessing PnP ownership from VID/PID, enumeration order, or a + * reconnect-local stream generation. + */ +typedef struct VIIPER_UDE_CREATE_DEVICE_RESULT { + VIIPER_UDE_HEADER Header; + VIIPER_UDE_UINT64 DeviceId; + VIIPER_UDE_UINT32 Generation; + VIIPER_UDE_UINT32 Speed; + VIIPER_UDE_UINT32 Usb20PortNumber; + VIIPER_UDE_UINT32 Usb30PortNumber; +} VIIPER_UDE_CREATE_DEVICE_RESULT; + +typedef struct VIIPER_UDE_DEVICE_IDENTITY { + VIIPER_UDE_HEADER Header; + VIIPER_UDE_UINT64 DeviceId; + VIIPER_UDE_UINT32 Generation; + VIIPER_UDE_UINT32 Reserved; +} VIIPER_UDE_DEVICE_IDENTITY; + +typedef enum VIIPER_UDE_OPERATION_KIND { + ViiperUdeOperationControl = 1, + ViiperUdeOperationTransfer = 2, + ViiperUdeOperationEndpointStart = 3, + ViiperUdeOperationEndpointPurge = 4, + ViiperUdeOperationEndpointReset = 5, + ViiperUdeOperationDeviceReset = 6, + ViiperUdeOperationSetInterface = 7, + ViiperUdeOperationDeviceD0Entry = 8, + ViiperUdeOperationDeviceD0Exit = 9, + ViiperUdeOperationCancel = 10, + ViiperUdeOperationBrokerFault = 11 +} VIIPER_UDE_OPERATION_KIND; + +typedef struct VIIPER_UDE_ISO_PACKET { + VIIPER_UDE_UINT32 Offset; + VIIPER_UDE_UINT32 Length; + VIIPER_UDE_INT32 Status; + VIIPER_UDE_UINT32 Reserved; +} VIIPER_UDE_ISO_PACKET; + +typedef struct VIIPER_UDE_OPERATION { + VIIPER_UDE_HEADER Header; + VIIPER_UDE_UINT64 Token; + VIIPER_UDE_UINT64 DeviceId; + VIIPER_UDE_UINT32 Generation; + VIIPER_UDE_UINT32 Kind; + VIIPER_UDE_UINT8 EndpointAddress; + VIIPER_UDE_UINT8 Direction; + VIIPER_UDE_UINT8 InterfaceNumber; + VIIPER_UDE_UINT8 InterfaceSetting; + VIIPER_UDE_UINT32 UrbFunction; + VIIPER_UDE_UINT32 TransferFlags; + VIIPER_UDE_UINT32 StartFrame; + VIIPER_UDE_UINT32 IsoPacketCount; + VIIPER_UDE_UINT32 TransferLength; + VIIPER_UDE_UINT32 PayloadOffset; + VIIPER_UDE_UINT32 PayloadLength; + VIIPER_UDE_UINT32 IsoPacketsOffset; + VIIPER_UDE_UINT8 SetupPacket[8]; + VIIPER_UDE_UINT8 EndpointAttributes; + VIIPER_UDE_UINT8 EndpointInterval; + VIIPER_UDE_UINT16 EndpointMaxPacketSize; + VIIPER_UDE_UINT64 EndpointSequence; + VIIPER_UDE_UINT64 DeviceSequence; + /* Immutable incarnation of EndpointAddress within this device generation. */ + VIIPER_UDE_UINT32 EndpointGeneration; +} VIIPER_UDE_OPERATION; + +typedef struct VIIPER_UDE_COMPLETION { + VIIPER_UDE_HEADER Header; + VIIPER_UDE_UINT64 Token; + VIIPER_UDE_UINT64 DeviceId; + VIIPER_UDE_UINT32 Generation; + VIIPER_UDE_INT32 Status; + VIIPER_UDE_UINT32 UsbdStatus; + VIIPER_UDE_UINT32 TransferLength; + VIIPER_UDE_UINT32 IsoPacketCount; + VIIPER_UDE_UINT32 PayloadOffset; + VIIPER_UDE_UINT32 PayloadLength; + VIIPER_UDE_UINT32 IsoPacketsOffset; + VIIPER_UDE_UINT32 EndpointGeneration; + VIIPER_UDE_UINT32 Reserved; +} VIIPER_UDE_COMPLETION; + +typedef struct VIIPER_UDE_INPUT_REPORT { + VIIPER_UDE_HEADER Header; + VIIPER_UDE_UINT64 DeviceId; + VIIPER_UDE_UINT32 Generation; + VIIPER_UDE_UINT8 EndpointAddress; + VIIPER_UDE_UINT8 Flags; + VIIPER_UDE_UINT8 Reserved1[2]; + VIIPER_UDE_UINT32 PayloadOffset; + VIIPER_UDE_UINT32 PayloadLength; + VIIPER_UDE_UINT64 Sequence; + /* Immutable incarnation of EndpointAddress within Generation. */ + VIIPER_UDE_UINT32 EndpointGeneration; +} VIIPER_UDE_INPUT_REPORT; + +typedef struct VIIPER_UDE_STATS { + VIIPER_UDE_HEADER Header; + VIIPER_UDE_UINT64 OperationsDequeued; + VIIPER_UDE_UINT64 OperationsCompleted; + VIIPER_UDE_UINT64 OperationsCancelled; + VIIPER_UDE_UINT64 OperationsPurged; + VIIPER_UDE_UINT64 LateCompletions; + VIIPER_UDE_UINT64 InvalidMessages; + VIIPER_UDE_UINT64 QueueExhaustions; + VIIPER_UDE_UINT64 IsoPackets; + VIIPER_UDE_UINT64 BytesToDevice; + VIIPER_UDE_UINT64 BytesFromDevice; + VIIPER_UDE_UINT64 NotificationEvents; + VIIPER_UDE_UINT64 NotificationEventOverflows; + VIIPER_UDE_UINT32 ActiveDevices; + VIIPER_UDE_UINT32 PendingOperations; + VIIPER_UDE_UINT32 WaitingDequeues; + VIIPER_UDE_UINT32 CleanupRetries; + VIIPER_UDE_UINT64 InputReportsSubmitted; + VIIPER_UDE_UINT64 InputReportsCompleted; + VIIPER_UDE_UINT32 ReservedPorts; + VIIPER_UDE_UINT32 Reserved; +} VIIPER_UDE_STATS; + +typedef struct VIIPER_UDE_LIFECYCLE_TRACE_RECORD { + VIIPER_UDE_UINT64 PublishedSequence; + VIIPER_UDE_UINT64 TimestampQpc; + VIIPER_UDE_UINT64 Caller; + VIIPER_UDE_UINT64 DeviceId; + VIIPER_UDE_UINT64 DeviceObject; + VIIPER_UDE_UINT64 EndpointObject; + VIIPER_UDE_UINT32 Generation; + VIIPER_UDE_UINT32 Line; + VIIPER_UDE_INT32 Status; + VIIPER_UDE_INT32 ActiveOperations; + VIIPER_UDE_INT32 PendingOperations; + VIIPER_UDE_UINT32 QueueState; + VIIPER_UDE_UINT16 Event; + VIIPER_UDE_UINT16 Processor; + VIIPER_UDE_UINT8 Source; + VIIPER_UDE_UINT8 Irql; + VIIPER_UDE_UINT8 EndpointAddress; + VIIPER_UDE_UINT8 Reserved; +} VIIPER_UDE_LIFECYCLE_TRACE_RECORD; + +typedef struct VIIPER_UDE_LIFECYCLE_TRACE { + VIIPER_UDE_HEADER Header; + VIIPER_UDE_UINT64 LatestSequence; + VIIPER_UDE_UINT64 PerformanceFrequency; + VIIPER_UDE_UINT32 RecordCount; + VIIPER_UDE_UINT32 RecordSize; + VIIPER_UDE_UINT32 Capacity; + VIIPER_UDE_UINT32 StatusFlags; + VIIPER_UDE_LIFECYCLE_TRACE_RECORD Records[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY]; +} VIIPER_UDE_LIFECYCLE_TRACE; + +#pragma pack(pop) + +#if defined(__cplusplus) +static_assert(sizeof(VIIPER_UDE_HEADER) == 16, "VIIPER_UDE_HEADER ABI drift"); +static_assert(sizeof(VIIPER_UDE_NEGOTIATE_REQUEST) == 32, "VIIPER_UDE_NEGOTIATE_REQUEST ABI drift"); +static_assert(sizeof(VIIPER_UDE_NEGOTIATE_RESPONSE) == 88, "VIIPER_UDE_NEGOTIATE_RESPONSE ABI drift"); +static_assert(sizeof(VIIPER_UDE_DESCRIPTOR_RECORD) == 16, "VIIPER_UDE_DESCRIPTOR_RECORD ABI drift"); +static_assert(sizeof(VIIPER_UDE_CREATE_DEVICE) == 56, "VIIPER_UDE_CREATE_DEVICE ABI drift"); +static_assert(sizeof(VIIPER_UDE_CREATE_DEVICE_RESULT) == 40, "VIIPER_UDE_CREATE_DEVICE_RESULT ABI drift"); +static_assert(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32, "VIIPER_UDE_DEVICE_IDENTITY ABI drift"); +static_assert(sizeof(VIIPER_UDE_ISO_PACKET) == 16, "VIIPER_UDE_ISO_PACKET ABI drift"); +static_assert(sizeof(VIIPER_UDE_OPERATION) == 108, "VIIPER_UDE_OPERATION ABI drift"); +static_assert(sizeof(VIIPER_UDE_COMPLETION) == 72, "VIIPER_UDE_COMPLETION ABI drift"); +static_assert(sizeof(VIIPER_UDE_INPUT_REPORT) == 52, "VIIPER_UDE_INPUT_REPORT ABI drift"); +static_assert(sizeof(VIIPER_UDE_STATS) == 152, "VIIPER_UDE_STATS ABI drift"); +static_assert(sizeof(VIIPER_UDE_LIFECYCLE_TRACE_RECORD) == 80, "VIIPER_UDE_LIFECYCLE_TRACE_RECORD ABI drift"); +static_assert(sizeof(VIIPER_UDE_LIFECYCLE_TRACE) == 41008, "VIIPER_UDE_LIFECYCLE_TRACE ABI drift"); +#elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L +_Static_assert(sizeof(VIIPER_UDE_HEADER) == 16, "VIIPER_UDE_HEADER ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_NEGOTIATE_REQUEST) == 32, "VIIPER_UDE_NEGOTIATE_REQUEST ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_NEGOTIATE_RESPONSE) == 88, "VIIPER_UDE_NEGOTIATE_RESPONSE ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_DESCRIPTOR_RECORD) == 16, "VIIPER_UDE_DESCRIPTOR_RECORD ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_CREATE_DEVICE) == 56, "VIIPER_UDE_CREATE_DEVICE ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_CREATE_DEVICE_RESULT) == 40, "VIIPER_UDE_CREATE_DEVICE_RESULT ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32, "VIIPER_UDE_DEVICE_IDENTITY ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_ISO_PACKET) == 16, "VIIPER_UDE_ISO_PACKET ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_OPERATION) == 108, "VIIPER_UDE_OPERATION ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_COMPLETION) == 72, "VIIPER_UDE_COMPLETION ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_INPUT_REPORT) == 52, "VIIPER_UDE_INPUT_REPORT ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_STATS) == 152, "VIIPER_UDE_STATS ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_LIFECYCLE_TRACE_RECORD) == 80, "VIIPER_UDE_LIFECYCLE_TRACE_RECORD ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_LIFECYCLE_TRACE) == 41008, "VIIPER_UDE_LIFECYCLE_TRACE ABI drift"); +#endif + +/* + * MSVC compiles the KMDF driver as C without defining __STDC_VERSION__, so + * neither static_assert branch above is guaranteed to run in the production + * driver build. These C89-compatible guards deliberately fail every C/C++ + * compiler if the packed wire ABI drifts. Keep them in addition to the more + * readable assertions above. + */ +typedef char VIIPER_UDE_ABI_HEADER_SIZE[(sizeof(VIIPER_UDE_HEADER) == 16) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_NEGOTIATE_REQUEST_SIZE[(sizeof(VIIPER_UDE_NEGOTIATE_REQUEST) == 32) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_NEGOTIATE_RESPONSE_SIZE[(sizeof(VIIPER_UDE_NEGOTIATE_RESPONSE) == 88) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_DESCRIPTOR_RECORD_SIZE[(sizeof(VIIPER_UDE_DESCRIPTOR_RECORD) == 16) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_CREATE_DEVICE_SIZE[(sizeof(VIIPER_UDE_CREATE_DEVICE) == 56) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_CREATE_DEVICE_RESULT_SIZE[(sizeof(VIIPER_UDE_CREATE_DEVICE_RESULT) == 40) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_DEVICE_IDENTITY_SIZE[(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_ISO_PACKET_SIZE[(sizeof(VIIPER_UDE_ISO_PACKET) == 16) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_OPERATION_SIZE[(sizeof(VIIPER_UDE_OPERATION) == 108) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_COMPLETION_SIZE[(sizeof(VIIPER_UDE_COMPLETION) == 72) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_INPUT_REPORT_SIZE[(sizeof(VIIPER_UDE_INPUT_REPORT) == 52) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_STATS_SIZE[(sizeof(VIIPER_UDE_STATS) == 152) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_LIFECYCLE_TRACE_RECORD_SIZE[(sizeof(VIIPER_UDE_LIFECYCLE_TRACE_RECORD) == 80) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_LIFECYCLE_TRACE_SIZE[(sizeof(VIIPER_UDE_LIFECYCLE_TRACE) == 41008) ? 1 : -1]; + +/* + * A same-size field reorder is just as destructive as a size change but would + * pass the guards above. Pin every cross-language field offset independently + * so the WDK build proves the C layout consumed by the driver is exactly the + * byte layout encoded by Go. + */ +#define VIIPER_UDE_ASSERT_OFFSET(Type, Field, Expected) \ + typedef char VIIPER_UDE_ABI_OFFSET_##Type##_##Field[(offsetof(Type, Field) == (Expected)) ? 1 : -1] + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_HEADER, Magic, 0); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_HEADER, Major, 4); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_HEADER, Minor, 6); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_HEADER, Size, 8); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_HEADER, Flags, 12); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_REQUEST, ClientNonce, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_REQUEST, RequestedCapabilities, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_REQUEST, Reserved, 28); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, ClientNonce, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, DriverNonce, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, Capabilities, 32); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, MaxDevices, 36); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, MaxDescriptorBytes, 40); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, MaxTransferBytes, 44); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, MaxIsoPackets, 48); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, MaxPendingOperations, 52); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, BuildIdentity, 56); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DESCRIPTOR_RECORD, Kind, 0); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DESCRIPTOR_RECORD, Index, 2); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DESCRIPTOR_RECORD, LanguageId, 4); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DESCRIPTOR_RECORD, Reserved, 6); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DESCRIPTOR_RECORD, Offset, 8); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DESCRIPTOR_RECORD, Length, 12); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, DeviceId, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, Generation, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, Speed, 28); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, DescriptorCount, 32); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, DescriptorRecordsOffset, 36); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, DescriptorDataOffset, 40); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, DescriptorDataLength, 44); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, MaxPendingOperations, 48); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, Reserved, 52); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE_RESULT, DeviceId, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE_RESULT, Generation, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE_RESULT, Speed, 28); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE_RESULT, Usb20PortNumber, 32); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE_RESULT, Usb30PortNumber, 36); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DEVICE_IDENTITY, DeviceId, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DEVICE_IDENTITY, Generation, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DEVICE_IDENTITY, Reserved, 28); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_ISO_PACKET, Offset, 0); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_ISO_PACKET, Length, 4); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_ISO_PACKET, Status, 8); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_ISO_PACKET, Reserved, 12); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, Token, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, DeviceId, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, Generation, 32); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, Kind, 36); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, EndpointAddress, 40); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, Direction, 41); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, InterfaceNumber, 42); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, InterfaceSetting, 43); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, UrbFunction, 44); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, TransferFlags, 48); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, StartFrame, 52); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, IsoPacketCount, 56); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, TransferLength, 60); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, PayloadOffset, 64); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, PayloadLength, 68); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, IsoPacketsOffset, 72); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, SetupPacket, 76); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, EndpointAttributes, 84); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, EndpointInterval, 85); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, EndpointMaxPacketSize, 86); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, EndpointSequence, 88); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, DeviceSequence, 96); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, EndpointGeneration, 104); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, Token, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, DeviceId, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, Generation, 32); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, Status, 36); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, UsbdStatus, 40); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, TransferLength, 44); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, IsoPacketCount, 48); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, PayloadOffset, 52); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, PayloadLength, 56); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, IsoPacketsOffset, 60); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, EndpointGeneration, 64); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, Reserved, 68); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, DeviceId, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, Generation, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, EndpointAddress, 28); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, Flags, 29); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, Reserved1, 30); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, PayloadOffset, 32); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, PayloadLength, 36); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, Sequence, 40); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, EndpointGeneration, 48); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, OperationsDequeued, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, OperationsCompleted, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, OperationsCancelled, 32); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, OperationsPurged, 40); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, LateCompletions, 48); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, InvalidMessages, 56); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, QueueExhaustions, 64); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, IsoPackets, 72); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, BytesToDevice, 80); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, BytesFromDevice, 88); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, NotificationEvents, 96); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, NotificationEventOverflows, 104); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, ActiveDevices, 112); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, PendingOperations, 116); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, WaitingDequeues, 120); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, CleanupRetries, 124); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, InputReportsSubmitted, 128); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, InputReportsCompleted, 136); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, ReservedPorts, 144); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, Reserved, 148); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, PublishedSequence, 0); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, TimestampQpc, 8); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, Caller, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, DeviceId, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, DeviceObject, 32); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, EndpointObject, 40); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, Generation, 48); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, Line, 52); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, Status, 56); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, ActiveOperations, 60); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, PendingOperations, 64); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, QueueState, 68); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, Event, 72); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, Processor, 74); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, Source, 76); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, Irql, 77); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, EndpointAddress, 78); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, Reserved, 79); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, LatestSequence, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, PerformanceFrequency, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, RecordCount, 32); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, RecordSize, 36); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, Capacity, 40); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, StatusFlags, 44); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, Records, 48); + +#undef VIIPER_UDE_ASSERT_OFFSET diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf new file mode 100644 index 00000000..ec171222 --- /dev/null +++ b/native/udecx/package/ViiperUde.inf @@ -0,0 +1,49 @@ +[Version] +Signature="$WINDOWS NT$" +Class=USB +ClassGuid={36FC9E60-C465-11CF-8056-444553540000} +Provider=%ProviderName% +CatalogFile=ViiperUde.cat +DriverVer=08/15/2026,0.1.0.38 +PnpLockDown=1 + +[DestinationDirs] +DefaultDestDir=13 + +[SourceDisksNames] +1=%DiskName% + +[SourceDisksFiles] +ViiperUde.sys=1 + +[Manufacturer] +%ProviderName%=Standard,NTamd64.10.0...17763 + +[Standard.NTamd64.10.0...17763] +%DeviceName%=ViiperUde_Install,ROOT\VIIPER\UDE + +[ViiperUde_Install.NT] +CopyFiles=@ViiperUde.sys + +[ViiperUde_Install.NT.Services] +AddService=ViiperUde,0x00000002,ViiperUde_Service + +[ViiperUde_Install.NT.Wdf] +KmdfService=ViiperUde,ViiperUde_Wdf + +[ViiperUde_Service] +DisplayName=%ServiceName% +ServiceType=1 +StartType=3 +ErrorControl=1 +ServiceBinary=%13%\ViiperUde.sys +Dependencies=ucx01000,udecx + +[ViiperUde_Wdf] +KmdfLibraryVersion=$KMDFVERSION$ + +[Strings] +ProviderName="VIIPER Project" +DeviceName="VIIPER Native USB Emulation Controller" +ServiceName="VIIPER Native UdeCx Bus" +DiskName="VIIPER Native UdeCx Installation Media" diff --git a/native/udecx/tools/Copy-ViiperCrashDumps.ps1 b/native/udecx/tools/Copy-ViiperCrashDumps.ps1 new file mode 100644 index 00000000..84fabc1d --- /dev/null +++ b/native/udecx/tools/Copy-ViiperCrashDumps.ps1 @@ -0,0 +1,109 @@ +[CmdletBinding()] +param( + [string]$Destination, + + [ValidateRange(0, 100)] + [int]$MaxMiniDumps = 5, + + [ValidatePattern('^S-1-(?:\d+-){1,14}\d+$')] + [string]$GrantReadToSID, + + [switch]$Force +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = [Security.Principal.WindowsPrincipal]::new($identity) +if (-not $principal.IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Crash-dump collection requires an elevated PowerShell session.' +} + +if ([string]::IsNullOrWhiteSpace($Destination)) { + $Destination = Join-Path $env:ProgramData 'Viiper\crash-dumps' +} +$destinationPath = [IO.Path]::GetFullPath($Destination) +$windowsPath = [IO.Path]::GetFullPath($env:SystemRoot).TrimEnd('\') +if ($destinationPath.TrimEnd('\') -eq $windowsPath -or + $destinationPath.StartsWith("$windowsPath\System32", + [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing unsafe crash-dump destination '$destinationPath'." +} + +$sources = @() +$memoryDump = Join-Path $env:SystemRoot 'MEMORY.DMP' +if (Test-Path -LiteralPath $memoryDump -PathType Leaf) { + $sources += Get-Item -LiteralPath $memoryDump +} +if ($MaxMiniDumps -gt 0) { + $miniDumpDirectory = Join-Path $env:SystemRoot 'Minidump' + if (Test-Path -LiteralPath $miniDumpDirectory -PathType Container) { + $sources += @(Get-ChildItem -LiteralPath $miniDumpDirectory -File -Filter '*.dmp' | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First $MaxMiniDumps) + } +} +if ($sources.Count -eq 0) { + throw 'Windows has no MEMORY.DMP or minidump files to collect.' +} + +$requiredBytes = [uint64](($sources | Measure-Object Length -Sum).Sum) +$destinationRoot = [IO.Path]::GetPathRoot($destinationPath) +$drive = Get-CimInstance Win32_LogicalDisk -Filter ` + "DeviceID='$($destinationRoot.TrimEnd('\'))'" -ErrorAction Stop +$safetyBytes = 2GB +if ([uint64]$drive.FreeSpace -lt ($requiredBytes + $safetyBytes)) { + throw "Destination volume needs $([Math]::Ceiling(($requiredBytes + $safetyBytes) / 1GB)) GB free to preserve dumps with safety headroom." +} + +New-Item -ItemType Directory -Path $destinationPath -Force | Out-Null +$manifestFiles = @() +foreach ($source in $sources) { + $destinationFile = Join-Path $destinationPath $source.Name + if (Test-Path -LiteralPath $destinationFile -PathType Leaf) { + $sourceHash = (Get-FileHash -LiteralPath $source.FullName -Algorithm SHA256).Hash + $destinationHash = (Get-FileHash -LiteralPath $destinationFile -Algorithm SHA256).Hash + if ($sourceHash -eq $destinationHash) { + $copied = Get-Item -LiteralPath $destinationFile + } + elseif (-not $Force) { + throw "Destination '$destinationFile' exists with different content; pass -Force to replace it." + } + else { + Copy-Item -LiteralPath $source.FullName -Destination $destinationFile -Force + $copied = Get-Item -LiteralPath $destinationFile + } + } + else { + Copy-Item -LiteralPath $source.FullName -Destination $destinationFile + $copied = Get-Item -LiteralPath $destinationFile + } + $manifestFiles += [ordered]@{ + name = $copied.Name + source = $source.FullName + length = [uint64]$copied.Length + lastWriteUtc = $copied.LastWriteTimeUtc.ToString('o') + sha256 = (Get-FileHash -LiteralPath $copied.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } +} + +if (-not [string]::IsNullOrWhiteSpace($GrantReadToSID)) { + $aclOutput = (& icacls.exe $destinationPath /grant "*$GrantReadToSID`:(OI)(CI)RX" /T /C 2>&1 | Out-String) + if ($LASTEXITCODE -ne 0) { + throw "Could not grant dump-directory read access to '$GrantReadToSID'.`n$aclOutput" + } +} + +$manifest = [ordered]@{ + schema = 1 + machine = $env:COMPUTERNAME + collectedUtc = [DateTime]::UtcNow.ToString('o') + files = $manifestFiles +} +$manifestPath = Join-Path $destinationPath 'crash-dumps.json' +[IO.File]::WriteAllText($manifestPath, ($manifest | ConvertTo-Json -Depth 6), + [Text.UTF8Encoding]::new($false)) +Write-Host "Collected $($manifestFiles.Count) crash dump(s) in '$destinationPath'." +Write-Host "Manifest: $manifestPath" diff --git a/native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1 b/native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1 new file mode 100644 index 00000000..ebec69af --- /dev/null +++ b/native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1 @@ -0,0 +1,117 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$SignedPackageDirectory, + + [Parameter(Mandatory = $true)] + [string]$SubmissionManifestPath, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] + [string]$ExpectedSourceRevision, + + [ValidateSet('LocalTest', 'ControlledTest', 'Production')] + [string]$SignatureValidationMode = 'Production', + + [string]$LocalTestCertificatePath, + + [switch]$DisposableTestMachine +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-DriverImagePath { + param([Parameter(Mandatory = $true)][string]$ImagePath) + + $path = [Environment]::ExpandEnvironmentVariables($ImagePath.Trim().Trim('"')) + if ($path.StartsWith('\??\', [StringComparison]::Ordinal)) { + $path = $path.Substring(4) + } + if ($path.StartsWith('\SystemRoot\', [StringComparison]::OrdinalIgnoreCase)) { + $path = Join-Path $env:SystemRoot $path.Substring('\SystemRoot\'.Length) + } + elseif ($path.StartsWith('System32\', [StringComparison]::OrdinalIgnoreCase)) { + $path = Join-Path $env:SystemRoot $path + } + if (-not [IO.Path]::IsPathRooted($path)) { + throw "VIIPER UDE has an unsupported relative service image path: '$ImagePath'." + } + return (Resolve-Path -LiteralPath $path -ErrorAction Stop).Path +} + +if (-not $DisposableTestMachine) { + throw 'Driver Verifier can deliberately crash Windows. Run this only on a disposable test machine and pass -DisposableTestMachine.' +} + +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = [Security.Principal.WindowsPrincipal]::new($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Driver Verifier configuration requires an elevated PowerShell session.' +} + +$signatureGate = Join-Path $PSScriptRoot 'Test-ViiperUdeSignedPackage.ps1' +& $signatureGate ` + -PackageDirectory $SignedPackageDirectory ` + -SubmissionManifestPath $SubmissionManifestPath ` + -ExpectedSourceRevision $ExpectedSourceRevision ` + -ValidationMode $SignatureValidationMode ` + -LocalTestCertificatePath $LocalTestCertificatePath + +$packageRoot = (Resolve-Path -LiteralPath $SignedPackageDirectory -ErrorAction Stop).Path +$packageDrivers = @(Get-ChildItem -LiteralPath $packageRoot -Recurse -File -Filter 'ViiperUde.sys') +if ($packageDrivers.Count -ne 1) { + throw "Expected exactly one signed package driver; found $($packageDrivers.Count)." +} + +$service = Get-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Services\ViiperUde' -ErrorAction Stop +if ([string]::IsNullOrWhiteSpace([string]$service.ImagePath)) { + throw 'The installed VIIPER UDE service has no ImagePath.' +} +$installedDriver = Resolve-DriverImagePath -ImagePath ([string]$service.ImagePath) +$packageHash = (Get-FileHash -LiteralPath $packageDrivers[0].FullName -Algorithm SHA256).Hash +$installedHash = (Get-FileHash -LiteralPath $installedDriver -Algorithm SHA256).Hash +if ($packageHash -ne $installedHash) { + throw "The installed VIIPER UDE driver does not match the verified source-bound package. Installed='$installedDriver'." +} + +$existingOutput = (& verifier.exe /querysettings 2>&1 | Out-String) +if ($LASTEXITCODE -notin @(0, 2)) { + throw "Could not inspect existing Driver Verifier settings (exit $LASTEXITCODE).`n$existingOutput" +} +$configuredDrivers = @([regex]::Matches($existingOutput, '(?im)\b[^\s\\/:*?"<>|]+\.sys\b') | + ForEach-Object { $_.Value } | + Sort-Object -Unique) +$foreignDrivers = @($configuredDrivers | Where-Object { $_ -ine 'ViiperUde.sys' }) +if ($foreignDrivers.Count -gt 0) { + throw "Refusing to replace an existing Driver Verifier configuration for: $($foreignDrivers -join ', '). Reset or preserve it manually first." +} + +$configured = $false +try { + $standardOutput = (& verifier.exe /standard /driver ViiperUde.sys 2>&1 | Out-String) + if ($LASTEXITCODE -notin @(0, 2)) { + throw "Could not configure standard Driver Verifier checks (exit $LASTEXITCODE).`n$standardOutput" + } + $configured = $true + + $bootOutput = (& verifier.exe /bootmode oneboot 2>&1 | Out-String) + if ($LASTEXITCODE -notin @(0, 2)) { + throw "Could not constrain Driver Verifier to one boot (exit $LASTEXITCODE).`n$bootOutput" + } + + $queryOutput = (& verifier.exe /querysettings 2>&1 | Out-String) + if (($LASTEXITCODE -notin @(0, 2)) -or $queryOutput -notmatch '(?im)\bViiperUde\.sys\b') { + throw "Driver Verifier did not report ViiperUde.sys in its next-boot configuration.`n$queryOutput" + } +} +catch { + if ($configured -and $foreignDrivers.Count -eq 0) { + & verifier.exe /reset 2>&1 | Out-Null + } + throw +} + +Write-Host 'Driver Verifier standard checks are staged for ViiperUde.sys for the next boot only.' +Write-Host 'Restart this disposable test machine, then run Invoke-ViiperUdeLiveValidation.ps1 with -RequireDriverVerifier.' +Write-Host 'Recovery if needed: start Windows Safe Mode, run "verifier.exe /reset" as administrator, and restart.' diff --git a/native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 b/native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 new file mode 100644 index 00000000..549c982b --- /dev/null +++ b/native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 @@ -0,0 +1,65 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] + [string]$SourceRevision, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^\d+\.\d+\.\d+\.\d+$')] + [string]$DriverPackageVersion, + + [Parameter(Mandatory = $true)] + [ValidateRange(1, 65535)] + [int]$ABIMajor, + + [Parameter(Mandatory = $true)] + [ValidateRange(0, 65535)] + [int]$ABIMinor, + + [Parameter(Mandatory = $true)] + [ValidateRange(1, [uint32]::MaxValue)] + [uint32]$Capabilities, + + [string]$OutputHeaderPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# This exact ASCII/UTF-8 preimage is a cross-language protocol. Keep it in +# lockstep with udecx.DeriveBuildIdentity and ViiperUdeCtl's derivation. +$preimage = "VIIPER-UDE-BUILD-IDENTITY/v1`n" + + "sourceRevision=$($SourceRevision.ToLowerInvariant())`n" + + "driverPackageVersion=$DriverPackageVersion`n" + + "abi=$ABIMajor.$ABIMinor`n" + + ("capabilities=0x{0:x8}`n" -f $Capabilities) +$bytes = [Text.UTF8Encoding]::new($false).GetBytes($preimage) +$sha256 = [Security.Cryptography.SHA256]::Create() +try { + $digest = $sha256.ComputeHash($bytes) +} +finally { + $sha256.Dispose() +} +$hex = ([BitConverter]::ToString($digest)).Replace('-', '').ToLowerInvariant() + +if (-not [string]::IsNullOrWhiteSpace($OutputHeaderPath)) { + $fullPath = [IO.Path]::GetFullPath($OutputHeaderPath) + $directory = [IO.Path]::GetDirectoryName($fullPath) + if ([string]::IsNullOrWhiteSpace($directory)) { + throw 'OutputHeaderPath must include a directory.' + } + [IO.Directory]::CreateDirectory($directory) | Out-Null + $initializer = ($digest | ForEach-Object { '0x{0:x2}' -f $_ }) -join ', ' + $header = @" +#pragma once + +/* Generated from an explicit source/package/ABI/capability tuple. */ +static const VIIPER_UDE_UINT8 ViiperUdeBuildIdentity[VIIPER_UDE_BUILD_IDENTITY_BYTES] = { + $initializer +}; +"@ + [IO.File]::WriteAllText($fullPath, $header, [Text.UTF8Encoding]::new($false)) +} + +$hex diff --git a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 new file mode 100644 index 00000000..29cdda24 --- /dev/null +++ b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 @@ -0,0 +1,916 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$PackageRoot, + [Parameter(Mandatory = $true)] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] + [string]$ExpectedSourceRevision, + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{64}$')] + [string]$ExpectedPackageLockSHA256, + [Parameter(Mandatory = $true)] + [ValidatePattern('^S-1-5-21-(?:[0-9]+-){3}[0-9]+$')] + [string]$TargetUserSID, + [switch]$AcknowledgeDisposableTestMachine, + [switch]$PreflightOnly +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$installerScriptPath = (Resolve-Path -LiteralPath $PSCommandPath -ErrorAction Stop).Path +$installerScriptItem = Get-Item -LiteralPath $installerScriptPath -Force -ErrorAction Stop +if ($installerScriptItem.PSIsContainer -or + ($installerScriptItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'The local-test installer must be a regular non-reparse file.' +} +$installerScriptStream = [IO.FileStream]::new( + $installerScriptPath, [IO.FileMode]::Open, [IO.FileAccess]::Read, + [IO.FileShare]::Read) +try { +$installerScriptAlgorithm = [Security.Cryptography.SHA256]::Create() +try { + $actualInstallerScriptSha256 = ([BitConverter]::ToString( + $installerScriptAlgorithm.ComputeHash($installerScriptStream))).Replace('-', '').ToLowerInvariant() +} +finally { + $installerScriptAlgorithm.Dispose() +} + +if (-not $AcknowledgeDisposableTestMachine) { + throw 'Local test driver installation is for a disposable test machine only. Pass -AcknowledgeDisposableTestMachine.' +} +$source = $ExpectedSourceRevision.ToLowerInvariant() +$expectedPackageLockSha256 = $ExpectedPackageLockSHA256.ToLowerInvariant() +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = [Security.Principal.WindowsPrincipal]::new($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Local VIIPER driver installation requires an elevated PowerShell session.' +} +if (-not $PreflightOnly) { + $bcdeditPath = Join-Path ([Environment]::SystemDirectory) 'bcdedit.exe' + $bcdOutput = (& $bcdeditPath /enum '{current}' 2>&1 | Out-String) + if ($LASTEXITCODE -ne 0 -or $bcdOutput -notmatch '(?im)^\s*testsigning\s+Yes\s*$') { + throw "The current boot entry does not report 'testsigning Yes'. Enable TESTSIGNING and reboot before installation.`n$bcdOutput" + } +} + +$root = (Resolve-Path -LiteralPath $PackageRoot -ErrorAction Stop).Path +$lockPath = Join-Path $root 'local-test-package.lock.json' +$manifestPath = Join-Path $root 'submission-manifest.json' +$certificatePath = Join-Path $root 'ViiperUdeTest.cer' +$helperPath = Join-Path $root 'ViiperUdeCtl.exe' +$packageBrokerPath = Join-Path $root 'viiper.exe' +$signedPackage = Join-Path $root 'signed-package' +$driverDirectory = Join-Path $root 'driver' + +function Assert-ExactDirectoryEntries { + param( + [Parameter(Mandatory = $true)][string]$Directory, + [Parameter(Mandatory = $true)][string[]]$Expected + ) + + $directoryItem = Get-Item -LiteralPath $Directory -Force -ErrorAction Stop + if (-not $directoryItem.PSIsContainer -or + ($directoryItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Local test package directory is missing or unsafe: '$Directory'." + } + $actual = @(Get-ChildItem -LiteralPath $Directory -Force | + ForEach-Object Name | Sort-Object -CaseSensitive) + $wanted = @($Expected | Sort-Object -CaseSensitive) + if ($actual.Count -ne $wanted.Count -or + @(Compare-Object -ReferenceObject $wanted -DifferenceObject $actual -CaseSensitive).Count -ne 0) { + throw "Local test package directory has missing, extra, or case-mismatched entries: '$Directory'." + } +} + +function Assert-ProtectedStagingDirectory { + param([Parameter(Mandatory = $true)][string]$Path) + + $directory = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (-not $directory.PSIsContainer -or + ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Local-test staging directory is missing, not a directory, or a reparse point: '$Path'." + } + $actualSecurity = $directory.GetAccessControl( + [Security.AccessControl.AccessControlSections]::Owner -bor + [Security.AccessControl.AccessControlSections]::Access) + if (-not $actualSecurity.AreAccessRulesProtected) { + throw "Local-test staging directory inherited an unsafe DACL for '$Path'." + } + $owner = $actualSecurity.GetOwner([Security.Principal.SecurityIdentifier]) + if ($owner.Value -cne 'S-1-5-32-544') { + throw "Local-test staging directory has an unexpected owner for '$Path'." + } + $rules = @($actualSecurity.GetAccessRules( + $true, $true, [Security.Principal.SecurityIdentifier])) + if ($rules.Count -ne 2) { + throw "Local-test staging directory has an unexpected access-rule count for '$Path'." + } + $expectedInheritance = + [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor + [Security.AccessControl.InheritanceFlags]::ObjectInherit + foreach ($expectedSID in @('S-1-5-18', 'S-1-5-32-544')) { + $matches = @($rules | Where-Object { + $_.IdentityReference.Value -ceq $expectedSID + }) + if ($matches.Count -ne 1) { + throw "Local-test staging directory is missing an exact protected principal for '$Path'." + } + $rule = $matches[0] + if ($rule.IsInherited -or + $rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + $rule.FileSystemRights -ne [Security.AccessControl.FileSystemRights]::FullControl -or + $rule.InheritanceFlags -ne $expectedInheritance -or + $rule.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None) { + throw "Local-test staging directory has an unexpected access rule for '$Path'." + } + } +} + +function Initialize-ProtectedStagingDirectory { + param([Parameter(Mandatory = $true)][string]$Path) + + if (Test-Path -LiteralPath $Path) { + throw "Refusing to reuse local-test staging directory '$Path'." + } + $expectedSecurity = [Security.AccessControl.DirectorySecurity]::new() + $expectedSecurity.SetSecurityDescriptorSddlForm( + 'O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)', + [Security.AccessControl.AccessControlSections]::All) + $directory = [IO.Directory]::CreateDirectory($Path, $expectedSecurity) + if (($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Local-test staging directory is a reparse point: '$Path'." + } + $directory.SetAccessControl($expectedSecurity) + Assert-ProtectedStagingDirectory -Path $Path +} + +function Copy-ExactBrokerToProtectedStage { + param( + [Parameter(Mandatory = $true)][string]$SourcePath, + [Parameter(Mandatory = $true)][string]$DestinationDirectory, + [Parameter(Mandatory = $true)][long]$ExpectedLength, + [Parameter(Mandatory = $true)][string]$ExpectedSHA256 + ) + + $destinationPath = Join-Path $DestinationDirectory 'viiper.exe' + $sourceStream = [IO.FileStream]::new( + $SourcePath, [IO.FileMode]::Open, [IO.FileAccess]::Read, + [IO.FileShare]::Read) + try { + if ($sourceStream.Length -ne $ExpectedLength) { + throw 'The broker changed before protected staging.' + } + $sourceAlgorithm = [Security.Cryptography.SHA256]::Create() + try { + $sourceDigest = ([BitConverter]::ToString( + $sourceAlgorithm.ComputeHash($sourceStream))).Replace('-', '').ToLowerInvariant() + } + finally { + $sourceAlgorithm.Dispose() + } + if ($sourceDigest -cne $ExpectedSHA256) { + throw 'The broker changed before protected staging.' + } + $sourceStream.Position = 0 + $destinationStream = [IO.FileStream]::new( + $destinationPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, + [IO.FileShare]::None, 1MB, [IO.FileOptions]::WriteThrough) + try { + $sourceStream.CopyTo($destinationStream) + $destinationStream.Flush($true) + } + finally { + $destinationStream.Dispose() + } + } + finally { + $sourceStream.Dispose() + } + $staged = Get-Item -LiteralPath $destinationPath -Force -ErrorAction Stop + if ($staged.PSIsContainer -or + ($staged.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $staged.Length -ne $ExpectedLength -or + (Get-FileHash -LiteralPath $destinationPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne + $ExpectedSHA256) { + throw 'The protected staged broker failed exact verification.' + } + return $destinationPath +} + +function Remove-ProtectedStagingDirectory { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ProgramDataRoot + ) + + if (-not (Test-Path -LiteralPath $Path)) { + return + } + $fullPath = [IO.Path]::GetFullPath($Path) + $expectedParent = [IO.Path]::GetFullPath($ProgramDataRoot).TrimEnd( + [IO.Path]::DirectorySeparatorChar) + if ([IO.Path]::GetDirectoryName($fullPath) -cne $expectedParent -or + [IO.Path]::GetFileName($fullPath) -notmatch '^VIIPER\.LocalTestStage\.[0-9a-f]{32}$') { + throw "Refusing unsafe local-test staging cleanup '$Path'." + } + Assert-ProtectedStagingDirectory -Path $fullPath + $children = @(Get-ChildItem -LiteralPath $fullPath -Force) + if ($children.Count -gt 1 -or + ($children.Count -eq 1 -and + ($children[0].Name -cne 'viiper.exe' -or $children[0].PSIsContainer -or + ($children[0].Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0))) { + throw "Refusing local-test staging cleanup with unexpected entries in '$Path'." + } + if ($children.Count -eq 1) { + [IO.File]::Delete($children[0].FullName) + } + [IO.Directory]::Delete($fullPath, $false) +} + +if (-not ('ViiperWindowsUptime' -as [type])) { + Add-Type -Language CSharp -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +public static class ViiperWindowsUptime +{ + [DllImport("kernel32.dll", ExactSpelling = true)] + public static extern ulong GetTickCount64(); +} +'@ +} + +$uptimeMethod = [ViiperWindowsUptime].GetMethod( + 'GetTickCount64', [Reflection.BindingFlags]'Public,Static') +$uptimeImport = $uptimeMethod.GetCustomAttributes( + [Runtime.InteropServices.DllImportAttribute], $false)[0] +if ($uptimeMethod.ReturnType -ne [uint64] -or + $uptimeImport.Value -cne 'kernel32.dll' -or + -not $uptimeImport.ExactSpelling) { + throw 'The local-test installer does not bind the exact Windows uptime API.' +} + +function Get-WindowsBootBoundaryUtc { + # Windows PowerShell 5.1 runs on .NET Framework, whose Environment type + # does not expose TickCount64. Bind the native 64-bit uptime API directly + # so long-running systems cannot suffer Environment.TickCount wraparound. + $uptimeMilliseconds = [ViiperWindowsUptime]::GetTickCount64() + return [DateTime]::UtcNow.Subtract( + [TimeSpan]::FromMilliseconds([double]$uptimeMilliseconds)) +} + +function Remove-PreBootProtectedStagingDirectories { + param([Parameter(Mandatory = $true)][string]$ProgramDataRoot) + + # A live sibling installer can own a same-boot staging directory before it + # acquires the nested package mutex. Only reclaim exact protected stages + # which predate this boot; Windows already terminated every possible owner. + $bootBoundaryUtc = Get-WindowsBootBoundaryUtc + $candidates = @(Get-ChildItem -LiteralPath $ProgramDataRoot -Force -Directory | + Where-Object { + $_.Name -match '^VIIPER\.LocalTestStage\.[0-9a-f]{32}$' -and + $_.LastWriteTimeUtc -lt $bootBoundaryUtc + }) + foreach ($candidate in $candidates) { + Remove-ProtectedStagingDirectory ` + -Path $candidate.FullName -ProgramDataRoot $ProgramDataRoot + Write-Host "local-test-stage action=cleanup result=removed path=$($candidate.Name)" + } +} + +function ConvertTo-WindowsProcessArgument { + param([AllowEmptyString()][Parameter(Mandatory = $true)][string]$Value) + + if ($Value.IndexOf([char]0) -ge 0) { + throw 'Native process argument contains NUL.' + } + if ($Value.Length -ne 0 -and $Value -notmatch '[\s"]') { + return $Value + } + $builder = [Text.StringBuilder]::new() + [void]$builder.Append([char]34) + $slashes = 0 + foreach ($character in $Value.ToCharArray()) { + if ($character -eq [char]92) { + ++$slashes + continue + } + if ($character -eq [char]34) { + [void]$builder.Append([char]92, (2 * $slashes) + 1) + [void]$builder.Append([char]34) + $slashes = 0 + continue + } + if ($slashes -ne 0) { + [void]$builder.Append([char]92, $slashes) + $slashes = 0 + } + [void]$builder.Append($character) + } + if ($slashes -ne 0) { + [void]$builder.Append([char]92, 2 * $slashes) + } + [void]$builder.Append([char]34) + return $builder.ToString() +} + +function Set-ExactProcessArguments { + param( + [Parameter(Mandatory = $true)][Diagnostics.ProcessStartInfo]$StartInfo, + [Parameter(Mandatory = $true)][string[]]$Arguments + ) + + if ($null -ne $StartInfo.PSObject.Properties['ArgumentList']) { + foreach ($argument in $Arguments) { + $StartInfo.ArgumentList.Add($argument) + } + return + } + $StartInfo.Arguments = (($Arguments | ForEach-Object { + ConvertTo-WindowsProcessArgument -Value $_ + }) -join ' ') +} + +function Invoke-JoinedNativeProcess { + param( + [Parameter(Mandatory = $true)][string]$FileName, + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$WorkingDirectory, + [Parameter(Mandatory = $true)][ref]$Started + ) + + $Started.Value = $false + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $FileName + $startInfo.WorkingDirectory = $WorkingDirectory + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + Set-ExactProcessArguments -StartInfo $startInfo -Arguments $Arguments + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + $joined = $false + try { + if (-not $process.Start()) { + throw 'The protected native broker process was not created.' + } + $Started.Value = $true + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + while (-not $joined) { + try { + $process.WaitForExit() + $joined = $true + } + catch { + # Never unwind while the exact mutating child may remain alive. + Start-Sleep -Milliseconds 250 + } + } + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + $combined = @($stdout, $stderr) -join [Environment]::NewLine + return [pscustomobject]@{ + ExitCode = $process.ExitCode + Output = @($combined -split '\r?\n' | Where-Object { $_.Length -ne 0 }) + } + } + finally { + if ($Started.Value -and -not $joined) { + while (-not $joined) { + try { + $process.WaitForExit() + $joined = $true + } + catch { + Start-Sleep -Milliseconds 250 + } + } + } + $process.Dispose() + } +} + +Assert-ExactDirectoryEntries $root @( + 'viiper.exe', 'ViiperUdeCtl.exe', 'ViiperUdeMediaProbe.exe', 'ViiperUdeInputProbe.exe', + 'ViiperUdeLiveProbes.manifest.json', 'ViiperUdeTest.cer', + 'submission-manifest.json', 'local-test-package.lock.json', + 'driver', 'signed-package' +) +Assert-ExactDirectoryEntries $driverDirectory @( + 'ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.cat' +) +Assert-ExactDirectoryEntries $signedPackage @( + 'ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.pdb', 'ViiperUde.cat' +) + +$lockBytes = [IO.File]::ReadAllBytes($lockPath) +$lockAlgorithm = [Security.Cryptography.SHA256]::Create() +try { + $actualPackageLockSha256 = ([BitConverter]::ToString( + $lockAlgorithm.ComputeHash($lockBytes))).Replace('-', '').ToLowerInvariant() +} +finally { + $lockAlgorithm.Dispose() +} +if ($actualPackageLockSha256 -cne $expectedPackageLockSha256) { + throw 'The local test package lock does not match the out-of-band workflow digest.' +} +$strictUtf8 = [Text.UTF8Encoding]::new($false, $true) +$lock = $strictUtf8.GetString($lockBytes) | ConvertFrom-Json -ErrorAction Stop +if ([int]$lock.schema -ne 1 -or [string]$lock.sourceRevision -cne $source -or + [string]$lock.driverBuildIdentity -notmatch '^[0-9a-f]{64}$' -or + [string]$lock.testSignerCertificateSha256 -notmatch '^[0-9a-f]{64}$' -or + [string]$lock.installerScriptSha256 -notmatch '^[0-9a-f]{64}$' -or + [string]$lock.installerScriptSha256 -cne $actualInstallerScriptSha256) { + throw 'The local test package lock does not match the requested source or schema.' +} + +$expectedPaths = @( + 'viiper.exe', 'ViiperUdeCtl.exe', 'ViiperUdeMediaProbe.exe', 'ViiperUdeInputProbe.exe', + 'ViiperUdeLiveProbes.manifest.json', 'ViiperUdeTest.cer', + 'submission-manifest.json', + 'driver/ViiperUde.inf', 'driver/ViiperUde.sys', 'driver/ViiperUde.cat', + 'signed-package/ViiperUde.inf', 'signed-package/ViiperUde.sys', + 'signed-package/ViiperUde.pdb', 'signed-package/ViiperUde.cat' +) +$entries = @($lock.files) +if ($entries.Count -ne $expectedPaths.Count) { + throw 'The local test package lock has an incomplete or extra file list.' +} +$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +$lockByPath = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) +foreach ($entry in $entries) { + $relative = [string]$entry.path + if ($expectedPaths -cnotcontains $relative -or -not $seen.Add($relative) -or + [long]$entry.length -le 0 -or [string]$entry.sha256 -notmatch '^[0-9a-f]{64}$') { + throw "The local test package lock contains an invalid entry '$relative'." + } + $lockByPath.Add($relative, $entry) + $path = Join-Path $root $relative.Replace('/', [IO.Path]::DirectorySeparatorChar) + $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop + if ($item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -ne [long]$entry.length -or + (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -cne + [string]$entry.sha256) { + throw "Local test package file validation failed for '$relative'." + } +} + +$certificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new($certificatePath) +$algorithm = [Security.Cryptography.SHA256]::Create() +try { + $certificateSha256 = ([BitConverter]::ToString( + $algorithm.ComputeHash($certificate.RawData))).Replace('-', '').ToLowerInvariant() +} +finally { + $algorithm.Dispose() +} +if ($certificateSha256 -cne [string]$lock.testSignerCertificateSha256) { + throw 'The local test certificate does not match the source-bound package lock.' +} + +if ($PreflightOnly) { + $brokerEntry = $lockByPath['viiper.exe'] + $brokerHash = [string]$brokerEntry.sha256 + $preflightProgramDataRoot = (Resolve-Path -LiteralPath $env:ProgramData -ErrorAction Stop).Path + $programDataItem = Get-Item -LiteralPath $preflightProgramDataRoot -Force -ErrorAction Stop + if (-not $programDataItem.PSIsContainer -or + ($programDataItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "ProgramData is not a safe staging parent: '$preflightProgramDataRoot'." + } + # Exercise both sides of the reboot-boundary cleanup under the exact + # inbox Windows PowerShell host used for installation. This regression + # path must execute before an artifact can be published. + $preflightOldStage = Join-Path $preflightProgramDataRoot ( + 'VIIPER.LocalTestStage.' + [Guid]::NewGuid().ToString('N')) + $preflightCurrentStage = Join-Path $preflightProgramDataRoot ( + 'VIIPER.LocalTestStage.' + [Guid]::NewGuid().ToString('N')) + try { + Initialize-ProtectedStagingDirectory -Path $preflightOldStage + Initialize-ProtectedStagingDirectory -Path $preflightCurrentStage + $preflightBootBoundaryUtc = Get-WindowsBootBoundaryUtc + [IO.Directory]::SetLastWriteTimeUtc( + $preflightOldStage, $preflightBootBoundaryUtc.AddSeconds(-1)) + [IO.Directory]::SetLastWriteTimeUtc( + $preflightCurrentStage, $preflightBootBoundaryUtc.AddSeconds(1)) + Remove-PreBootProtectedStagingDirectories ` + -ProgramDataRoot $preflightProgramDataRoot + if (Test-Path -LiteralPath $preflightOldStage) { + throw 'Pre-boot protected staging cleanup did not remove its test directory.' + } + if (-not (Test-Path -LiteralPath $preflightCurrentStage)) { + throw 'Pre-boot protected staging cleanup removed a same-boot test directory.' + } + } + finally { + foreach ($preflightCleanupStage in @( + $preflightOldStage, $preflightCurrentStage)) { + if (Test-Path -LiteralPath $preflightCleanupStage) { + Remove-ProtectedStagingDirectory ` + -Path $preflightCleanupStage ` + -ProgramDataRoot $preflightProgramDataRoot + } + } + } + + $preflightStage = Join-Path $preflightProgramDataRoot ( + 'VIIPER.LocalTestStage.' + [Guid]::NewGuid().ToString('N')) + try { + Initialize-ProtectedStagingDirectory -Path $preflightStage + [void](Copy-ExactBrokerToProtectedStage ` + -SourcePath $packageBrokerPath -DestinationDirectory $preflightStage ` + -ExpectedLength ([long]$brokerEntry.length) -ExpectedSHA256 $brokerHash) + } + finally { + if (Test-Path -LiteralPath $preflightStage) { + Remove-ProtectedStagingDirectory ` + -Path $preflightStage -ProgramDataRoot $preflightProgramDataRoot + } + } +} + +$certificateThumbprint = $certificate.Thumbprint +$expectedCertificateBytes = [Convert]::ToBase64String($certificate.RawData) +if (-not ('ViiperLocalTestCertificateStore' -as [type])) { + Add-Type -Language CSharp -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +public static class ViiperLocalTestCertificateStore +{ + private const int CERT_STORE_PROV_SYSTEM_W = 10; + private const uint CERT_SYSTEM_STORE_LOCAL_MACHINE = 0x00020000; + private const uint CERT_STORE_OPEN_EXISTING_FLAG = 0x00004000; + private const uint CERT_STORE_MAXIMUM_ALLOWED_FLAG = 0x00001000; + private const uint CERT_ENCODING = 0x00010001; + private const uint CERT_STORE_ADD_NEW = 1; + private const uint CERT_FIND_EXISTING = 0x000d0000; + private const int CRYPT_E_NOT_FOUND = unchecked((int)0x80092004); + + [DllImport("crypt32.dll", CharSet = CharSet.Unicode, SetLastError = true, + ExactSpelling = true)] + private static extern IntPtr CertOpenStore( + IntPtr provider, uint encoding, IntPtr cryptProvider, + uint flags, string storeName); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern bool CertAddEncodedCertificateToStore( + IntPtr store, uint encoding, byte[] certificate, uint length, + uint disposition, out IntPtr context); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern IntPtr CertCreateCertificateContext( + uint encoding, byte[] certificate, uint length); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern IntPtr CertFindCertificateInStore( + IntPtr store, uint encoding, uint findFlags, uint findType, + IntPtr findParameter, IntPtr previousContext); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern bool CertDeleteCertificateFromStore(IntPtr context); + + [DllImport("crypt32.dll")] + private static extern bool CertFreeCertificateContext(IntPtr context); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern bool CertCloseStore(IntPtr store, uint flags); + + private static IntPtr Open(string storeName) + { + IntPtr store = CertOpenStore( + new IntPtr(CERT_STORE_PROV_SYSTEM_W), 0, IntPtr.Zero, + CERT_SYSTEM_STORE_LOCAL_MACHINE | CERT_STORE_OPEN_EXISTING_FLAG | + CERT_STORE_MAXIMUM_ALLOWED_FLAG, + storeName); + if (store == IntPtr.Zero) + throw new Win32Exception(Marshal.GetLastWin32Error(), "CertOpenStore"); + return store; + } + + public static void Add(string storeName, byte[] certificate) + { + IntPtr store = Open(storeName); + IntPtr context = IntPtr.Zero; + try + { + if (!CertAddEncodedCertificateToStore( + store, CERT_ENCODING, certificate, (uint)certificate.Length, + CERT_STORE_ADD_NEW, out context)) + throw new Win32Exception( + Marshal.GetLastWin32Error(), "CertAddEncodedCertificateToStore"); + } + finally + { + if (context != IntPtr.Zero) CertFreeCertificateContext(context); + CertCloseStore(store, 0); + } + } + + public static bool Remove(string storeName, byte[] certificate) + { + IntPtr store = Open(storeName); + IntPtr search = IntPtr.Zero; + try + { + search = CertCreateCertificateContext( + CERT_ENCODING, certificate, (uint)certificate.Length); + if (search == IntPtr.Zero) + throw new Win32Exception( + Marshal.GetLastWin32Error(), "CertCreateCertificateContext"); + IntPtr found = CertFindCertificateInStore( + store, CERT_ENCODING, 0, CERT_FIND_EXISTING, search, IntPtr.Zero); + if (found == IntPtr.Zero) + { + int error = Marshal.GetLastWin32Error(); + if (error == CRYPT_E_NOT_FOUND) return false; + throw new Win32Exception(error, "CertFindCertificateInStore"); + } + if (!CertDeleteCertificateFromStore(found)) + throw new Win32Exception( + Marshal.GetLastWin32Error(), "CertDeleteCertificateFromStore"); + return true; + } + finally + { + if (search != IntPtr.Zero) CertFreeCertificateContext(search); + CertCloseStore(store, 0); + } + } +} +'@ +} + +$certificateStoreOpenMethod = [ViiperLocalTestCertificateStore].GetMethod( + 'CertOpenStore', [Reflection.BindingFlags]'NonPublic,Static') +$certificateStoreOpenImport = $certificateStoreOpenMethod.GetCustomAttributes( + [Runtime.InteropServices.DllImportAttribute], $false)[0] +if ($certificateStoreOpenImport.Value -cne 'crypt32.dll' -or + -not $certificateStoreOpenImport.ExactSpelling -or + $certificateStoreOpenImport.CharSet -ne [Runtime.InteropServices.CharSet]::Unicode) { + throw 'The local-test certificate-store interop does not bind the exact CertOpenStore entry point.' +} + +if ($PreflightOnly) { + Write-Output 'result=success operation=local-test-preflight changed=0 rebootRequired=0 rollback=not-needed exitCode=0' + return +} + +function Get-ExactLocalTestTrustState { + param([Parameter(Mandatory = $true)][string]$StoreName) + + $store = [Security.Cryptography.X509Certificates.X509Store]::new( + $StoreName, [Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine) + $matches = $null + try { + # Reopening the store read-only makes every verification a persisted-state + # postcondition rather than an observation through the mutating handle. + $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) + $matches = $store.Certificates.Find( + [Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, + $certificateThumbprint, $false) + $exactMatches = @($matches | Where-Object { + [Convert]::ToBase64String($_.RawData) -ceq $expectedCertificateBytes + }) + if ($matches.Count -ne $exactMatches.Count -or $exactMatches.Count -gt 1) { + throw "Certificate collision in LocalMachine\$StoreName." + } + return [pscustomobject]@{ ExactCount = [int]$exactMatches.Count } + } + finally { + if ($null -ne $matches) { + foreach ($match in $matches) { + $match.Dispose() + } + } + $store.Close() + } +} + +$addedStores = [Collections.Generic.List[string]]::new() +function Remove-NewLocalTestTrust { + $removalErrors = [Collections.Generic.List[Exception]]::new() + foreach ($storeName in $addedStores) { + $cleanupAction = 'inspect-cleanup' + try { + $cleanupState = Get-ExactLocalTestTrustState -StoreName $storeName + $cleanupAction = 'remove' + if ($cleanupState.ExactCount -eq 1) { + $removed = [ViiperLocalTestCertificateStore]::Remove( + $storeName, $certificate.RawData) + $removeResult = if ($removed) { 'removed' } else { 'already-absent' } + } + else { + $removeResult = 'already-absent' + } + Write-Host "local-test-trust store=$storeName action=remove result=$removeResult" + + $cleanupAction = 'verify-cleanup' + $cleanupState = Get-ExactLocalTestTrustState -StoreName $storeName + if ($cleanupState.ExactCount -ne 0) { + throw "Exact local-test certificate remained in LocalMachine\$storeName." + } + Write-Host "local-test-trust store=$storeName action=verify-cleanup result=absent" + } + catch { + Write-Host "local-test-trust store=$storeName action=$cleanupAction result=error" + $removalErrors.Add([InvalidOperationException]::new( + "LocalMachine\$storeName trust cleanup failed during $cleanupAction.", + $_.Exception)) + } + } + if ($removalErrors.Count -ne 0) { + throw [AggregateException]::new( + 'Failed to remove one or more local-test trust anchors after a settled failure.', + [Exception[]]$removalErrors.ToArray()) + } +} + +function Test-SettledLocalTestFailure { + param( + [Parameter(Mandatory = $true)][object[]]$Lines, + [Parameter(Mandatory = $true)][int]$ProcessExitCode + ) + + $pattern = '(?m)^result=error operation=install changed=(?[01]) ' + + 'rebootRequired=(?[01]) rollback=(?not-needed|succeeded|failed) ' + + 'exitCode=(?[0-9]+)(?: .*)?\r?$' + # Out-String formats through the host and wraps long native proof lines at + # the current console width. Preserve the already-delimited child output + # byte-for-line instead: diagnostics may make the canonical proof much + # wider than the host while the rollback fields remain authoritative. + $proofText = [string]::Join([Environment]::NewLine, [string[]]$Lines) + $matches = [regex]::Matches($proofText, $pattern) + if ($matches.Count -ne 1) { + return $false + } + $match = $matches[0] + $proofExitCode = 0 + if (-not [int]::TryParse($match.Groups['exit'].Value, [ref]$proofExitCode) -or + $proofExitCode -ne $ProcessExitCode) { + return $false + } + return ($match.Groups['changed'].Value -ceq '0' -and + $match.Groups['reboot'].Value -ceq '0' -and + $match.Groups['rollback'].Value -ceq 'not-needed' -and + $proofExitCode -in @(1, 4)) -or + ($match.Groups['changed'].Value -ceq '1' -and + $match.Groups['reboot'].Value -ceq '0' -and + $match.Groups['rollback'].Value -ceq 'succeeded' -and + $proofExitCode -eq 1) +} + +$trustCommitted = $false +$retainTrustOnFailure = $false +$stageDirectory = $null +$programDataRoot = $null +try { + foreach ($storeName in @('Root', 'TrustedPublisher')) { + $trustAction = 'inspect-add' + try { + $trustState = Get-ExactLocalTestTrustState -StoreName $storeName + if ($trustState.ExactCount -eq 0) { + $trustAction = 'add' + [ViiperLocalTestCertificateStore]::Add( + $storeName, $certificate.RawData) + $addedStores.Add($storeName) + Write-Host "local-test-trust store=$storeName action=add result=added" + + $trustAction = 'verify-add' + $trustState = Get-ExactLocalTestTrustState -StoreName $storeName + if ($trustState.ExactCount -ne 1) { + throw "Exact local-test certificate was not installed in LocalMachine\$storeName." + } + Write-Host "local-test-trust store=$storeName action=verify-add result=present" + } + else { + Write-Host "local-test-trust store=$storeName action=add result=preexisting" + } + } + catch { + Write-Host "local-test-trust store=$storeName action=$trustAction result=error" + throw + } + } + + foreach ($name in @('ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.cat')) { + $runtime = Join-Path $driverDirectory $name + $evidence = Join-Path $signedPackage $name + if ((Get-FileHash -LiteralPath $runtime -Algorithm SHA256).Hash -cne + (Get-FileHash -LiteralPath $evidence -Algorithm SHA256).Hash) { + throw "Runtime driver file '$name' differs from its validated evidence copy." + } + } + + $manifestHash = [string]($lockByPath['submission-manifest.json'].sha256) + $infHash = [string]($lockByPath['driver/ViiperUde.inf'].sha256) + $sysHash = [string]($lockByPath['driver/ViiperUde.sys'].sha256) + $catHash = [string]($lockByPath['driver/ViiperUde.cat'].sha256) + $brokerEntry = $lockByPath['viiper.exe'] + $brokerHash = [string]$brokerEntry.sha256 + $helperHash = [string]($lockByPath['ViiperUdeCtl.exe'].sha256) + + $programDataRoot = (Resolve-Path -LiteralPath $env:ProgramData -ErrorAction Stop).Path + $programDataItem = Get-Item -LiteralPath $programDataRoot -Force -ErrorAction Stop + if (-not $programDataItem.PSIsContainer -or + ($programDataItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "ProgramData is not a safe staging parent: '$programDataRoot'." + } + Remove-PreBootProtectedStagingDirectories -ProgramDataRoot $programDataRoot + $stageDirectory = Join-Path $programDataRoot ( + 'VIIPER.LocalTestStage.' + [Guid]::NewGuid().ToString('N')) + Initialize-ProtectedStagingDirectory -Path $stageDirectory + $brokerPath = Copy-ExactBrokerToProtectedStage ` + -SourcePath $packageBrokerPath -DestinationDirectory $stageDirectory ` + -ExpectedLength ([long]$brokerEntry.length) -ExpectedSHA256 $brokerHash + + $output = @() + $exitCode = $null + $launchError = $null + $processStarted = $false + $brokerArguments = @( + 'native-package-install', + '--package-directory', $driverDirectory, + '--submission-manifest', $manifestPath, + '--source-revision', $source, + '--driver-helper', $helperPath, + '--expected-broker-sha-256', $brokerHash, + '--expected-helper-sha-256', $helperHash, + '--expected-manifest-sha-256', $manifestHash, + '--expected-inf-sha-256', $infHash, + '--expected-sys-sha-256', $sysHash, + '--expected-cat-sha-256', $catHash, + '--target-user-sid', $TargetUserSID, + '--driver-validation-mode', 'local-test' + ) + try { + $processResult = Invoke-JoinedNativeProcess ` + -FileName $brokerPath -Arguments $brokerArguments ` + -WorkingDirectory $stageDirectory -Started ([ref]$processStarted) + $retainTrustOnFailure = $processStarted + $exitCode = [int]$processResult.ExitCode + $output = @($processResult.Output) + } + catch { + $retainTrustOnFailure = $processStarted + $launchError = $_ + } + $output | ForEach-Object { Write-Host $_ } + if ($null -ne $exitCode) { + if ($exitCode -in @(0, 3010)) { + $trustCommitted = $true + } + elseif (Test-SettledLocalTestFailure ` + -Lines $output -ProcessExitCode $exitCode) { + $retainTrustOnFailure = $false + } + } + Remove-ProtectedStagingDirectory ` + -Path $stageDirectory -ProgramDataRoot $programDataRoot + $stageDirectory = $null + if ($null -ne $launchError) { + throw $launchError + } + if ($exitCode -notin @(0, 3010)) { + throw "Local VIIPER driver transaction failed with exit code $exitCode." + } + if ($exitCode -eq 3010) { + Write-Warning 'The native transaction stopped at a safe reboot boundary before mutation or after successful rollback. Restart, rerun this identical install command before creating another virtual device, and proceed to live validation only after it returns exit 0.' + exit 3010 + } +} +catch { + $failure = $_ + $cleanupFailure = $null + if ($null -ne $stageDirectory -and $null -ne $programDataRoot) { + try { + Remove-ProtectedStagingDirectory ` + -Path $stageDirectory -ProgramDataRoot $programDataRoot + $stageDirectory = $null + } + catch { + $cleanupFailure = $_ + } + } + if (-not $trustCommitted -and -not $retainTrustOnFailure) { + Remove-NewLocalTestTrust + } + if ($null -ne $cleanupFailure) { + throw [AggregateException]::new( + 'Local VIIPER installation failed and protected staging cleanup also failed.', + [Exception[]]@($failure.Exception, $cleanupFailure.Exception)) + } + throw $failure +} + +Write-Host 'The exact local test-signed VIIPER UdeCx driver and native broker are installed, authenticated, and ready.' +Write-Host 'Next: enable Driver Verifier for ViiperUde.sys, reboot, then run Invoke-ViiperUdeLiveValidation.ps1 in LocalTest mode.' +} +finally { + $installerScriptStream.Dispose() +} diff --git a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 new file mode 100644 index 00000000..c7122939 --- /dev/null +++ b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 @@ -0,0 +1,513 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$SignedPackageDirectory, + + [Parameter(Mandatory = $true)] + [string]$SubmissionManifestPath, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] + [string]$ExpectedSourceRevision, + + [ValidateSet('LocalTest', 'ControlledTest', 'Production')] + [string]$SignatureValidationMode = 'Production', + + [string]$LocalTestCertificatePath, + + [ValidateRange(1, 100)] + [int]$Iterations = 1, + + [string]$RepositoryRoot, + + [switch]$RequireDriverVerifier, + + [string]$MediaProbePath, + + [string]$InputProbePath, + + [string]$ProbeManifestPath, + + [switch]$RestartRootDevice, + + [switch]$DisposableTestMachine, + + [switch]$ManageInstalledBrokerService, + + [ValidateRange(1, 300)] + [int]$MediaDurationSeconds = 3, + + [switch]$ReleaseGate +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-DriverImagePath { + param([Parameter(Mandatory = $true)][string]$ImagePath) + + $path = [Environment]::ExpandEnvironmentVariables($ImagePath.Trim().Trim('"')) + if ($path.StartsWith('\??\', [StringComparison]::Ordinal)) { + $path = $path.Substring(4) + } + if ($path.StartsWith('\SystemRoot\', [StringComparison]::OrdinalIgnoreCase)) { + $path = Join-Path $env:SystemRoot $path.Substring('\SystemRoot\'.Length) + } + elseif ($path.StartsWith('System32\', [StringComparison]::OrdinalIgnoreCase)) { + $path = Join-Path $env:SystemRoot $path + } + if (-not [IO.Path]::IsPathRooted($path)) { + throw "VIIPER UDE has an unsupported relative service image path: '$ImagePath'." + } + return (Resolve-Path -LiteralPath $path -ErrorAction Stop).Path +} + +function Test-LiveProbeManifest { + param( + [Parameter(Mandatory = $true)][string]$ManifestPath, + [Parameter(Mandatory = $true)][string]$SourceRevision, + [Parameter(Mandatory = $true)][string]$ResolvedMediaProbe, + [Parameter(Mandatory = $true)][string]$ResolvedInputProbe + ) + + $resolvedManifest = (Resolve-Path -LiteralPath $ManifestPath -ErrorAction Stop).Path + try { + $manifest = Get-Content -LiteralPath $resolvedManifest -Raw -ErrorAction Stop | + ConvertFrom-Json -ErrorAction Stop + } + catch { + throw "The native live-probe manifest is not valid JSON: '$resolvedManifest'. $($_.Exception.Message)" + } + if ([int]$manifest.schemaVersion -ne 1) { + throw "The native live-probe manifest has unsupported schemaVersion '$($manifest.schemaVersion)'." + } + if (-not [string]::Equals([string]$manifest.sourceRevision, $SourceRevision, + [StringComparison]::OrdinalIgnoreCase)) { + throw "The native live-probe manifest represents source '$($manifest.sourceRevision)', not '$SourceRevision'." + } + if ($null -eq $manifest.probes) { + throw 'The native live-probe manifest has no probes object.' + } + + $expected = [ordered]@{ + 'ViiperUdeMediaProbe.exe' = $ResolvedMediaProbe + 'ViiperUdeInputProbe.exe' = $ResolvedInputProbe + } + $properties = @($manifest.probes.PSObject.Properties) + $actualNames = @($properties | ForEach-Object { $_.Name } | Sort-Object) + $expectedNames = @($expected.Keys | Sort-Object) + if ($actualNames.Count -ne $expectedNames.Count -or + @(Compare-Object -ReferenceObject $expectedNames -DifferenceObject $actualNames).Count -ne 0) { + throw "The native live-probe manifest must contain exactly: $($expectedNames -join ', ')." + } + + foreach ($name in $expected.Keys) { + $path = [string]$expected[$name] + if ([IO.Path]::GetFileName($path) -cne $name) { + throw "The live probe path must retain its source-built name '$name': '$path'." + } + $expectedHash = [string]$manifest.probes.PSObject.Properties[$name].Value + if ($expectedHash -notmatch '^[0-9a-fA-F]{64}$') { + throw "The native live-probe manifest has an invalid SHA-256 for '$name'." + } + $actualHash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash + if (-not [string]::Equals($actualHash, $expectedHash, + [StringComparison]::OrdinalIgnoreCase)) { + throw "The live probe '$name' does not match the source-bound manifest." + } + } +} + +if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) { + $RepositoryRoot = Join-Path $PSScriptRoot '..\..\..' +} + +if ($ReleaseGate) { + $releaseGateFailures = [Collections.Generic.List[string]]::new() + if ($SignatureValidationMode -ne 'Production') { + [void]$releaseGateFailures.Add('-SignatureValidationMode must be Production') + } + if (-not $RequireDriverVerifier) { + [void]$releaseGateFailures.Add('-RequireDriverVerifier is required') + } + if ([string]::IsNullOrWhiteSpace($MediaProbePath)) { + [void]$releaseGateFailures.Add('-MediaProbePath is required') + } + if ([string]::IsNullOrWhiteSpace($InputProbePath)) { + [void]$releaseGateFailures.Add('-InputProbePath is required') + } + if ([string]::IsNullOrWhiteSpace($ProbeManifestPath)) { + [void]$releaseGateFailures.Add('-ProbeManifestPath is required') + } + if (-not $RestartRootDevice) { + [void]$releaseGateFailures.Add('-RestartRootDevice is required') + } + if (-not $DisposableTestMachine) { + [void]$releaseGateFailures.Add('-DisposableTestMachine is required') + } + if (-not $ManageInstalledBrokerService) { + [void]$releaseGateFailures.Add('-ManageInstalledBrokerService is required') + } + if ($Iterations -lt 3) { + [void]$releaseGateFailures.Add('-Iterations must be at least 3') + } + if ($MediaDurationSeconds -lt 180) { + [void]$releaseGateFailures.Add('-MediaDurationSeconds must be at least 180') + } + if ($releaseGateFailures.Count -ne 0) { + throw "Release-gate validation is incomplete:`n - $($releaseGateFailures -join "`n - ")" + } +} + +$hasMediaProbe = -not [string]::IsNullOrWhiteSpace($MediaProbePath) +$hasInputProbe = -not [string]::IsNullOrWhiteSpace($InputProbePath) +if ($SignatureValidationMode -in @('Production', 'LocalTest') -and + ($hasMediaProbe -or $hasInputProbe) -and + [string]::IsNullOrWhiteSpace($ProbeManifestPath)) { + throw '-ProbeManifestPath is required whenever a source-bound live probe is used.' +} + +$repository = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).Path +if ($SignatureValidationMode -in @('Production', 'LocalTest')) { + $git = Get-Command git.exe -ErrorAction Stop + $headOutput = & $git.Source -C $repository rev-parse --verify HEAD 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "The source-bound live-test harness is not an exact Git checkout.`n$($headOutput -join [Environment]::NewLine)" + } + $headRevision = ($headOutput | Select-Object -First 1).Trim() + if (-not [string]::Equals($headRevision, $ExpectedSourceRevision, + [StringComparison]::OrdinalIgnoreCase)) { + throw "The source-bound live-test harness is source '$headRevision', not '$ExpectedSourceRevision'." + } + $treeStatus = @(& $git.Source -C $repository status --porcelain=v1 --untracked-files=all 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "Could not verify the source-bound live-test source tree.`n$($treeStatus -join [Environment]::NewLine)" + } + if ($treeStatus.Count -ne 0) { + throw ("The source-bound live-test source tree is not clean; refusing unreviewed test code or data:`n" + + ($treeStatus -join [Environment]::NewLine)) + } + $submoduleStatus = @(& $git.Source -C $repository submodule status --recursive 2>&1) + if ($LASTEXITCODE -ne 0 -or @($submoduleStatus | Where-Object { $_ -match '^[\-+U]' }).Count -ne 0) { + throw "The source-bound live-test source tree has an unbound submodule state.`n$($submoduleStatus -join [Environment]::NewLine)" + } +} +$signatureGate = Join-Path $PSScriptRoot 'Test-ViiperUdeSignedPackage.ps1' +& $signatureGate ` + -PackageDirectory $SignedPackageDirectory ` + -SubmissionManifestPath $SubmissionManifestPath ` + -ExpectedSourceRevision $ExpectedSourceRevision ` + -ValidationMode $SignatureValidationMode ` + -LocalTestCertificatePath $LocalTestCertificatePath + +$packageRoot = (Resolve-Path -LiteralPath $SignedPackageDirectory -ErrorAction Stop).Path +$packageDrivers = @(Get-ChildItem -LiteralPath $packageRoot -Recurse -File -Filter 'ViiperUde.sys') +if ($packageDrivers.Count -ne 1) { + throw "Expected exactly one signed package driver; found $($packageDrivers.Count)." +} + +$service = Get-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Services\ViiperUde' -ErrorAction Stop +if ([string]::IsNullOrWhiteSpace([string]$service.ImagePath)) { + throw 'The installed VIIPER UDE service has no ImagePath.' +} +$installedDriver = Resolve-DriverImagePath -ImagePath ([string]$service.ImagePath) +$packageHash = (Get-FileHash -LiteralPath $packageDrivers[0].FullName -Algorithm SHA256).Hash +$installedHash = (Get-FileHash -LiteralPath $installedDriver -Algorithm SHA256).Hash +if ($packageHash -ne $installedHash) { + throw "The loaded VIIPER UDE service image does not match the verified package. Installed='$installedDriver'." +} + +$ownedRootDevices = @(Get-CimInstance -ClassName Win32_PnPEntity | Where-Object { + @($_.HardwareID) -contains 'ROOT\VIIPER\UDE' +}) +if ($ownedRootDevices.Count -ne 1) { + throw "Expected exactly one VIIPER UDE hardware-ID owner; found $($ownedRootDevices.Count)." +} +$ownedRootInstance = [string]$ownedRootDevices[0].PNPDeviceID +$devnodes = @(Get-CimInstance -ClassName Win32_PnPSignedDriver | Where-Object { + [string]$_.DeviceID -ieq $ownedRootInstance +}) +if ($devnodes.Count -ne 1) { + throw "Expected exactly one VIIPER UDE root devnode; found $($devnodes.Count)." +} +if ([uint32]$ownedRootDevices[0].ConfigManagerErrorCode -ne 0) { + throw "The installed VIIPER UDE root devnode has PnP problem code '$($ownedRootDevices[0].ConfigManagerErrorCode)'." +} +$infName = [string]$devnodes[0].InfName +if ($infName -cnotmatch '^oem[0-9]+\.inf$') { + throw "The installed VIIPER UDE root devnode has an invalid OEM INF identity '$infName'." +} +$packageInfs = @(Get-ChildItem -LiteralPath $packageRoot -File -Filter 'ViiperUde.inf') +if ($packageInfs.Count -ne 1) { + throw "Expected exactly one signed-package INF; found $($packageInfs.Count)." +} +$installedInf = Join-Path (Join-Path $env:SystemRoot 'INF') $infName +$packageInfHash = (Get-FileHash -LiteralPath $packageInfs[0].FullName -Algorithm SHA256).Hash +$installedInfHash = (Get-FileHash -LiteralPath $installedInf -Algorithm SHA256).Hash +if ($packageInfHash -cne $installedInfHash) { + throw "The active VIIPER UDE devnode INF does not match the verified package (InfName='$infName')." +} +if ($SignatureValidationMode -ne 'LocalTest') { + if (-not [bool]$devnodes[0].IsSigned -or + [string]::IsNullOrWhiteSpace([string]$devnodes[0].Signer)) { + throw "The installed VIIPER UDE devnode is not backed by a signed driver (Signer='$($devnodes[0].Signer)')." + } + if ([string]$devnodes[0].Signer -notmatch '(?i)Microsoft') { + throw "The installed VIIPER UDE devnode is not backed by a Microsoft-signed driver (Signer='$($devnodes[0].Signer)')." + } +} + +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = [Security.Principal.WindowsPrincipal]::new($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Live VIIPER UDE validation must run from an elevated PowerShell session.' +} + +if ($SignatureValidationMode -eq 'LocalTest') { + $bcdOutput = (& bcdedit.exe /enum '{current}' 2>&1 | Out-String) + if ($LASTEXITCODE -ne 0 -or $bcdOutput -notmatch '(?im)^\s*testsigning\s+Yes\s*$') { + throw "LocalTest requires the current boot entry to report 'testsigning Yes'. Enable TESTSIGNING and reboot before retrying.`n$bcdOutput" + } +} + +if ($ReleaseGate) { + $operatingSystem = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop + $build = 0 + if (-not [int]::TryParse([string]$operatingSystem.BuildNumber, [ref]$build) -or + [uint32]$operatingSystem.ProductType -ne 1 -or $build -lt 22000 -or + -not [Environment]::Is64BitOperatingSystem) { + throw "The release live gate requires a 64-bit Windows 11 client HLK target; got '$($operatingSystem.Caption)' build '$($operatingSystem.BuildNumber)'." + } + $secureBootCommand = Get-Command Confirm-SecureBootUEFI -ErrorAction SilentlyContinue + if ($null -eq $secureBootCommand) { + throw 'The release live gate could not verify Secure Boot because Confirm-SecureBootUEFI is unavailable.' + } + try { + $secureBootEnabled = [bool](& $secureBootCommand -ErrorAction Stop) + } + catch { + throw "The release live gate could not verify Secure Boot: $($_.Exception.Message)" + } + if (-not $secureBootEnabled) { + throw 'The release live gate requires Secure Boot to be enabled.' + } +} + +if ($RestartRootDevice) { + if (-not $DisposableTestMachine) { + throw 'Root-device restart validation is destructive to the active native session. Pass -DisposableTestMachine on a dedicated test system.' + } + if ([Environment]::OSVersion.Version.Build -lt 19041) { + throw 'PnPUtil /restart-device requires Windows 10 version 2004 (build 19041) or newer.' + } +} + +if ($RequireDriverVerifier) { + $verifierOutput = (& verifier.exe /query 2>&1 | Out-String) + if ($LASTEXITCODE -ne 0) { + throw "Driver Verifier query failed with exit code $LASTEXITCODE.`n$verifierOutput" + } + if ($verifierOutput -notmatch '(?im)\bViiperUde\.sys\b') { + throw 'Driver Verifier is not currently active for ViiperUde.sys. Configure one-boot verification, restart, and retry.' + } + $verifiedDrivers = @([regex]::Matches( + $verifierOutput, '(?im)\b[^\s\\/:*?"<>|]+\.sys\b') | + ForEach-Object { $_.Value } | + Sort-Object -Unique) + if ($verifiedDrivers.Count -ne 1 -or $verifiedDrivers[0] -ine 'ViiperUde.sys') { + throw "Driver Verifier must target only ViiperUde.sys; active list: $($verifiedDrivers -join ', ')." + } + $flagsMatch = [regex]::Match($verifierOutput, '(?i)\b0x(?[0-9a-f]{8})\b') + if (-not $flagsMatch.Success) { + throw "Driver Verifier did not report its current flag level.`n$verifierOutput" + } + $verifierFlags = [Convert]::ToUInt32($flagsMatch.Groups['flags'].Value, 16) + # /standard is 0x209BB. Supported Windows 10/11 adds 0x100000 for KMDF + # verification. Additional stress flags are allowed, but a subset is not. + [uint32]$requiredVerifierFlags = 0x001209BB + if (($verifierFlags -band $requiredVerifierFlags) -ne $requiredVerifierFlags) { + throw (("Driver Verifier is active for ViiperUde.sys with flags 0x{0:X8}, " + + "but /standard plus KMDF requires 0x{1:X8}.") -f + $verifierFlags, $requiredVerifierFlags) + } +} + +$resolvedMediaProbe = $null +if (-not [string]::IsNullOrWhiteSpace($MediaProbePath)) { + $resolvedMediaProbe = (Resolve-Path -LiteralPath $MediaProbePath -ErrorAction Stop).Path + if ([IO.Path]::GetExtension($resolvedMediaProbe) -ine '.exe') { + throw "The native CoreAudio probe must be an executable: '$resolvedMediaProbe'." + } +} + +$resolvedInputProbe = $null +if (-not [string]::IsNullOrWhiteSpace($InputProbePath)) { + $resolvedInputProbe = (Resolve-Path -LiteralPath $InputProbePath -ErrorAction Stop).Path + if ([IO.Path]::GetExtension($resolvedInputProbe) -ine '.exe') { + throw "The native HID input/output probe must be an executable: '$resolvedInputProbe'." + } +} + +if (-not [string]::IsNullOrWhiteSpace($ProbeManifestPath)) { + if ($null -eq $resolvedMediaProbe -or $null -eq $resolvedInputProbe) { + throw '-ProbeManifestPath requires both -MediaProbePath and -InputProbePath.' + } + Test-LiveProbeManifest ` + -ManifestPath $ProbeManifestPath ` + -SourceRevision $ExpectedSourceRevision ` + -ResolvedMediaProbe $resolvedMediaProbe ` + -ResolvedInputProbe $resolvedInputProbe +} + +$go = Get-Command go.exe -ErrorAction Stop +$oldLive = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE', 'Process') +$oldIterations = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_ITERATIONS', 'Process') +$oldMediaProbe = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_PROBE', 'Process') +$oldMediaSeconds = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_SECONDS', 'Process') +$oldInputProbe = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_INPUT_PROBE', 'Process') +$oldRestartInstance = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_RESTART_INSTANCE_ID', 'Process') +$oldGoFlags = [Environment]::GetEnvironmentVariable('GOFLAGS', 'Process') +$oldGoWork = [Environment]::GetEnvironmentVariable('GOWORK', 'Process') +$oldGoEnv = [Environment]::GetEnvironmentVariable('GOENV', 'Process') +$oldGoToolchain = [Environment]::GetEnvironmentVariable('GOTOOLCHAIN', 'Process') +$oldGoOS = [Environment]::GetEnvironmentVariable('GOOS', 'Process') +$oldGoArch = [Environment]::GetEnvironmentVariable('GOARCH', 'Process') +$oldCgoEnabled = [Environment]::GetEnvironmentVariable('CGO_ENABLED', 'Process') +$brokerService = Get-Service -Name 'VIIPERNativeBroker' -ErrorAction SilentlyContinue +if ($ManageInstalledBrokerService) { + if ($null -eq $brokerService) { + throw '-ManageInstalledBrokerService requires the installed VIIPERNativeBroker service.' + } + if ($brokerService.Status -ne [ServiceProcess.ServiceControllerStatus]::Running) { + throw "The installed VIIPERNativeBroker service must be running before validation; got '$($brokerService.Status)'." + } +} +elseif ($null -ne $brokerService -and + $brokerService.Status -eq [ServiceProcess.ServiceControllerStatus]::Running) { + throw 'VIIPERNativeBroker currently owns the controller. Pass -ManageInstalledBrokerService for a controlled stop/test/restart boundary.' +} +try { + if ($ManageInstalledBrokerService) { + Stop-Service -Name $brokerService.Name -ErrorAction Stop + $brokerService.WaitForStatus( + [ServiceProcess.ServiceControllerStatus]::Stopped, + [TimeSpan]::FromSeconds(30)) + } + $env:VIIPER_UDE_LIVE = '1' + $env:VIIPER_UDE_LIVE_ITERATIONS = [string]$Iterations + if ($null -ne $resolvedMediaProbe) { + $env:VIIPER_UDE_LIVE_MEDIA_PROBE = $resolvedMediaProbe + $env:VIIPER_UDE_LIVE_MEDIA_SECONDS = [string]$MediaDurationSeconds + } + else { + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_PROBE', $null, 'Process') + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_SECONDS', $null, 'Process') + } + if ($null -ne $resolvedInputProbe) { + $env:VIIPER_UDE_LIVE_INPUT_PROBE = $resolvedInputProbe + } + else { + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_INPUT_PROBE', $null, 'Process') + } + if ($RestartRootDevice) { + $env:VIIPER_UDE_LIVE_RESTART_INSTANCE_ID = [string]$devnodes[0].DeviceID + } + else { + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_RESTART_INSTANCE_ID', $null, 'Process') + } + # The live harness is part of the certification evidence. User GOFLAGS, + # go.work redirection, GOENV defaults, or automatic toolchain downloads + # must not select different packages, tests, or source during the gate. + $env:GOFLAGS = '-mod=readonly' + $env:GOWORK = 'off' + $env:GOENV = 'off' + $env:GOTOOLCHAIN = 'local' + $env:GOOS = 'windows' + $env:GOARCH = 'amd64' + $env:CGO_ENABLED = '0' + $mediaMinutes = if ($null -ne $resolvedMediaProbe) { + [Math]::Ceiling(($MediaDurationSeconds * 3) / 60.0) + } + else { 0 } + $timeoutMinutes = ($Iterations * 5) + $mediaMinutes + $(if ($RestartRootDevice) { 5 } else { 2 }) + Push-Location $repository + try { + $modulePath = (& $go.Source env GOMOD 2>&1 | Select-Object -First 1) + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace([string]$modulePath) -or + -not [string]::Equals( + [IO.Path]::GetFullPath([string]$modulePath), + [IO.Path]::GetFullPath((Join-Path $repository 'go.mod')), + [StringComparison]::OrdinalIgnoreCase)) { + throw "The live test selected an unexpected Go module '$modulePath'." + } + $nativeIdentityLdflags = '-X github.com/Alia5/VIIPER/internal/transport/udecx.nativeSourceRevision=' + + $ExpectedSourceRevision.ToLowerInvariant() + $savedErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + $goTestOutput = @(& $go.Source test -v -count=1 -timeout "${timeoutMinutes}m" ` + -ldflags $nativeIdentityLdflags ` + -run '^TestNativeUDELive(ProductionControllers|OwnerCrashRecovery|RootRestartRecovery)$' ` + ./internal/server/usb 2>&1 + ) + $goTestExitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $savedErrorActionPreference + } + $goTestOutput | ForEach-Object { Write-Host $_ } + if ($goTestExitCode -ne 0) { + throw "Native UDE live validation failed with exit code $goTestExitCode." + } + $goTestText = $goTestOutput | Out-String + $requiredLiveTests = @( + 'TestNativeUDELiveProductionControllers', + 'TestNativeUDELiveOwnerCrashRecovery' + ) + if ($RestartRootDevice) { + $requiredLiveTests += 'TestNativeUDELiveRootRestartRecovery' + } + foreach ($testName in $requiredLiveTests) { + if ($goTestText -notmatch "(?m)^--- PASS: $([regex]::Escape($testName)) ") { + throw "Go reported success without executing required live test '$testName'." + } + } + } + finally { + Pop-Location + } +} +finally { + if ($ManageInstalledBrokerService) { + $brokerService.Refresh() + if ($brokerService.Status -ne [ServiceProcess.ServiceControllerStatus]::Running) { + Start-Service -Name $brokerService.Name -ErrorAction Stop + $brokerService.WaitForStatus( + [ServiceProcess.ServiceControllerStatus]::Running, + [TimeSpan]::FromSeconds(30)) + } + } + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE', $oldLive, 'Process') + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_ITERATIONS', $oldIterations, 'Process') + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_PROBE', $oldMediaProbe, 'Process') + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_SECONDS', $oldMediaSeconds, 'Process') + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_INPUT_PROBE', $oldInputProbe, 'Process') + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_RESTART_INSTANCE_ID', $oldRestartInstance, 'Process') + [Environment]::SetEnvironmentVariable('GOFLAGS', $oldGoFlags, 'Process') + [Environment]::SetEnvironmentVariable('GOWORK', $oldGoWork, 'Process') + [Environment]::SetEnvironmentVariable('GOENV', $oldGoEnv, 'Process') + [Environment]::SetEnvironmentVariable('GOTOOLCHAIN', $oldGoToolchain, 'Process') + [Environment]::SetEnvironmentVariable('GOOS', $oldGoOS, 'Process') + [Environment]::SetEnvironmentVariable('GOARCH', $oldGoArch, 'Process') + [Environment]::SetEnvironmentVariable('CGO_ENABLED', $oldCgoEnabled, 'Process') +} + +$verifierSuffix = if ($RequireDriverVerifier) { ' with Driver Verifier active' } else { '' } +$mediaSuffix = if ($null -ne $resolvedMediaProbe) { + " with $MediaDurationSeconds-second full-duplex CoreAudio media per PlayStation controller" +} +else { '' } +$inputSuffix = if ($null -ne $resolvedInputProbe) { ' with end-to-end HID input latency and output feedback' } else { '' } +$restartSuffix = if ($RestartRootDevice) { ' with active root-device restart recovery' } else { '' } +$releaseSuffix = if ($ReleaseGate) { ' under the complete production release contract' } else { '' } +Write-Host "VIIPER UDE live lifecycle/HID/media validation passed for $Iterations iteration(s)$verifierSuffix$mediaSuffix$inputSuffix$restartSuffix$releaseSuffix." diff --git a/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 new file mode 100644 index 00000000..f8a24571 --- /dev/null +++ b/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 @@ -0,0 +1,250 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$SignedPackageDirectory, + + [Parameter(Mandatory = $true)] + [string]$SubmissionManifestPath, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] + [string]$ExpectedSourceRevision, + + [ValidateSet('LocalTest', 'ControlledTest', 'Production')] + [string]$SignatureValidationMode = 'Production', + + [string]$LocalTestCertificatePath, + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [ValidateRange(1, 1000)] + [int]$Iterations = 10, + + [Parameter(Mandatory = $true)] + [string]$MediaProbePath, + + [Parameter(Mandatory = $true)] + [string]$InputProbePath, + + [Parameter(Mandatory = $true)] + [string]$ProbeManifestPath, + + [switch]$RequireDriverVerifier, + + [switch]$RestartRootDevice, + + [switch]$DisposableTestMachine, + + [switch]$ManageInstalledBrokerService, + + [ValidateRange(1, 300)] + [int]$MediaDurationSeconds = 3 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Test-IsAdministrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +if (-not (Test-IsAdministrator)) { + throw 'Native UDE performance validation requires an elevated PowerShell session.' +} + +$wprPath = Join-Path $env:SystemRoot 'System32\wpr.exe' +if (-not (Test-Path -LiteralPath $wprPath -PathType Leaf)) { + throw "Windows Performance Recorder was not found at '$wprPath'." +} + +$validationPath = Join-Path $PSScriptRoot 'Invoke-ViiperUdeLiveValidation.ps1' +if (-not (Test-Path -LiteralPath $validationPath -PathType Leaf)) { + throw "The signed live-validation script was not found at '$validationPath'." +} + +$resolvedOutput = [IO.Path]::GetFullPath($OutputPath) +if (Test-Path -LiteralPath $resolvedOutput) { + throw "Refusing to overwrite the existing trace '$resolvedOutput'." +} +$evidencePath = "$resolvedOutput.evidence.json" +if (Test-Path -LiteralPath $evidencePath) { + throw "Refusing to overwrite the existing trace evidence '$evidencePath'." +} +$outputDirectory = Split-Path -Parent $resolvedOutput +if ([string]::IsNullOrWhiteSpace($outputDirectory)) { + throw 'The trace output path must include a parent directory.' +} +[void][IO.Directory]::CreateDirectory($outputDirectory) + +# A unique instance name is the ownership boundary. Every WPR mutation below +# carries it as the final argument, as required by WPR, so this gate can never +# stop or cancel an unrelated recording on the test machine. +$instanceName = 'ViiperUdePerf_{0}_{1}' -f $PID, [Guid]::NewGuid().ToString('N') +$profile = 'GeneralProfile.Verbose' +$started = $false +$validationFailure = $null + +# GeneralProfile.Light records scheduler events, but it intentionally omits +# the CSwitch, ReadyThread, and sampled-profile stacks needed to attribute a +# tail-latency stall to the actual user/kernel critical path. Fail closed if a +# future Windows image changes the bounded-memory verbose profile contract. +$profileDetailsOutput = & $wprPath -profiledetails $profile 2>&1 +if ($LASTEXITCODE -ne 0) { + throw "WPR could not describe '$profile' (exit $LASTEXITCODE).`n$($profileDetailsOutput -join [Environment]::NewLine)" +} +$profileDetails = $profileDetailsOutput | Out-String +if ($profileDetails -notmatch '(?im)^Profile\s*:\s*GeneralProfile\.Verbose\.Memory\s*$') { + throw "WPR '$profile' is not the required bounded-memory profile.`n$profileDetails" +} +foreach ($eventName in @('DPC', 'Interrupt', 'WDFDPC', 'WDFInterrupt')) { + if ([regex]::Matches($profileDetails, "(?im)^\s*$eventName\s*$").Count -lt 1) { + throw "WPR '$profile' does not capture the required $eventName evidence." + } +} +foreach ($stackName in @('CSwitch', 'ReadyThread', 'SampledProfile')) { + # Each required name must appear once under System Keywords and again + # under System Stacks. A single occurrence is event-only evidence and + # cannot explain the ready/scheduled critical path in WPA. + if ([regex]::Matches($profileDetails, "(?im)^\s*$stackName\s*$").Count -lt 2) { + throw "WPR '$profile' does not capture the required $stackName events and stacks." + } +} + +$validationArguments = @{ + SignedPackageDirectory = $SignedPackageDirectory + SubmissionManifestPath = $SubmissionManifestPath + ExpectedSourceRevision = $ExpectedSourceRevision + SignatureValidationMode = $SignatureValidationMode + LocalTestCertificatePath = $LocalTestCertificatePath + Iterations = $Iterations + MediaProbePath = $MediaProbePath + InputProbePath = $InputProbePath + ProbeManifestPath = $ProbeManifestPath + MediaDurationSeconds = $MediaDurationSeconds +} +if ($RequireDriverVerifier) { + $validationArguments.RequireDriverVerifier = $true +} +if ($RestartRootDevice) { + $validationArguments.RestartRootDevice = $true +} +if ($DisposableTestMachine) { + $validationArguments.DisposableTestMachine = $true +} +if ($ManageInstalledBrokerService) { + $validationArguments.ManageInstalledBrokerService = $true +} + +try { + $startOutput = & $wprPath -start $profile -instancename $instanceName 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "WPR failed to start '$profile' (exit $LASTEXITCODE).`n$($startOutput -join [Environment]::NewLine)" + } + $started = $true + + try { + & $validationPath @validationArguments + } + catch { + $validationFailure = $_.Exception + } +} +finally { + if ($started) { + $statusOutput = & $wprPath -status -instancename $instanceName 2>&1 + $statusExitCode = $LASTEXITCODE + $statusText = $statusOutput | Out-String + $statusFailure = $null + if ($statusExitCode -ne 0) { + $statusFailure = [InvalidOperationException]::new( + "WPR status failed with exit $statusExitCode. $($statusOutput -join ' ')") + } + else { + $droppedMatch = [regex]::Match($statusText, '(?im)^\s*Dropped Event\s*:\s*(?\d+)\s*$') + if (-not $droppedMatch.Success) { + $statusFailure = [InvalidOperationException]::new( + "WPR did not report its dropped-event count. $($statusOutput -join ' ')") + } + elseif ([uint64]$droppedMatch.Groups['count'].Value -ne 0) { + $statusFailure = [InvalidOperationException]::new( + "WPR dropped $($droppedMatch.Groups['count'].Value) event(s); the performance trace is incomplete.") + } + } + if ($null -ne $statusFailure) { + if ($null -eq $validationFailure) { + $validationFailure = $statusFailure + } + else { + $validationFailure = [AggregateException]::new( + 'Native UDE validation and WPR capture integrity both failed.', + @($validationFailure, $statusFailure)) + } + } + + # Stop, rather than cancel, after a workload failure. The trace is most + # valuable when a latency or lifecycle gate failed. GeneralProfile is + # intentionally left in its bounded verbose memory mode; file mode is + # never enabled by this script. + $stopOutput = & $wprPath -stop $resolvedOutput -instancename $instanceName 2>&1 + $stopExitCode = $LASTEXITCODE + if ($stopExitCode -ne 0) { + if ($null -ne $validationFailure) { + throw [AggregateException]::new( + 'Native UDE validation and WPR trace finalization both failed.', + @( + $validationFailure, + [InvalidOperationException]::new( + "WPR stop failed with exit $stopExitCode. $($stopOutput -join ' ')") + )) + } + throw "WPR failed to save '$resolvedOutput' (exit $stopExitCode).`n$($stopOutput -join [Environment]::NewLine)" + } + } +} + +if (-not (Test-Path -LiteralPath $resolvedOutput -PathType Leaf) -or + (Get-Item -LiteralPath $resolvedOutput).Length -eq 0) { + throw "WPR reported success but did not create a non-empty trace at '$resolvedOutput'." +} + +# An ETL has no trustworthy provenance merely because its filename resembles a +# reviewed build. Bind the exact trace, signed-package manifest, and source-built +# probes into a sidecar before reporting completion. The live validator already +# checked that the probe manifest's declared hashes and source revision match. +$evidence = [ordered]@{ + schemaVersion = 1 + sourceRevision = $ExpectedSourceRevision.ToLowerInvariant() + profile = 'GeneralProfile.Verbose.Memory' + trace = [ordered]@{ + name = [IO.Path]::GetFileName($resolvedOutput) + sha256 = (Get-FileHash -LiteralPath $resolvedOutput -Algorithm SHA256).Hash.ToLowerInvariant() + } + signedPackageManifestSha256 = (Get-FileHash -LiteralPath $SubmissionManifestPath -Algorithm SHA256).Hash.ToLowerInvariant() + probeManifestSha256 = (Get-FileHash -LiteralPath $ProbeManifestPath -Algorithm SHA256).Hash.ToLowerInvariant() + mediaProbeSha256 = (Get-FileHash -LiteralPath $MediaProbePath -Algorithm SHA256).Hash.ToLowerInvariant() + inputProbeSha256 = (Get-FileHash -LiteralPath $InputProbePath -Algorithm SHA256).Hash.ToLowerInvariant() + signatureValidationMode = $SignatureValidationMode + iterations = $Iterations + mediaDurationSeconds = $MediaDurationSeconds + managesInstalledBrokerService = [bool]$ManageInstalledBrokerService + analysisRequired = $true +} +$evidenceJson = $evidence | ConvertTo-Json -Depth 4 +[IO.File]::WriteAllText($evidencePath, $evidenceJson, [Text.UTF8Encoding]::new($false)) +if (-not (Test-Path -LiteralPath $evidencePath -PathType Leaf) -or + (Get-Item -LiteralPath $evidencePath).Length -eq 0) { + throw "The source-bound trace evidence was not written to '$evidencePath'." +} + +if ($null -ne $validationFailure) { + throw [InvalidOperationException]::new( + "Native UDE live validation failed; the diagnostic trace was preserved at '$resolvedOutput'.", + $validationFailure) +} + +Write-Host ("Native UDE workload and trace-integrity validation passed. " + + "Performance acceptance still requires WPA analysis of '$resolvedOutput'. " + + "Source-bound evidence: '$evidencePath'.") diff --git a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 new file mode 100644 index 00000000..e8e4be0b --- /dev/null +++ b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 @@ -0,0 +1,225 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$InfPath, + + [Parameter(Mandatory = $true)] + [string]$SysPath, + + [Parameter(Mandatory = $true)] + [string]$PdbPath, + + [Parameter(Mandatory = $true)] + [string]$CatalogPath, + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] + [string]$SourceRevision, + + [switch]$AcknowledgeTestingOnly, + + [switch]$Force +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not $AcknowledgeTestingOnly) { + throw 'Microsoft documents attestation signing as testing-only. Pass -AcknowledgeTestingOnly to create a controlled-test submission CAB; use HLK/WHCP for a VIIPER retail release.' +} + +function Resolve-RequiredFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$ExpectedExtension + ) + + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + $item = Get-Item -LiteralPath $resolved.Path -Force + if (-not $item.PSIsContainer -and $item.Extension -ieq $ExpectedExtension -and $item.Length -gt 0) { + return $item + } + throw "Expected a nonempty $ExpectedExtension file at '$Path'." +} + +function Assert-InfContract { + param([Parameter(Mandatory = $true)][string]$Path) + + $contents = Get-Content -LiteralPath $Path -Raw + $required = @( + '(?im)^\s*Class\s*=\s*USB\s*$', + '(?im)^\s*ClassGuid\s*=\s*\{36FC9E60-C465-11CF-8056-444553540000\}\s*$', + '(?im)^\s*CatalogFile\s*=\s*ViiperUde\.cat\s*$', + '(?im)^\s*CopyFiles\s*=\s*@ViiperUde\.sys\s*$', + '(?im)^\s*%DeviceName%\s*=\s*ViiperUde_Install\s*,\s*ROOT\\VIIPER\\UDE\s*$' + ) + foreach ($pattern in $required) { + if ($contents -notmatch $pattern) { + throw "The INF does not satisfy the native VIIPER package contract: $pattern" + } + } +} + +$inf = Resolve-RequiredFile -Path $InfPath -ExpectedExtension '.inf' +$sys = Resolve-RequiredFile -Path $SysPath -ExpectedExtension '.sys' +$pdb = Resolve-RequiredFile -Path $PdbPath -ExpectedExtension '.pdb' +$cat = Resolve-RequiredFile -Path $CatalogPath -ExpectedExtension '.cat' +Assert-InfContract -Path $inf.FullName + +[xml]$driverProject = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\driver\ViiperUde.vcxproj') -Raw +$projectNamespace = [Xml.XmlNamespaceManager]::new($driverProject.NameTable) +$projectNamespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') +$versionNodes = @($driverProject.SelectNodes('//msb:ViiperUdeDriverVersion', $projectNamespace)) +if ($versionNodes.Count -ne 1) { + throw 'The driver project must declare one deterministic ViiperUdeDriverVersion.' +} +$driverPackageVersion = $versionNodes[0].InnerText.Trim() +$driverABIMajor = 1 +$driverABIMinor = 14 +$driverCapabilities = [uint32]61 +$driverBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` + -SourceRevision $SourceRevision ` + -DriverPackageVersion $driverPackageVersion ` + -ABIMajor $driverABIMajor ` + -ABIMinor $driverABIMinor ` + -Capabilities $driverCapabilities + +$makeCab = Get-Command makecab.exe -ErrorAction Stop +$expand = Get-Command expand.exe -ErrorAction Stop +$outputFullPath = [System.IO.Path]::GetFullPath($OutputPath) +if ([System.IO.Path]::GetExtension($outputFullPath) -ine '.cab') { + throw "The output path must end in .cab." +} +$outputDirectory = [System.IO.Path]::GetDirectoryName($outputFullPath) +if ([string]::IsNullOrWhiteSpace($outputDirectory)) { + throw "The output path must include a directory." +} +New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null +if (Test-Path -LiteralPath $outputFullPath) { + if (-not $Force) { + throw "The output CAB already exists. Pass -Force to replace '$outputFullPath'." + } + Remove-Item -LiteralPath $outputFullPath -Force +} + +$workRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("ViiperUdeCab" + [Guid]::NewGuid().ToString('N')) +$stage = Join-Path $workRoot 'stage' +$verify = Join-Path $workRoot 'verify' +$packageFolder = 'ViiperUde' +$cabName = [System.IO.Path]::GetFileName($outputFullPath) + +try { + New-Item -ItemType Directory -Path $stage, $verify -Force | Out-Null + $sourceByName = [ordered]@{ + 'ViiperUde.inf' = $inf.FullName + 'ViiperUde.sys' = $sys.FullName + 'ViiperUde.pdb' = $pdb.FullName + 'ViiperUde.cat' = $cat.FullName + } + foreach ($entry in $sourceByName.GetEnumerator()) { + Copy-Item -LiteralPath $entry.Value -Destination (Join-Path $stage $entry.Key) + } + + $infVerif = Get-Command infverif.exe -ErrorAction SilentlyContinue + if ($null -ne $infVerif) { + & $infVerif.Source /v (Join-Path $stage 'ViiperUde.inf') + if ($LASTEXITCODE -ne 0) { + throw "InfVerif rejected the staged VIIPER INF with exit code $LASTEXITCODE." + } + } + + $ddfPath = Join-Path $workRoot 'ViiperUde.ddf' + $ddfLines = @( + '.OPTION EXPLICIT', + '.Set CabinetFileCountThreshold=0', + '.Set FolderFileCountThreshold=0', + '.Set FolderSizeThreshold=0', + '.Set MaxCabinetSize=0', + '.Set MaxDiskFileCount=0', + '.Set MaxDiskSize=0', + '.Set CompressionType=MSZIP', + '.Set Cabinet=on', + '.Set Compress=on', + ".Set CabinetNameTemplate=$cabName", + ".Set DiskDirectoryTemplate=$outputDirectory", + ".Set DestinationDir=$packageFolder" + ) + foreach ($name in $sourceByName.Keys) { + $ddfLines += ('"{0}" "{1}"' -f (Join-Path $stage $name), $name) + } + Set-Content -LiteralPath $ddfPath -Value $ddfLines -Encoding ascii + + Push-Location -LiteralPath $workRoot + try { + & $makeCab.Source /V1 /F $ddfPath + } + finally { + Pop-Location + } + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $outputFullPath)) { + throw "MakeCab failed to create '$outputFullPath' (exit code $LASTEXITCODE)." + } + + & $expand.Source -R '-F:*' $outputFullPath $verify | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Expand failed to verify '$outputFullPath' (exit code $LASTEXITCODE)." + } + foreach ($name in $sourceByName.Keys) { + $expanded = @(Get-ChildItem -LiteralPath $verify -Recurse -File -Filter $name) + if ($expanded.Count -ne 1) { + throw "The CAB must contain exactly one '$name'; found $($expanded.Count)." + } + $expectedHash = (Get-FileHash -LiteralPath (Join-Path $stage $name) -Algorithm SHA256).Hash + $actualHash = (Get-FileHash -LiteralPath $expanded[0].FullName -Algorithm SHA256).Hash + if ($actualHash -ne $expectedHash) { + throw "The expanded '$name' does not match the staged input." + } + } + + $manifest = [ordered]@{ + schema = 2 + purpose = 'Microsoft Hardware Dev Center controlled-test attestation submission; not a retail release package' + releaseEligible = $false + signingRoute = 'ControlledTestAttestation' + requiredProductionRoute = 'HLK/WHCP dashboard signing' + sourceRevision = $SourceRevision.ToLowerInvariant() + driverPackageVersion = $driverPackageVersion + driverABIMajor = $driverABIMajor + driverABIMinor = $driverABIMinor + driverCapabilities = ('0x{0:x8}' -f $driverCapabilities) + driverBuildIdentity = $driverBuildIdentity + cabinet = [System.IO.Path]::GetFileName($outputFullPath) + cabinetSha256 = (Get-FileHash -LiteralPath $outputFullPath -Algorithm SHA256).Hash + packageFolder = $packageFolder + files = @( + foreach ($name in $sourceByName.Keys) { + $path = Join-Path $stage $name + [ordered]@{ + name = $name + length = (Get-Item -LiteralPath $path).Length + sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash + } + } + ) + } + $manifestPath = "$outputFullPath.sha256.json" + [System.IO.File]::WriteAllText( + $manifestPath, + ($manifest | ConvertTo-Json -Depth 5), + [System.Text.UTF8Encoding]::new($false)) + + Write-Host "Created exact VIIPER controlled-test attestation package: $outputFullPath" + Write-Host "Hash manifest: $manifestPath" + Write-Warning 'This CAB and any attestation-signed result are testing-only under Microsoft current policy. Do not ship them to retail users. A release requires HLK/WHCP dashboard signing.' +} +finally { + if (Test-Path -LiteralPath $workRoot) { + Remove-Item -LiteralPath $workRoot -Recurse -Force + } +} diff --git a/native/udecx/tools/New-ViiperUdeDebugBundle.ps1 b/native/udecx/tools/New-ViiperUdeDebugBundle.ps1 new file mode 100644 index 00000000..de8ced28 --- /dev/null +++ b/native/udecx/tools/New-ViiperUdeDebugBundle.ps1 @@ -0,0 +1,219 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$RepositoryRoot, + [Parameter(Mandatory = $true)] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] + [string]$SourceRevision, + [Parameter(Mandatory = $true)][string]$DriverImagePath, + [Parameter(Mandatory = $true)][string]$DriverPdbPath, + [Parameter(Mandatory = $true)][string]$DriverMapPath, + [Parameter(Mandatory = $true)][string]$BrokerPath, + [Parameter(Mandatory = $true)][string]$BrokerBuildInfoPath, + [Parameter(Mandatory = $true)][string]$BrokerBuildManifestPath, + [Parameter(Mandatory = $true)][string]$HelperPath, + [Parameter(Mandatory = $true)][string]$HelperPdbPath, + [Parameter(Mandatory = $true)][string]$MediaProbePath, + [Parameter(Mandatory = $true)][string]$MediaProbePdbPath, + [Parameter(Mandatory = $true)][string]$InputProbePath, + [Parameter(Mandatory = $true)][string]$InputProbePdbPath, + [Parameter(Mandatory = $true)][string]$OutputDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-ExactFile { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ExpectedName + ) + + $resolved = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path + $item = Get-Item -LiteralPath $resolved -Force -ErrorAction Stop + if ($item.PSIsContainer -or $item.Name -cne $ExpectedName -or $item.Length -le 0 -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Expected a nonempty, case-exact, non-reparse '$ExpectedName' at '$Path'." + } + return $item +} + +function Copy-DebugArtifact { + param( + [Parameter(Mandatory = $true)]$Source, + [Parameter(Mandatory = $true)][string]$RelativePath, + [Parameter(Mandatory = $true)][string]$Role, + [Parameter(Mandatory = $true)][string]$Root + ) + + $destination = Join-Path $Root $RelativePath.Replace( + '/', [IO.Path]::DirectorySeparatorChar) + [void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) + [IO.File]::Copy($Source.FullName, $destination, $false) + $item = Get-Item -LiteralPath $destination -Force + return [ordered]@{ + path = $RelativePath + role = $Role + length = $item.Length + sha256 = (Get-FileHash -LiteralPath $item.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } +} + +$root = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).Path +$rootItem = Get-Item -LiteralPath $root -Force +if (-not $rootItem.PSIsContainer) { + throw "Repository root is not a directory: '$RepositoryRoot'." +} +$git = Get-Command git.exe -CommandType Application -ErrorAction Stop | + Select-Object -First 1 +$source = $SourceRevision.ToLowerInvariant() +$head = (& $git.Source -C $root rev-parse HEAD 2>&1 | Out-String).Trim().ToLowerInvariant() +if ($LASTEXITCODE -ne 0 -or $head -cne $source) { + throw "Debug source revision '$source' does not match repository HEAD '$head'." +} +$trackedStatus = @(& $git.Source -C $root status --porcelain=v1 --untracked-files=no 2>&1) +if ($LASTEXITCODE -ne 0 -or $trackedStatus.Count -ne 0) { + throw "Refusing a debug bundle from a modified tracked source tree.`n$($trackedStatus -join [Environment]::NewLine)" +} + +$inputs = [ordered]@{ + 'driver-image' = Resolve-ExactFile $DriverImagePath 'ViiperUde.sys' + 'driver-pdb' = Resolve-ExactFile $DriverPdbPath 'ViiperUde.pdb' + 'driver-map' = Resolve-ExactFile $DriverMapPath 'ViiperUde.map' + 'broker-image' = Resolve-ExactFile $BrokerPath 'viiper.exe' + 'broker-build-info' = Resolve-ExactFile $BrokerBuildInfoPath 'viiper.exe.buildinfo.txt' + 'broker-build-manifest' = Resolve-ExactFile $BrokerBuildManifestPath 'viiper.exe.build.json' + 'helper-image' = Resolve-ExactFile $HelperPath 'ViiperUdeCtl.exe' + 'helper-pdb' = Resolve-ExactFile $HelperPdbPath 'ViiperUdeCtl.pdb' + 'media-probe-image' = Resolve-ExactFile $MediaProbePath 'ViiperUdeMediaProbe.exe' + 'media-probe-pdb' = Resolve-ExactFile $MediaProbePdbPath 'ViiperUdeMediaProbe.pdb' + 'input-probe-image' = Resolve-ExactFile $InputProbePath 'ViiperUdeInputProbe.exe' + 'input-probe-pdb' = Resolve-ExactFile $InputProbePdbPath 'ViiperUdeInputProbe.pdb' +} + +try { + $brokerBuildManifest = Get-Content -LiteralPath ` + $inputs['broker-build-manifest'].FullName -Raw -ErrorAction Stop | + ConvertFrom-Json -ErrorAction Stop +} +catch { + throw "Broker build manifest is not valid JSON. $($_.Exception.Message)" +} +$brokerHash = (Get-FileHash -LiteralPath $inputs['broker-image'].FullName ` + -Algorithm SHA256).Hash.ToLowerInvariant() +$buildInfoHash = (Get-FileHash -LiteralPath $inputs['broker-build-info'].FullName ` + -Algorithm SHA256).Hash.ToLowerInvariant() +$buildInfoText = Get-Content -LiteralPath $inputs['broker-build-info'].FullName -Raw +$declaredDwarfSections = @($brokerBuildManifest.embeddedDwarfSections | + ForEach-Object { [string]$_ }) +if ([int]$brokerBuildManifest.schema -ne 1 -or + [string]$brokerBuildManifest.sourceRevision -cne $source -or + [string]$brokerBuildManifest.commit -cne $source -or + [string]::IsNullOrWhiteSpace([string]$brokerBuildManifest.version) -or + [string]::IsNullOrWhiteSpace([string]$brokerBuildManifest.buildDate) -or + [string]::IsNullOrWhiteSpace([string]$brokerBuildManifest.goVersion) -or + -not [bool]$brokerBuildManifest.trimpath -or + -not [bool]$brokerBuildManifest.embeddedDwarf -or + @('debug_info', 'debug_line', 'debug_abbrev' | + Where-Object { $declaredDwarfSections -cnotcontains $_ }).Count -ne 0 -or + [string]$brokerBuildManifest.binary.name -cne 'viiper.exe' -or + [long]$brokerBuildManifest.binary.length -ne $inputs['broker-image'].Length -or + [string]$brokerBuildManifest.binary.sha256 -cne $brokerHash -or + [string]$brokerBuildManifest.buildInfoSha256 -cne $buildInfoHash -or + $buildInfoText -notmatch ('(?m)^\s*build\s+vcs\.revision=' + + [regex]::Escape($source) + '\s*$')) { + throw 'Broker image, embedded-DWARF policy, build metadata, and source revision are not an exact set.' +} + +$output = [IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path -LiteralPath $output) { + throw "Refusing to overwrite debug bundle '$output'." +} +[void][IO.Directory]::CreateDirectory($output) + +$files = [Collections.Generic.List[object]]::new() +try { + $layout = @( + @('driver-image', 'binaries/ViiperUde.sys', 'driver-image'), + @('broker-image', 'binaries/viiper.exe', 'broker-image-with-embedded-go-dwarf'), + @('helper-image', 'binaries/ViiperUdeCtl.exe', 'helper-image'), + @('media-probe-image', 'binaries/ViiperUdeMediaProbe.exe', 'media-probe-image'), + @('input-probe-image', 'binaries/ViiperUdeInputProbe.exe', 'input-probe-image'), + @('driver-pdb', 'symbols/ViiperUde.pdb', 'driver-private-pdb'), + @('driver-map', 'symbols/ViiperUde.map', 'driver-link-map'), + @('helper-pdb', 'symbols/ViiperUdeCtl.pdb', 'helper-private-pdb'), + @('media-probe-pdb', 'symbols/ViiperUdeMediaProbe.pdb', 'media-probe-private-pdb'), + @('input-probe-pdb', 'symbols/ViiperUdeInputProbe.pdb', 'input-probe-private-pdb'), + @('broker-build-info', 'symbols/viiper.exe.buildinfo.txt', 'broker-go-build-info'), + @('broker-build-manifest', 'symbols/viiper.exe.build.json', 'broker-build-manifest') + ) + foreach ($entry in $layout) { + [void]$files.Add((Copy-DebugArtifact -Source $inputs[$entry[0]] ` + -RelativePath $entry[1] -Role $entry[2] -Root $output)) + } + + $archiveName = "VIIPER-source-$source.zip" + $archiveRelative = "source/$archiveName" + $archivePath = Join-Path $output $archiveRelative.Replace( + '/', [IO.Path]::DirectorySeparatorChar) + [void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($archivePath)) + & $git.Source -C $root archive --format=zip "--prefix=VIIPER-$source/" ` + "--output=$archivePath" $source + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $archivePath -PathType Leaf)) { + throw "git archive failed for exact source revision '$source'." + } + $archive = Get-Item -LiteralPath $archivePath -Force + if ($archive.Length -le 0) { + throw 'The exact source archive is empty.' + } + [void]$files.Add([ordered]@{ + path = $archiveRelative + role = 'exact-git-source-archive' + length = $archive.Length + sha256 = (Get-FileHash -LiteralPath $archive.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + }) + + $manifest = [ordered]@{ + schema = 1 + sourceRevision = $source + sourceArchive = $archiveRelative + sourceArchiveFormat = 'git-archive-zip' + symbolPolicy = 'private-source-line-type-sidecars; embedded Go DWARF' + pairs = @( + [ordered]@{ image = 'binaries/ViiperUde.sys'; symbols = 'symbols/ViiperUde.pdb'; map = 'symbols/ViiperUde.map' }, + [ordered]@{ image = 'binaries/ViiperUdeCtl.exe'; symbols = 'symbols/ViiperUdeCtl.pdb' }, + [ordered]@{ image = 'binaries/ViiperUdeMediaProbe.exe'; symbols = 'symbols/ViiperUdeMediaProbe.pdb' }, + [ordered]@{ image = 'binaries/ViiperUdeInputProbe.exe'; symbols = 'symbols/ViiperUdeInputProbe.pdb' }, + [ordered]@{ image = 'binaries/viiper.exe'; symbols = 'embedded-go-dwarf'; buildInfo = 'symbols/viiper.exe.buildinfo.txt'; buildManifest = 'symbols/viiper.exe.build.json' } + ) + files = @($files) + } + $manifestPath = Join-Path $output 'ViiperUdeDebug.manifest.json' + [IO.File]::WriteAllText($manifestPath, + ($manifest | ConvertTo-Json -Depth 7 -Compress), + [Text.UTF8Encoding]::new($false)) + + $roundTrip = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + if ([int]$roundTrip.schema -ne 1 -or + [string]$roundTrip.sourceRevision -cne $source -or + @($roundTrip.files).Count -ne $files.Count) { + throw 'The emitted debug bundle manifest did not round-trip exactly.' + } + foreach ($entry in @($roundTrip.files)) { + $path = Join-Path $output ([string]$entry.path).Replace( + '/', [IO.Path]::DirectorySeparatorChar) + $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop + if ($item.PSIsContainer -or $item.Length -ne [long]$entry.length -or + (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -cne + [string]$entry.sha256) { + throw "Debug bundle verification failed for '$($entry.path)'." + } + } +} +catch { + if (Test-Path -LiteralPath $output) { + Remove-Item -LiteralPath $output -Recurse -Force + } + throw +} + +Write-Host "Created exact source-bound debug bundle at '$output'." diff --git a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 new file mode 100644 index 00000000..3204813a --- /dev/null +++ b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 @@ -0,0 +1,272 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$InfPath, + [Parameter(Mandatory = $true)][string]$SysPath, + [Parameter(Mandatory = $true)][string]$PdbPath, + [Parameter(Mandatory = $true)][string]$CatalogPath, + [Parameter(Mandatory = $true)][string]$TestCertificatePath, + [Parameter(Mandatory = $true)][string]$BrokerPath, + [Parameter(Mandatory = $true)][string]$HelperPath, + [Parameter(Mandatory = $true)][string]$MediaProbePath, + [Parameter(Mandatory = $true)][string]$InputProbePath, + [Parameter(Mandatory = $true)][string]$ProbeManifestPath, + [Parameter(Mandatory = $true)][string]$OutputDirectory, + [Parameter(Mandatory = $true)] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] + [string]$SourceRevision +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-CertificateSha256 { + param([Parameter(Mandatory = $true)]$Certificate) + + $algorithm = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString( + $algorithm.ComputeHash($Certificate.RawData))).Replace('-', '').ToLowerInvariant() + } + finally { + $algorithm.Dispose() + } +} + +function Resolve-ExactInput { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ExpectedName + ) + + $resolved = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path + $item = Get-Item -LiteralPath $resolved -Force + if (-not $item.PSIsContainer -and $item.Length -gt 0 -and + $item.Name -ceq $ExpectedName -and + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) { + return $resolved + } + throw "Local test input must be a nonempty, case-exact, non-reparse '$ExpectedName': '$Path'." +} + +$inputs = [ordered]@{ + 'ViiperUde.inf' = Resolve-ExactInput $InfPath 'ViiperUde.inf' + 'ViiperUde.sys' = Resolve-ExactInput $SysPath 'ViiperUde.sys' + 'ViiperUde.pdb' = Resolve-ExactInput $PdbPath 'ViiperUde.pdb' + 'ViiperUde.cat' = Resolve-ExactInput $CatalogPath 'viiperude.cat' +} +$helper = Resolve-ExactInput $HelperPath 'ViiperUdeCtl.exe' +$broker = Resolve-ExactInput $BrokerPath 'viiper.exe' +$mediaProbe = Resolve-ExactInput $MediaProbePath 'ViiperUdeMediaProbe.exe' +$inputProbe = Resolve-ExactInput $InputProbePath 'ViiperUdeInputProbe.exe' +$probeManifest = Resolve-ExactInput $ProbeManifestPath 'ViiperUdeLiveProbes.manifest.json' +$testCertificate = Resolve-ExactInput $TestCertificatePath 'ViiperUde.cer' + +$output = [IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path -LiteralPath $output) { + throw "Refusing to overwrite local test package '$output'." +} + +$expectedCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( + $testCertificate) +try { + $certificateSha256 = Get-CertificateSha256 $expectedCertificate +} +finally { + $expectedCertificate.Dispose() +} + +[void][IO.Directory]::CreateDirectory($output) +$signedDirectory = Join-Path $output 'signed-package' +$driverDirectory = Join-Path $output 'driver' +[void][IO.Directory]::CreateDirectory($signedDirectory) +[void][IO.Directory]::CreateDirectory($driverDirectory) + +foreach ($entry in $inputs.GetEnumerator()) { + [IO.File]::Copy($entry.Value, (Join-Path $signedDirectory $entry.Key), $false) + if ($entry.Key -cne 'ViiperUde.pdb') { + [IO.File]::Copy($entry.Value, (Join-Path $driverDirectory $entry.Key), $false) + } +} +[IO.File]::Copy($helper, (Join-Path $output 'ViiperUdeCtl.exe'), $false) +[IO.File]::Copy($broker, (Join-Path $output 'viiper.exe'), $false) +[IO.File]::Copy($mediaProbe, (Join-Path $output 'ViiperUdeMediaProbe.exe'), $false) +[IO.File]::Copy($inputProbe, (Join-Path $output 'ViiperUdeInputProbe.exe'), $false) +[IO.File]::Copy($probeManifest, (Join-Path $output 'ViiperUdeLiveProbes.manifest.json'), $false) +$certificatePath = Join-Path $output 'ViiperUdeTest.cer' +[IO.File]::Copy($testCertificate, $certificatePath, $false) + +[xml]$project = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\driver\ViiperUde.vcxproj') -Raw +$namespace = [Xml.XmlNamespaceManager]::new($project.NameTable) +$namespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') +$versionNodes = @($project.SelectNodes('//msb:ViiperUdeDriverVersion', $namespace)) +if ($versionNodes.Count -ne 1) { + throw 'The native driver project must declare one package version.' +} +$driverVersion = $versionNodes[0].InnerText.Trim() +$source = $SourceRevision.ToLowerInvariant() +$buildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` + -SourceRevision $source -DriverPackageVersion $driverVersion ` + -ABIMajor 1 -ABIMinor 14 -Capabilities 61 + +$manifest = [ordered]@{ + schema = 2 + purpose = 'Local test-signed VIIPER UdeCx package; disposable test machines only' + releaseEligible = $false + signingRoute = 'LocalTest' + requiredProductionRoute = 'HLK/WHCP dashboard signing' + sourceRevision = $source + driverPackageVersion = $driverVersion + driverABIMajor = 1 + driverABIMinor = 14 + driverCapabilities = '0x0000003d' + driverBuildIdentity = $buildIdentity + testSignerCertificateSha256 = $certificateSha256 + files = @( + foreach ($entry in $inputs.GetEnumerator()) { + $path = Join-Path $signedDirectory $entry.Key + [ordered]@{ + name = $entry.Key + length = (Get-Item -LiteralPath $path).Length + sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() + } + } + ) +} +$manifestPath = Join-Path $output 'submission-manifest.json' +[IO.File]::WriteAllText($manifestPath, ($manifest | ConvertTo-Json -Depth 5), + [Text.UTF8Encoding]::new($false)) + +$payloadNames = @( + 'viiper.exe', 'ViiperUdeCtl.exe', 'ViiperUdeMediaProbe.exe', 'ViiperUdeInputProbe.exe', + 'ViiperUdeLiveProbes.manifest.json', 'ViiperUdeTest.cer', + 'submission-manifest.json', + 'driver/ViiperUde.inf', 'driver/ViiperUde.sys', 'driver/ViiperUde.cat', + 'signed-package/ViiperUde.inf', 'signed-package/ViiperUde.sys', + 'signed-package/ViiperUde.pdb', 'signed-package/ViiperUde.cat' +) +$lockFiles = @( + foreach ($relative in $payloadNames) { + $path = Join-Path $output $relative.Replace('/', [IO.Path]::DirectorySeparatorChar) + [ordered]@{ + path = $relative + length = (Get-Item -LiteralPath $path).Length + sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() + } + } +) +$installerScriptPath = (Resolve-Path -LiteralPath ( + Join-Path $PSScriptRoot 'Install-ViiperUdeLocalTest.ps1') -ErrorAction Stop).Path +$installerScriptSha256 = (Get-FileHash -LiteralPath $installerScriptPath ` + -Algorithm SHA256).Hash.ToLowerInvariant() +$lock = [ordered]@{ + schema = 1 + sourceRevision = $source + driverPackageVersion = $driverVersion + driverBuildIdentity = $buildIdentity + testSignerCertificateSha256 = $certificateSha256 + installerScriptSha256 = $installerScriptSha256 + files = $lockFiles +} +$lockPath = Join-Path $output 'local-test-package.lock.json' +[IO.File]::WriteAllText($lockPath, + ($lock | ConvertTo-Json -Depth 5), [Text.UTF8Encoding]::new($false)) +$lockSha256 = (Get-FileHash -LiteralPath $lockPath -Algorithm SHA256).Hash.ToLowerInvariant() + +& (Join-Path $PSScriptRoot 'Test-ViiperUdeSignedPackage.ps1') ` + -PackageDirectory $signedDirectory ` + -SubmissionManifestPath $manifestPath ` + -ExpectedSourceRevision $source ` + -ValidationMode LocalTest ` + -LocalTestCertificatePath $certificatePath ` + -RequireLocalTestToolchainValidation + +# Bind the locked installer arguments to the compiled Kong command surface. +$brokerHelpOutput = @(& $broker native-package-install --help 2>&1) +$brokerHelpExitCode = $LASTEXITCODE +$brokerHelpText = $brokerHelpOutput -join [Environment]::NewLine +$expectedBrokerFlags = @( + '--expected-broker-sha-256', '--expected-helper-sha-256', + '--expected-manifest-sha-256', '--expected-inf-sha-256', + '--expected-sys-sha-256', '--expected-cat-sha-256' +) +if ($brokerHelpExitCode -ne 0 -or + @($expectedBrokerFlags | Where-Object { + $brokerHelpText -notmatch [regex]::Escape($_) + }).Count -ne 0) { + throw "Compiled local-test broker command contract is incompatible with the locked installer.`n$brokerHelpText" +} + +# The retained native helper launches this hidden broker command directly. +# Exercise its generated Kong option names so source-bound test artifacts +# cannot ship a helper/broker CLI mismatch that fails after driver mutation. +$brokerCommitHelpOutput = @(& $broker native-package-broker-commit --help 2>&1) +$brokerCommitHelpExitCode = $LASTEXITCODE +$brokerCommitHelpText = $brokerCommitHelpOutput -join [Environment]::NewLine +$expectedBrokerCommitFlags = @( + '--token-file', '--expected-token-sha-256', + '--expected-broker-sha-256', '--target-user-sid', + '--transaction-deadline-unix-ms' +) +if ($brokerCommitHelpExitCode -ne 0 -or + @($expectedBrokerCommitFlags | Where-Object { + $brokerCommitHelpText -notmatch [regex]::Escape($_) + }).Count -ne 0 -or + $brokerCommitHelpText -match '--expected-(?:token|broker)-sha256') { + throw "Compiled nested broker command contract is incompatible with the retained helper.`n$brokerCommitHelpText" +} + +# Exercise the compiled helper's exact read-only SetupAPI/INF contract before +# publishing an installer artifact. Static source checks cannot prove the +# Windows API's two-call buffer-sizing behavior. +$manifestSha256 = (Get-FileHash -LiteralPath $manifestPath ` + -Algorithm SHA256).Hash.ToLowerInvariant() +$infSha256 = (Get-FileHash -LiteralPath (Join-Path $driverDirectory 'ViiperUde.inf') ` + -Algorithm SHA256).Hash.ToLowerInvariant() +$sysSha256 = (Get-FileHash -LiteralPath (Join-Path $driverDirectory 'ViiperUde.sys') ` + -Algorithm SHA256).Hash.ToLowerInvariant() +$catSha256 = (Get-FileHash -LiteralPath (Join-Path $driverDirectory 'ViiperUde.cat') ` + -Algorithm SHA256).Hash.ToLowerInvariant() +$deadline = [DateTimeOffset]::UtcNow.AddMinutes(4).ToUnixTimeMilliseconds().ToString() +$helperVerifyOutput = @(& $helper verify (Join-Path $driverDirectory 'ViiperUde.inf') ` + --manifest $manifestPath ` + --manifest-sha256 $manifestSha256 ` + --source-revision $source ` + --validation-mode local-test ` + --expected-inf-sha256 $infSha256 ` + --expected-sys-sha256 $sysSha256 ` + --expected-cat-sha256 $catSha256 ` + --transaction-deadline-unix-ms $deadline 2>&1) +$helperVerifyExitCode = $LASTEXITCODE +$helperVerifyText = $helperVerifyOutput -join [Environment]::NewLine +if ($helperVerifyExitCode -ne 0 -or + @([regex]::Matches($helperVerifyText, + '(?m)^result=success operation=verify changed=0 rebootRequired=0 rollback=not-needed exitCode=0\r?$')).Count -ne 1) { + throw "Compiled local-test helper verification failed (exit $helperVerifyExitCode).`n$helperVerifyText" +} + +# Run the exact elevated installer validation and protected-staging path under +# inbox Windows PowerShell 5.1 before publishing it. This route never imports +# trust, launches the broker, or changes driver/device/service state. +$windowsPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +$preflightSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value +$preflightOutput = @(& $windowsPowerShell -NoProfile -ExecutionPolicy Bypass ` + -File $installerScriptPath ` + -PackageRoot $output ` + -ExpectedSourceRevision $source ` + -ExpectedPackageLockSHA256 $lockSha256 ` + -TargetUserSID $preflightSid ` + -AcknowledgeDisposableTestMachine ` + -PreflightOnly 2>&1) +$preflightExitCode = $LASTEXITCODE +$preflightText = $preflightOutput -join [Environment]::NewLine +if ($preflightExitCode -ne 0 -or + @([regex]::Matches($preflightText, + '(?m)^result=success operation=local-test-preflight changed=0 rebootRequired=0 rollback=not-needed exitCode=0\r?$')).Count -ne 1) { + throw "Windows PowerShell 5.1 local-test installer preflight failed (exit $preflightExitCode).`n$preflightText" +} + +Write-Host "Created compact source-bound local test package at '$output'." +Write-Host "Source: $source" +Write-Host "Driver: $driverVersion / ABI $($manifest.driverABIMajor).$($manifest.driverABIMinor) / $buildIdentity" +Write-Host "Test signer certificate SHA-256: $certificateSha256" +Write-Host "Local test package lock SHA-256: $lockSha256" diff --git a/native/udecx/tools/Protect-ViiperWindowsReleaseBinaries.ps1 b/native/udecx/tools/Protect-ViiperWindowsReleaseBinaries.ps1 new file mode 100644 index 00000000..c87460b7 --- /dev/null +++ b/native/udecx/tools/Protect-ViiperWindowsReleaseBinaries.ps1 @@ -0,0 +1,172 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string[]]$Paths, + + [Parameter(Mandatory = $true)] + [string]$CertificateBase64, + + [Parameter(Mandatory = $true)] + [string]$CertificatePassword, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{64}$')] + [string]$ExpectedCertificateSHA256, + + [Parameter(Mandatory = $true)] + [string]$SignToolPath, + + [ValidatePattern('^https?://[^\s]+$')] + [string]$TimestampUrl = 'http://timestamp.digicert.com' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-CertificateSha256 { + param( + [Parameter(Mandatory = $true)] + [Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + ) + + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha256.ComputeHash($Certificate.RawData))).Replace('-', '').ToLowerInvariant() + } + finally { + $sha256.Dispose() + } +} + +function Test-CodeSigningEku { + param( + [Parameter(Mandatory = $true)] + [Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + ) + + foreach ($extension in $Certificate.Extensions) { + if ($extension.Oid.Value -ne '2.5.29.37') { + continue + } + $eku = if ($extension -is [Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]) { + $extension + } + else { + [Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($extension, $false) + } + return @($eku.EnhancedKeyUsages | Where-Object Value -ceq '1.3.6.1.5.5.7.3.3').Count -eq 1 + } + return $false +} + +$signTool = (Resolve-Path -LiteralPath $SignToolPath -ErrorAction Stop).Path +if ((Get-Item -LiteralPath $signTool).PSIsContainer -or + [IO.Path]::GetFileName($signTool) -ine 'signtool.exe') { + throw 'SignToolPath must identify the exact restored signtool.exe.' +} + +$resolvedPaths = @() +foreach ($path in $Paths) { + $item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $path -ErrorAction Stop).Path -Force + if ($item.PSIsContainer -or $item.Length -le 0 -or $item.Extension -ine '.exe') { + throw "Release signing accepts only nonempty .exe files; rejected '$path'." + } + $stream = [IO.File]::OpenRead($item.FullName) + try { + if ($stream.ReadByte() -ne 0x4d -or $stream.ReadByte() -ne 0x5a) { + throw "Release signing input '$path' is not a Windows PE image." + } + } + finally { + $stream.Dispose() + } + $resolvedPaths += $item.FullName +} +if ($resolvedPaths.Count -eq 0 -or @($resolvedPaths | Sort-Object -Unique).Count -ne $resolvedPaths.Count) { + throw 'Release signing requires one or more unique executable paths.' +} + +$expectedDigest = $ExpectedCertificateSHA256.ToLowerInvariant() +$pfxPath = Join-Path ([IO.Path]::GetTempPath()) ("viiper-release-signing-{0}.pfx" -f [Guid]::NewGuid().ToString('N')) +$importedCertificates = @() +$preexistingThumbprints = @( + Get-ChildItem Cert:\CurrentUser\My -ErrorAction SilentlyContinue | + ForEach-Object Thumbprint) +try { + try { + $pfxBytes = [Convert]::FromBase64String($CertificateBase64) + } + catch { + throw 'WINDOWS_SIGNING_PFX_BASE64 is not valid base64.' + } + if ($pfxBytes.Length -eq 0) { + throw 'WINDOWS_SIGNING_PFX_BASE64 decoded to an empty file.' + } + [IO.File]::WriteAllBytes($pfxPath, $pfxBytes) + $securePassword = ConvertTo-SecureString -String $CertificatePassword -AsPlainText -Force + $importedCertificates = @( + Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation Cert:\CurrentUser\My ` + -Password $securePassword -Exportable:$false) + $signers = @($importedCertificates | Where-Object { $_.HasPrivateKey -and (Test-CodeSigningEku $_) }) + if ($signers.Count -ne 1) { + throw "The release PFX must contain exactly one private-key certificate with the Code Signing EKU; found $($signers.Count)." + } + $certificate = $signers[0] + if ((Get-CertificateSha256 $certificate) -cne $expectedDigest) { + throw 'The release PFX certificate does not match WINDOWS_SIGNING_CERTIFICATE_SHA256.' + } + if ($certificate.Subject -ceq $certificate.Issuer -or + $certificate.Subject -match '(?i)(^|[ ,])(test|self[- ]?signed)([ ,]|$)') { + throw 'Self-signed or test-named certificates cannot sign a public VIIPER release.' + } + $now = [DateTime]::UtcNow + if ($now -lt $certificate.NotBefore.ToUniversalTime() -or + $now -gt $certificate.NotAfter.ToUniversalTime()) { + throw 'The release code-signing certificate is not currently valid.' + } + + $chain = New-Object Security.Cryptography.X509Certificates.X509Chain + try { + $chain.ChainPolicy.RevocationMode = [Security.Cryptography.X509Certificates.X509RevocationMode]::Online + $chain.ChainPolicy.RevocationFlag = [Security.Cryptography.X509Certificates.X509RevocationFlag]::ExcludeRoot + $chain.ChainPolicy.VerificationFlags = [Security.Cryptography.X509Certificates.X509VerificationFlags]::NoFlag + if (-not $chain.Build($certificate)) { + $status = @($chain.ChainStatus | ForEach-Object StatusInformation) -join '; ' + throw "The release code-signing certificate did not build a trusted revocation-checked chain: $status" + } + } + finally { + $chain.Dispose() + } + + foreach ($path in $resolvedPaths) { + & $signTool sign /sha1 $certificate.Thumbprint /s My /fd SHA256 ` + /tr $TimestampUrl /td SHA256 $path + if ($LASTEXITCODE -ne 0) { + throw "SignTool failed to sign '$path' (exit $LASTEXITCODE)." + } + & $signTool verify /pa /all /v $path + if ($LASTEXITCODE -ne 0) { + throw "SignTool failed Authenticode policy verification for '$path' (exit $LASTEXITCODE)." + } + $signature = Get-AuthenticodeSignature -LiteralPath $path + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or + $null -eq $signature.SignerCertificate -or + $null -eq $signature.TimeStamperCertificate -or + (Get-CertificateSha256 $signature.SignerCertificate) -cne $expectedDigest -or + -not (Test-CodeSigningEku $signature.SignerCertificate)) { + throw "'$path' does not have the expected trusted, timestamped production Authenticode signature." + } + Write-Host "Signed and verified $path with certificate SHA-256 $expectedDigest." + } +} +finally { + foreach ($certificate in $importedCertificates) { + if ($preexistingThumbprints -cnotcontains $certificate.Thumbprint) { + Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($certificate.Thumbprint)" -Force -ErrorAction SilentlyContinue + } + } + if (Test-Path -LiteralPath $pfxPath) { + Remove-Item -LiteralPath $pfxPath -Force + } +} diff --git a/native/udecx/tools/Set-ViiperCrashDiagnostics.ps1 b/native/udecx/tools/Set-ViiperCrashDiagnostics.ps1 new file mode 100644 index 00000000..fd195e91 --- /dev/null +++ b/native/udecx/tools/Set-ViiperCrashDiagnostics.ps1 @@ -0,0 +1,248 @@ +[CmdletBinding()] +param( + [ValidateSet('Status', 'Enable', 'Restore')] + [string]$Mode = 'Status', + + [ValidateSet('Complete', 'Kernel', 'Automatic')] + [string]$DumpType = 'Complete', + + [string]$StatePath, + + [switch]$AcknowledgeDiskUse +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$crashControlPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl' +$memoryManagementPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management' +if ([string]::IsNullOrWhiteSpace($StatePath)) { + $StatePath = Join-Path $env:ProgramData 'Viiper\diagnostics\crash-policy-backup.json' +} + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Crash-diagnostic configuration requires an elevated PowerShell session.' + } +} + +function Get-RegistryValueSnapshot { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string[]]$Names + ) + + $key = Get-Item -LiteralPath $Path -ErrorAction Stop + $presentNames = @($key.GetValueNames()) + $values = [ordered]@{} + foreach ($name in $Names) { + $present = $presentNames -contains $name + $values[$name] = [ordered]@{ + present = $present + kind = if ($present) { $key.GetValueKind($name).ToString() } else { $null } + value = if ($present) { + $key.GetValue($name, $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + } + else { $null } + } + } + return $values +} + +function Set-RegistryValueFromSnapshot { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)]$Snapshot + ) + + if (-not [bool]$Snapshot.present) { + Remove-ItemProperty -LiteralPath $Path -Name $Name -ErrorAction SilentlyContinue + return + } + $propertyType = switch ([string]$Snapshot.kind) { + 'DWord' { 'DWord' } + 'QWord' { 'QWord' } + 'String' { 'String' } + 'ExpandString' { 'ExpandString' } + 'MultiString' { 'MultiString' } + 'Binary' { 'Binary' } + default { throw "Unsupported saved registry kind '$($Snapshot.kind)' for '$Name'." } + } + $value = $Snapshot.value + if ($propertyType -eq 'MultiString') { + $value = @($value | ForEach-Object { [string]$_ }) + } + New-ItemProperty -LiteralPath $Path -Name $Name -Value $value ` + -PropertyType $propertyType -Force | Out-Null +} + +function Write-StateFile { + param([Parameter(Mandatory = $true)]$State) + + $fullPath = [IO.Path]::GetFullPath($StatePath) + $directory = Split-Path -Parent $fullPath + New-Item -ItemType Directory -Path $directory -Force | Out-Null + $temporary = "$fullPath.tmp" + [IO.File]::WriteAllText($temporary, ($State | ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false)) + [IO.File]::Replace($temporary, $fullPath, $null, $true) +} + +function Write-NewStateFile { + param([Parameter(Mandatory = $true)]$State) + + $fullPath = [IO.Path]::GetFullPath($StatePath) + $directory = Split-Path -Parent $fullPath + New-Item -ItemType Directory -Path $directory -Force | Out-Null + if (Test-Path -LiteralPath $fullPath) { + throw "Crash-diagnostic backup already exists at '$fullPath'; restore it before replacing policy." + } + $temporary = "$fullPath.tmp" + [IO.File]::WriteAllText($temporary, ($State | ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $temporary -Destination $fullPath +} + +function Get-CurrentStatus { + $computer = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop + $pageUsage = @(Get-CimInstance Win32_PageFileUsage -ErrorAction SilentlyContinue) + $crash = Get-RegistryValueSnapshot -Path $crashControlPath -Names @( + 'CrashDumpEnabled', 'DumpFile', 'AlwaysKeepMemoryDump', 'Overwrite') + $paging = Get-ItemProperty -LiteralPath $memoryManagementPath -ErrorAction Stop + [ordered]@{ + crashDumpEnabled = if ($crash.CrashDumpEnabled.present) { + [int]$crash.CrashDumpEnabled.value + } else { 0 } + dumpFile = if ($crash.DumpFile.present) { + [string]$crash.DumpFile.value + } else { '' } + alwaysKeepMemoryDump = if ($crash.AlwaysKeepMemoryDump.present) { + [int]$crash.AlwaysKeepMemoryDump.value + } else { 0 } + overwrite = if ($crash.Overwrite.present) { + [int]$crash.Overwrite.value + } else { 0 } + automaticManagedPagefile = [bool]$computer.AutomaticManagedPagefile + pagingFiles = @($paging.PagingFiles) + totalPhysicalMemoryBytes = [uint64]$computer.TotalPhysicalMemory + pagefiles = @($pageUsage | ForEach-Object { + [ordered]@{ + name = [string]$_.Name + allocatedMB = [uint32]$_.AllocatedBaseSize + currentUsageMB = [uint32]$_.CurrentUsage + peakUsageMB = [uint32]$_.PeakUsage + } + }) + policyBackup = [IO.Path]::GetFullPath($StatePath) + policyBackupPresent = Test-Path -LiteralPath $StatePath -PathType Leaf + } +} + +if ($Mode -eq 'Status') { + Get-CurrentStatus | ConvertTo-Json -Depth 6 + return +} + +Assert-Administrator + +$crashNames = @('CrashDumpEnabled', 'DumpFile', 'AlwaysKeepMemoryDump', + 'Overwrite', 'LogEvent', 'AutoReboot', 'FilterPages') +$memoryNames = @('PagingFiles') + +if ($Mode -eq 'Restore') { + $stateFile = Resolve-Path -LiteralPath $StatePath -ErrorAction Stop + $state = Get-Content -LiteralPath $stateFile.Path -Raw | ConvertFrom-Json + if ([int]$state.schema -ne 1 -or [string]$state.machine -cne $env:COMPUTERNAME) { + throw 'Crash-diagnostic backup schema or machine identity does not match this machine.' + } + foreach ($name in $crashNames) { + Set-RegistryValueFromSnapshot -Path $crashControlPath -Name $name ` + -Snapshot $state.crashControl.$name + } + foreach ($name in $memoryNames) { + Set-RegistryValueFromSnapshot -Path $memoryManagementPath -Name $name ` + -Snapshot $state.memoryManagement.$name + } + $state | Add-Member -NotePropertyName restoredUtc -NotePropertyValue ` + ([DateTime]::UtcNow.ToString('o')) -Force + Write-StateFile -State $state + Write-Host 'The prior crash-dump and pagefile policy is restored. Restart Windows to apply pagefile changes.' + return +} + +if (-not $AcknowledgeDiskUse) { + throw 'Enabling full crash diagnostics can reserve substantial disk space. Pass -AcknowledgeDiskUse.' +} + +$computer = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop +$physicalMB = [uint64][Math]::Ceiling( + [double]$computer.TotalPhysicalMemory / 1MB) +$requiredPagefileMB = $physicalMB + 300 +$dumpTypeValue = switch ($DumpType) { + 'Complete' { 1 } + 'Kernel' { 2 } + 'Automatic' { 7 } +} +$systemDrive = $env:SystemDrive.TrimEnd('\') +$logicalDisk = Get-CimInstance Win32_LogicalDisk -Filter ` + "DeviceID='$systemDrive'" -ErrorAction Stop +if ($DumpType -eq 'Complete') { + $requiredFreeBytes = ([uint64]$requiredPagefileMB * 2MB) + 10GB + if ([uint64]$logicalDisk.FreeSpace -lt $requiredFreeBytes) { + throw "Complete-dump policy needs at least $([Math]::Ceiling($requiredFreeBytes / 1GB)) GB free on $systemDrive for pagefile, dump, and safety headroom." + } +} + +$state = [ordered]@{ + schema = 1 + machine = $env:COMPUTERNAME + capturedUtc = [DateTime]::UtcNow.ToString('o') + requestedDumpType = $DumpType + totalPhysicalMemoryBytes = [uint64]$computer.TotalPhysicalMemory + crashControl = Get-RegistryValueSnapshot -Path $crashControlPath -Names $crashNames + memoryManagement = Get-RegistryValueSnapshot -Path $memoryManagementPath -Names $memoryNames +} +Write-NewStateFile -State $state + +try { + New-ItemProperty -LiteralPath $crashControlPath -Name CrashDumpEnabled ` + -Value $dumpTypeValue -PropertyType DWord -Force | Out-Null + New-ItemProperty -LiteralPath $crashControlPath -Name DumpFile ` + -Value '%SystemRoot%\MEMORY.DMP' -PropertyType ExpandString -Force | Out-Null + New-ItemProperty -LiteralPath $crashControlPath -Name AlwaysKeepMemoryDump ` + -Value 1 -PropertyType DWord -Force | Out-Null + New-ItemProperty -LiteralPath $crashControlPath -Name Overwrite ` + -Value 1 -PropertyType DWord -Force | Out-Null + New-ItemProperty -LiteralPath $crashControlPath -Name LogEvent ` + -Value 1 -PropertyType DWord -Force | Out-Null + Remove-ItemProperty -LiteralPath $crashControlPath -Name FilterPages ` + -ErrorAction SilentlyContinue + if ($DumpType -eq 'Complete') { + $pagingFile = "$systemDrive\pagefile.sys $requiredPagefileMB $requiredPagefileMB" + New-ItemProperty -LiteralPath $memoryManagementPath -Name PagingFiles ` + -Value @($pagingFile) -PropertyType MultiString -Force | Out-Null + } +} +catch { + foreach ($name in $crashNames) { + Set-RegistryValueFromSnapshot -Path $crashControlPath -Name $name ` + -Snapshot $state.crashControl[$name] + } + foreach ($name in $memoryNames) { + Set-RegistryValueFromSnapshot -Path $memoryManagementPath -Name $name ` + -Snapshot $state.memoryManagement[$name] + } + throw +} + +Write-Host "$DumpType memory dumps are enabled at %SystemRoot%\MEMORY.DMP." +if ($DumpType -eq 'Complete') { + Write-Host "The next boot will reserve a $requiredPagefileMB MB system-drive pagefile." +} +Write-Host "Original policy backup: $([IO.Path]::GetFullPath($StatePath))" +Write-Host 'Restart Windows before fault injection so the pagefile and dump policy are active.' diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 new file mode 100644 index 00000000..5ec6fe46 --- /dev/null +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -0,0 +1,999 @@ +[CmdletBinding()] +param( + [string]$SourcePath, + [string]$BinaryPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($SourcePath)) { + $SourcePath = Join-Path $PSScriptRoot 'ViiperUdeCtl.cpp' +} + +$source = Get-Content -LiteralPath $SourcePath -Raw + +function Get-SourceContractRegion { + param( + [Parameter(Mandatory = $true)][string]$Text, + [Parameter(Mandatory = $true)][string]$Start, + [Parameter(Mandatory = $true)][string]$End, + [Parameter(Mandatory = $true)][string]$Name, + [switch]$LastStart + ) + + $startIndex = if ($LastStart) { + $Text.LastIndexOf($Start, [StringComparison]::Ordinal) + } else { + $Text.IndexOf($Start, [StringComparison]::Ordinal) + } + $endIndex = if ($startIndex -ge 0) { + $Text.IndexOf($End, $startIndex + $Start.Length, [StringComparison]::Ordinal) + } else { + -1 + } + if ($startIndex -lt 0 -or $endIndex -le $startIndex) { + throw "ViiperUdeCtl $Name source region is missing or malformed." + } + return $Text.Substring($startIndex, $endIndex - $startIndex) +} + +function Assert-OrderedSourceFragments { + param( + [Parameter(Mandatory = $true)][string]$Text, + [Parameter(Mandatory = $true)][string[]]$Fragments, + [Parameter(Mandatory = $true)][string]$Name + ) + + $cursor = -1 + foreach ($fragment in $Fragments) { + $next = $Text.IndexOf( + $fragment, $cursor + 1, [StringComparison]::Ordinal) + if ($next -lt 0) { + throw "ViiperUdeCtl violates its $Name ordering contract at '$fragment'." + } + $cursor = $next + } +} + +$requiredContracts = [ordered]@{ + 'source-manifest preflight' = 'ValidateManifest\(' + 'installer manifest hash binding' = '--manifest-sha256' + 'read-only package verification' = 'Outcome Verify\(' + 'catalog signature preflight' = 'SetupVerifyInfFileW\(' + 'Microsoft hardware publisher gate' = 'VerifyMicrosoftHardwareInfSigner\(' + 'exact SYS catalog membership' = 'VerifyDriverCatalogMember\(' + 'exact INF catalog membership' = 'VerifyDriverCatalogMember\(catalogPath, infPath' + 'Windows driver catalog policy' = 'WinVerifyTrust\(' + 'System32-only catalog API loading' = 'LoadLibraryExW\([\s\S]*LOAD_LIBRARY_SEARCH_SYSTEM32' + 'documented dynamic catalog API contract' = 'GetProcAddress\(' + 'production hardware verification EKU' = '1\.3\.6\.1\.4\.1\.311\.10\.3\.5' + 'production attestation rejection' = '1\.3\.6\.1\.4\.1\.311\.10\.3\.5\.1' + 'signed-certificate EKU extension only' = 'CERT_FIND_EXT_ONLY_ENHKEY_USAGE_FLAG' + 'published INF capture' = 'SetupGetInfPublishedNameW\(' + 'driver-store source capture' = 'SetupGetInfDriverStoreLocationW\(' + 'installed INF ownership' = 'DEVPKEY_Device_DriverInfPath' + 'installed version ownership' = 'DEVPKEY_Device_DriverVersion' + 'documented add-only package staging' = 'SetupCopyOEMInfW\(' + 'non-overwriting package staging' = 'SP_COPY_NOOVERWRITE' + 'documented idempotent package staging result' = 'copyError != ERROR_FILE_EXISTS' + 'documented package removal' = 'DiUninstallDriverW\(' + 'ABI health negotiation' = 'IOCTL_VIIPER_UDE_NEGOTIATE' + 'pristine upgrade statistics' = 'IOCTL_VIIPER_UDE_QUERY_STATS' + 'pristine upgrade reboot boundary' = 'upgrade-runtime-reboot-boundary' + 'all running-root driver mutations require pristine proof' = + 'const bool requiresPristineRuntimeProof =[\s\S]{0,180}RequiresPristineRuntimeProof\(' + 'already-staged exact binding is classified as a driver mutation' = + 'RequiresDriverMutation\(disposition, exactBindingHealthy\)' + 'stopped and absent roots skip unavailable live ABI proof' = + 'self-test-pristine-runtime-decision' + 'every nonzero runtime counter is rejected' = + 'self-test-pristine-runtime-stats' + 'loaded-kernel build identity negotiation' = 'response\.BuildIdentity' + 'exact negotiated capability identity' = 'response\.Capabilities == profile\.capabilities' + 'explicit ABI 1.14 profile' = '\{14, 61, 152, true\}' + 'explicit ABI 1.13 profile' = '\{13, 29, 152, true\}' + 'explicit ABI 1.12 profile' = '\{12, 29, 152, true\}' + 'explicit ABI 1.11 profile' = '\{11, 29, 144, false\}' + 'explicit ABI 1.10 profile' = '\{10, 13, 144, false\}' + 'strict ABI profile order' = 'AbiCompatibilityProfilesAreValid\(\)' + 'legacy statistics boundary assertion' = 'offsetof\(VIIPER_UDE_STATS, ReservedPorts\) == 144' + 'previous ABI pristine-upgrade boundary' = 'IsAbiRetryEligible\(' + 'previous ABI version-mismatch retry errors' = + 'error\.code == ERROR_REVISION_MISMATCH[\s\S]{0,120}error\.code == ERROR_INVALID_PARAMETER[\s\S]{0,180}abi-negotiate-result' + 'exact ABI negotiation response validation' = 'AbiNegotiationResponseMatchesProfile\(' + 'exact ABI statistics response validation' = 'StatsRecordMatchesProfile\(' + 'previous ABI stats header validation' = 'stats\.Header\.Minor == profile\.minor' + 'previous ABI stats wire size' = 'stats\.Header\.Size == profile\.statsSize' + 'reserved-port wire-range validation' = 'stats\.ReservedPorts <= VIIPER_UDE_MAX_DEVICES' + 'stats reserved-word validation' = 'stats\.Reserved == 0' + 'reserved-port pristine-runtime gate' = + '!profile\.hasReservedPortFields \|\| stats\.ReservedPorts == 0' + 'source-bound manifest identity' = 'driverBuildIdentity' + 'same-ABI stale-kernel rejection' = 'expectedBuildIdentity' + 'install rollback' = 'RollbackInstall\(' + 'exact staged-here rollback removal' = 'SetupUninstallOEMInfW\(' + 'non-forced staged-here rollback removal' = + 'SetupUninstallOEMInfW\([\s\S]{0,120}stagedCandidate\.publishedName\.c_str\(\), 0, nullptr' + 'exact rollback package inventory proof' = 'VerifyPackageInventory\(' + 'formerly-running rollback start proof' = 'rollback-runtime-start-verification' + 'formerly-running rollback ABI proof' = 'AbiHealthPurpose::RollbackHealth' + 'captured stopped rollback state proof' = 'rollback-stopped-state-verification' + 'exact rollback lifecycle comparator' = 'RollbackLifecycleStateMatches\(' + 'stage mutation marked before SetupCopy' = + 'MarkTransactionMutationStarted\(\);[\s\S]{0,500}SetupCopyOEMInfW\(' + 'successful stage retains cleanup ownership' = '\*stagedHere = true' + 'malformed stage receipt recovery' = + 'FindPublishedCandidate\([\s\S]{0,160}recoveredReceipt' + 'post-stage exact inventory proof' = 'stage-package-inventory-verification' + 'post-quiescence exact inventory proof' = 'post-quiescence-package-inventory-verification' + 'final pre-bind exact inventory proof' = 'final-pre-bind-package-inventory-verification' + 'post-bind exact inventory proof' = 'post-bind-package-inventory-verification' + 'post-stage full root invariance proof' = 'stage-root-binding-verification' + 'post-quiescence full root invariance proof' = 'post-quiescence-root-verification' + 'prepared-driver final root invariance proof' = 'final-pre-bind-root-verification' + 'fresh global pre-bind topology proof' = 'final-pre-bind-root-topology-verification' + 'read-only compatible-driver preparation' = 'PreparePreinstalledDriverOnDevice\(' + 'immediate selected-device binding commit' = 'CommitPreparedDriverBinding\(' + 'exact final pristine ABI recheck' = 'AbiHealthPurpose::PristineRecheck' + 'broker deadline before quiescence signal' = + 'transaction-deadline-before-broker-quiescence[\s\S]{0,700}SetEvent\(options\.brokerQuiesceRequest\)' + 'broker health transaction' = 'RunBrokerInstall\(' + 'canonical broker proof parser' = 'ParseBrokerCommitProof\(' + 'bounded broker proof channel' = 'kMaximumBrokerProofBytes' + 'bounded sanitized broker diagnostic' = 'SanitizeBrokerDiagnostic\(' + 'separate nested application exit reporting' = 'nestedExitCode=' + 'broker failure Win32 mapping' = 'SetError\(error, phase, ERROR_INSTALL_FAILURE' + 'ambiguous broker diagnostic rejection' = 'diagnosticRejected = true' + 'explicit inherited broker handles' = 'PROC_THREAD_ATTRIBUTE_HANDLE_LIST' + 'indeterminate broker wait retention' = 'GetExitCodeProcess\(processHandle\.get\(\), &observedExit\)' + 'production broker requirement' = 'broker-required' + 'local test broker transaction requirement' = 'options\.production \|\| options\.localTest' + 'explicit local test route' = 'validation mode must be production, controlled-test, or local-test' + 'local test manifest separation' = '"signingRoute"[\s\S]{0,6000}"LocalTest"' + 'non-release local test enforcement' = 'else if \(localTest\)[\s\S]{0,900}\*releaseValue' + 'local test signer digest shape' = 'testSignerCertificateSha256Value->size\(\) == 64' + 'local test native signer verification' = 'VerifyLocalTestPackageSigner\(' + 'local test exact signer certificate digest' = + 'actualCertificateSha256 != expectedCertificateSha256' + 'local test INF and SYS catalog membership' = + 'bool VerifyLocalTestPackageSigner\([\s\S]{0,220}Error\* error\) \{[\s\S]{0,1800}VerifyDriverCatalogMember\(catalogPath, infPath[\s\S]{0,180}infPath\.parent_path\(\) / kDriverFileName' + 'staged broker hash binding' = '--broker-sha256' + 'protected package token binding' = '--broker-token-sha256' + 'inherited broker quiescence request' = '--broker-quiesce-request-handle' + 'inherited broker quiescence readiness' = '--broker-quiesce-ready-handle' + 'inherited broker quiescence abort' = '--broker-quiesce-abort-handle' + 'inherited broker service handoff' = '--broker-handoff-handle' + 'driver mutation broker quiescence' = 'RequestBrokerQuiescence\(' + 'verified binding broker handoff' = 'SignalBrokerHandoff\(' + 'inherited event handle validation' = 'ParseInheritedEventHandle\(' + 'nested package broker commit' = 'native-package-broker-commit' + 'nested broker expected token hash option' = '--expected-token-sha-256' + 'nested broker expected executable hash option' = '--expected-broker-sha-256' + 'cooperative package deadline' = '--transaction-deadline-unix-ms' + 'same-handle manifest binding' = 'Sha256Handle\(manifest\.get\(\)' + 'final exact package enumeration' = 'ValidateExactPackageDirectory\(' + 'reboot boundary rollback' = 'broker-reboot-boundary' + 'fixed remove recovery root' = + 'kRemoveRecoveryRootDirectory\[\][\s\S]{0,60}L"VIIPER-UdeCx-RemoveTransactions"' + 'single remove active identity' = + 'kRemoveRecoveryActiveDirectory\[\] = L"active-v2"' + 'remove protected prior backup' = 'BackupPackagesIntoDirectory\(' + 'protected rollback directory' = 'kRollbackDirectorySecurity' + 'inherited rollback protection' = 'O:BAD:P\(A;OICI;FA;;;SY\)\(A;OICI;FA;;;BA\)' + 'verified protected rollback ACLs' = 'VerifyProtectedFileSystemSecurity\(' + 'protected exact rollback file copy' = 'CopyProtectedBackupFile\(' + 'exact rollback package tree' = 'ValidateExactPackageDirectory\(destination' + 'durable rollback package payloads' = 'rollback-backup-file-flush' + 'immutable rollback package files' = 'LockPackageFiles\(destination, &locks' + 'protected recovery record' = 'kRecoveryRecordSecurity' + 'explicit recovery record flush' = 'FlushFileBuffers\(file\.get\(\)\)' + 'atomic recovery record publish' = + 'MoveFileExW\([\s\S]{0,180}MOVEFILE_WRITE_THROUGH' + 'remove record read-back verification' = 'remove-journal-readback' + 'remove hash chain' = 'WriteRemoveJournalRecord\(' + 'remove automatic recovery' = 'ReconcileRemoveJournal\(' + 'remove legal transitions' = 'ValidateRemoveJournalTransition\(' + 'remove manual latch' = 'RemoveJournalPhase::ManualReconciliationRequired' + 'remove deterministic model' = 'RunRemoveJournalModelSelfTest\(' + 'recovery record path emission' = 'recoveryRecordWritten=' + 'retained backup path emission' = 'recoveryBackupRetained=' + 'recovery relative path confinement' = 'IsSafeRecoveryRelativePath\(' + 'exact package cutpoint identity' = '\\"activePackageIndex\\":' + 'atomic remove terminal retirement' = 'RetireRemoveRecoveryActiveDirectory\(' + 'loaded remove terminal retirement' = 'RetireLoadedRemoveJournal\(' + 'remove descendant lock release regression' = + 'RunRemoveJournalRetirementSelfTest\(' + 'single captured remove target' = 'RemoveExactCapturedDevice\(' + 'remove rollback exact-absence restore policy' = + 'RestorePriorBindingPolicy::RemoveJournalExactAbsence' + 'fresh remove rollback deadline' = 'FreshRemoveRollbackDeadline\(' + 'crossed remove reboot manual latch' = + 'CrossedRemoveRebootStillPendingRequiresManual\(' + 'retained settled tombstone warning' = + 'warning=\\"remove-settled-cleanup-retained\\"' + 'top-level exception boundary' = 'catch \(\.\.\.\)' + 'exception-safe active recovery path' = 'gActiveRecoveryRecordWritten' + 'exception-safe mutation classification' = 'gTransactionMutationStarted' + 'remove deadline parser' = 'ParseRemoveOptions\(' + 'remove mutation deadline' = 'remove-journal-package-deadline' + 'finite remove rollback ceiling' = 'kDriverRollbackCeilingMs' + 'remove rollback deadline' = 'remove-journal-restore-package-deadline' + 'transaction mutex' = 'VIIPER_UDE_DRIVER_TRANSACTION_V1' + 'protected private transaction namespace' = 'CreatePrivateNamespaceW\(' + 'protected transaction object DACL' = 'D:P\(A;;GA;;;SY\)\(A;;GA;;;BA\)' + 'acquired transaction mutex ownership' = 'WaitForSingleObject\(mutex_\.get\(\), 0\)' + 'abandoned transaction recovery' = 'WAIT_ABANDONED' + 'transaction mutex release' = 'ReleaseMutex\(' + 'overlapped ABI negotiation' = 'FILE_FLAG_OVERLAPPED' + 'deadline cancellation' = 'CancelIoEx\(' + 'cancelled IO drain ceiling' = 'kCancelledIoDrainMs' + 'finite broker rollback ceiling' = 'kBrokerRollbackCeilingMs' + 'nested rollback budget composition' = '3ULL \* 60ULL \* 1000ULL' + 'forward root mutation deadline' = 'transaction-deadline-before-root-registration' + 'forward root property deadline' = 'transaction-deadline-before-root-properties' + 'device binding mutation deadline' = 'transaction-deadline-before-selected-device-binding' + 'driver package mutation deadline' = 'transaction-deadline-before-driver-stage' + 'finite install rollback deadline' = 'install-rollback-deadline-staged-package' + 'selected driver mutation deadline' = 'transaction-deadline-before-selected-device-binding' + 'owned generated root namespace' = 'kRootDeviceName\[\] = L"VIIPERUDE"' + 'legacy generated root rollback namespace' = 'kLegacyRootDeviceName\[\] = L"USB"' + 'exact generated root identity validation' = 'IsOwnedGeneratedRootInstanceId\(' + 'forward generated root identity verification' = 'verify-generated-root-instance-id' + 'post-registration cleanup state' = 'registrationSucceeded' + 'captured root namespace validation' = 'device-instance-ownership' + 'actual remove-device mutation deadline' = 'remove-deadline-before-device-mutation' + 'rollback remove-device mutation deadline' = 'rollback-deadline-before-device-removal' + 'rollback root property deadline' = 'rollback-deadline-before-root-properties' + 'rollback root registration deadline' = 'rollback-deadline-before-root-registration' + 'exact rollback devnode identity' = 'RegisterRootDeviceExact\(' + 'rollback identity verification' = 'rollback-identity-verification' + 'in-place existing-root binding' = 'SameEnumeratedRootState\(' + 'structured reboot exit' = 'ERROR_SUCCESS_REBOOT_REQUIRED' + 'guarded downgrade' = '--allow-controlled-downgrade' +} + +foreach ($entry in $requiredContracts.GetEnumerator()) { + if ($source -notmatch $entry.Value) { + throw "ViiperUdeCtl is missing its $($entry.Key) contract." + } +} + +$installJournalRequired = [ordered]@{ + 'known-folder ProgramData resolution' = 'SHGetKnownFolderPath\(' + 'exact ProgramData known-folder identity' = 'FOLDERID_ProgramData' + 'fixed VIIPER recovery segment' = 'kInstallRecoveryProductDirectory\[\] = L"VIIPER"' + 'fixed UdeCx recovery segment' = 'kInstallRecoveryComponentDirectory\[\] = L"UdeCx"' + 'fixed transaction recovery segment' = 'kInstallRecoveryTransactionsDirectory\[\] = L"Transactions"' + 'single active recovery identity' = 'kInstallRecoveryActiveDirectory\[\] = L"active-v2"' + 'append-only journal prefix' = 'kInstallRecoveryJournalPrefix\[\] = L"journal-"' + 'protected recovery directory open' = 'OpenStableDirectory\(' + 'reparse-safe recovery directory handles' = 'FILE_FLAG_OPEN_REPARSE_POINT' + 'reparse rejection for recovery directories' = 'FILE_ATTRIBUTE_REPARSE_POINT' + 'exact recovery ACL verification' = 'VerifyProtectedFileSystemSecurity\(' + 'install journal writer' = 'WriteInstallJournalRecord\(' + 'journal previous-record hash' = '\\"previousSha256\\"' + 'journal envelope hash' = '\\"payloadSha256\\"' + 'journal hash-chain comparison' = 'parsed\.previousDigest[\s\S]{0,120}priorDigest' + 'write-through journal file' = 'FILE_FLAG_WRITE_THROUGH' + 'flushed journal bytes' = 'install-journal-flush' + 'write-through journal publication' = 'MOVEFILE_WRITE_THROUGH' + 'published journal readback' = 'install-journal-readback' + 'explicit recovery command' = 'Outcome Recover\(' + 'automatic pre-mutation recovery' = 'ReconcileInstallJournal\(' + 'recovery CLI route' = '_wcsicmp\(argv\[1\], L"recover"\)' + 'broker-unsettled manual retention' = + 'loaded\.state\.brokerEntered[\s\S]{0,120}!rollbackWasAuthorized[\s\S]{0,500}no mutation was attempted' + 'durable rollback direction' = '\"direction\"[\s\S]{0,200}\"rollbackAuthorized\"' + 'legal journal transitions' = 'ValidateInstallJournalTransition\(' + 'poisoned append latch' = 'impl_->poisoned = true' + 'canonical next-temp cleanup' = 'ValidateAndDiscardInstallJournalTemporaryFile\(' + 'verified existing component walk' = 'OpenExistingInstallRecoveryDirectory\(' + 'generic ACL normalization' = 'MapGenericMask\(' + 'durable-only stage ownership' = 'StageReceiptCaptured ownership record' + 'exact pre-rollback inventory' = 'exactPreRollbackInventory' + 'exact root rollback authority' = 'RootSnapshotIsAuthorizedForInstallRollback\(' + 'same-boot rollback reboot cutpoint' = 'InstallJournalNeedsRestoreRebootPending\(' + 'durable pending reboot boot epoch' = 'pendingRebootBootIdentifier' + 'fresh reboot epoch authority' = 'freshRebootRequired' + 'durable generated root receipt' = 'rootRegistrationInstanceId' + 'pre-registration root receipt phase' = 'RootRegistrationIntentCaptured' + 'broad prior-empty root observer' = 'ObservePriorEmptyInstallRecoveryRoot\(' + 'canonical raw hardware ID reader' = 'ReadInstallRecoveryHardwareIds\(' + 'canonical raw string reader' = 'DecodeCanonicalInstallRecoveryString\(' + 'receipt-bound partial root cleanup' = 'RemoveAuthorizedPriorEmptyRootAfterAdmission\(' + 'partial root removal entered receipt' = 'PartialRootRemovalEntered' + 'partial root removal returned receipt' = 'PartialRootRemovalReturned' + 'partial root removal reboot boundary' = 'PartialRootRemovalRebootPending' + 'partial root removal exact shape' = 'partialRootRemovalBinding' + 'partial root removal attempt epoch' = 'partialRootRemovalBootIdentifier' + 'product-only chain discovery model' = 'InstallRecoveryChainHasActive\(' + 'target-user product ACL builder' = 'BuildInstallRecoveryProductDirectorySecurity\(' + 'exact target-user product ACL verifier' = 'VerifyProtectedProductDirectorySecurity\(' + 'forward reboot-pending phase' = 'ForwardRebootPending' + 'restore reboot-pending phase' = 'RestoreRebootPending' + 'manual reconciliation phase' = 'ManualReconciliationRequired' + 'authoritative mutation watchdog' = 'SynchronousMutationWatchdog' + 'authoritative mutation wrapper' = 'InvokeAuthoritativeSynchronousMutation\(' + 'deadline overrun retained in journal' = 'deadlineOverrun' +} + +foreach ($entry in $installJournalRequired.GetEnumerator()) { + if ($source -notmatch $entry.Value) { + throw "ViiperUdeCtl is missing its $($entry.Key) install-journal contract." + } +} + +$installJournalPhases = @( + 'Prepared', + 'SetupCopyEntered', + 'SetupCopyReturned', + 'StageReceiptCaptured', + 'QuiesceSignalEntered', + 'QuiesceSignalReturned', + 'RootRegistrationIntentCaptured', + 'RootRegistrationEntered', + 'RootRegistrationReturned', + 'DiInstallEntered', + 'DiInstallReturned', + 'PriorAbiProfileCaptured', + 'DriverValidated', + 'BrokerHandoffEntered', + 'BrokerHandoffReturned', + 'BrokerChildEntered', + 'BrokerChildSettled', + 'RollbackBindingEntered', + 'PartialRootRemovalEntered', + 'PartialRootRemovalReturned', + 'PartialRootRemovalRebootPending', + 'RollbackBindingReturned', + 'SetupUninstallEntered', + 'SetupUninstallReturned', + 'ForwardValidated', + 'ExactPriorRestored', + 'ForwardRebootPending', + 'RestoreRebootPending', + 'ManualReconciliationRequired' +) +foreach ($phase in $installJournalPhases) { + $qualified = 'InstallJournalPhase::' + $phase + if ([regex]::Matches($source, [regex]::Escape($qualified)).Count -lt 2) { + throw "ViiperUdeCtl install journal does not both define and use phase '$phase'." + } +} + +$recoveryPathSource = Get-SourceContractRegion -Text $source ` + -Start 'bool ResolveInstallRecoveryPaths(' -End 'bool GetBootIdentifier(' ` + -Name 'fixed recovery path' +Assert-OrderedSourceFragments -Text $recoveryPathSource -Name 'fixed ProgramData path' ` + -Fragments @( + 'SHGetKnownFolderPath(', + 'FOLDERID_ProgramData', + '*product = *programData / kInstallRecoveryProductDirectory;', + '*component = *product / kInstallRecoveryComponentDirectory;', + '*transactions = *component / kInstallRecoveryTransactionsDirectory;', + '*active = *transactions / kInstallRecoveryActiveDirectory;' + ) +foreach ($fragment in @( + 'FILE_FLAG_OPEN_REPARSE_POINT', + 'FILE_ATTRIBUTE_REPARSE_POINT', + 'VerifyProtectedFileSystemSecurity(', + 'CreateOrOpenInstallRecoveryDirectory(', + 'active, false, true, &activeHandle' +)) { + if (-not $recoveryPathSource.Contains($fragment)) { + throw "ViiperUdeCtl fixed recovery path lost '$fragment'." + } +} + +$journalWriterSource = Get-SourceContractRegion -Text $source ` + -Start 'bool WriteInstallJournalRecord(' -End 'bool GenerateInstallTransactionId(' ` + -Name 'append-only journal writer' +Assert-OrderedSourceFragments -Text $journalWriterSource -Name 'durable journal publication' ` + -Fragments @( + 'BuildInstallJournalPayload(', + 'Sha256Data(payload, &digest', + '\"payloadSha256\"', + 'CREATE_NEW', + 'FILE_FLAG_WRITE_THROUGH', + 'FlushFileBuffers(file.get())', + 'MoveFileExW(', + 'MOVEFILE_WRITE_THROUGH', + 'OPEN_EXISTING', + 'ReadFile(file.get(), observed.data()', + 'observed != record', + 'trailingRead != 0', + 'state->previousDigest = digest', + '++state->sequence' + ) +if ($journalWriterSource.Contains('MOVEFILE_REPLACE_EXISTING')) { + throw 'ViiperUdeCtl append-only journal must never replace a published record.' +} + +$journalPrepareSource = Get-SourceContractRegion -Text $source ` + -Start 'bool InstallJournal::Prepare(' -End 'bool InstallJournal::Record(' ` + -Name 'install journal preparation' +Assert-OrderedSourceFragments -Text $journalPrepareSource -Name 'pre-Prepared protected evidence' ` + -Fragments @( + 'BackupPackagesIntoDirectory(', + 'CopyCandidateIntoInstallJournal(', + 'impl_->state.prior = prior;', + 'impl_->state.candidate = candidate;', + 'impl_->state.phase = InstallJournalPhase::Prepared;', + 'WriteInstallJournalRecord(' + ) + +$journalRecordSource = Get-SourceContractRegion -Text $source ` + -Start 'bool InstallJournal::RecordNext(' -End 'bool InstallJournal::RecordCutpoint(' ` + -Name 'atomic install journal record' +Assert-OrderedSourceFragments -Text $journalRecordSource -Name 'atomic journal state publication' ` + -Fragments @( + 'ValidateInstallJournalTransition(&impl_->state, next', + 'WriteInstallJournalRecord(', + 'impl_->state = std::move(next);', + 'PublishInstallRecoveryEvidence(' + ) +if (-not $journalRecordSource.Contains('impl_->poisoned = true')) { + throw 'ViiperUdeCtl must poison the in-process journal after an indeterminate append or publication.' +} + +$watchdogSource = Get-SourceContractRegion -Text $source ` + -Start 'class SynchronousMutationWatchdog final' -End 'class DeviceInfoSet final' ` + -Name 'authoritative synchronous mutation watchdog' +foreach ($fragment in @( + 'completion_.get(), waitMilliseconds', + 'timedOut_.store(true', + 'WaitForSingleObject(completion_.get(), INFINITE)', + 'thread_.join()', + 'gLastSynchronousMutationTimedOut = watchdog.Complete()' +)) { + if (-not $watchdogSource.Contains($fragment)) { + throw "ViiperUdeCtl authoritative watchdog lost '$fragment'." + } +} +foreach ($forbidden in @( + 'CancelIoEx(', 'CancelSynchronousIo(', 'TerminateThread(', + 'TerminateProcess(', '.detach()' +)) { + if ($watchdogSource.Contains($forbidden)) { + throw "ViiperUdeCtl authoritative watchdog contains forbidden cancellation '$forbidden'." + } +} + +$installEntrySource = Get-SourceContractRegion -Text $source ` + -Start 'Outcome Install(const InstallOptions& options)' -End 'struct PackageBackup {' ` + -Name 'install entry' +Assert-OrderedSourceFragments -Text $installEntrySource -Name 'install pre-mutation reconciliation' ` + -Fragments @( + 'mutex.Acquire(', + 'ReconcileInstallJournal(', + 'ValidateCandidateInputs(', + 'CaptureSnapshot(', + 'installJournal.Prepare(' + ) +if ([regex]::Matches($installEntrySource, + 'RemoveAuthorizedPriorEmptyRootAfterAdmission\(').Count -ne 2 -or + [regex]::Matches($installEntrySource, + 'VerifyPriorTopologyBeforePackageRollback\(').Count -ne 2 -or + [regex]::Matches($installEntrySource, + '!prior\.devices\.empty\(\) && bindingMutationStarted').Count -ne 2) { + throw 'ViiperUdeCtl must apply receipt-bound root cleanup and strict post-removal proof in both in-process rollback branches without generic prior-empty deletion.' +} + +$removeEntrySource = Get-SourceContractRegion -Text $source ` + -Start 'Outcome Remove(const RemoveOptions& options)' -End 'Outcome Recover(' ` + -Name 'remove entry' +Assert-OrderedSourceFragments -Text $removeEntrySource -Name 'remove pre-mutation reconciliation' ` + -Fragments @( + 'mutex.Acquire(', + 'ReconcileRemoveJournal(', + 'ReconcileInstallJournal(', + 'CaptureSnapshot(', + 'PrepareRemoveJournal(', + 'ReconcileRemoveJournal(' + ) + +$removePrepareSource = Get-SourceContractRegion -Text $source ` + -Start 'bool PrepareRemoveJournal(' -End 'enum class RemoveRootShape' ` + -Name 'remove journal preparation' +Assert-OrderedSourceFragments -Text $removePrepareSource ` + -Name 'remove evidence before Prepared' -Fragments @( + 'OpenChain(true', + 'PublishRemoveRecoveryEvidence(', + 'BackupPackagesIntoDirectory(', + 'ValidateRemoveJournalTransition(nullptr', + 'WriteRemoveJournalRecord(' + ) + +$removeRecordSource = Get-SourceContractRegion -Text $source ` + -Start 'bool AppendRemoveJournalRecord(' -End 'bool PrepareRemoveJournal(' ` + -Name 'remove append-only record' -LastStart +Assert-OrderedSourceFragments -Text $removeRecordSource ` + -Name 'remove atomic state publication' -Fragments @( + 'ValidateRemoveJournalTransition(', + 'WriteRemoveJournalRecord(', + 'loaded->state = std::move(next);', + 'PublishRemoveRecoveryEvidence(' + ) +if (-not $removeRecordSource.Contains('loaded->poisoned = true')) { + throw 'ViiperUdeCtl must poison remove recovery after an indeterminate append.' +} + +$removeRetireSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RetireLoadedRemoveJournal(' -End 'bool AppendRemoveJournalRecord(' ` + -Name 'loaded remove journal retirement' +Assert-OrderedSourceFragments -Text $removeRetireSource ` + -Name 'remove descendant evidence release immediately before rename' -Fragments @( + 'RemoveJournalPhase::ForwardValidated', + 'RemoveJournalPhase::ExactPriorRestored', + 'const std::string transactionId', + 'loaded->priorBackups.clear();', + 'loaded->evidenceLocks.clear();', + 'return RetireRemoveRecoveryActiveDirectory(' + ) + +$removeRawRetireSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RetireRemoveRecoveryActiveDirectory(' ` + -End 'struct RemoveJournalStateData {' ` + -Name 'raw remove terminal retirement' +Assert-OrderedSourceFragments -Text $removeRawRetireSource ` + -Name 'remove tombstone retirement evidence' -Fragments @( + 'MoveFileExW(', + 'error->recoveryBackup = tombstone.wstring();', + 'ClearActiveRecoveryEvidence();', + 'std::filesystem::remove_all(', + 'gRetainedRemoveTombstoneError', + 'OutputDebugStringW(' + ) + +$removePriorRetireSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RetireRemoveJournalAsPrior(' ` + -End 'bool RetireRemoveJournalAsUninstalled(' ` + -Name 'remove prior terminal retirement' +Assert-OrderedSourceFragments -Text $removePriorRetireSource ` + -Name 'remove prior terminal double validation' -Fragments @( + 'CurrentRemoveStateMatchesPrior(', + 'RemoveJournalPhase::ExactPriorRestored', + 'RecordRemoveJournalPhase(', + 'CurrentRemoveStateMatchesPrior(', + 'RetireLoadedRemoveJournal(' + ) +if (-not $removePriorRetireSource.Contains( + 'if (outcome->error.recoveryBackup.empty())')) { + throw 'Prior retirement must preserve an exact tombstone failure path instead of overwriting it with absent active-v2.' +} + +$removeForwardRetireSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RetireRemoveJournalAsUninstalled(' ` + -End 'bool FailRemoveJournalManual(' ` + -Name 'remove forward terminal retirement' +Assert-OrderedSourceFragments -Text $removeForwardRetireSource ` + -Name 'remove forward terminal double validation' -Fragments @( + 'CurrentRemoveStateIsUninstalled(', + 'RemoveJournalPhase::ForwardValidated', + 'RecordRemoveJournalPhase(', + 'CurrentRemoveStateIsUninstalled(', + 'RetireLoadedRemoveJournal(' + ) +if (-not $removeForwardRetireSource.Contains( + 'if (outcome->error.recoveryBackup.empty())')) { + throw 'Forward retirement must preserve an exact tombstone failure path instead of overwriting it with absent active-v2.' +} + +$removeManualSource = Get-SourceContractRegion -Text $source ` + -Start 'bool FailRemoveJournalManual(' ` + -End 'bool ReturnRemoveJournalRebootPending(' ` + -Name 'remove manual evidence retention' +if (-not $removeManualSource.Contains('!cause->recoveryBackup.empty()') -or + $removeManualSource.Contains('RetireLoadedRemoveJournal(')) { + throw 'Manual recovery must preserve callee tombstone evidence and must never release terminal evidence locks.' +} + +$removeDeviceSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RemoveExactCapturedDevice(' -End 'bool RegisterRootDevice(' ` + -Name 'single captured device removal' +Assert-OrderedSourceFragments -Text $removeDeviceSource ` + -Name 'single captured device immutable revalidation' -Fragments @( + 'FindExactDevices(', + 'LoadOwnedPackage(', + 'IsExactCapturedRemoveTarget(', + 'return RemoveDevice(' + ) + +$removeRollbackSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RunRemoveRollbackRecovery(' -End 'bool AdmitRemoveRollback(' ` + -Name 'remove rollback recovery' +Assert-OrderedSourceFragments -Text $removeRollbackSource ` + -Name 'interrupted binding admission reuse' -Fragments @( + 'ReusesInterruptedRemoveBindingAdmission(', + '!reusingInterruptedBindingAdmission', + 'RemoveJournalPhase::RollbackBindingEntered', + 'ObserveRemoveRootShape(', + 'VerifyPackageInventory(', + 'RestorePriorBinding(' + ) +Assert-OrderedSourceFragments -Text $removeRollbackSource ` + -Name 'remove rollback exact-absence binding authority' -Fragments @( + 'ObserveRemoveRootShape(', + 'root != RemoveRootShape::Absent', + 'VerifyPackageInventory(', + 'RestorePriorBinding(restorable,', + 'RestorePriorBindingPolicy::RemoveJournalExactAbsence' + ) + +$restoreBindingSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RestorePriorBinding(' -End 'bool RollbackInstall(' ` + -Name 'prior binding restore policy' +Assert-OrderedSourceFragments -Text $restoreBindingSource ` + -Name 'remove exact-absence race fails before mutation' -Fragments @( + 'CaptureSnapshot(', + 'RestorePriorBindingTopologyAdmitsMutation(', + 'RestorePriorBindingPolicy::InstallRollbackReconcile &&', + 'RemoveDevice(', + 'RegisterRootDeviceExact(', + 'InstallPreinstalledDriverOnDevice(' + ) +if (-not $restoreBindingSource.Contains( + 'policy == RestorePriorBindingPolicy::RemoveJournalExactAbsence') -or + -not $source.Contains( + 'self-test-remove-journal-binding-exact-absence-race')) { + throw 'Remove rollback must reject a concurrently appeared root before any restore mutation.' +} + +$removeAdmissionSource = Get-SourceContractRegion -Text $source ` + -Start 'bool AdmitRemoveRollback(' -End 'bool RunRemoveForwardRecovery(' ` + -Name 'remove rollback admission' +Assert-OrderedSourceFragments -Text $removeAdmissionSource ` + -Name 'durable rollback admission and fresh deadline' -Fragments @( + 'RemoveJournalPhase::RestoreRebootPending', + 'RecordRemoveJournalPhase(', + 'FreshRemoveRollbackDeadline();', + 'RunRemoveRollbackRecovery(' + ) +if ($removeAdmissionSource.Contains('deadlineUnixMs')) { + throw 'Forward-to-rollback admission must not accept or reuse the exhausted forward deadline.' +} + +$removeForwardSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RunRemoveForwardRecovery(' -End 'bool ReconcileRemoveJournal(' ` + -Name 'remove forward recovery' +Assert-OrderedSourceFragments -Text $removeForwardSource ` + -Name 'crossed reboot loop fails closed' -Fragments @( + 'CrossedRemoveRebootStillPendingRequiresManual(', + 'FailRemoveJournalManual(', + 'ReturnRemoveJournalRebootPending(' + ) +$crossedRebootSource = Get-SourceContractRegion -Text $source ` + -Start 'bool CrossedRemoveRebootStillPendingRequiresManual(' ` + -End 'bool ReusesInterruptedRemoveBindingAdmission(' ` + -Name 'crossed remove reboot decision' +foreach ($fragment in @( + 'RemoveJournalPhase::DeviceRemovalReturned', + 'callSucceeded', + 'freshRebootRequired', + '!samePendingBoot' +)) { + if (-not $crossedRebootSource.Contains($fragment)) { + throw "Returned-to-pending crossed reboot decision lost '$fragment'." + } +} +if (-not $source.Contains( + 'self-test-remove-journal-device-returned-pending-cut')) { + throw 'ViiperUdeCtl lost the compiled DeviceRemovalReturned-to-pending crash cut test.' +} +if ($removeForwardSource.Contains('RemoveAllExactDevices(') -or + -not $removeForwardSource.Contains('RemoveExactCapturedDevice(')) { + throw 'Protected forward removal must target one immutable captured root and never call broad all-device removal.' +} + +$removeReconcileSource = Get-SourceContractRegion -Text $source ` + -Start 'bool ReconcileRemoveJournal(' -End 'struct RemoveOptions {' ` + -Name 'remove startup reconciliation' -LastStart +foreach ($fragment in @( + 'LoadRemoveJournal(', + 'InstallRecoveryDirectory installDirectory', + 'installExists', + 'ManualReconciliationRequired', + 'GetBootIdentifier(', + 'RunRemoveRollbackRecovery(', + 'RunRemoveForwardRecovery(' +)) { + if (-not $removeReconcileSource.Contains($fragment)) { + throw "ViiperUdeCtl remove reconciliation lost '$fragment'." + } +} + +$reconcileSource = Get-SourceContractRegion -Text $source ` + -Start 'bool ReconcileInstallJournal(' -End 'const char* RemoveJournalPhaseName(' ` + -Name 'startup journal reconciliation' -LastStart +foreach ($fragment in @( + 'ForwardRebootPending && sameBoot', + 'RestoreRebootPending && sameBoot', + 'return rebootPending(', + 'loaded.state.phase == InstallJournalPhase::BrokerChildEntered', + 'loaded.state.phase == InstallJournalPhase::BrokerHandoffReturned', + 'loaded.state.rollbackAuthorized', + 'loaded.state.hasBrokerProof', + 'loaded.state.brokerProofSuccess', + 'no mutation was attempted', + 'InstallJournalPhase::ManualReconciliationRequired' + 'appendPartialRootRemovalEntered' + 'install-journal-partial-root-removal-inventory' + 'install-journal-pre-package-rollback-inventory' + 'ClassifyPartialRootRemovalJournalRecovery(' +)) { + if (-not $reconcileSource.Contains($fragment)) { + throw "ViiperUdeCtl startup reconciliation lost '$fragment'." + } +} + +$apiPhaseContracts = @( + @('bool StageCandidatePackage(', 'bool RemoveDevice(', 'SetupCopyEntered', 'SetupCopyOEMInfW(', 'SetupCopyReturned'), + @('bool CommitPreparedDriverBinding(', 'bool InstallPreinstalledDriverOnDevice(', 'DiInstallEntered', 'DiInstallDevice(', 'DiInstallReturned'), + @('bool RemoveStagedCandidateExact(', 'bool RestorePriorBinding(', 'SetupUninstallEntered', 'SetupUninstallOEMInfW(', 'SetupUninstallReturned'), + @('bool RequestBrokerQuiescence(', 'bool SignalBrokerHandoff(', 'QuiesceSignalEntered', 'SetEvent(options.brokerQuiesceRequest)', 'QuiesceSignalReturned'), + @('bool SignalBrokerHandoff(', 'bool ValidateTransactionDeadlineBudget(', 'BrokerHandoffEntered', 'SetEvent(options.brokerHandoff)', 'BrokerHandoffReturned'), + @('bool RunBrokerInstall(', 'Outcome Install(', 'BrokerChildEntered', 'CreateProcessW(', 'BrokerChildSettled') +) +foreach ($contract in $apiPhaseContracts) { + $region = Get-SourceContractRegion -Text $source -Start $contract[0] ` + -End $contract[1] -Name ("phase-wrapped API " + $contract[3]) + Assert-OrderedSourceFragments -Text $region -Name ("phase-wrapped API " + $contract[3]) ` + -Fragments @($contract[2], $contract[3], $contract[4]) +} + +foreach ($registrationStart in @('bool RegisterRootDevice(', 'bool RegisterRootDeviceExact(')) { + $registrationEnd = if ($registrationStart -eq 'bool RegisterRootDevice(') { + 'bool DriverInfoUsesPublishedPackage(' + } else { + 'bool IssueAbiNegotiation(' + } + $region = Get-SourceContractRegion -Text $source -Start $registrationStart ` + -End $registrationEnd -Name 'phase-wrapped root registration' + $entered = $region.IndexOf('RootRegistrationEntered', [StringComparison]::Ordinal) + $mutation = $region.IndexOf('SetupDiCallClassInstaller(', [StringComparison]::Ordinal) + $returned = $region.LastIndexOf('RootRegistrationReturned', [StringComparison]::Ordinal) + if ($entered -lt 0 -or $mutation -le $entered -or $returned -le $mutation) { + throw 'ViiperUdeCtl root registration is not enclosed by durable entered/returned phases.' + } +} + +$forwardRegistrationSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RegisterRootDevice(' -End 'bool DriverInfoUsesPublishedPackage(' ` + -Name 'forward root registration receipt' +Assert-OrderedSourceFragments -Text $forwardRegistrationSource ` + -Name 'generated root receipt before every registration mutation' ` + -Fragments @( + 'SetupDiCreateDeviceInfoW(', + 'DICD_GENERATE_ID', + 'SetupDiGetDeviceInstanceIdW(', + 'RecordActiveInstallJournalRootRegistrationIntent(', + 'InstallJournalPhase::RootRegistrationEntered', + 'SetupDiSetDeviceRegistryPropertyW(', + 'SetupDiCallClassInstaller(', + 'DIF_REGISTERDEVICE', + 'InstallJournalPhase::RootRegistrationReturned' + ) + +$partialRootSource = Get-SourceContractRegion -Text $source ` + -Start 'bool InstallJournal::RemoveAuthorizedPriorEmptyRootAfterAdmission(' ` + -End 'bool CurrentRootIsAuthorizedForInstallRollback(' ` + -Name 'in-process receipt-bound partial root removal' +Assert-OrderedSourceFragments -Text $partialRootSource ` + -Name 'partial root removal write-ahead and authoritative return' ` + -Fragments @( + 'InstallJournalPhase::PartialRootRemovalEntered', + 'VerifyPackageInventory(', + 'observe(false, &confirmed', + 'RemoveDevice(', + 'RecordAuthoritativeReturn(', + 'InstallJournalPhase::PartialRootRemovalReturned', + 'observe(true, &after' + ) +foreach ($fragment in @( + 'RemoveUnboundExactRoot', + 'RemoveCandidateBoundExactRoot', + 'PendingExactRootRemoval', + 'freshRemovalReboot', + 'rootRemovalRebootPending' +)) { + if (-not $partialRootSource.Contains($fragment)) { + throw "ViiperUdeCtl partial root removal lost '$fragment'." + } +} + +$rawRootSource = Get-SourceContractRegion -Text $source ` + -Start 'bool ObservePriorEmptyInstallRecoveryRoot(' ` + -End 'bool VerifyInstallJournalRawPriorTopology(' ` + -Name 'broad raw root topology observer' +foreach ($fragment in @( + 'ReadInstallRecoveryHardwareIds(', + 'IsInGeneratedRootDeviceNamespace(', + 'hardwareIds.containsExpected', + 'related.size() == 1U', + 'loaded.state.hasRootRegistrationIntent', + 'loaded.state.rootRegistrationInstanceId.c_str()', + 'ReadCanonicalInstallRecoveryService(', + 'ReadCanonicalInstallRecoveryDevicePropertyString(', + 'CM_PROB_WILL_BE_REMOVED', + 'ClassifyPartialInstallRootRecovery(' +)) { + if (-not $rawRootSource.Contains($fragment)) { + throw "ViiperUdeCtl broad raw root observer lost '$fragment'." + } +} + +$openChainSource = Get-SourceContractRegion -Text $source ` + -Start ' bool OpenChain(' -End '};' -Name 'install recovery directory chain' +Assert-OrderedSourceFragments -Text $openChainSource ` + -Name 'product-only recovery discovery' ` + -Fragments @( + 'bool productExists = false;', + 'bool componentExists = false;', + 'bool transactionsExist = false;', + 'bool activeExists = false;', + 'if (!productExists) return true;', + 'if (!componentExists) return true;', + 'if (!transactionsExist) return true;', + '*exists = InstallRecoveryChainHasActive(' + ) +foreach ($fragment in @( + 'BuildInstallRecoveryProductDirectorySecurity(', + '*exactTargetUserSid', + 'CreateOrOpenInstallRecoveryDirectoryWithSecurity(', + 'VerifyProtectedProductDirectorySecurity(', + 'exactTargetUserSid' +)) { + if (-not $openChainSource.Contains($fragment)) { + throw "ViiperUdeCtl install recovery chain lost '$fragment'." + } +} + +$orderedMutationContracts = [ordered]@{ + 'driver package staging deadline immediately precedes add-only mutation' = + 'transaction-deadline-before-driver-stage[\s\S]{0,1800}MarkTransactionMutationStarted\(\);[\s\S]{0,500}SetupCopyOEMInfW\(' + 'root property deadline immediately precedes mutation' = + 'transaction-deadline-before-root-properties[\s\S]{0,900}mutationStarted[\s\S]{0,300}SetupDiSetDeviceRegistryPropertyW\(' + 'root registration deadline immediately precedes mutation' = + 'transaction-deadline-before-root-registration[\s\S]{0,900}SetupDiCallClassInstaller\([\s\S]{0,120}DIF_REGISTERDEVICE' + 'selected driver deadline immediately precedes mutation' = + 'transaction-deadline-before-selected-device-binding[\s\S]{0,700}mutationStarted[\s\S]{0,400}SetupDiSetSelectedDriverW\([\s\S]{0,1800}DiInstallDevice\(' + 'broker deadline immediately precedes quiescence signal' = + 'transaction-deadline-before-broker-quiescence[\s\S]{0,700}SetEvent\(options\.brokerQuiesceRequest\)' + 'remove deadline immediately precedes device mutation' = + 'CheckTransactionDeadline\(transactionDeadlineUnixMs, deadlinePhase, error\)[\s\S]{0,300}mutationStarted[\s\S]{0,180}DiUninstallDevice\(' + 'first-time root creation uses the owned device name' = + 'SetupDiCreateDeviceInfoW\([\s\S]{0,120}kRootDeviceName[\s\S]{0,120}DICD_GENERATE_ID' + 'failed registration cannot suppress receipt-authorized cleanup' = + 'bool registrationSucceeded = false[\s\S]{0,30000}RemoveAuthorizedPriorEmptyRootAfterAdmission\(' + 'add-only stage inventory and exact root proof precede broker quiescence' = + 'StageCandidatePackage\([\s\S]{0,5000}stage-package-inventory-verification[\s\S]{0,1600}stage-root-binding-verification[\s\S]{0,3000}RequestBrokerQuiescence\(' + 'broker quiescence inventory and fresh root proof precede pristine admission' = + 'RequestBrokerQuiescence\([\s\S]{0,1200}post-quiescence-package-inventory-verification[\s\S]{0,1000}post-quiescence-root-verification[\s\S]{0,1800}AbiHealthPurpose::PristineUpgrade' + 'driver preparation and final proofs precede immediate in-place binding' = + 'PreparePreinstalledDriverOnDevice\([\s\S]{0,800}final-pre-bind-root-topology-verification[\s\S]{0,800}final-pre-bind-package-inventory-verification[\s\S]{0,900}final-pre-bind-root-verification[\s\S]{0,1000}AbiHealthPurpose::PristineRecheck[\s\S]{0,900}CommitPreparedDriverBinding\(' + 'new root registration is confined to an absent captured root' = + 'if \(prior\.devices\.empty\(\)\) \{[\s\S]{0,300}RegisterRootDevice\(' + 'post-stage failure reaches exact common rollback' = + 'StageCandidatePackage\([\s\S]{0,22000}if \(outcome\.error\.code != ERROR_SUCCESS && driverMutationStarted\)[\s\S]{0,3000}packageStagedHere \? &publishedCandidate : nullptr[\s\S]{0,600}RollbackInstall\(' + 'binding restore precedes exact staged cleanup and inventory proof' = + 'if \(bindingMutationStarted\)[\s\S]{0,300}RestorePriorBinding\([\s\S]{0,700}RemoveStagedCandidateExact\([\s\S]{0,500}VerifyPackageInventory\(' + 'formerly-running rollback requires exact start and ABI health' = + 'if \(prior\.devices\[0\]\.started\)[\s\S]{0,500}rollback-runtime-start-verification[\s\S]{0,800}AbiHealthPurpose::RollbackHealth' + 'captured-stopped rollback requires exact stopped problem state' = + 'AbiHealthPurpose::RollbackHealth[\s\S]{0,400}RollbackLifecycleStateMatches\([\s\S]{0,300}rollback-stopped-state-verification' + 'broker handoff follows exact binding verification and precedes nested commit' = + 'VerifyInstalledBinding\([\s\S]{0,12000}SignalBrokerHandoff\([\s\S]{0,800}RunBrokerInstall\(' + 'remove journal and exact backups precede first mutation' = + 'Outcome Remove\([\s\S]{0,6000}PrepareRemoveJournal\([\s\S]{0,1400}ReconcileRemoveJournal\(' + 'remove package admission is revalidated before exact mutation' = + 'PackageRemovalEntered[\s\S]{0,1200}ObserveRemovePackagePrefix\([\s\S]{0,1200}InvokeRemovePackageMutation\(' + 'rollback package admission is revalidated before exact restoration' = + 'RollbackPackageEntered[\s\S]{0,1200}ObserveRemovePackageSubset\([\s\S]{0,1800}InvokeRestorePackageMutation\(' + 'forward removal retires only after double exact validation' = + 'CurrentRemoveStateIsUninstalled\([\s\S]{0,800}ForwardValidated[\s\S]{0,800}CurrentRemoveStateIsUninstalled\([\s\S]{0,800}RetireLoadedRemoveJournal\(' + 'rollback retires only after double exact prior validation' = + 'CurrentRemoveStateMatchesPrior\([\s\S]{0,800}ExactPriorRestored[\s\S]{0,800}CurrentRemoveStateMatchesPrior\([\s\S]{0,800}RetireLoadedRemoveJournal\(' + 'exception outcome distinguishes preflight from mutation' = + 'const bool changed = gTransactionMutationStarted;[\s\S]{0,180}changed[\s\S]{0,100}ExitCode::RollbackFailed : ExitCode::PreflightRejected;' +} + +foreach ($entry in $orderedMutationContracts.GetEnumerator()) { + if ($source -notmatch $entry.Value) { + throw "ViiperUdeCtl violates its $($entry.Key) ordering contract." + } +} + +$forwardInstallStart = $source.IndexOf('Outcome Install(const InstallOptions& options)') +$forwardInstallEnd = $source.IndexOf('struct PackageBackup {', $forwardInstallStart) +if ($forwardInstallStart -lt 0 -or $forwardInstallEnd -le $forwardInstallStart) { + throw 'ViiperUdeCtl forward install transaction is missing or malformed.' +} +$forwardInstallSource = $source.Substring( + $forwardInstallStart, $forwardInstallEnd - $forwardInstallStart) +if ($forwardInstallSource.Contains('InstallJournalPhase::DiInstallReturned')) { + throw 'Forward install must not synthesize a DiInstallReturned phase outside the actual API wrapper.' +} +if (-not $forwardInstallSource.Contains('InstallJournalPhase::StageReceiptCaptured')) { + throw 'Forward install must durably separate the exact stage receipt from SetupCopyOEMInfW return.' +} +if ($forwardInstallSource -match '\b(?:DiInstallDriverW|UpdateDriverForPlugAndPlayDevicesW)\s*\(') { + throw 'Forward install must use add-only staging plus exact selected-device binding, never a device-auto-binding package API.' +} +if ($forwardInstallSource -match 'upgrade-deadline-before-device-removal|ExactRootRegistrationMode|RegisterRootDeviceExact\(') { + throw 'Forward install must never remove and recreate an existing root before exact in-place binding.' +} + +if ($source -match 'SUOI_FORCEDELETE') { + throw 'ViiperUdeCtl must never force-delete a published INF.' +} + +if ($source -match 'TerminateProcess\(') { + throw 'ViiperUdeCtl must never hard-terminate the mutating broker transaction.' +} + +if ($source -match '--expected-(?:token|broker)-sha256') { + throw 'ViiperUdeCtl retained obsolete nested Kong SHA-256 option spelling.' +} + +if ($source -match 'SetError\(error,\s*L"broker-health",\s*exitCode') { + throw 'Nested broker application exits must not be mislabeled as Win32 errors.' +} + +if ($source -match 'std::filesystem::copy_file') { + throw 'Rollback packages must use the protected, write-through, verified exact-file copy path.' +} + +foreach ($runtimeExport in @( + 'CryptCATAdminAcquireContext2', + 'CryptCATAdminCalcHashFromFileHandle2', + 'CryptCATAdminReleaseContext' +)) { + if ($source -match ("\b" + [regex]::Escape($runtimeExport) + "\s*\(")) { + throw "$runtimeExport must be loaded from the protected System32 Wintrust runtime, not statically imported." + } +} + +if ($source -match 'WaitForSingleObject\(processHandle\.get\(\),\s*INFINITE\)') { + throw 'The nested broker wait must use the cooperative package deadline contract.' +} + +if ($source -match 'std::max\(CurrentUnixMilliseconds\(\),\s*options\.transactionDeadlineUnixMs\)\s*\+\s*kDriverRollbackCeilingMs') { + throw 'Remove rollback must receive a fresh finite ceiling, not the unused forward deadline plus a rollback budget.' +} + +if ([regex]::Matches($source, ',\s*DICD_GENERATE_ID\s*,').Count -ne 1) { + throw 'Generated root identities are allowed only for first-time forward creation, never rollback.' +} + +if ($source -match 'SetupDiCreateDeviceInfoW\([\s\S]{0,120}className\.c_str\(\)') { + throw 'Forward root creation must use the VIIPER-owned device-name namespace, not the INF class name.' +} + +if ($source -match '\bRemoveAllExactDevices\(') { + throw 'Protected removal must never retain broad all-device mutation plumbing.' +} + +if ([regex]::Matches($source, 'VerifyDriverCatalogMember\(catalogPath').Count -ne 4) { + throw 'Production and LocalTest validation must each bind the exact INF and SYS to the exact adjacent catalog.' +} + +$forceInfUses = [regex]::Matches($source, '\bDIIRFLAG_FORCE_INF\b').Count +if ($forceInfUses -ne 0) { + throw 'Add-only staging and exact selected-device binding must not force global INF selection.' +} + +$forceBindUses = [regex]::Matches($source, '\bINSTALLFLAG_FORCE\b').Count +if ($forceBindUses -ne 0) { + throw "Selected preinstalled package binding and rollback must not use INSTALLFLAG_FORCE; found $forceBindUses uses." +} + +if (-not [string]::IsNullOrWhiteSpace($BinaryPath)) { + $resolvedBinary = Resolve-Path -LiteralPath $BinaryPath -ErrorAction Stop + $output = & $resolvedBinary.Path self-test 2>&1 | Out-String + if ($LASTEXITCODE -ne 0 -or $output -notmatch 'result=success operation=self-test') { + throw "ViiperUdeCtl deterministic self-test failed (exit $LASTEXITCODE):`n$output" + } +} + +Write-Host 'ViiperUdeCtl transaction contract is deterministic and fail-closed.' diff --git a/native/udecx/tools/Test-ViiperUdeDebugArtifacts.ps1 b/native/udecx/tools/Test-ViiperUdeDebugArtifacts.ps1 new file mode 100644 index 00000000..6939a1a3 --- /dev/null +++ b/native/udecx/tools/Test-ViiperUdeDebugArtifacts.ps1 @@ -0,0 +1,163 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$SysPath, + + [Parameter(Mandatory = $true)] + [string]$PdbPath, + + [Parameter(Mandatory = $true)] + [string]$MapPath, + + [string]$HelperPath, + + [string]$HelperPdbPath, + + [string]$MediaProbePath, + + [string]$MediaProbePdbPath, + + [string]$InputProbePath, + + [string]$InputProbePdbPath, + + [string]$SymChkPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-ExactArtifact { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ExpectedName + ) + + $resolved = Get-Item -LiteralPath $Path -ErrorAction Stop + if (-not $resolved.PSIsContainer -and $resolved.Name -ceq $ExpectedName -and + $resolved.Length -gt 0) { + return $resolved + } + throw "Expected non-empty '$ExpectedName' artifact at '$Path'." +} + +function Resolve-SymChk { + param([string]$ExplicitPath) + + if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) { + return (Resolve-Path -LiteralPath $ExplicitPath -ErrorAction Stop).Path + } + $command = Get-Command symchk.exe -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($null -ne $command) { + return $command.Source + } + $candidate = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Debuggers\x64\symchk.exe' + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return (Resolve-Path -LiteralPath $candidate).Path + } + throw 'symchk.exe is required to prove that the SYS and full private line PDB match.' +} + +function Test-RelocatablePdbReference { + param( + [Parameter(Mandatory = $true)]$Image, + [Parameter(Mandatory = $true)]$Pdb + ) + + $embeddedImageText = [Text.Encoding]::ASCII.GetString( + [IO.File]::ReadAllBytes($Image.FullName)) + $pdbNamePattern = '(?:^|\x00)' + [regex]::Escape($Pdb.Name) + '(?:\x00|$)' + if ($embeddedImageText -notmatch $pdbNamePattern -or + $embeddedImageText -match ('(?i)[A-Z]:\\[^\x00]{0,512}' + + [regex]::Escape($Pdb.Name))) { + throw "'$($Image.Name)' must embed only the relocatable '$($Pdb.Name)' basename." + } +} + +function Test-MatchingPrivateSymbols { + param( + [Parameter(Mandatory = $true)]$Image, + [Parameter(Mandatory = $true)]$Pdb, + [Parameter(Mandatory = $true)][string]$SymChk + ) + + $savedErrorActionPreference = $ErrorActionPreference + try { + # Windows PowerShell 5.1 wraps native stderr as non-terminating + # ErrorRecord objects. Preserve the text and judge symchk by its exit + # code and the private line/type report. + $ErrorActionPreference = 'Continue' + $symbolOutput = (& $SymChk /v $Image.FullName /s $Pdb.DirectoryName 2>&1 | + ForEach-Object { $_.ToString() } | Out-String) + $symbolExitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $savedErrorActionPreference + } + if ($symbolExitCode -ne 0 -or + $symbolOutput -notmatch '(?im)private symbols & lines' -or + $symbolOutput -notmatch '(?im)PDB Matched:\s+TRUE' -or + $symbolOutput -notmatch '(?im)Line numbers:\s+TRUE' -or + $symbolOutput -notmatch '(?im)Type Info:\s+TRUE') { + throw "'$($Image.Name)' and '$($Pdb.Name)' are not a matching private source/line/type set.`n$symbolOutput" + } +} + +$sys = Resolve-ExactArtifact -Path $SysPath -ExpectedName 'ViiperUde.sys' +$pdb = Resolve-ExactArtifact -Path $PdbPath -ExpectedName 'ViiperUde.pdb' +$map = Resolve-ExactArtifact -Path $MapPath -ExpectedName 'ViiperUde.map' +$symchk = Resolve-SymChk -ExplicitPath $SymChkPath + +Test-RelocatablePdbReference -Image $sys -Pdb $pdb +Test-MatchingPrivateSymbols -Image $sys -Pdb $pdb -SymChk $symchk + +$mapText = Get-Content -LiteralPath $map.FullName -Raw +foreach ($symbol in @('ViiperTraceLifecycle', 'ViiperEvtEndpointPurgeWorkItem', + 'ViiperEvtEndpointPurge', 'ViiperBeginControllerShutdown', + 'ViiperEvtEndpointIoInternalControl', 'ViiperSubmitInputReport', + 'ViiperEvtFastInputQueueReady', 'ViiperPrepareCachedInputUrb', + 'ViiperQueueUrb', 'ViiperDispatchAvailable', 'ViiperSerializeOperation', + 'ViiperReserveIsoStartFrame', 'ViiperQueueUrbCompletion', + 'ViiperEvtCompletionDpc')) { + if ($mapText -notmatch ('\b' + [regex]::Escape($symbol) + '\b')) { + throw "The driver link map does not contain required lifecycle/hot-path symbol '$symbol'." + } +} + +$userModeArtifacts = @( + [pscustomobject]@{ + ImagePath = $HelperPath + PdbPath = $HelperPdbPath + ImageName = 'ViiperUdeCtl.exe' + PdbName = 'ViiperUdeCtl.pdb' + }, + [pscustomobject]@{ + ImagePath = $MediaProbePath + PdbPath = $MediaProbePdbPath + ImageName = 'ViiperUdeMediaProbe.exe' + PdbName = 'ViiperUdeMediaProbe.pdb' + }, + [pscustomobject]@{ + ImagePath = $InputProbePath + PdbPath = $InputProbePdbPath + ImageName = 'ViiperUdeInputProbe.exe' + PdbName = 'ViiperUdeInputProbe.pdb' + } +) +foreach ($artifact in $userModeArtifacts) { + $hasImage = -not [string]::IsNullOrWhiteSpace($artifact.ImagePath) + $hasPdb = -not [string]::IsNullOrWhiteSpace($artifact.PdbPath) + if ($hasImage -ne $hasPdb) { + throw "Both '$($artifact.ImageName)' and '$($artifact.PdbName)' must be supplied together." + } + if (-not $hasImage) { + continue + } + $image = Resolve-ExactArtifact -Path $artifact.ImagePath -ExpectedName $artifact.ImageName + $userPdb = Resolve-ExactArtifact -Path $artifact.PdbPath -ExpectedName $artifact.PdbName + Test-RelocatablePdbReference -Image $image -Pdb $userPdb + Test-MatchingPrivateSymbols -Image $image -Pdb $userPdb -SymChk $symchk +} + +Write-Host 'VIIPER UDE debug artifacts match and contain private symbols, line tables, type information, and required lifecycle/hot-path symbols.' diff --git a/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 b/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 new file mode 100644 index 00000000..93c57b36 --- /dev/null +++ b/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 @@ -0,0 +1,183 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$BundleDirectory, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^(?:[0-9a-f]{40}|[0-9a-f]{64})$')] + [string]$ExpectedSourceRevision, + + [string]$ProjectPath, + + [switch]$RequireAuthenticode, + + [ValidatePattern('^$|^[0-9a-fA-F]{64}$')] + [string]$ExpectedSignerCertificateSHA256 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($ProjectPath)) { + $ProjectPath = Join-Path $PSScriptRoot '..\driver\ViiperUde.vcxproj' +} + +$root = (Resolve-Path -LiteralPath $BundleDirectory -ErrorAction Stop).Path +if (-not (Get-Item -LiteralPath $root).PSIsContainer) { + throw 'The native release bundle must be a directory.' +} + +$expectedNames = @( + 'viiper.exe', + 'ViiperUdeCtl.exe', + 'ViiperUde.inf', + 'ViiperUde.sys', + 'ViiperUde.cat', + 'submission-manifest.json' +) +$allEntries = @(Get-ChildItem -LiteralPath $root -Force) +if (@($allEntries | Where-Object PSIsContainer).Count -ne 0) { + throw 'The native runtime files must be direct children of the bundle directory; subdirectories are forbidden.' +} +$allFiles = @($allEntries | Where-Object { -not $_.PSIsContainer }) +if ($allFiles.Count -ne $expectedNames.Count) { + throw "The runtime bundle must contain exactly $($expectedNames.Count) files; found $($allFiles.Count)." +} +$files = @{} +foreach ($name in $expectedNames) { + $matches = @($allFiles | Where-Object Name -CEQ $name) + if ($matches.Count -ne 1) { + throw "The runtime bundle must contain exactly one case-exact '$name'; found $($matches.Count)." + } + if ($matches[0].Length -le 0) { + throw "The runtime bundle file '$name' is empty." + } + $files[$name] = $matches[0] +} +if (@($allFiles | Where-Object { $_.DirectoryName -cne $root }).Count -ne 0) { + throw 'All native runtime files must reside directly in the canonical bundle directory.' +} +if (@($allFiles | Where-Object Extension -ieq '.pdb').Count -ne 0) { + throw 'Private PDB submission evidence must not be shipped in the public runtime bundle.' +} + +foreach ($name in @('viiper.exe', 'ViiperUdeCtl.exe', 'ViiperUde.sys')) { + $stream = [IO.File]::OpenRead($files[$name].FullName) + try { + if ($stream.ReadByte() -ne 0x4d -or $stream.ReadByte() -ne 0x5a) { + throw "The runtime artifact '$name' is not a Windows PE image." + } + } + finally { + $stream.Dispose() + } +} + +if ($RequireAuthenticode) { + if ([string]::IsNullOrWhiteSpace($ExpectedSignerCertificateSHA256)) { + throw '-RequireAuthenticode also requires ExpectedSignerCertificateSHA256.' + } + $expectedSigner = $ExpectedSignerCertificateSHA256.ToLowerInvariant() + foreach ($name in @('viiper.exe', 'ViiperUdeCtl.exe')) { + $signature = Get-AuthenticodeSignature -LiteralPath $files[$name].FullName + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or + $null -eq $signature.SignerCertificate -or + $null -eq $signature.TimeStamperCertificate) { + throw "The runtime artifact '$name' lacks a valid timestamped Authenticode signature." + } + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + $signerDigest = ([BitConverter]::ToString( + $sha256.ComputeHash($signature.SignerCertificate.RawData))).Replace('-', '').ToLowerInvariant() + } + finally { + $sha256.Dispose() + } + if ($signerDigest -cne $expectedSigner) { + throw "The runtime artifact '$name' was not signed by the release certificate allowlist." + } + $codeSigningEkus = @( + foreach ($extension in $signature.SignerCertificate.Extensions) { + if ($extension.Oid.Value -ne '2.5.29.37') { continue } + $eku = if ($extension -is [Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]) { + $extension + } + else { + [Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($extension, $false) + } + @($eku.EnhancedKeyUsages | Where-Object Value -ceq '1.3.6.1.5.5.7.3.3') + }) + if ($codeSigningEkus.Count -ne 1) { + throw "The runtime artifact '$name' signer lacks the Code Signing EKU." + } + } +} + +$manifest = Get-Content -LiteralPath $files['submission-manifest.json'].FullName -Raw | + ConvertFrom-Json + +$submissionNames = @('ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.pdb', 'ViiperUde.cat') +$manifestEntries = @($manifest.files) +if ($manifestEntries.Count -ne $submissionNames.Count) { + throw 'The HLK/WHCP submission manifest must describe exactly INF, SYS, PDB, and CAT submission inputs.' +} +$manifestByName = @{} +foreach ($entry in $manifestEntries) { + $name = [string]$entry.name + if ($submissionNames -cnotcontains $name -or $manifestByName.ContainsKey($name)) { + throw "The HLK/WHCP manifest contains an unexpected or duplicate file '$name'." + } + if ([long]$entry.length -le 0 -or [string]$entry.sha256 -cnotmatch '^[0-9A-Fa-f]{64}$') { + throw "The HLK/WHCP manifest contains invalid metadata for '$name'." + } + $manifestByName[$name] = $entry +} + +# Microsoft signing changes the SYS and CAT bytes. The stamped INF remains +# unchanged and is the source-bound runtime member that can be compared to the +# pre-submission manifest after the signed package has passed the Windows gate. +$runtimeInf = $files['ViiperUde.inf'] +$runtimeInfHash = (Get-FileHash -LiteralPath $runtimeInf.FullName -Algorithm SHA256).Hash +if ($runtimeInf.Length -ne [long]$manifestByName['ViiperUde.inf'].length -or + $runtimeInfHash -cne ([string]$manifestByName['ViiperUde.inf'].sha256).ToUpperInvariant()) { + throw 'The runtime INF does not match the source-bound HLK/WHCP submission manifest.' +} + +[xml]$project = Get-Content -LiteralPath (Resolve-Path -LiteralPath $ProjectPath).Path -Raw +$namespace = New-Object System.Xml.XmlNamespaceManager($project.NameTable) +$namespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') +$dateNodes = @($project.SelectNodes('//msb:ViiperUdeDriverDate', $namespace)) +$versionNodes = @($project.SelectNodes('//msb:ViiperUdeDriverVersion', $namespace)) +if ($dateNodes.Count -ne 1 -or $versionNodes.Count -ne 1) { + throw 'The reviewed native project must declare one deterministic DriverVer date and version.' +} +$driverDate = $dateNodes[0].InnerText.Trim() +$driverVersion = $versionNodes[0].InnerText.Trim() +$expectedBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` + -SourceRevision $ExpectedSourceRevision ` + -DriverPackageVersion $driverVersion ` + -ABIMajor 1 -ABIMinor 14 -Capabilities 61 +if ($manifest.schema -ne 2 -or + [string]$manifest.sourceRevision -cne $ExpectedSourceRevision -or + [string]$manifest.driverPackageVersion -cne $driverVersion -or + [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 14 -or + [string]$manifest.driverCapabilities -cne '0x0000003d' -or + [string]$manifest.driverBuildIdentity -cne $expectedBuildIdentity -or + -not [bool]$manifest.releaseEligible -or + [string]$manifest.signingRoute -cne 'HLK/WHCP') { + throw 'The runtime bundle requires the exact release-eligible HLK/WHCP loaded-driver build identity manifest.' +} +$infContents = Get-Content -LiteralPath $runtimeInf.FullName -Raw +$driverVerPattern = '(?mi)^DriverVer\s*=\s*' + + [regex]::Escape($driverDate) + '\s*,\s*' + + [regex]::Escape($driverVersion) + '\s*$' +if ($infContents -notmatch $driverVerPattern -or + $infContents -notmatch '(?mi)^KmdfLibraryVersion\s*=\s*1\.27\s*$') { + throw "The runtime INF is not the stamped DriverVer/KMDF output reviewed at $ExpectedSourceRevision." +} + +foreach ($name in $expectedNames) { + $hash = (Get-FileHash -LiteralPath $files[$name].FullName -Algorithm SHA256).Hash.ToLowerInvariant() + Write-Host "$name sha256:$hash" +} +Write-Host "Validated exact six-file VIIPER native runtime bundle for $ExpectedSourceRevision (Microsoft HLK/WHCP route)." diff --git a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 new file mode 100644 index 00000000..154ec275 --- /dev/null +++ b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 @@ -0,0 +1,840 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$PackageDirectory, + + [Parameter(Mandatory = $true)] + [string]$SubmissionManifestPath, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] + [string]$ExpectedSourceRevision, + + [ValidateSet('LocalTest', 'ControlledTest', 'Production')] + [string]$ValidationMode = 'Production', + + [string]$LocalTestCertificatePath, + + [switch]$RequireLocalTestToolchainValidation +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Write-Host 'Initializing native bounded validation runner.' +if (-not ('ViiperUdeBoundedProcessRunner' -as [type])) { + Add-Type -Language CSharp -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +public sealed class ViiperUdeBoundedProcessResult +{ + public int ExitCode; + public string StandardOutput; + public string StandardError; +} + +public static class ViiperUdeBoundedProcessRunner +{ + private const uint CREATE_SUSPENDED = 0x00000004; + private const uint CREATE_NO_WINDOW = 0x08000000; + private const uint STARTF_USESTDHANDLES = 0x00000100; + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; + private const int JobObjectBasicAccountingInformation = 1; + private const int JobObjectExtendedLimitInformation = 9; + private const uint WAIT_OBJECT_0 = 0; + private const uint WAIT_TIMEOUT = 258; + private const uint INFINITE = 0xffffffff; + private static readonly IntPtr InvalidHandleValue = new IntPtr(-1); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct STARTUPINFO + { + public int cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public uint dwX; + public uint dwY; + public uint dwXSize; + public uint dwYSize; + public uint dwXCountChars; + public uint dwYCountChars; + public uint dwFillAttribute; + public uint dwFlags; + public ushort wShowWindow; + public ushort cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public uint dwProcessId; + public uint dwThreadId; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IO_COUNTERS + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_ACCOUNTING_INFORMATION + { + public long TotalUserTime; + public long TotalKernelTime; + public long ThisPeriodTotalUserTime; + public long ThisPeriodTotalKernelTime; + public uint TotalPageFaultCount; + public uint TotalProcesses; + public uint ActiveProcesses; + public uint TotalTerminatedProcesses; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateJobObject(IntPtr attributes, string name); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject( + IntPtr job, int informationClass, IntPtr information, uint informationLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool QueryInformationJobObject( + IntPtr job, int informationClass, out JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information, + uint informationLength, IntPtr returnLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateJobObject(IntPtr job, uint exitCode); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CreateProcess( + string applicationName, StringBuilder commandLine, IntPtr processAttributes, + IntPtr threadAttributes, bool inheritHandles, uint creationFlags, IntPtr environment, + string currentDirectory, ref STARTUPINFO startupInfo, + out PROCESS_INFORMATION processInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint ResumeThread(IntPtr thread); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateProcess(IntPtr process, uint exitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GetStdHandle(int standardHandle); + + private static void ThrowLastError(string operation) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), operation); + } + + private static void ConfigureJob(IntPtr job) + { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = + new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + int size = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + IntPtr buffer = Marshal.AllocHGlobal(size); + try + { + Marshal.StructureToPtr(limits, buffer, false); + if (!SetInformationJobObject( + job, JobObjectExtendedLimitInformation, buffer, (uint)size)) + { + ThrowLastError("SetInformationJobObject"); + } + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + private static uint RemainingMilliseconds(Stopwatch clock, int timeoutMilliseconds) + { + long remaining = timeoutMilliseconds - clock.ElapsedMilliseconds; + if (remaining <= 0) + { + return 0; + } + return remaining > int.MaxValue ? (uint)int.MaxValue : (uint)remaining; + } + + private static bool WaitForJobEmpty(IntPtr job, uint timeoutMilliseconds) + { + Stopwatch clock = Stopwatch.StartNew(); + int size = Marshal.SizeOf(typeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION)); + for (;;) + { + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION accounting; + if (!QueryInformationJobObject( + job, JobObjectBasicAccountingInformation, out accounting, + (uint)size, IntPtr.Zero)) + { + ThrowLastError("QueryInformationJobObject"); + } + if (accounting.ActiveProcesses == 0) + { + return true; + } + if (clock.ElapsedMilliseconds >= timeoutMilliseconds) + { + return false; + } + Thread.Sleep(10); + } + } + + public static ViiperUdeBoundedProcessResult Run( + string applicationName, string commandLine, int timeoutMilliseconds) + { + if (timeoutMilliseconds <= 0) + { + throw new ArgumentOutOfRangeException("timeoutMilliseconds"); + } + + IntPtr job = IntPtr.Zero; + PROCESS_INFORMATION process = new PROCESS_INFORMATION(); + bool processCreated = false; + bool processAssigned = false; + bool jobDrained = false; + AnonymousPipeServerStream stdoutPipe = null; + AnonymousPipeServerStream stderrPipe = null; + StreamReader stdoutReader = null; + StreamReader stderrReader = null; + Task stdoutTask = null; + Task stderrTask = null; + Stopwatch clock = Stopwatch.StartNew(); + try + { + job = CreateJobObject(IntPtr.Zero, null); + if (job == IntPtr.Zero) + { + ThrowLastError("CreateJobObject"); + } + ConfigureJob(job); + + stdoutPipe = new AnonymousPipeServerStream( + PipeDirection.In, HandleInheritability.Inheritable); + stderrPipe = new AnonymousPipeServerStream( + PipeDirection.In, HandleInheritability.Inheritable); + STARTUPINFO startup = new STARTUPINFO(); + startup.cb = Marshal.SizeOf(typeof(STARTUPINFO)); + startup.dwFlags = STARTF_USESTDHANDLES; + startup.hStdInput = GetStdHandle(-10); + startup.hStdOutput = stdoutPipe.ClientSafePipeHandle.DangerousGetHandle(); + startup.hStdError = stderrPipe.ClientSafePipeHandle.DangerousGetHandle(); + + if (!CreateProcess( + applicationName, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero, + true, CREATE_SUSPENDED | CREATE_NO_WINDOW, IntPtr.Zero, null, + ref startup, out process)) + { + ThrowLastError("CreateProcess"); + } + processCreated = true; + stdoutPipe.DisposeLocalCopyOfClientHandle(); + stderrPipe.DisposeLocalCopyOfClientHandle(); + stdoutReader = new StreamReader(stdoutPipe, Encoding.UTF8, true, 4096, true); + stderrReader = new StreamReader(stderrPipe, Encoding.UTF8, true, 4096, true); + stdoutTask = stdoutReader.ReadToEndAsync(); + stderrTask = stderrReader.ReadToEndAsync(); + + if (!AssignProcessToJobObject(job, process.hProcess)) + { + ThrowLastError("AssignProcessToJobObject"); + } + processAssigned = true; + if (ResumeThread(process.hThread) == UInt32.MaxValue) + { + ThrowLastError("ResumeThread"); + } + + uint remaining = RemainingMilliseconds(clock, timeoutMilliseconds); + uint wait = WaitForSingleObject(process.hProcess, remaining); + if (wait == WAIT_TIMEOUT) + { + throw new TimeoutException("validation process exceeded its deadline"); + } + if (wait != WAIT_OBJECT_0) + { + ThrowLastError("WaitForSingleObject(process)"); + } + + remaining = RemainingMilliseconds(clock, timeoutMilliseconds); + if (!WaitForJobEmpty(job, remaining)) + { + throw new TimeoutException("validation process tree exceeded its deadline"); + } + jobDrained = true; + + Task[] outputTasks = new Task[] { stdoutTask, stderrTask }; + if (!Task.WaitAll(outputTasks, 10000)) + { + throw new TimeoutException("validation output did not drain within 10000 ms"); + } + uint exitCode; + if (!GetExitCodeProcess(process.hProcess, out exitCode)) + { + ThrowLastError("GetExitCodeProcess"); + } + return new ViiperUdeBoundedProcessResult + { + ExitCode = unchecked((int)exitCode), + StandardOutput = stdoutTask.GetAwaiter().GetResult(), + StandardError = stderrTask.GetAwaiter().GetResult() + }; + } + catch (Exception failure) + { + string cleanupFailure = null; + try + { + if (processAssigned && !jobDrained) + { + if (!TerminateJobObject(job, 1)) + { + ThrowLastError("TerminateJobObject"); + } + if (!WaitForJobEmpty(job, 10000)) + { + throw new TimeoutException( + "terminated validation job did not drain within 10000 ms"); + } + jobDrained = true; + } + else if (processCreated && !processAssigned) + { + if (!TerminateProcess(process.hProcess, 1)) + { + ThrowLastError("TerminateProcess"); + } + if (WaitForSingleObject(process.hProcess, 10000) != WAIT_OBJECT_0) + { + throw new TimeoutException( + "unassigned suspended validation process did not terminate within 10000 ms"); + } + } + } + catch (Exception cleanup) + { + cleanupFailure = cleanup.Message; + } + if (cleanupFailure != null) + { + throw new InvalidOperationException( + failure.Message + "; validation process cleanup failed: " + cleanupFailure, + failure); + } + throw; + } + finally + { + if (process.hThread != IntPtr.Zero && process.hThread != InvalidHandleValue) + { + CloseHandle(process.hThread); + } + if (process.hProcess != IntPtr.Zero && process.hProcess != InvalidHandleValue) + { + CloseHandle(process.hProcess); + } + if (job != IntPtr.Zero && job != InvalidHandleValue) + { + CloseHandle(job); + } + if (stdoutReader != null) stdoutReader.Dispose(); + if (stderrReader != null) stderrReader.Dispose(); + if (stdoutPipe != null) stdoutPipe.Dispose(); + if (stderrPipe != null) stderrPipe.Dispose(); + } + } +} +'@ +} +Write-Host 'Initialized native bounded validation runner.' + +function ConvertTo-WindowsCommandLineArgument { + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value) + + if ($Value.Length -gt 0 -and $Value -notmatch '[\s"]') { + return $Value + } + $builder = [Text.StringBuilder]::new() + [void]$builder.Append('"') + $backslashes = 0 + foreach ($character in $Value.ToCharArray()) { + if ($character -eq '\') { + ++$backslashes + continue + } + if ($character -eq '"') { + [void]$builder.Append(('\' * ($backslashes * 2 + 1))) + [void]$builder.Append('"') + $backslashes = 0 + continue + } + [void]$builder.Append(('\' * $backslashes)) + $backslashes = 0 + [void]$builder.Append($character) + } + [void]$builder.Append(('\' * ($backslashes * 2))) + [void]$builder.Append('"') + return $builder.ToString() +} + +function Invoke-BoundedValidationTool { + param( + [Parameter(Mandatory = $true)][string]$FilePath, + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$Operation, + [int]$TimeoutMilliseconds = 120000, + [switch]$SuppressOutput + ) + + $commandLine = ConvertTo-WindowsCommandLineArgument -Value $FilePath + if ($Arguments.Count -gt 0) { + $commandLine += ' ' + (($Arguments | ForEach-Object { + ConvertTo-WindowsCommandLineArgument -Value $_ + }) -join ' ') + } + try { + Write-Host "Starting bounded validation: $Operation" + $result = [ViiperUdeBoundedProcessRunner]::Run( + $FilePath, $commandLine, $TimeoutMilliseconds) + Write-Host "Completed bounded validation: $Operation" + if (-not $SuppressOutput -and $result.StandardOutput) { + Write-Host $result.StandardOutput.TrimEnd() + } + if (-not $SuppressOutput -and $result.StandardError) { + Write-Host $result.StandardError.TrimEnd() + } + return [pscustomobject]@{ + ExitCode = $result.ExitCode + StandardOutput = $result.StandardOutput + StandardError = $result.StandardError + } + } + catch { + throw "$Operation failed closed: $($_.Exception.Message)" + } +} + +function Get-CertificateEkuOids { + param( + [Parameter(Mandatory = $true)] + [Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + ) + + $oids = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($extension in $Certificate.Extensions) { + if ($extension.Oid.Value -ne '2.5.29.37') { + continue + } + $eku = if ($extension -is [Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]) { + $extension + } + else { + [Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($extension, $false) + } + foreach ($oid in $eku.EnhancedKeyUsages) { + [void]$oids.Add($oid.Value) + } + } + return ,$oids +} + +function Get-CertificateSha256 { + param( + [Parameter(Mandatory = $true)] + [Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + ) + + $algorithm = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString( + $algorithm.ComputeHash($Certificate.RawData))).Replace('-', '').ToLowerInvariant() + } + finally { + $algorithm.Dispose() + } +} + +function Get-BoundedAuthenticodeSignature { + param([Parameter(Mandatory = $true)][string]$Path) + + $encodedPath = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Path)) + $command = @" +`$ErrorActionPreference = 'Stop' +`$ProgressPreference = 'SilentlyContinue' +`$path = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('$encodedPath')) +`$signature = Get-AuthenticodeSignature -LiteralPath `$path +`$certificate = if (`$null -eq `$signature.SignerCertificate) { '' } else { + [Convert]::ToBase64String(`$signature.SignerCertificate.RawData) +} +[ordered]@{ status = `$signature.Status.ToString(); certificate = `$certificate } | + ConvertTo-Json -Compress +"@ + $encodedCommand = [Convert]::ToBase64String( + [Text.Encoding]::Unicode.GetBytes($command)) + $hostPath = (Get-Process -Id $PID).Path + $result = Invoke-BoundedValidationTool -FilePath $hostPath ` + -Arguments @('-NoProfile', '-NonInteractive', '-EncodedCommand', $encodedCommand) ` + -Operation "Authenticode validation for '$Path'" -SuppressOutput + if ($result.ExitCode -ne 0) { + throw "Authenticode validation failed for '$Path' with exit code $($result.ExitCode)." + } + try { + $value = $result.StandardOutput.Trim() | ConvertFrom-Json -ErrorAction Stop + if ([string]$value.status -notmatch '^[A-Za-z]+$' -or + [string]::IsNullOrEmpty([string]$value.certificate)) { + throw 'missing status or signer certificate' + } + $certificateBytes = [Convert]::FromBase64String([string]$value.certificate) + return [pscustomobject]@{ + Status = [string]$value.status + SignerCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( + $certificateBytes) + } + } + catch { + throw "Authenticode validation returned malformed evidence for '$Path': $($_.Exception.Message)" + } +} + +function Test-ExpectedLocalTestTrustFailure { + param( + [Parameter(Mandatory = $true)]$Result, + [Parameter(Mandatory = $true)][string]$ExpectedCertificateThumbprint, + [Parameter(Mandatory = $true)][string]$TargetPath, + [string]$CatalogPath + ) + + if ($Result.ExitCode -ne 1 -or + $ExpectedCertificateThumbprint -cnotmatch '^[0-9A-F]{40}$') { + return $false + } + $evidence = ([string]$Result.StandardOutput) + "`n" + + ([string]$Result.StandardError) + $rootTrustError = '(?ims)^SignTool Error: A certificate chain processed, but terminated in a root\s*\r?\n\s*certificate which is not trusted by the trust provider\.\s*$' + if (@([regex]::Matches($evidence, $rootTrustError)).Count -ne 1 -or + @([regex]::Matches($evidence, + '(?im)^\s*SHA1 hash:\s*' + [regex]::Escape($ExpectedCertificateThumbprint) + '\s*$')).Count -ne 1 -or + @([regex]::Matches($evidence, '(?im)^Number of warnings:\s*0\s*$')).Count -ne 1 -or + @([regex]::Matches($evidence, '(?im)^Number of errors:\s*1\s*$')).Count -ne 1 -or + @([regex]::Matches($evidence, + '(?im)^Verifying:\s*' + [regex]::Escape($TargetPath) + '\s*$')).Count -ne 1) { + return $false + } + if (-not [string]::IsNullOrWhiteSpace($CatalogPath) -and + @([regex]::Matches($evidence, + '(?im)^File is signed in catalog:\s*' + [regex]::Escape($CatalogPath) + '\s*$')).Count -ne 1) { + return $false + } + return $true +} + +function Assert-DriverSignature { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [ValidateSet('LocalTest', 'ControlledTest', 'Production')] + [string]$Mode, + + [string]$ExpectedLocalTestCertificateSha256, + + [switch]$AllowUntrustedLocalTestRoot + ) + + $signature = Get-BoundedAuthenticodeSignature -Path $Path + try { + if ($signature.Status -cne 'Valid' -and + -not ($Mode -eq 'LocalTest' -and $AllowUntrustedLocalTestRoot -and + $signature.Status -ceq 'UnknownError')) { + throw "'$Path' does not have a valid Authenticode signature (status '$($signature.Status)')." + } + if ($null -eq $signature.SignerCertificate) { + throw "'$Path' did not expose its signing certificate." + } + if ($Mode -eq 'LocalTest') { + $actual = Get-CertificateSha256 -Certificate $signature.SignerCertificate + if ($ExpectedLocalTestCertificateSha256 -notmatch '^[0-9a-f]{64}$' -or + $actual -cne $ExpectedLocalTestCertificateSha256) { + throw "'$Path' is not signed by the exact source-bound local test certificate." + } + return + } + if ( + $signature.SignerCertificate.Subject -notmatch '(?i)(^|,\s*)O=Microsoft Corporation(,|$)') { + throw "'$Path' is not signed by Microsoft Corporation." + } + + $ekuOids = Get-CertificateEkuOids -Certificate $signature.SignerCertificate + $hardwareVerificationOid = '1.3.6.1.4.1.311.10.3.5' + $attestedVerificationOid = '1.3.6.1.4.1.311.10.3.5.1' + if (-not $ekuOids.Contains($hardwareVerificationOid)) { + throw "'$Path' lacks the Windows Hardware Driver Verification EKU." + } + if ($Mode -eq 'ControlledTest') { + if (-not $ekuOids.Contains($attestedVerificationOid)) { + throw "'$Path' is not a Microsoft attestation-signed controlled-test artifact." + } + } + elseif ($ekuOids.Contains($attestedVerificationOid)) { + throw "'$Path' is attestation signed and cannot pass the production HLK/WHCP release gate." + } + } + finally { + $signature.SignerCertificate.Dispose() + } +} + +$root = Resolve-Path -LiteralPath $PackageDirectory -ErrorAction Stop +if (-not (Get-Item -LiteralPath $root.Path).PSIsContainer) { + throw 'The signed package path must be a directory.' +} + +$localTestCertificate = $null +$localTestCertificateSha256 = $null +$localTestCertificateThumbprint = $null +if ($ValidationMode -eq 'LocalTest') { + if ([string]::IsNullOrWhiteSpace($LocalTestCertificatePath)) { + throw '-LocalTestCertificatePath is required for LocalTest validation.' + } + $resolvedCertificate = (Resolve-Path -LiteralPath $LocalTestCertificatePath -ErrorAction Stop).Path + $certificateItem = Get-Item -LiteralPath $resolvedCertificate -Force + if ($certificateItem.PSIsContainer -or $certificateItem.Length -le 0 -or + $certificateItem.Name -cne 'ViiperUdeTest.cer' -or + ($certificateItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'The local test certificate must be a nonempty, case-exact, non-reparse ViiperUdeTest.cer file.' + } + $localTestCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new($resolvedCertificate) + $localTestCertificateSha256 = Get-CertificateSha256 -Certificate $localTestCertificate + $localTestCertificateThumbprint = $localTestCertificate.Thumbprint.ToUpperInvariant() + $chain = [Security.Cryptography.X509Certificates.X509Chain]::new() + try { + $chain.ChainPolicy.RevocationMode = + [Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck + $chain.ChainPolicy.VerificationFlags = + [Security.Cryptography.X509Certificates.X509VerificationFlags]::AllowUnknownCertificateAuthority + $chainValid = $chain.Build($localTestCertificate) + $chainStatuses = @($chain.ChainStatus) + $onlyExpectedTrustStatus = $chainStatuses.Count -eq 0 -or + ($chainStatuses.Count -eq 1 -and + $chainStatuses[0].Status -eq + [Security.Cryptography.X509Certificates.X509ChainStatusFlags]::UntrustedRoot) + $ekuOids = Get-CertificateEkuOids -Certificate $localTestCertificate + if (-not $chainValid -or -not $onlyExpectedTrustStatus -or + $chain.ChainElements.Count -ne 1 -or + $localTestCertificate.Subject -cne $localTestCertificate.Issuer -or + $localTestCertificate.NotBefore -gt [DateTime]::Now -or + $localTestCertificate.NotAfter -lt [DateTime]::Now -or + -not $ekuOids.Contains('1.3.6.1.5.5.7.3.3') -or + $localTestCertificateThumbprint -cnotmatch '^[0-9A-F]{40}$') { + throw 'The local-test certificate is not a current, self-issued, code-signing certificate with a valid self-chain.' + } + } + finally { + $chain.Dispose() + } +} +elseif (-not [string]::IsNullOrWhiteSpace($LocalTestCertificatePath)) { + throw '-LocalTestCertificatePath is valid only with -ValidationMode LocalTest.' +} +if ($RequireLocalTestToolchainValidation -and $ValidationMode -ne 'LocalTest') { + throw '-RequireLocalTestToolchainValidation is valid only with -ValidationMode LocalTest.' +} + +$expectedNames = @('ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.pdb', 'ViiperUde.cat') +$allEntries = @(Get-ChildItem -LiteralPath $root.Path -Force) +if (@($allEntries | Where-Object PSIsContainer).Count -ne 0) { + throw 'The signed package files must be direct children of the package directory; subdirectories are forbidden.' +} +$allFiles = @($allEntries | Where-Object { -not $_.PSIsContainer }) +if ($allFiles.Count -ne $expectedNames.Count) { + throw "The signed package must contain exactly $($expectedNames.Count) files; found $($allFiles.Count)." +} +$files = @{} +foreach ($name in $expectedNames) { + $matches = @($allFiles | Where-Object Name -CEQ $name) + if ($matches.Count -ne 1) { + throw "The signed package must contain exactly one case-exact '$name'; found $($matches.Count)." + } + if ($matches[0].Length -le 0) { + throw "The signed package file '$name' is empty." + } + $files[$name] = $matches[0].FullName +} +if (@($allFiles | Where-Object { $_.DirectoryName -cne $root.Path }).Count -ne 0) { + throw 'All signed package files must reside directly in the canonical package directory.' +} + +$manifestFile = Resolve-Path -LiteralPath $SubmissionManifestPath -ErrorAction Stop +$manifest = Get-Content -LiteralPath $manifestFile.Path -Raw | ConvertFrom-Json +$projectPath = Join-Path $PSScriptRoot '..\driver\ViiperUde.vcxproj' +[xml]$driverProject = Get-Content -LiteralPath $projectPath -Raw +$projectNamespace = [Xml.XmlNamespaceManager]::new($driverProject.NameTable) +$projectNamespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') +$versionNodes = @($driverProject.SelectNodes('//msb:ViiperUdeDriverVersion', $projectNamespace)) +if ($versionNodes.Count -ne 1) { + throw 'The driver project must declare one deterministic ViiperUdeDriverVersion.' +} +$driverPackageVersion = $versionNodes[0].InnerText.Trim() +$expectedBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` + -SourceRevision $ExpectedSourceRevision ` + -DriverPackageVersion $driverPackageVersion ` + -ABIMajor 1 -ABIMinor 14 -Capabilities 61 +if ($manifest.schema -ne 2 -or + [string]$manifest.sourceRevision -cne $ExpectedSourceRevision.ToLowerInvariant() -or + [string]$manifest.driverPackageVersion -cne $driverPackageVersion -or + [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 14 -or + [string]$manifest.driverCapabilities -cne '0x0000003d' -or + [string]$manifest.driverBuildIdentity -cne $expectedBuildIdentity) { + throw 'The submission manifest schema, source revision, or native loaded-build identity does not match the reviewed source.' +} +if ($ValidationMode -eq 'LocalTest') { + if ([bool]$manifest.releaseEligible -or [string]$manifest.signingRoute -cne 'LocalTest' -or + [string]$manifest.testSignerCertificateSha256 -cne $localTestCertificateSha256) { + throw 'LocalTest validation requires a non-release manifest bound to the exact local test certificate.' + } +} +elseif ($ValidationMode -eq 'ControlledTest') { + if ([bool]$manifest.releaseEligible -or [string]$manifest.signingRoute -cne 'ControlledTestAttestation') { + throw 'Controlled-test validation requires a testing-only attestation submission manifest.' + } +} +elseif (-not [bool]$manifest.releaseEligible -or [string]$manifest.signingRoute -cne 'HLK/WHCP') { + throw 'Production validation requires a release-eligible HLK/WHCP submission manifest.' +} + +$manifestFiles = @($manifest.files) +if ($manifestFiles.Count -ne $expectedNames.Count) { + throw "The submission manifest must describe exactly $($expectedNames.Count) files." +} +$manifestByName = @{} +foreach ($entry in $manifestFiles) { + $name = [string]$entry.name + if ($expectedNames -cnotcontains $name -or $manifestByName.ContainsKey($name)) { + throw "The submission manifest contains an unexpected or duplicate file '$name'." + } + if ([long]$entry.length -le 0 -or [string]$entry.sha256 -cnotmatch '^[0-9A-Fa-f]{64}$') { + throw "The submission manifest contains invalid metadata for '$name'." + } + $manifestByName[$name] = $entry +} +foreach ($name in @('ViiperUde.inf', 'ViiperUde.pdb')) { + if (-not $manifestByName.ContainsKey($name)) { + throw "The submission manifest does not describe '$name'." + } + $actual = Get-Item -LiteralPath $files[$name] + $actualHash = (Get-FileHash -LiteralPath $actual.FullName -Algorithm SHA256).Hash + if ($actual.Length -ne [long]$manifestByName[$name].length -or + $actualHash -cne ([string]$manifestByName[$name].sha256).ToUpperInvariant()) { + throw "The Microsoft-returned '$name' does not match the source-bound submission manifest." + } +} + +foreach ($name in @('ViiperUde.cat', 'ViiperUde.sys')) { + Assert-DriverSignature -Path $files[$name] -Mode $ValidationMode ` + -ExpectedLocalTestCertificateSha256 $localTestCertificateSha256 ` + -AllowUntrustedLocalTestRoot:$RequireLocalTestToolchainValidation +} +$requireExternalTools = $ValidationMode -ne 'LocalTest' -or $RequireLocalTestToolchainValidation +if ($requireExternalTools) { + $signTool = Get-Command signtool.exe -ErrorAction Stop + foreach ($name in @('ViiperUde.cat', 'ViiperUde.sys')) { + $policy = if ($ValidationMode -eq 'LocalTest') { '/pa' } else { '/kp' } + $exitCode = Invoke-BoundedValidationTool -FilePath $signTool.Source ` + -Arguments @('verify', $policy, '/v', $files[$name]) ` + -Operation "SignTool signature validation for '$name'" + $expectedUntrustedRoot = $ValidationMode -eq 'LocalTest' -and + (Test-ExpectedLocalTestTrustFailure -Result $exitCode ` + -ExpectedCertificateThumbprint $localTestCertificateThumbprint ` + -TargetPath $files[$name]) + if ($exitCode.ExitCode -ne 0 -and -not $expectedUntrustedRoot) { + throw "Signature policy validation failed for '$name' with exit code $($exitCode.ExitCode)." + } + } + foreach ($name in @('ViiperUde.inf', 'ViiperUde.sys')) { + $policy = if ($ValidationMode -eq 'LocalTest') { '/pa' } else { '/kp' } + $exitCode = Invoke-BoundedValidationTool -FilePath $signTool.Source ` + -Arguments @('verify', $policy, '/v', '/c', $files['ViiperUde.cat'], $files[$name]) ` + -Operation "SignTool catalog membership validation for '$name'" + $expectedUntrustedRoot = $ValidationMode -eq 'LocalTest' -and + (Test-ExpectedLocalTestTrustFailure -Result $exitCode ` + -ExpectedCertificateThumbprint $localTestCertificateThumbprint ` + -TargetPath $files[$name] -CatalogPath $files['ViiperUde.cat']) + if ($exitCode.ExitCode -ne 0 -and -not $expectedUntrustedRoot) { + throw "'$name' is not a verified member of the exact catalog (exit code $($exitCode.ExitCode))." + } + } + + $infVerif = Get-Command infverif.exe -ErrorAction Stop + foreach ($mode in @('/h', '/u')) { + $exitCode = Invoke-BoundedValidationTool -FilePath $infVerif.Source ` + -Arguments @($mode, $files['ViiperUde.inf']) ` + -Operation "InfVerif $mode validation" + if ($exitCode.ExitCode -ne 0) { + throw "InfVerif $mode rejected the signed package with exit code $($exitCode.ExitCode)." + } + } +} + +$signatureKind = if ($ValidationMode -eq 'LocalTest') { 'local test-signed' } else { 'Microsoft-signed' } +Write-Host "Validated source-bound $signatureKind VIIPER native UDE package in $ValidationMode mode at '$($root.Path)'." diff --git a/native/udecx/tools/Test-ViiperUdeStaticAnalysis.ps1 b/native/udecx/tools/Test-ViiperUdeStaticAnalysis.ps1 new file mode 100644 index 00000000..111b5b4e --- /dev/null +++ b/native/udecx/tools/Test-ViiperUdeStaticAnalysis.ps1 @@ -0,0 +1,44 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$AnalysisDirectory, + + [ValidateRange(1, 1024)] + [int]$ExpectedSourceCount = 6 +) + +$ErrorActionPreference = 'Stop' +$analysisRoot = (Resolve-Path -LiteralPath $AnalysisDirectory).Path +$results = @(Get-ChildItem -LiteralPath $analysisRoot -File -Filter '*.nativecodeanalysis.xml') +if ($results.Count -ne $ExpectedSourceCount) { + throw "Expected $ExpectedSourceCount native code-analysis result files in '$analysisRoot'; found $($results.Count)." +} + +$defects = [Collections.Generic.List[object]]::new() +foreach ($result in $results) { + $settings = [Xml.XmlReaderSettings]::new() + $settings.DtdProcessing = [Xml.DtdProcessing]::Prohibit + $settings.XmlResolver = $null + $reader = [Xml.XmlReader]::Create($result.FullName, $settings) + try { + $document = [Xml.XmlDocument]::new() + $document.XmlResolver = $null + $document.Load($reader) + } finally { + $reader.Dispose() + } + + foreach ($defect in @($document.SelectNodes('/DEFECTS/*'))) { + $defects.Add([pscustomobject]@{ + Source = $result.Name + Detail = $defect.OuterXml + }) + } +} + +if ($defects.Count -ne 0) { + $details = ($defects | ForEach-Object { "$($_.Source): $($_.Detail)" }) -join [Environment]::NewLine + throw "Native driver static analysis reported $($defects.Count) defect(s):$([Environment]::NewLine)$details" +} + +Write-Host "VIIPER UDE native static analysis passed for $($results.Count) translation units." diff --git a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 new file mode 100644 index 00000000..50373d40 --- /dev/null +++ b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 @@ -0,0 +1,735 @@ +[CmdletBinding()] +param( + [string]$ProjectPath, + [string]$InfPath, + [switch]$RequireStampedInf +) + +$ErrorActionPreference = 'Stop' +if ([string]::IsNullOrWhiteSpace($ProjectPath)) { + $ProjectPath = Join-Path $PSScriptRoot '..\driver\ViiperUde.vcxproj' +} +if ([string]::IsNullOrWhiteSpace($InfPath)) { + $InfPath = Join-Path $PSScriptRoot '..\package\ViiperUde.inf' +} +$projectPathResolved = (Resolve-Path -LiteralPath $ProjectPath).Path +$infPathResolved = (Resolve-Path -LiteralPath $InfPath).Path +$driverSourceDirectory = Split-Path -Parent $projectPathResolved + +[xml]$project = Get-Content -LiteralPath $projectPathResolved -Raw +$namespace = New-Object System.Xml.XmlNamespaceManager($project.NameTable) +$namespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') +$minorNodes = @($project.SelectNodes('//msb:KMDF_VERSION_MINOR', $namespace)) +if ($minorNodes.Count -ne 2) { + throw "Expected Debug and Release KMDF_VERSION_MINOR nodes; found $($minorNodes.Count)." +} +$minorVersions = @($minorNodes | ForEach-Object { $_.InnerText.Trim() } | Sort-Object -Unique) +if ($minorVersions.Count -ne 1 -or $minorVersions[0] -ne '27') { + throw "Windows 10 1809 requires the committed KMDF 1.27 contract; project targets: $($minorVersions -join ', ')." +} + +$majorNodes = @($project.SelectNodes('//msb:KMDF_VERSION_MAJOR', $namespace)) +if ($majorNodes.Count -ne 2) { + throw "Expected Debug and Release KMDF_VERSION_MAJOR nodes; found $($majorNodes.Count)." +} +$majorVersions = @($majorNodes | ForEach-Object { $_.InnerText.Trim() } | Sort-Object -Unique) +if ($majorVersions.Count -ne 1 -or $majorVersions[0] -ne '1') { + throw "The committed driver must target KMDF major version 1; project targets: $($majorVersions -join ', ')." +} + +function Get-SingleProjectValue([string]$elementName) { + $nodes = @($project.SelectNodes("//msb:$elementName", $namespace)) + if ($nodes.Count -ne 1 -or [string]::IsNullOrWhiteSpace($nodes[0].InnerText)) { + throw "Expected exactly one non-empty $elementName project value; found $($nodes.Count)." + } + return $nodes[0].InnerText.Trim() +} + +function Get-CFunctionBody( + [string]$Source, + [string]$FunctionName +) { + $escapedName = [regex]::Escape($FunctionName) + $definitionPattern = + "(?ms)^[ \t]*(?:static\s+)?(?:NTSTATUS|VOID)\s+$escapedName\s*\([^;{}]*?\)\s*\{" + $definitions = @([regex]::Matches($Source, $definitionPattern)) + if ($definitions.Count -ne 1) { + throw "Expected exactly one C definition for $FunctionName; found $($definitions.Count)." + } + + $openingBrace = $definitions[0].Index + $definitions[0].Length - 1 + $depth = 0 + for ($index = $openingBrace; $index -lt $Source.Length; $index++) { + if ($Source[$index] -eq '{') { + $depth++ + } elseif ($Source[$index] -eq '}') { + $depth-- + if ($depth -eq 0) { + return $Source.Substring( + $openingBrace + 1, + $index - $openingBrace - 1) + } + } + } + throw "Unbalanced C definition for $FunctionName." +} + +$driverDate = Get-SingleProjectValue 'ViiperUdeDriverDate' +$driverVersion = Get-SingleProjectValue 'ViiperUdeDriverVersion' +$parsedDriverDate = [DateTime]::MinValue +if (-not [DateTime]::TryParseExact($driverDate, 'MM/dd/yyyy', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::None, [ref]$parsedDriverDate)) { + throw "ViiperUdeDriverDate must use deterministic MM/dd/yyyy format; found '$driverDate'." +} +if ($driverVersion -notmatch '^\d+\.\d+\.\d+\.\d+$') { + throw "ViiperUdeDriverVersion must be a four-part numeric version; found '$driverVersion'." +} + +$compileDefinition = $project.SelectSingleNode( + '//msb:ItemDefinitionGroup/msb:ClCompile', $namespace) +$linkDefinition = $project.SelectSingleNode( + '//msb:ItemDefinitionGroup/msb:Link', $namespace) +if ($null -eq $compileDefinition -or + $compileDefinition.DebugInformationFormat -cne 'ProgramDatabase' -or + $compileDefinition.SupportJustMyCode -cne 'false' -or + $compileDefinition.AdditionalOptions -notmatch '(?:^|\s)/Zo(?:\s|$)') { + throw 'Every native build must emit optimized source/line/type information with /Zi and /Zo.' +} +if ($null -eq $linkDefinition -or + $linkDefinition.GenerateDebugInformation -cne 'true' -or + $linkDefinition.GenerateMapFile -cne 'true' -or + $linkDefinition.MapExports -cne 'true' -or + $linkDefinition.AdditionalOptions -notmatch '(?:^|\s)/DEBUG:FULL(?:\s|$)' -or + $linkDefinition.AdditionalOptions -notmatch '/PDBALTPATH:%_PDB%') { + throw 'Every native build must emit a full matching PDB and link map with a relocatable image PDB path.' +} +$releaseConfiguration = $project.SelectSingleNode( + "//msb:PropertyGroup[contains(@Condition, 'Release|x64')]", $namespace) +$staticAnalysisTarget = $project.SelectSingleNode( + "//msb:Target[@Name='VerifyViiperUdeStaticAnalysis']", $namespace) +if ($null -eq $releaseConfiguration -or + $releaseConfiguration.RunCodeAnalysis -cne 'true' -or + $releaseConfiguration.EnableMicrosoftCodeAnalysis -cne 'true' -or + $null -eq $staticAnalysisTarget -or + $staticAnalysisTarget.AfterTargets -cne 'ClCompile' -or + $staticAnalysisTarget.Condition -notmatch 'RunCodeAnalysis' -or + $null -eq $staticAnalysisTarget.Exec -or + $staticAnalysisTarget.Exec.Command -notmatch 'Test-ViiperUdeStaticAnalysis\.ps1' -or + $staticAnalysisTarget.Exec.Command -notmatch '-ExpectedSourceCount 6') { + throw 'Every Release driver build must run WDK static analysis and fail closed if any translation unit reports a defect.' +} +$traceCompileItems = @($project.SelectNodes( + "//msb:ClCompile[@Include='Trace.c']", $namespace)) +if ($traceCompileItems.Count -ne 1) { + throw "Expected exactly one Trace.c compile item; found $($traceCompileItems.Count)." +} + +$infItems = @($project.SelectNodes('//msb:Inf', $namespace)) +if ($infItems.Count -ne 1) { + throw "Expected exactly one INF project item; found $($infItems.Count)." +} +$infItem = $infItems[0] +$stampContract = [ordered]@{ + 'SpecifyDriverVerDirectiveDate' = 'true' + 'DateStamp' = '$(ViiperUdeDriverDate)' + 'SpecifyDriverVerDirectiveVersion' = 'true' + 'TimeStamp' = '$(ViiperUdeDriverVersion)' +} +foreach ($entry in $stampContract.GetEnumerator()) { + $node = $infItem.SelectSingleNode("msb:$($entry.Key)", $namespace) + if ($null -eq $node -or $node.InnerText.Trim() -cne $entry.Value) { + throw "INF build metadata '$($entry.Key)' must be '$($entry.Value)' so StampInf cannot synthesize a date or version." + } +} + +$inf = Get-Content -LiteralPath $infPathResolved -Raw +if ($inf -notmatch '(?mi)^\[Standard\.NTamd64\.10\.0\.\.\.17763\]\s*$') { + throw 'The INF no longer declares the reviewed Windows 10 1809 (build 17763) target floor.' +} +$driverVerPattern = '(?mi)^DriverVer\s*=\s*' + + [regex]::Escape($driverDate) + '\s*,\s*' + + [regex]::Escape($driverVersion) + '\s*$' +if ($inf -notmatch $driverVerPattern) { + throw "The INF DriverVer must exactly match the deterministic project contract '$driverDate,$driverVersion'." +} +if ($inf -notmatch '(?mi)^\[ViiperUde_Install\.NT\.Wdf\]\s*$' -or + $inf -notmatch '(?mi)^KmdfService\s*=\s*ViiperUde\s*,\s*ViiperUde_Wdf\s*$' -or + $inf -notmatch '(?mi)^\[ViiperUde_Wdf\]\s*$') { + throw 'The INF must bind the ViiperUde service through ViiperUde_Install.NT.Wdf.' +} +$expectedKmdfLibraryVersion = if ($RequireStampedInf) { '1.27' } else { '$KMDFVERSION$' } +$kmdfLibraryPattern = '(?mi)^KmdfLibraryVersion\s*=\s*' + + [regex]::Escape($expectedKmdfLibraryVersion) + '\s*$' +if ($inf -notmatch $kmdfLibraryPattern) { + throw "The INF KmdfLibraryVersion must be '$expectedKmdfLibraryVersion'." +} + +# Keep the reviewed UdeCx callback and teardown contracts machine-verifiable. +# These checks intentionally target small invariants rather than formatting so +# a refactor cannot silently restore dispatch-level pageable callbacks or make +# parent cleanup call framework children that KMDF has already deleted. +$header = Get-Content -LiteralPath (Join-Path $driverSourceDirectory 'ViiperUde.h') -Raw +$controllerSource = Get-Content -LiteralPath (Join-Path $driverSourceDirectory 'Controller.c') -Raw +$deviceSource = Get-Content -LiteralPath (Join-Path $driverSourceDirectory 'Device.c') -Raw +$brokerSource = Get-Content -LiteralPath (Join-Path $driverSourceDirectory 'Broker.c') -Raw +$traceSource = Get-Content -LiteralPath (Join-Path $driverSourceDirectory 'Trace.c') -Raw +$allDriverCSource = (Get-ChildItem -LiteralPath $driverSourceDirectory -Filter '*.c' | + Sort-Object -Property FullName | + ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw }) -join "`n" + +foreach ($requiredTraceContract in @( + '#define VIIPER_UDE_LIFECYCLE_TRACE_MAX_SHARDS 64', + 'typedef struct VIIPER_UDE_LIFECYCLE_TRACE_SHARD', + 'DECLSPEC_ALIGN(SYSTEM_CACHE_ALIGNMENT_SIZE) volatile LONG64 WriteSequence;', + 'volatile LONG64 SlotStates[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY];', + 'VIIPER_UDE_LIFECYCLE_TRACE_RECORD Records[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY];', + 'volatile LONG64 LifecycleTraceSequence;', + 'volatile LONG LifecycleTraceStatus;', + 'WDFMEMORY LifecycleTraceStorage;', + 'VIIPER_UDE_LIFECYCLE_TRACE_SHARD *LifecycleTraceShards;', + 'ULONG LifecycleTraceShardCount;', + '#define VIIPER_TRACE_LIFECYCLE')) { + if (-not $header.Contains($requiredTraceContract)) { + throw "Missing bounded lifecycle-flight-recorder contract: $requiredTraceContract" + } +} +$traceInitialize = Get-CFunctionBody $traceSource 'ViiperInitializeLifecycleTrace' +$traceHot = Get-CFunctionBody $traceSource 'ViiperTraceLifecycle' +if ($traceInitialize -notmatch + '(?s)WdfMemoryCreate\s*\(\s*&attributes\s*,\s*NonPagedPoolNx\s*,\s*0x56495554\s*,\s*storageSize\s*,\s*&controllerContext->LifecycleTraceStorage\s*,\s*&rawStorage\s*\)' -or + $traceInitialize -notmatch + '(?s)shardCount\s*=\s*maximumProcessors\s*>\s*VIIPER_UDE_LIFECYCLE_TRACE_MAX_SHARDS\s*\?\s*VIIPER_UDE_LIFECYCLE_TRACE_MAX_SHARDS\s*:\s*maximumProcessors\s*;' -or + $traceInitialize -notmatch + '(?s)RtlZeroMemory\s*\(\s*rawStorage\s*,\s*storageSize\s*\).*?controllerContext->LifecycleTraceShards\s*=.*?controllerContext->LifecycleTraceShardCount\s*=\s*shardCount\s*;') { + throw 'Lifecycle trace initialization must allocate, clear, and publish exact nonpaged shard storage.' +} +foreach ($requiredHotContract in @( + 'InterlockedIncrement64(&shard->WriteSequence)', + 'InterlockedCompareExchange64(', + 'VIIPER_UDE_LIFECYCLE_TRACE_STATUS_DROPPED_RECORD', + 'InterlockedIncrement64(', + '&controllerContext->LifecycleTraceSequence', + 'KeQueryPerformanceCounter(NULL)', + '_ReturnAddress()', + 'KeGetCurrentIrql()', + 'InterlockedExchange64(')) { + if (-not $traceHot.Contains($requiredHotContract)) { + throw "Lifecycle tracing hot path is missing: $requiredHotContract" + } +} +$statusUpdates = @([regex]::Matches( + $traceHot, + 'InterlockedOr\s*\(\s*&controllerContext->LifecycleTraceStatus\s*,')) +if ($statusUpdates.Count -ne 2 -or + $traceHot -notmatch + 'InterlockedIncrement64\s*\(\s*&shard->WriteSequence\s*\)' -or + $traceHot -notmatch + 'InterlockedCompareExchange64\s*\(\s*slotState\s*,\s*claimedSlotState\s*,\s*observedSlotState\s*\)' -or + $traceHot -notmatch + 'InterlockedIncrement64\s*\(\s*&controllerContext->LifecycleTraceSequence\s*\)' -or + $traceHot -notmatch + '(?s)Event\s*>=\s*VIIPER_UDE_TRACE_ENDPOINT_QUIESCENCE_WATCHDOG\s*&&\s*Event\s*<=\s*VIIPER_UDE_TRACE_OWNER_RUNDOWN_WATCHDOG.*?InterlockedOr\s*\(\s*&controllerContext->LifecycleTraceStatus\s*,\s*VIIPER_UDE_LIFECYCLE_TRACE_STATUS_WATCHDOG_FIRED\s*\)' -or + $traceHot -notmatch + '(?s)\(observedSlotState\s*&\s*1\)\s*!=\s*0\s*\|\|.*?>=\s*localSequence.*?InterlockedOr\s*\(\s*&controllerContext->LifecycleTraceStatus\s*,\s*VIIPER_UDE_LIFECYCLE_TRACE_STATUS_DROPPED_RECORD\s*\)' -or + $traceHot -notmatch + '(?s)InterlockedExchange64\s*\(\s*\(volatile LONG64 \*\)&record->PublishedSequence\s*,\s*0\s*\)\s*;\s*KeMemoryBarrier\s*\(\s*\)\s*;.*?KeMemoryBarrier\s*\(\s*\)\s*;\s*\(VOID\)InterlockedExchange64\s*\(\s*\(volatile LONG64 \*\)&record->PublishedSequence\s*,\s*\(LONG64\)sequence\s*\)\s*;\s*KeMemoryBarrier\s*\(\s*\)\s*;\s*\(VOID\)InterlockedExchange64\s*\(\s*slotState\s*,\s*\(LONG64\)\(localSequence\s*<<\s*1\)\s*\)\s*;' -or + $traceHot -match 'ExAllocatePool|WdfMemoryCreate|WdfSpinLockAcquire|WdfWaitLockAcquire|KeWaitForSingleObject') { + throw 'Lifecycle tracing must remain preallocated, nonblocking, timestamped, and source-addressable.' +} + +if ($controllerSource -notmatch + 'WdfDeviceInitSetCharacteristics\s*\(\s*DeviceInit\s*,\s*FILE_DEVICE_SECURE_OPEN\s*\|\s*FILE_AUTOGENERATED_DEVICE_NAME\s*,\s*FALSE\s*\)\s*;' -or + $controllerSource -notmatch + 'FILE_AUTOGENERATED_DEVICE_NAME[\s\S]{0,300}?WdfDeviceInitAssignSDDLString\s*\(\s*DeviceInit') { + throw 'The controller must name its device before assigning the broker-only SDDL.' +} +if ($controllerSource -notmatch + 'WdfDeviceCreate\s*\([\s\S]{0,1500}?WDF_DEVICE_POWER_POLICY_IDLE_SETTINGS_INIT\s*\(\s*&idleSettings\s*,\s*IdleCannotWakeFromS0\s*\)\s*;[\s\S]{0,300}?WdfDeviceAssignS0IdleSettings\s*\(\s*device\s*,\s*&idleSettings\s*\)[\s\S]{0,2500}?UdecxWdfDeviceAddUsbDeviceEmulation') { + throw 'The controller must establish non-wakeable S0 idle policy before publishing UdeCx emulation.' +} + +foreach ($requiredHeaderContract in @( + 'EX_PUSH_LOCK DeviceLock;', + 'ULONG InputDeviceCount;', + 'UDECXUSBDEVICE InputDevices[VIIPER_UDE_MAX_DEVICES];', + 'KEVENT BrokerOperationsDrained;', + 'KEVENT CompletionOperationsDrained;', + 'KEVENT FileCleanupsDrained;', + 'WDFDPC CompletionDpc;', + 'WDFWORKITEM D0ExitWorkItem;', + 'volatile LONG D0ExitPending;', + 'EVT_WDF_WORKITEM ViiperEvtUsbDeviceD0ExitWorkItem;', + 'volatile LONG ReservedPorts;', + 'WDFWORKITEM PurgeWorkItem;', + 'volatile LONG PurgeOutstanding;', + 'volatile LONG PurgeWorkerActive;', + 'EVT_WDF_WORKITEM ViiperEvtEndpointPurgeWorkItem;', + 'LIST_ENTRY CompletionQueue;', + 'volatile LONG PendingCompletions;', + 'volatile LONG ShuttingDown;')) { + if (-not $header.Contains($requiredHeaderContract)) { + throw "Missing native teardown contract in ViiperUde.h: $requiredHeaderContract" + } +} +$controllerRundownBody = Get-CFunctionBody $controllerSource 'ViiperWaitForControllerRundown' +if ($controllerRundownBody -notmatch + '(?s)watchdogWait\.QuadPart\s*=\s*-\s*\(LONGLONG\)VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS\s*;.*?for\s*\(\s*;\s*;\s*\).*?KeWaitForSingleObject\s*\(\s*Event\s*,.*?&watchdogWait\s*\).*?waitStatus\s*!=\s*STATUS_TIMEOUT.*?return\s*;.*?VIIPER_TRACE_LIFECYCLE\s*\(.*?WatchdogEvent\s*,.*?STATUS_IO_TIMEOUT\s*,.*?InterlockedCompareExchange\s*\(\s*ActiveCounter\s*,\s*0\s*,\s*0\s*\)') { + throw 'Controller rundown must wait to completion while recording every bounded watchdog interval.' +} +$terminalCleanupBody = Get-CFunctionBody $controllerSource 'ViiperEvtDeviceSelfManagedIoCleanup' +$fileCleanupBody = Get-CFunctionBody $controllerSource 'ViiperEvtFileCleanup' +$deviceAddBody = Get-CFunctionBody $controllerSource 'ViiperEvtDeviceAdd' +if ($deviceAddBody -notmatch + 'KeInitializeEvent\s*\(\s*&context->FileCleanupsDrained\s*,\s*NotificationEvent\s*,\s*TRUE\s*\)\s*;' -or + $fileCleanupBody -notmatch + '(?s)WdfWaitLockAcquire\s*\(\s*context->OwnerLock\s*,\s*NULL\s*\)\s*;\s*if\s*\(\s*InterlockedCompareExchange\s*\(\s*&context->ShuttingDown\s*,\s*0\s*,\s*0\s*\)\s*==\s*0\s*&&\s*context->OwnerFile\s*==\s*FileObject\s*\)\s*\{.*?InterlockedCompareExchange\s*\(\s*&context->ActiveFileCleanups\s*,\s*0\s*,\s*0\s*\)\s*==\s*0.*?KeClearEvent\s*\(\s*&context->FileCleanupsDrained\s*\).*?InterlockedIncrement\s*\(\s*&context->ActiveFileCleanups\s*\).*?cleanupAdmitted\s*=\s*TRUE\s*;.*?\}\s*WdfWaitLockRelease\s*\(\s*context->OwnerLock\s*\)\s*;' -or + $fileCleanupBody -notmatch + '(?s)if\s*\(\s*cleanupAdmitted\s*\)\s*\{\s*WdfWaitLockAcquire\s*\(\s*context->OwnerLock\s*,\s*NULL\s*\)\s*;\s*remainingCleanups\s*=\s*InterlockedDecrement\s*\(\s*&context->ActiveFileCleanups\s*\)\s*;.*?if\s*\(\s*remainingCleanups\s*==\s*0\s*\)\s*\{\s*KeSetEvent\s*\(\s*&context->FileCleanupsDrained\s*,\s*IO_NO_INCREMENT\s*,\s*FALSE\s*\)\s*;\s*\}\s*WdfWaitLockRelease\s*\(\s*context->OwnerLock\s*\)\s*;\s*\}' -or + $terminalCleanupBody -notmatch + '(?s)InterlockedExchange\s*\(\s*&context->ShuttingDown\s*,\s*TRUE\s*\)\s*;.*?ViiperWaitForControllerRundown\s*\(\s*Device\s*,\s*&context->FileCleanupsDrained\s*,\s*VIIPER_UDE_TRACE_CONTROLLER_RUNDOWN_WATCHDOG\s*,\s*&context->ActiveFileCleanups\s*\)\s*;.*?WdfIoQueuePurgeSynchronously\s*\(\s*context->DefaultQueue\s*\)') { + throw 'Terminal rundown must account admitted file cleanup, close admission, join with watchdog telemetry, and only then purge queues.' +} +if ($controllerSource -notmatch + 'pnpCallbacks\.EvtDeviceSelfManagedIoInit\s*=\s*ViiperEvtDeviceSelfManagedIoInit\s*;' -or + $controllerSource -notmatch + 'pnpCallbacks\.EvtDeviceSelfManagedIoCleanup\s*=\s*ViiperEvtDeviceSelfManagedIoCleanup\s*;') { + throw 'The controller must register both self-managed I/O initialization and terminal rundown callbacks.' +} +if ($controllerSource -notmatch + 'WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE\(&fileAttributes,\s*VIIPER_UDE_FILE_CONTEXT\);\s*fileAttributes\.ExecutionLevel\s*=\s*WdfExecutionLevelPassive\s*;') { + throw 'File create/cleanup callbacks must explicitly run at PASSIVE_LEVEL.' +} +if ($deviceSource -notmatch + 'WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE\(&attributes,\s*VIIPER_UDE_DEVICE_CONTEXT\);[\s\S]{0,600}?attributes\.ExecutionLevel\s*=\s*WdfExecutionLevelPassive\s*;[\s\S]{0,300}?UdecxUsbDeviceCreate') { + throw 'Every UdeCx USB-device object must explicitly request passive callback execution.' +} +if ($deviceSource -notmatch + 'WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE\(&attributes,\s*VIIPER_UDE_ENDPOINT_CONTEXT\);[\s\S]{0,600}?attributes\.ExecutionLevel\s*=\s*WdfExecutionLevelPassive\s*;[\s\S]{0,300}?UdecxUsbEndpointCreate') { + throw 'Every UdeCx endpoint object must explicitly request passive callback execution.' +} +if ($deviceSource -notmatch + 'WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE\(&attributes,\s*UDECXUSBENDPOINT\);[\s\S]{0,300}?attributes\.ExecutionLevel\s*=\s*WdfExecutionLevelPassive\s*;[\s\S]{0,300}?WdfIoQueueCreate') { + throw 'Every endpoint queue must explicitly run at PASSIVE_LEVEL for buffer preparation and broker admission.' +} +$d0ExitMatch = [regex]::Match( + $deviceSource, + '(?ms)^NTSTATUS\s+ViiperEvtUsbDeviceD0Exit\s*\([^)]*\)\s*\{(?.*?)^\}') +$d0ExitWorkItemMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperEvtUsbDeviceD0ExitWorkItem\s*\([^)]*\)\s*\{(?.*?)^\}') +$d0EntryMatch = [regex]::Match( + $deviceSource, + '(?ms)^NTSTATUS\s+ViiperEvtUsbDeviceD0Entry\s*\([^)]*\)\s*\{(?.*?)^\}') +$d0ExitForbiddenDispatchWork = + 'ViiperInvalidate(?:Endpoint|Device)InputReports?|ViiperAcquireDeviceLock|WdfWaitLockAcquire|KeWaitForSingleObject|KeDelayExecutionThread' +if (-not $d0ExitMatch.Success -or + $d0ExitMatch.Groups['body'].Value -notmatch + 'WdfSpinLockAcquire\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*InterlockedExchange\s*\(\s*&deviceContext->InD0\s*,\s*FALSE\s*\)[\s\S]*controllerContext->ShuttingDown[\s\S]*deviceContext->Purging[\s\S]*status\s*=\s*STATUS_SUCCESS[\s\S]*D0ExitPending\s*,\s*TRUE\s*,\s*FALSE[\s\S]*status\s*=\s*STATUS_DEVICE_BUSY[\s\S]*WdfWorkItemEnqueue\s*\(\s*deviceContext->D0ExitWorkItem\s*\)[\s\S]*status\s*=\s*STATUS_PENDING[\s\S]*WdfSpinLockRelease\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*return\s+status' -or + $d0ExitMatch.Groups['body'].Value -match $d0ExitForbiddenDispatchWork -or + -not $d0ExitWorkItemMatch.Success -or + $d0ExitWorkItemMatch.Groups['body'].Value -notmatch + 'KeGetCurrentIrql\s*\(\s*\)\s*==\s*PASSIVE_LEVEL[\s\S]*ViiperInvalidateDeviceInputReports\s*\(\s*device\s*\)[\s\S]*ViiperUdeOperationDeviceD0Exit[\s\S]*InterlockedExchange\s*\(\s*&deviceContext->D0ExitPending\s*,\s*FALSE\s*\)[\s\S]*UdecxUsbDeviceLinkPowerExitComplete\s*\(\s*device\s*,\s*STATUS_SUCCESS\s*\)' -or + -not $d0EntryMatch.Success -or + $d0EntryMatch.Groups['body'].Value -notmatch + 'WdfSpinLockAcquire\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*deviceContext->Purging[\s\S]*deviceContext->D0ExitPending[\s\S]*STATUS_DEVICE_BUSY[\s\S]*InterlockedExchange\s*\(\s*&deviceContext->InD0\s*,\s*TRUE\s*\)[\s\S]*WdfSpinLockRelease\s*\(\s*controllerContext->BrokerLock\s*\)' -or + $d0EntryMatch.Groups['body'].Value -match $d0ExitForbiddenDispatchWork) { + throw 'D0 entry/exit must gate admission at DISPATCH_LEVEL and defer cache invalidation to one passive asynchronous completion.' +} +$d0ExitCompletion = 'UdecxUsbDeviceLinkPowerExitComplete(device, STATUS_SUCCESS);' +$d0ExitCompletionOffset = $d0ExitWorkItemMatch.Groups['body'].Value.IndexOf( + $d0ExitCompletion, + [StringComparison]::Ordinal) +if ($d0ExitCompletionOffset -lt 0 -or + $d0ExitWorkItemMatch.Groups['body'].Value.Substring( + $d0ExitCompletionOffset + $d0ExitCompletion.Length) -match + 'deviceContext|controllerContext|ViiperGet|Wdf|VIIPER_TRACE') { + throw 'The D0-exit work item must not access device state after completing the UdeCx transition.' +} +if ($deviceSource -notmatch + 'WDF_WORKITEM_CONFIG_INIT\s*\(\s*&workItemConfig\s*,\s*ViiperEvtUsbDeviceD0ExitWorkItem\s*\)\s*;\s*workItemConfig\.AutomaticSerialization\s*=\s*WdfFalse\s*;[\s\S]*?attributes\.ParentObject\s*=\s*device\s*;[\s\S]*?WdfWorkItemCreate\s*\([\s\S]*?&deviceContext->D0ExitWorkItem\s*\)[\s\S]*?ViiperClaimDeviceSlot[\s\S]*?UdecxUsbDevicePlugIn') { + throw 'Every virtual device must create its passive D0-exit work item before UdeCx exposure.' +} +$d0ExitFlushMatch = [regex]::Match( + $deviceSource, + '(?ms)^ViiperFlushD0ExitWorkItem\s*\([^)]*\)\s*\{(?.*?)^\}') +$destroyDeviceMatch = [regex]::Match( + $deviceSource, + '(?ms)^NTSTATUS\s+ViiperDestroyVirtualDevice\s*\([^)]*\)\s*\{(?.*?)^\}') +$destroyOwnedMatch = [regex]::Match( + $deviceSource, + '(?ms)^BOOLEAN\s+ViiperDestroyOwnedDevices\s*\([^)]*\)\s*\{(?.*?)^\}') +$controllerShutdownMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperBeginControllerShutdown\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $d0ExitFlushMatch.Success -or + $d0ExitFlushMatch.Groups['body'].Value -notmatch + 'KeGetCurrentIrql\s*\(\s*\)\s*==\s*PASSIVE_LEVEL[\s\S]*WdfWorkItemFlush\s*\(\s*deviceContext->D0ExitWorkItem\s*\)[\s\S]*D0ExitPending\s*,\s*0\s*,\s*0\s*\)\s*==\s*0' -or + $d0ExitFlushMatch.Groups['body'].Value -match + 'if\s*\([^)]*D0ExitPending' -or + -not $destroyDeviceMatch.Success -or + $destroyDeviceMatch.Groups['body'].Value -notmatch + 'ViiperBeginRemoveDevice[\s\S]*ViiperFlushD0ExitWorkItem\s*\(\s*device\s*\)[\s\S]*ViiperAbortDeviceManagementOperations[\s\S]*UdecxUsbDevicePlugOutAndDelete\s*\(\s*device\s*\)' -or + -not $destroyOwnedMatch.Success -or + $destroyOwnedMatch.Groups['body'].Value -notmatch + 'plugged\s*=\s*deviceContext->Plugged[\s\S]*ViiperFlushD0ExitWorkItem\s*\(\s*device\s*\)[\s\S]*ViiperAbortDeviceManagementOperations[\s\S]*if\s*\(\s*plugged\s*\)[\s\S]*UdecxUsbDevicePlugOutAndDelete\s*\(\s*device\s*\)' -or + -not $controllerShutdownMatch.Success -or + $controllerShutdownMatch.Groups['body'].Value -notmatch + 'plugged\s*=\s*deviceContext->Plugged[\s\S]*ViiperFlushD0ExitWorkItem\s*\(\s*devices\[index\]\s*\)[\s\S]*if\s*\(\s*plugged\s*\)[\s\S]*UdecxUsbDevicePlugOutAndDelete\s*\(\s*devices\[index\]\s*\)') { + throw 'Every device-consuming teardown path must unconditionally join D0-exit work after closing admission and before consuming the UdeCx handle.' +} +$claimDeviceSlotMatch = [regex]::Match( + $deviceSource, + '(?ms)^static\s+NTSTATUS\s+ViiperClaimDeviceSlot\s*\([^)]*\)\s*\{(?.*?)^\}') +$releaseDeviceSlotMatch = [regex]::Match( + $deviceSource, + '(?ms)^static\s+VOID\s+ViiperReleaseDeviceSlot\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $claimDeviceSlotMatch.Success -or + $claimDeviceSlotMatch.Groups['body'].Value -notmatch + 'ControllerContext->PortReserved\[freeSlot\]\s*=\s*TRUE\s*;[\s\S]*InterlockedIncrement\s*\(\s*&ControllerContext->ReservedPorts\s*\)[\s\S]*ControllerContext->Devices\[freeSlot\]\s*=\s*Device\s*;' -or + -not $releaseDeviceSlotMatch.Success -or + $releaseDeviceSlotMatch.Groups['body'].Value -notmatch + 'PortReservationEpochs\[Slot\]\s*==\s*PortReservation[\s\S]*ControllerContext->PortReserved\[Slot\]\s*=\s*FALSE\s*;[\s\S]*InterlockedDecrement\s*\(\s*&ControllerContext->ReservedPorts\s*\)[\s\S]*NT_ASSERT\s*\(\s*remaining\s*>=\s*0\s*\)') { + throw 'Physical-port accounting must increment after reservation publication and decrement only after an exact token-matched release.' +} +if (($controllerSource + $deviceSource + $brokerSource) -match + 'WdfWaitLock(?:Acquire|Release)\s*\([^;\r\n]*DeviceLock') { + throw 'DeviceLock must remain embedded; a sibling WDF lock is unsafe during UdeCx child cleanup.' +} +if ($allDriverCSource -match 'Ex(?:Acquire|Release)FastMutex\s*\([^;\r\n]*DeviceLock') { + throw 'DeviceLock must remain a shared/exclusive push lock; FAST_MUTEX serializes every input producer.' +} +foreach ($pushLockContract in @( + 'KeEnterCriticalRegion();', + 'ExAcquirePushLockShared(&ControllerContext->DeviceLock);', + 'ExAcquirePushLockExclusive(&ControllerContext->DeviceLock);', + 'ExReleasePushLockShared(&ControllerContext->DeviceLock);', + 'ExReleasePushLockExclusive(&ControllerContext->DeviceLock);', + 'KeLeaveCriticalRegion();')) { + if (-not $header.Contains($pushLockContract)) { + throw "DeviceLock lost required push-lock/APC contract: $pushLockContract" + } +} +if ([regex]::Matches($header, '_IRQL_requires_max_\(APC_LEVEL\)').Count -lt 4) { + throw 'Every shared/exclusive DeviceLock acquire/release helper must declare IRQL <= APC_LEVEL.' +} +if ($brokerSource -notmatch + 'WDF_DPC_CONFIG_INIT\s*\(\s*&dpcConfig\s*,\s*ViiperEvtCompletionDpc\s*\)[\s\S]{0,200}?dpcConfig\.AutomaticSerialization\s*=\s*WdfFalse\s*;[\s\S]{0,300}?WdfDpcCreate') { + throw 'UdeCx completion must use one preallocated, nonserialized controller DPC.' +} +if ($controllerSource -notmatch + 'InitializeListHead\s*\(\s*&context->CompletionQueue\s*\)[\s\S]{0,300}?KeInitializeEvent\s*\(\s*&context->CompletionOperationsDrained') { + throw 'The controller must initialize the intrusive completion queue and its drain event before broker creation.' +} +if ($allDriverCSource -match 'Ke(?:Raise|Lower)Irql') { + throw 'The UdeCx completion boundary must be a real WDF DPC, never a synthetic IRQL transition.' +} +$completionDpcMatch = [regex]::Match( + $brokerSource, + '(?ms)^VOID\s+ViiperEvtCompletionDpc\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $completionDpcMatch.Success -or + $completionDpcMatch.Groups['body'].Value -notmatch + 'KeGetCurrentIrql\s*\(\s*\)\s*==\s*DISPATCH_LEVEL' -or + $completionDpcMatch.Groups['body'].Value -match 'PAGED_CODE' -or + $completionDpcMatch.Groups['body'].Value -notmatch + 'RemoveHeadList[\s\S]*UdecxUrbComplete[\s\S]*ViiperClearSlotLocked[\s\S]*ViiperEndpointOperationCompleted[\s\S]*InterlockedDecrement\s*\(\s*&controllerContext->PendingCompletions[\s\S]*KeSetEvent\s*\(\s*&controllerContext->CompletionOperationsDrained') { + throw 'The completion DPC must run at exact DISPATCH_LEVEL, complete once, release ownership afterward, and signal final drain.' +} +$completionQueueMatch = [regex]::Match( + $brokerSource, + '(?ms)^BOOLEAN\s+ViiperQueueUrbCompletion\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $completionQueueMatch.Success -or + $completionQueueMatch.Groups['body'].Value -notmatch 'requestContext->CompletionQueued' -or + $completionQueueMatch.Groups['body'].Value -notmatch + 'WdfObjectReference\s*\(\s*Request\s*\)' -or + $completionQueueMatch.Groups['body'].Value -match + 'WdfObjectReference\s*\(\s*Endpoint\s*\)' -or + $completionQueueMatch.Groups['body'].Value -notmatch + 'KeClearEvent\s*\(\s*&controllerContext->CompletionOperationsDrained\s*\)[\s\S]*InterlockedIncrement\s*\(\s*&controllerContext->PendingCompletions\s*\)[\s\S]*InsertTailList\s*\(\s*&controllerContext->CompletionQueue[\s\S]*WdfDpcEnqueue') { + throw 'Completion admission must retain only the request, rely on pre-cleanup endpoint rundown, account drain, and enqueue the DPC.' +} +if ($completionDpcMatch.Groups['body'].Value -match + 'WdfObjectDereference\s*\(\s*endpoint\s*\)') { + throw 'Completion DPC must not treat an endpoint WDF reference as permission to access an object after EvtCleanup.' +} +$unownedCompletionMatch = [regex]::Match( + $brokerSource, + '(?ms)^VOID\s+ViiperCompleteUnownedUrb\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $unownedCompletionMatch.Success -or + $unownedCompletionMatch.Groups['body'].Value -notmatch 'ViiperQueueUrbCompletion' -or + $unownedCompletionMatch.Groups['body'].Value -match 'UdecxUrbComplete') { + throw 'Rejected endpoint URBs must transfer terminal ownership to the shared completion DPC.' +} +$retrievedInputCompletionMatch = [regex]::Match( + $deviceSource, + '(?ms)^static\s+VOID\s+ViiperCompleteRetrievedInputUrb\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $retrievedInputCompletionMatch.Success -or + $retrievedInputCompletionMatch.Groups['body'].Value -notmatch 'ViiperQueueUrbCompletion' -or + $retrievedInputCompletionMatch.Groups['body'].Value -match 'UdecxUrbComplete') { + throw 'Retrieved fast-input URBs must transfer terminal ownership to the shared completion DPC.' +} +$allUdeCxCompletionCalls = [regex]::Matches( + $allDriverCSource, + 'UdecxUrbComplete(?:WithNtStatus)?\s*\(').Count +$dpcUdeCxCompletionCalls = [regex]::Matches( + $completionDpcMatch.Groups['body'].Value, + 'UdecxUrbComplete(?:WithNtStatus)?\s*\(').Count +if ($dpcUdeCxCompletionCalls -ne 2 -or + $allUdeCxCompletionCalls -ne $dpcUdeCxCompletionCalls) { + throw 'Every UdeCx URB terminal call must remain exclusively inside the DISPATCH_LEVEL completion DPC.' +} +$cancelMatch = [regex]::Match( + $brokerSource, + '(?ms)^VOID\s+ViiperEvtUrbCancel\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $cancelMatch.Success -or + $cancelMatch.Groups['body'].Value -notmatch + 'pending->State\s*=\s*ViiperUdePendingDpcCompletion[\s\S]*ViiperQueueUrbCompletion') { + throw 'WDF cancellation must claim the slot exactly once before queueing its DISPATCH_LEVEL completion.' +} +$canceledOnQueueMatch = [regex]::Match( + $brokerSource, + '(?ms)^VOID\s+ViiperEvtUrbCanceledOnQueue\s*\([^)]*\)\s*\{(?.*?)^\}') +if ($deviceSource -notmatch + 'queueConfig\.EvtIoCanceledOnQueue\s*=\s*ViiperEvtUrbCanceledOnQueue\s*;' -or + -not $canceledOnQueueMatch.Success -or + $canceledOnQueueMatch.Groups['body'].Value -notmatch + 'ViiperEndpointOperationStarted\s*\(\s*endpoint\s*\)[\s\S]*ViiperQueueUrbCompletion') { + throw 'Every endpoint queue must override synchronous queued cancellation and transfer it through endpoint rundown to the DPC.' +} +$endpointOperationStartMatch = [regex]::Match( + $brokerSource, + '(?ms)^VOID\s+ViiperEndpointOperationStarted\s*\([^)]*\)\s*\{(?.*?)^\}') +$endpointOperationCompleteMatch = [regex]::Match( + $brokerSource, + '(?ms)^VOID\s+ViiperEndpointOperationCompletedLocked\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $endpointOperationStartMatch.Success -or + $endpointOperationStartMatch.Groups['body'].Value -notmatch + 'if\s*\(\s*active\s*==\s*0\s*\)[\s\S]*KeClearEvent\s*\(\s*&endpointContext->OperationsDrained\s*\)[\s\S]*InterlockedIncrement\s*\(\s*&endpointContext->ActiveOperations\s*\)' -or + -not $endpointOperationCompleteMatch.Success -or + $endpointOperationCompleteMatch.Groups['body'].Value -notmatch + 'InterlockedDecrement\s*\(\s*&endpointContext->ActiveOperations\s*\)[\s\S]*if\s*\(\s*remaining\s*==\s*0\s*\)[\s\S]*KeSetEvent\s*\(\s*&endpointContext->OperationsDrained') { + throw 'Endpoint ActiveOperations count/event transitions must remain linearized through the BrokerLock-owned helpers.' +} +$queueUrbMatch = [regex]::Match( + $brokerSource, + '(?ms)^NTSTATUS\s+ViiperQueueUrb\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $queueUrbMatch.Success -or + $queueUrbMatch.Groups['body'].Value -notmatch + 'WdfSpinLockAcquire\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*ViiperEndpointOperationStarted\s*\(\s*endpoint\s*\)[\s\S]*controllerContext->ShuttingDown[\s\S]*controllerContext->BrokerFaulted[\s\S]*deviceContext->InD0[\s\S]*deviceContext->Resetting[\s\S]*deviceContext->Purging[\s\S]*endpointContext->Resetting[\s\S]*endpointContext->Purging[\s\S]*WdfSpinLockRelease\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*ViiperAllocatePendingSlot' -or + $queueUrbMatch.Groups['body'].Value -notmatch + 'queueCancelledCompletion[\s\S]*ViiperUdePendingDpcCompletion[\s\S]*ViiperQueueUrbCompletion') { + throw 'URB admission must combine rundown and lifecycle closure under BrokerLock, then route every rejection/cancel through the DPC.' +} +$endpointQuiescenceMatch = [regex]::Match( + $deviceSource, + '(?ms)^ViiperWaitForEndpointQuiescence\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $endpointQuiescenceMatch.Success -or + $endpointQuiescenceMatch.Groups['body'].Value -notmatch + 'KeWaitForSingleObject\s*\(\s*&endpointContext->OperationsDrained[\s\S]*WdfSpinLockAcquire\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*WdfIoQueueGetState\s*\(\s*endpointContext->Queue[\s\S]*WdfIoQueueDriverNoRequests[\s\S]*endpointContext->ActiveOperations[\s\S]*WdfSpinLockRelease\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*KeDelayExecutionThread' -or + $endpointQuiescenceMatch.Groups['body'].Value -match + 'WDF_IO_QUEUE_IDLE|WdfIoQueueAcceptRequests|WdfIoQueueDispatchRequests') { + throw 'Reset and terminal pre-consumption quiescence must join only driver-owned requests and BrokerLock-owned rundown.' +} +$endpointAddMatch = [regex]::Match( + $deviceSource, + '(?ms)^NTSTATUS\s+ViiperEvtEndpointAdd\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $endpointAddMatch.Success -or + $endpointAddMatch.Groups['body'].Value -notmatch + 'KeInitializeEvent\s*\(\s*&endpointContext->OperationsDrained[\s\S]*WDF_WORKITEM_CONFIG_INIT\s*\(\s*&workItemConfig\s*,\s*ViiperEvtEndpointPurgeWorkItem\s*\)[\s\S]*attributes\.ParentObject\s*=\s*endpoint\s*;[\s\S]*WdfWorkItemCreate\s*\([\s\S]*&endpointContext->PurgeWorkItem\s*\)') { + throw 'Every endpoint must own a passive PURGE work item before its queue can be published.' +} +$purgeQuiescenceMatch = [regex]::Match( + $deviceSource, + '(?ms)^ViiperWaitForEndpointPurgeQuiescence\s*\([^)]*\)\s*\{(?.*?)^\}') +$purgeSampleMatch = if ($purgeQuiescenceMatch.Success) { + [regex]::Match( + $purgeQuiescenceMatch.Groups['body'].Value, + '(?ms)WdfSpinLockAcquire\s*\(\s*controllerContext->BrokerLock\s*\)\s*;(?.*?)WdfSpinLockRelease\s*\(\s*controllerContext->BrokerLock\s*\)\s*;') +} else { + [Text.RegularExpressions.Match]::Empty +} +if (-not $purgeQuiescenceMatch.Success -or + $purgeQuiescenceMatch.Groups['body'].Value -notmatch + 'KeWaitForSingleObject\s*\(\s*&endpointContext->OperationsDrained' -or + $purgeQuiescenceMatch.Groups['body'].Value -notmatch 'KeDelayExecutionThread' -or + -not $purgeSampleMatch.Success -or + $purgeSampleMatch.Groups['sample'].Value -notmatch + 'WdfIoQueueGetState\s*\(\s*endpointContext->Queue' -or + $purgeSampleMatch.Groups['sample'].Value -notmatch 'endpointContext->PurgeOutstanding' -or + $purgeSampleMatch.Groups['sample'].Value -notmatch 'endpointContext->Purging' -or + $purgeSampleMatch.Groups['sample'].Value -notmatch 'WdfIoQueueDriverNoRequests' -or + $purgeSampleMatch.Groups['sample'].Value -notmatch 'driverRequests\s*==\s*0' -or + $purgeSampleMatch.Groups['sample'].Value -notmatch 'endpointContext->ActiveOperations' -or + $purgeQuiescenceMatch.Groups['body'].Value -match + 'WDF_IO_QUEUE_(?:READY|IDLE|PURGED)|WdfIoQueueNoRequests|queuedRequests\s*==\s*0') { + throw 'Endpoint PURGE must prove driver-owned quiescence without waiting on class-owned queue readiness or queued requests.' +} +$purgeWorkItemMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperEvtEndpointPurgeWorkItem\s*\([^)]*\)\s*\{(?.*?)^\}') +$endpointPurgeMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperEvtEndpointPurge\s*\([^)]*\)\s*\{(?.*?)^\}') +$endpointStartMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperEvtEndpointStart\s*\([^)]*\)\s*\{(?.*?)^\}') +$endpointActivateMatch = [regex]::Match( + $deviceSource, + '(?ms)^static\s+VOID\s+ViiperActivateEndpoint\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $purgeWorkItemMatch.Success -or + $purgeWorkItemMatch.Groups['body'].Value -notmatch + 'WdfWorkItemGetParentObject\s*\(\s*WorkItem\s*\)[\s\S]*for\s*\(\s*;\s*;\s*\)[\s\S]*PurgeOutstanding[\s\S]*PurgeWorkerActive[\s\S]*ViiperWaitForEndpointPurgeQuiescence\s*\(\s*endpoint\s*,[\s\S]*&queueState\s*,[\s\S]*&queuedRequests\s*,[\s\S]*&driverRequests\s*\)[\s\S]*ViiperInvalidateEndpointInputReport\s*\(\s*endpoint\s*\)[\s\S]*InterlockedDecrement\s*\(\s*&endpointContext->PurgeOutstanding\s*\)[\s\S]*UdecxUsbEndpointPurgeComplete\s*\(\s*endpoint\s*\)[\s\S]*PurgeOutstanding[\s\S]*InterlockedExchange\s*\(\s*&endpointContext->PurgeWorkerActive\s*,\s*FALSE\s*\)' -or + $purgeWorkItemMatch.Groups['body'].Value -notmatch + 'InterlockedDecrement\s*\(\s*&endpointContext->PurgeOutstanding\s*\)[\s\S]*UdecxUsbEndpointPurgeComplete\s*\(\s*endpoint\s*\)[\s\S]*InterlockedExchange\s*\(\s*&endpointContext->PurgeWorkerActive\s*,\s*FALSE\s*\)' -or + -not $endpointPurgeMatch.Success -or + $endpointPurgeMatch.Groups['body'].Value -notmatch + 'InterlockedExchange\s*\(\s*&endpointContext->Purging\s*,\s*TRUE\s*\)[\s\S]*InterlockedExchange\s*\(\s*&endpointContext->StartAnnounced\s*,\s*FALSE\s*\)[\s\S]*InterlockedIncrement\s*\(\s*&endpointContext->PurgeOutstanding\s*\)[\s\S]*InterlockedCompareExchange\s*\(\s*&endpointContext->PurgeWorkerActive\s*,\s*TRUE\s*,\s*FALSE\s*\)[\s\S]*ViiperPurgeEndpointOperations[\s\S]*if\s*\(\s*enqueueWorkItem\s*\)[\s\S]*WdfWorkItemEnqueue\s*\(\s*endpointContext->PurgeWorkItem\s*\)' -or + $endpointPurgeMatch.Groups['body'].Value -match + 'ViiperInvalidateEndpointInputReport' -or + $deviceSource -notmatch + 'WDF_WORKITEM_CONFIG_INIT\s*\(\s*&workItemConfig\s*,\s*ViiperEvtEndpointPurgeWorkItem\s*\)\s*;\s*workItemConfig\.AutomaticSerialization\s*=\s*WdfFalse\s*;' -or + $deviceSource -notmatch + 'WDF_WORKITEM_CONFIG_INIT\s*\(\s*&workItemConfig\s*,\s*ViiperEvtEndpointResetWorkItem\s*\)\s*;\s*workItemConfig\.AutomaticSerialization\s*=\s*WdfFalse\s*;' -or + -not $endpointStartMatch.Success -or + $endpointStartMatch.Groups['body'].Value -notmatch + 'ViiperActivateEndpoint\s*\(\s*Endpoint\s*\)' -or + -not $endpointActivateMatch.Success -or + $endpointActivateMatch.Groups['body'].Value -notmatch + 'deviceContext->InD0[\s\S]*deviceContext->D0ExitPending[\s\S]*endpointContext->PurgeOutstanding[\s\S]*InterlockedExchange\s*\(\s*&endpointContext->Purging\s*,\s*FALSE\s*\)[\s\S]*StartAnnounced[\s\S]*ViiperQueueEndpointLifecycleEvent' -or + $endpointActivateMatch.Groups['body'].Value -match 'PurgeWorkerActive') { + throw 'Endpoint PURGE/START must count every callback, preserve worker ownership across synchronous completion, and reopen only after the final decrement.' +} +$endpointResetMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperEvtEndpointReset\s*\([^)]*\)\s*\{(?.*?)^\}') +$resetWorkItemMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperEvtEndpointResetWorkItem\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $endpointResetMatch.Success -or + $endpointResetMatch.Groups['body'].Value -match + 'ViiperInvalidate(?:Endpoint|Device)InputReports?|ViiperAcquireDeviceLock|WdfWaitLockAcquire|KeWaitForSingleObject|KeDelayExecutionThread' -or + -not $resetWorkItemMatch.Success -or + $resetWorkItemMatch.Groups['body'].Value -notmatch + 'ViiperQuiesceResetByIdentity[\s\S]*if\s*\(\s*!resetCurrent\s*\)[\s\S]*WdfSpinLockAcquire\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*InterlockedExchange\s*\(\s*&endpointContext->Resetting\s*,\s*FALSE\s*\)[\s\S]*WdfSpinLockRelease\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*WdfRequestComplete\s*\(\s*request\s*,\s*STATUS_DEVICE_NOT_READY\s*\)[\s\S]*ViiperInvalidateEndpointInputReport\s*\(\s*endpoint\s*\)[\s\S]*ViiperQueueAcknowledgedEndpointLifecycleEvent') { + throw 'Endpoint RESET must defer passive input invalidation, prove a live exact identity after rundown, and fail closed on removal.' +} +foreach ($forbiddenAssociatedQueueMutation in @( + 'WdfIoQueuePurge', + 'WdfIoQueuePurgeSynchronously', + 'WdfIoQueueStart', + 'WdfIoQueueStop', + 'WdfIoQueueStopSynchronously', + 'WdfIoQueueDrain', + 'WdfIoQueueDrainSynchronously')) { + if ($deviceSource -match ([regex]::Escape($forbiddenAssociatedQueueMutation) + '\s*\(')) { + throw "Associated endpoint queues must not use $forbiddenAssociatedQueueMutation." + } +} +$resetIdentityMatch = [regex]::Match( + $deviceSource, + '(?ms)^BOOLEAN\s+ViiperQuiesceResetByIdentity\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $resetIdentityMatch.Success -or + $resetIdentityMatch.Groups['body'].Value -notmatch + 'ViiperAcquireDeviceLockShared[\s\S]*device\s*!=\s*ExpectedDevice[\s\S]*DeviceId[\s\S]*Generation[\s\S]*ResetEpoch[\s\S]*ExpectedResetEpoch[\s\S]*Endpoints\[EndpointAddress\][\s\S]*endpoint\s*==\s*ExpectedEndpoint[\s\S]*ViiperWaitForEndpointQuiescence\s*\(\s*endpoint\s*\)[\s\S]*if\s*\(\s*ReleaseGate\s*\)[\s\S]*endpointContext->Resetting[\s\S]*ViiperReleaseDeviceLockShared') { + throw 'Reset acknowledgement must prove and release only an exact pinned device/endpoint generation and reset epoch.' +} +$endpointResetAdmissionMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperEvtEndpointReset\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $endpointResetAdmissionMatch.Success -or + $endpointResetAdmissionMatch.Groups['body'].Value -notmatch + 'InterlockedCompareExchange\s*\(\s*&endpointContext->Resetting\s*,\s*TRUE\s*,\s*FALSE\s*\)[\s\S]*else[\s\S]*ResetDeviceEpoch[\s\S]*deviceContext->ResetEpoch') { + throw 'Endpoint reset must capture the device reset epoch atomically after admission.' +} +$endpointsConfigureMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperEvtEndpointsConfigure\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $endpointsConfigureMatch.Success -or + $endpointsConfigureMatch.Groups['body'].Value -notmatch + 'case\s+UdecxEndpointsConfigureTypeDeviceConfigurationChange\s*:[\s\S]*EndpointsToConfigureCount[\s\S]*ViiperActivateEndpoint\s*\(\s*ConfigureParams->EndpointsToConfigure\[endpointIndex\]\s*\)\s*;[\s\S]*WdfRequestComplete\s*\(\s*Request\s*,\s*STATUS_SUCCESS\s*\)[\s\S]*return\s*;[\s\S]*case\s+UdecxEndpointsConfigureTypeInterfaceSettingChange\s*:' -or + $endpointsConfigureMatch.Groups['body'].Value -match + 'ViiperBeginAcknowledgedDeviceReset|ViiperUdeOperationDeviceReset') { + throw 'Device configuration selection must announce selected dynamic endpoints before completing directly.' +} +if ($deviceSource -match 'callbacks\.EvtUsbDeviceReset\s*=' -or + $deviceSource -match '(?m)^ViiperEvtUsbDeviceReset\s*\(' -or + $header -match 'EVT_UDECX_USB_DEVICE_POST_ENUMERATION_RESET') { + throw 'Post-enumeration reset must remain UdeCx-owned and must not wait on the user-mode lifecycle stream.' +} +$managementSlotPinMatch = [regex]::Match( + $brokerSource, + '(?ms)^ViiperQueueAcknowledgedLifecycleEvent\s*\([^)]*\)\s*\{(?.*?)^\}') +$managementSlotClearMatch = [regex]::Match( + $brokerSource, + '(?ms)^ViiperClearManagementSlotLocked\s*\([^)]*\)\s*\{(?.*?)^\}') +$managementSlotReleaseMatch = [regex]::Match( + $brokerSource, + '(?ms)^ViiperReleaseManagementSlotReferences\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $managementSlotPinMatch.Success -or + $managementSlotPinMatch.Groups['body'].Value -notmatch + 'WdfObjectReference\s*\(\s*Device\s*\)[\s\S]*WdfObjectReference\s*\(\s*Endpoint\s*\)[\s\S]*ViiperUdeOperationEndpointReset[\s\S]*deviceContext->ResetEpoch[\s\S]*endpointContext->ResetDeviceEpoch[\s\S]*pending->Device\s*=\s*Device[\s\S]*pending->Endpoint\s*=\s*Endpoint[\s\S]*pending->ResetEpoch[\s\S]*WdfSpinLockRelease[\s\S]*ViiperReleaseManagementSlotReferences' -or + -not $managementSlotClearMatch.Success -or + $managementSlotClearMatch.Groups['body'].Value -notmatch + '\*DeviceReference\s*=\s*pending->Device[\s\S]*\*EndpointReference\s*=\s*pending->Endpoint[\s\S]*pending->Device\s*=\s*WDF_NO_HANDLE[\s\S]*pending->Endpoint\s*=\s*WDF_NO_HANDLE' -or + -not $managementSlotReleaseMatch.Success -or + $managementSlotReleaseMatch.Groups['body'].Value -notmatch + 'WdfObjectDereference\s*\(\s*Endpoint\s*\)[\s\S]*WdfObjectDereference\s*\(\s*Device\s*\)') { + throw 'Management reset identities must pin exact WDF objects and release every pin outside BrokerLock.' +} +$managementCompletionMatch = [regex]::Match( + $brokerSource, + '(?ms)^ViiperCompleteManagementOperation\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $managementCompletionMatch.Success -or + $managementCompletionMatch.Groups['body'].Value -notmatch + 'ViiperQuiesceResetByIdentity[\s\S]*if\s*\(\s*!resetReleased\s*\)[\s\S]*WdfRequestComplete\s*\(\s*request\s*,\s*STATUS_DEVICE_NOT_READY\s*\)[\s\S]*ViiperClearManagementSlotLocked[\s\S]*return\s+STATUS_DEVICE_NOT_READY[\s\S]*WdfRequestComplete\s*\(\s*request\s*,\s*\(NTSTATUS\)Completion->Status\s*\)') { + throw 'Reset acknowledgement must fail closed on removal or identity reuse before applying owner status.' +} +$completionDrainMatch = [regex]::Match( + $brokerSource, + '(?ms)^VOID\s+ViiperDrainUrbCompletions\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $completionDrainMatch.Success -or + $completionDrainMatch.Groups['body'].Value -notmatch + 'CompletionOperationsDrained[\s\S]*WdfDpcCancel\s*\(\s*controllerContext->CompletionDpc\s*,\s*TRUE\s*\)[\s\S]*IsListEmpty\s*\(\s*&controllerContext->CompletionQueue\s*\)[\s\S]*WdfDpcEnqueue') { + throw 'Terminal DPC rundown must wait, join, verify the list, and re-arm work canceled before dispatch.' +} +$controllerCleanupMatch = [regex]::Match( + $controllerSource, + '(?ms)^VOID\s+ViiperEvtControllerCleanup\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $controllerCleanupMatch.Success) { + throw 'Could not locate ViiperEvtControllerCleanup for teardown validation.' +} +if ($controllerCleanupMatch.Groups['body'].Value -notmatch + 'context->ActiveDevices[\s\S]*context->ReservedPorts[\s\S]*context->InputDeviceCount\s*==\s*0[\s\S]*for\s*\(\s*index\s*=\s*0\s*;[\s\S]*VIIPER_UDE_MAX_DEVICES[\s\S]*!context->PortReserved\[index\]') { + throw 'Controller cleanup must prove both logical-device and physical-port accounting reached zero.' +} +$forbiddenCleanupCalls = @( + 'WdfTimerStop', + 'WdfIoQueuePurgeSynchronously', + 'WdfSpinLockAcquire', + 'WdfWaitLockAcquire', + 'WdfWorkItemFlush', + 'ViiperPurgeOwnerOperations', + 'ViiperBeginControllerShutdown') +foreach ($forbiddenCall in $forbiddenCleanupCalls) { + if ($controllerCleanupMatch.Groups['body'].Value.Contains($forbiddenCall)) { + throw "Controller EvtCleanup must not call child-backed teardown routine '$forbiddenCall'." + } +} +if ($controllerCleanupMatch.Groups['body'].Value -match '\b(?:Wdf|Udecx)[A-Za-z0-9_]*\s*\(') { + throw 'Controller EvtCleanup must not call any WDF/UdeCx child-backed API.' +} +$selfManagedCleanupMatch = $terminalCleanupBody +if ($selfManagedCleanupMatch -notmatch + 'ViiperPurgeOwnerOperations[\s\S]*ViiperDrainControllerEndpointOperations[\s\S]*BrokerOperationsDrained[\s\S]*ViiperDrainUrbCompletions[\s\S]*PendingOperations[\s\S]*PendingCompletions[\s\S]*CompletionQueue[\s\S]*CompletionDpcActive[\s\S]*ViiperBeginControllerShutdown') { + throw 'Terminal rundown must join VIIPER-owned endpoint work and the completion DPC before asynchronously consuming children.' +} +$controllerShutdownMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperBeginControllerShutdown\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $controllerShutdownMatch.Success -or + $controllerShutdownMatch.Groups['body'].Value -match 'KeWaitForSingleObject|WdfIoQueueGetState') { + throw 'UdeCx child teardown must remain asynchronous and must not use endpoint queues after the pre-consumption proof.' +} + +$stampState = if ($RequireStampedInf) { 'stamped output' } else { 'source template' } +Write-Host "VIIPER UDE target, DISPATCH completion, and teardown contracts are aligned: Windows 10 1809, KMDF 1.27, deterministic DriverVer ($stampState)." diff --git a/native/udecx/tools/Test-ViiperUdeVersionMonotonicity.ps1 b/native/udecx/tools/Test-ViiperUdeVersionMonotonicity.ps1 new file mode 100644 index 00000000..fc8e6fe4 --- /dev/null +++ b/native/udecx/tools/Test-ViiperUdeVersionMonotonicity.ps1 @@ -0,0 +1,182 @@ +[CmdletBinding()] +param( + [string]$BaseRevision, + + [string]$HeadRevision = 'HEAD' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$projectPath = 'native/udecx/driver/ViiperUde.vcxproj' +$infPath = 'native/udecx/package/ViiperUde.inf' +$protocolPath = 'internal/transport/udecx/protocol.go' +$payloadPaths = @( + 'native/udecx/driver', + 'native/udecx/include', + 'native/udecx/package' +) + +function Invoke-Git { + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments, + + [switch]$AllowFailure + ) + + $output = @(& git @Arguments 2>&1) + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0 -and -not $AllowFailure) { + throw "git $($Arguments -join ' ') failed with exit code $exitCode`n$($output -join [Environment]::NewLine)" + } + return [pscustomobject]@{ + ExitCode = $exitCode + Text = ($output -join "`n").Trim() + } +} + +function Resolve-Commit { + param([Parameter(Mandatory = $true)][string]$Revision) + + return (Invoke-Git -Arguments @('rev-parse', '--verify', "$Revision^{commit}")).Text +} + +function Test-GitPath { + param( + [Parameter(Mandatory = $true)][string]$Revision, + [Parameter(Mandatory = $true)][string]$Path + ) + + $result = Invoke-Git -Arguments @('cat-file', '-e', "$Revision`:$Path") -AllowFailure + return $result.ExitCode -eq 0 +} + +function ConvertTo-DriverVersion { + param( + [Parameter(Mandatory = $true)][string]$Value, + [Parameter(Mandatory = $true)][string]$Revision + ) + + if ($Value -notmatch '^\d+\.\d+\.\d+\.\d+$') { + throw "Driver version at $Revision is not a four-part numeric value: '$Value'." + } + $parts = @($Value.Split('.') | ForEach-Object { [int64]$_ }) + if (@($parts | Where-Object { $_ -gt 65535 }).Count -ne 0) { + throw "Driver version at $Revision exceeds the Windows 16-bit component limit: '$Value'." + } + return [Version]$Value +} + +function Get-DriverContract { + param( + [Parameter(Mandatory = $true)][string]$Revision, + [switch]$Required + ) + + if (-not (Test-GitPath -Revision $Revision -Path $projectPath)) { + if ($Required) { + throw "The native driver project is missing at $Revision." + } + return $null + } + + [xml]$project = (Invoke-Git -Arguments @('show', "$Revision`:$projectPath")).Text + $namespace = New-Object System.Xml.XmlNamespaceManager($project.NameTable) + $namespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') + + $dateNodes = @($project.SelectNodes('//msb:ViiperUdeDriverDate', $namespace)) + $versionNodes = @($project.SelectNodes('//msb:ViiperUdeDriverVersion', $namespace)) + if ($dateNodes.Count -ne 1 -or $versionNodes.Count -ne 1) { + if (-not $Required) { + return $null + } + throw "The native project at $Revision must contain one driver date and version." + } + + $dateText = $dateNodes[0].InnerText.Trim() + $date = [DateTime]::MinValue + if (-not [DateTime]::TryParseExact( + $dateText, + 'MM/dd/yyyy', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::None, + [ref]$date)) { + throw "Driver date at $Revision is not deterministic MM/dd/yyyy: '$dateText'." + } + $versionText = $versionNodes[0].InnerText.Trim() + $version = ConvertTo-DriverVersion -Value $versionText -Revision $Revision + + if (-not (Test-GitPath -Revision $Revision -Path $infPath)) { + throw "The native INF template is missing at $Revision." + } + $inf = (Invoke-Git -Arguments @('show', "$Revision`:$infPath")).Text + $driverVerPattern = '(?mi)^DriverVer\s*=\s*' + + [regex]::Escape($dateText) + '\s*,\s*' + + [regex]::Escape($versionText) + '\s*$' + if ($inf -notmatch $driverVerPattern) { + throw "The INF DriverVer at $Revision does not match the project contract '$dateText,$versionText'." + } + + if (-not (Test-GitPath -Revision $Revision -Path $protocolPath)) { + throw "The native broker package-version contract is missing at $Revision." + } + $protocol = (Invoke-Git -Arguments @('show', "$Revision`:$protocolPath")).Text + $matches = @([regex]::Matches( + $protocol, + '(?m)^\s*DriverPackageVersion\s*=\s*"(?\d+\.\d+\.\d+\.\d+)"\s*$')) + if ($matches.Count -ne 1 -or $matches[0].Groups['version'].Value -cne $versionText) { + throw "The Go broker package version at $Revision does not exactly match DriverVer '$versionText'." + } + + $tree = (Invoke-Git -Arguments (@('ls-tree', '-r', '--full-tree', $Revision, '--') + $payloadPaths)).Text + return [pscustomobject]@{ + Revision = $Revision + Date = $date.Date + DateText = $dateText + Version = $version + VersionText = $versionText + PayloadTree = $tree + } +} + +$head = Resolve-Commit -Revision $HeadRevision +$headContract = Get-DriverContract -Revision $head -Required + +$base = $null +if (-not [string]::IsNullOrWhiteSpace($BaseRevision) -and + $BaseRevision -notmatch '^0{40}$') { + $base = Resolve-Commit -Revision $BaseRevision +} +else { + $parent = Invoke-Git -Arguments @('rev-parse', '--verify', "$head^") -AllowFailure + if ($parent.ExitCode -eq 0) { + $base = $parent.Text + } +} + +if ($null -eq $base) { + Write-Host "Validated initial native DriverVer contract $($headContract.DateText),$($headContract.VersionText) at $head." + return +} + +$baseContract = Get-DriverContract -Revision $base +if ($null -eq $baseContract) { + Write-Host "Validated initial native DriverVer contract $($headContract.DateText),$($headContract.VersionText); baseline $base predates the contract." + return +} + +if ($headContract.Date -lt $baseContract.Date) { + throw "Native DriverVer date regressed from $($baseContract.DateText) at $base to $($headContract.DateText) at $head." +} +if ($headContract.Version -lt $baseContract.Version) { + throw "Native DriverVer version regressed from $($baseContract.VersionText) at $base to $($headContract.VersionText) at $head." +} + +$payloadChanged = $headContract.PayloadTree -cne $baseContract.PayloadTree +if ($payloadChanged -and $headContract.Version -le $baseContract.Version) { + throw "Native driver package content changed between $base and $head without a strict DriverVer version increase above $($baseContract.VersionText)." +} + +$changeState = if ($payloadChanged) { 'changed with a strict version increase' } else { 'is byte-identical' } +Write-Host "Native driver package content $changeState relative to $base; DriverVer is $($headContract.DateText),$($headContract.VersionText)." diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp new file mode 100644 index 00000000..e252f682 --- /dev/null +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -0,0 +1,18958 @@ +/* + * Copyright (c) 2026 VIIPER Project contributors + * + * Driver-store mutation follows the documented SetupAPI/NewDev contracts. + * Installs are source-manifest bound, signature checked, version ordered, and + * rolled back to the exact previously published INF if post-install health + * verification fails. Removal touches only the exact signed VIIPER package + * contract and exact ROOT\VIIPER\UDE devnodes. + */ + +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#ifndef _WIN32_WINNT +#define _WIN32_WINNT 0x0A00 +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../include/ViiperUdeProtocol.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// MinGW's setupapi/newdev headers lag these Vista/Windows 10 declarations. +// Keep the signatures identical to the Windows SDK so the independent +// Windows-target syntax gate can compile the same source as MSVC CI. +#if defined(__MINGW32__) +extern "C" { +WINSETUPAPI BOOL WINAPI SetupGetInfPublishedNameW(PCWSTR, PWSTR, DWORD, PDWORD); +WINSETUPAPI BOOL WINAPI SetupGetInfDriverStoreLocationW( + PCWSTR, PSP_ALTPLATFORM_INFO, PCWSTR, PWSTR, DWORD, PDWORD); +BOOL WINAPI DiUninstallDriverW(HWND, LPCWSTR, DWORD, PBOOL); +} +#endif + +#pragma comment(lib, "Cfgmgr32.lib") +#pragma comment(lib, "Newdev.lib") +#pragma comment(lib, "Setupapi.lib") +#pragma comment(lib, "Advapi32.lib") +#pragma comment(lib, "Crypt32.lib") +#pragma comment(lib, "Wintrust.lib") +#pragma comment(lib, "Shell32.lib") +#pragma comment(lib, "Ole32.lib") + +namespace { + +constexpr wchar_t kHardwareId[] = L"ROOT\\VIIPER\\UDE"; +constexpr wchar_t kEnumerator[] = L"ROOT"; +// DICD_GENERATE_ID derives ROOT\\\\#### from this value. Keep +// new devnodes in a VIIPER-owned instance namespace instead of the USB class +// namespace used by older builds. +constexpr wchar_t kRootDeviceName[] = L"VIIPERUDE"; +constexpr wchar_t kLegacyRootDeviceName[] = L"USB"; +constexpr wchar_t kServiceName[] = L"ViiperUde"; +constexpr wchar_t kProviderName[] = L"VIIPER Project"; +constexpr wchar_t kCatalogName[] = L"ViiperUde.cat"; +constexpr wchar_t kDriverFileName[] = L"ViiperUde.sys"; + +struct AbiCompatibilityProfile { + VIIPER_UDE_UINT16 minor; + VIIPER_UDE_UINT32 capabilities; + DWORD statsSize; + bool hasReservedPortFields; +}; + +constexpr std::array kAbiCompatibilityProfiles{{ + {14, 61, 152, true}, + {13, 29, 152, true}, + {12, 29, 152, true}, + {11, 29, 144, false}, + {10, 13, 144, false}, +}}; + +constexpr bool AbiCompatibilityProfilesAreValid() noexcept { + return kAbiCompatibilityProfiles[0].minor == VIIPER_UDE_ABI_MINOR && + kAbiCompatibilityProfiles[0].capabilities == VIIPER_UDE_ADVERTISED_CAPABILITIES && + kAbiCompatibilityProfiles[0].statsSize == sizeof(VIIPER_UDE_STATS) && + kAbiCompatibilityProfiles[0].hasReservedPortFields && + kAbiCompatibilityProfiles[1].minor == 13 && + kAbiCompatibilityProfiles[1].capabilities == 29 && + kAbiCompatibilityProfiles[1].statsSize == 152 && + kAbiCompatibilityProfiles[1].hasReservedPortFields && + kAbiCompatibilityProfiles[2].minor == 12 && + kAbiCompatibilityProfiles[2].capabilities == 29 && + kAbiCompatibilityProfiles[2].statsSize == 152 && + kAbiCompatibilityProfiles[2].hasReservedPortFields && + kAbiCompatibilityProfiles[3].minor == 11 && + kAbiCompatibilityProfiles[3].capabilities == 29 && + kAbiCompatibilityProfiles[3].statsSize == 144 && + !kAbiCompatibilityProfiles[3].hasReservedPortFields && + kAbiCompatibilityProfiles[4].minor == 10 && + kAbiCompatibilityProfiles[4].capabilities == 13 && + kAbiCompatibilityProfiles[4].statsSize == 144 && + !kAbiCompatibilityProfiles[4].hasReservedPortFields && + kAbiCompatibilityProfiles[0].minor == kAbiCompatibilityProfiles[1].minor + 1 && + kAbiCompatibilityProfiles[1].minor == kAbiCompatibilityProfiles[2].minor + 1 && + kAbiCompatibilityProfiles[2].minor == kAbiCompatibilityProfiles[3].minor + 1 && + kAbiCompatibilityProfiles[3].minor == kAbiCompatibilityProfiles[4].minor + 1; +} + +static_assert(VIIPER_UDE_ABI_MAJOR == 1, "ABI compatibility table major drift"); +static_assert(VIIPER_UDE_ABI_MINOR == 14, "ABI compatibility table current minor drift"); +static_assert(VIIPER_UDE_ADVERTISED_CAPABILITIES == 61, + "ABI compatibility table current capabilities drift"); +static_assert(sizeof(VIIPER_UDE_STATS) == 152, + "ABI compatibility table current statistics size drift"); +static_assert(offsetof(VIIPER_UDE_STATS, ReservedPorts) == 144, + "ABI 1.10/1.11 statistics boundary drift"); +static_assert(AbiCompatibilityProfilesAreValid(), + "ABI compatibility profiles must be exact and strictly descending"); + +constexpr bool SameAbiCompatibilityProfile( + const AbiCompatibilityProfile& left, + const AbiCompatibilityProfile& right) noexcept { + return left.minor == right.minor && + left.capabilities == right.capabilities && + left.statsSize == right.statsSize && + left.hasReservedPortFields == right.hasReservedPortFields; +} + +constexpr bool IsKnownAbiCompatibilityProfile( + const AbiCompatibilityProfile& profile) noexcept { + return std::any_of(kAbiCompatibilityProfiles.begin(), + kAbiCompatibilityProfiles.end(), + [&](const AbiCompatibilityProfile& known) { + return SameAbiCompatibilityProfile(profile, known); + }); +} +constexpr wchar_t kModelSection[] = L"Standard.NTamd64.10.0...17763"; +constexpr wchar_t kInstallSection[] = L"ViiperUde_Install"; +constexpr wchar_t kTransactionNamespace[] = L"VIIPER_UDE_DRIVER_TRANSACTION_NAMESPACE_V1"; +constexpr wchar_t kTransactionBoundary[] = L"VIIPER_UDE_DRIVER_TRANSACTION_BOUNDARY_V1"; +constexpr wchar_t kTransactionMutex[] = L"VIIPER_UDE_DRIVER_TRANSACTION_V1"; +constexpr wchar_t kTransactionObjectSecurity[] = + L"D:P(A;;GA;;;SY)(A;;GA;;;BA)"; +constexpr size_t kMaximumManifestBytes = 1024U * 1024U; +constexpr uint64_t kMaximumTransactionDurationMs = 4ULL * 60ULL * 1000ULL; +// The child can spend 45 seconds in its inner SCM/credential rollback and then +// up to two minutes in the outer protected-image rollback. Keep a bounded +// margin beyond both budgets while retaining the driver mutex until exit. +constexpr uint64_t kBrokerRollbackCeilingMs = 3ULL * 60ULL * 1000ULL; +constexpr uint64_t kDriverRollbackCeilingMs = 2ULL * 60ULL * 1000ULL; +constexpr DWORD kCancelledIoDrainMs = 5000; +constexpr size_t kMaximumBrokerProofBytes = 64U * 1024U; +constexpr size_t kMaximumBrokerDiagnosticCharacters = 1024U; +constexpr std::string_view kBrokerDiagnosticPrefix = "VIIPER: error: "; +constexpr wchar_t kRollbackDirectorySecurity[] = + L"O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)"; +constexpr wchar_t kRecoveryRecordSecurity[] = + L"O:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)"; +constexpr size_t kMaximumRecoveryRecordBytes = 256U * 1024U; +constexpr wchar_t kInstallRecoveryProductDirectory[] = L"VIIPER"; +constexpr wchar_t kInstallRecoveryComponentDirectory[] = L"UdeCx"; +constexpr wchar_t kInstallRecoveryTransactionsDirectory[] = L"Transactions"; +constexpr wchar_t kInstallRecoveryActiveDirectory[] = L"active-v2"; +constexpr wchar_t kInstallRecoverySettledPrefix[] = L"settled-v2-"; +constexpr wchar_t kInstallRecoveryDiscardPrefix[] = L"discarding-v2-"; +constexpr wchar_t kInstallRecoveryJournalPrefix[] = L"journal-"; +constexpr wchar_t kInstallRecoveryJournalSuffix[] = L".json"; +constexpr wchar_t kInstallRecoveryTemporarySuffix[] = L".tmp"; +constexpr wchar_t kInstallRecoveryPriorDirectory[] = L"prior"; +constexpr wchar_t kInstallRecoveryCandidateDirectory[] = L"candidate"; +constexpr wchar_t kInstallRecoveryBrokerDirectory[] = L"broker"; +constexpr wchar_t kInstallRecoveryBrokerExecutable[] = L"viiper.exe"; +constexpr size_t kMaximumInstallRecoveryRecords = 96; +constexpr std::string_view kInstallRecoveryKind = + "VIIPER-UDE-install-switch-recovery"; +constexpr wchar_t kRemoveRecoveryRootDirectory[] = + L"VIIPER-UdeCx-RemoveTransactions"; +constexpr wchar_t kRemoveRecoveryActiveDirectory[] = L"active-v2"; +constexpr wchar_t kRemoveRecoverySettledPrefix[] = L"settled-v2-"; +constexpr wchar_t kRemoveRecoveryPriorDirectory[] = L"prior"; +constexpr size_t kMaximumRemoveRecoveryRecords = 256; +constexpr std::string_view kRemoveRecoveryKind = + "VIIPER-UDE-remove-transaction-recovery"; +constexpr std::string_view kZeroSha256 = + "0000000000000000000000000000000000000000000000000000000000000000"; +constexpr wchar_t kBrokerSettlementRequestFile[] = L"outer-settlement.json"; +constexpr wchar_t kBrokerSettlementFinalFile[] = L"outer-settled.json"; +constexpr wchar_t kBrokerTransactionDirectory[] = L"BrokerTransactions"; +constexpr wchar_t kBrokerTransactionActiveDirectory[] = L"active-v1"; +constexpr size_t kMaximumBrokerSettlementRequestBytes = 16U * 1024U; +constexpr wchar_t kNativeInstallMutexNamespace[] = + L"VIIPER_NATIVE_INSTALL_NAMESPACE_V1"; +constexpr wchar_t kNativeInstallMutexBoundary[] = + L"VIIPER_NATIVE_INSTALL_ADMIN_BOUNDARY_V1"; +constexpr wchar_t kNativePackageInstallMutex[] = L"VIIPER.NativePackage.Install.v1"; +constexpr std::string_view kHardwareVerificationOid = "1.3.6.1.4.1.311.10.3.5"; +constexpr std::string_view kAttestationVerificationOid = "1.3.6.1.4.1.311.10.3.5.1"; + +uint64_t CurrentUnixMilliseconds(); + +// A fixed-size, allocation-free copy lets the top-level exception boundary +// report the protected write-ahead record after C++ stack unwinding has closed +// all transaction handles. Only one helper transaction exists per process. +std::array gActiveRecoveryRecord{}; +bool gActiveRecoveryRecordWritten = false; +std::array gActiveBackupRoot{}; +bool gActiveBackupRootRetained = false; +std::array gRetainedRemoveTombstone{}; +DWORD gRetainedRemoveTombstoneError = ERROR_SUCCESS; +bool gTransactionMutationStarted = false; +bool gLastSynchronousMutationTimedOut = false; + +void MarkTransactionMutationStarted() noexcept { + gTransactionMutationStarted = true; +} + +void ClearActiveRecoveryEvidence() noexcept { + gActiveRecoveryRecord.fill(L'\0'); + gActiveRecoveryRecordWritten = false; + gActiveBackupRoot.fill(L'\0'); + gActiveBackupRootRetained = false; +} + +void ClearRemoveRetirementWarning() noexcept { + gRetainedRemoveTombstone.fill(L'\0'); + gRetainedRemoveTombstoneError = ERROR_SUCCESS; +} + +constexpr GUID kViiperInterfaceGuid = { + 0x32d03f48, 0x725b, 0x4baa, {0x97, 0x0f, 0x7f, 0x5d, 0xe6, 0xc4, 0x46, 0x87}}; + +enum class ExitCode : int { + Success = 0, + Failure = 1, + Usage = 2, + RollbackFailed = 3, + PreflightRejected = 4, + RebootRequired = ERROR_SUCCESS_REBOOT_REQUIRED, +}; + +struct Error { + DWORD code = ERROR_SUCCESS; + std::wstring phase; + std::wstring message; + std::optional nestedExitCode; + std::wstring recoveryRecord; + bool recoveryRecordWritten = false; + DWORD recoveryRecordError = ERROR_SUCCESS; + std::wstring recoveryRecordPhase; + std::wstring recoveryRecordMessage; + std::wstring recoveryBackup; + bool recoveryBackupRetained = false; +}; + +bool CheckTransactionDeadline(uint64_t deadlineUnixMs, const wchar_t* phase, Error* error); +bool IsGeneratedRootInstanceIdForDeviceName( + const std::wstring& instanceId, const wchar_t* deviceName); +bool IsOwnedGeneratedRootInstanceId(const std::wstring& instanceId); + +struct BrokerJournalBinding { + bool present = false; + std::string transactionId; + std::string outerTransactionId; + std::string candidateSha256; + std::string state; + std::string digest; + std::string driverTransactionId; + std::string driverDigest; + std::string settlementNonce; + std::string recovery; +}; + +struct Outcome { + bool success = false; + bool changed = false; + bool rebootRequired = false; + ExitCode exitCode = ExitCode::Failure; + Error error; + std::wstring rollback = L"not-needed"; + BrokerJournalBinding brokerBinding; +}; + +std::wstring FormatError(DWORD error) { + wchar_t* raw = nullptr; + const DWORD flags = FORMAT_MESSAGE_ALLOCATE_BUFFER | + FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS; + const DWORD count = FormatMessageW( + flags, nullptr, error, 0, reinterpret_cast(&raw), 0, nullptr); + std::wstring message = count != 0 && raw != nullptr ? std::wstring(raw, count) : L"unknown error"; + if (raw != nullptr) { + LocalFree(raw); + } + while (!message.empty() && + (message.back() == L'\r' || message.back() == L'\n' || + message.back() == L' ' || message.back() == L'.')) { + message.pop_back(); + } + return message; +} + +bool SetError(Error* error, const wchar_t* phase, DWORD code, std::wstring message = {}) { + if (error != nullptr) { + error->code = code; + error->phase = phase; + error->message = message.empty() ? FormatError(code) : std::move(message); + error->nestedExitCode.reset(); + } + SetLastError(code); + return false; +} + +bool SetLastErrorDetail(Error* error, const wchar_t* phase, std::wstring message = {}) { + return SetError(error, phase, GetLastError(), std::move(message)); +} + +void EmitOutcome(const wchar_t* operation, const Outcome& outcome) { + std::wostream& stream = outcome.success ? std::wcout : std::wcerr; + stream << L"result=" << (outcome.success ? L"success" : L"error") + << L" operation=" << operation + << L" changed=" << (outcome.changed ? 1 : 0) + << L" rebootRequired=" << (outcome.rebootRequired ? 1 : 0) + << L" rollback=" << outcome.rollback + << L" exitCode=" << static_cast(outcome.exitCode); + if (!outcome.success) { + stream << L" phase=" << std::quoted(outcome.error.phase) + << L" win32Error=" << outcome.error.code; + if (outcome.error.nestedExitCode) { + stream << L" nestedExitCode=" << *outcome.error.nestedExitCode; + } + stream << L" message=" << std::quoted(outcome.error.message); + const std::wstring recoveryRecord = + !outcome.error.recoveryRecord.empty() + ? outcome.error.recoveryRecord + : gActiveRecoveryRecord[0] != L'\0' + ? std::wstring(gActiveRecoveryRecord.data()) + : std::wstring{}; + const bool recoveryRecordWritten = + !outcome.error.recoveryRecord.empty() + ? outcome.error.recoveryRecordWritten + : gActiveRecoveryRecordWritten; + if (!recoveryRecord.empty()) { + stream << L" recoveryRecord=" << std::quoted(recoveryRecord) + << L" recoveryRecordWritten=" + << (recoveryRecordWritten ? 1 : 0); + if (!recoveryRecordWritten && + !outcome.error.recoveryRecord.empty()) { + stream << L" recoveryRecordPhase=" + << std::quoted(outcome.error.recoveryRecordPhase) + << L" recoveryRecordWin32Error=" + << outcome.error.recoveryRecordError + << L" recoveryRecordMessage=" + << std::quoted(outcome.error.recoveryRecordMessage); + } + } + const std::wstring recoveryBackup = + !outcome.error.recoveryBackup.empty() + ? outcome.error.recoveryBackup + : gActiveBackupRoot[0] != L'\0' + ? std::wstring(gActiveBackupRoot.data()) + : std::wstring{}; + if (!recoveryBackup.empty()) { + stream << L" recoveryBackup=" << std::quoted(recoveryBackup) + << L" recoveryBackupRetained=" + << ((!outcome.error.recoveryBackup.empty() + ? outcome.error.recoveryBackupRetained + : gActiveBackupRootRetained) ? 1 : 0); + } + } + if (gRetainedRemoveTombstone[0] != L'\0') { + stream << L" warning=\"remove-settled-cleanup-retained\"" + << L" warningWin32Error=" + << gRetainedRemoveTombstoneError + << L" retainedTombstone=" + << std::quoted(gRetainedRemoveTombstone.data()); + } + stream << L"\n"; + if (outcome.success && outcome.brokerBinding.present) { + const auto wide = [](const std::string& value) { + return std::wstring(value.begin(), value.end()); + }; + stream << L"journal-binding operation=install transactionId=" + << wide(outcome.brokerBinding.transactionId) + << L" outerTransactionId=" + << wide(outcome.brokerBinding.outerTransactionId) + << L" candidateSha256=" + << wide(outcome.brokerBinding.candidateSha256) + << L" state=" << wide(outcome.brokerBinding.state) + << L" digest=" << wide(outcome.brokerBinding.digest) + << L" driverTransactionId=" + << wide(outcome.brokerBinding.driverTransactionId) + << L" driverDigest=" + << wide(outcome.brokerBinding.driverDigest) + << L" settlementNonce=" + << wide(outcome.brokerBinding.settlementNonce) + << L" recovery=" << wide(outcome.brokerBinding.recovery) + << L"\n"; + } + stream.flush(); +} + +class WinHandle final { +public: + WinHandle() noexcept = default; + explicit WinHandle(HANDLE value) noexcept : value_(value) {} + ~WinHandle() { reset(); } + WinHandle(const WinHandle&) = delete; + WinHandle& operator=(const WinHandle&) = delete; + WinHandle(WinHandle&& other) noexcept : value_(other.release()) {} + WinHandle& operator=(WinHandle&& other) noexcept { + if (this != &other) { + reset(other.release()); + } + return *this; + } + HANDLE get() const noexcept { return value_; } + explicit operator bool() const noexcept { + return value_ != nullptr && value_ != INVALID_HANDLE_VALUE; + } + HANDLE release() noexcept { + HANDLE value = value_; + value_ = INVALID_HANDLE_VALUE; + return value; + } + void reset(HANDLE value = INVALID_HANDLE_VALUE) noexcept { + if (*this) { + CloseHandle(value_); + } + value_ = value; + } + +private: + HANDLE value_ = INVALID_HANDLE_VALUE; +}; + +class SynchronousMutationWatchdog final { +public: + SynchronousMutationWatchdog( + uint64_t deadlineUnixMs, + const wchar_t* apiName) + : apiName_(apiName == nullptr ? L"unknown" : apiName) { + completion_.reset(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + if (!completion_) { + throw std::bad_alloc(); + } + thread_ = std::thread([this, deadlineUnixMs]() noexcept { + for (;;) { + const uint64_t now = CurrentUnixMilliseconds(); + const DWORD waitMilliseconds = deadlineUnixMs <= now + ? 0 + : static_cast(std::min( + deadlineUnixMs - now, + std::numeric_limits::max() - 1ULL)); + const DWORD wait = WaitForSingleObject( + completion_.get(), waitMilliseconds); + if (wait == WAIT_OBJECT_0) { + return; + } + if (wait == WAIT_TIMEOUT) { + timedOut_.store(true, std::memory_order_release); + std::wstring diagnostic = + L"VIIPER: authoritative synchronous mutation exceeded its deadline; " + L"the owner remains alive and is still waiting for "; + diagnostic += apiName_; + diagnostic += L" to return.\n"; + OutputDebugStringW(diagnostic.c_str()); + WaitForSingleObject(completion_.get(), INFINITE); + return; + } + timedOut_.store(true, std::memory_order_release); + return; + } + }); + } + + ~SynchronousMutationWatchdog() noexcept { + Complete(); + } + + SynchronousMutationWatchdog(const SynchronousMutationWatchdog&) = delete; + SynchronousMutationWatchdog& operator=(const SynchronousMutationWatchdog&) = delete; + + bool Complete() noexcept { + if (completion_) { + SetEvent(completion_.get()); + } + if (thread_.joinable()) { + thread_.join(); + } + return timedOut_.load(std::memory_order_acquire); + } + +private: + std::wstring apiName_; + WinHandle completion_; + std::thread thread_; + std::atomic timedOut_{false}; +}; + +template +auto InvokeAuthoritativeSynchronousMutation( + uint64_t deadlineUnixMs, + const wchar_t* apiName, + Callback&& callback) -> decltype(callback()) { + SynchronousMutationWatchdog watchdog(deadlineUnixMs, apiName); + auto result = callback(); + const DWORD callbackError = GetLastError(); + gLastSynchronousMutationTimedOut = watchdog.Complete(); + SetLastError(callbackError); + return result; +} + +class DeviceInfoSet final { +public: + explicit DeviceInfoSet(HDEVINFO value = INVALID_HANDLE_VALUE) noexcept : value_(value) {} + ~DeviceInfoSet() { + if (value_ != INVALID_HANDLE_VALUE) { + SetupDiDestroyDeviceInfoList(value_); + } + } + DeviceInfoSet(const DeviceInfoSet&) = delete; + DeviceInfoSet& operator=(const DeviceInfoSet&) = delete; + DeviceInfoSet(DeviceInfoSet&& other) noexcept : value_(other.value_) { + other.value_ = INVALID_HANDLE_VALUE; + } + DeviceInfoSet& operator=(DeviceInfoSet&& other) noexcept { + if (this != &other) { + if (value_ != INVALID_HANDLE_VALUE) { + SetupDiDestroyDeviceInfoList(value_); + } + value_ = other.value_; + other.value_ = INVALID_HANDLE_VALUE; + } + return *this; + } + HDEVINFO get() const noexcept { return value_; } + explicit operator bool() const noexcept { return value_ != INVALID_HANDLE_VALUE; } + +private: + HDEVINFO value_; +}; + +class InfHandle final { +public: + explicit InfHandle(HINF value = INVALID_HANDLE_VALUE) noexcept : value_(value) {} + ~InfHandle() { + if (value_ != INVALID_HANDLE_VALUE) { + SetupCloseInfFile(value_); + } + } + InfHandle(const InfHandle&) = delete; + InfHandle& operator=(const InfHandle&) = delete; + HINF get() const noexcept { return value_; } + explicit operator bool() const noexcept { return value_ != INVALID_HANDLE_VALUE; } + +private: + HINF value_; +}; + +class TransactionMutex final { +public: + ~TransactionMutex() { + if (owned_ && mutex_) { + ReleaseMutex(mutex_.get()); + } + mutex_.reset(); + if (namespace_ != nullptr) { + ClosePrivateNamespace(namespace_, 0); + } + if (boundary_ != nullptr) { + DeleteBoundaryDescriptor(boundary_); + } + } + + bool Acquire(Error* error) { + BYTE administratorsBuffer[SECURITY_MAX_SID_SIZE]{}; + DWORD administratorsSize = sizeof(administratorsBuffer); + if (!CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, + administratorsBuffer, &administratorsSize)) { + return SetLastErrorDetail(error, L"transaction-boundary-sid"); + } + boundary_ = CreateBoundaryDescriptorW(kTransactionBoundary, 0); + if (boundary_ == nullptr || + !AddSIDToBoundaryDescriptor(&boundary_, administratorsBuffer)) { + return SetLastErrorDetail(error, L"transaction-boundary"); + } + + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + kTransactionObjectSecurity, SDDL_REVISION_1, &descriptor, nullptr)) { + return SetLastErrorDetail(error, L"transaction-security"); + } + SECURITY_ATTRIBUTES security{}; + security.nLength = sizeof(security); + security.lpSecurityDescriptor = descriptor; + security.bInheritHandle = FALSE; + + namespace_ = CreatePrivateNamespaceW(&security, boundary_, kTransactionNamespace); + DWORD namespaceError = GetLastError(); + if (namespace_ == nullptr && namespaceError == ERROR_ALREADY_EXISTS) { + namespace_ = OpenPrivateNamespaceW(boundary_, kTransactionNamespace); + namespaceError = GetLastError(); + } + if (namespace_ == nullptr) { + LocalFree(descriptor); + SetLastError(namespaceError); + return SetLastErrorDetail(error, L"transaction-namespace"); + } + + const std::wstring mutexName = + std::wstring(kTransactionNamespace) + L"\\" + kTransactionMutex; + mutex_.reset(CreateMutexExW(&security, mutexName.c_str(), 0, MUTEX_ALL_ACCESS)); + const DWORD mutexError = GetLastError(); + LocalFree(descriptor); + if (!mutex_) { + SetLastError(mutexError); + return SetLastErrorDetail(error, L"transaction-mutex"); + } + const DWORD wait = WaitForSingleObject(mutex_.get(), 0); + if (wait == WAIT_OBJECT_0 || wait == WAIT_ABANDONED) { + // WAIT_ABANDONED is safe here: all mutable state is inventoried + // again before the first SetupAPI operation, so no prior process + // state is trusted merely because the lock changed owners. + owned_ = true; + abandoned_ = wait == WAIT_ABANDONED; + return true; + } + if (wait == WAIT_TIMEOUT) { + return SetError(error, L"transaction-mutex", ERROR_INSTALL_ALREADY_RUNNING, + L"another VIIPER native driver transaction is active"); + } + return SetLastErrorDetail(error, L"transaction-mutex-wait"); + } + +private: + HANDLE namespace_ = nullptr; + HANDLE boundary_ = nullptr; + WinHandle mutex_; + bool owned_ = false; + bool abandoned_ = false; +}; + +class OuterPackageMutexWitness final { +public: + ~OuterPackageMutexWitness() { + mutex_.reset(); + if (namespace_ != nullptr) { + ClosePrivateNamespace(namespace_, 0); + } + if (boundary_ != nullptr) { + DeleteBoundaryDescriptor(boundary_); + } + } + + bool VerifyHeldByOuterOwner(Error* error) { + BYTE administratorsBuffer[SECURITY_MAX_SID_SIZE]{}; + DWORD administratorsSize = sizeof(administratorsBuffer); + if (!CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, + administratorsBuffer, &administratorsSize)) { + return SetLastErrorDetail(error, + L"broker-settlement-outer-mutex-sid"); + } + boundary_ = CreateBoundaryDescriptorW( + kNativeInstallMutexBoundary, 0); + if (boundary_ == nullptr || + !AddSIDToBoundaryDescriptor( + &boundary_, administratorsBuffer)) { + return SetLastErrorDetail(error, + L"broker-settlement-outer-mutex-boundary"); + } + namespace_ = OpenPrivateNamespaceW( + boundary_, kNativeInstallMutexNamespace); + if (namespace_ == nullptr) { + return SetLastErrorDetail(error, + L"broker-settlement-outer-mutex-namespace", + L"the authenticated outer package mutex namespace is absent"); + } + const std::wstring name = + std::wstring(kNativeInstallMutexNamespace) + L"\\" + + kNativePackageInstallMutex; + mutex_.reset(OpenMutexW( + SYNCHRONIZE | MUTEX_MODIFY_STATE, FALSE, name.c_str())); + if (!mutex_) { + return SetLastErrorDetail(error, + L"broker-settlement-outer-mutex-open"); + } + const DWORD wait = WaitForSingleObject(mutex_.get(), 0); + if (wait == WAIT_TIMEOUT) { + return true; + } + if (wait == WAIT_OBJECT_0 || wait == WAIT_ABANDONED) { + ReleaseMutex(mutex_.get()); + return SetError(error, + L"broker-settlement-outer-mutex-owner", + ERROR_ACCESS_DENIED, + L"outer settlement requires a different process to retain the package mutex"); + } + return SetLastErrorDetail(error, + L"broker-settlement-outer-mutex-wait"); + } + +private: + HANDLE namespace_ = nullptr; + HANDLE boundary_ = nullptr; + WinHandle mutex_; +}; + +bool IsElevated() { + WinHandle token; + HANDLE raw = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw)) { + return false; + } + token.reset(raw); + TOKEN_ELEVATION elevation{}; + DWORD returned = 0; + return GetTokenInformation(token.get(), TokenElevation, &elevation, sizeof(elevation), &returned) && + elevation.TokenIsElevated != 0; +} + +struct Version { + std::array parts{}; + + friend bool operator==(const Version&, const Version&) = default; + friend bool operator<(const Version& left, const Version& right) { + return left.parts < right.parts; + } +}; + +std::wstring VersionToString(const Version& version) { + std::wostringstream stream; + stream << version.parts[0] << L'.' << version.parts[1] << L'.' + << version.parts[2] << L'.' << version.parts[3]; + return stream.str(); +} + +bool ParseVersion(std::wstring_view text, Version* version) { + Version parsed{}; + size_t start = 0; + for (size_t index = 0; index < parsed.parts.size(); ++index) { + const size_t end = text.find(L'.', start); + if ((end == std::wstring_view::npos) != (index == parsed.parts.size() - 1)) { + return false; + } + const size_t limit = end == std::wstring_view::npos ? text.size() : end; + if (limit == start) { + return false; + } + uint32_t value = 0; + for (size_t cursor = start; cursor < limit; ++cursor) { + if (text[cursor] < L'0' || text[cursor] > L'9') { + return false; + } + const uint32_t digit = static_cast(text[cursor] - L'0'); + if (value > (65535U - digit) / 10U) { + return false; + } + value = value * 10U + digit; + } + parsed.parts[index] = value; + start = limit + 1; + } + if (version != nullptr) { + *version = parsed; + } + return true; +} + +std::string LowerAscii(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char character) { + return static_cast(std::tolower(character)); + }); + return value; +} + +bool IsHexRevision(const std::string& value) { + if (value.size() != 40 && value.size() != 64) { + return false; + } + return std::all_of(value.begin(), value.end(), [](unsigned char character) { + return std::isxdigit(character) != 0; + }); +} + +bool CopySha256Argument( + const wchar_t* value, + const wchar_t* name, + std::string* destination, + Error* error) { + const std::wstring wide = value; + destination->clear(); + destination->reserve(wide.size()); + for (const wchar_t character : wide) { + if (character > 0x7f) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + std::wstring(name) + L" SHA-256 must contain ASCII hexadecimal characters"); + } + destination->push_back(static_cast(character)); + } + if (destination->size() != 64 || + !std::all_of(destination->begin(), destination->end(), + [](unsigned char character) { return std::isxdigit(character) != 0; })) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + std::wstring(name) + L" SHA-256 must contain exactly 64 hexadecimal characters"); + } + return true; +} + +struct JsonValue { + using Object = std::map; + using Array = std::vector; + std::variant value; +}; + +class JsonParser final { +public: + explicit JsonParser(std::string_view text) : text_(text) {} + + bool Parse(JsonValue* value, std::string* message) { + SkipWhitespace(); + if (!ParseValue(value, 0, message)) { + return false; + } + SkipWhitespace(); + if (position_ != text_.size()) { + *message = "trailing data after JSON value"; + return false; + } + return true; + } + +private: + void SkipWhitespace() { + while (position_ < text_.size() && + (text_[position_] == ' ' || text_[position_] == '\t' || + text_[position_] == '\r' || text_[position_] == '\n')) { + ++position_; + } + } + + bool ParseValue(JsonValue* value, unsigned depth, std::string* message) { + if (depth > 16) { + *message = "JSON nesting limit exceeded"; + return false; + } + SkipWhitespace(); + if (position_ >= text_.size()) { + *message = "unexpected end of JSON"; + return false; + } + const char current = text_[position_]; + if (current == '{') { + JsonValue::Object object; + if (!ParseObject(&object, depth + 1, message)) { + return false; + } + value->value = std::move(object); + return true; + } + if (current == '[') { + JsonValue::Array array; + if (!ParseArray(&array, depth + 1, message)) { + return false; + } + value->value = std::move(array); + return true; + } + if (current == '"') { + std::string stringValue; + if (!ParseString(&stringValue, message)) { + return false; + } + value->value = std::move(stringValue); + return true; + } + if (Match("true")) { + value->value = true; + return true; + } + if (Match("false")) { + value->value = false; + return true; + } + if (Match("null")) { + value->value = nullptr; + return true; + } + return ParseInteger(value, message); + } + + bool ParseObject(JsonValue::Object* object, unsigned depth, std::string* message) { + ++position_; + SkipWhitespace(); + if (Consume('}')) { + return true; + } + for (;;) { + std::string key; + if (!ParseString(&key, message)) { + return false; + } + SkipWhitespace(); + if (!Consume(':')) { + *message = "expected ':' in JSON object"; + return false; + } + JsonValue child; + if (!ParseValue(&child, depth, message)) { + return false; + } + if (!object->emplace(std::move(key), std::move(child)).second) { + *message = "duplicate JSON object key"; + return false; + } + SkipWhitespace(); + if (Consume('}')) { + return true; + } + if (!Consume(',')) { + *message = "expected ',' in JSON object"; + return false; + } + SkipWhitespace(); + } + } + + bool ParseArray(JsonValue::Array* array, unsigned depth, std::string* message) { + ++position_; + SkipWhitespace(); + if (Consume(']')) { + return true; + } + for (;;) { + JsonValue child; + if (!ParseValue(&child, depth, message)) { + return false; + } + array->push_back(std::move(child)); + SkipWhitespace(); + if (Consume(']')) { + return true; + } + if (!Consume(',')) { + *message = "expected ',' in JSON array"; + return false; + } + SkipWhitespace(); + } + } + + static void AppendUtf8(uint32_t codePoint, std::string* value) { + if (codePoint <= 0x7fU) { + value->push_back(static_cast(codePoint)); + } else if (codePoint <= 0x7ffU) { + value->push_back(static_cast(0xc0U | (codePoint >> 6U))); + value->push_back(static_cast(0x80U | (codePoint & 0x3fU))); + } else if (codePoint <= 0xffffU) { + value->push_back(static_cast(0xe0U | (codePoint >> 12U))); + value->push_back(static_cast(0x80U | ((codePoint >> 6U) & 0x3fU))); + value->push_back(static_cast(0x80U | (codePoint & 0x3fU))); + } else { + value->push_back(static_cast(0xf0U | (codePoint >> 18U))); + value->push_back(static_cast(0x80U | ((codePoint >> 12U) & 0x3fU))); + value->push_back(static_cast(0x80U | ((codePoint >> 6U) & 0x3fU))); + value->push_back(static_cast(0x80U | (codePoint & 0x3fU))); + } + } + + bool ParseUnicodeEscape(uint32_t* codePoint, std::string* message) { + if (position_ + 4 > text_.size()) { + *message = "short JSON unicode escape"; + return false; + } + uint32_t parsed = 0; + for (unsigned index = 0; index < 4; ++index) { + const char digit = text_[position_++]; + parsed <<= 4U; + if (digit >= '0' && digit <= '9') parsed |= static_cast(digit - '0'); + else if (digit >= 'a' && digit <= 'f') parsed |= static_cast(digit - 'a' + 10); + else if (digit >= 'A' && digit <= 'F') parsed |= static_cast(digit - 'A' + 10); + else { + *message = "invalid JSON unicode escape"; + return false; + } + } + *codePoint = parsed; + return true; + } + + bool ParseString(std::string* value, std::string* message) { + if (!Consume('"')) { + *message = "expected JSON string"; + return false; + } + value->clear(); + while (position_ < text_.size()) { + const unsigned char character = static_cast(text_[position_++]); + if (character == '"') { + return true; + } + if (character < 0x20U) { + *message = "control character in JSON string"; + return false; + } + if (character != '\\') { + value->push_back(static_cast(character)); + continue; + } + if (position_ >= text_.size()) { + *message = "unterminated JSON escape"; + return false; + } + const char escaped = text_[position_++]; + switch (escaped) { + case '"': value->push_back('"'); break; + case '\\': value->push_back('\\'); break; + case '/': value->push_back('/'); break; + case 'b': value->push_back('\b'); break; + case 'f': value->push_back('\f'); break; + case 'n': value->push_back('\n'); break; + case 'r': value->push_back('\r'); break; + case 't': value->push_back('\t'); break; + case 'u': { + uint32_t codePoint = 0; + if (!ParseUnicodeEscape(&codePoint, message)) { + return false; + } + if (codePoint >= 0xd800U && codePoint <= 0xdbffU) { + if (position_ + 2 > text_.size() || text_[position_] != '\\' || + text_[position_ + 1] != 'u') { + *message = "high surrogate JSON escape lacks a low surrogate"; + return false; + } + position_ += 2; + uint32_t low = 0; + if (!ParseUnicodeEscape(&low, message) || + low < 0xdc00U || low > 0xdfffU) { + *message = "invalid low surrogate JSON escape"; + return false; + } + codePoint = 0x10000U + + ((codePoint - 0xd800U) << 10U) + (low - 0xdc00U); + } else if (codePoint >= 0xdc00U && codePoint <= 0xdfffU) { + *message = "unpaired low surrogate JSON escape"; + return false; + } + AppendUtf8(codePoint, value); + break; + } + default: + *message = "invalid JSON escape"; + return false; + } + } + *message = "unterminated JSON string"; + return false; + } + + bool ParseInteger(JsonValue* value, std::string* message) { + const size_t start = position_; + bool negative = false; + if (position_ < text_.size() && text_[position_] == '-') { + negative = true; + ++position_; + } + if (position_ >= text_.size() || text_[position_] < '0' || text_[position_] > '9') { + *message = "expected JSON value"; + return false; + } + if (text_[position_] == '0' && position_ + 1 < text_.size() && + text_[position_ + 1] >= '0' && text_[position_ + 1] <= '9') { + *message = "leading zero in JSON integer"; + return false; + } + uint64_t magnitude = 0; + while (position_ < text_.size() && text_[position_] >= '0' && text_[position_] <= '9') { + const uint64_t digit = static_cast(text_[position_++] - '0'); + if (magnitude > (static_cast(INT64_MAX) - digit) / 10U) { + *message = "JSON integer out of range"; + return false; + } + magnitude = magnitude * 10U + digit; + } + if (position_ < text_.size() && + (text_[position_] == '.' || text_[position_] == 'e' || text_[position_] == 'E')) { + *message = "non-integer JSON number is not permitted in install manifests"; + return false; + } + if (position_ == start) { + *message = "expected JSON integer"; + return false; + } + const int64_t signedValue = negative ? -static_cast(magnitude) : static_cast(magnitude); + value->value = signedValue; + return true; + } + + bool Match(std::string_view expected) { + if (text_.substr(position_, expected.size()) != expected) { + return false; + } + position_ += expected.size(); + return true; + } + + bool Consume(char expected) { + if (position_ >= text_.size() || text_[position_] != expected) { + return false; + } + ++position_; + return true; + } + + std::string_view text_; + size_t position_ = 0; +}; + +const JsonValue* ObjectField(const JsonValue::Object& object, const char* name) { + const auto iterator = object.find(name); + return iterator == object.end() ? nullptr : &iterator->second; +} + +bool ReadSmallHandle(HANDLE file, std::string* contents, Error* error) { + LARGE_INTEGER beginning{}; + if (!SetFilePointerEx(file, beginning, nullptr, FILE_BEGIN)) { + return SetLastErrorDetail(error, L"manifest-seek"); + } + LARGE_INTEGER size{}; + if (!GetFileSizeEx(file, &size)) { + return SetLastErrorDetail(error, L"manifest-size"); + } + if (size.QuadPart <= 0 || static_cast(size.QuadPart) > kMaximumManifestBytes) { + return SetError(error, L"manifest-size", ERROR_FILE_TOO_LARGE, + L"manifest must be nonempty and no larger than one MiB"); + } + contents->assign(static_cast(size.QuadPart), '\0'); + DWORD read = 0; + if (!ReadFile(file, contents->data(), static_cast(contents->size()), &read, nullptr) || + static_cast(read) != contents->size()) { + return SetLastErrorDetail(error, L"manifest-read"); + } + if (contents->size() >= 3 && + static_cast((*contents)[0]) == 0xefU && + static_cast((*contents)[1]) == 0xbbU && + static_cast((*contents)[2]) == 0xbfU) { + contents->erase(0, 3); + } + return true; +} + +bool Sha256Handle(HANDLE file, std::string* digest, Error* error) { + HCRYPTPROV provider = 0; + HCRYPTHASH hash = 0; + if (!CryptAcquireContextW(&provider, nullptr, nullptr, PROV_RSA_AES, CRYPT_VERIFYCONTEXT)) { + return SetLastErrorDetail(error, L"sha256-provider"); + } + const auto releaseProvider = [&]() { CryptReleaseContext(provider, 0); }; + if (!CryptCreateHash(provider, CALG_SHA_256, 0, 0, &hash)) { + const DWORD code = GetLastError(); + releaseProvider(); + return SetError(error, L"sha256-create", code); + } + LARGE_INTEGER beginning{}; + if (!SetFilePointerEx(file, beginning, nullptr, FILE_BEGIN)) { + const DWORD code = GetLastError(); + CryptDestroyHash(hash); + releaseProvider(); + return SetError(error, L"sha256-seek", code); + } + std::array buffer{}; + for (;;) { + DWORD read = 0; + if (!ReadFile(file, buffer.data(), static_cast(buffer.size()), &read, nullptr)) { + const DWORD code = GetLastError(); + CryptDestroyHash(hash); + releaseProvider(); + return SetError(error, L"sha256-read", code); + } + if (read == 0) { + break; + } + if (!CryptHashData(hash, buffer.data(), read, 0)) { + const DWORD code = GetLastError(); + CryptDestroyHash(hash); + releaseProvider(); + return SetError(error, L"sha256-update", code); + } + } + std::array bytes{}; + DWORD length = static_cast(bytes.size()); + if (!CryptGetHashParam(hash, HP_HASHVAL, bytes.data(), &length, 0) || length != bytes.size()) { + const DWORD code = GetLastError(); + CryptDestroyHash(hash); + releaseProvider(); + return SetError(error, L"sha256-finish", code); + } + CryptDestroyHash(hash); + releaseProvider(); + static constexpr char digits[] = "0123456789ABCDEF"; + digest->clear(); + digest->reserve(bytes.size() * 2); + for (BYTE byte : bytes) { + digest->push_back(digits[byte >> 4U]); + digest->push_back(digits[byte & 0x0fU]); + } + return true; +} + +bool Sha256File(const std::filesystem::path& path, std::string* digest, Error* error) { + WinHandle file(CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_SEQUENTIAL_SCAN, nullptr)); + if (!file) { + return SetLastErrorDetail(error, L"sha256-open"); + } + return Sha256Handle(file.get(), digest, error); +} + +bool Sha256Data(std::string_view data, std::string* digest, Error* error) { + HCRYPTPROV provider = 0; + HCRYPTHASH hash = 0; + if (!CryptAcquireContextW(&provider, nullptr, nullptr, PROV_RSA_AES, CRYPT_VERIFYCONTEXT)) { + return SetLastErrorDetail(error, L"sha256-data-provider"); + } + const auto releaseProvider = [&]() { CryptReleaseContext(provider, 0); }; + if (!CryptCreateHash(provider, CALG_SHA_256, 0, 0, &hash)) { + const DWORD code = GetLastError(); + releaseProvider(); + return SetError(error, L"sha256-data-create", code); + } + const bool updated = data.size() <= MAXDWORD && CryptHashData(hash, + reinterpret_cast(data.data()), static_cast(data.size()), 0) != FALSE; + if (!updated) { + const DWORD code = data.size() > MAXDWORD ? ERROR_FILE_TOO_LARGE : GetLastError(); + CryptDestroyHash(hash); + releaseProvider(); + return SetError(error, L"sha256-data-update", code); + } + std::array bytes{}; + DWORD length = static_cast(bytes.size()); + if (!CryptGetHashParam(hash, HP_HASHVAL, bytes.data(), &length, 0)) { + const DWORD code = GetLastError(); + CryptDestroyHash(hash); + releaseProvider(); + return SetError(error, L"sha256-data-finish", code); + } + if (length != bytes.size()) { + CryptDestroyHash(hash); + releaseProvider(); + return SetError(error, L"sha256-data-finish", ERROR_INVALID_DATA); + } + CryptDestroyHash(hash); + releaseProvider(); + static constexpr char digits[] = "0123456789abcdef"; + digest->clear(); + digest->reserve(bytes.size() * 2); + for (BYTE byte : bytes) { + digest->push_back(digits[byte >> 4U]); + digest->push_back(digits[byte & 0x0fU]); + } + return true; +} + +bool DeriveDriverBuildIdentity( + const std::string& sourceRevision, + std::string* digest, + Error* error) { + if (!IsHexRevision(sourceRevision)) { + return SetError(error, L"build-identity-source", ERROR_INVALID_DATA, + L"driver build identity requires an exact 40- or 64-digit source revision"); + } + std::ostringstream preimage; + preimage << "VIIPER-UDE-BUILD-IDENTITY/v1\n" + << "sourceRevision=" << LowerAscii(sourceRevision) << "\n" + << "driverPackageVersion=" << VIIPER_UDE_DRIVER_PACKAGE_VERSION << "\n" + << "abi=" << VIIPER_UDE_ABI_MAJOR << "." << VIIPER_UDE_ABI_MINOR << "\n" + << "capabilities=0x" << std::hex << std::nouppercase << std::setw(8) + << std::setfill('0') << VIIPER_UDE_ADVERTISED_CAPABILITIES << "\n"; + return Sha256Data(preimage.str(), digest, error); +} + +bool FileLength(const std::filesystem::path& path, uint64_t* length, Error* error) { + std::error_code fileError; + const uintmax_t size = std::filesystem::file_size(path, fileError); + if (fileError) { + return SetError(error, L"manifest-file-size", static_cast(fileError.value())); + } + *length = static_cast(size); + return true; +} + +bool VerifyLocalTestPackageSigner( + const std::filesystem::path& infPath, + std::string_view expectedCertificateSha256, + Error* error); + +bool ValidateManifest( + const std::string& rawManifest, + const std::string& expectedRevision, + bool production, + bool localTest, + const std::filesystem::path& packageDirectory, + Error* error) { + std::string raw = rawManifest; + if (raw.size() >= 3 && static_cast(raw[0]) == 0xefU && + static_cast(raw[1]) == 0xbbU && + static_cast(raw[2]) == 0xbfU) { + raw.erase(0, 3); + } + JsonValue root; + std::string parseMessage; + if (!JsonParser(raw).Parse(&root, &parseMessage)) { + return SetError(error, L"manifest-parse", ERROR_INVALID_DATA, + std::wstring(parseMessage.begin(), parseMessage.end())); + } + const auto* object = std::get_if(&root.value); + if (object == nullptr) { + return SetError(error, L"manifest-contract", ERROR_INVALID_DATA, L"manifest root must be an object"); + } + const JsonValue* schema = ObjectField(*object, "schema"); + const JsonValue* revision = ObjectField(*object, "sourceRevision"); + const JsonValue* releaseEligible = ObjectField(*object, "releaseEligible"); + const JsonValue* signingRoute = ObjectField(*object, "signingRoute"); + const JsonValue* testSignerCertificateSha256 = + ObjectField(*object, "testSignerCertificateSha256"); + const JsonValue* driverVersion = ObjectField(*object, "driverPackageVersion"); + const JsonValue* driverMajor = ObjectField(*object, "driverABIMajor"); + const JsonValue* driverMinor = ObjectField(*object, "driverABIMinor"); + const JsonValue* driverCapabilities = ObjectField(*object, "driverCapabilities"); + const JsonValue* driverBuildIdentity = ObjectField(*object, "driverBuildIdentity"); + const JsonValue* files = ObjectField(*object, "files"); + const auto* schemaValue = schema == nullptr ? nullptr : std::get_if(&schema->value); + const auto* revisionValue = revision == nullptr ? nullptr : std::get_if(&revision->value); + const auto* releaseValue = releaseEligible == nullptr ? nullptr : std::get_if(&releaseEligible->value); + const auto* routeValue = signingRoute == nullptr ? nullptr : std::get_if(&signingRoute->value); + const auto* testSignerCertificateSha256Value = testSignerCertificateSha256 == nullptr ? + nullptr : std::get_if(&testSignerCertificateSha256->value); + const auto* driverVersionValue = driverVersion == nullptr ? nullptr : std::get_if(&driverVersion->value); + const auto* driverMajorValue = driverMajor == nullptr ? nullptr : std::get_if(&driverMajor->value); + const auto* driverMinorValue = driverMinor == nullptr ? nullptr : std::get_if(&driverMinor->value); + const auto* driverCapabilitiesValue = driverCapabilities == nullptr ? nullptr : std::get_if(&driverCapabilities->value); + const auto* driverBuildIdentityValue = driverBuildIdentity == nullptr ? nullptr : std::get_if(&driverBuildIdentity->value); + const auto* fileArray = files == nullptr ? nullptr : std::get_if(&files->value); + std::string expectedBuildIdentity; + if (!DeriveDriverBuildIdentity(expectedRevision, &expectedBuildIdentity, error)) { + return false; + } + std::ostringstream expectedCapabilities; + expectedCapabilities << "0x" << std::hex << std::nouppercase << std::setw(8) + << std::setfill('0') << VIIPER_UDE_ADVERTISED_CAPABILITIES; + if (schemaValue == nullptr || *schemaValue != 2 || revisionValue == nullptr || + LowerAscii(*revisionValue) != LowerAscii(expectedRevision) || releaseValue == nullptr || + routeValue == nullptr || fileArray == nullptr || driverVersionValue == nullptr || + *driverVersionValue != VIIPER_UDE_DRIVER_PACKAGE_VERSION || driverMajorValue == nullptr || + *driverMajorValue != VIIPER_UDE_ABI_MAJOR || driverMinorValue == nullptr || + *driverMinorValue != VIIPER_UDE_ABI_MINOR || driverCapabilitiesValue == nullptr || + *driverCapabilitiesValue != expectedCapabilities.str() || driverBuildIdentityValue == nullptr || + *driverBuildIdentityValue != expectedBuildIdentity) { + return SetError(error, L"manifest-contract", ERROR_INVALID_DATA, + L"manifest schema, source revision, loaded-driver identity, release route, or file list is invalid"); + } + if (production) { + if (!*releaseValue || *routeValue != "HLK/WHCP") { + return SetError(error, L"manifest-release-route", ERROR_INVALID_DATA, + L"production installation requires a release-eligible HLK/WHCP manifest"); + } + } else if (localTest) { + const bool signerDigestValid = testSignerCertificateSha256Value != nullptr && + testSignerCertificateSha256Value->size() == 64 && + std::all_of(testSignerCertificateSha256Value->begin(), + testSignerCertificateSha256Value->end(), [](char value) { + return (value >= '0' && value <= '9') || + (value >= 'a' && value <= 'f'); + }); + if (*releaseValue || *routeValue != "LocalTest" || !signerDigestValid) { + return SetError(error, L"manifest-release-route", ERROR_INVALID_DATA, + L"local test installation requires its explicit non-release LocalTest manifest and signer digest"); + } + if (!VerifyLocalTestPackageSigner( + packageDirectory / L"ViiperUde.inf", + *testSignerCertificateSha256Value, + error)) { + return false; + } + } else if (*releaseValue || *routeValue != "ControlledTestAttestation") { + return SetError(error, L"manifest-release-route", ERROR_INVALID_DATA, + L"controlled-test installation requires its testing-only attestation manifest"); + } + const std::set expectedNames = { + "ViiperUde.inf", "ViiperUde.sys", "ViiperUde.pdb", "ViiperUde.cat"}; + if (fileArray->size() != expectedNames.size()) { + return SetError(error, L"manifest-files", ERROR_INVALID_DATA, + L"manifest must describe exactly the four VIIPER package files"); + } + std::set seen; + for (const JsonValue& entry : *fileArray) { + const auto* entryObject = std::get_if(&entry.value); + if (entryObject == nullptr) { + return SetError(error, L"manifest-files", ERROR_INVALID_DATA, L"manifest file entry is not an object"); + } + const JsonValue* nameNode = ObjectField(*entryObject, "name"); + const JsonValue* lengthNode = ObjectField(*entryObject, "length"); + const JsonValue* hashNode = ObjectField(*entryObject, "sha256"); + const auto* name = nameNode == nullptr ? nullptr : std::get_if(&nameNode->value); + const auto* length = lengthNode == nullptr ? nullptr : std::get_if(&lengthNode->value); + const auto* hash = hashNode == nullptr ? nullptr : std::get_if(&hashNode->value); + if (name == nullptr || length == nullptr || *length < 0 || hash == nullptr || + !expectedNames.contains(*name) || !seen.insert(*name).second) { + return SetError(error, L"manifest-files", ERROR_INVALID_DATA, + L"manifest has an unexpected, duplicate, or malformed file entry"); + } + // Production intake binds both INF and PDB to this manifest. The + // installer pins that validated manifest hash, but the public runtime + // package deliberately omits the PDB because Windows needs only + // INF/SYS/CAT. Recheck the unchanged INF here; retaining the PDB entry + // proves this is the exact intake manifest, not a weaker replacement. + if (*name == "ViiperUde.inf") { + const std::filesystem::path filePath = packageDirectory / std::wstring(name->begin(), name->end()); + uint64_t actualLength = 0; + std::string actualHash; + if (!FileLength(filePath, &actualLength, error) || !Sha256File(filePath, &actualHash, error)) { + return false; + } + if (actualLength != static_cast(*length) || + LowerAscii(actualHash) != LowerAscii(*hash)) { + return SetError(error, L"manifest-hash", ERROR_CRC, + L"INF does not match the source-bound submission manifest"); + } + } + } + return seen == expectedNames; +} + +bool GetInfField( + HINF inf, + const wchar_t* section, + const wchar_t* key, + DWORD field, + std::wstring* value, + Error* error) { + INFCONTEXT context{}; + if (!SetupFindFirstLineW(inf, section, key, &context)) { + return SetLastErrorDetail(error, L"inf-contract-line"); + } + DWORD required = 0; + if (!SetupGetStringFieldW(&context, field, nullptr, 0, &required) || + required == 0) { + return SetLastErrorDetail(error, L"inf-contract-field"); + } + std::vector buffer(required); + if (!SetupGetStringFieldW(&context, field, buffer.data(), required, nullptr)) { + return SetLastErrorDetail(error, L"inf-contract-field"); + } + *value = buffer.data(); + return true; +} + +bool ValidateSingleModelLine(HINF inf, Error* error) { + INFCONTEXT context{}; + if (!SetupFindFirstLineW(inf, kModelSection, nullptr, &context)) { + return SetLastErrorDetail(error, L"inf-model"); + } + std::wstring install; + std::wstring hardware; + if (SetupGetFieldCount(&context) != 2) { + return SetError(error, L"inf-model", ERROR_INVALID_DATA, + L"VIIPER model entry must contain only install section and exact hardware ID"); + } + DWORD required = 0; + SetupGetStringFieldW(&context, 1, nullptr, 0, &required); + std::vector installBuffer(required); + if (required == 0 || !SetupGetStringFieldW(&context, 1, installBuffer.data(), required, nullptr)) { + return SetLastErrorDetail(error, L"inf-model-install"); + } + install = installBuffer.data(); + required = 0; + SetupGetStringFieldW(&context, 2, nullptr, 0, &required); + std::vector hardwareBuffer(required); + if (required == 0 || !SetupGetStringFieldW(&context, 2, hardwareBuffer.data(), required, nullptr)) { + return SetLastErrorDetail(error, L"inf-model-hardware-id"); + } + hardware = hardwareBuffer.data(); + INFCONTEXT next{}; + if (_wcsicmp(install.c_str(), kInstallSection) != 0 || + _wcsicmp(hardware.c_str(), kHardwareId) != 0 || + SetupFindNextLine(&context, &next)) { + return SetError(error, L"inf-model", ERROR_INVALID_DATA, + L"INF must contain exactly one VIIPER root model entry"); + } + return true; +} + +struct PackageInfo { + std::filesystem::path infPath; + std::wstring publishedName; + Version version{}; + std::string infSha256; + std::string sysSha256; + std::string catSha256; +}; + +bool SamePackageBytes(const PackageInfo& left, const PackageInfo& right) { + return left.infSha256 == right.infSha256 && + left.sysSha256 == right.sysSha256 && + left.catSha256 == right.catSha256; +} + +std::string PackageBytesKey(const PackageInfo& package) { + return package.infSha256 + ":" + package.sysSha256 + ":" + package.catSha256; +} + +bool GetDriverStoreInfPath( + const std::filesystem::path& publishedPath, + std::filesystem::path* storePath, + Error* error); + +bool InspectInfContract( + const std::filesystem::path& infPath, + bool* owned, + Version* version, + Error* error) { + *owned = false; + UINT errorLine = 0; + InfHandle inf(SetupOpenInfFileW(infPath.c_str(), nullptr, INF_STYLE_WIN4, &errorLine)); + if (!inf) { + return true; + } + GUID classGuid{}; + wchar_t className[MAX_CLASS_NAME_LEN]{}; + if (!SetupDiGetINFClassW(infPath.c_str(), &classGuid, className, MAX_CLASS_NAME_LEN, nullptr) || + !IsEqualGUID(classGuid, GUID_DEVCLASS_USB)) { + return true; + } + std::wstring provider; + std::wstring catalog; + std::wstring driverVersion; + std::wstring pnpLockdown; + std::wstring copyFile; + std::wstring sourceDisk; + std::wstring service; + Error local; + if (!GetInfField(inf.get(), L"Version", L"Provider", 1, &provider, &local) || + !GetInfField(inf.get(), L"Version", L"CatalogFile", 1, &catalog, &local) || + !GetInfField(inf.get(), L"Version", L"DriverVer", 2, &driverVersion, &local) || + !GetInfField(inf.get(), L"Version", L"PnpLockDown", 1, &pnpLockdown, &local) || + !GetInfField(inf.get(), L"ViiperUde_Install.NT", L"CopyFiles", 1, ©File, &local) || + !GetInfField(inf.get(), L"SourceDisksFiles", kDriverFileName, 1, &sourceDisk, &local) || + !GetInfField(inf.get(), L"ViiperUde_Install.NT.Services", L"AddService", 1, &service, &local)) { + return true; + } + if (_wcsicmp(provider.c_str(), kProviderName) != 0 || + _wcsicmp(catalog.c_str(), kCatalogName) != 0 || + pnpLockdown != L"1" || _wcsicmp(copyFile.c_str(), L"@ViiperUde.sys") != 0 || + sourceDisk != L"1" || _wcsicmp(service.c_str(), kServiceName) != 0) { + return true; + } + Version parsed{}; + if (!ParseVersion(driverVersion, &parsed)) { + return SetError(error, L"inf-version", ERROR_INVALID_DATA, + L"VIIPER DriverVer must contain a four-component numeric version"); + } + if (!ValidateSingleModelLine(inf.get(), error)) { + return false; + } + *owned = true; + *version = parsed; + return true; +} + +bool VerifyInfSignature( + const std::filesystem::path& infPath, + std::filesystem::path* catalogPath, + Error* error) { + SP_INF_SIGNER_INFO_W signer{}; + signer.cbSize = sizeof(signer); + if (!SetupVerifyInfFileW(infPath.c_str(), nullptr, &signer)) { + const DWORD code = GetLastError(); + // SetupAPI reports a valid non-WHQL Authenticode package by returning + // FALSE with this classification. The local-test route has already + // installed the exact source-bound signer into TrustedPublisher; its + // manifest validation below still proves that exact certificate and + // the INF/SYS membership in the exact catalog. Production separately + // requires the Microsoft hardware publisher and never relies on this. + if (code != ERROR_AUTHENTICODE_TRUSTED_PUBLISHER) { + return SetError(error, L"inf-signature", code); + } + } + if (signer.CatalogFile[0] == L'\0' || signer.DigitalSigner[0] == L'\0') { + return SetError(error, L"inf-signature", ERROR_INVALID_DATA, + L"signed INF did not report a catalog and signer"); + } + if (catalogPath != nullptr) { + *catalogPath = signer.CatalogFile; + } + return true; +} + +bool IsProductionHardwareVerificationUsage(const std::vector& usages) { + const bool hardware = std::find(usages.begin(), usages.end(), + kHardwareVerificationOid) != usages.end(); + const bool attestation = std::find(usages.begin(), usages.end(), + kAttestationVerificationOid) != usages.end(); + return hardware && !attestation; +} + +template +bool LoadWinTrustFunction( + HMODULE module, + const char* name, + Function* function, + Error* error) { + const FARPROC address = GetProcAddress(module, name); + if (address == nullptr) { + return SetLastErrorDetail(error, L"catalog-policy-api", + L"required Windows catalog policy API is unavailable"); + } + static_assert(sizeof(address) == sizeof(*function)); + std::memcpy(function, &address, sizeof(address)); + return true; +} + +bool VerifyDriverCatalogMember( + const std::filesystem::path& catalogPath, + const std::filesystem::path& memberPath, + bool allowUntrustedLocalTestRoot, + Error* error) { + WinHandle member(CreateFileW(memberPath.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, nullptr)); + if (!member) { + return SetLastErrorDetail(error, L"catalog-member-open"); + } + FILE_ATTRIBUTE_TAG_INFO attributes{}; + if (!GetFileInformationByHandleEx(member.get(), FileAttributeTagInfo, + &attributes, sizeof(attributes)) || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { + return SetError(error, L"catalog-member-open", ERROR_REPARSE_TAG_MISMATCH, + L"catalog member must be a regular non-reparse file"); + } + using AcquireContext2 = BOOL (WINAPI*)( + HCATADMIN*, const GUID*, PCWSTR, PCCERT_STRONG_SIGN_PARA, DWORD); + using CalculateHash2 = BOOL (WINAPI*)(HCATADMIN, HANDLE, DWORD*, BYTE*, DWORD); + using ReleaseContext = BOOL (WINAPI*)(HCATADMIN, DWORD); + HMODULE winTrust = LoadLibraryExW( + L"wintrust.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32); + if (winTrust == nullptr) { + return SetLastErrorDetail(error, L"catalog-policy-library"); + } + AcquireContext2 acquireContext = nullptr; + CalculateHash2 calculateHash = nullptr; + ReleaseContext releaseContext = nullptr; + if (!LoadWinTrustFunction(winTrust, "CryptCATAdminAcquireContext2", + &acquireContext, error) || + !LoadWinTrustFunction(winTrust, "CryptCATAdminCalcHashFromFileHandle2", + &calculateHash, error) || + !LoadWinTrustFunction(winTrust, "CryptCATAdminReleaseContext", + &releaseContext, error)) { + FreeLibrary(winTrust); + return false; + } + HCATADMIN administrator = nullptr; + // Let Windows select the catalog's approved hash algorithm. Microsoft + // explicitly recommends this over hard-coding an algorithm that policy may + // retire; the returned context is also supplied to WinVerifyTrust below. + if (!acquireContext(&administrator, nullptr, nullptr, nullptr, 0)) { + const DWORD code = GetLastError(); + FreeLibrary(winTrust); + return SetError(error, L"catalog-admin", code); + } + const auto releasePolicy = [&]() { + releaseContext(administrator, 0); + FreeLibrary(winTrust); + }; + DWORD hashSize = 0; + if (!calculateHash( + administrator, member.get(), &hashSize, nullptr, 0) || hashSize == 0) { + const DWORD code = GetLastError(); + releasePolicy(); + return SetError(error, L"catalog-member-hash", code); + } + std::vector hash(hashSize); + if (!calculateHash( + administrator, member.get(), &hashSize, hash.data(), 0)) { + const DWORD code = GetLastError(); + releasePolicy(); + return SetError(error, L"catalog-member-hash", code); + } + hash.resize(hashSize); + static constexpr wchar_t digits[] = L"0123456789ABCDEF"; + std::wstring memberTag; + memberTag.reserve(hash.size() * 2); + for (BYTE value : hash) { + memberTag.push_back(digits[value >> 4U]); + memberTag.push_back(digits[value & 0x0fU]); + } + WINTRUST_CATALOG_INFO catalog{}; + catalog.cbStruct = sizeof(catalog); + catalog.pcwszCatalogFilePath = catalogPath.c_str(); + catalog.pcwszMemberTag = memberTag.c_str(); + catalog.pcwszMemberFilePath = memberPath.c_str(); + catalog.hMemberFile = member.get(); + catalog.pbCalculatedFileHash = hash.data(); + catalog.cbCalculatedFileHash = static_cast(hash.size()); + catalog.hCatAdmin = administrator; + WINTRUST_DATA trust{}; + trust.cbStruct = sizeof(trust); + trust.dwUIChoice = WTD_UI_NONE; + trust.fdwRevocationChecks = WTD_REVOKE_NONE; + trust.dwUnionChoice = WTD_CHOICE_CATALOG; + trust.pCatalog = &catalog; + trust.dwStateAction = WTD_STATEACTION_VERIFY; + trust.dwProvFlags = WTD_CACHE_ONLY_URL_RETRIEVAL; + // This operation proves that the exact member hash is present in the + // supplied, Authenticode-trusted catalog. Microsoft documents + // DRIVER_ACTION_VERIFY as the WHQL-specific add-on policy; using it here + // incorrectly rejects a valid test-signed package before deployment. + // Production hardware-publisher policy is enforced separately by + // VerifyMicrosoftHardwareInfSigner. + GUID action = WINTRUST_ACTION_GENERIC_VERIFY_V2; + const LONG status = WinVerifyTrust(reinterpret_cast(INVALID_HANDLE_VALUE), &action, &trust); + trust.dwStateAction = WTD_STATEACTION_CLOSE; + WinVerifyTrust(reinterpret_cast(INVALID_HANDLE_VALUE), &action, &trust); + releasePolicy(); + if (status != ERROR_SUCCESS && + !(allowUntrustedLocalTestRoot && + status == static_cast(CERT_E_UNTRUSTEDROOT))) { + return SetError(error, L"catalog-member-policy", static_cast(status), + L"package file is not a valid member of the exact trusted driver catalog"); + } + return true; +} + +bool VerifyLocalTestPackageSigner( + const std::filesystem::path& infPath, + std::string_view expectedCertificateSha256, + Error* error) { + // InspectInfContract already pins CatalogFile to kCatalogName. Verify the + // exact member hashes and signer directly so a clean packaging machine + // does not need to trust the disposable WDK certificate first. + const std::filesystem::path catalogPath = infPath.parent_path() / kCatalogName; + if (!VerifyDriverCatalogMember(catalogPath, infPath, true, error) || + !VerifyDriverCatalogMember(catalogPath, + infPath.parent_path() / kDriverFileName, true, error)) { + return false; + } + + DWORD encoding = 0; + HCERTSTORE store = nullptr; + HCRYPTMSG message = nullptr; + if (!CryptQueryObject(CERT_QUERY_OBJECT_FILE, catalogPath.c_str(), + CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED | CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED, + CERT_QUERY_FORMAT_FLAG_BINARY, 0, &encoding, nullptr, nullptr, + &store, &message, nullptr)) { + return SetLastErrorDetail(error, L"local-test-catalog-signature-open"); + } + const auto closeCatalog = [&]() { + if (message != nullptr) { + CryptMsgClose(message); + } + if (store != nullptr) { + CertCloseStore(store, 0); + } + }; + DWORD signerSize = 0; + if (!CryptMsgGetParam(message, CMSG_SIGNER_INFO_PARAM, 0, nullptr, &signerSize) || + signerSize < sizeof(CMSG_SIGNER_INFO)) { + const DWORD code = GetLastError(); + closeCatalog(); + return SetError(error, L"local-test-catalog-signer-info", code); + } + std::vector signerBytes(signerSize); + if (!CryptMsgGetParam(message, CMSG_SIGNER_INFO_PARAM, 0, + signerBytes.data(), &signerSize)) { + const DWORD code = GetLastError(); + closeCatalog(); + return SetError(error, L"local-test-catalog-signer-info", code); + } + const auto* signerInfo = reinterpret_cast(signerBytes.data()); + CERT_INFO certificateIdentity{}; + certificateIdentity.Issuer = signerInfo->Issuer; + certificateIdentity.SerialNumber = signerInfo->SerialNumber; + PCCERT_CONTEXT certificate = CertFindCertificateInStore(store, encoding, 0, + CERT_FIND_SUBJECT_CERT, &certificateIdentity, nullptr); + if (certificate == nullptr) { + const DWORD code = GetLastError(); + closeCatalog(); + return SetError(error, L"local-test-catalog-signer-certificate", code); + } + std::string actualCertificateSha256; + const std::string_view encodedCertificate( + reinterpret_cast(certificate->pbCertEncoded), + certificate->cbCertEncoded); + const bool hashed = Sha256Data( + encodedCertificate, &actualCertificateSha256, error); + CertFreeCertificateContext(certificate); + closeCatalog(); + if (!hashed) { + return false; + } + if (actualCertificateSha256 != expectedCertificateSha256) { + return SetError(error, L"local-test-catalog-signer-certificate", + ERROR_CRC, + L"local test catalog signer does not match the source-bound manifest digest"); + } + return true; +} + +bool VerifyMicrosoftHardwareInfSigner( + const std::filesystem::path& infPath, + Error* error) { + SP_INF_SIGNER_INFO_W signer{}; + signer.cbSize = sizeof(signer); + if (!SetupVerifyInfFileW(infPath.c_str(), nullptr, &signer)) { + return SetLastErrorDetail(error, L"inf-microsoft-signature"); + } + if (_wcsicmp(signer.DigitalSigner, + L"Microsoft Windows Hardware Compatibility Publisher") != 0) { + return SetError(error, L"inf-microsoft-signature", ERROR_INVALID_DATA, + L"driver catalog signer is not Microsoft Windows Hardware Compatibility Publisher"); + } + + std::filesystem::path packageInfPath = infPath; + if (_wcsicmp(infPath.filename().c_str(), L"ViiperUde.inf") != 0 && + !GetDriverStoreInfPath(infPath, &packageInfPath, error)) { + return false; + } + std::filesystem::path catalogPath = signer.CatalogFile; + if (catalogPath.is_relative()) { + catalogPath = packageInfPath.parent_path() / catalogPath.filename(); + } + + DWORD encoding = 0; + HCERTSTORE store = nullptr; + HCRYPTMSG message = nullptr; + if (!VerifyDriverCatalogMember(catalogPath, infPath, false, error) || + !VerifyDriverCatalogMember(catalogPath, + packageInfPath.parent_path() / kDriverFileName, false, error)) { + return false; + } + if (!CryptQueryObject(CERT_QUERY_OBJECT_FILE, catalogPath.c_str(), + CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED | CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED, + CERT_QUERY_FORMAT_FLAG_BINARY, 0, &encoding, nullptr, nullptr, + &store, &message, nullptr)) { + return SetLastErrorDetail(error, L"catalog-signature-open"); + } + const auto closeCatalog = [&]() { + if (message != nullptr) { + CryptMsgClose(message); + } + if (store != nullptr) { + CertCloseStore(store, 0); + } + }; + DWORD signerSize = 0; + if (!CryptMsgGetParam(message, CMSG_SIGNER_INFO_PARAM, 0, nullptr, &signerSize) || + signerSize < sizeof(CMSG_SIGNER_INFO)) { + const DWORD code = GetLastError(); + closeCatalog(); + return SetError(error, L"catalog-signer-info", code); + } + std::vector signerBytes(signerSize); + if (!CryptMsgGetParam(message, CMSG_SIGNER_INFO_PARAM, 0, + signerBytes.data(), &signerSize)) { + const DWORD code = GetLastError(); + closeCatalog(); + return SetError(error, L"catalog-signer-info", code); + } + const auto* signerInfo = reinterpret_cast(signerBytes.data()); + CERT_INFO certificateIdentity{}; + certificateIdentity.Issuer = signerInfo->Issuer; + certificateIdentity.SerialNumber = signerInfo->SerialNumber; + PCCERT_CONTEXT certificate = CertFindCertificateInStore(store, encoding, 0, + CERT_FIND_SUBJECT_CERT, &certificateIdentity, nullptr); + if (certificate == nullptr) { + const DWORD code = GetLastError(); + closeCatalog(); + return SetError(error, L"catalog-signer-certificate", code); + } + DWORD usageSize = 0; + if (!CertGetEnhancedKeyUsage(certificate, CERT_FIND_EXT_ONLY_ENHKEY_USAGE_FLAG, + nullptr, &usageSize) || + usageSize < sizeof(CERT_ENHKEY_USAGE)) { + const DWORD code = GetLastError(); + CertFreeCertificateContext(certificate); + closeCatalog(); + return SetError(error, L"catalog-signer-eku", code, + L"production catalog signer must declare Windows Hardware Driver Verification EKU"); + } + std::vector usageBytes(usageSize); + auto* usage = reinterpret_cast(usageBytes.data()); + if (!CertGetEnhancedKeyUsage(certificate, CERT_FIND_EXT_ONLY_ENHKEY_USAGE_FLAG, + usage, &usageSize)) { + const DWORD code = GetLastError(); + CertFreeCertificateContext(certificate); + closeCatalog(); + return SetError(error, L"catalog-signer-eku", code); + } + std::vector usages; + usages.reserve(usage->cUsageIdentifier); + for (DWORD index = 0; index < usage->cUsageIdentifier; ++index) { + const char* oid = usage->rgpszUsageIdentifier[index]; + if (oid != nullptr) { + usages.emplace_back(oid); + } + } + const bool productionUsage = IsProductionHardwareVerificationUsage(usages); + CertFreeCertificateContext(certificate); + closeCatalog(); + if (!productionUsage) { + return SetError(error, L"catalog-signer-eku", ERROR_INVALID_DATA, + L"production requires HLK/WHCP hardware verification and rejects attestation EKU"); + } + return true; +} + +bool LoadOwnedPackage( + const std::filesystem::path& rawPath, + bool requireOwned, + bool allowUntrustedLocalTestRoot, + PackageInfo* package, + bool* owned, + Error* error) { + std::error_code pathError; + const std::filesystem::path path = std::filesystem::canonical(rawPath, pathError); + const bool regular = !pathError && std::filesystem::is_regular_file(path, pathError); + if (pathError || !regular) { + if (requireOwned) { + return SetError(error, L"package-path", ERROR_FILE_NOT_FOUND); + } + *owned = false; + return true; + } + Version version{}; + bool exact = false; + if (!InspectInfContract(path, &exact, &version, error)) { + return false; + } + if (!exact) { + if (requireOwned) { + return SetError(error, L"package-contract", ERROR_INVALID_DATA, + L"INF does not match the exact VIIPER native driver contract"); + } + *owned = false; + return true; + } + std::filesystem::path catalogPath; + if (allowUntrustedLocalTestRoot) { + catalogPath = path.parent_path() / kCatalogName; + } else { + if (!VerifyInfSignature(path, &catalogPath, error)) { + return false; + } + } + std::filesystem::path packageInfPath = path; + if (_wcsicmp(path.filename().c_str(), L"ViiperUde.inf") != 0 && + !GetDriverStoreInfPath(path, &packageInfPath, error)) { + return false; + } + if (catalogPath.is_relative()) { + catalogPath = packageInfPath.parent_path() / catalogPath.filename(); + } + std::string infHash; + std::string sysHash; + std::string catHash; + if (!Sha256File(path, &infHash, error) || + !Sha256File(packageInfPath.parent_path() / kDriverFileName, &sysHash, error) || + !Sha256File(catalogPath, &catHash, error)) { + return false; + } + package->infPath = path; + package->version = version; + package->infSha256 = std::move(infHash); + package->sysSha256 = std::move(sysHash); + package->catSha256 = std::move(catHash); + *owned = true; + return true; +} + +bool IsSafePublishedInfName(const std::wstring& value) { + const std::filesystem::path path(value); + if (path.has_parent_path() || path.filename().wstring() != value || value.size() < 9) { + return false; + } + std::wstring lower = value; + std::transform(lower.begin(), lower.end(), lower.begin(), [](wchar_t character) { + return static_cast(towlower(character)); + }); + if (!lower.starts_with(L"oem") || !lower.ends_with(L".inf")) { + return false; + } + return std::all_of(lower.begin() + 3, lower.end() - 4, [](wchar_t character) { + return character >= L'0' && character <= L'9'; + }); +} + +bool GetSystemInfDirectory(std::filesystem::path* directory, Error* error) { + std::vector buffer(MAX_PATH); + const UINT length = GetWindowsDirectoryW(buffer.data(), static_cast(buffer.size())); + if (length == 0) { + return SetLastErrorDetail(error, L"windows-directory"); + } + if (static_cast(length) >= buffer.size()) { + buffer.resize(static_cast(length) + 1); + const UINT retry = GetWindowsDirectoryW(buffer.data(), static_cast(buffer.size())); + if (retry == 0 || static_cast(retry) >= buffer.size()) { + return SetLastErrorDetail(error, L"windows-directory"); + } + } + *directory = std::filesystem::path(buffer.data()) / L"INF"; + return true; +} + +bool GetPublishedInfPath( + const std::filesystem::path& infPath, + std::filesystem::path* publishedPath, + Error* error) { + DWORD required = 0; + SetupGetInfPublishedNameW(infPath.c_str(), nullptr, 0, &required); + if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return SetLastErrorDetail(error, L"published-inf"); + } + std::vector buffer(required); + if (!SetupGetInfPublishedNameW(infPath.c_str(), buffer.data(), required, nullptr)) { + return SetLastErrorDetail(error, L"published-inf"); + } + const std::filesystem::path result(buffer.data()); + std::filesystem::path systemInf; + if (!GetSystemInfDirectory(&systemInf, error)) { + return false; + } + std::error_code parentError; + std::error_code systemError; + const std::filesystem::path canonicalParent = std::filesystem::canonical(result.parent_path(), parentError); + const std::filesystem::path canonicalSystemInf = std::filesystem::canonical(systemInf, systemError); + if (parentError || systemError || !IsSafePublishedInfName(result.filename().wstring()) || + _wcsicmp(canonicalParent.c_str(), canonicalSystemInf.c_str()) != 0) { + return SetError(error, L"published-inf", ERROR_INVALID_NAME, + L"SetupAPI returned a published INF outside the system INF directory"); + } + *publishedPath = result; + return true; +} + +bool GetDriverStoreInfPath( + const std::filesystem::path& publishedPath, + std::filesystem::path* storePath, + Error* error) { + DWORD required = 0; + SetupGetInfDriverStoreLocationW(publishedPath.c_str(), nullptr, nullptr, nullptr, 0, &required); + if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return SetLastErrorDetail(error, L"driver-store-inf"); + } + std::vector buffer(required); + if (!SetupGetInfDriverStoreLocationW( + publishedPath.c_str(), nullptr, nullptr, buffer.data(), required, nullptr)) { + return SetLastErrorDetail(error, L"driver-store-inf"); + } + *storePath = buffer.data(); + return true; +} + +bool EnumerateOwnedPackages(std::vector* packages, Error* error) { + packages->clear(); + std::filesystem::path infDirectory; + if (!GetSystemInfDirectory(&infDirectory, error)) { + return false; + } + const std::wstring pattern = (infDirectory / L"oem*.inf").wstring(); + WIN32_FIND_DATAW data{}; + HANDLE rawFind = FindFirstFileW(pattern.c_str(), &data); + if (rawFind == INVALID_HANDLE_VALUE) { + if (GetLastError() == ERROR_FILE_NOT_FOUND) { + return true; + } + return SetLastErrorDetail(error, L"enumerate-published-inf"); + } + do { + if ((data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 || + !IsSafePublishedInfName(data.cFileName)) { + continue; + } + PackageInfo package; + bool owned = false; + Error packageError; + if (!LoadOwnedPackage(infDirectory / data.cFileName, false, false, + &package, &owned, &packageError)) { + FindClose(rawFind); + *error = std::move(packageError); + return false; + } + if (owned) { + package.publishedName = data.cFileName; + packages->push_back(std::move(package)); + } + } while (FindNextFileW(rawFind, &data)); + const DWORD enumerationError = GetLastError(); + FindClose(rawFind); + if (enumerationError != ERROR_NO_MORE_FILES) { + return SetError(error, L"enumerate-published-inf", enumerationError); + } + std::sort(packages->begin(), packages->end(), [](const PackageInfo& left, const PackageInfo& right) { + return _wcsicmp(left.publishedName.c_str(), right.publishedName.c_str()) < 0; + }); + return true; +} + +bool FindPublishedCandidate( + const PackageInfo& candidate, + PackageInfo* published, + Error* error) { + std::vector packages; + if (!EnumerateOwnedPackages(&packages, error)) { + return false; + } + size_t matches = 0; + for (const PackageInfo& package : packages) { + if (package.version == candidate.version && SamePackageBytes(package, candidate)) { + *published = package; + ++matches; + } + } + if (matches != 1) { + return SetError(error, L"published-candidate", + matches == 0 ? ERROR_NOT_FOUND : ERROR_DUPLICATE_SERVICE_NAME, + L"driver store must contain exactly one published copy of the candidate package"); + } + return true; +} + +bool MultiSzContains(const std::vector& value, const wchar_t* expected) { + if (value.empty() || value.size() % sizeof(wchar_t) != 0) { + return false; + } + const auto* current = reinterpret_cast(value.data()); + const auto* end = current + value.size() / sizeof(wchar_t); + while (current < end && *current != L'\0') { + const size_t remaining = static_cast(end - current); + const size_t length = wcsnlen_s(current, remaining); + if (length == remaining) { + return false; + } + if (_wcsicmp(std::wstring(current, length).c_str(), expected) == 0) { + return true; + } + current += length + 1; + } + return false; +} + +bool HasExactHardwareId(HDEVINFO set, SP_DEVINFO_DATA& data) { + DWORD type = 0; + DWORD required = 0; + if (SetupDiGetDeviceRegistryPropertyW( + set, &data, SPDRP_HARDWAREID, &type, nullptr, 0, &required)) { + return false; + } + if (GetLastError() != ERROR_INSUFFICIENT_BUFFER || required == 0 || type != REG_MULTI_SZ) { + return false; + } + std::vector value(required); + if (!SetupDiGetDeviceRegistryPropertyW( + set, &data, SPDRP_HARDWAREID, &type, value.data(), + static_cast(value.size()), nullptr)) { + return false; + } + return type == REG_MULTI_SZ && MultiSzContains(value, kHardwareId); +} + +bool ReadDevicePresence(HDEVINFO set, SP_DEVINFO_DATA& data, bool* present, Error* error) { + DEVPROPTYPE type = 0; + DEVPROP_BOOLEAN value = DEVPROP_FALSE; + DWORD required = 0; + if (!SetupDiGetDevicePropertyW( + set, &data, &DEVPKEY_Device_IsPresent, &type, + reinterpret_cast(&value), sizeof(value), &required, 0)) { + return SetLastErrorDetail(error, L"device-presence"); + } + if (type != DEVPROP_TYPE_BOOLEAN || required != sizeof(value)) { + return SetError(error, L"device-presence", ERROR_INVALID_DATA); + } + *present = value == DEVPROP_TRUE; + return true; +} + +bool ReadDevicePropertyString( + HDEVINFO set, + SP_DEVINFO_DATA& data, + const DEVPROPKEY& key, + std::wstring* value, + Error* error) { + DEVPROPTYPE type = 0; + DWORD required = 0; + if (SetupDiGetDevicePropertyW(set, &data, &key, &type, nullptr, 0, &required, 0)) { + return SetError(error, L"device-property", ERROR_INVALID_DATA); + } + const DWORD code = GetLastError(); + if (code == ERROR_NOT_FOUND) { + value->clear(); + return true; + } + if (code != ERROR_INSUFFICIENT_BUFFER || required < sizeof(wchar_t) || type != DEVPROP_TYPE_STRING) { + return SetError(error, L"device-property", code); + } + std::vector buffer(required); + if (!SetupDiGetDevicePropertyW( + set, &data, &key, &type, buffer.data(), static_cast(buffer.size()), nullptr, 0)) { + return SetLastErrorDetail(error, L"device-property"); + } + *value = reinterpret_cast(buffer.data()); + return true; +} + +bool ReadService(HDEVINFO set, SP_DEVINFO_DATA& data, std::wstring* service, Error* error) { + DWORD type = 0; + DWORD required = 0; + if (SetupDiGetDeviceRegistryPropertyW(set, &data, SPDRP_SERVICE, &type, nullptr, 0, &required)) { + return SetError(error, L"device-service", ERROR_INVALID_DATA); + } + const DWORD code = GetLastError(); + if (code == ERROR_INVALID_DATA) { + service->clear(); + return true; + } + if (code != ERROR_INSUFFICIENT_BUFFER || required < sizeof(wchar_t) || type != REG_SZ) { + return SetError(error, L"device-service", code); + } + std::vector buffer(required); + if (!SetupDiGetDeviceRegistryPropertyW( + set, &data, SPDRP_SERVICE, &type, buffer.data(), static_cast(buffer.size()), nullptr)) { + return SetLastErrorDetail(error, L"device-service"); + } + *service = reinterpret_cast(buffer.data()); + return true; +} + +struct DeviceState { + std::wstring instanceId; + bool present = false; + bool started = false; + ULONG problem = 0; + std::wstring service; + std::wstring publishedInf; + Version version{}; + PackageInfo package; +}; + +DeviceInfoSet OpenRootDevices() { + return DeviceInfoSet(SetupDiGetClassDevsW(nullptr, kEnumerator, nullptr, DIGCF_ALLCLASSES)); +} + +bool FindExactDevices(HDEVINFO set, std::vector>* devices, Error* error) { + devices->clear(); + for (DWORD index = 0;; ++index) { + SP_DEVINFO_DATA data{}; + data.cbSize = sizeof(data); + if (!SetupDiEnumDeviceInfo(set, index, &data)) { + if (GetLastError() != ERROR_NO_MORE_ITEMS) { + return SetLastErrorDetail(error, L"enumerate-root-devices"); + } + break; + } + if (!HasExactHardwareId(set, data)) { + continue; + } + DeviceState state; + if (!ReadDevicePresence(set, data, &state.present, error) || + !ReadService(set, data, &state.service, error) || + !ReadDevicePropertyString(set, data, DEVPKEY_Device_DriverInfPath, &state.publishedInf, error)) { + return false; + } + std::wstring driverVersion; + if (!ReadDevicePropertyString(set, data, DEVPKEY_Device_DriverVersion, &driverVersion, error)) { + return false; + } + if (!driverVersion.empty() && !ParseVersion(driverVersion, &state.version)) { + return SetError(error, L"device-version", ERROR_INVALID_DATA, + L"installed device exposes a malformed driver version"); + } + DWORD required = 0; + SetupDiGetDeviceInstanceIdW(set, &data, nullptr, 0, &required); + if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return SetLastErrorDetail(error, L"device-instance-id"); + } + std::vector instance(required); + if (!SetupDiGetDeviceInstanceIdW(set, &data, instance.data(), required, nullptr)) { + return SetLastErrorDetail(error, L"device-instance-id"); + } + state.instanceId = instance.data(); + ULONG status = 0; + ULONG problem = 0; + const CONFIGRET configuration = CM_Get_DevNode_Status(&status, &problem, data.DevInst, 0); + state.started = state.present && configuration == CR_SUCCESS && (status & DN_STARTED) != 0 && problem == 0; + state.problem = configuration == CR_SUCCESS ? problem : static_cast(configuration); + devices->emplace_back(data, std::move(state)); + } + return true; +} + +struct Snapshot { + std::vector devices; + std::vector packages; +}; + +enum class InstallJournalPhase { + Prepared, + SetupCopyEntered, + SetupCopyReturned, + StageReceiptCaptured, + QuiesceSignalEntered, + QuiesceSignalReturned, + RootRegistrationIntentCaptured, + RootRegistrationEntered, + RootRegistrationReturned, + DiInstallEntered, + DiInstallReturned, + PriorAbiProfileCaptured, + DriverValidated, + BrokerHandoffEntered, + BrokerHandoffReturned, + BrokerChildEntered, + BrokerChildSettled, + BrokerOuterSettlementPending, + BrokerOuterSettled, + RollbackBindingEntered, + PartialRootRemovalEntered, + PartialRootRemovalReturned, + PartialRootRemovalRebootPending, + RollbackBindingReturned, + SetupUninstallEntered, + SetupUninstallReturned, + ForwardValidated, + ExactPriorRestored, + ForwardRebootPending, + RestoreRebootPending, + ManualReconciliationRequired, +}; + +enum class InstallJournalDirection { + Forward, + Rollback, +}; + +enum class RemoveJournalPhase { + Prepared, + DeviceRemovalEntered, + DeviceRemovalReturned, + DeviceRemovalCommitted, + PackageRemovalEntered, + PackageRemovalReturned, + PackageRemovalCommitted, + RollbackAdmitted, + RollbackPackageEntered, + RollbackPackageReturned, + RollbackPackageCommitted, + RollbackBindingEntered, + RollbackBindingReturned, + ForwardValidated, + ExactPriorRestored, + ForwardRebootPending, + RestoreRebootPending, + ManualReconciliationRequired, +}; + +enum class RemoveJournalDirection { + Forward, + Rollback, +}; + +bool RecordActiveInstallJournalCutpoint( + InstallJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + Error* error); + +bool RecordActiveInstallJournalCutpointWithReboot( + InstallJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + bool rebootRequired, + bool freshRebootRequired, + Error* error); + +bool RecordActiveInstallJournalRollbackAuthorization( + InstallJournalPhase phase, + DWORD callError, + Error* error); + +bool RecordActiveInstallJournalRootRegistrationIntent( + const std::wstring& instanceId, + Error* error); + +enum class CandidateDisposition { + InstallRequired, + Exact, +}; + +bool RequiresDriverMutation( + CandidateDisposition disposition, + bool exactBindingHealthy) noexcept { + return disposition == CandidateDisposition::InstallRequired || + (disposition == CandidateDisposition::Exact && !exactBindingHealthy); +} + +bool RequiresPristineRuntimeProof( + CandidateDisposition disposition, + bool exactBindingHealthy, + bool rootPresent, + bool rootStarted) noexcept { + return RequiresDriverMutation(disposition, exactBindingHealthy) && + rootPresent && rootStarted; +} + +bool ClassifyCandidatePackage( + const PackageInfo& candidate, + const std::vector& installedPackages, + const std::optional& expectedDowngradeFrom, + CandidateDisposition* disposition, + bool* downgrade, + Error* error) { + if (disposition == nullptr || downgrade == nullptr) { + return SetError(error, L"version-policy", ERROR_INVALID_PARAMETER, + L"candidate package classification requires output storage"); + } + *disposition = CandidateDisposition::InstallRequired; + *downgrade = false; + + const bool conflictingSameVersion = std::any_of( + installedPackages.begin(), installedPackages.end(), [&](const PackageInfo& package) { + return package.version == candidate.version && + !SamePackageBytes(package, candidate); + }); + if (conflictingSameVersion) { + return SetError(error, L"version-policy", ERROR_REVISION_MISMATCH, + L"same-version INF, SYS, or signing catalog replacement is rejected; increment DriverVer"); + } + + std::optional highest; + for (const PackageInfo& package : installedPackages) { + if (!highest || highest->version < package.version) { + highest = package; + } + } + if (!highest) { + if (expectedDowngradeFrom) { + return SetError(error, L"version-policy", ERROR_INVALID_PARAMETER, + L"controlled downgrade guard is valid only for an actual downgrade"); + } + return true; + } + + if (candidate.version < highest->version) { + *downgrade = true; + if (!expectedDowngradeFrom || !(*expectedDowngradeFrom == highest->version)) { + return SetError(error, L"version-policy", ERROR_REVISION_MISMATCH, + L"downgrade rejected; pass --allow-controlled-downgrade with the exact installed version " + + VersionToString(highest->version)); + } + return true; + } + if (candidate.version == highest->version) { + if (expectedDowngradeFrom) { + return SetError(error, L"version-policy", ERROR_INVALID_PARAMETER, + L"controlled downgrade guard is valid only for an actual downgrade"); + } + *disposition = CandidateDisposition::Exact; + return true; + } + if (expectedDowngradeFrom) { + return SetError(error, L"version-policy", ERROR_INVALID_PARAMETER, + L"controlled downgrade guard is valid only for an actual downgrade"); + } + return true; +} + +bool CaptureSnapshot(Snapshot* snapshot, Error* error) { + snapshot->devices.clear(); + if (!EnumerateOwnedPackages(&snapshot->packages, error)) { + return false; + } + DeviceInfoSet set = OpenRootDevices(); + if (!set) { + return SetLastErrorDetail(error, L"open-root-devices"); + } + std::vector> matches; + if (!FindExactDevices(set.get(), &matches, error)) { + return false; + } + std::filesystem::path infDirectory; + if (!GetSystemInfDirectory(&infDirectory, error)) { + return false; + } + for (auto& match : matches) { + DeviceState& device = match.second; + if (!IsOwnedGeneratedRootInstanceId(device.instanceId)) { + return SetError(error, L"device-instance-ownership", ERROR_INVALID_DATA, + L"ROOT\\VIIPER\\UDE has an instance ID outside the VIIPER or legacy generated root namespace"); + } + if (_wcsicmp(device.service.c_str(), kServiceName) != 0 || + !IsSafePublishedInfName(device.publishedInf)) { + return SetError(error, L"device-ownership", ERROR_NOT_FOUND, + L"ROOT\\VIIPER\\UDE is bound to an unowned service or package; refusing mutation"); + } + bool owned = false; + PackageInfo package; + if (!LoadOwnedPackage(infDirectory / device.publishedInf, true, false, + &package, &owned, error)) { + return false; + } + package.publishedName = device.publishedInf; + if (!owned || !(device.version == package.version)) { + return SetError(error, L"device-ownership", ERROR_REVISION_MISMATCH, + L"devnode version does not match its exact signed published INF"); + } + device.package = std::move(package); + snapshot->devices.push_back(std::move(device)); + } + return true; +} + +bool SameRootBinding(const DeviceState& left, const DeviceState& right) noexcept { + return _wcsicmp(left.instanceId.c_str(), right.instanceId.c_str()) == 0 && + left.present == right.present && + _wcsicmp(left.service.c_str(), right.service.c_str()) == 0 && + _wcsicmp(left.publishedInf.c_str(), right.publishedInf.c_str()) == 0 && + left.version == right.version && SamePackageBytes(left.package, right.package); +} + +bool SameEnumeratedRootState( + const DeviceState& left, + const DeviceState& right) noexcept { + return _wcsicmp(left.instanceId.c_str(), right.instanceId.c_str()) == 0 && + left.present == right.present && left.started == right.started && + left.problem == right.problem && + _wcsicmp(left.service.c_str(), right.service.c_str()) == 0 && + _wcsicmp(left.publishedInf.c_str(), right.publishedInf.c_str()) == 0 && + left.version == right.version; +} + +bool SameCapturedRootState( + const Snapshot& left, + const Snapshot& right) noexcept { + return left.devices.size() == right.devices.size() && + (left.devices.empty() || + (SameRootBinding(left.devices[0], right.devices[0]) && + left.devices[0].started == right.devices[0].started && + left.devices[0].problem == right.devices[0].problem)); +} + +bool RollbackLifecycleStateMatches( + const DeviceState& captured, + const DeviceState& restored) noexcept { + if (captured.started) { + return restored.started && restored.problem == 0; + } + return !restored.started && restored.problem == captured.problem; +} + +bool CaptureAndVerifyRootUnchanged( + const Snapshot& expected, + const wchar_t* phase, + Snapshot* observed, + Error* error) { + Snapshot current; + if (!CaptureSnapshot(¤t, error)) { + return false; + } + if (!SameCapturedRootState(expected, current)) { + return SetError(error, phase, + ERROR_REVISION_MISMATCH, + L"the captured root identity or lifecycle state changed before exact binding"); + } + if (observed != nullptr) { + *observed = std::move(current); + } + return true; +} + +bool CaptureAndVerifyPreparedRootUnchanged( + const DeviceState& expected, + HDEVINFO set, + DEVINST expectedDevInst, + const wchar_t* phase, + Error* error) { + std::vector> matches; + if (!FindExactDevices(set, &matches, error)) { + return false; + } + if (matches.size() != 1 || matches[0].first.DevInst != expectedDevInst) { + return SetError(error, phase, ERROR_REVISION_MISMATCH, + L"the selected compatible-driver list no longer belongs to the captured root devnode"); + } + + DeviceState& observed = matches[0].second; + std::filesystem::path infDirectory; + if (!GetSystemInfDirectory(&infDirectory, error)) { + return false; + } + bool owned = false; + PackageInfo package; + if (!IsOwnedGeneratedRootInstanceId(observed.instanceId) || + _wcsicmp(observed.service.c_str(), kServiceName) != 0 || + !IsSafePublishedInfName(observed.publishedInf) || + !LoadOwnedPackage(infDirectory / observed.publishedInf, + true, false, &package, &owned, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, phase, ERROR_ACCESS_DENIED, + L"the selected root no longer has an exact owned package identity"); + } + return false; + } + package.publishedName = observed.publishedInf; + observed.package = std::move(package); + if (!owned || !(observed.version == observed.package.version) || + !SameRootBinding(expected, observed) || + expected.started != observed.started || expected.problem != observed.problem) { + return SetError(error, phase, ERROR_REVISION_MISMATCH, + L"the selected root identity, lifecycle state, or package bytes changed before binding"); + } + return true; +} + +bool StageCandidatePackage( + const PackageInfo& candidate, + bool production, + uint64_t transactionDeadlineUnixMs, + bool* mutationStarted, + bool* stagedHere, + PackageInfo* published, + Error* error) { + *stagedHere = false; + *published = PackageInfo{}; + const std::wstring sourcePath = candidate.infPath.native(); + if (sourcePath.empty() || sourcePath.size() >= MAX_PATH) { + return SetError(error, L"stage-driver-package-path", ERROR_FILENAME_EXCED_RANGE, + L"SetupCopyOEMInf requires a canonical source INF path shorter than MAX_PATH"); + } + if (!CheckTransactionDeadline(transactionDeadlineUnixMs, + L"transaction-deadline-before-driver-stage", error)) { + return false; + } + std::filesystem::path systemInf; + if (!GetSystemInfDirectory(&systemInf, error)) { + return false; + } + std::error_code systemInfError; + const std::filesystem::path canonicalSystemInf = + std::filesystem::canonical(systemInf, systemInfError); + if (systemInfError) { + return SetError(error, L"stage-system-inf-directory", + static_cast(systemInfError.value()), + L"the canonical system INF directory could not be captured before staging"); + } + + std::array destination{}; + DWORD required = 0; + // SetupCopyOEMInf can publish bytes before returning or before subsequent + // receipt validation. Mark the protected transaction as potentially + // mutated before the API boundary; stagedHere remains success-only so + // rollback never claims ownership of a preexisting or uncertain package. + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::SetupCopyEntered, true, ERROR_SUCCESS, + false, error)) { + return false; + } + MarkTransactionMutationStarted(); + const BOOL copied = InvokeAuthoritativeSynchronousMutation( + transactionDeadlineUnixMs, L"SetupCopyOEMInfW", [&]() { + return SetupCopyOEMInfW( + sourcePath.c_str(), nullptr, SPOST_PATH, SP_COPY_NOOVERWRITE, + destination.data(), static_cast(destination.size()), + &required, nullptr); + }); + const DWORD copyError = copied ? ERROR_SUCCESS : GetLastError(); + Error journalReturnError; + const bool journalReturnRecorded = RecordActiveInstallJournalCutpoint( + InstallJournalPhase::SetupCopyReturned, copied != FALSE, + copyError, gLastSynchronousMutationTimedOut, &journalReturnError); + if (copied) { + if (mutationStarted != nullptr) { + *mutationStarted = true; + } + *stagedHere = true; + } else if (copyError != ERROR_FILE_EXISTS) { + if (mutationStarted != nullptr) { + *mutationStarted = true; + } + } + if (!copied && copyError != ERROR_FILE_EXISTS) { + // An unexpected API failure is not proof of package ownership. Leave + // stagedHere false; common rollback will prove the prior inventory and + // fail closed if SetupAPI nevertheless changed it. + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } + return SetError(error, L"stage-driver-package", copyError, + L"add-only candidate import into the Driver Store failed"); + } + + const size_t destinationLength = + wcsnlen_s(destination.data(), destination.size()); + bool receiptValid = destinationLength != 0 && + destinationLength < destination.size() && + required == destinationLength + 1; + std::filesystem::path destinationPath; + if (receiptValid) { + destinationPath = destination.data(); + std::error_code parentError; + const std::filesystem::path canonicalParent = + std::filesystem::canonical(destinationPath.parent_path(), parentError); + receiptValid = !parentError && + IsSafePublishedInfName(destinationPath.filename().wstring()) && + _wcsicmp(canonicalParent.c_str(), canonicalSystemInf.c_str()) == 0; + } + if (receiptValid) { + // Preserve the API's exact, safe published-name receipt immediately. + // Full bytes/catalog/signer verification below may still fail, but + // rollback can then identify and verify only this package. + *published = candidate; + published->infPath = destinationPath; + published->publishedName = destinationPath.filename().wstring(); + } else { + PackageInfo recoveredReceipt; + Error ignoredReceiptError; + if (FindPublishedCandidate( + candidate, &recoveredReceipt, &ignoredReceiptError)) { + *published = std::move(recoveredReceipt); + } + return SetError(error, L"stage-published-inf", ERROR_INVALID_DATA, + L"SetupCopyOEMInf returned a malformed published INF identity"); + } + std::filesystem::path resolvedPublishedPath; + PackageInfo verifiedPublished; + if (!GetPublishedInfPath(candidate.infPath, &resolvedPublishedPath, error) || + _wcsicmp(resolvedPublishedPath.c_str(), destinationPath.c_str()) != 0 || + !FindPublishedCandidate(candidate, &verifiedPublished, error) || + _wcsicmp(verifiedPublished.infPath.c_str(), resolvedPublishedPath.c_str()) != 0 || + _wcsicmp(verifiedPublished.publishedName.c_str(), + resolvedPublishedPath.filename().c_str()) != 0) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"stage-published-inf", ERROR_REVISION_MISMATCH, + L"add-only staging did not resolve to the unique exact candidate package"); + } + return false; + } + if (production && + !VerifyMicrosoftHardwareInfSigner(verifiedPublished.infPath, error)) { + return false; + } + *published = std::move(verifiedPublished); + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } + if (gLastSynchronousMutationTimedOut) { + return SetError(error, L"stage-driver-package-timeout", ERROR_TIMEOUT, + L"SetupCopyOEMInfW exceeded the transaction deadline; its authoritative return and exact receipt were retained for rollback"); + } + return true; +} + +bool RemoveDevice( + HDEVINFO set, + SP_DEVINFO_DATA& data, + uint64_t transactionDeadlineUnixMs, + const wchar_t* deadlinePhase, + bool* mutationStarted, + bool* rebootRequired, + Error* error, + bool* freshRebootRequired = nullptr) { + if (transactionDeadlineUnixMs != 0 && + !CheckTransactionDeadline(transactionDeadlineUnixMs, deadlinePhase, error)) { + return false; + } + MarkTransactionMutationStarted(); + if (mutationStarted != nullptr) { + *mutationStarted = true; + } + BOOL reboot = FALSE; + if (!DiUninstallDevice(nullptr, set, &data, 0, &reboot)) { + return SetLastErrorDetail(error, L"remove-devnode"); + } + if (freshRebootRequired != nullptr) { + *freshRebootRequired = reboot != FALSE; + } + *rebootRequired = *rebootRequired || reboot != FALSE; + return true; +} + +bool IsExactCapturedRemoveTarget( + const DeviceState& expected, + const std::vector& observed) noexcept { + return observed.size() == 1U && + IsOwnedGeneratedRootInstanceId(observed[0].instanceId) && + _wcsicmp(observed[0].service.c_str(), kServiceName) == 0 && + IsSafePublishedInfName(observed[0].publishedInf) && + SameRootBinding(expected, observed[0]); +} + +bool RemoveExactCapturedDevice( + const DeviceState& expected, + uint64_t transactionDeadlineUnixMs, + bool* mutationStarted, + bool* rebootRequired, + Error* error) { + DeviceInfoSet set = OpenRootDevices(); + if (!set) { + return SetLastErrorDetail(error, + L"remove-journal-open-captured-root"); + } + std::vector> matches; + if (!FindExactDevices(set.get(), &matches, error)) return false; + + std::filesystem::path infDirectory; + if (!GetSystemInfDirectory(&infDirectory, error)) return false; + std::vector observed; + observed.reserve(matches.size()); + for (auto& match : matches) { + DeviceState& device = match.second; + PackageInfo package; + bool owned = false; + if (!IsOwnedGeneratedRootInstanceId(device.instanceId) || + _wcsicmp(device.service.c_str(), kServiceName) != 0 || + !IsSafePublishedInfName(device.publishedInf) || + !LoadOwnedPackage(infDirectory / device.publishedInf, + true, false, &package, &owned, error) || !owned || + !(device.version == package.version)) { + if (error == nullptr || error->code == ERROR_SUCCESS) { + SetError(error, L"remove-journal-captured-root-identity", + ERROR_REVISION_MISMATCH, + L"the admitted root no longer has its exact captured package identity"); + } + return false; + } + package.publishedName = device.publishedInf; + device.package = std::move(package); + observed.push_back(device); + } + if (!IsExactCapturedRemoveTarget(expected, observed)) { + return SetError(error, L"remove-journal-captured-root-authority", + ERROR_REVISION_MISMATCH, + L"device removal requires exactly one root with the immutable captured instance, service, published INF, version, and package bytes"); + } + return RemoveDevice(set.get(), matches[0].first, + transactionDeadlineUnixMs, + L"remove-deadline-before-device-mutation", mutationStarted, + rebootRequired, error); +} + +bool RegisterRootDevice( + const GUID& classGuid, + uint64_t transactionDeadlineUnixMs, + bool* mutationStarted, + bool* registrationSucceeded, + DeviceInfoSet* set, + SP_DEVINFO_DATA* data, + Error* error) { + if (registrationSucceeded != nullptr) { + *registrationSucceeded = false; + } + *set = DeviceInfoSet(SetupDiCreateDeviceInfoList(&classGuid, nullptr)); + if (!*set) { + return SetLastErrorDetail(error, L"create-device-info-list"); + } + *data = SP_DEVINFO_DATA{}; + data->cbSize = sizeof(*data); + if (!SetupDiCreateDeviceInfoW( + set->get(), kRootDeviceName, &classGuid, nullptr, nullptr, + DICD_GENERATE_ID, data)) { + return SetLastErrorDetail(error, L"create-root-devnode"); + } + DWORD instanceCharacters = 0; + SetupDiGetDeviceInstanceIdW( + set->get(), data, nullptr, 0, &instanceCharacters); + if (instanceCharacters == 0U || + GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return SetLastErrorDetail( + error, L"capture-generated-root-instance-id"); + } + std::vector generatedInstance(instanceCharacters); + if (!SetupDiGetDeviceInstanceIdW( + set->get(), data, generatedInstance.data(), + instanceCharacters, nullptr)) { + return SetLastErrorDetail( + error, L"capture-generated-root-instance-id"); + } + const std::wstring intendedInstanceId = generatedInstance.data(); + if (!IsEqualGUID(classGuid, GUID_DEVCLASS_USB) || + !IsGeneratedRootInstanceIdForDeviceName( + intendedInstanceId, kRootDeviceName)) { + return SetError(error, L"capture-generated-root-instance-id", + ERROR_INVALID_DATA, + L"SetupAPI generated a root identity or class outside the exact VIIPER transaction namespace"); + } + if (!RecordActiveInstallJournalRootRegistrationIntent( + intendedInstanceId, error)) { + return false; + } + const size_t idCharacters = std::size(kHardwareId) + 1; + std::vector identifiers(idCharacters, L'\0'); + std::copy(std::begin(kHardwareId), std::end(kHardwareId), identifiers.begin()); + if (!CheckTransactionDeadline(transactionDeadlineUnixMs, + L"transaction-deadline-before-root-properties", error)) { + return false; + } + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::RootRegistrationEntered, + true, ERROR_SUCCESS, false, error)) { + return false; + } + MarkTransactionMutationStarted(); + if (mutationStarted != nullptr) { + *mutationStarted = true; + } + if (!SetupDiSetDeviceRegistryPropertyW( + set->get(), data, SPDRP_HARDWAREID, + reinterpret_cast(identifiers.data()), + static_cast(identifiers.size() * sizeof(wchar_t)))) { + const DWORD code = GetLastError(); + Error journalError; + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::RootRegistrationReturned, + false, code, false, &journalError)) { + *error = std::move(journalError); + return false; + } + return SetError(error, L"set-root-hardware-id", code); + } + if (!CheckTransactionDeadline(transactionDeadlineUnixMs, + L"transaction-deadline-before-root-registration", error)) { + return false; + } + MarkTransactionMutationStarted(); + if (mutationStarted != nullptr) { + *mutationStarted = true; + } + const BOOL registered = InvokeAuthoritativeSynchronousMutation( + transactionDeadlineUnixMs, L"SetupDiCallClassInstaller(DIF_REGISTERDEVICE)", + [&]() { + return SetupDiCallClassInstaller( + DIF_REGISTERDEVICE, set->get(), data); + }); + const DWORD registerError = registered ? ERROR_SUCCESS : GetLastError(); + Error journalReturnError; + const bool journalReturnRecorded = RecordActiveInstallJournalCutpoint( + InstallJournalPhase::RootRegistrationReturned, + registered != FALSE, registerError, gLastSynchronousMutationTimedOut, + &journalReturnError); + if (!registered) { + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } + return SetError(error, L"register-root-devnode", registerError); + } + if (registrationSucceeded != nullptr) { + *registrationSucceeded = true; + } + wchar_t instanceId[MAX_DEVICE_ID_LEN]{}; + if (!SetupDiGetDeviceInstanceIdW( + set->get(), data, instanceId, static_cast(std::size(instanceId)), nullptr)) { + return SetLastErrorDetail(error, L"verify-generated-root-instance-id"); + } + if (!IsGeneratedRootInstanceIdForDeviceName(instanceId, kRootDeviceName) || + _wcsicmp(instanceId, intendedInstanceId.c_str()) != 0) { + return SetError(error, L"verify-generated-root-instance-id", ERROR_INVALID_DATA, + L"registered root identity changed from the exact durable pre-registration receipt"); + } + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } + if (gLastSynchronousMutationTimedOut) { + return SetError(error, L"register-root-devnode-timeout", ERROR_TIMEOUT, + L"root registration exceeded the transaction deadline; its authoritative return was retained for rollback"); + } + return true; +} + +bool DriverInfoUsesPublishedPackage( + const std::filesystem::path& driverInfPath, + const std::wstring& expectedPublishedName) { + if (IsSafePublishedInfName(driverInfPath.filename().wstring())) { + return _wcsicmp( + driverInfPath.filename().c_str(), expectedPublishedName.c_str()) == 0; + } + std::filesystem::path publishedPath; + Error ignored; + return GetPublishedInfPath(driverInfPath, &publishedPath, &ignored) && + _wcsicmp(publishedPath.filename().c_str(), expectedPublishedName.c_str()) == 0; +} + +struct PreparedDriverBinding { + HDEVINFO set = INVALID_HANDLE_VALUE; + SP_DEVINFO_DATA* device = nullptr; + SP_DRVINFO_DATA_W selected{}; + bool active = false; + + PreparedDriverBinding() = default; + PreparedDriverBinding(const PreparedDriverBinding&) = delete; + PreparedDriverBinding& operator=(const PreparedDriverBinding&) = delete; + + ~PreparedDriverBinding() { + Reset(); + } + + bool Reset() noexcept { + if (!active) { + return true; + } + const BOOL destroyed = SetupDiDestroyDriverInfoList( + set, device, SPDIT_COMPATDRIVER); + active = false; + set = INVALID_HANDLE_VALUE; + device = nullptr; + selected = SP_DRVINFO_DATA_W{}; + return destroyed != FALSE; + } +}; + +bool PreparePreinstalledDriverOnDevice( + HDEVINFO set, + SP_DEVINFO_DATA* device, + const PackageInfo& publishedPackage, + PreparedDriverBinding* prepared, + Error* error) { + if (prepared == nullptr || prepared->active || + set == INVALID_HANDLE_VALUE || device == nullptr) { + return SetError(error, L"repair-prepare-driver-binding", + ERROR_INVALID_PARAMETER); + } + if (!SetupDiBuildDriverInfoList(set, device, SPDIT_COMPATDRIVER)) { + return SetLastErrorDetail(error, L"repair-build-compatible-driver-list"); + } + prepared->set = set; + prepared->device = device; + prepared->active = true; + + SP_DRVINFO_DATA_W selected{}; + size_t exactMatches = 0; + for (DWORD index = 0;; ++index) { + SP_DRVINFO_DATA_W driver{}; + driver.cbSize = sizeof(driver); + if (!SetupDiEnumDriverInfoW(set, device, SPDIT_COMPATDRIVER, index, &driver)) { + if (GetLastError() != ERROR_NO_MORE_ITEMS) { + const DWORD code = GetLastError(); + prepared->Reset(); + return SetError(error, L"repair-enumerate-compatible-driver", code); + } + break; + } + DWORD required = 0; + SP_DRVINFO_DETAIL_DATA_W probe{}; + probe.cbSize = sizeof(probe); + if (!SetupDiGetDriverInfoDetailW( + set, device, &driver, &probe, sizeof(probe), &required) && + GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + const DWORD code = GetLastError(); + prepared->Reset(); + return SetError(error, L"repair-compatible-driver-detail", code); + } + const DWORD detailBytes = std::max( + required, static_cast(sizeof(SP_DRVINFO_DETAIL_DATA_W))); + std::vector detailBuffer(detailBytes); + auto* detail = reinterpret_cast(detailBuffer.data()); + detail->cbSize = sizeof(SP_DRVINFO_DETAIL_DATA_W); + if (!SetupDiGetDriverInfoDetailW( + set, device, &driver, detail, detailBytes, nullptr)) { + const DWORD code = GetLastError(); + prepared->Reset(); + return SetError(error, L"repair-compatible-driver-detail", code); + } + if (DriverInfoUsesPublishedPackage( + detail->InfFileName, publishedPackage.publishedName)) { + selected = driver; + ++exactMatches; + } + } + if (exactMatches != 1) { + prepared->Reset(); + return SetError(error, L"repair-exact-driver-selection", + exactMatches == 0 ? ERROR_NOT_FOUND : ERROR_DUPLICATE_SERVICE_NAME, + L"compatible driver list must contain exactly one node for the exact preinstalled package"); + } + prepared->selected = selected; + return true; +} + +bool CommitPreparedDriverBinding( + PreparedDriverBinding* prepared, + uint64_t transactionDeadlineUnixMs, + bool* mutationStarted, + bool* rebootRequired, + Error* error) { + if (prepared == nullptr || !prepared->active || + prepared->set == INVALID_HANDLE_VALUE || prepared->device == nullptr || + prepared->selected.cbSize != sizeof(SP_DRVINFO_DATA_W)) { + return SetError(error, L"repair-commit-driver-binding", + ERROR_INVALID_PARAMETER); + } + if (transactionDeadlineUnixMs != 0 && + !CheckTransactionDeadline(transactionDeadlineUnixMs, + L"transaction-deadline-before-selected-device-binding", error)) { + return false; + } + MarkTransactionMutationStarted(); + if (mutationStarted != nullptr) { + *mutationStarted = true; + } + if (!SetupDiSetSelectedDriverW( + prepared->set, prepared->device, &prepared->selected)) { + const DWORD code = GetLastError(); + prepared->Reset(); + return SetError(error, L"repair-select-exact-driver", code); + } + BOOL reboot = FALSE; + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::DiInstallEntered, true, ERROR_SUCCESS, + false, error)) { + prepared->Reset(); + return false; + } + const BOOL installed = InvokeAuthoritativeSynchronousMutation( + transactionDeadlineUnixMs, L"DiInstallDevice", [&]() { + return DiInstallDevice(nullptr, prepared->set, prepared->device, + &prepared->selected, 0, &reboot); + }); + const DWORD installError = installed ? ERROR_SUCCESS : GetLastError(); + const bool combinedRebootRequired = + *rebootRequired || reboot != FALSE; + *rebootRequired = combinedRebootRequired; + Error journalReturnError; + const bool journalReturnRecorded = + RecordActiveInstallJournalCutpointWithReboot( + InstallJournalPhase::DiInstallReturned, installed != FALSE, + installError, gLastSynchronousMutationTimedOut, + combinedRebootRequired, reboot != FALSE, + &journalReturnError); + if (!installed) { + const DWORD code = installError; + prepared->Reset(); + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } + return SetError(error, L"repair-install-preinstalled-driver", code); + } + if (!prepared->Reset()) { + return SetLastErrorDetail(error, L"repair-destroy-compatible-driver-list"); + } + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } + if (gLastSynchronousMutationTimedOut) { + return SetError(error, L"repair-install-preinstalled-driver-timeout", + ERROR_TIMEOUT, + L"DiInstallDevice exceeded the transaction deadline; its authoritative return was retained for rollback"); + } + return true; +} + +bool InstallPreinstalledDriverOnDevice( + HDEVINFO set, + SP_DEVINFO_DATA* device, + const PackageInfo& publishedPackage, + uint64_t transactionDeadlineUnixMs, + bool* mutationStarted, + bool* rebootRequired, + Error* error) { + PreparedDriverBinding prepared; + return PreparePreinstalledDriverOnDevice( + set, device, publishedPackage, &prepared, error) && + CommitPreparedDriverBinding( + &prepared, transactionDeadlineUnixMs, mutationStarted, + rebootRequired, error); +} + +bool IsGeneratedRootInstanceIdForDeviceName( + const std::wstring& instanceId, + const wchar_t* deviceName) { + const std::wstring prefix = std::wstring(L"ROOT\\") + deviceName + L"\\"; + if (instanceId.size() != prefix.size() + 4 || + _wcsnicmp(instanceId.c_str(), prefix.c_str(), prefix.size()) != 0) { + return false; + } + for (size_t index = prefix.size(); index < instanceId.size(); ++index) { + if (instanceId[index] < L'0' || instanceId[index] > L'9') { + return false; + } + } + return true; +} + +bool IsOwnedGeneratedRootInstanceId(const std::wstring& instanceId) { + return IsGeneratedRootInstanceIdForDeviceName(instanceId, kRootDeviceName) || + IsGeneratedRootInstanceIdForDeviceName(instanceId, kLegacyRootDeviceName); +} + +bool RegisterRootDeviceExact( + const GUID& classGuid, + const std::wstring& instanceId, + uint64_t transactionDeadlineUnixMs, + bool* mutationStarted, + bool* registrationSucceeded, + DeviceInfoSet* set, + SP_DEVINFO_DATA* data, + Error* error) { + if (registrationSucceeded != nullptr) { + *registrationSucceeded = false; + } + if (!IsOwnedGeneratedRootInstanceId(instanceId)) { + return SetError(error, L"rollback-instance-id", + ERROR_INVALID_DATA, + L"captured root devnode identity is outside the VIIPER or legacy generated root namespace"); + } + *set = DeviceInfoSet(SetupDiCreateDeviceInfoList(&classGuid, nullptr)); + if (!*set) { + return SetLastErrorDetail(error, L"rollback-create-device-info-list"); + } + *data = SP_DEVINFO_DATA{}; + data->cbSize = sizeof(*data); + // With DICD_GENERATE_ID absent, SetupAPI treats DeviceName as the complete + // device instance ID. Rollback must never substitute a fresh ROOT instance. + if (!SetupDiCreateDeviceInfoW(set->get(), instanceId.c_str(), &classGuid, + nullptr, nullptr, 0, data)) { + return SetLastErrorDetail(error, L"rollback-create-exact-root-devnode"); + } + const size_t idCharacters = std::size(kHardwareId) + 1; + std::vector identifiers(idCharacters, L'\0'); + std::copy(std::begin(kHardwareId), std::end(kHardwareId), identifiers.begin()); + if (transactionDeadlineUnixMs != 0 && + !CheckTransactionDeadline(transactionDeadlineUnixMs, + L"rollback-deadline-before-root-properties", error)) { + return false; + } + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::RootRegistrationEntered, + true, ERROR_SUCCESS, false, error)) { + return false; + } + MarkTransactionMutationStarted(); + if (mutationStarted != nullptr) { + *mutationStarted = true; + } + if (!SetupDiSetDeviceRegistryPropertyW(set->get(), data, SPDRP_HARDWAREID, + reinterpret_cast(identifiers.data()), + static_cast(identifiers.size() * sizeof(wchar_t)))) { + const DWORD code = GetLastError(); + Error journalError; + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::RootRegistrationReturned, + false, code, false, &journalError)) { + *error = std::move(journalError); + return false; + } + return SetError(error, L"rollback-set-root-hardware-id", code); + } + if (transactionDeadlineUnixMs != 0 && + !CheckTransactionDeadline(transactionDeadlineUnixMs, + L"rollback-deadline-before-root-registration", error)) { + return false; + } + MarkTransactionMutationStarted(); + if (mutationStarted != nullptr) { + *mutationStarted = true; + } + const BOOL registered = InvokeAuthoritativeSynchronousMutation( + transactionDeadlineUnixMs, + L"SetupDiCallClassInstaller(DIF_REGISTERDEVICE)", [&]() { + return SetupDiCallClassInstaller( + DIF_REGISTERDEVICE, set->get(), data); + }); + const DWORD registerError = registered ? ERROR_SUCCESS : GetLastError(); + Error journalReturnError; + const bool journalReturnRecorded = RecordActiveInstallJournalCutpoint( + InstallJournalPhase::RootRegistrationReturned, + registered != FALSE, registerError, gLastSynchronousMutationTimedOut, + &journalReturnError); + if (!registered) { + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } + return SetError(error, L"rollback-register-exact-root-devnode", + registerError); + } + if (registrationSucceeded != nullptr) { + *registrationSucceeded = true; + } + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } + if (gLastSynchronousMutationTimedOut) { + return SetError(error, L"rollback-register-exact-root-devnode-timeout", + ERROR_TIMEOUT, + L"exact root registration exceeded the rollback deadline; its authoritative return was retained"); + } + return true; +} + +bool IssueAbiNegotiation( + HANDLE device, + uint64_t deadlineUnixMs, + VIIPER_UDE_UINT16 abiMinor, + VIIPER_UDE_UINT32 requestedCapabilities, + VIIPER_UDE_NEGOTIATE_RESPONSE* response, + VIIPER_UDE_UINT64* clientNonce, + DWORD* returnedBytes, + Error* error) { + if (response == nullptr || clientNonce == nullptr || returnedBytes == nullptr) { + return SetError(error, L"abi-negotiate-arguments", ERROR_INVALID_PARAMETER); + } + LARGE_INTEGER counter{}; + QueryPerformanceCounter(&counter); + VIIPER_UDE_NEGOTIATE_REQUEST request{}; + request.Header.Magic = VIIPER_UDE_MAGIC; + request.Header.Major = VIIPER_UDE_ABI_MAJOR; + request.Header.Minor = abiMinor; + request.Header.Size = sizeof(request); + request.ClientNonce = static_cast(counter.QuadPart) ^ GetTickCount64(); + if (request.ClientNonce == 0) request.ClientNonce = 1; + request.RequestedCapabilities = requestedCapabilities; + *response = VIIPER_UDE_NEGOTIATE_RESPONSE{}; + DWORD returned = 0; + WinHandle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + if (!event) { + return SetLastErrorDetail(error, L"abi-negotiate-event"); + } + OVERLAPPED overlapped{}; + overlapped.hEvent = event.get(); + const BOOL completed = DeviceIoControl(device, IOCTL_VIIPER_UDE_NEGOTIATE, + &request, sizeof(request), response, sizeof(*response), &returned, &overlapped); + if (!completed && GetLastError() != ERROR_IO_PENDING) { + return SetLastErrorDetail(error, L"abi-negotiate"); + } + if (!completed) { + const uint64_t now = CurrentUnixMilliseconds(); + if (deadlineUnixMs <= now) { + const BOOL cancelled = CancelIoEx(device, &overlapped); + const DWORD cancelError = cancelled ? ERROR_SUCCESS : GetLastError(); + const DWORD drain = WaitForSingleObject(event.get(), kCancelledIoDrainMs); + if ((!cancelled && cancelError != ERROR_NOT_FOUND) || drain != WAIT_OBJECT_0) { + return SetError(error, L"abi-negotiate-drain", + !cancelled && cancelError != ERROR_NOT_FOUND + ? cancelError : ERROR_OPERATION_ABORTED, + L"expired native ABI negotiation could not be cancelled and drained safely"); + } + DWORD ignored = 0; + GetOverlappedResult(device, &overlapped, &ignored, FALSE); + return SetError(error, L"abi-negotiate-timeout", ERROR_TIMEOUT, + L"native ABI negotiation exceeded the package transaction deadline"); + } + const uint64_t remaining = deadlineUnixMs - now; + const DWORD waitMilliseconds = static_cast( + std::min(remaining, static_cast(MAXDWORD - 1))); + const DWORD wait = WaitForSingleObject(event.get(), waitMilliseconds); + if (wait == WAIT_TIMEOUT) { + const BOOL cancelled = CancelIoEx(device, &overlapped); + const DWORD cancelError = cancelled ? ERROR_SUCCESS : GetLastError(); + const DWORD drain = WaitForSingleObject(event.get(), kCancelledIoDrainMs); + if (drain == WAIT_OBJECT_0) { + DWORD ignored = 0; + GetOverlappedResult(device, &overlapped, &ignored, FALSE); + } + if (!cancelled && cancelError != ERROR_NOT_FOUND) { + return SetError(error, L"abi-negotiate-cancel", cancelError, + L"timed-out native ABI negotiation could not be cancelled"); + } + if (drain != WAIT_OBJECT_0) { + return SetError(error, L"abi-negotiate-drain", ERROR_OPERATION_ABORTED, + L"timed-out native ABI negotiation did not complete cancellation within the rollback ceiling"); + } + return SetError(error, L"abi-negotiate-timeout", ERROR_TIMEOUT, + L"native ABI negotiation exceeded the package transaction deadline"); + } + if (wait != WAIT_OBJECT_0) { + const DWORD waitError = GetLastError(); + CancelIoEx(device, &overlapped); + const DWORD drain = WaitForSingleObject(event.get(), kCancelledIoDrainMs); + if (drain != WAIT_OBJECT_0) { + return SetError(error, L"abi-negotiate-drain", ERROR_OPERATION_ABORTED, + L"failed native ABI wait could not be drained safely"); + } + SetLastError(waitError); + return SetLastErrorDetail(error, L"abi-negotiate-wait"); + } + if (!GetOverlappedResult(device, &overlapped, &returned, FALSE)) { + return SetLastErrorDetail(error, L"abi-negotiate-result"); + } + } + *clientNonce = request.ClientNonce; + *returnedBytes = returned; + return true; +} + +enum class AbiHealthPurpose { + ExactCandidate, + PristineUpgrade, + PristineRecheck, + RollbackHealth, +}; + +bool IsAbiRetryEligible( + AbiHealthPurpose purpose, + const std::string* expectedBuildIdentity, + const Error& error) { + return purpose == AbiHealthPurpose::PristineUpgrade && + expectedBuildIdentity == nullptr && + (error.code == ERROR_REVISION_MISMATCH || + error.code == ERROR_INVALID_PARAMETER) && + (error.phase == L"abi-negotiate" || + error.phase == L"abi-negotiate-result"); +} + +bool RuntimeStatsArePristine( + const VIIPER_UDE_STATS& stats, + const AbiCompatibilityProfile& profile) noexcept { + return stats.OperationsDequeued == 0 && stats.OperationsCompleted == 0 && + stats.OperationsCancelled == 0 && stats.OperationsPurged == 0 && + stats.LateCompletions == 0 && stats.InvalidMessages == 0 && + stats.QueueExhaustions == 0 && stats.IsoPackets == 0 && + stats.BytesToDevice == 0 && stats.BytesFromDevice == 0 && + stats.NotificationEvents == 0 && stats.NotificationEventOverflows == 0 && + stats.ActiveDevices == 0 && stats.PendingOperations == 0 && + stats.WaitingDequeues == 0 && stats.CleanupRetries == 0 && + stats.InputReportsSubmitted == 0 && stats.InputReportsCompleted == 0 && + (!profile.hasReservedPortFields || stats.ReservedPorts == 0); +} + +bool AbiNegotiationResponseMatchesProfile( + const VIIPER_UDE_NEGOTIATE_RESPONSE& response, + DWORD returned, + VIIPER_UDE_UINT64 clientNonce, + const AbiCompatibilityProfile& profile) noexcept { + return returned == sizeof(response) && + response.Header.Magic == VIIPER_UDE_MAGIC && + response.Header.Major == VIIPER_UDE_ABI_MAJOR && + response.Header.Minor == profile.minor && + response.Header.Size == sizeof(response) && response.Header.Flags == 0 && + response.ClientNonce == clientNonce && response.DriverNonce != 0 && + response.Capabilities == profile.capabilities && + response.MaxDevices == VIIPER_UDE_MAX_DEVICES && + response.MaxDescriptorBytes == VIIPER_UDE_MAX_DESCRIPTOR_BYTES && + response.MaxTransferBytes == VIIPER_UDE_MAX_TRANSFER_BYTES && + response.MaxIsoPackets == VIIPER_UDE_MAX_ISO_PACKETS && + response.MaxPendingOperations == VIIPER_UDE_MAX_PENDING_OPERATIONS; +} + +bool StatsRecordMatchesProfile( + const VIIPER_UDE_STATS& stats, + DWORD returned, + const AbiCompatibilityProfile& profile) noexcept { + return returned == profile.statsSize && + stats.Header.Magic == VIIPER_UDE_MAGIC && + stats.Header.Major == VIIPER_UDE_ABI_MAJOR && + stats.Header.Minor == profile.minor && + stats.Header.Size == profile.statsSize && stats.Header.Flags == 0 && + (!profile.hasReservedPortFields || + (stats.ReservedPorts <= VIIPER_UDE_MAX_DEVICES && stats.Reserved == 0)); +} + +bool VerifyAbiHealth( + uint64_t deadlineUnixMs, + const std::string* expectedBuildIdentity, + Error* error, + AbiHealthPurpose purpose = AbiHealthPurpose::ExactCandidate, + const AbiCompatibilityProfile* requiredProfile = nullptr, + AbiCompatibilityProfile* negotiatedProfile = nullptr) { + const bool requiresKnownProfile = + purpose == AbiHealthPurpose::PristineRecheck || + purpose == AbiHealthPurpose::RollbackHealth; + if ((requiresKnownProfile && requiredProfile == nullptr) || + (!requiresKnownProfile && requiredProfile != nullptr) || + (purpose != AbiHealthPurpose::ExactCandidate && + expectedBuildIdentity != nullptr)) { + return SetError(error, L"abi-health-purpose", ERROR_INVALID_PARAMETER, + L"ABI health purpose and compatibility profile are inconsistent"); + } + DeviceInfoSet set(SetupDiGetClassDevsW( + &kViiperInterfaceGuid, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE)); + if (!set) { + return SetLastErrorDetail(error, L"abi-interface-enumeration"); + } + std::wstring interfacePath; + size_t exactCount = 0; + for (DWORD index = 0;; ++index) { + SP_DEVICE_INTERFACE_DATA interfaceData{}; + interfaceData.cbSize = sizeof(interfaceData); + if (!SetupDiEnumDeviceInterfaces(set.get(), nullptr, &kViiperInterfaceGuid, index, &interfaceData)) { + if (GetLastError() != ERROR_NO_MORE_ITEMS) { + return SetLastErrorDetail(error, L"abi-interface-enumeration"); + } + break; + } + SP_DEVINFO_DATA deviceData{}; + deviceData.cbSize = sizeof(deviceData); + DWORD required = 0; + SetupDiGetDeviceInterfaceDetailW( + set.get(), &interfaceData, nullptr, 0, &required, &deviceData); + if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return SetLastErrorDetail(error, L"abi-interface-detail"); + } + std::vector buffer(required); + auto* detail = reinterpret_cast(buffer.data()); + detail->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W); + if (!SetupDiGetDeviceInterfaceDetailW( + set.get(), &interfaceData, detail, required, nullptr, &deviceData)) { + return SetLastErrorDetail(error, L"abi-interface-detail"); + } + if (!HasExactHardwareId(set.get(), deviceData)) { + continue; + } + std::wstring service; + if (!ReadService(set.get(), deviceData, &service, error)) { + return false; + } + if (_wcsicmp(service.c_str(), kServiceName) != 0) { + return SetError(error, L"abi-interface-ownership", ERROR_ACCESS_DENIED); + } + ++exactCount; + interfacePath = detail->DevicePath; + } + if (exactCount != 1) { + return SetError(error, L"abi-interface-count", + exactCount == 0 ? ERROR_DEVICE_NOT_AVAILABLE : ERROR_DUPLICATE_SERVICE_NAME); + } + WinHandle device(CreateFileW(interfacePath.c_str(), GENERIC_READ | GENERIC_WRITE, + 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, nullptr)); + if (!device) { + return SetLastErrorDetail(error, L"abi-interface-open", + L"native broker interface is unavailable or still owned by another process"); + } + const AbiCompatibilityProfile* profiles = nullptr; + size_t profileCount = 0; + bool requirePristineRuntime = false; + switch (purpose) { + case AbiHealthPurpose::ExactCandidate: + profiles = &kAbiCompatibilityProfiles[0]; + profileCount = 1; + break; + case AbiHealthPurpose::PristineUpgrade: + profiles = kAbiCompatibilityProfiles.data(); + profileCount = kAbiCompatibilityProfiles.size(); + requirePristineRuntime = true; + break; + case AbiHealthPurpose::PristineRecheck: + profiles = requiredProfile; + profileCount = 1; + requirePristineRuntime = true; + break; + case AbiHealthPurpose::RollbackHealth: + profiles = requiredProfile; + profileCount = 1; + requirePristineRuntime = true; + break; + } + + VIIPER_UDE_NEGOTIATE_RESPONSE response{}; + VIIPER_UDE_UINT64 clientNonce = 0; + DWORD returned = 0; + const AbiCompatibilityProfile* selectedProfile = nullptr; + for (size_t index = 0; index < profileCount; ++index) { + response = VIIPER_UDE_NEGOTIATE_RESPONSE{}; + clientNonce = 0; + returned = 0; + if (IssueAbiNegotiation(device.get(), deadlineUnixMs, profiles[index].minor, + profiles[index].capabilities, &response, &clientNonce, &returned, error)) { + selectedProfile = &profiles[index]; + break; + } + if (index + 1 == profileCount || + !IsAbiRetryEligible(purpose, expectedBuildIdentity, *error)) { + return false; + } + *error = Error{}; + } + if (selectedProfile == nullptr) { + return SetError(error, L"abi-negotiate", ERROR_REVISION_MISMATCH, + L"no compatible native driver ABI profile was negotiated"); + } + + std::string loadedBuildIdentity; + loadedBuildIdentity.reserve(VIIPER_UDE_BUILD_IDENTITY_BYTES * 2); + static constexpr char digits[] = "0123456789abcdef"; + for (VIIPER_UDE_UINT8 byte : response.BuildIdentity) { + loadedBuildIdentity.push_back(digits[byte >> 4U]); + loadedBuildIdentity.push_back(digits[byte & 0x0fU]); + } + if (!AbiNegotiationResponseMatchesProfile( + response, returned, clientNonce, *selectedProfile) || + (expectedBuildIdentity != nullptr && + loadedBuildIdentity != *expectedBuildIdentity)) { + return SetError(error, L"abi-negotiate", ERROR_REVISION_MISMATCH, + L"loaded driver health response does not match the source-bound package identity"); + } + if (requirePristineRuntime) { + VIIPER_UDE_STATS stats{}; + DWORD statsReturned = 0; + WinHandle statsEvent(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + if (!statsEvent) { + return SetLastErrorDetail(error, L"upgrade-pristine-stats-event"); + } + OVERLAPPED statsOverlapped{}; + statsOverlapped.hEvent = statsEvent.get(); + const BOOL statsCompleted = DeviceIoControl( + device.get(), IOCTL_VIIPER_UDE_QUERY_STATS, + nullptr, 0, &stats, sizeof(stats), &statsReturned, &statsOverlapped); + if (!statsCompleted && GetLastError() != ERROR_IO_PENDING) { + return SetLastErrorDetail(error, L"upgrade-pristine-stats"); + } + if (!statsCompleted) { + const uint64_t now = CurrentUnixMilliseconds(); + if (deadlineUnixMs <= now) { + const BOOL cancelled = CancelIoEx(device.get(), &statsOverlapped); + const DWORD cancelError = cancelled ? ERROR_SUCCESS : GetLastError(); + const DWORD drain = WaitForSingleObject(statsEvent.get(), kCancelledIoDrainMs); + if ((!cancelled && cancelError != ERROR_NOT_FOUND) || drain != WAIT_OBJECT_0) { + return SetError(error, L"upgrade-pristine-stats-drain", + !cancelled && cancelError != ERROR_NOT_FOUND + ? cancelError : ERROR_OPERATION_ABORTED, + L"expired pristine-runtime query could not be cancelled and drained safely"); + } + DWORD ignored = 0; + GetOverlappedResult(device.get(), &statsOverlapped, &ignored, FALSE); + return SetError(error, L"upgrade-pristine-stats-timeout", ERROR_TIMEOUT, + L"pristine-runtime query exceeded the package transaction deadline"); + } + const uint64_t remaining = deadlineUnixMs - now; + const DWORD waitMilliseconds = static_cast( + std::min(remaining, static_cast(MAXDWORD - 1))); + const DWORD wait = WaitForSingleObject(statsEvent.get(), waitMilliseconds); + if (wait == WAIT_TIMEOUT) { + const BOOL cancelled = CancelIoEx(device.get(), &statsOverlapped); + const DWORD cancelError = cancelled ? ERROR_SUCCESS : GetLastError(); + const DWORD drain = WaitForSingleObject(statsEvent.get(), kCancelledIoDrainMs); + if (drain == WAIT_OBJECT_0) { + DWORD ignored = 0; + GetOverlappedResult(device.get(), &statsOverlapped, &ignored, FALSE); + } + if (!cancelled && cancelError != ERROR_NOT_FOUND) { + return SetError(error, L"upgrade-pristine-stats-cancel", cancelError, + L"timed-out pristine-runtime query could not be cancelled"); + } + if (drain != WAIT_OBJECT_0) { + return SetError(error, L"upgrade-pristine-stats-drain", + ERROR_OPERATION_ABORTED, + L"timed-out pristine-runtime query did not drain safely"); + } + return SetError(error, L"upgrade-pristine-stats-timeout", ERROR_TIMEOUT, + L"pristine-runtime query exceeded the package transaction deadline"); + } + if (wait != WAIT_OBJECT_0) { + const DWORD waitError = GetLastError(); + CancelIoEx(device.get(), &statsOverlapped); + const DWORD drain = WaitForSingleObject(statsEvent.get(), kCancelledIoDrainMs); + if (drain != WAIT_OBJECT_0) { + return SetError(error, L"upgrade-pristine-stats-drain", + ERROR_OPERATION_ABORTED, + L"failed pristine-runtime wait could not be drained safely"); + } + SetLastError(waitError); + return SetLastErrorDetail(error, L"upgrade-pristine-stats-wait"); + } + if (!GetOverlappedResult( + device.get(), &statsOverlapped, &statsReturned, FALSE)) { + return SetLastErrorDetail(error, L"upgrade-pristine-stats-result"); + } + } + if (!StatsRecordMatchesProfile(stats, statsReturned, *selectedProfile)) { + return SetError(error, L"upgrade-pristine-stats", ERROR_REVISION_MISMATCH, + L"loaded driver returned an invalid pristine-runtime statistics record"); + } + if (!RuntimeStatsArePristine(stats, *selectedProfile)) { + return SetError(error, L"upgrade-runtime-reboot-boundary", + ERROR_SUCCESS_REBOOT_REQUIRED, + L"the loaded native bus has serviced virtual-device work since boot; restart Windows and rerun the identical package command before creating another virtual device"); + } + } + if (negotiatedProfile != nullptr) { + *negotiatedProfile = *selectedProfile; + } + return true; +} + +bool VerifyInstalledBinding( + const PackageInfo& candidate, + const std::wstring& publishedName, + bool allowStopped, + Error* error) { + Snapshot snapshot; + if (!CaptureSnapshot(&snapshot, error)) { + return false; + } + if (snapshot.devices.size() != 1 || !snapshot.devices[0].present || + _wcsicmp(snapshot.devices[0].publishedInf.c_str(), publishedName.c_str()) != 0 || + !(snapshot.devices[0].version == candidate.version) || + !SamePackageBytes(snapshot.devices[0].package, candidate)) { + return SetError(error, L"install-verification", ERROR_REVISION_MISMATCH, + L"installed devnode is not bound to the exact candidate package"); + } + if (!allowStopped && !snapshot.devices[0].started) { + return SetError(error, L"install-start", ERROR_DEVICE_NOT_AVAILABLE, + L"installed driver did not start; problem=" + std::to_wstring(snapshot.devices[0].problem)); + } + return true; +} + +bool VerifyInstalled( + const PackageInfo& candidate, + const std::wstring& publishedName, + bool allowStopped, + uint64_t healthDeadlineUnixMs, + const std::string* expectedBuildIdentity, + Error* error) { + return VerifyInstalledBinding(candidate, publishedName, allowStopped, error) && + (allowStopped || VerifyAbiHealth( + healthDeadlineUnixMs, expectedBuildIdentity, error)); +} + +bool UninstallPackage(const PackageInfo& package, bool* rebootRequired, Error* error) { + BOOL reboot = FALSE; + MarkTransactionMutationStarted(); + if (!DiUninstallDriverW(nullptr, package.infPath.c_str(), 0, &reboot)) { + return SetLastErrorDetail(error, L"remove-driver-package"); + } + *rebootRequired = *rebootRequired || reboot != FALSE; + return true; +} + +bool SamePackageInventory( + const std::vector& left, + const std::vector& right) noexcept { + if (left.size() != right.size()) { + return false; + } + for (size_t index = 0; index < left.size(); ++index) { + if (_wcsicmp(left[index].publishedName.c_str(), + right[index].publishedName.c_str()) != 0 || + !(left[index].version == right[index].version) || + !SamePackageBytes(left[index], right[index])) { + return false; + } + } + return true; +} + +bool ContainsExactPackage( + const std::vector& packages, + const PackageInfo& candidate) noexcept { + return std::any_of(packages.begin(), packages.end(), + [&](const PackageInfo& package) { + return package.version == candidate.version && + SamePackageBytes(package, candidate); + }); +} + +bool VerifyPackageInventory( + const std::vector& expected, + const wchar_t* phase, + Error* error) { + std::vector observed; + if (!EnumerateOwnedPackages(&observed, error)) { + return false; + } + if (!SamePackageInventory(expected, observed)) { + return SetError(error, phase, ERROR_REVISION_MISMATCH, + L"the exact captured Driver Store inventory changed during the protected transaction"); + } + return true; +} + +bool RemoveStagedCandidateExact( + const PackageInfo& stagedCandidate, + uint64_t rollbackDeadlineUnixMs, + Error* error) { + if (!IsSafePublishedInfName(stagedCandidate.publishedName)) { + return SetError(error, L"rollback-staged-package-identity", ERROR_INVALID_NAME, + L"the staged-here candidate lacks a safe exact published INF identity"); + } + std::vector current; + if (!EnumerateOwnedPackages(¤t, error)) { + return false; + } + size_t matches = 0; + for (const PackageInfo& package : current) { + if (_wcsicmp(package.publishedName.c_str(), + stagedCandidate.publishedName.c_str()) == 0) { + if (!(package.version == stagedCandidate.version) || + !SamePackageBytes(package, stagedCandidate)) { + return SetError(error, L"rollback-staged-package-identity", + ERROR_REVISION_MISMATCH, + L"the staged-here published INF no longer matches the exact candidate"); + } + ++matches; + } + } + if (matches != 1) { + return SetError(error, L"rollback-staged-package-identity", + matches == 0 ? ERROR_NOT_FOUND : ERROR_DUPLICATE_SERVICE_NAME, + L"rollback requires exactly one matching staged-here published INF"); + } + Snapshot topology; + if (!CaptureSnapshot(&topology, error)) { + return false; + } + for (const DeviceState& device : topology.devices) { + if (_wcsicmp(device.publishedInf.c_str(), + stagedCandidate.publishedName.c_str()) == 0) { + return SetError(error, L"rollback-staged-package-in-use", + ERROR_DEVICE_IN_USE, + L"rollback refuses to remove a staged-here package still bound to a root device"); + } + } + if (!CheckTransactionDeadline(rollbackDeadlineUnixMs, + L"install-rollback-deadline-staged-package", error)) { + return false; + } + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::SetupUninstallEntered, + true, ERROR_SUCCESS, false, error)) { + return false; + } + MarkTransactionMutationStarted(); + const BOOL removed = InvokeAuthoritativeSynchronousMutation( + rollbackDeadlineUnixMs, L"SetupUninstallOEMInfW", [&]() { + return SetupUninstallOEMInfW( + stagedCandidate.publishedName.c_str(), 0, nullptr); + }); + const DWORD removeError = removed ? ERROR_SUCCESS : GetLastError(); + Error journalReturnError; + const bool journalReturnRecorded = RecordActiveInstallJournalCutpoint( + InstallJournalPhase::SetupUninstallReturned, + removed != FALSE, removeError, gLastSynchronousMutationTimedOut, + &journalReturnError); + if (!removed) { + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } + return SetError(error, L"rollback-staged-package-remove", removeError, + L"the exact unbound staged-here candidate could not be removed"); + } + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } + if (gLastSynchronousMutationTimedOut) { + return SetError(error, L"rollback-staged-package-remove-timeout", + ERROR_TIMEOUT, + L"SetupUninstallOEMInfW exceeded the rollback deadline; its authoritative return was retained"); + } + return true; +} + +enum class RestorePriorBindingPolicy { + InstallRollbackReconcile, + RemoveJournalExactAbsence, +}; + +bool RestorePriorBindingTopologyAdmitsMutation( + RestorePriorBindingPolicy policy, + size_t priorDeviceCount, + size_t currentDeviceCount) noexcept { + if (policy == RestorePriorBindingPolicy::RemoveJournalExactAbsence) { + return priorDeviceCount == 1U && currentDeviceCount == 0U; + } + return priorDeviceCount <= 1U && currentDeviceCount <= 1U; +} + +bool RestorePriorBinding( + const Snapshot& prior, + RestorePriorBindingPolicy policy, + uint64_t transactionDeadlineUnixMs, + bool* rebootRequired, + Error* error) { + if (prior.devices.size() > 1) { + return SetError(error, L"rollback-topology", ERROR_DUPLICATE_SERVICE_NAME, + L"rollback refuses an unsupported multi-devnode native topology"); + } + + Snapshot current; + if (!CaptureSnapshot(¤t, error) || current.devices.size() > 1) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"rollback-topology", ERROR_DUPLICATE_SERVICE_NAME, + L"rollback observed an unexpected multi-devnode native topology"); + } + return false; + } + if (!RestorePriorBindingTopologyAdmitsMutation( + policy, prior.devices.size(), current.devices.size())) { + return SetError(error, + policy == RestorePriorBindingPolicy::RemoveJournalExactAbsence + ? L"remove-rollback-exact-absence-raced" + : L"rollback-topology", + current.devices.empty() + ? ERROR_INVALID_DATA + : ERROR_REVISION_MISMATCH, + policy == RestorePriorBindingPolicy::RemoveJournalExactAbsence + ? L"remove rollback requires one captured prior root and a fresh exact-absence observation; a concurrent root forbids all binding mutation" + : L"rollback observed an unsupported native topology"); + } + const auto sameIdentity = [](const std::wstring& left, const std::wstring& right) { + return _wcsicmp(left.c_str(), right.c_str()) == 0; + }; + const bool keepCurrent = !prior.devices.empty() && !current.devices.empty() && + sameIdentity(prior.devices[0].instanceId, current.devices[0].instanceId); + + if (policy == RestorePriorBindingPolicy::InstallRollbackReconcile && + !current.devices.empty() && !keepCurrent) { + DeviceInfoSet set = OpenRootDevices(); + if (!set) { + return SetLastErrorDetail(error, L"rollback-open-root-devices"); + } + std::vector> matches; + if (!FindExactDevices(set.get(), &matches, error) || matches.size() != 1) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"rollback-topology", ERROR_REVISION_MISMATCH); + } + return false; + } + bool removalReboot = false; + if (!RemoveDevice(set.get(), matches[0].first, transactionDeadlineUnixMs, + L"rollback-deadline-before-device-removal", nullptr, + &removalReboot, error)) { + return false; + } + *rebootRequired = *rebootRequired || removalReboot; + } + if (prior.devices.empty()) { + Snapshot restored; + if (!CaptureSnapshot(&restored, error) || !restored.devices.empty()) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"rollback-identity-verification", ERROR_REVISION_MISMATCH, + L"rollback did not restore the captured empty devnode topology"); + } + return false; + } + return true; + } + + const DeviceState& expected = prior.devices[0]; + const PackageInfo& package = expected.package; + DeviceInfoSet target; + SP_DEVINFO_DATA targetData{}; + targetData.cbSize = sizeof(targetData); + if (!keepCurrent) { + GUID classGuid{}; + wchar_t className[MAX_CLASS_NAME_LEN]{}; + if (!SetupDiGetINFClassW(package.infPath.c_str(), &classGuid, className, + MAX_CLASS_NAME_LEN, nullptr)) { + return SetLastErrorDetail(error, L"rollback-inf-class"); + } + if (!RegisterRootDeviceExact(classGuid, expected.instanceId, + transactionDeadlineUnixMs, nullptr, nullptr, + &target, &targetData, error)) { + return false; + } + } else { + target = OpenRootDevices(); + if (!target) { + return SetLastErrorDetail(error, L"rollback-open-retained-root-device"); + } + std::vector> matches; + if (!FindExactDevices(target.get(), &matches, error) || matches.size() != 1 || + !sameIdentity(matches[0].second.instanceId, expected.instanceId)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"rollback-retained-root-identity", ERROR_REVISION_MISMATCH); + } + return false; + } + targetData = matches[0].first; + } + if (!InstallPreinstalledDriverOnDevice( + target.get(), &targetData, package, transactionDeadlineUnixMs, nullptr, + rebootRequired, error)) { + return false; + } + + Snapshot restored; + if (!CaptureSnapshot(&restored, error) || restored.devices.size() != 1 || + !sameIdentity(restored.devices[0].instanceId, expected.instanceId) || + !SamePackageBytes(restored.devices[0].package, expected.package)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"rollback-identity-verification", ERROR_REVISION_MISMATCH, + L"rollback did not restore the exact captured devnode identity and package binding"); + } + return false; + } + return true; +} + +bool RollbackInstall( + const Snapshot& prior, + const PackageInfo* stagedHereCandidate, + bool bindingMutationStarted, + const AbiCompatibilityProfile* priorAbiProfile, + uint64_t rollbackDeadlineUnixMs, + bool* rebootRequired, + Error* error) { + if (!CheckTransactionDeadline(rollbackDeadlineUnixMs, + L"install-rollback-deadline-root", error)) { + return false; + } + if (bindingMutationStarted) { + if (!RestorePriorBinding(prior, + RestorePriorBindingPolicy::InstallRollbackReconcile, + rollbackDeadlineUnixMs, rebootRequired, error)) { + return false; + } + } else if (!CaptureAndVerifyRootUnchanged( + prior, L"rollback-stage-root-invariance", nullptr, error)) { + return false; + } + if (stagedHereCandidate != nullptr && + !RemoveStagedCandidateExact( + *stagedHereCandidate, rollbackDeadlineUnixMs, error)) { + return false; + } + if (!CheckTransactionDeadline(rollbackDeadlineUnixMs, + L"install-rollback-deadline-inventory", error)) { + return false; + } + if (!VerifyPackageInventory( + prior.packages, L"rollback-package-inventory", error)) { + return false; + } + if (bindingMutationStarted && !prior.devices.empty() && !*rebootRequired) { + Snapshot restored; + if (!CaptureSnapshot(&restored, error) || restored.devices.size() != 1 || + !SameRootBinding(prior.devices[0], restored.devices[0])) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"rollback-runtime-binding-verification", + ERROR_REVISION_MISMATCH, + L"rollback did not preserve the exact captured root and package identity"); + } + return false; + } + if (prior.devices[0].started) { + if (!RollbackLifecycleStateMatches( + prior.devices[0], restored.devices[0])) { + return SetError(error, L"rollback-runtime-start-verification", + ERROR_DEVICE_NOT_AVAILABLE, + L"rollback did not restore the formerly-running root to started/problem-zero state"); + } + if (priorAbiProfile == nullptr) { + return SetError(error, L"rollback-runtime-abi-profile", + ERROR_REVISION_MISMATCH, + L"rollback lacks the exact known-compatible ABI profile captured before binding"); + } + return VerifyAbiHealth( + rollbackDeadlineUnixMs, nullptr, error, + AbiHealthPurpose::RollbackHealth, priorAbiProfile, nullptr); + } + if (!RollbackLifecycleStateMatches( + prior.devices[0], restored.devices[0])) { + return SetError(error, L"rollback-stopped-state-verification", + ERROR_DEVICE_NOT_AVAILABLE, + L"rollback changed the captured stopped/problem lifecycle state"); + } + } + return true; +} + +bool LockPackageFiles( + const std::filesystem::path& directory, + std::vector* locks, + Error* error) { + locks->clear(); + for (const wchar_t* name : {L"ViiperUde.inf", L"ViiperUde.sys", L"ViiperUde.cat"}) { + WinHandle file(CreateFileW((directory / name).c_str(), GENERIC_READ, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, nullptr)); + if (!file) { + return SetLastErrorDetail(error, L"package-lock", + L"INF, SYS, and CAT must exist and remain immutable during installation"); + } + FILE_ATTRIBUTE_TAG_INFO attributes{}; + if (!GetFileInformationByHandleEx(file.get(), FileAttributeTagInfo, + &attributes, sizeof(attributes)) || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { + return SetError(error, L"package-lock", ERROR_REPARSE_TAG_MISMATCH, + L"package inputs must be regular non-reparse files"); + } + locks->push_back(std::move(file)); + } + return true; +} + +bool ValidateExactPackageDirectory(const std::filesystem::path& directory, Error* error) { + static const std::set expected = { + L"ViiperUde.inf", L"ViiperUde.sys", L"ViiperUde.cat"}; + const DWORD attributes = GetFileAttributesW(directory.c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES || + (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 || + (attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + return SetError(error, L"package-directory", ERROR_REPARSE_TAG_MISMATCH, + L"signed package path must be a regular non-reparse directory"); + } + std::set seen; + std::error_code enumerationError; + for (std::filesystem::directory_iterator iterator(directory, enumerationError), end; + !enumerationError && iterator != end; iterator.increment(enumerationError)) { + std::error_code typeError; + if (!iterator->is_regular_file(typeError) || typeError || + !expected.contains(iterator->path().filename().wstring()) || + !seen.insert(iterator->path().filename().wstring()).second) { + return SetError(error, L"package-directory", ERROR_INVALID_DATA, + L"signed runtime package directory must contain only INF, SYS, and CAT"); + } + } + if (enumerationError || seen != expected) { + return SetError(error, L"package-directory", ERROR_INVALID_DATA, + L"signed package directory changed or is incomplete"); + } + return true; +} + +struct InstallOptions { + std::filesystem::path infPath; + std::filesystem::path manifestPath; + std::string manifestSha256; + std::string sourceRevision; + std::string expectedInfSha256; + std::string expectedSysSha256; + std::string expectedCatSha256; + bool production = true; + bool localTest = false; + std::optional expectedDowngradeFrom; + std::filesystem::path brokerExecutable; + std::string brokerSha256; + std::filesystem::path brokerToken; + std::string brokerTokenSha256; + std::wstring targetUserSid; + uint64_t transactionDeadlineUnixMs = 0; + HANDLE brokerQuiesceRequest = nullptr; + HANDLE brokerQuiesceReady = nullptr; + HANDLE brokerQuiesceAbort = nullptr; + HANDLE brokerHandoff = nullptr; +}; + +struct BrokerCommitProof; +struct InstallJournalStateData; + +class InstallJournal final { +public: + InstallJournal(); + ~InstallJournal(); + InstallJournal(const InstallJournal&) = delete; + InstallJournal& operator=(const InstallJournal&) = delete; + + bool Prepare( + const Snapshot& prior, + const PackageInfo& candidate, + const std::filesystem::path& candidateDirectory, + const std::vector& expectedInventory, + const InstallOptions& options, + Error* error); + bool Record( + InstallJournalPhase phase, + const PackageInfo* publishedCandidate, + bool packageStagedHere, + bool bindingMutationStarted, + bool rebootRequired, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + Error* error); + bool RecordCutpoint( + InstallJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + bool rebootRequired, + bool freshRebootRequired, + Error* error); + bool RecordAuthoritativeReturn( + InstallJournalPhase phase, + const PackageInfo* publishedCandidate, + bool packageStagedHere, + bool bindingMutationStarted, + bool rebootRequired, + bool freshRebootRequired, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + Error* error); + bool RecordPriorAbiProfile( + const AbiCompatibilityProfile& profile, + const PackageInfo& publishedCandidate, + bool packageStagedHere, + Error* error); + bool RecordRootRegistrationIntent( + const std::wstring& instanceId, + Error* error); + bool RecordBrokerProof( + const BrokerCommitProof& proof, + Error* error); + bool RecordRollbackAuthorization( + InstallJournalPhase phase, + DWORD callError, + Error* error); + bool RetireAfterForwardValidation( + const PackageInfo& candidate, + const std::wstring& publishedName, + bool rebootRequired, + uint64_t deadlineUnixMs, + BrokerJournalBinding* binding, + std::string_view recovery, + Error* error); + bool RetireAfterPriorValidation( + bool rebootRequired, + Error* error); + bool RemoveAuthorizedPriorEmptyRootAfterAdmission( + uint64_t rollbackDeadlineUnixMs, + bool* rebootRequired, + bool* rootRemovalRebootPending, + Error* error); + bool VerifyPriorTopologyBeforePackageRollback(Error* error) const; + void AttachEvidence(Error* error) const; + +private: + bool RecordNext( + InstallJournalStateData next, + InstallJournalPhase phase, + const PackageInfo* publishedCandidate, + bool packageStagedHere, + bool bindingMutationStarted, + bool rebootRequired, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + bool freshRebootRequired, + Error* error); + struct Impl; + std::unique_ptr impl_; +}; + +InstallJournal* gActiveInstallJournal = nullptr; + +class ActiveInstallJournalScope final { +public: + explicit ActiveInstallJournalScope(InstallJournal* journal) noexcept + : prior_(gActiveInstallJournal) { + gActiveInstallJournal = journal; + } + ~ActiveInstallJournalScope() { + gActiveInstallJournal = prior_; + } + ActiveInstallJournalScope(const ActiveInstallJournalScope&) = delete; + ActiveInstallJournalScope& operator=(const ActiveInstallJournalScope&) = delete; + +private: + InstallJournal* prior_; +}; + +bool ReconcileInstallJournal( + bool explicitRecovery, + uint64_t deadlineUnixMs, + Outcome* outcome); + +bool ReconcileSettledBrokerOuterSettlement( + uint64_t deadlineUnixMs, + bool* handled, + Outcome* outcome, + Error* error); + +bool ReconcileRemoveJournal( + bool explicitRecovery, + uint64_t deadlineUnixMs, + Outcome* outcome); + +uint64_t CurrentUnixMilliseconds() { + FILETIME now{}; + GetSystemTimeAsFileTime(&now); + ULARGE_INTEGER ticks{}; + ticks.LowPart = now.dwLowDateTime; + ticks.HighPart = now.dwHighDateTime; + constexpr uint64_t windowsToUnixEpochTicks = 116444736000000000ULL; + return ticks.QuadPart <= windowsToUnixEpochTicks + ? 0 : (ticks.QuadPart - windowsToUnixEpochTicks) / 10000ULL; +} + +uint64_t SaturatingDeadlineAfter( + uint64_t nowUnixMs, + uint64_t durationMs) noexcept { + const uint64_t maximum = std::numeric_limits::max(); + return nowUnixMs > maximum - durationMs + ? maximum : nowUnixMs + durationMs; +} + +uint64_t FreshRemoveRollbackDeadline() { + return SaturatingDeadlineAfter( + CurrentUnixMilliseconds(), kDriverRollbackCeilingMs); +} + +bool CheckTransactionDeadline(const InstallOptions& options, const wchar_t* phase, Error* error) { + if (options.transactionDeadlineUnixMs == 0 || + CurrentUnixMilliseconds() >= options.transactionDeadlineUnixMs) { + return SetError(error, phase, ERROR_TIMEOUT, + L"native package transaction deadline expired before the next mutation"); + } + return true; +} + +bool CheckTransactionDeadline(uint64_t deadlineUnixMs, const wchar_t* phase, Error* error) { + if (deadlineUnixMs == 0 || CurrentUnixMilliseconds() >= deadlineUnixMs) { + return SetError(error, phase, ERROR_TIMEOUT, + L"native package transaction deadline expired before the next mutation"); + } + return true; +} + +bool ValidateTransactionDeadlineBudget(const InstallOptions& options, Error* error) { + const uint64_t now = CurrentUnixMilliseconds(); + if (options.transactionDeadlineUnixMs <= now || + options.transactionDeadlineUnixMs - now > kMaximumTransactionDurationMs) { + return SetError(error, L"transaction-deadline", ERROR_INVALID_PARAMETER, + L"transaction deadline is expired or exceeds the four-minute package budget"); + } + return true; +} + +bool RequestBrokerQuiescence(const InstallOptions& options, Error* error) { + if (options.brokerQuiesceRequest == nullptr || + options.brokerQuiesceReady == nullptr || + options.brokerQuiesceAbort == nullptr) { + return SetError(error, L"broker-quiescence-handles", ERROR_INVALID_HANDLE, + L"driver mutation requires the inherited broker quiescence handshake"); + } + if (!CheckTransactionDeadline( + options, L"transaction-deadline-before-broker-quiescence", error)) { + return false; + } + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::QuiesceSignalEntered, + true, ERROR_SUCCESS, false, error)) { + return false; + } + if (!SetEvent(options.brokerQuiesceRequest)) { + const DWORD code = GetLastError(); + Error journalError; + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::QuiesceSignalReturned, + false, code, false, &journalError)) { + *error = std::move(journalError); + return false; + } + return SetError(error, L"broker-quiescence-request", code); + } + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::QuiesceSignalReturned, + true, ERROR_SUCCESS, false, error)) { + return false; + } + const std::array responses{ + options.brokerQuiesceReady, options.brokerQuiesceAbort, + }; + for (;;) { + const uint64_t now = CurrentUnixMilliseconds(); + if (now >= options.transactionDeadlineUnixMs) { + return SetError(error, L"broker-quiescence-timeout", ERROR_TIMEOUT, + L"native broker did not prove quiescence before the package deadline"); + } + const DWORD waitMilliseconds = static_cast(std::min( + options.transactionDeadlineUnixMs - now, + std::numeric_limits::max() - 1ULL)); + const DWORD wait = WaitForMultipleObjects( + static_cast(responses.size()), responses.data(), FALSE, waitMilliseconds); + if (wait == WAIT_OBJECT_0) { + return true; + } + if (wait == WAIT_OBJECT_0 + 1) { + return SetError(error, L"broker-quiescence-aborted", ERROR_OPERATION_ABORTED, + L"the outer package transaction could not safely quiesce the native broker service"); + } + if (wait == WAIT_TIMEOUT) { + continue; + } + if (wait == WAIT_FAILED) { + return SetLastErrorDetail(error, L"broker-quiescence-wait"); + } + return SetError(error, L"broker-quiescence-wait", ERROR_INVALID_HANDLE, + L"broker quiescence wait returned an unexpected event state"); + } +} + +bool SignalBrokerHandoff(const InstallOptions& options, Error* error) { + if (options.brokerHandoff == nullptr) { + return SetError(error, L"broker-handoff-handle", ERROR_INVALID_HANDLE, + L"authenticated broker commit requires the inherited service-lock handoff"); + } + if (!CheckTransactionDeadline( + options, L"transaction-deadline-before-broker-handoff", error) || + !RecordActiveInstallJournalCutpoint( + InstallJournalPhase::BrokerHandoffEntered, + true, ERROR_SUCCESS, false, error)) { + return false; + } + if (!SetEvent(options.brokerHandoff)) { + const DWORD code = GetLastError(); + Error journalError; + if (!RecordActiveInstallJournalRollbackAuthorization( + InstallJournalPhase::BrokerHandoffReturned, + code, &journalError)) { + *error = std::move(journalError); + return false; + } + return SetError(error, L"broker-handoff-signal", code); + } + return RecordActiveInstallJournalCutpoint( + InstallJournalPhase::BrokerHandoffReturned, + true, ERROR_SUCCESS, false, error); +} + +bool ValidateTransactionDeadlineBudget(uint64_t deadlineUnixMs, Error* error) { + const uint64_t now = CurrentUnixMilliseconds(); + if (deadlineUnixMs <= now || deadlineUnixMs - now > kMaximumTransactionDurationMs) { + return SetError(error, L"transaction-deadline", ERROR_INVALID_PARAMETER, + L"transaction deadline is expired or exceeds the four-minute package budget"); + } + return true; +} + +bool ValidateCandidateInputs( + const InstallOptions& options, + std::filesystem::path* packageDirectory, + std::vector* packageLocks, + PackageInfo* candidate, + Error* error) { + if (!ValidateTransactionDeadlineBudget(options, error)) { + return false; + } + std::error_code candidatePathError; + const std::filesystem::path lockedInfPath = + std::filesystem::canonical(options.infPath, candidatePathError); + if (candidatePathError || lockedInfPath.filename().wstring() != L"ViiperUde.inf") { + return SetError(error, L"package-path", ERROR_FILE_NOT_FOUND); + } + *packageDirectory = lockedInfPath.parent_path(); + if (!ValidateExactPackageDirectory(*packageDirectory, error) || + !LockPackageFiles(*packageDirectory, packageLocks, error)) { + return false; + } + bool owned = false; + if (!LoadOwnedPackage(lockedInfPath, true, options.localTest, + candidate, &owned, error) || !owned || + (options.production && !VerifyMicrosoftHardwareInfSigner(lockedInfPath, error))) { + return false; + } + if (_stricmp(candidate->infSha256.c_str(), options.expectedInfSha256.c_str()) != 0 || + _stricmp(candidate->sysSha256.c_str(), options.expectedSysSha256.c_str()) != 0 || + _stricmp(candidate->catSha256.c_str(), options.expectedCatSha256.c_str()) != 0) { + return SetError(error, L"package-runtime-hash", ERROR_CRC, + L"INF, SYS, or CAT does not match the installer-reviewed runtime package bytes"); + } + WinHandle manifest(CreateFileW(options.manifestPath.c_str(), GENERIC_READ, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, nullptr)); + if (!manifest) { + error->phase = L"manifest-installer-open"; + return SetLastErrorDetail(error, L"manifest-installer-open"); + } + FILE_ATTRIBUTE_TAG_INFO manifestAttributes{}; + if (!GetFileInformationByHandleEx(manifest.get(), FileAttributeTagInfo, + &manifestAttributes, sizeof(manifestAttributes)) || + (manifestAttributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { + return SetError(error, L"manifest-installer-open", ERROR_REPARSE_TAG_MISMATCH, + L"source-bound manifest must be a regular non-reparse file"); + } + std::string actualManifestSha256; + if (!Sha256Handle(manifest.get(), &actualManifestSha256, error)) { + error->phase = L"manifest-installer-hash"; + return false; + } + if (_stricmp(actualManifestSha256.c_str(), options.manifestSha256.c_str()) != 0) { + return SetError(error, L"manifest-installer-hash", ERROR_CRC, + L"source-bound manifest does not match the installer-embedded SHA-256"); + } + std::string manifestContents; + if (!ReadSmallHandle(manifest.get(), &manifestContents, error)) { + return false; + } + return ValidateManifest(manifestContents, options.sourceRevision, options.production, + options.localTest, + *packageDirectory, error) && + CheckTransactionDeadline(options, L"transaction-deadline-preflight", error); +} + +Outcome Verify(const InstallOptions& options) { + Outcome outcome; + std::filesystem::path packageDirectory; + std::vector packageLocks; + PackageInfo candidate; + if (!ValidateCandidateInputs( + options, &packageDirectory, &packageLocks, &candidate, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + outcome.success = true; + outcome.exitCode = ExitCode::Success; + return outcome; +} + +bool IsSafeTargetUserSid(const std::wstring& sid) { + return sid.size() >= 5 && sid.size() <= 184 && + (sid.starts_with(L"S-") || sid.starts_with(L"s-")) && + std::all_of(sid.begin() + 2, sid.end(), [](wchar_t value) { + return (value >= L'0' && value <= L'9') || value == L'-'; + }); +} + +std::wstring QuoteWindowsArgument(const std::wstring& value) { + std::wstring quoted(1, L'"'); + size_t backslashes = 0; + for (const wchar_t character : value) { + if (character == L'\\') { + ++backslashes; + continue; + } + if (character == L'"') { + quoted.append(backslashes * 2 + 1, L'\\'); + quoted.push_back(L'"'); + backslashes = 0; + continue; + } + quoted.append(backslashes, L'\\'); + backslashes = 0; + quoted.push_back(character); + } + quoted.append(backslashes * 2, L'\\'); + quoted.push_back(L'"'); + return quoted; +} + +std::wstring BuildBrokerCommitCommandLine( + const InstallOptions& options, + bool recoveryOnly = false) { + return QuoteWindowsArgument(options.brokerExecutable.wstring()) + + L" native-package-broker-commit --token-file " + + QuoteWindowsArgument(options.brokerToken.wstring()) + + L" --expected-token-sha-256 " + + QuoteWindowsArgument(std::wstring( + options.brokerTokenSha256.begin(), options.brokerTokenSha256.end())) + + L" --expected-broker-sha-256 " + + QuoteWindowsArgument(std::wstring( + options.brokerSha256.begin(), options.brokerSha256.end())) + + L" --target-user-sid " + + QuoteWindowsArgument(options.targetUserSid) + + L" --transaction-deadline-unix-ms " + + QuoteWindowsArgument(std::to_wstring(options.transactionDeadlineUnixMs)) + + (recoveryOnly ? L" --recovery-only" : L""); +} + +struct BrokerCommitProof { + bool success = false; + bool changed = false; + std::string rollback; + DWORD exitCode = ERROR_GEN_FAILURE; + bool driverRollbackAuthorized = false; + std::wstring diagnostic; + bool hasJournalProof = false; + std::string journalTransactionId; + std::string journalOuterTransactionId; + std::string journalCandidateSha256; + std::string journalState; + std::string journalDigest; +}; + +bool IsCanonicalLowerHex(std::string_view value, size_t length) noexcept { + return value.size() == length && + std::all_of(value.begin(), value.end(), [](unsigned char character) { + return (character >= '0' && character <= '9') || + (character >= 'a' && character <= 'f'); + }); +} + +bool ParseBrokerJournalProofLine( + const std::string& line, + BrokerCommitProof* proof) { + std::istringstream stream(line); + std::array fields{}; + for (std::string& field : fields) { + if (!(stream >> field)) return false; + } + std::string extra; + if (stream >> extra || fields[0] != "journal-proof" || + fields[1] != "operation=native-package-broker-commit") { + return false; + } + const auto value = [&](size_t index, std::string_view prefix) { + return fields[index].starts_with(prefix) + ? fields[index].substr(prefix.size()) : std::string{}; + }; + BrokerCommitProof parsed = *proof; + parsed.journalTransactionId = value(2, "transactionId="); + parsed.journalOuterTransactionId = value(3, "outerTransactionId="); + parsed.journalCandidateSha256 = value(4, "candidateSha256="); + parsed.journalState = value(5, "state="); + parsed.journalDigest = value(6, "digest="); + std::string canonical = + "journal-proof operation=native-package-broker-commit transactionId=" + + parsed.journalTransactionId + " outerTransactionId=" + + parsed.journalOuterTransactionId + " candidateSha256=" + + parsed.journalCandidateSha256 + " state=" + parsed.journalState + + " digest=" + parsed.journalDigest; + if (canonical != line || + !IsCanonicalLowerHex(parsed.journalTransactionId, 32U) || + !IsCanonicalLowerHex(parsed.journalOuterTransactionId, 64U) || + !IsCanonicalLowerHex(parsed.journalCandidateSha256, 64U) || + !IsCanonicalLowerHex(parsed.journalDigest, 64U) || + (parsed.journalState != "nested-ready" && + parsed.journalState != "rollback-settled" && + parsed.journalState != "manual")) { + return false; + } + parsed.hasJournalProof = true; + *proof = std::move(parsed); + return true; +} + +bool BrokerProofFieldsAreCanonical( + bool success, + bool changed, + std::string_view rollback, + DWORD exitCode, + bool driverRollbackAuthorized) noexcept { + return (success && !driverRollbackAuthorized && + rollback == "not-needed" && exitCode == 0U) || + (!success && !changed && driverRollbackAuthorized && + rollback == "not-needed" && exitCode == 4U) || + (!success && changed && driverRollbackAuthorized && + rollback == "succeeded" && exitCode == 1U) || + (!success && changed && !driverRollbackAuthorized && + rollback == "failed" && exitCode == 3U); +} + +bool IsUnsafeBrokerDiagnosticCharacter(wchar_t value) { + const uint32_t codePoint = static_cast(value); + // The outer structured result is consumed by installers and logs. Preserve + // printable ASCII only; quotes and backslashes are escaped by std::quoted, + // while every control, direction mark, separator, and non-ASCII glyph is + // made visibly inert instead of being allowed to reshape that record. + return codePoint < 0x20U || codePoint > 0x7eU; +} + +bool SanitizeBrokerDiagnostic( + std::string_view payload, + std::wstring* diagnostic) { + static_assert(kMaximumBrokerDiagnosticCharacters > 3U); + if (diagnostic == nullptr || payload.empty() || + payload.size() > static_cast(std::numeric_limits::max())) { + return false; + } + const int payloadBytes = static_cast(payload.size()); + const int required = MultiByteToWideChar( + CP_UTF8, MB_ERR_INVALID_CHARS, payload.data(), payloadBytes, nullptr, 0); + if (required <= 0) { + return false; + } + std::wstring converted(static_cast(required), L'\0'); + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, payload.data(), payloadBytes, + converted.data(), required) != required) { + return false; + } + for (wchar_t& character : converted) { + if (IsUnsafeBrokerDiagnosticCharacter(character)) { + character = L'?'; + } + } + if (converted.size() > kMaximumBrokerDiagnosticCharacters) { + converted.resize(kMaximumBrokerDiagnosticCharacters - 3U); + converted.append(L"..."); + } + *diagnostic = std::move(converted); + return true; +} + +bool ParseBrokerCommitProof( + const std::string& output, + DWORD processExitCode, + BrokerCommitProof* proof, + Error* error) { + struct CanonicalProof { + std::string_view record; + bool success; + bool changed; + std::string_view rollback; + DWORD exitCode; + bool driverRollbackAuthorized; + }; + static constexpr std::array canonicalProofs = {{ + {"result=success operation=native-package-broker-commit changed=0 rollback=not-needed exitCode=0", + true, false, "not-needed", ERROR_SUCCESS, false}, + {"result=success operation=native-package-broker-commit changed=1 rollback=not-needed exitCode=0", + true, true, "not-needed", ERROR_SUCCESS, false}, + {"result=error operation=native-package-broker-commit changed=0 rollback=not-needed exitCode=4", + false, false, "not-needed", 4, true}, + {"result=error operation=native-package-broker-commit changed=1 rollback=succeeded exitCode=1", + false, true, "succeeded", 1, true}, + {"result=error operation=native-package-broker-commit changed=1 rollback=failed exitCode=3", + false, true, "failed", 3, false}, + }}; + std::optional parsed; + std::optional journalProof; + std::optional diagnostic; + bool diagnosticSeen = false; + bool diagnosticRejected = false; + size_t cursor = 0; + while (cursor < output.size()) { + const size_t newline = output.find('\n', cursor); + const bool terminated = newline != std::string::npos; + std::string line = output.substr( + cursor, terminated ? newline - cursor : output.size() - cursor); + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + if (line.starts_with("result=")) { + if (!terminated || parsed) { + return SetError(error, L"broker-proof", ERROR_INVALID_DATA, + L"nested broker must emit exactly one newline-terminated canonical outcome"); + } + const auto match = std::find_if( + canonicalProofs.begin(), canonicalProofs.end(), + [&](const CanonicalProof& candidate) { + return candidate.record == line; + }); + if (match == canonicalProofs.end()) { + return SetError(error, L"broker-proof", ERROR_INVALID_DATA, + L"nested broker outcome is not in canonical byte form"); + } + parsed = BrokerCommitProof{ + match->success, + match->changed, + std::string(match->rollback), + match->exitCode, + match->driverRollbackAuthorized, + }; + } + if (line.starts_with("journal-proof")) { + BrokerCommitProof candidate; + if (!terminated || journalProof || + !ParseBrokerJournalProofLine(line, &candidate)) { + return SetError(error, L"broker-proof", ERROR_INVALID_DATA, + L"nested broker journal proof is not one canonical newline-terminated binding"); + } + journalProof = std::move(candidate); + } + if (line.starts_with(kBrokerDiagnosticPrefix)) { + if (!terminated || diagnosticSeen) { + diagnosticRejected = true; + diagnostic.reset(); + } else { + std::wstring sanitized; + if (SanitizeBrokerDiagnostic( + std::string_view(line).substr(kBrokerDiagnosticPrefix.size()), + &sanitized)) { + diagnostic = std::move(sanitized); + } else { + diagnosticRejected = true; + } + } + diagnosticSeen = true; + } + if (!terminated) { + break; + } + cursor = newline + 1; + } + if (!parsed || parsed->exitCode != processExitCode) { + return SetError(error, L"broker-proof", ERROR_INVALID_DATA, + L"nested broker process exit and structured outcome are missing or inconsistent"); + } + const bool journalRequired = parsed->changed; + if (journalRequired != journalProof.has_value()) { + return SetError(error, L"broker-proof", ERROR_INVALID_DATA, + L"nested broker changed ownership without exactly one durable journal proof"); + } + if (journalProof) { + const std::string expectedState = parsed->success + ? "nested-ready" + : parsed->driverRollbackAuthorized ? "rollback-settled" : "manual"; + if (journalProof->journalState != expectedState) { + return SetError(error, L"broker-proof", ERROR_INVALID_DATA, + L"nested broker outcome and durable journal state disagree"); + } + parsed->hasJournalProof = true; + parsed->journalTransactionId = journalProof->journalTransactionId; + parsed->journalOuterTransactionId = journalProof->journalOuterTransactionId; + parsed->journalCandidateSha256 = journalProof->journalCandidateSha256; + parsed->journalState = journalProof->journalState; + parsed->journalDigest = journalProof->journalDigest; + } + // Diagnostics are never transaction authority. Ambiguous, malformed, + // unterminated, or success-adjacent text is discarded; only the exact + // canonical result above controls changed/rollback classification. + if (!parsed->success && !diagnosticRejected && diagnostic) { + parsed->diagnostic = std::move(*diagnostic); + } + *proof = std::move(*parsed); + return true; +} + +bool SetBrokerCommitFailure(const BrokerCommitProof& proof, Error* error) { + std::wstring message = proof.driverRollbackAuthorized + ? L"nested broker transaction failed after proving a settled state" + : L"nested broker transaction failed with indeterminate service state"; + if (!proof.diagnostic.empty()) { + message.append(L"; nested diagnostic: "); + message.append(proof.diagnostic); + } + const wchar_t* phase = proof.changed ? L"broker-health" : L"broker-preflight"; + SetError(error, phase, ERROR_INSTALL_FAILURE, std::move(message)); + if (error != nullptr) { + error->nestedExitCode = proof.exitCode; + } + return false; +} + +bool DrainBrokerProofPipe( + HANDLE pipe, + std::string* output, + bool* overflow, + Error* error) { + for (;;) { + DWORD available = 0; + if (!PeekNamedPipe(pipe, nullptr, 0, nullptr, &available, nullptr)) { + const DWORD code = GetLastError(); + if (code == ERROR_BROKEN_PIPE) { + return true; + } + return SetError(error, L"broker-proof-read", code); + } + if (available == 0) { + return true; + } + std::array buffer{}; + const DWORD requested = std::min( + available, static_cast(buffer.size())); + DWORD read = 0; + if (!ReadFile(pipe, buffer.data(), requested, &read, nullptr)) { + const DWORD code = GetLastError(); + if (code == ERROR_BROKEN_PIPE) { + return true; + } + return SetError(error, L"broker-proof-read", code); + } + const size_t retained = std::min( + read, kMaximumBrokerProofBytes - + std::min(output->size(), kMaximumBrokerProofBytes)); + output->append(buffer.data(), retained); + if (retained != read) { + *overflow = true; + } + } +} + +bool RunBrokerInstall( + const InstallOptions& options, + bool* driverRollbackAuthorized, + bool* brokerChanged, + BrokerCommitProof* durableProof, + bool recoveryOnly, + Error* error) { + // Published ownership remains fail-closed until the exact child result and + // journal binding have both survived a write-through/readback append. + *driverRollbackAuthorized = false; + *brokerChanged = false; + if (durableProof != nullptr) { + *durableProof = {}; + } + if (options.brokerExecutable.empty() || !options.brokerExecutable.is_absolute() || + options.brokerExecutable.filename().wstring() != L"viiper.exe" || + options.brokerToken.empty() || !options.brokerToken.is_absolute() || + options.brokerToken.extension().wstring() != L".token" || + options.brokerTokenSha256.size() != 64 || + !std::all_of(options.brokerTokenSha256.begin(), options.brokerTokenSha256.end(), + [](unsigned char value) { return std::isxdigit(value) != 0; }) || + !IsSafeTargetUserSid(options.targetUserSid)) { + return SetError(error, L"broker-arguments", ERROR_INVALID_PARAMETER, + L"broker executable, protected transaction token, and target SID do not match the native package contract"); + } + WinHandle broker(CreateFileW(options.brokerExecutable.c_str(), + GENERIC_READ | FILE_READ_ATTRIBUTES, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT, nullptr)); + if (!broker) { + return SetLastErrorDetail(error, L"broker-open"); + } + FILE_ATTRIBUTE_TAG_INFO attributes{}; + if (!GetFileInformationByHandleEx(broker.get(), FileAttributeTagInfo, + &attributes, sizeof(attributes)) || + (attributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 || + (attributes.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) { + return SetError(error, L"broker-path", ERROR_REPARSE_TAG_MISMATCH, + L"broker executable must be a regular non-reparse file"); + } + std::array header{}; + DWORD read = 0; + if (!ReadFile(broker.get(), header.data(), static_cast(header.size()), &read, nullptr) || + read != static_cast(header.size()) || header[0] != 'M' || header[1] != 'Z') { + return SetError(error, L"broker-image", ERROR_BAD_EXE_FORMAT, + L"broker executable is not a Windows PE image"); + } + std::string actualBrokerSha256; + if (!Sha256Handle(broker.get(), &actualBrokerSha256, error)) { + error->phase = L"broker-hash"; + return false; + } + if (_stricmp(actualBrokerSha256.c_str(), options.brokerSha256.c_str()) != 0) { + return SetError(error, L"broker-hash", ERROR_CRC, + L"staged native broker does not match the installer-bound SHA-256"); + } + + std::wstring commandLine = + BuildBrokerCommitCommandLine(options, recoveryOnly); + std::vector mutableCommand(commandLine.begin(), commandLine.end()); + mutableCommand.push_back(L'\0'); + SECURITY_ATTRIBUTES inheritedSecurity{}; + inheritedSecurity.nLength = sizeof(inheritedSecurity); + inheritedSecurity.bInheritHandle = TRUE; + HANDLE rawProofRead = INVALID_HANDLE_VALUE; + HANDLE rawProofWrite = INVALID_HANDLE_VALUE; + if (!CreatePipe( + &rawProofRead, &rawProofWrite, &inheritedSecurity, 0)) { + return SetLastErrorDetail(error, L"broker-proof-pipe"); + } + WinHandle proofRead(rawProofRead); + WinHandle proofWrite(rawProofWrite); + if (!SetHandleInformation( + proofRead.get(), HANDLE_FLAG_INHERIT, 0)) { + return SetLastErrorDetail(error, L"broker-proof-pipe-inheritance"); + } + WinHandle nullInput(CreateFileW( + L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, + &inheritedSecurity, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)); + if (!nullInput) { + return SetLastErrorDetail(error, L"broker-null-input"); + } + + SIZE_T attributeBytes = 0; + InitializeProcThreadAttributeList(nullptr, 1, 0, &attributeBytes); + if (attributeBytes == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return SetLastErrorDetail(error, L"broker-handle-list-size"); + } + std::vector attributeStorage(attributeBytes); + STARTUPINFOEXW startup{}; + startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = nullInput.get(); + startup.StartupInfo.hStdOutput = proofWrite.get(); + startup.StartupInfo.hStdError = proofWrite.get(); + startup.lpAttributeList = reinterpret_cast( + attributeStorage.data()); + if (!InitializeProcThreadAttributeList( + startup.lpAttributeList, 1, 0, &attributeBytes)) { + return SetLastErrorDetail(error, L"broker-handle-list-init"); + } + const auto deleteAttributeList = [&]() { + DeleteProcThreadAttributeList(startup.lpAttributeList); + }; + HANDLE inheritedHandles[] = {nullInput.get(), proofWrite.get()}; + if (!UpdateProcThreadAttribute( + startup.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + inheritedHandles, sizeof(inheritedHandles), nullptr, nullptr)) { + const DWORD code = GetLastError(); + deleteAttributeList(); + return SetError(error, L"broker-handle-list-update", code); + } + PROCESS_INFORMATION process{}; + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::BrokerChildEntered, + true, ERROR_SUCCESS, false, error)) { + deleteAttributeList(); + return false; + } + if (!CreateProcessW(options.brokerExecutable.c_str(), mutableCommand.data(), nullptr, nullptr, + TRUE, CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT, nullptr, + options.brokerExecutable.parent_path().c_str(), + &startup.StartupInfo, &process)) { + const DWORD code = GetLastError(); + deleteAttributeList(); + Error journalError; + if (!RecordActiveInstallJournalRollbackAuthorization( + InstallJournalPhase::BrokerChildSettled, + code, &journalError)) { + *error = std::move(journalError); + return false; + } + *driverRollbackAuthorized = true; + return SetError(error, L"broker-start", code); + } + MarkTransactionMutationStarted(); + deleteAttributeList(); + // From this point forward, only an exact child proof may authorize driver + // rollback. A crash, malformed output, or late/indeterminate exit must not + // compound an unknown SCM transaction with a second SetupAPI mutation. + *driverRollbackAuthorized = false; + WinHandle processHandle(process.hProcess); + WinHandle threadHandle(process.hThread); + proofWrite.reset(); + nullInput.reset(); + // The child shares the exact outer deadline and owns a separately bounded + // rollback. Poll and drain rather than hard-terminate: cancellation is + // cooperative, and this helper retains the driver mutex until the child + // exits so a foreign helper cannot overlap an indeterminate SCM mutation. + const uint64_t brokerCeiling = + options.transactionDeadlineUnixMs + kBrokerRollbackCeilingMs; + bool exceededCeiling = false; + bool proofOverflow = false; + bool proofReadFailed = false; + bool waitFailed = false; + Error proofReadError; + Error waitError; + std::string brokerOutput; + for (;;) { + if (!proofReadFailed && !DrainBrokerProofPipe( + proofRead.get(), &brokerOutput, &proofOverflow, &proofReadError)) { + proofReadFailed = true; + proofRead.reset(); + } + const uint64_t now = CurrentUnixMilliseconds(); + if (now >= brokerCeiling && !exceededCeiling) { + exceededCeiling = true; + std::wcerr + << L"native broker exceeded its transaction and rollback deadline; " + L"retaining the driver transaction lock until the child exits\n"; + } + const DWORD waitSlice = static_cast( + exceededCeiling ? 250 : std::min(250, brokerCeiling - now)); + const DWORD wait = WaitForSingleObject(processHandle.get(), waitSlice); + if (wait == WAIT_OBJECT_0) { + break; + } + if (wait != WAIT_TIMEOUT) { + if (!waitFailed) { + DWORD code = GetLastError(); + if (code == ERROR_SUCCESS) { + code = ERROR_GEN_FAILURE; + } + waitError = Error{code, L"broker-wait", FormatError(code)}; + waitFailed = true; + } + // A failed wait is ambiguous, not permission to release the driver + // transaction mutex while the nested SCM child may still mutate. + // Retain ownership and use the process exit query only as a + // termination observation; the final outcome remains indeterminate. + DWORD observedExit = STILL_ACTIVE; + if (GetExitCodeProcess(processHandle.get(), &observedExit) && + observedExit != STILL_ACTIVE) { + break; + } + Sleep(250); + } + } + if (!proofReadFailed && !DrainBrokerProofPipe( + proofRead.get(), &brokerOutput, &proofOverflow, &proofReadError)) { + proofReadFailed = true; + } + DWORD exitCode = ERROR_GEN_FAILURE; + if (!GetExitCodeProcess(processHandle.get(), &exitCode)) { + return SetLastErrorDetail(error, L"broker-exit"); + } + if (exceededCeiling) { + return SetError(error, L"broker-wait-ceiling", ERROR_TIMEOUT, + L"native broker exited only after its transaction and rollback deadline; external reconciliation is required"); + } + if (waitFailed) { + *error = std::move(waitError); + return false; + } + if (proofReadFailed) { + *error = std::move(proofReadError); + return false; + } + if (proofOverflow) { + return SetError(error, L"broker-proof", ERROR_BUFFER_OVERFLOW, + L"nested broker output exceeded the bounded proof channel"); + } + BrokerCommitProof proof; + if (!ParseBrokerCommitProof(brokerOutput, exitCode, &proof, error)) { + return false; + } + if (gActiveInstallJournal != nullptr && + !gActiveInstallJournal->RecordBrokerProof(proof, error)) { + return false; + } + *driverRollbackAuthorized = proof.driverRollbackAuthorized; + *brokerChanged = proof.changed; + if (durableProof != nullptr) { + *durableProof = proof; + } + if (!proof.success) { + return SetBrokerCommitFailure(proof, error); + } + return true; +} + +Outcome Install(const InstallOptions& options) { + Outcome outcome; + if (!IsElevated()) { + SetError(&outcome.error, L"elevation", ERROR_ELEVATION_REQUIRED); + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + TransactionMutex mutex; + if (!mutex.Acquire(&outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + Outcome recoveryOutcome; + if (!ReconcileRemoveJournal( + false, options.transactionDeadlineUnixMs, &recoveryOutcome) || + !ReconcileInstallJournal( + false, options.transactionDeadlineUnixMs, &recoveryOutcome)) { + return recoveryOutcome; + } + std::filesystem::path packageDirectory; + std::vector packageLocks; + PackageInfo candidate; + if (!ValidateCandidateInputs( + options, &packageDirectory, &packageLocks, &candidate, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + std::string expectedBuildIdentity; + if (!DeriveDriverBuildIdentity( + options.sourceRevision, &expectedBuildIdentity, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + if (!ValidateExactPackageDirectory(packageDirectory, &outcome.error) || + !CheckTransactionDeadline(options, L"transaction-deadline-before-driver", &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + Snapshot prior; + if (!CaptureSnapshot(&prior, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + if (prior.devices.size() > 1 || + (!prior.devices.empty() && !prior.devices[0].present)) { + SetError(&outcome.error, L"install-topology", ERROR_DUPLICATE_SERVICE_NAME, + L"installation requires zero devices or one present exact owned root devnode"); + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + CandidateDisposition disposition = CandidateDisposition::InstallRequired; + bool downgrade = false; + if (!ClassifyCandidatePackage( + candidate, prior.packages, options.expectedDowngradeFrom, + &disposition, &downgrade, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + // Re-enumerate at the last possible point before SetupAPI reopens the + // package paths. The four leaf handles already deny write/delete sharing. + if (!ValidateExactPackageDirectory(packageDirectory, &outcome.error) || + !CheckTransactionDeadline(options, L"transaction-deadline-before-driver", &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + PackageInfo publishedCandidate; + std::vector expectedTransactionInventory = prior.packages; + bool exactBindingHealthy = false; + if (disposition == CandidateDisposition::Exact) { + if (!FindPublishedCandidate(candidate, &publishedCandidate, &outcome.error) || + (options.production && !VerifyMicrosoftHardwareInfSigner( + publishedCandidate.infPath, &outcome.error))) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + exactBindingHealthy = prior.devices.size() == 1 && prior.devices[0].present && + prior.devices[0].started && + _wcsicmp(prior.devices[0].publishedInf.c_str(), + publishedCandidate.publishedName.c_str()) == 0 && + prior.devices[0].version == candidate.version && + SamePackageBytes(prior.devices[0].package, candidate); + } + + InstallJournal installJournal; + if (!installJournal.Prepare( + prior, candidate, packageDirectory, + expectedTransactionInventory, options, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + ActiveInstallJournalScope activeJournal(&installJournal); + if (disposition == CandidateDisposition::Exact && + !installJournal.Record( + InstallJournalPhase::Prepared, &publishedCandidate, + false, false, false, true, ERROR_SUCCESS, false, + &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + + // Same-version bytes are immutable. An exact package with a missing, + // stopped, or stale binding may repair only the ROOT topology from the + // already-published exact INF. It selects that preinstalled package for the + // specific devnode and calls DiInstallDevice, so it cannot replace + // same-version Driver Store content or auto-bind any other device. + const bool driverMutation = + RequiresDriverMutation(disposition, exactBindingHealthy); + bool driverMutationStarted = false; + bool packageStagedHere = false; + bool bindingMutationStarted = false; + std::optional priorAbiProfile; + DeviceInfoSet created; + SP_DEVINFO_DATA createdData{}; + createdData.cbSize = sizeof(createdData); + bool registrationSucceeded = false; + GUID candidateClassGuid{}; + wchar_t candidateClassName[MAX_CLASS_NAME_LEN]{}; + const bool needsRootRegistration = driverMutation && prior.devices.empty(); + if (needsRootRegistration && + !SetupDiGetINFClassW(candidate.infPath.c_str(), &candidateClassGuid, + candidateClassName, MAX_CLASS_NAME_LEN, nullptr)) { + SetLastErrorDetail(&outcome.error, L"candidate-inf-class"); + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + + // Import a new candidate with the add-only Driver Store API while the + // captured root remains fully intact. Prove the unique published bytes, + // catalog/signer, and unchanged root binding before asking the broker to + // quiesce. Every failure after a successful import falls through to the + // snapshot rollback path, which removes the new package and preserves the + // prior binding. + if (disposition == CandidateDisposition::InstallRequired) { + const bool stageSucceeded = StageCandidatePackage( + candidate, options.production, options.transactionDeadlineUnixMs, + &driverMutationStarted, &packageStagedHere, + &publishedCandidate, &outcome.error); + const Error stageError = outcome.error; + Error stageJournalError; + const PackageInfo* stageReceipt = + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr; + const bool stageCallReturned = + driverMutationStarted || stageReceipt != nullptr; + if (stageCallReturned && !installJournal.Record( + InstallJournalPhase::StageReceiptCaptured, stageReceipt, + packageStagedHere, bindingMutationStarted, + outcome.rebootRequired, stageSucceeded, + stageSucceeded ? ERROR_SUCCESS : stageError.code, + gLastSynchronousMutationTimedOut, &stageJournalError)) { + outcome.error = std::move(stageJournalError); + } else if (!stageSucceeded) { + outcome.error = stageError; + } + if (!stageSucceeded) { + // Exact staging proof recorded the failure. + } else { + if (!packageStagedHere && + !ContainsExactPackage(prior.packages, candidate)) { + SetError(&outcome.error, L"stage-concurrent-publication", ERROR_RETRY, + L"an exact candidate appeared after the Driver Store snapshot; rerun the identical transaction"); + } + if (outcome.error.code == ERROR_SUCCESS && packageStagedHere) { + expectedTransactionInventory.push_back(publishedCandidate); + std::sort(expectedTransactionInventory.begin(), + expectedTransactionInventory.end(), + [](const PackageInfo& left, const PackageInfo& right) { + return _wcsicmp(left.publishedName.c_str(), + right.publishedName.c_str()) < 0; + }); + } + if (outcome.error.code == ERROR_SUCCESS && + !VerifyPackageInventory(expectedTransactionInventory, + L"stage-package-inventory-verification", &outcome.error)) { + // Any concurrent package publication fails closed. + } else if (outcome.error.code == ERROR_SUCCESS && + !CaptureAndVerifyRootUnchanged( + prior, L"stage-root-binding-verification", + nullptr, &outcome.error)) { + // The candidate remained staged so common rollback can remove it. + } + } + outcome.changed = outcome.changed || driverMutationStarted; + } + + // The service mutex remains owned by the outer package transaction. Ask it + // to stop only a trusted running broker after exact candidate publication + // and root-invariance proof, then keep that mutex held across exact + // selected-device binding and verification. This prevents the broker from + // retaining a UdeCx handle across the package switch. + if (outcome.error.code == ERROR_SUCCESS && driverMutation && + !options.brokerExecutable.empty() && + !RequestBrokerQuiescence(options, &outcome.error)) { + // A newly staged package is a completed mutation and must take the + // common rollback path even when broker quiescence fails. + } + + if (outcome.error.code == ERROR_SUCCESS && driverMutation && + !VerifyPackageInventory(expectedTransactionInventory, + L"post-quiescence-package-inventory-verification", &outcome.error)) { + // Quiescence never authorizes concurrent Driver Store changes. + } + + Snapshot preBinding; + if (outcome.error.code == ERROR_SUCCESS && driverMutation && + !CaptureAndVerifyRootUnchanged( + prior, L"post-quiescence-root-verification", + &preBinding, &outcome.error)) { + // Full identity and lifecycle state must still match immediately before + // runtime admission and exact device binding. + } + + // UdeCx child deletion is asynchronous. Older installed images could + // release their logical device slot before framework teardown settled. + // Once the trusted broker is stopped, require a zero-lifetime-work runtime + // before any in-place package binding mutation of a running root. + // A PnP-stopped exact owned root has no live UdeCx stack or ABI endpoint; + // its captured devnode/package identity is already the quiescence proof. + // Do not start an old driver solely to replace it. Exact binding and + // rollback checks below still guard the stopped-root transaction. For a + // running root, a restart resets these counters and guarantees that no + // pre-replacement child object can survive into rebinding. + const bool currentRootPresent = preBinding.devices.size() == 1 && + preBinding.devices[0].present; + const bool requiresPristineRuntimeProof = + outcome.error.code == ERROR_SUCCESS && + RequiresPristineRuntimeProof( + disposition, exactBindingHealthy, currentRootPresent, + currentRootPresent && preBinding.devices[0].started); + if (outcome.error.code == ERROR_SUCCESS && requiresPristineRuntimeProof) { + AbiCompatibilityProfile negotiatedProfile{}; + if (!VerifyAbiHealth( + options.transactionDeadlineUnixMs, nullptr, &outcome.error, + AbiHealthPurpose::PristineUpgrade, nullptr, + &negotiatedProfile)) { + if (outcome.error.code == ERROR_SUCCESS_REBOOT_REQUIRED) { + outcome.rebootRequired = true; + } + } else { + if (!installJournal.RecordPriorAbiProfile( + negotiatedProfile, publishedCandidate, + packageStagedHere, &outcome.error)) { + // The exact compatibility profile must be durable before bind. + } else { + priorAbiProfile = negotiatedProfile; + } + } + } + + if (outcome.error.code == ERROR_SUCCESS && driverMutation) { + if (prior.devices.empty()) { + const bool inventoryVerified = VerifyPackageInventory( + expectedTransactionInventory, + L"final-pre-bind-package-inventory-verification", &outcome.error); + const bool registeredAndVerified = inventoryVerified && RegisterRootDevice( + candidateClassGuid, options.transactionDeadlineUnixMs, + &bindingMutationStarted, ®istrationSucceeded, + &created, &createdData, &outcome.error); + if (registeredAndVerified) { + InstallPreinstalledDriverOnDevice( + created.get(), &createdData, publishedCandidate, + options.transactionDeadlineUnixMs, &bindingMutationStarted, + &outcome.rebootRequired, &outcome.error); + } + } else { + DeviceInfoSet bindingSet = OpenRootDevices(); + std::vector> bindingDevices; + PreparedDriverBinding prepared; + if (!bindingSet) { + SetLastErrorDetail(&outcome.error, L"binding-open-root-devices"); + } else if (!FindExactDevices( + bindingSet.get(), &bindingDevices, &outcome.error)) { + // Exact enumeration recorded the failure. + } else if (bindingDevices.size() != 1 || + !SameEnumeratedRootState( + bindingDevices[0].second, prior.devices[0])) { + SetError(&outcome.error, L"binding-root-invariance", + ERROR_REVISION_MISMATCH, + L"the captured root identity or lifecycle state changed before compatible-driver preparation"); + } else if (!PreparePreinstalledDriverOnDevice( + bindingSet.get(), &bindingDevices[0].first, + publishedCandidate, &prepared, &outcome.error)) { + // Exact compatible-driver selection recorded the failure. + } else if (!CaptureAndVerifyRootUnchanged( + prior, + L"final-pre-bind-root-topology-verification", + nullptr, &outcome.error)) { + // A fresh global set catches roots absent from the prepared set. + } else if (!VerifyPackageInventory(expectedTransactionInventory, + L"final-pre-bind-package-inventory-verification", + &outcome.error)) { + // No concurrent package can enter the selected-driver window. + } else if (!CaptureAndVerifyPreparedRootUnchanged( + prior.devices[0], bindingSet.get(), + bindingDevices[0].first.DevInst, + L"final-pre-bind-root-verification", &outcome.error)) { + // The final same-devnode proof includes exact package bytes. + } else if (requiresPristineRuntimeProof && + (!priorAbiProfile.has_value() || + !VerifyAbiHealth( + options.transactionDeadlineUnixMs, nullptr, + &outcome.error, AbiHealthPurpose::PristineRecheck, + priorAbiProfile.has_value() + ? &priorAbiProfile.value() : nullptr, + nullptr))) { + if (outcome.error.code == ERROR_SUCCESS) { + SetError(&outcome.error, L"final-pre-bind-abi-profile", + ERROR_REVISION_MISMATCH, + L"the exact pre-quiescence ABI profile is unavailable for final pristine proof"); + } else if (outcome.error.code == ERROR_SUCCESS_REBOOT_REQUIRED) { + outcome.rebootRequired = true; + } + } else { + CommitPreparedDriverBinding( + &prepared, options.transactionDeadlineUnixMs, + &bindingMutationStarted, &outcome.rebootRequired, + &outcome.error); + } + } + driverMutationStarted = driverMutationStarted || bindingMutationStarted; + outcome.changed = outcome.changed || bindingMutationStarted; + } + + if (outcome.error.code == ERROR_SUCCESS) { + CheckTransactionDeadline(options, L"transaction-deadline-before-verify", &outcome.error); + } + if (outcome.error.code == ERROR_SUCCESS && + !(options.brokerExecutable.empty() + ? VerifyInstalled(candidate, publishedCandidate.publishedName, + outcome.rebootRequired, options.transactionDeadlineUnixMs, + &expectedBuildIdentity, &outcome.error) + : VerifyInstalledBinding(candidate, publishedCandidate.publishedName, + outcome.rebootRequired, &outcome.error))) { + // Verification recorded the exact failure. + } + if (outcome.error.code == ERROR_SUCCESS && driverMutation && + !VerifyPackageInventory(expectedTransactionInventory, + L"post-bind-package-inventory-verification", &outcome.error)) { + // A concurrent package mutation invalidates the transaction outcome. + } + if (outcome.error.code == ERROR_SUCCESS && + !installJournal.Record( + InstallJournalPhase::DriverValidated, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + outcome.rebootRequired, true, ERROR_SUCCESS, false, + &outcome.error)) { + // A durable validation boundary is required before broker handoff. + } + const auto verifyPostAdmissionRollbackInventory = + [&](const wchar_t* phase, Error* error) { + std::vector exactInventory = prior.packages; + if (packageStagedHere) { + if (!IsSafePublishedInfName( + publishedCandidate.publishedName) || + !SamePackageBytes(publishedCandidate, candidate) || + !(publishedCandidate.version == candidate.version)) { + return SetError(error, phase, ERROR_INVALID_DATA, + L"staged-here rollback lacks its exact published candidate receipt"); + } + if (!ContainsExactPackage( + exactInventory, publishedCandidate)) { + exactInventory.push_back(publishedCandidate); + } + } + std::sort(exactInventory.begin(), exactInventory.end(), + [](const PackageInfo& left, + const PackageInfo& right) { + return _wcsicmp(left.publishedName.c_str(), + right.publishedName.c_str()) < 0; + }); + return VerifyPackageInventory(exactInventory, phase, error); + }; + if (outcome.error.code != ERROR_SUCCESS && driverMutationStarted) { + const Error installError = outcome.error; + Error rollbackError; + bool rollbackReboot = outcome.rebootRequired; + const uint64_t rollbackDeadline = + CurrentUnixMilliseconds() + kDriverRollbackCeilingMs; + if (!installJournal.Record( + InstallJournalPhase::RollbackBindingEntered, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + rollbackReboot, true, ERROR_SUCCESS, false, + &rollbackError)) { + outcome.rollback = L"failed"; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + if (!verifyPostAdmissionRollbackInventory( + L"install-rollback-post-admission-inventory", + &rollbackError)) { + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + const bool rollbackRebootAtAdmission = rollbackReboot; + bool rootRemovalRebootPending = false; + if (!installJournal.RemoveAuthorizedPriorEmptyRootAfterAdmission( + rollbackDeadline, &rollbackReboot, + &rootRemovalRebootPending, + &rollbackError)) { + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + if (rootRemovalRebootPending) { + outcome.rollback = L"not-needed"; + outcome.rebootRequired = true; + SetError(&outcome.error, + L"install-partial-root-removal-reboot-pending", + ERROR_SUCCESS_REBOOT_REQUIRED, + L"receipt-bound root removal requires a restart before package rollback can continue"); + outcome.exitCode = ExitCode::RebootRequired; + return outcome; + } + if (!verifyPostAdmissionRollbackInventory( + L"install-rollback-pre-package-inventory", + &rollbackError) || + !installJournal.VerifyPriorTopologyBeforePackageRollback( + &rollbackError)) { + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + const PackageInfo* stagedHereCandidate = + packageStagedHere ? &publishedCandidate : nullptr; + const bool restoreBindingThroughStrictSnapshot = + !prior.devices.empty() && bindingMutationStarted; + if (RollbackInstall( + prior, stagedHereCandidate, + restoreBindingThroughStrictSnapshot, + priorAbiProfile.has_value() ? &priorAbiProfile.value() : nullptr, + rollbackDeadline, &rollbackReboot, &rollbackError)) { + Error journalError; + if (!installJournal.RecordAuthoritativeReturn( + InstallJournalPhase::RollbackBindingReturned, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + rollbackReboot, + rollbackReboot && !rollbackRebootAtAdmission, + true, ERROR_SUCCESS, false, + &journalError) || + !installJournal.RetireAfterPriorValidation( + rollbackReboot, &journalError)) { + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(journalError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + outcome.rollback = L"succeeded"; + outcome.rebootRequired = rollbackReboot; + outcome.error = installError; + outcome.exitCode = installError.code == ERROR_SUCCESS_REBOOT_REQUIRED + ? ExitCode::RebootRequired : ExitCode::Failure; + return outcome; + } + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(rollbackError); + Error journalError; + installJournal.RecordAuthoritativeReturn( + InstallJournalPhase::RollbackBindingReturned, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + rollbackReboot, + rollbackReboot && !rollbackRebootAtAdmission, + false, outcome.error.code, false, + &journalError); + installJournal.Record( + InstallJournalPhase::ManualReconciliationRequired, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + rollbackReboot, false, outcome.error.code, false, + &journalError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + if (outcome.error.code != ERROR_SUCCESS) { + Error journalError; + if (!installJournal.RetireAfterPriorValidation( + outcome.rebootRequired, &journalError)) { + outcome.error = std::move(journalError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + outcome.exitCode = outcome.rebootRequired && + outcome.error.code == ERROR_SUCCESS_REBOOT_REQUIRED + ? ExitCode::RebootRequired : ExitCode::PreflightRejected; + return outcome; + } + + if (!options.brokerExecutable.empty()) { + Error brokerError; + bool driverRollbackAuthorized = true; + bool brokerChanged = false; + if (outcome.rebootRequired) { + SetError(&brokerError, L"broker-reboot-boundary", ERROR_SUCCESS_REBOOT_REQUIRED, + L"driver activation requires a restart; legacy ownership remains active and broker migration was not attempted"); + } else if (!CheckTransactionDeadline( + options, L"transaction-deadline-before-broker", &brokerError) || + !SignalBrokerHandoff(options, &brokerError) || + !RunBrokerInstall( + options, &driverRollbackAuthorized, &brokerChanged, + nullptr, false, &brokerError)) { + // The broker command includes authenticated health verification and + // rolls back its own SCM/credential/legacy transaction. Keep the + // driver snapshot alive in this process until that proof succeeds. + } + outcome.changed = outcome.changed || brokerChanged; + if (brokerError.code != ERROR_SUCCESS) { + if (!driverRollbackAuthorized) { + Error journalError; + installJournal.Record( + InstallJournalPhase::ManualReconciliationRequired, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + outcome.rebootRequired, false, brokerError.code, + false, &journalError); + outcome.rollback = L"failed"; + outcome.error = std::move(brokerError); + installJournal.AttachEvidence(&outcome.error); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + if (!driverMutationStarted) { + Error journalError; + if (!installJournal.RetireAfterPriorValidation( + outcome.rebootRequired, &journalError)) { + outcome.rollback = L"failed"; + outcome.error = std::move(journalError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + outcome.rollback = brokerChanged ? L"succeeded" : L"not-needed"; + outcome.error = std::move(brokerError); + outcome.exitCode = outcome.error.code == ERROR_SUCCESS_REBOOT_REQUIRED + ? ExitCode::RebootRequired : ExitCode::Failure; + return outcome; + } + Error rollbackError; + bool rollbackReboot = outcome.rebootRequired; + const uint64_t rollbackDeadline = + CurrentUnixMilliseconds() + kDriverRollbackCeilingMs; + if (!installJournal.Record( + InstallJournalPhase::RollbackBindingEntered, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + rollbackReboot, true, ERROR_SUCCESS, false, + &rollbackError)) { + outcome.rollback = L"failed"; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + if (!verifyPostAdmissionRollbackInventory( + L"install-broker-rollback-post-admission-inventory", + &rollbackError)) { + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + const bool rollbackRebootAtAdmission = rollbackReboot; + bool rootRemovalRebootPending = false; + if (!installJournal.RemoveAuthorizedPriorEmptyRootAfterAdmission( + rollbackDeadline, &rollbackReboot, + &rootRemovalRebootPending, + &rollbackError)) { + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + if (rootRemovalRebootPending) { + outcome.rollback = L"not-needed"; + outcome.rebootRequired = true; + SetError(&outcome.error, + L"install-partial-root-removal-reboot-pending", + ERROR_SUCCESS_REBOOT_REQUIRED, + L"receipt-bound root removal requires a restart before package rollback can continue"); + outcome.exitCode = ExitCode::RebootRequired; + return outcome; + } + if (!verifyPostAdmissionRollbackInventory( + L"install-broker-rollback-pre-package-inventory", + &rollbackError) || + !installJournal.VerifyPriorTopologyBeforePackageRollback( + &rollbackError)) { + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + const PackageInfo* stagedHereCandidate = + packageStagedHere ? &publishedCandidate : nullptr; + const bool restoreBindingThroughStrictSnapshot = + !prior.devices.empty() && bindingMutationStarted; + if (RollbackInstall( + prior, stagedHereCandidate, + restoreBindingThroughStrictSnapshot, + priorAbiProfile.has_value() ? &priorAbiProfile.value() : nullptr, + rollbackDeadline, &rollbackReboot, &rollbackError)) { + Error journalError; + if (!installJournal.RecordAuthoritativeReturn( + InstallJournalPhase::RollbackBindingReturned, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + rollbackReboot, + rollbackReboot && !rollbackRebootAtAdmission, + true, ERROR_SUCCESS, false, + &journalError) || + !installJournal.RetireAfterPriorValidation( + rollbackReboot, &journalError)) { + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(journalError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + outcome.rollback = L"succeeded"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(brokerError); + outcome.exitCode = outcome.error.code == ERROR_SUCCESS_REBOOT_REQUIRED + ? ExitCode::RebootRequired : ExitCode::Failure; + return outcome; + } + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(rollbackError); + Error journalError; + installJournal.RecordAuthoritativeReturn( + InstallJournalPhase::RollbackBindingReturned, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + rollbackReboot, + rollbackReboot && !rollbackRebootAtAdmission, + false, outcome.error.code, false, + &journalError); + installJournal.Record( + InstallJournalPhase::ManualReconciliationRequired, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + rollbackReboot, false, outcome.error.code, false, + &journalError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + } + + if (!installJournal.RetireAfterForwardValidation( + candidate, publishedCandidate.publishedName, + outcome.rebootRequired, options.transactionDeadlineUnixMs, + &outcome.brokerBinding, "fresh", &outcome.error)) { + outcome.rollback = L"failed"; + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + if (outcome.rebootRequired) { + outcome.success = false; + if (outcome.changed) { + outcome.rollback = L"failed"; + SetError(&outcome.error, + L"install-forward-reboot-unsettled", + ERROR_INSTALL_SUSPEND, + L"forward mutation remains journaled across a required restart and is not yet a settled success"); + installJournal.AttachEvidence(&outcome.error); + outcome.exitCode = ExitCode::RollbackFailed; + } else { + outcome.rollback = L"not-needed"; + SetError(&outcome.error, L"install-reboot-boundary", + ERROR_SUCCESS_REBOOT_REQUIRED); + outcome.exitCode = ExitCode::RebootRequired; + } + return outcome; + } + outcome.success = true; + outcome.rollback = L"not-needed"; + outcome.exitCode = ExitCode::Success; + return outcome; +} + +struct PackageBackup { + PackageInfo original; + std::filesystem::path directory; + std::filesystem::path infPath; + std::vector locks; +}; + +class LocalSecurityDescriptor final { +public: + LocalSecurityDescriptor() = default; + + ~LocalSecurityDescriptor() { + if (value_ != nullptr) { + LocalFree(value_); + } + } + + LocalSecurityDescriptor(const LocalSecurityDescriptor&) = delete; + LocalSecurityDescriptor& operator=(const LocalSecurityDescriptor&) = delete; + + bool Initialize(const wchar_t* sddl, const wchar_t* phase, Error* error) { + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl, SDDL_REVISION_1, &value_, nullptr)) { + return SetLastErrorDetail(error, phase); + } + attributes_ = SECURITY_ATTRIBUTES{}; + attributes_.nLength = sizeof(attributes_); + attributes_.lpSecurityDescriptor = value_; + attributes_.bInheritHandle = FALSE; + return true; + } + + SECURITY_ATTRIBUTES* attributes() noexcept { return &attributes_; } + +private: + PSECURITY_DESCRIPTOR value_ = nullptr; + SECURITY_ATTRIBUTES attributes_{}; +}; + +bool VerifyProtectedFileSystemSecurity( + HANDLE handle, + bool directory, + const wchar_t* phase, + Error* error) { + PSID owner = nullptr; + PACL dacl = nullptr; + PSECURITY_DESCRIPTOR descriptor = nullptr; + const DWORD securityError = GetSecurityInfo( + handle, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &owner, nullptr, &dacl, nullptr, &descriptor); + if (securityError != ERROR_SUCCESS) { + return SetError(error, phase, securityError); + } + const auto fail = [&](DWORD code, std::wstring message) { + LocalFree(descriptor); + return SetError(error, phase, code, std::move(message)); + }; + + BYTE administratorsBuffer[SECURITY_MAX_SID_SIZE]{}; + DWORD administratorsSize = sizeof(administratorsBuffer); + BYTE systemBuffer[SECURITY_MAX_SID_SIZE]{}; + DWORD systemSize = sizeof(systemBuffer); + if (!CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, + administratorsBuffer, &administratorsSize) || + !CreateWellKnownSid(WinLocalSystemSid, nullptr, + systemBuffer, &systemSize)) { + const DWORD code = GetLastError(); + return fail(code, L"could not construct protected backup principals"); + } + SECURITY_DESCRIPTOR_CONTROL control = 0; + DWORD revision = 0; + ACL_SIZE_INFORMATION information{}; + if (owner == nullptr || !EqualSid(owner, administratorsBuffer) || dacl == nullptr || + !GetSecurityDescriptorControl(descriptor, &control, &revision) || + (control & SE_DACL_PROTECTED) == 0 || + !GetAclInformation(dacl, &information, sizeof(information), AclSizeInformation) || + information.AceCount != 2) { + return fail(ERROR_INVALID_SECURITY_DESCR, + L"protected backup owner or DACL is not exact"); + } + + const BYTE expectedFlags = directory + ? static_cast(OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE) : 0; + bool administratorsSeen = false; + bool systemSeen = false; + for (DWORD index = 0; index < information.AceCount; ++index) { + void* rawAce = nullptr; + if (!GetAce(dacl, index, &rawAce) || rawAce == nullptr) { + const DWORD code = GetLastError(); + return fail(code == ERROR_SUCCESS ? ERROR_INVALID_ACL : code, + L"protected backup DACL could not be enumerated"); + } + const auto* ace = static_cast(rawAce); + if (ace->Header.AceType != ACCESS_ALLOWED_ACE_TYPE || + ace->Header.AceFlags != expectedFlags || ace->Mask != FILE_ALL_ACCESS) { + return fail(ERROR_INVALID_ACL, + L"protected backup DACL contains an unexpected access rule"); + } + PSID sid = const_cast(&ace->SidStart); + if (EqualSid(sid, administratorsBuffer)) { + if (administratorsSeen) { + return fail(ERROR_INVALID_ACL, + L"protected backup DACL duplicates the Administrators rule"); + } + administratorsSeen = true; + } else if (EqualSid(sid, systemBuffer)) { + if (systemSeen) { + return fail(ERROR_INVALID_ACL, + L"protected backup DACL duplicates the LocalSystem rule"); + } + systemSeen = true; + } else { + return fail(ERROR_INVALID_ACL, + L"protected backup DACL grants an unexpected principal"); + } + } + LocalFree(descriptor); + if (!administratorsSeen || !systemSeen) { + return SetError(error, phase, ERROR_INVALID_ACL, + L"protected backup DACL is missing an exact principal"); + } + return true; +} + +constexpr ACCESS_MASK kProductReadExecuteMask = + FILE_LIST_DIRECTORY | FILE_TRAVERSE | FILE_READ_EA | + FILE_READ_ATTRIBUTES | READ_CONTROL | SYNCHRONIZE; + +ACCESS_MASK NormalizeProductDirectoryAccessMask( + ACCESS_MASK mask) noexcept { + GENERIC_MAPPING mapping{ + FILE_GENERIC_READ, + FILE_GENERIC_WRITE, + FILE_GENERIC_EXECUTE, + FILE_ALL_ACCESS, + }; + MapGenericMask(&mask, &mapping); + return mask; +} + +bool ProductDirectoryMaskIsReadExecuteOnly( + ACCESS_MASK mask) noexcept { + mask = NormalizeProductDirectoryAccessMask(mask); + return mask != 0 && (mask & ~kProductReadExecuteMask) == 0; +} + +bool VerifyProtectedProductDirectorySecurity( + HANDLE handle, + const std::wstring* exactTargetUserSid, + Error* error) { + PSID owner = nullptr; + PACL dacl = nullptr; + PSECURITY_DESCRIPTOR descriptor = nullptr; + const DWORD securityError = GetSecurityInfo( + handle, SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &owner, nullptr, &dacl, nullptr, &descriptor); + if (securityError != ERROR_SUCCESS) { + return SetError(error, L"install-journal-product-security", + securityError); + } + const auto fail = [&](DWORD code, std::wstring message) { + LocalFree(descriptor); + return SetError(error, L"install-journal-product-security", + code, std::move(message)); + }; + BYTE administratorsBuffer[SECURITY_MAX_SID_SIZE]{}; + DWORD administratorsSize = sizeof(administratorsBuffer); + BYTE systemBuffer[SECURITY_MAX_SID_SIZE]{}; + DWORD systemSize = sizeof(systemBuffer); + if (!CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, + administratorsBuffer, &administratorsSize) || + !CreateWellKnownSid(WinLocalSystemSid, nullptr, + systemBuffer, &systemSize)) { + return fail(GetLastError(), + L"could not construct product-directory principals"); + } + PSID targetUser = nullptr; + if (exactTargetUserSid != nullptr && + (!IsSafeTargetUserSid(*exactTargetUserSid) || + !ConvertStringSidToSidW( + exactTargetUserSid->c_str(), &targetUser))) { + return fail(GetLastError() == ERROR_SUCCESS + ? ERROR_INVALID_SID : GetLastError(), + L"could not construct the exact product-directory target user principal"); + } + const auto freeTargetUser = [&]() { + if (targetUser != nullptr) { + LocalFree(targetUser); + targetUser = nullptr; + } + }; + SECURITY_DESCRIPTOR_CONTROL control = 0; + DWORD revision = 0; + ACL_SIZE_INFORMATION information{}; + if (owner == nullptr || + (exactTargetUserSid != nullptr + ? !EqualSid(owner, administratorsBuffer) + : (!EqualSid(owner, administratorsBuffer) && + !EqualSid(owner, systemBuffer))) || + dacl == nullptr || + !GetSecurityDescriptorControl(descriptor, &control, &revision) || + (control & SE_DACL_PROTECTED) == 0 || + !GetAclInformation(dacl, &information, sizeof(information), + AclSizeInformation) || + (exactTargetUserSid != nullptr + ? information.AceCount != 3U + : information.AceCount < 2U)) { + freeTargetUser(); + return fail(ERROR_INVALID_SECURITY_DESCR, + L"product directory must have a protected Administrators/LocalSystem-owned DACL"); + } + constexpr BYTE inheritedFlags = + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE; + bool administratorsSeen = false; + bool systemSeen = false; + bool targetUserSeen = false; + for (DWORD index = 0; index < information.AceCount; ++index) { + void* rawAce = nullptr; + if (!GetAce(dacl, index, &rawAce) || rawAce == nullptr) { + const DWORD code = GetLastError(); + freeTargetUser(); + return fail(code == ERROR_SUCCESS ? ERROR_INVALID_ACL : code, + L"product directory DACL could not be enumerated"); + } + const auto* ace = static_cast(rawAce); + if (ace->Header.AceType != ACCESS_ALLOWED_ACE_TYPE || + (ace->Header.AceFlags & ~inheritedFlags) != 0) { + freeTargetUser(); + return fail(ERROR_INVALID_ACL, + L"product directory contains a deny, inherited, or otherwise unsupported access rule"); + } + PSID sid = const_cast(&ace->SidStart); + const ACCESS_MASK normalizedMask = + NormalizeProductDirectoryAccessMask(ace->Mask); + if (EqualSid(sid, administratorsBuffer) || + EqualSid(sid, systemBuffer)) { + bool& seen = EqualSid(sid, administratorsBuffer) + ? administratorsSeen : systemSeen; + if (seen || ace->Header.AceFlags != inheritedFlags || + normalizedMask != FILE_ALL_ACCESS) { + freeTargetUser(); + return fail(ERROR_INVALID_ACL, + L"product directory Administrators/LocalSystem rules are not exact full-control entries"); + } + seen = true; + continue; + } + if (exactTargetUserSid != nullptr && + EqualSid(sid, targetUser)) { + if (targetUserSeen || + ace->Header.AceFlags != inheritedFlags || + normalizedMask != kProductReadExecuteMask) { + freeTargetUser(); + return fail(ERROR_INVALID_ACL, + L"product directory target-user rule is not exact inherited read/execute access"); + } + targetUserSeen = true; + continue; + } + if (!ProductDirectoryMaskIsReadExecuteOnly(ace->Mask)) { + freeTargetUser(); + return fail(ERROR_INVALID_ACL, + L"product directory grants a non-system principal create, write, delete, ownership, or ACL authority"); + } + if (exactTargetUserSid != nullptr) { + freeTargetUser(); + return fail(ERROR_INVALID_ACL, + L"product directory grants read/execute access to a principal other than the requested target user"); + } + } + freeTargetUser(); + LocalFree(descriptor); + if (!administratorsSeen || !systemSeen || + (exactTargetUserSid != nullptr && !targetUserSeen)) { + return SetError(error, L"install-journal-product-security", + ERROR_INVALID_ACL, + L"product directory is missing exact Administrators or LocalSystem full control"); + } + return true; +} + +bool CreateProtectedBackupDirectory( + const std::filesystem::path& path, + Error* error) { + LocalSecurityDescriptor security; + if (!security.Initialize( + kRollbackDirectorySecurity, L"rollback-backup-directory-security", error)) { + return false; + } + if (!CreateDirectoryW(path.c_str(), security.attributes())) { + return SetLastErrorDetail(error, L"rollback-backup-create"); + } + WinHandle directory(CreateFileW( + path.c_str(), FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_BACKUP_SEMANTICS, + nullptr)); + if (!directory) { + return SetLastErrorDetail(error, L"rollback-backup-directory-open"); + } + FILE_ATTRIBUTE_TAG_INFO attributes{}; + if (!GetFileInformationByHandleEx( + directory.get(), FileAttributeTagInfo, &attributes, sizeof(attributes)) || + (attributes.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0 || + (attributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + return SetError(error, L"rollback-backup-directory-open", + ERROR_REPARSE_TAG_MISMATCH); + } + return VerifyProtectedFileSystemSecurity( + directory.get(), true, L"rollback-backup-directory-security", error); +} + +bool CopyProtectedBackupFile( + const std::filesystem::path& sourcePath, + const std::filesystem::path& destinationPath, + Error* error) { + WinHandle source(CreateFileW( + sourcePath.c_str(), GENERIC_READ | FILE_READ_ATTRIBUTES, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_SEQUENTIAL_SCAN, + nullptr)); + if (!source) { + return SetLastErrorDetail(error, L"rollback-backup-source-open"); + } + FILE_ATTRIBUTE_TAG_INFO sourceAttributes{}; + if (!GetFileInformationByHandleEx( + source.get(), FileAttributeTagInfo, &sourceAttributes, + sizeof(sourceAttributes)) || + (sourceAttributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { + return SetError(error, L"rollback-backup-source-open", + ERROR_REPARSE_TAG_MISMATCH, + L"rollback sources must be regular non-reparse files"); + } + + LocalSecurityDescriptor security; + if (!security.Initialize( + kRecoveryRecordSecurity, L"rollback-backup-file-security", error)) { + return false; + } + WinHandle destination(CreateFileW( + destinationPath.c_str(), + GENERIC_READ | GENERIC_WRITE | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, security.attributes(), CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_SEQUENTIAL_SCAN | FILE_FLAG_WRITE_THROUGH, + nullptr)); + if (!destination) { + return SetLastErrorDetail(error, L"rollback-backup-file-create"); + } + FILE_ATTRIBUTE_TAG_INFO destinationAttributes{}; + const BOOL queriedDestination = GetFileInformationByHandleEx( + destination.get(), FileAttributeTagInfo, &destinationAttributes, + sizeof(destinationAttributes)); + const DWORD destinationQueryError = queriedDestination + ? ERROR_SUCCESS : GetLastError(); + if (!queriedDestination || + (destinationAttributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { + return SetError(error, L"rollback-backup-file-create", + queriedDestination ? ERROR_REPARSE_TAG_MISMATCH : destinationQueryError); + } + if (!VerifyProtectedFileSystemSecurity( + destination.get(), false, L"rollback-backup-file-security", error)) { + return false; + } + + std::array buffer{}; + for (;;) { + DWORD read = 0; + if (!ReadFile(source.get(), buffer.data(), + static_cast(buffer.size()), &read, nullptr)) { + return SetLastErrorDetail(error, L"rollback-backup-file-read"); + } + if (read == 0) { + break; + } + DWORD offset = 0; + while (offset < read) { + DWORD written = 0; + if (!WriteFile(destination.get(), buffer.data() + offset, + read - offset, &written, nullptr) || written == 0) { + const DWORD writeError = GetLastError(); + const DWORD code = writeError == ERROR_SUCCESS + ? ERROR_WRITE_FAULT : writeError; + return SetError(error, L"rollback-backup-file-write", code); + } + offset += written; + } + } + if (!FlushFileBuffers(destination.get())) { + return SetLastErrorDetail(error, L"rollback-backup-file-flush"); + } + return true; +} + +bool BackupPackagesIntoDirectory( + const std::vector& packages, + const std::filesystem::path& baseDirectory, + std::vector* backups, + Error* error) { + backups->clear(); + for (size_t index = 0; index < packages.size(); ++index) { + std::filesystem::path storeInf; + if (!GetDriverStoreInfPath(packages[index].infPath, &storeInf, error)) { + return false; + } + std::filesystem::path resolvedPublished; + if (!GetPublishedInfPath(storeInf, &resolvedPublished, error) || + _wcsicmp(resolvedPublished.filename().c_str(), packages[index].publishedName.c_str()) != 0) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"rollback-backup-published-inf", ERROR_REVISION_MISMATCH); + } + return false; + } + const std::filesystem::path destination = + baseDirectory / std::to_wstring(index); + std::filesystem::path signerCatalog; + if (!VerifyInfSignature(storeInf, &signerCatalog, error)) { + return false; + } + if (signerCatalog.is_relative()) { + signerCatalog = storeInf.parent_path() / signerCatalog.filename(); + } + if (signerCatalog.empty()) { + return SetError(error, L"rollback-backup-catalog", ERROR_FILE_NOT_FOUND, + L"signed rollback package did not resolve a catalog payload"); + } + const std::filesystem::path backupInf = destination / L"ViiperUde.inf"; + if (!CreateProtectedBackupDirectory(destination, error) || + !CopyProtectedBackupFile(storeInf, backupInf, error) || + !CopyProtectedBackupFile( + storeInf.parent_path() / kDriverFileName, + destination / kDriverFileName, error) || + !CopyProtectedBackupFile( + signerCatalog, destination / kCatalogName, error) || + !ValidateExactPackageDirectory(destination, error)) { + return false; + } + PackageInfo verified; + bool owned = false; + if (!LoadOwnedPackage(backupInf, true, false, + &verified, &owned, error) || !owned) { + return false; + } + if (!(verified.version == packages[index].version) || + !SamePackageBytes(verified, packages[index])) { + return SetError(error, L"rollback-backup-identity", ERROR_REVISION_MISMATCH, + L"protected rollback copy does not match the captured signed package"); + } + std::vector locks; + if (!LockPackageFiles(destination, &locks, error)) { + error->phase = L"rollback-backup-lock"; + return false; + } + backups->push_back(PackageBackup{ + packages[index], destination, backupInf, std::move(locks)}); + } + return true; +} + +bool IsSha256Digest(std::string_view value) { + return value.size() == 64 && + std::all_of(value.begin(), value.end(), [](unsigned char character) { + return std::isxdigit(character) != 0; + }); +} + +void AppendJsonString(std::string* output, std::wstring_view value) { + static constexpr char digits[] = "0123456789abcdef"; + output->push_back('"'); + for (wchar_t character : value) { + const uint32_t codePoint = static_cast(character); + if (codePoint == '"' || codePoint == '\\') { + output->push_back('\\'); + output->push_back(static_cast(codePoint)); + } else if (codePoint >= 0x20U && codePoint <= 0x7eU) { + output->push_back(static_cast(codePoint)); + } else if (codePoint <= 0xffffU) { + output->append("\\u"); + output->push_back(digits[(codePoint >> 12U) & 0x0fU]); + output->push_back(digits[(codePoint >> 8U) & 0x0fU]); + output->push_back(digits[(codePoint >> 4U) & 0x0fU]); + output->push_back(digits[codePoint & 0x0fU]); + } else { + const uint32_t supplementary = codePoint - 0x10000U; + const uint32_t high = 0xd800U + (supplementary >> 10U); + const uint32_t low = 0xdc00U + (supplementary & 0x3ffU); + for (uint32_t surrogate : {high, low}) { + output->append("\\u"); + output->push_back(digits[(surrogate >> 12U) & 0x0fU]); + output->push_back(digits[(surrogate >> 8U) & 0x0fU]); + output->push_back(digits[(surrogate >> 4U) & 0x0fU]); + output->push_back(digits[surrogate & 0x0fU]); + } + } + } + output->push_back('"'); +} + +void AppendJsonAsciiString(std::string* output, std::string_view value) { + std::wstring wide; + wide.reserve(value.size()); + for (unsigned char character : value) { + wide.push_back(static_cast(character)); + } + AppendJsonString(output, wide); +} + +bool IsSafeRecoveryRelativePath(const std::filesystem::path& path) { + if (path.empty() || path.is_absolute() || path.has_root_name() || + path.has_root_directory() || path.lexically_normal() != path) { + return false; + } + size_t components = 0; + for (const std::filesystem::path& component : path) { + const std::wstring value = component.wstring(); + if (value.empty() || value == L"." || value == L".." || + value.find(L':') != std::wstring::npos || + std::any_of(value.begin(), value.end(), [](wchar_t character) { + return character < 0x20; + })) { + return false; + } + ++components; + } + return components != 0; +} + +const char* InstallJournalPhaseName(InstallJournalPhase phase) noexcept { + switch (phase) { + case InstallJournalPhase::Prepared: return "Prepared"; + case InstallJournalPhase::SetupCopyEntered: return "SetupCopyEntered"; + case InstallJournalPhase::SetupCopyReturned: return "SetupCopyReturned"; + case InstallJournalPhase::StageReceiptCaptured: + return "StageReceiptCaptured"; + case InstallJournalPhase::QuiesceSignalEntered: return "QuiesceSignalEntered"; + case InstallJournalPhase::QuiesceSignalReturned: return "QuiesceSignalReturned"; + case InstallJournalPhase::RootRegistrationIntentCaptured: + return "RootRegistrationIntentCaptured"; + case InstallJournalPhase::RootRegistrationEntered: return "RootRegistrationEntered"; + case InstallJournalPhase::RootRegistrationReturned: return "RootRegistrationReturned"; + case InstallJournalPhase::DiInstallEntered: return "DiInstallEntered"; + case InstallJournalPhase::DiInstallReturned: return "DiInstallReturned"; + case InstallJournalPhase::PriorAbiProfileCaptured: + return "PriorAbiProfileCaptured"; + case InstallJournalPhase::DriverValidated: return "DriverValidated"; + case InstallJournalPhase::BrokerHandoffEntered: return "BrokerHandoffEntered"; + case InstallJournalPhase::BrokerHandoffReturned: return "BrokerHandoffReturned"; + case InstallJournalPhase::BrokerChildEntered: return "BrokerChildEntered"; + case InstallJournalPhase::BrokerChildSettled: return "BrokerChildSettled"; + case InstallJournalPhase::BrokerOuterSettlementPending: + return "BrokerOuterSettlementPending"; + case InstallJournalPhase::BrokerOuterSettled: + return "BrokerOuterSettled"; + case InstallJournalPhase::RollbackBindingEntered: return "RollbackBindingEntered"; + case InstallJournalPhase::PartialRootRemovalEntered: + return "PartialRootRemovalEntered"; + case InstallJournalPhase::PartialRootRemovalReturned: + return "PartialRootRemovalReturned"; + case InstallJournalPhase::PartialRootRemovalRebootPending: + return "PartialRootRemovalRebootPending"; + case InstallJournalPhase::RollbackBindingReturned: return "RollbackBindingReturned"; + case InstallJournalPhase::SetupUninstallEntered: return "SetupUninstallEntered"; + case InstallJournalPhase::SetupUninstallReturned: return "SetupUninstallReturned"; + case InstallJournalPhase::ForwardValidated: return "ForwardValidated"; + case InstallJournalPhase::ExactPriorRestored: return "ExactPriorRestored"; + case InstallJournalPhase::ForwardRebootPending: return "ForwardRebootPending"; + case InstallJournalPhase::RestoreRebootPending: return "RestoreRebootPending"; + case InstallJournalPhase::ManualReconciliationRequired: + return "ManualReconciliationRequired"; + } + return "ManualReconciliationRequired"; +} + +std::optional ParseInstallJournalPhase( + std::string_view value) noexcept { + for (InstallJournalPhase phase : { + InstallJournalPhase::Prepared, + InstallJournalPhase::SetupCopyEntered, + InstallJournalPhase::SetupCopyReturned, + InstallJournalPhase::StageReceiptCaptured, + InstallJournalPhase::QuiesceSignalEntered, + InstallJournalPhase::QuiesceSignalReturned, + InstallJournalPhase::RootRegistrationIntentCaptured, + InstallJournalPhase::RootRegistrationEntered, + InstallJournalPhase::RootRegistrationReturned, + InstallJournalPhase::DiInstallEntered, + InstallJournalPhase::DiInstallReturned, + InstallJournalPhase::PriorAbiProfileCaptured, + InstallJournalPhase::DriverValidated, + InstallJournalPhase::BrokerHandoffEntered, + InstallJournalPhase::BrokerHandoffReturned, + InstallJournalPhase::BrokerChildEntered, + InstallJournalPhase::BrokerChildSettled, + InstallJournalPhase::BrokerOuterSettlementPending, + InstallJournalPhase::BrokerOuterSettled, + InstallJournalPhase::BrokerOuterSettlementPending, + InstallJournalPhase::BrokerOuterSettled, + InstallJournalPhase::RollbackBindingEntered, + InstallJournalPhase::PartialRootRemovalEntered, + InstallJournalPhase::PartialRootRemovalReturned, + InstallJournalPhase::PartialRootRemovalRebootPending, + InstallJournalPhase::RollbackBindingReturned, + InstallJournalPhase::SetupUninstallEntered, + InstallJournalPhase::SetupUninstallReturned, + InstallJournalPhase::ForwardValidated, + InstallJournalPhase::ExactPriorRestored, + InstallJournalPhase::ForwardRebootPending, + InstallJournalPhase::RestoreRebootPending, + InstallJournalPhase::ManualReconciliationRequired}) { + if (value == InstallJournalPhaseName(phase)) { + return phase; + } + } + return std::nullopt; +} + +bool InstallJournalPhaseRequiresPriorAbiProfile( + InstallJournalPhase phase) noexcept { + switch (phase) { + case InstallJournalPhase::PriorAbiProfileCaptured: + case InstallJournalPhase::DiInstallEntered: + case InstallJournalPhase::DiInstallReturned: + return true; + default: + return false; + } +} + +const char* InstallJournalDirectionName( + InstallJournalDirection direction) noexcept { + return direction == InstallJournalDirection::Rollback + ? "rollback" : "forward"; +} + +std::optional ParseInstallJournalDirection( + std::string_view value) noexcept { + if (value == "forward") return InstallJournalDirection::Forward; + if (value == "rollback") return InstallJournalDirection::Rollback; + return std::nullopt; +} + +bool Utf8ToWide(std::string_view value, std::wstring* wide, Error* error) { + if (value.size() > static_cast(std::numeric_limits::max())) { + return SetError(error, L"install-journal-utf8", ERROR_BUFFER_OVERFLOW); + } + if (value.empty()) { + wide->clear(); + return true; + } + const int bytes = static_cast(value.size()); + const int required = MultiByteToWideChar( + CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), bytes, nullptr, 0); + if (required <= 0) { + return SetLastErrorDetail(error, L"install-journal-utf8"); + } + wide->assign(static_cast(required), L'\0'); + if (MultiByteToWideChar( + CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), bytes, + wide->data(), required) != required) { + return SetLastErrorDetail(error, L"install-journal-utf8"); + } + return true; +} + +void AppendJsonUtf8String(std::string* output, std::string_view value) { + static constexpr char digits[] = "0123456789abcdef"; + output->push_back('"'); + for (unsigned char character : value) { + if (character == '"' || character == '\\') { + output->push_back('\\'); + output->push_back(static_cast(character)); + } else if (character < 0x20U) { + output->append("\\u00"); + output->push_back(digits[(character >> 4U) & 0x0fU]); + output->push_back(digits[character & 0x0fU]); + } else { + output->push_back(static_cast(character)); + } + } + output->push_back('"'); +} + +bool ResolveInstallRecoveryPaths( + std::filesystem::path* programData, + std::filesystem::path* product, + std::filesystem::path* component, + std::filesystem::path* transactions, + std::filesystem::path* active, + Error* error) { + PWSTR raw = nullptr; + const HRESULT result = SHGetKnownFolderPath( + FOLDERID_ProgramData, KF_FLAG_DEFAULT, nullptr, &raw); + if (FAILED(result) || raw == nullptr) { + return SetError(error, L"install-journal-programdata", + HRESULT_CODE(result == S_OK ? E_FAIL : result)); + } + try { + *programData = std::filesystem::path(raw).lexically_normal(); + *product = *programData / kInstallRecoveryProductDirectory; + *component = *product / kInstallRecoveryComponentDirectory; + *transactions = *component / kInstallRecoveryTransactionsDirectory; + *active = *transactions / kInstallRecoveryActiveDirectory; + } catch (...) { + CoTaskMemFree(raw); + throw; + } + CoTaskMemFree(raw); + if (!programData->is_absolute() || + active->lexically_relative(*programData).empty()) { + return SetError(error, L"install-journal-programdata", ERROR_INVALID_NAME, + L"known ProgramData did not resolve an absolute journal parent"); + } + return true; +} + +bool OpenStableDirectory( + const std::filesystem::path& path, + bool exactProtectedSecurity, + WinHandle* handle, + Error* error) { + handle->reset(CreateFileW( + path.c_str(), FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_BACKUP_SEMANTICS, + nullptr)); + if (!*handle) { + return SetLastErrorDetail(error, L"install-journal-directory-open"); + } + FILE_ATTRIBUTE_TAG_INFO attributes{}; + if (!GetFileInformationByHandleEx( + handle->get(), FileAttributeTagInfo, &attributes, + sizeof(attributes)) || + (attributes.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0 || + (attributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + return SetError(error, L"install-journal-directory-open", + ERROR_REPARSE_TAG_MISMATCH, + L"install journal components must be regular non-reparse directories"); + } + return !exactProtectedSecurity || VerifyProtectedFileSystemSecurity( + handle->get(), true, L"install-journal-directory-security", error); +} + +bool CreateOrOpenInstallRecoveryDirectoryWithSecurity( + const std::filesystem::path& path, + bool allowExisting, + bool exactProtectedSecurity, + const wchar_t* securitySddl, + WinHandle* handle, + bool* created, + Error* error) { + LocalSecurityDescriptor security; + if (!security.Initialize( + securitySddl, + L"install-journal-directory-security", error)) { + return false; + } + *created = false; + if (CreateDirectoryW(path.c_str(), security.attributes())) { + *created = true; + } else { + const DWORD code = GetLastError(); + if (code != ERROR_ALREADY_EXISTS || !allowExisting) { + return SetError(error, L"install-journal-directory-create", + code == ERROR_ALREADY_EXISTS ? ERROR_INSTALL_SUSPEND : code, + code == ERROR_ALREADY_EXISTS + ? L"an unfinished native driver transaction already exists" + : std::wstring{}); + } + } + return OpenStableDirectory(path, exactProtectedSecurity, handle, error); +} + +bool CreateOrOpenInstallRecoveryDirectory( + const std::filesystem::path& path, + bool allowExisting, + bool exactProtectedSecurity, + WinHandle* handle, + bool* created, + Error* error) { + return CreateOrOpenInstallRecoveryDirectoryWithSecurity( + path, allowExisting, exactProtectedSecurity, + kRollbackDirectorySecurity, handle, created, error); +} + +bool BuildInstallRecoveryProductDirectorySecurity( + const std::wstring& targetUserSid, + std::wstring* sddl, + Error* error) { + if (!IsSafeTargetUserSid(targetUserSid)) { + return SetError(error, L"install-journal-product-security", + ERROR_INVALID_SID, + L"fresh product-directory creation requires one canonical target-user SID"); + } + *sddl = L"O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + L"(A;OICI;GRGX;;;" + targetUserSid + L")"; + return true; +} + +bool InstallRecoveryChainHasActive( + bool productExists, + bool componentExists, + bool transactionsExist, + bool activeExists) noexcept { + return productExists && componentExists && + transactionsExist && activeExists; +} + +bool OpenExistingInstallRecoveryDirectory( + const std::filesystem::path& path, + bool exactProtectedSecurity, + WinHandle* handle, + bool* exists, + Error* error) { + *exists = false; + const DWORD attributes = GetFileAttributesW(path.c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES) { + const DWORD code = GetLastError(); + if (code == ERROR_FILE_NOT_FOUND || code == ERROR_PATH_NOT_FOUND) { + return true; + } + return SetError(error, L"install-journal-discovery", code); + } + if (!OpenStableDirectory(path, exactProtectedSecurity, handle, error)) { + return false; + } + *exists = true; + return true; +} + +struct InstallRecoveryDirectory { + std::filesystem::path programData; + std::filesystem::path product; + std::filesystem::path component; + std::filesystem::path transactions; + std::filesystem::path active; + WinHandle programDataHandle; + WinHandle productHandle; + WinHandle componentHandle; + WinHandle transactionsHandle; + WinHandle activeHandle; + bool activeCreated = false; + + bool OpenChain( + bool createActive, + const std::wstring* exactTargetUserSid, + bool* exists, + Error* error) { + *exists = false; + if (!ResolveInstallRecoveryPaths( + &programData, &product, &component, &transactions, &active, + error) || + !OpenStableDirectory( + programData, false, &programDataHandle, error)) { + return false; + } + if (!createActive) { + bool productExists = false; + bool componentExists = false; + bool transactionsExist = false; + bool activeExists = false; + if (!OpenExistingInstallRecoveryDirectory( + product, false, &productHandle, + &productExists, error)) { + return false; + } + if (!productExists) return true; + if (!VerifyProtectedProductDirectorySecurity( + productHandle.get(), nullptr, error) || + !OpenExistingInstallRecoveryDirectory( + component, true, &componentHandle, + &componentExists, error)) { + return false; + } + if (!componentExists) return true; + if (!OpenExistingInstallRecoveryDirectory( + transactions, true, &transactionsHandle, + &transactionsExist, error)) { + return false; + } + if (!transactionsExist) return true; + if (!OpenExistingInstallRecoveryDirectory( + active, true, &activeHandle, + &activeExists, error)) { + return false; + } + *exists = InstallRecoveryChainHasActive( + productExists, componentExists, + transactionsExist, activeExists); + return true; + } + if (exactTargetUserSid == nullptr) { + return SetError(error, L"install-journal-product-security", + ERROR_INVALID_PARAMETER, + L"fresh install journal creation requires the exact target-user SID"); + } + std::wstring productSecurity; + bool created = false; + if (!BuildInstallRecoveryProductDirectorySecurity( + *exactTargetUserSid, &productSecurity, error) || + !CreateOrOpenInstallRecoveryDirectoryWithSecurity( + product, true, false, productSecurity.c_str(), + &productHandle, &created, error) || + !VerifyProtectedProductDirectorySecurity( + productHandle.get(), exactTargetUserSid, error) || + !CreateOrOpenInstallRecoveryDirectory( + component, true, true, &componentHandle, &created, error) || + !CreateOrOpenInstallRecoveryDirectory( + transactions, true, true, &transactionsHandle, &created, + error)) { + return false; + } + if (createActive) { + const bool opened = CreateOrOpenInstallRecoveryDirectory( + active, false, true, &activeHandle, &created, error); + activeCreated = created; + if (!opened) { + return false; + } + *exists = true; + return true; + } + if (!OpenStableDirectory(active, true, &activeHandle, error)) { + return false; + } + *exists = true; + return true; + } +}; + +bool RetireInstallRecoveryActiveDirectory( + InstallRecoveryDirectory* directory, + std::string_view transactionId, + Error* error, + bool retainTombstone = false, + std::filesystem::path* retiredPath = nullptr) { + if (directory == nullptr || !IsSha256Digest(transactionId) || + directory->active.filename() != kInstallRecoveryActiveDirectory) { + return SetError(error, L"install-journal-retire-identity", + ERROR_INVALID_PARAMETER); + } + std::wstring transactionIdWide( + transactionId.begin(), transactionId.end()); + const std::filesystem::path tombstone = + directory->transactions / + (std::wstring(kInstallRecoverySettledPrefix) + transactionIdWide); + directory->activeHandle.reset(); + if (!MoveFileExW(directory->active.c_str(), tombstone.c_str(), + MOVEFILE_WRITE_THROUGH)) { + return SetLastErrorDetail(error, L"install-journal-retire-rename", + L"terminal journal could not be atomically moved out of active admission"); + } + const DWORD activeAttributes = GetFileAttributesW(directory->active.c_str()); + const DWORD activeError = activeAttributes == INVALID_FILE_ATTRIBUTES + ? GetLastError() : ERROR_SUCCESS; + if (activeAttributes != INVALID_FILE_ATTRIBUTES || + (activeError != ERROR_FILE_NOT_FOUND && + activeError != ERROR_PATH_NOT_FOUND)) { + if (error != nullptr) { + error->recoveryBackup = tombstone.wstring(); + error->recoveryBackupRetained = true; + } + return SetError(error, L"install-journal-retire-active-absence", + activeAttributes != INVALID_FILE_ATTRIBUTES + ? ERROR_ALREADY_EXISTS : activeError, + L"atomic retirement did not prove active-v2 absent"); + } + WinHandle tombstoneHandle; + if (!OpenStableDirectory( + tombstone, true, &tombstoneHandle, error)) { + if (error != nullptr) { + error->recoveryBackup = tombstone.wstring(); + error->recoveryBackupRetained = true; + } + return false; + } + ClearActiveRecoveryEvidence(); + if (retiredPath != nullptr) { + *retiredPath = tombstone; + } + tombstoneHandle.reset(); + + if (retainTombstone) { + return true; + } + + // Once active-v2 is atomically absent, cleanup is intentionally + // best-effort. A power loss may leave a settled-v2-* tombstone, but it is + // outside active admission and its transaction-bound name cannot be + // confused with an unfinished transaction. + std::error_code removalError; + std::filesystem::remove_all(tombstone, removalError); + if (removalError) { + std::wstring diagnostic = + L"VIIPER: settled install journal tombstone retained after cleanup error "; + diagnostic += std::to_wstring(removalError.value()); + diagnostic += L".\n"; + OutputDebugStringW(diagnostic.c_str()); + } + return true; +} + +bool PublishInstallRecoveryEvidence( + const std::filesystem::path& active, + uint64_t sequence, + Error* error) { + std::wostringstream name; + name << kInstallRecoveryJournalPrefix << std::setw(8) << std::setfill(L'0') + << sequence << kInstallRecoveryJournalSuffix; + const std::filesystem::path record = active / name.str(); + const std::wstring activeValue = active.wstring(); + const std::wstring recordValue = record.wstring(); + if (activeValue.empty() || recordValue.empty() || + activeValue.size() >= gActiveBackupRoot.size() || + recordValue.size() >= gActiveRecoveryRecord.size()) { + return SetError(error, L"install-journal-evidence", + ERROR_FILENAME_EXCED_RANGE, + L"fixed recovery journal path exceeds the exception-safe reporting bound"); + } + ClearActiveRecoveryEvidence(); + std::copy(activeValue.begin(), activeValue.end(), gActiveBackupRoot.begin()); + std::copy(recordValue.begin(), recordValue.end(), gActiveRecoveryRecord.begin()); + gActiveBackupRootRetained = true; + return true; +} + +bool GetBootIdentifier(std::string* identifier, Error* error) { + using NtQuerySystemInformationFn = LONG(NTAPI*)(ULONG, PVOID, ULONG, PULONG); + struct BootEnvironmentInformation { + GUID bootIdentifier; + ULONG firmwareType; + ULONGLONG bootFlags; + } information{}; + const HMODULE ntdll = GetModuleHandleW(L"ntdll.dll"); + const auto query = ntdll == nullptr ? nullptr + : reinterpret_cast( + GetProcAddress(ntdll, "NtQuerySystemInformation")); + if (query == nullptr || query(90U, &information, + static_cast(sizeof(information)), nullptr) < 0) { + return SetError(error, L"install-journal-boot-identifier", + ERROR_NOT_SUPPORTED, + L"the current boot session could not be identified durably"); + } + wchar_t value[64]{}; + if (StringFromGUID2(information.bootIdentifier, value, + static_cast(std::size(value))) <= 0) { + return SetError(error, L"install-journal-boot-identifier", + ERROR_INVALID_DATA); + } + identifier->clear(); + for (wchar_t character : std::wstring_view(value)) { + if (character == L'{' || character == L'}' || character == L'-') { + continue; + } + if (character > 0x7f) { + return SetError(error, L"install-journal-boot-identifier", + ERROR_INVALID_DATA); + } + identifier->push_back(static_cast( + std::tolower(static_cast(character)))); + } + if (identifier->size() != 32U) { + return SetError(error, L"install-journal-boot-identifier", + ERROR_INVALID_DATA); + } + return true; +} + +bool IsCanonicalBootIdentifier(std::string_view identifier) noexcept { + return identifier.size() == 32U && + std::all_of(identifier.begin(), identifier.end(), + [](unsigned char character) { + return (character >= '0' && character <= '9') || + (character >= 'a' && character <= 'f'); + }); +} + +struct InstallJournalStateData { + InstallJournalPhase phase = InstallJournalPhase::Prepared; + InstallJournalDirection direction = InstallJournalDirection::Forward; + bool rollbackAuthorized = false; + uint64_t sequence = 0; + std::string previousDigest = std::string(kZeroSha256); + std::string lastDigest; + std::string transactionId; + std::string bootIdentifier; + std::string pendingRebootBootIdentifier; + std::string sourceRevision; + bool production = true; + bool localTest = false; + bool brokerRequired = false; + std::string brokerExecutableSha256; + std::filesystem::path brokerTokenPath; + std::wstring brokerTargetUserSid; + bool brokerEntered = false; + bool brokerSettled = false; + bool hasBrokerProof = false; + bool brokerProofSuccess = false; + bool brokerProofChanged = false; + bool brokerDriverRollbackAuthorized = false; + std::string brokerProofRollback; + DWORD brokerProofExitCode = ERROR_SUCCESS; + std::string brokerJournalTransactionId; + std::string brokerJournalOuterTransactionId; + std::string brokerJournalCandidateSha256; + std::string brokerJournalState; + std::string brokerJournalDigest; + std::string brokerSettlementNonce; + std::string brokerDriverPendingDigest; + std::string brokerSettlementRequestSha256; + std::string brokerGoPendingDigest; + bool hasPriorAbiProfile = false; + AbiCompatibilityProfile priorAbiProfile{}; + bool hasRootRegistrationIntent = false; + std::wstring rootRegistrationInstanceId; + enum class PartialRootRemovalBinding { + None, + Unbound, + Candidate, + } partialRootRemovalBinding = PartialRootRemovalBinding::None; + std::string partialRootRemovalBootIdentifier; + Snapshot prior; + PackageInfo candidate; + PackageInfo publishedCandidate; + bool hasPublishedCandidate = false; + std::vector expectedInventory; + bool packageStagedHere = false; + bool bindingMutationStarted = false; + bool rebootRequired = false; + bool freshRebootRequired = false; + bool callSucceeded = true; + DWORD callError = ERROR_SUCCESS; + bool deadlineOverrun = false; +}; + +bool ValidateInstallJournalTransition( + const InstallJournalStateData* previous, + const InstallJournalStateData& next, + Error* error); + +bool VerifyInstallJournalRawPriorTopology( + const InstallJournalStateData& state, + Error* error); + +bool VerifyInstallJournalRawForwardTopology( + const InstallJournalStateData& state, + Error* error); + +void AppendPackageIdentityJson( + std::string* output, + const PackageInfo& package, + std::wstring_view backupInf) { + output->append("{\"publishedInf\":"); + AppendJsonString(output, package.publishedName); + output->append(",\"version\":"); + AppendJsonString(output, VersionToString(package.version)); + output->append(",\"infSha256\":"); + AppendJsonAsciiString(output, LowerAscii(package.infSha256)); + output->append(",\"sysSha256\":"); + AppendJsonAsciiString(output, LowerAscii(package.sysSha256)); + output->append(",\"catSha256\":"); + AppendJsonAsciiString(output, LowerAscii(package.catSha256)); + output->append(",\"backupInf\":"); + AppendJsonString(output, backupInf); + output->push_back('}'); +} + +bool BuildInstallJournalPayload( + const InstallJournalStateData& state, + std::string* payload, + Error* error) { + const bool priorRequiresAbiProfile = + state.prior.devices.size() == 1U && + state.prior.devices[0].started && + state.prior.devices[0].problem == 0; + const bool authoritativeRebootReturn = + state.phase == InstallJournalPhase::DiInstallReturned || + state.phase == InstallJournalPhase::RollbackBindingReturned || + state.phase == + InstallJournalPhase::PartialRootRemovalReturned; + const bool partialRootRemovalPhase = + state.phase == InstallJournalPhase::PartialRootRemovalEntered || + state.phase == InstallJournalPhase::PartialRootRemovalReturned || + state.phase == InstallJournalPhase:: + PartialRootRemovalRebootPending; + const bool hasPartialRootRemovalBinding = + state.partialRootRemovalBinding != + InstallJournalStateData::PartialRootRemovalBinding::None; + const bool rebootPendingPhase = + state.phase == InstallJournalPhase::ForwardRebootPending || + state.phase == InstallJournalPhase::RestoreRebootPending; + const bool brokerInvocationCanonical = state.brokerRequired + ? IsCanonicalLowerHex(state.brokerExecutableSha256, 64U) && + state.brokerTokenPath.is_absolute() && + state.brokerTokenPath.extension() == L".token" && + IsSafeTargetUserSid(state.brokerTargetUserSid) + : state.brokerExecutableSha256.empty() && + state.brokerTokenPath.empty() && + state.brokerTargetUserSid.empty(); + const bool brokerJournalCanonical = state.hasBrokerProof && + state.brokerProofChanged + ? IsCanonicalLowerHex(state.brokerJournalTransactionId, 32U) && + IsCanonicalLowerHex(state.brokerJournalOuterTransactionId, 64U) && + IsCanonicalLowerHex(state.brokerJournalCandidateSha256, 64U) && + IsCanonicalLowerHex(state.brokerJournalDigest, 64U) && + state.brokerJournalOuterTransactionId == state.transactionId && + state.brokerJournalCandidateSha256 == + state.brokerExecutableSha256 && + ((state.brokerProofSuccess && + state.brokerJournalState == "nested-ready") || + (!state.brokerProofSuccess && + state.brokerDriverRollbackAuthorized && + state.brokerJournalState == "rollback-settled") || + (!state.brokerProofSuccess && + !state.brokerDriverRollbackAuthorized && + state.brokerJournalState == "manual")) + : state.brokerJournalTransactionId.empty() && + state.brokerJournalOuterTransactionId.empty() && + state.brokerJournalCandidateSha256.empty() && + state.brokerJournalState.empty() && + state.brokerJournalDigest.empty(); + const bool settlementPending = + state.phase == InstallJournalPhase::BrokerOuterSettlementPending; + const bool settlementFinal = + state.phase == InstallJournalPhase::BrokerOuterSettled; + const bool brokerSettlementCanonical = settlementPending + ? IsCanonicalLowerHex(state.brokerSettlementNonce, 64U) && + state.brokerDriverPendingDigest.empty() && + state.brokerSettlementRequestSha256.empty() && + state.brokerGoPendingDigest.empty() + : settlementFinal + ? IsCanonicalLowerHex(state.brokerSettlementNonce, 64U) && + IsCanonicalLowerHex( + state.brokerDriverPendingDigest, 64U) && + IsCanonicalLowerHex( + state.brokerSettlementRequestSha256, 64U) && + IsCanonicalLowerHex(state.brokerGoPendingDigest, 64U) + : state.brokerSettlementNonce.empty() && + state.brokerDriverPendingDigest.empty() && + state.brokerSettlementRequestSha256.empty() && + state.brokerGoPendingDigest.empty(); + if (!IsSha256Digest(state.previousDigest) || + !IsCanonicalBootIdentifier(state.bootIdentifier) || + (!state.pendingRebootBootIdentifier.empty() && + !IsCanonicalBootIdentifier( + state.pendingRebootBootIdentifier)) || + (!state.partialRootRemovalBootIdentifier.empty() && + !IsCanonicalBootIdentifier( + state.partialRootRemovalBootIdentifier)) || + (partialRootRemovalPhase && + (state.partialRootRemovalBootIdentifier.empty() || + !hasPartialRootRemovalBinding)) || + (state.partialRootRemovalBootIdentifier.empty() != + !hasPartialRootRemovalBinding) || + (!state.partialRootRemovalBootIdentifier.empty() && + (!state.hasRootRegistrationIntent || + !state.prior.devices.empty() || + state.direction != InstallJournalDirection::Rollback || + !state.rollbackAuthorized)) || + (!state.rebootRequired && + !state.pendingRebootBootIdentifier.empty()) || + (rebootPendingPhase && + state.pendingRebootBootIdentifier.empty()) || + (state.freshRebootRequired && + (!state.rebootRequired || + state.pendingRebootBootIdentifier.empty() || + !authoritativeRebootReturn)) || + !IsSha256Digest(state.candidate.infSha256) || + !IsSha256Digest(state.candidate.sysSha256) || + !IsSha256Digest(state.candidate.catSha256) || + (state.hasPriorAbiProfile && + !IsKnownAbiCompatibilityProfile(state.priorAbiProfile)) || + (state.hasRootRegistrationIntent && + (!state.prior.devices.empty() || + !state.hasPublishedCandidate || + !IsGeneratedRootInstanceIdForDeviceName( + state.rootRegistrationInstanceId, + kRootDeviceName))) || + (!state.hasRootRegistrationIntent && + (!state.rootRegistrationInstanceId.empty() || + state.phase == + InstallJournalPhase::RootRegistrationIntentCaptured || + (state.prior.devices.empty() && + state.bindingMutationStarted))) || + (state.phase == InstallJournalPhase::RootRegistrationIntentCaptured && + (state.direction != InstallJournalDirection::Forward || + state.bindingMutationStarted)) || + (state.hasBrokerProof && + !BrokerProofFieldsAreCanonical( + state.brokerProofSuccess, + state.brokerProofChanged, + state.brokerProofRollback, + state.brokerProofExitCode, + state.brokerDriverRollbackAuthorized)) || + !brokerInvocationCanonical || !brokerJournalCanonical || + !brokerSettlementCanonical || + ((settlementPending || settlementFinal) && + (!state.brokerRequired || !state.hasBrokerProof || + !state.brokerProofSuccess || + !state.brokerProofChanged || + state.brokerDriverRollbackAuthorized || + state.direction != InstallJournalDirection::Forward || + state.rollbackAuthorized)) || + (state.hasBrokerProof && + state.brokerDriverRollbackAuthorized != + state.rollbackAuthorized) || + (state.brokerSettled && !state.hasBrokerProof && + !state.rollbackAuthorized) || + ((state.direction == InstallJournalDirection::Rollback) != + state.rollbackAuthorized) || + (priorRequiresAbiProfile && + (InstallJournalPhaseRequiresPriorAbiProfile(state.phase) || + state.bindingMutationStarted) && + !state.hasPriorAbiProfile) || + state.sequence >= kMaximumInstallRecoveryRecords) { + return SetError(error, L"install-journal-state", ERROR_INVALID_DATA); + } + payload->clear(); + payload->append("{\"sequence\":"); + payload->append(std::to_string(state.sequence)); + payload->append(",\"previousSha256\":"); + AppendJsonAsciiString(payload, LowerAscii(state.previousDigest)); + payload->append(",\"phase\":"); + AppendJsonAsciiString(payload, InstallJournalPhaseName(state.phase)); + payload->append(",\"direction\":"); + AppendJsonAsciiString(payload, + InstallJournalDirectionName(state.direction)); + payload->append(",\"rollbackAuthorized\":"); + payload->append(state.rollbackAuthorized ? "true" : "false"); + payload->append(",\"transactionId\":"); + AppendJsonAsciiString(payload, state.transactionId); + payload->append(",\"bootIdentifier\":"); + AppendJsonAsciiString(payload, state.bootIdentifier); + payload->append(",\"pendingRebootBootIdentifier\":"); + if (state.pendingRebootBootIdentifier.empty()) { + payload->append("null"); + } else { + AppendJsonAsciiString( + payload, state.pendingRebootBootIdentifier); + } + payload->append(",\"sourceRevision\":"); + AppendJsonAsciiString(payload, LowerAscii(state.sourceRevision)); + payload->append(",\"production\":"); + payload->append(state.production ? "true" : "false"); + payload->append(",\"localTest\":"); + payload->append(state.localTest ? "true" : "false"); + payload->append(",\"brokerRequired\":"); + payload->append(state.brokerRequired ? "true" : "false"); + payload->append(",\"brokerInvocation\":"); + if (state.brokerRequired) { + payload->append("{\"executableSha256\":"); + AppendJsonAsciiString(payload, state.brokerExecutableSha256); + payload->append(",\"tokenPath\":"); + AppendJsonString(payload, state.brokerTokenPath.wstring()); + payload->append(",\"targetUserSid\":"); + AppendJsonString(payload, state.brokerTargetUserSid); + payload->push_back('}'); + } else { + payload->append("null"); + } + payload->append(",\"brokerEntered\":"); + payload->append(state.brokerEntered ? "true" : "false"); + payload->append(",\"brokerSettled\":"); + payload->append(state.brokerSettled ? "true" : "false"); + payload->append(",\"brokerProof\":"); + if (state.hasBrokerProof) { + payload->append("{\"success\":"); + payload->append(state.brokerProofSuccess ? "true" : "false"); + payload->append(",\"changed\":"); + payload->append(state.brokerProofChanged ? "true" : "false"); + payload->append(",\"rollback\":"); + AppendJsonAsciiString(payload, state.brokerProofRollback); + payload->append(",\"exitCode\":"); + payload->append(std::to_string(state.brokerProofExitCode)); + payload->append(",\"driverRollbackAuthorized\":"); + payload->append(state.brokerDriverRollbackAuthorized + ? "true" : "false"); + payload->append(",\"journal\":"); + if (state.brokerProofChanged) { + payload->append("{\"transactionId\":"); + AppendJsonAsciiString(payload, + state.brokerJournalTransactionId); + payload->append(",\"outerTransactionId\":"); + AppendJsonAsciiString(payload, + state.brokerJournalOuterTransactionId); + payload->append(",\"candidateSha256\":"); + AppendJsonAsciiString(payload, + state.brokerJournalCandidateSha256); + payload->append(",\"state\":"); + AppendJsonAsciiString(payload, state.brokerJournalState); + payload->append(",\"digest\":"); + AppendJsonAsciiString(payload, state.brokerJournalDigest); + payload->push_back('}'); + } else { + payload->append("null"); + } + payload->push_back('}'); + } else { + payload->append("null"); + } + payload->append(",\"brokerSettlement\":"); + if (settlementPending || settlementFinal) { + payload->append("{\"nonce\":"); + AppendJsonAsciiString(payload, state.brokerSettlementNonce); + payload->append(",\"driverPendingDigest\":"); + if (settlementFinal) { + AppendJsonAsciiString(payload, + state.brokerDriverPendingDigest); + } else { + payload->append("null"); + } + payload->append(",\"requestSha256\":"); + if (settlementFinal) { + AppendJsonAsciiString(payload, + state.brokerSettlementRequestSha256); + } else { + payload->append("null"); + } + payload->append(",\"brokerPendingDigest\":"); + if (settlementFinal) { + AppendJsonAsciiString(payload, + state.brokerGoPendingDigest); + } else { + payload->append("null"); + } + payload->push_back('}'); + } else { + payload->append("null"); + } + payload->append(",\"priorAbiProfile\":"); + if (state.hasPriorAbiProfile) { + payload->append("{\"minor\":"); + payload->append(std::to_string(state.priorAbiProfile.minor)); + payload->append(",\"capabilities\":"); + payload->append(std::to_string(state.priorAbiProfile.capabilities)); + payload->append(",\"statsSize\":"); + payload->append(std::to_string(state.priorAbiProfile.statsSize)); + payload->append(",\"hasReservedPortFields\":"); + payload->append(state.priorAbiProfile.hasReservedPortFields + ? "true" : "false"); + payload->push_back('}'); + } else { + payload->append("null"); + } + payload->append(",\"rootRegistrationInstanceId\":"); + if (state.hasRootRegistrationIntent) { + AppendJsonString(payload, state.rootRegistrationInstanceId); + } else { + payload->append("null"); + } + payload->append(",\"partialRootRemovalBootIdentifier\":"); + if (state.partialRootRemovalBootIdentifier.empty()) { + payload->append("null"); + } else { + AppendJsonAsciiString( + payload, state.partialRootRemovalBootIdentifier); + } + payload->append(",\"partialRootRemovalBinding\":"); + switch (state.partialRootRemovalBinding) { + case InstallJournalStateData::PartialRootRemovalBinding::None: + payload->append("null"); + break; + case InstallJournalStateData::PartialRootRemovalBinding::Unbound: + AppendJsonAsciiString(payload, "unbound"); + break; + case InstallJournalStateData::PartialRootRemovalBinding::Candidate: + AppendJsonAsciiString(payload, "candidate"); + break; + } + payload->append(",\"packageStagedHere\":"); + payload->append(state.packageStagedHere ? "true" : "false"); + payload->append(",\"bindingMutationStarted\":"); + payload->append(state.bindingMutationStarted ? "true" : "false"); + payload->append(",\"rebootRequired\":"); + payload->append(state.rebootRequired ? "true" : "false"); + payload->append(",\"freshRebootRequired\":"); + payload->append(state.freshRebootRequired ? "true" : "false"); + payload->append(",\"callSucceeded\":"); + payload->append(state.callSucceeded ? "true" : "false"); + payload->append(",\"callError\":"); + payload->append(std::to_string(state.callError)); + payload->append(",\"deadlineOverrun\":"); + payload->append(state.deadlineOverrun ? "true" : "false"); + payload->append(",\"candidate\":"); + AppendPackageIdentityJson(payload, state.candidate, + std::wstring(kInstallRecoveryCandidateDirectory) + L"/ViiperUde.inf"); + payload->append(",\"publishedCandidate\":"); + if (state.hasPublishedCandidate) { + AppendPackageIdentityJson(payload, state.publishedCandidate, + std::wstring(kInstallRecoveryCandidateDirectory) + L"/ViiperUde.inf"); + } else { + payload->append("null"); + } + payload->append(",\"priorPackages\":["); + for (size_t index = 0; index < state.prior.packages.size(); ++index) { + if (index != 0) payload->push_back(','); + AppendPackageIdentityJson(payload, state.prior.packages[index], + std::wstring(kInstallRecoveryPriorDirectory) + L"/" + + std::to_wstring(index) + L"/ViiperUde.inf"); + } + payload->append("],\"priorDevices\":["); + for (size_t index = 0; index < state.prior.devices.size(); ++index) { + if (index != 0) payload->push_back(','); + const DeviceState& device = state.prior.devices[index]; + payload->append("{\"instanceId\":"); + AppendJsonString(payload, device.instanceId); + payload->append(",\"present\":"); + payload->append(device.present ? "true" : "false"); + payload->append(",\"started\":"); + payload->append(device.started ? "true" : "false"); + payload->append(",\"problem\":"); + payload->append(std::to_string(device.problem)); + payload->append(",\"service\":"); + AppendJsonString(payload, device.service); + payload->append(",\"publishedInf\":"); + AppendJsonString(payload, device.publishedInf); + payload->append(",\"version\":"); + AppendJsonString(payload, VersionToString(device.version)); + payload->append(",\"packageInfSha256\":"); + AppendJsonAsciiString(payload, LowerAscii(device.package.infSha256)); + payload->append(",\"packageSysSha256\":"); + AppendJsonAsciiString(payload, LowerAscii(device.package.sysSha256)); + payload->append(",\"packageCatSha256\":"); + AppendJsonAsciiString(payload, LowerAscii(device.package.catSha256)); + payload->push_back('}'); + } + payload->append("],\"expectedInventory\":["); + for (size_t index = 0; index < state.expectedInventory.size(); ++index) { + if (index != 0) payload->push_back(','); + AppendPackageIdentityJson(payload, state.expectedInventory[index], L""); + } + payload->append("]}"); + if (payload->size() > kMaximumRecoveryRecordBytes) { + return SetError(error, L"install-journal-size", ERROR_FILE_TOO_LARGE); + } + return true; +} + +bool WriteInstallJournalRecord( + const std::filesystem::path& active, + InstallJournalStateData* state, + Error* error) { + std::string payload; + std::string digest; + if (!BuildInstallJournalPayload(*state, &payload, error) || + !Sha256Data(payload, &digest, error)) { + return false; + } + std::string record = "{\"schema\":2,\"kind\":"; + AppendJsonAsciiString(&record, kInstallRecoveryKind); + record.append(",\"payloadSha256\":"); + AppendJsonAsciiString(&record, digest); + record.append(",\"payload\":"); + AppendJsonUtf8String(&record, payload); + record.append("}\n"); + if (record.size() > kMaximumRecoveryRecordBytes) { + return SetError(error, L"install-journal-size", ERROR_FILE_TOO_LARGE); + } + + std::wostringstream finalName; + finalName << kInstallRecoveryJournalPrefix << std::setw(8) + << std::setfill(L'0') << state->sequence + << kInstallRecoveryJournalSuffix; + const std::filesystem::path finalPath = active / finalName.str(); + const std::filesystem::path temporaryPath = + active / (finalName.str() + kInstallRecoveryTemporarySuffix); + LocalSecurityDescriptor security; + if (!security.Initialize( + kRecoveryRecordSecurity, L"install-journal-file-security", error)) { + return false; + } + WinHandle file(CreateFileW( + temporaryPath.c_str(), + GENERIC_READ | GENERIC_WRITE | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, security.attributes(), CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_WRITE_THROUGH, + nullptr)); + if (!file) { + return SetLastErrorDetail(error, L"install-journal-create"); + } + const auto discard = [&]() noexcept { + file.reset(); + DeleteFileW(temporaryPath.c_str()); + }; + FILE_ATTRIBUTE_TAG_INFO attributes{}; + if (!GetFileInformationByHandleEx( + file.get(), FileAttributeTagInfo, &attributes, sizeof(attributes)) || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0 || + !VerifyProtectedFileSystemSecurity( + file.get(), false, L"install-journal-file-security", error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-create", ERROR_REPARSE_TAG_MISMATCH); + } + discard(); + return false; + } + size_t offset = 0; + while (offset < record.size()) { + DWORD written = 0; + const DWORD requested = static_cast(std::min( + record.size() - offset, MAXDWORD)); + if (!WriteFile(file.get(), record.data() + offset, requested, + &written, nullptr) || written == 0) { + const DWORD code = GetLastError() == ERROR_SUCCESS + ? ERROR_WRITE_FAULT : GetLastError(); + SetError(error, L"install-journal-write", code); + discard(); + return false; + } + offset += written; + } + if (!FlushFileBuffers(file.get())) { + SetLastErrorDetail(error, L"install-journal-flush"); + discard(); + return false; + } + file.reset(); + if (!MoveFileExW( + temporaryPath.c_str(), finalPath.c_str(), MOVEFILE_WRITE_THROUGH)) { + const DWORD code = GetLastError(); + DeleteFileW(temporaryPath.c_str()); + return SetError(error, L"install-journal-publish", code); + } + file.reset(CreateFileW( + finalPath.c_str(), GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_SEQUENTIAL_SCAN, + nullptr)); + if (!file || !VerifyProtectedFileSystemSecurity( + file.get(), false, L"install-journal-file-security", error)) { + if (!file) SetLastErrorDetail(error, L"install-journal-reopen"); + return false; + } + std::string observed(record.size(), '\0'); + DWORD read = 0; + if (!ReadFile(file.get(), observed.data(), static_cast(observed.size()), + &read, nullptr) || read != observed.size() || observed != record) { + return SetError(error, L"install-journal-readback", ERROR_CRC, + L"published journal record does not match the flushed bytes"); + } + char trailing = 0; + DWORD trailingRead = 0; + if (!ReadFile(file.get(), &trailing, 1, &trailingRead, nullptr) || + trailingRead != 0) { + return SetError(error, L"install-journal-readback", ERROR_FILE_INVALID, + L"published journal record has trailing bytes"); + } + state->lastDigest = digest; + state->previousDigest = digest; + ++state->sequence; + gActiveRecoveryRecordWritten = true; + return true; +} + +bool GenerateInstallTransactionId(std::string* identifier, Error* error) { + HCRYPTPROV provider = 0; + if (!CryptAcquireContextW( + &provider, nullptr, nullptr, PROV_RSA_AES, + CRYPT_VERIFYCONTEXT | CRYPT_SILENT)) { + return SetLastErrorDetail(error, L"install-journal-transaction-id"); + } + std::array random{}; + const BOOL generated = CryptGenRandom( + provider, static_cast(random.size()), random.data()); + const DWORD code = generated ? ERROR_SUCCESS : GetLastError(); + CryptReleaseContext(provider, 0); + if (!generated) { + return SetError(error, L"install-journal-transaction-id", code); + } + static constexpr char digits[] = "0123456789abcdef"; + identifier->clear(); + identifier->reserve(random.size() * 2U); + for (BYTE value : random) { + identifier->push_back(digits[value >> 4U]); + identifier->push_back(digits[value & 0x0fU]); + } + return true; +} + +bool CopyCandidateIntoInstallJournal( + const std::filesystem::path& sourceDirectory, + const std::filesystem::path& destinationDirectory, + const PackageInfo& expected, + bool localTest, + PackageInfo* verified, + std::vector* locks, + Error* error) { + if (!CopyProtectedBackupFile( + sourceDirectory / L"ViiperUde.inf", + destinationDirectory / L"ViiperUde.inf", error) || + !CopyProtectedBackupFile( + sourceDirectory / kDriverFileName, + destinationDirectory / kDriverFileName, error) || + !CopyProtectedBackupFile( + sourceDirectory / kCatalogName, + destinationDirectory / kCatalogName, error) || + !ValidateExactPackageDirectory(destinationDirectory, error)) { + return false; + } + bool owned = false; + if (!LoadOwnedPackage( + destinationDirectory / L"ViiperUde.inf", true, localTest, + verified, &owned, error) || !owned || + !(verified->version == expected.version) || + !SamePackageBytes(*verified, expected)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-candidate-identity", + ERROR_REVISION_MISMATCH, + L"protected candidate copy differs from the reviewed package bytes"); + } + return false; + } + verified->publishedName.clear(); + return LockPackageFiles(destinationDirectory, locks, error); +} + +bool LockProtectedBrokerImage( + const std::filesystem::path& image, + std::string_view expectedSha256, + WinHandle* lock, + Error* error) { + if (!IsCanonicalLowerHex(expectedSha256, 64U)) { + return SetError(error, L"install-journal-broker-image", + ERROR_INVALID_PARAMETER); + } + lock->reset(CreateFileW( + image.c_str(), GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_SEQUENTIAL_SCAN, + nullptr)); + if (!*lock) { + return SetLastErrorDetail(error, L"install-journal-broker-image-open"); + } + FILE_ATTRIBUTE_TAG_INFO attributes{}; + BY_HANDLE_FILE_INFORMATION identity{}; + std::array header{}; + DWORD read = 0; + if (!GetFileInformationByHandleEx( + lock->get(), FileAttributeTagInfo, &attributes, + sizeof(attributes)) || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0 || + !GetFileInformationByHandle(lock->get(), &identity) || + identity.nNumberOfLinks != 1U || + !VerifyProtectedFileSystemSecurity( + lock->get(), false, + L"install-journal-broker-image-security", error) || + !ReadFile(lock->get(), header.data(), + static_cast(header.size()), &read, nullptr) || + read != header.size() || header[0] != 'M' || header[1] != 'Z') { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-broker-image", + ERROR_BAD_EXE_FORMAT, + L"protected broker evidence must be one single-link non-reparse PE image"); + } + return false; + } + std::string observed; + if (!Sha256Handle(lock->get(), &observed, error)) { + error->phase = L"install-journal-broker-image-hash"; + return false; + } + if (observed != expectedSha256) { + return SetError(error, L"install-journal-broker-image-hash", + ERROR_CRC, + L"protected broker evidence differs from its immutable digest"); + } + return true; +} + +bool ValidateExactBrokerEvidenceDirectory( + const std::filesystem::path& directory, + Error* error) { + size_t entries = 0; + std::error_code enumerationError; + for (std::filesystem::directory_iterator iterator( + directory, enumerationError), end; + !enumerationError && iterator != end; + iterator.increment(enumerationError)) { + ++entries; + if (iterator->path().filename() != + kInstallRecoveryBrokerExecutable) { + return SetError(error, L"install-journal-broker-evidence", + ERROR_INVALID_DATA, + L"protected broker evidence directory contains an unexpected entry"); + } + } + if (enumerationError || entries != 1U) { + return SetError(error, L"install-journal-broker-evidence", + enumerationError + ? static_cast(enumerationError.value()) + : ERROR_FILE_NOT_FOUND, + L"protected broker evidence directory is incomplete"); + } + return true; +} + +struct InstallJournal::Impl { + InstallRecoveryDirectory directory; + InstallJournalStateData state; + std::vector priorBackups; + std::vector candidateLocks; + WinHandle brokerLock; + bool preparedRecord = false; + bool retired = false; + bool poisoned = false; + bool evidenceMayBeDurable = false; + bool forwardRootRegistrationEntered = false; + bool forwardDiInstallEntered = false; + bool partialRootRemovalEntered = false; + + ~Impl() noexcept { + if (preparedRecord || retired || poisoned || evidenceMayBeDurable || + !directory.activeCreated || + directory.active.empty()) { + return; + } + candidateLocks.clear(); + brokerLock.reset(); + priorBackups.clear(); + directory.activeHandle.reset(); + std::error_code ignored; + std::filesystem::remove_all(directory.active, ignored); + std::error_code presenceError; + if (!std::filesystem::exists(directory.active, presenceError) && + !presenceError) { + ClearActiveRecoveryEvidence(); + } + } +}; + +InstallJournal::InstallJournal() = default; +InstallJournal::~InstallJournal() = default; + +bool InstallJournal::Prepare( + const Snapshot& prior, + const PackageInfo& candidate, + const std::filesystem::path& candidateDirectory, + const std::vector& expectedInventory, + const InstallOptions& options, + Error* error) { + impl_ = std::make_unique(); + bool exists = false; + if (!impl_->directory.OpenChain( + true, &options.targetUserSid, &exists, error) || !exists || + !PublishInstallRecoveryEvidence(impl_->directory.active, 0, error)) { + return false; + } + bool created = false; + WinHandle priorDirectoryHandle; + WinHandle candidateDirectoryHandle; + const std::filesystem::path priorDirectory = + impl_->directory.active / kInstallRecoveryPriorDirectory; + const std::filesystem::path protectedCandidateDirectory = + impl_->directory.active / kInstallRecoveryCandidateDirectory; + if (!CreateOrOpenInstallRecoveryDirectory( + priorDirectory, false, true, &priorDirectoryHandle, &created, + error) || + !CreateOrOpenInstallRecoveryDirectory( + protectedCandidateDirectory, false, true, + &candidateDirectoryHandle, &created, error) || + !BackupPackagesIntoDirectory( + prior.packages, priorDirectory, &impl_->priorBackups, error)) { + return false; + } + PackageInfo protectedCandidate; + if (!CopyCandidateIntoInstallJournal( + candidateDirectory, protectedCandidateDirectory, candidate, + options.localTest, &protectedCandidate, &impl_->candidateLocks, + error)) { + return false; + } + + if (!options.brokerExecutable.empty()) { + WinHandle brokerDirectoryHandle; + const std::filesystem::path brokerDirectory = + impl_->directory.active / kInstallRecoveryBrokerDirectory; + const std::filesystem::path brokerImage = + brokerDirectory / kInstallRecoveryBrokerExecutable; + if (!CreateOrOpenInstallRecoveryDirectory( + brokerDirectory, false, true, &brokerDirectoryHandle, + &created, error) || + !CopyProtectedBackupFile( + options.brokerExecutable, brokerImage, error) || + !LockProtectedBrokerImage( + brokerImage, LowerAscii(options.brokerSha256), + &impl_->brokerLock, error)) { + return false; + } + } + + impl_->state.prior = prior; + impl_->state.candidate = candidate; + impl_->state.expectedInventory = expectedInventory; + impl_->state.production = options.production; + impl_->state.localTest = options.localTest; + impl_->state.brokerRequired = !options.brokerExecutable.empty(); + if (impl_->state.brokerRequired) { + impl_->state.brokerExecutableSha256 = + LowerAscii(options.brokerSha256); + impl_->state.brokerTokenPath = options.brokerToken; + impl_->state.brokerTargetUserSid = options.targetUserSid; + } + impl_->state.sourceRevision = options.sourceRevision; + if (!GetBootIdentifier(&impl_->state.bootIdentifier, error)) { + return false; + } + if (!options.brokerTokenSha256.empty()) { + impl_->state.transactionId = LowerAscii(options.brokerTokenSha256); + } else if (!GenerateInstallTransactionId( + &impl_->state.transactionId, error)) { + return false; + } + impl_->state.phase = InstallJournalPhase::Prepared; + if (!ValidateInstallJournalTransition( + nullptr, impl_->state, error)) { + return false; + } + impl_->evidenceMayBeDurable = true; + if (!WriteInstallJournalRecord( + impl_->directory.active, &impl_->state, error)) { + impl_->poisoned = true; + return false; + } + impl_->preparedRecord = true; + if (!PublishInstallRecoveryEvidence( + impl_->directory.active, impl_->state.sequence - 1U, error)) { + impl_->poisoned = true; + return false; + } + gActiveRecoveryRecordWritten = true; + return true; +} + +bool InstallJournal::Record( + InstallJournalPhase phase, + const PackageInfo* publishedCandidate, + bool packageStagedHere, + bool bindingMutationStarted, + bool rebootRequired, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + Error* error) { + if (!impl_) { + return SetError(error, L"install-journal-state", ERROR_INVALID_STATE, + L"install journal is not armed for a phase transition"); + } + return RecordNext(impl_->state, phase, publishedCandidate, + packageStagedHere, bindingMutationStarted, rebootRequired, + callSucceeded, callError, deadlineOverrun, false, error); +} + +bool InstallJournal::RecordAuthoritativeReturn( + InstallJournalPhase phase, + const PackageInfo* publishedCandidate, + bool packageStagedHere, + bool bindingMutationStarted, + bool rebootRequired, + bool freshRebootRequired, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + Error* error) { + if (!impl_ || + (phase != InstallJournalPhase::DiInstallReturned && + phase != InstallJournalPhase::RollbackBindingReturned && + phase != + InstallJournalPhase::PartialRootRemovalReturned)) { + return SetError(error, L"install-journal-reboot-return", + ERROR_INVALID_PARAMETER); + } + InstallJournalStateData next = impl_->state; + if (freshRebootRequired && + !GetBootIdentifier( + &next.pendingRebootBootIdentifier, error)) { + return false; + } + return RecordNext(std::move(next), phase, publishedCandidate, + packageStagedHere, bindingMutationStarted, rebootRequired, + callSucceeded, callError, deadlineOverrun, + freshRebootRequired, error); +} + +bool InstallJournal::RecordNext( + InstallJournalStateData next, + InstallJournalPhase phase, + const PackageInfo* publishedCandidate, + bool packageStagedHere, + bool bindingMutationStarted, + bool rebootRequired, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + bool freshRebootRequired, + Error* error) { + if (!impl_ || !impl_->preparedRecord || impl_->retired || + impl_->poisoned) { + return SetError(error, L"install-journal-state", ERROR_INVALID_STATE, + impl_ && impl_->poisoned + ? L"install journal is poisoned after an indeterminate durable append; restart into recovery" + : L"install journal is not armed for a phase transition"); + } + next.phase = phase; + next.packageStagedHere = + next.packageStagedHere || packageStagedHere; + const bool phaseMayMutateBinding = + phase == InstallJournalPhase::RootRegistrationEntered || + phase == InstallJournalPhase::RootRegistrationReturned || + phase == InstallJournalPhase::DiInstallEntered || + phase == InstallJournalPhase::DiInstallReturned; + next.bindingMutationStarted = + next.bindingMutationStarted || bindingMutationStarted || + phaseMayMutateBinding; + next.rebootRequired = next.rebootRequired || rebootRequired; + next.freshRebootRequired = freshRebootRequired; + next.callSucceeded = callSucceeded; + next.callError = callError; + next.deadlineOverrun = next.deadlineOverrun || deadlineOverrun; + if (phase == InstallJournalPhase::RollbackBindingEntered) { + next.direction = InstallJournalDirection::Rollback; + next.rollbackAuthorized = true; + } + if (publishedCandidate != nullptr) { + next.publishedCandidate = *publishedCandidate; + next.hasPublishedCandidate = true; + if (packageStagedHere && + !ContainsExactPackage( + next.expectedInventory, *publishedCandidate)) { + next.expectedInventory.push_back(*publishedCandidate); + std::sort(next.expectedInventory.begin(), + next.expectedInventory.end(), + [](const PackageInfo& left, const PackageInfo& right) { + return _wcsicmp(left.publishedName.c_str(), + right.publishedName.c_str()) < 0; + }); + } + } + if (phase == InstallJournalPhase::BrokerHandoffEntered || + phase == InstallJournalPhase::BrokerHandoffReturned || + phase == InstallJournalPhase::BrokerChildEntered || + phase == InstallJournalPhase::BrokerChildSettled) { + next.brokerEntered = true; + } + if (phase == InstallJournalPhase::BrokerChildSettled) { + next.brokerSettled = true; + } + if (!ValidateInstallJournalTransition(&impl_->state, next, error) || + !WriteInstallJournalRecord( + impl_->directory.active, &next, error)) { + impl_->poisoned = true; + return false; + } + impl_->state = std::move(next); + if (impl_->state.direction == InstallJournalDirection::Forward && + phase == InstallJournalPhase::RootRegistrationEntered) { + impl_->forwardRootRegistrationEntered = true; + } + if (impl_->state.direction == InstallJournalDirection::Forward && + phase == InstallJournalPhase::DiInstallEntered) { + impl_->forwardDiInstallEntered = true; + } + if (phase == InstallJournalPhase::PartialRootRemovalEntered) { + impl_->partialRootRemovalEntered = true; + } + if (!PublishInstallRecoveryEvidence( + impl_->directory.active, impl_->state.sequence - 1U, error)) { + impl_->poisoned = true; + return false; + } + gActiveRecoveryRecordWritten = true; + return true; +} + +bool InstallJournal::RecordCutpoint( + InstallJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + bool rebootRequired, + bool freshRebootRequired, + Error* error) { + if (!impl_) { + return true; + } + const PackageInfo* publishedCandidate = + impl_->state.hasPublishedCandidate + ? &impl_->state.publishedCandidate : nullptr; + if (phase == InstallJournalPhase::DiInstallReturned || + phase == InstallJournalPhase::RollbackBindingReturned || + phase == InstallJournalPhase::PartialRootRemovalReturned) { + return RecordAuthoritativeReturn(phase, publishedCandidate, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, + impl_->state.rebootRequired || rebootRequired, + freshRebootRequired, callSucceeded, callError, + deadlineOverrun, error); + } + if (freshRebootRequired) { + return SetError(error, L"install-journal-reboot-return", + ERROR_INVALID_PARAMETER, + L"fresh reboot authority is legal only on an authoritative returned phase"); + } + return Record(phase, publishedCandidate, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, + impl_->state.rebootRequired || rebootRequired, + callSucceeded, callError, deadlineOverrun, error); +} + +bool InstallJournal::RecordPriorAbiProfile( + const AbiCompatibilityProfile& profile, + const PackageInfo& publishedCandidate, + bool packageStagedHere, + Error* error) { + if (!impl_ || !IsKnownAbiCompatibilityProfile(profile) || + impl_->state.prior.devices.size() != 1U || + !impl_->state.prior.devices[0].started || + impl_->state.prior.devices[0].problem != 0) { + return SetError(error, L"install-journal-prior-abi-profile", + ERROR_INVALID_PARAMETER); + } + if (impl_->state.hasPriorAbiProfile && + !SameAbiCompatibilityProfile( + impl_->state.priorAbiProfile, profile)) { + return SetError(error, L"install-journal-prior-abi-profile", + ERROR_REVISION_MISMATCH, + L"captured prior ABI profile changed within one transaction"); + } + InstallJournalStateData next = impl_->state; + next.priorAbiProfile = profile; + next.hasPriorAbiProfile = true; + return RecordNext(std::move(next), + InstallJournalPhase::PriorAbiProfileCaptured, + &publishedCandidate, packageStagedHere, + impl_->state.bindingMutationStarted, + impl_->state.rebootRequired, true, ERROR_SUCCESS, false, + false, error); +} + +bool InstallJournal::RecordRootRegistrationIntent( + const std::wstring& instanceId, + Error* error) { + if (!impl_ || !impl_->state.prior.devices.empty() || + !impl_->state.hasPublishedCandidate || + !IsGeneratedRootInstanceIdForDeviceName( + instanceId, kRootDeviceName)) { + return SetError(error, L"install-journal-root-intent", + ERROR_INVALID_PARAMETER); + } + if (impl_->state.hasRootRegistrationIntent && + _wcsicmp(impl_->state.rootRegistrationInstanceId.c_str(), + instanceId.c_str()) != 0) { + return SetError(error, L"install-journal-root-intent", + ERROR_REVISION_MISMATCH, + L"generated root identity changed within one transaction"); + } + InstallJournalStateData next = impl_->state; + next.hasRootRegistrationIntent = true; + next.rootRegistrationInstanceId = instanceId; + return RecordNext(std::move(next), + InstallJournalPhase::RootRegistrationIntentCaptured, + &impl_->state.publishedCandidate, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, + impl_->state.rebootRequired, + true, ERROR_SUCCESS, false, false, error); +} + +bool InstallJournal::RecordBrokerProof( + const BrokerCommitProof& proof, + Error* error) { + if (!impl_ || !BrokerProofFieldsAreCanonical( + proof.success, proof.changed, proof.rollback, + proof.exitCode, proof.driverRollbackAuthorized) || + (proof.changed != proof.hasJournalProof) || + (proof.changed && + (!IsCanonicalLowerHex(proof.journalTransactionId, 32U) || + !IsCanonicalLowerHex( + proof.journalOuterTransactionId, 64U) || + !IsCanonicalLowerHex( + proof.journalCandidateSha256, 64U) || + !IsCanonicalLowerHex(proof.journalDigest, 64U)))) { + return SetError(error, L"install-journal-broker-proof", + ERROR_INVALID_DATA); + } + if (impl_->state.hasBrokerProof && + (impl_->state.brokerProofSuccess != proof.success || + impl_->state.brokerProofChanged != proof.changed || + impl_->state.brokerProofRollback != proof.rollback || + impl_->state.brokerProofExitCode != proof.exitCode || + impl_->state.brokerDriverRollbackAuthorized != + proof.driverRollbackAuthorized || + impl_->state.brokerJournalTransactionId != + proof.journalTransactionId || + impl_->state.brokerJournalOuterTransactionId != + proof.journalOuterTransactionId || + impl_->state.brokerJournalCandidateSha256 != + proof.journalCandidateSha256 || + impl_->state.brokerJournalState != proof.journalState || + impl_->state.brokerJournalDigest != proof.journalDigest)) { + return SetError(error, L"install-journal-broker-proof", + ERROR_REVISION_MISMATCH, + L"settled broker proof changed within one transaction"); + } + InstallJournalStateData next = impl_->state; + next.brokerEntered = true; + next.brokerSettled = true; + next.hasBrokerProof = true; + next.brokerProofSuccess = proof.success; + next.brokerProofChanged = proof.changed; + next.brokerProofRollback = proof.rollback; + next.brokerProofExitCode = proof.exitCode; + next.brokerDriverRollbackAuthorized = + proof.driverRollbackAuthorized; + next.brokerJournalTransactionId = proof.journalTransactionId; + next.brokerJournalOuterTransactionId = + proof.journalOuterTransactionId; + next.brokerJournalCandidateSha256 = + proof.journalCandidateSha256; + next.brokerJournalState = proof.journalState; + next.brokerJournalDigest = proof.journalDigest; + if (proof.driverRollbackAuthorized) { + next.direction = InstallJournalDirection::Rollback; + next.rollbackAuthorized = true; + } + return RecordNext(std::move(next), + InstallJournalPhase::BrokerChildSettled, + impl_->state.hasPublishedCandidate + ? &impl_->state.publishedCandidate : nullptr, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, + impl_->state.rebootRequired, + proof.success, proof.exitCode, false, false, error); +} + +bool InstallJournal::RecordRollbackAuthorization( + InstallJournalPhase phase, + DWORD callError, + Error* error) { + if (!impl_ || + (phase != InstallJournalPhase::BrokerHandoffReturned && + phase != InstallJournalPhase::BrokerChildSettled)) { + return SetError(error, L"install-journal-rollback-authorization", + ERROR_INVALID_PARAMETER); + } + InstallJournalStateData next = impl_->state; + next.direction = InstallJournalDirection::Rollback; + next.rollbackAuthorized = true; + return RecordNext(std::move(next), phase, + impl_->state.hasPublishedCandidate + ? &impl_->state.publishedCandidate : nullptr, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, + impl_->state.rebootRequired, + false, callError, false, false, error); +} + +bool RecordActiveInstallJournalCutpoint( + InstallJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + Error* error) { + return gActiveInstallJournal == nullptr || + gActiveInstallJournal->RecordCutpoint( + phase, callSucceeded, callError, deadlineOverrun, + false, false, error); +} + +bool RecordActiveInstallJournalCutpointWithReboot( + InstallJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + bool rebootRequired, + bool freshRebootRequired, + Error* error) { + return gActiveInstallJournal == nullptr || + gActiveInstallJournal->RecordCutpoint( + phase, callSucceeded, callError, deadlineOverrun, + rebootRequired, freshRebootRequired, error); +} + +bool RecordActiveInstallJournalRollbackAuthorization( + InstallJournalPhase phase, + DWORD callError, + Error* error) { + return gActiveInstallJournal == nullptr || + gActiveInstallJournal->RecordRollbackAuthorization( + phase, callError, error); +} + +bool RecordActiveInstallJournalRootRegistrationIntent( + const std::wstring& instanceId, + Error* error) { + return gActiveInstallJournal == nullptr || + gActiveInstallJournal->RecordRootRegistrationIntent( + instanceId, error); +} + +void InstallJournal::AttachEvidence(Error* error) const { + if (error == nullptr || !impl_) { + return; + } + error->recoveryBackup = impl_->directory.active.wstring(); + error->recoveryBackupRetained = true; + if (gActiveRecoveryRecord[0] != L'\0') { + error->recoveryRecord = gActiveRecoveryRecord.data(); + error->recoveryRecordWritten = gActiveRecoveryRecordWritten; + } +} + +bool InstallJournal::RetireAfterForwardValidation( + const PackageInfo& candidate, + const std::wstring& publishedName, + bool rebootRequired, + uint64_t deadlineUnixMs, + BrokerJournalBinding* binding, + std::string_view recovery, + Error* error) { + if (!impl_ || !impl_->preparedRecord || impl_->retired || + impl_->poisoned) { + return SetError(error, L"install-journal-retire", ERROR_INVALID_STATE); + } + if (binding == nullptr || + (recovery != "fresh" && recovery != "replayed")) { + return SetError(error, L"install-journal-retire", + ERROR_INVALID_PARAMETER); + } + *binding = {}; + if (rebootRequired) { + if (impl_->state.pendingRebootBootIdentifier.empty()) { + return SetError(error, L"install-journal-forward-reboot-epoch", + ERROR_INVALID_DATA, + L"forward reboot pending lacks an authoritative returned reboot epoch"); + } + return Record(InstallJournalPhase::ForwardRebootPending, + &impl_->state.publishedCandidate, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, true, true, + ERROR_SUCCESS_REBOOT_REQUIRED, false, error); + } + std::string expectedBuildIdentity; + if (!DeriveDriverBuildIdentity( + impl_->state.sourceRevision, &expectedBuildIdentity, error) || + !VerifyInstallJournalRawForwardTopology(impl_->state, error) || + !VerifyInstalled(candidate, publishedName, false, deadlineUnixMs, + &expectedBuildIdentity, error) || + !VerifyPackageInventory( + impl_->state.expectedInventory, + L"install-journal-retire-inventory", error) || + !Record(InstallJournalPhase::ForwardValidated, + &impl_->state.publishedCandidate, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, false, true, + ERROR_SUCCESS, false, error) || + !VerifyInstallJournalRawForwardTopology(impl_->state, error) || + !VerifyInstalled(candidate, publishedName, false, deadlineUnixMs, + &expectedBuildIdentity, error) || + !VerifyPackageInventory( + impl_->state.expectedInventory, + L"install-journal-retire-revalidation", error)) { + return false; + } + if (impl_->state.brokerRequired && + impl_->state.hasBrokerProof && + impl_->state.brokerProofSuccess && + impl_->state.brokerProofChanged) { + InstallJournalStateData pending = impl_->state; + if (!GenerateInstallTransactionId( + &pending.brokerSettlementNonce, error) || + !RecordNext(std::move(pending), + InstallJournalPhase::BrokerOuterSettlementPending, + &impl_->state.publishedCandidate, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, + false, true, ERROR_SUCCESS, false, false, error)) { + return false; + } + binding->present = true; + binding->transactionId = + impl_->state.brokerJournalTransactionId; + binding->outerTransactionId = + impl_->state.brokerJournalOuterTransactionId; + binding->candidateSha256 = + impl_->state.brokerJournalCandidateSha256; + binding->state = impl_->state.brokerJournalState; + binding->digest = impl_->state.brokerJournalDigest; + binding->driverTransactionId = impl_->state.transactionId; + binding->driverDigest = impl_->state.lastDigest; + binding->settlementNonce = + impl_->state.brokerSettlementNonce; + binding->recovery = std::string(recovery); + return true; + } + impl_->candidateLocks.clear(); + impl_->brokerLock.reset(); + impl_->priorBackups.clear(); + if (!RetireInstallRecoveryActiveDirectory( + &impl_->directory, impl_->state.transactionId, + error, false, nullptr)) { + return false; + } + impl_->retired = true; + impl_->preparedRecord = false; + ClearActiveRecoveryEvidence(); + return true; +} + +bool InstallJournal::RetireAfterPriorValidation( + bool rebootRequired, + Error* error) { + if (!impl_ || !impl_->preparedRecord || impl_->retired || + impl_->poisoned) { + return SetError(error, L"install-journal-retire", ERROR_INVALID_STATE); + } + if (rebootRequired && + !impl_->state.pendingRebootBootIdentifier.empty()) { + return Record(InstallJournalPhase::RestoreRebootPending, + impl_->state.hasPublishedCandidate + ? &impl_->state.publishedCandidate : nullptr, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, true, true, + ERROR_SUCCESS_REBOOT_REQUIRED, false, error); + } + const auto validatePrior = [&]() { + Snapshot observed; + if (!VerifyInstallJournalRawPriorTopology(impl_->state, error) || + !CaptureSnapshot(&observed, error) || + !SameCapturedRootState(impl_->state.prior, observed) || + !SamePackageInventory( + impl_->state.prior.packages, observed.packages)) { + return false; + } + if (impl_->state.bindingMutationStarted && + !impl_->state.prior.devices.empty() && + impl_->state.prior.devices[0].started) { + if (!impl_->state.hasPriorAbiProfile) { + return SetError(error, + L"install-journal-prior-abi-profile", + ERROR_REVISION_MISMATCH, + L"started prior root lacks its durable exact ABI profile"); + } + return VerifyAbiHealth( + CurrentUnixMilliseconds() + 15000U, nullptr, error, + AbiHealthPurpose::RollbackHealth, + &impl_->state.priorAbiProfile, nullptr); + } + return true; + }; + if (!validatePrior()) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-prior-revalidation", + ERROR_REVISION_MISMATCH, + L"exact captured root and package inventory were not restored"); + } + return false; + } + if (!Record(InstallJournalPhase::ExactPriorRestored, + impl_->state.hasPublishedCandidate + ? &impl_->state.publishedCandidate : nullptr, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, false, true, + ERROR_SUCCESS, false, error) || + !validatePrior()) { + return false; + } + impl_->candidateLocks.clear(); + impl_->priorBackups.clear(); + if (!RetireInstallRecoveryActiveDirectory( + &impl_->directory, impl_->state.transactionId, + error, false, nullptr)) { + return false; + } + impl_->retired = true; + impl_->preparedRecord = false; + ClearActiveRecoveryEvidence(); + return true; +} + +bool RequireJournalObject( + const JsonValue& value, + const JsonValue::Object** object, + Error* error) { + *object = std::get_if(&value.value); + return *object != nullptr || SetError( + error, L"install-journal-parse", ERROR_INVALID_DATA, + L"journal field must be a JSON object"); +} + +bool RequireJournalArray( + const JsonValue::Object& object, + const char* name, + const JsonValue::Array** array, + Error* error) { + const JsonValue* field = ObjectField(object, name); + *array = field == nullptr ? nullptr + : std::get_if(&field->value); + return *array != nullptr || SetError( + error, L"install-journal-parse", ERROR_INVALID_DATA, + L"journal array field is missing or malformed"); +} + +bool RequireJournalString( + const JsonValue::Object& object, + const char* name, + std::string* value, + Error* error) { + const JsonValue* field = ObjectField(object, name); + const std::string* stringValue = field == nullptr ? nullptr + : std::get_if(&field->value); + if (stringValue == nullptr) { + return SetError(error, L"install-journal-parse", ERROR_INVALID_DATA, + L"journal string field is missing or malformed"); + } + *value = *stringValue; + return true; +} + +bool RequireJournalBool( + const JsonValue::Object& object, + const char* name, + bool* value, + Error* error) { + const JsonValue* field = ObjectField(object, name); + const bool* boolValue = field == nullptr ? nullptr + : std::get_if(&field->value); + if (boolValue == nullptr) { + return SetError(error, L"install-journal-parse", ERROR_INVALID_DATA, + L"journal Boolean field is missing or malformed"); + } + *value = *boolValue; + return true; +} + +bool RequireJournalUnsigned( + const JsonValue::Object& object, + const char* name, + uint64_t maximum, + uint64_t* value, + Error* error) { + const JsonValue* field = ObjectField(object, name); + const int64_t* integer = field == nullptr ? nullptr + : std::get_if(&field->value); + if (integer == nullptr || *integer < 0 || + static_cast(*integer) > maximum) { + return SetError(error, L"install-journal-parse", ERROR_INVALID_DATA, + L"journal integer field is missing or out of range"); + } + *value = static_cast(*integer); + return true; +} + +bool ParseJournalPackageIdentity( + const JsonValue& value, + const std::filesystem::path& active, + bool requirePublishedName, + PackageInfo* package, + std::filesystem::path* backupInf, + Error* error) { + const JsonValue::Object* object = nullptr; + std::string published; + std::string version; + std::string infSha; + std::string sysSha; + std::string catSha; + std::string relative; + if (!RequireJournalObject(value, &object, error) || + !RequireJournalString(*object, "publishedInf", &published, error) || + !RequireJournalString(*object, "version", &version, error) || + !RequireJournalString(*object, "infSha256", &infSha, error) || + !RequireJournalString(*object, "sysSha256", &sysSha, error) || + !RequireJournalString(*object, "catSha256", &catSha, error) || + !RequireJournalString(*object, "backupInf", &relative, error)) { + return false; + } + std::wstring publishedWide; + std::wstring versionWide; + std::wstring relativeWide; + if (!Utf8ToWide(published, &publishedWide, error) || + !Utf8ToWide(version, &versionWide, error) || + !Utf8ToWide(relative, &relativeWide, error) || + !ParseVersion(versionWide, &package->version) || + !IsSha256Digest(infSha) || !IsSha256Digest(sysSha) || + !IsSha256Digest(catSha) || + (requirePublishedName && !IsSafePublishedInfName(publishedWide))) { + return SetError(error, L"install-journal-package", ERROR_INVALID_DATA, + L"journal package identity is malformed"); + } + package->publishedName = std::move(publishedWide); + package->infSha256 = LowerAscii(std::move(infSha)); + package->sysSha256 = LowerAscii(std::move(sysSha)); + package->catSha256 = LowerAscii(std::move(catSha)); + if (relativeWide.empty()) { + backupInf->clear(); + package->infPath.clear(); + return true; + } + const std::filesystem::path relativePath(relativeWide); + if (!IsSafeRecoveryRelativePath(relativePath)) { + return SetError(error, L"install-journal-package-path", + ERROR_INVALID_NAME); + } + *backupInf = (active / relativePath).lexically_normal(); + if (backupInf->lexically_relative(active).empty()) { + return SetError(error, L"install-journal-package-path", + ERROR_INVALID_NAME); + } + package->infPath = *backupInf; + return true; +} + +bool ParseInstallJournalPayload( + std::string_view payload, + const std::filesystem::path& active, + InstallJournalStateData* state, + Error* error) { + JsonValue root; + std::string parseMessage; + if (!JsonParser(payload).Parse(&root, &parseMessage)) { + std::wstring message; + Utf8ToWide(parseMessage, &message, nullptr); + return SetError(error, L"install-journal-parse", ERROR_INVALID_DATA, + L"journal payload is not canonical JSON: " + message); + } + const JsonValue::Object* object = nullptr; + if (!RequireJournalObject(root, &object, error)) { + return false; + } + uint64_t sequence = 0; + uint64_t callError = 0; + std::string previous; + std::string phase; + std::string direction; + if (!RequireJournalUnsigned(*object, "sequence", + kMaximumInstallRecoveryRecords - 1U, &sequence, error) || + !RequireJournalString(*object, "previousSha256", &previous, error) || + !RequireJournalString(*object, "phase", &phase, error) || + !RequireJournalString(*object, "direction", &direction, error) || + !RequireJournalBool(*object, "rollbackAuthorized", + &state->rollbackAuthorized, error) || + !RequireJournalString(*object, "transactionId", &state->transactionId, error) || + !RequireJournalString(*object, "bootIdentifier", &state->bootIdentifier, error) || + !RequireJournalString(*object, "sourceRevision", &state->sourceRevision, error) || + !RequireJournalBool(*object, "production", &state->production, error) || + !RequireJournalBool(*object, "localTest", &state->localTest, error) || + !RequireJournalBool(*object, "brokerRequired", &state->brokerRequired, error) || + !RequireJournalBool(*object, "brokerEntered", &state->brokerEntered, error) || + !RequireJournalBool(*object, "brokerSettled", &state->brokerSettled, error) || + !RequireJournalBool(*object, "packageStagedHere", &state->packageStagedHere, error) || + !RequireJournalBool(*object, "bindingMutationStarted", &state->bindingMutationStarted, error) || + !RequireJournalBool(*object, "rebootRequired", &state->rebootRequired, error) || + !RequireJournalBool(*object, "freshRebootRequired", + &state->freshRebootRequired, error) || + !RequireJournalBool(*object, "callSucceeded", &state->callSucceeded, error) || + !RequireJournalUnsigned(*object, "callError", MAXDWORD, &callError, error) || + !RequireJournalBool(*object, "deadlineOverrun", &state->deadlineOverrun, error)) { + return false; + } + const JsonValue* pendingRebootNode = + ObjectField(*object, "pendingRebootBootIdentifier"); + if (pendingRebootNode == nullptr) { + return SetError(error, L"install-journal-reboot-epoch", + ERROR_INVALID_DATA); + } + if (std::holds_alternative( + pendingRebootNode->value)) { + state->pendingRebootBootIdentifier.clear(); + } else { + const auto* pendingBoot = + std::get_if(&pendingRebootNode->value); + if (pendingBoot == nullptr || + !IsCanonicalBootIdentifier(*pendingBoot)) { + return SetError(error, L"install-journal-reboot-epoch", + ERROR_INVALID_DATA, + L"pending reboot boot identifier is not one canonical boot epoch"); + } + state->pendingRebootBootIdentifier = *pendingBoot; + } + const JsonValue* brokerInvocationNode = + ObjectField(*object, "brokerInvocation"); + if (brokerInvocationNode == nullptr) { + return SetError(error, L"install-journal-broker-invocation", + ERROR_INVALID_DATA); + } + if (state->brokerRequired) { + const JsonValue::Object* invocationObject = nullptr; + std::string tokenPath; + std::string targetUserSid; + std::wstring tokenPathWide; + if (!RequireJournalObject( + *brokerInvocationNode, &invocationObject, error) || + invocationObject->size() != 3U || + !RequireJournalString(*invocationObject, + "executableSha256", &state->brokerExecutableSha256, + error) || + !RequireJournalString(*invocationObject, + "tokenPath", &tokenPath, error) || + !RequireJournalString(*invocationObject, + "targetUserSid", &targetUserSid, error) || + !Utf8ToWide(tokenPath, &tokenPathWide, error) || + !Utf8ToWide(targetUserSid, &state->brokerTargetUserSid, + error)) { + return false; + } + state->brokerTokenPath = tokenPathWide; + if (!IsCanonicalLowerHex( + state->brokerExecutableSha256, 64U) || + !state->brokerTokenPath.is_absolute() || + state->brokerTokenPath.extension() != L".token" || + !IsSafeTargetUserSid(state->brokerTargetUserSid)) { + return SetError(error, L"install-journal-broker-invocation", + ERROR_INVALID_DATA, + L"durable broker recovery invocation is not canonical"); + } + } else if (!std::holds_alternative( + brokerInvocationNode->value)) { + return SetError(error, L"install-journal-broker-invocation", + ERROR_INVALID_DATA, + L"non-broker transaction carried recovery invocation state"); + } + const JsonValue* brokerProofNode = ObjectField(*object, "brokerProof"); + if (brokerProofNode == nullptr) { + return SetError(error, L"install-journal-broker-proof", + ERROR_INVALID_DATA); + } + if (std::holds_alternative(brokerProofNode->value)) { + state->hasBrokerProof = false; + } else { + const JsonValue::Object* brokerProofObject = nullptr; + uint64_t exitCode = 0; + if (!RequireJournalObject( + *brokerProofNode, &brokerProofObject, error) || + brokerProofObject->size() != 6U || + !RequireJournalBool(*brokerProofObject, "success", + &state->brokerProofSuccess, error) || + !RequireJournalBool(*brokerProofObject, "changed", + &state->brokerProofChanged, error) || + !RequireJournalString(*brokerProofObject, "rollback", + &state->brokerProofRollback, error) || + !RequireJournalUnsigned(*brokerProofObject, "exitCode", MAXDWORD, + &exitCode, error) || + !RequireJournalBool(*brokerProofObject, + "driverRollbackAuthorized", + &state->brokerDriverRollbackAuthorized, error)) { + return false; + } + const JsonValue* journalNode = + ObjectField(*brokerProofObject, "journal"); + if (journalNode == nullptr) { + return SetError(error, L"install-journal-broker-proof", + ERROR_INVALID_DATA); + } + if (state->brokerProofChanged) { + const JsonValue::Object* journalObject = nullptr; + if (!RequireJournalObject( + *journalNode, &journalObject, error) || + journalObject->size() != 5U || + !RequireJournalString(*journalObject, + "transactionId", + &state->brokerJournalTransactionId, error) || + !RequireJournalString(*journalObject, + "outerTransactionId", + &state->brokerJournalOuterTransactionId, error) || + !RequireJournalString(*journalObject, + "candidateSha256", + &state->brokerJournalCandidateSha256, error) || + !RequireJournalString(*journalObject, "state", + &state->brokerJournalState, error) || + !RequireJournalString(*journalObject, "digest", + &state->brokerJournalDigest, error)) { + return false; + } + } else if (!std::holds_alternative( + journalNode->value)) { + return SetError(error, L"install-journal-broker-proof", + ERROR_INVALID_DATA, + L"unchanged child proof carried a journal identity"); + } + state->brokerProofExitCode = static_cast(exitCode); + if (!BrokerProofFieldsAreCanonical( + state->brokerProofSuccess, + state->brokerProofChanged, + state->brokerProofRollback, + state->brokerProofExitCode, + state->brokerDriverRollbackAuthorized)) { + return SetError(error, L"install-journal-broker-proof", + ERROR_INVALID_DATA, + L"durable child proof is not a canonical settled outcome"); + } + state->hasBrokerProof = true; + } + const JsonValue* brokerSettlementNode = + ObjectField(*object, "brokerSettlement"); + if (brokerSettlementNode == nullptr) { + return SetError(error, L"install-journal-broker-settlement", + ERROR_INVALID_DATA); + } + if (!std::holds_alternative( + brokerSettlementNode->value)) { + const JsonValue::Object* settlementObject = nullptr; + const JsonValue* driverPendingNode = nullptr; + const JsonValue* requestNode = nullptr; + const JsonValue* pendingNode = nullptr; + if (!RequireJournalObject( + *brokerSettlementNode, &settlementObject, error) || + settlementObject->size() != 4U || + !RequireJournalString(*settlementObject, "nonce", + &state->brokerSettlementNonce, error) || + (driverPendingNode = ObjectField( + *settlementObject, "driverPendingDigest")) == nullptr || + (requestNode = ObjectField( + *settlementObject, "requestSha256")) == nullptr || + (pendingNode = ObjectField( + *settlementObject, "brokerPendingDigest")) == nullptr) { + return false; + } + const auto* requestDigest = + std::get_if(&requestNode->value); + const auto* pendingDigest = + std::get_if(&pendingNode->value); + const auto* driverPendingDigest = + std::get_if(&driverPendingNode->value); + if (driverPendingDigest != nullptr) { + state->brokerDriverPendingDigest = + *driverPendingDigest; + } else if (!std::holds_alternative( + driverPendingNode->value)) { + return SetError(error, + L"install-journal-broker-settlement", + ERROR_INVALID_DATA); + } + if (requestDigest != nullptr) { + state->brokerSettlementRequestSha256 = *requestDigest; + } else if (!std::holds_alternative( + requestNode->value)) { + return SetError(error, + L"install-journal-broker-settlement", + ERROR_INVALID_DATA); + } + if (pendingDigest != nullptr) { + state->brokerGoPendingDigest = *pendingDigest; + } else if (!std::holds_alternative( + pendingNode->value)) { + return SetError(error, + L"install-journal-broker-settlement", + ERROR_INVALID_DATA); + } + } + const JsonValue* profileNode = ObjectField(*object, "priorAbiProfile"); + if (profileNode == nullptr) { + return SetError(error, L"install-journal-prior-abi-profile", + ERROR_INVALID_DATA); + } + if (std::holds_alternative(profileNode->value)) { + state->hasPriorAbiProfile = false; + } else { + const JsonValue::Object* profileObject = nullptr; + uint64_t minor = 0; + uint64_t capabilities = 0; + uint64_t statsSize = 0; + if (!RequireJournalObject(*profileNode, &profileObject, error) || + profileObject->size() != 4U || + !RequireJournalUnsigned(*profileObject, "minor", UINT16_MAX, + &minor, error) || + !RequireJournalUnsigned(*profileObject, "capabilities", UINT32_MAX, + &capabilities, error) || + !RequireJournalUnsigned(*profileObject, "statsSize", MAXDWORD, + &statsSize, error) || + !RequireJournalBool(*profileObject, "hasReservedPortFields", + &state->priorAbiProfile.hasReservedPortFields, error)) { + return false; + } + state->priorAbiProfile.minor = + static_cast(minor); + state->priorAbiProfile.capabilities = + static_cast(capabilities); + state->priorAbiProfile.statsSize = static_cast(statsSize); + if (!IsKnownAbiCompatibilityProfile(state->priorAbiProfile)) { + return SetError(error, L"install-journal-prior-abi-profile", + ERROR_REVISION_MISMATCH, + L"journal prior ABI profile is not an exact supported contract"); + } + state->hasPriorAbiProfile = true; + } + const JsonValue* rootIntentNode = + ObjectField(*object, "rootRegistrationInstanceId"); + if (rootIntentNode == nullptr) { + return SetError(error, L"install-journal-root-intent", + ERROR_INVALID_DATA); + } + if (std::holds_alternative(rootIntentNode->value)) { + state->hasRootRegistrationIntent = false; + state->rootRegistrationInstanceId.clear(); + } else { + const auto* encodedInstanceId = + std::get_if(&rootIntentNode->value); + if (encodedInstanceId == nullptr || + !Utf8ToWide(*encodedInstanceId, + &state->rootRegistrationInstanceId, error) || + !IsGeneratedRootInstanceIdForDeviceName( + state->rootRegistrationInstanceId, kRootDeviceName)) { + return SetError(error, L"install-journal-root-intent", + ERROR_INVALID_DATA, + L"durable root registration intent is not one exact generated VIIPER instance ID"); + } + state->hasRootRegistrationIntent = true; + } + const JsonValue* rootRemovalBootNode = + ObjectField(*object, + "partialRootRemovalBootIdentifier"); + if (rootRemovalBootNode == nullptr) { + return SetError(error, + L"install-journal-partial-root-removal", + ERROR_INVALID_DATA); + } + if (std::holds_alternative( + rootRemovalBootNode->value)) { + state->partialRootRemovalBootIdentifier.clear(); + } else { + const auto* removalBoot = + std::get_if(&rootRemovalBootNode->value); + if (removalBoot == nullptr || + !IsCanonicalBootIdentifier(*removalBoot)) { + return SetError(error, + L"install-journal-partial-root-removal", + ERROR_INVALID_DATA, + L"partial root removal lacks one canonical attempt boot epoch"); + } + state->partialRootRemovalBootIdentifier = *removalBoot; + } + const JsonValue* rootRemovalBindingNode = + ObjectField(*object, "partialRootRemovalBinding"); + if (rootRemovalBindingNode == nullptr) { + return SetError(error, + L"install-journal-partial-root-removal", + ERROR_INVALID_DATA); + } + if (std::holds_alternative( + rootRemovalBindingNode->value)) { + state->partialRootRemovalBinding = + InstallJournalStateData::PartialRootRemovalBinding::None; + } else { + const auto* binding = + std::get_if(&rootRemovalBindingNode->value); + if (binding == nullptr || + (*binding != "unbound" && *binding != "candidate")) { + return SetError(error, + L"install-journal-partial-root-removal", + ERROR_INVALID_DATA, + L"partial root removal pre-call binding shape is not canonical"); + } + state->partialRootRemovalBinding = *binding == "unbound" + ? InstallJournalStateData::PartialRootRemovalBinding::Unbound + : InstallJournalStateData::PartialRootRemovalBinding::Candidate; + } + const std::optional parsedPhase = + ParseInstallJournalPhase(phase); + const std::optional parsedDirection = + ParseInstallJournalDirection(direction); + const bool partialRootRemovalPhase = parsedPhase && + (*parsedPhase == InstallJournalPhase::PartialRootRemovalEntered || + *parsedPhase == InstallJournalPhase::PartialRootRemovalReturned || + *parsedPhase == InstallJournalPhase:: + PartialRootRemovalRebootPending); + const bool hasPartialRootRemovalBinding = + state->partialRootRemovalBinding != + InstallJournalStateData::PartialRootRemovalBinding::None; + if (!parsedPhase || !parsedDirection || !IsSha256Digest(previous) || + !IsSha256Digest(state->transactionId) || + !IsCanonicalBootIdentifier(state->bootIdentifier) || + (!state->pendingRebootBootIdentifier.empty() && + !state->rebootRequired) || + (partialRootRemovalPhase && + (state->partialRootRemovalBootIdentifier.empty() || + !hasPartialRootRemovalBinding)) || + (state->partialRootRemovalBootIdentifier.empty() != + !hasPartialRootRemovalBinding) || + (!state->partialRootRemovalBootIdentifier.empty() && + (!state->hasRootRegistrationIntent || + !state->prior.devices.empty() || + *parsedDirection != InstallJournalDirection::Rollback || + !state->rollbackAuthorized)) || + ((*parsedPhase == InstallJournalPhase::ForwardRebootPending || + *parsedPhase == InstallJournalPhase::RestoreRebootPending) && + state->pendingRebootBootIdentifier.empty()) || + (state->freshRebootRequired && + (!state->rebootRequired || + state->pendingRebootBootIdentifier.empty() || + (*parsedPhase != InstallJournalPhase::DiInstallReturned && + *parsedPhase != InstallJournalPhase:: + RollbackBindingReturned && + *parsedPhase != InstallJournalPhase:: + PartialRootRemovalReturned))) || + !IsHexRevision(state->sourceRevision) || + (state->production && state->localTest) || + (state->brokerSettled && !state->brokerEntered) || + (state->hasBrokerProof && + (!state->brokerEntered || !state->brokerSettled || + state->brokerDriverRollbackAuthorized != + state->rollbackAuthorized)) || + (state->brokerSettled && !state->hasBrokerProof && + !state->rollbackAuthorized)) { + return SetError(error, L"install-journal-state", ERROR_INVALID_DATA, + L"journal phase or transaction identity is inconsistent"); + } + state->sequence = sequence; + state->previousDigest = LowerAscii(std::move(previous)); + state->phase = *parsedPhase; + state->direction = *parsedDirection; + state->callError = static_cast(callError); + + const JsonValue* candidateNode = ObjectField(*object, "candidate"); + std::filesystem::path candidateBackup; + if (candidateNode == nullptr || + !ParseJournalPackageIdentity(*candidateNode, active, false, + &state->candidate, &candidateBackup, error) || + candidateBackup != active / kInstallRecoveryCandidateDirectory / + L"ViiperUde.inf") { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-candidate-path", + ERROR_INVALID_NAME); + } + return false; + } + const JsonValue* publishedNode = ObjectField(*object, "publishedCandidate"); + if (publishedNode == nullptr) { + return SetError(error, L"install-journal-parse", ERROR_INVALID_DATA); + } + if (std::holds_alternative(publishedNode->value)) { + state->hasPublishedCandidate = false; + } else { + std::filesystem::path ignoredBackup; + if (!ParseJournalPackageIdentity(*publishedNode, active, true, + &state->publishedCandidate, &ignoredBackup, error) || + !SamePackageBytes(state->publishedCandidate, state->candidate) || + !(state->publishedCandidate.version == state->candidate.version)) { + return SetError(error, L"install-journal-published-candidate", + ERROR_REVISION_MISMATCH); + } + state->hasPublishedCandidate = true; + } + + const JsonValue::Array* priorPackages = nullptr; + const JsonValue::Array* priorDevices = nullptr; + const JsonValue::Array* expectedInventory = nullptr; + if (!RequireJournalArray(*object, "priorPackages", &priorPackages, error) || + !RequireJournalArray(*object, "priorDevices", &priorDevices, error) || + !RequireJournalArray(*object, "expectedInventory", &expectedInventory, error) || + priorPackages->size() > 32U || priorDevices->size() > 1U || + expectedInventory->size() > 33U) { + return SetError(error, L"install-journal-inventory", + ERROR_INVALID_DATA); + } + state->prior.packages.clear(); + for (size_t index = 0; index < priorPackages->size(); ++index) { + PackageInfo package; + std::filesystem::path backupInf; + const std::filesystem::path expectedBackup = + active / kInstallRecoveryPriorDirectory / + std::to_wstring(index) / L"ViiperUde.inf"; + if (!ParseJournalPackageIdentity( + (*priorPackages)[index], active, true, + &package, &backupInf, error) || backupInf != expectedBackup) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-prior-package-path", + ERROR_INVALID_NAME); + } + return false; + } + state->prior.packages.push_back(std::move(package)); + } + state->expectedInventory.clear(); + for (const JsonValue& packageValue : *expectedInventory) { + PackageInfo package; + std::filesystem::path backupInf; + if (!ParseJournalPackageIdentity( + packageValue, active, true, &package, &backupInf, error) || + !backupInf.empty()) { + return SetError(error, L"install-journal-expected-inventory", + ERROR_INVALID_DATA); + } + state->expectedInventory.push_back(std::move(package)); + } + + state->prior.devices.clear(); + for (const JsonValue& deviceValue : *priorDevices) { + const JsonValue::Object* deviceObject = nullptr; + std::string instanceId; + std::string service; + std::string publishedInf; + std::string versionValue; + std::string infSha; + std::string sysSha; + std::string catSha; + uint64_t problem = 0; + DeviceState device; + if (!RequireJournalObject(deviceValue, &deviceObject, error) || + !RequireJournalString(*deviceObject, "instanceId", &instanceId, error) || + !RequireJournalBool(*deviceObject, "present", &device.present, error) || + !RequireJournalBool(*deviceObject, "started", &device.started, error) || + !RequireJournalUnsigned(*deviceObject, "problem", MAXDWORD, &problem, error) || + !RequireJournalString(*deviceObject, "service", &service, error) || + !RequireJournalString(*deviceObject, "publishedInf", &publishedInf, error) || + !RequireJournalString(*deviceObject, "version", &versionValue, error) || + !RequireJournalString(*deviceObject, "packageInfSha256", &infSha, error) || + !RequireJournalString(*deviceObject, "packageSysSha256", &sysSha, error) || + !RequireJournalString(*deviceObject, "packageCatSha256", &catSha, error) || + !Utf8ToWide(instanceId, &device.instanceId, error) || + !Utf8ToWide(service, &device.service, error) || + !Utf8ToWide(publishedInf, &device.publishedInf, error)) { + return false; + } + std::wstring versionWide; + if (!Utf8ToWide(versionValue, &versionWide, error) || + !ParseVersion(versionWide, &device.version) || + !IsOwnedGeneratedRootInstanceId(device.instanceId) || + _wcsicmp(device.service.c_str(), kServiceName) != 0 || + !IsSafePublishedInfName(device.publishedInf) || + !IsSha256Digest(infSha) || !IsSha256Digest(sysSha) || + !IsSha256Digest(catSha)) { + return SetError(error, L"install-journal-prior-device", + ERROR_INVALID_DATA); + } + device.problem = static_cast(problem); + size_t matches = 0; + for (const PackageInfo& package : state->prior.packages) { + if (_wcsicmp(package.publishedName.c_str(), + device.publishedInf.c_str()) == 0 && + package.version == device.version && + _stricmp(package.infSha256.c_str(), infSha.c_str()) == 0 && + _stricmp(package.sysSha256.c_str(), sysSha.c_str()) == 0 && + _stricmp(package.catSha256.c_str(), catSha.c_str()) == 0) { + device.package = package; + ++matches; + } + } + if (matches != 1U) { + return SetError(error, L"install-journal-prior-device-package", + ERROR_REVISION_MISMATCH); + } + state->prior.devices.push_back(std::move(device)); + } + const bool priorRequiresAbiProfile = + state->prior.devices.size() == 1U && + state->prior.devices[0].started && + state->prior.devices[0].problem == 0; + if ((state->hasPriorAbiProfile && !priorRequiresAbiProfile) || + (state->phase == InstallJournalPhase::PriorAbiProfileCaptured && + !state->hasPriorAbiProfile) || + ((state->direction == InstallJournalDirection::Rollback) != + state->rollbackAuthorized) || + (priorRequiresAbiProfile && + (InstallJournalPhaseRequiresPriorAbiProfile(state->phase) || + state->bindingMutationStarted) && + !state->hasPriorAbiProfile) || + (state->hasRootRegistrationIntent && + (!state->prior.devices.empty() || + !state->hasPublishedCandidate)) || + (!state->hasRootRegistrationIntent && + (state->phase == + InstallJournalPhase::RootRegistrationIntentCaptured || + (state->prior.devices.empty() && + state->bindingMutationStarted))) || + (state->phase == InstallJournalPhase::RootRegistrationIntentCaptured && + (state->direction != InstallJournalDirection::Forward || + state->bindingMutationStarted))) { + return SetError(error, L"install-journal-prior-abi-profile", + ERROR_INVALID_DATA, + L"journal ABI profile or root registration intent does not match the captured prior lifecycle"); + } + std::string canonicalPayload; + if (!BuildInstallJournalPayload(*state, &canonicalPayload, error) || + canonicalPayload != payload) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-canonical-payload", + ERROR_INVALID_DATA, + L"journal payload is not in exact canonical byte form"); + } + return false; + } + return true; +} + +bool ReadInstallJournalFile( + const std::filesystem::path& path, + std::string* record, + Error* error) { + WinHandle file(CreateFileW( + path.c_str(), GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_SEQUENTIAL_SCAN, + nullptr)); + if (!file) { + return SetLastErrorDetail(error, L"install-journal-read"); + } + FILE_ATTRIBUTE_TAG_INFO attributes{}; + if (!GetFileInformationByHandleEx( + file.get(), FileAttributeTagInfo, &attributes, sizeof(attributes)) || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0 || + !VerifyProtectedFileSystemSecurity( + file.get(), false, L"install-journal-file-security", error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-read", + ERROR_REPARSE_TAG_MISMATCH); + } + return false; + } + LARGE_INTEGER size{}; + if (!GetFileSizeEx(file.get(), &size) || size.QuadPart <= 0 || + static_cast(size.QuadPart) > kMaximumRecoveryRecordBytes) { + return SetError(error, L"install-journal-size", + ERROR_FILE_TOO_LARGE); + } + record->assign(static_cast(size.QuadPart), '\0'); + DWORD read = 0; + if (!ReadFile(file.get(), record->data(), + static_cast(record->size()), &read, nullptr) || + static_cast(read) != record->size()) { + return SetLastErrorDetail(error, L"install-journal-read"); + } + return true; +} + +bool ValidateAndDiscardInstallJournalTemporaryFile( + const std::filesystem::path& path, + Error* error) { + WinHandle file(CreateFileW( + path.c_str(), GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, + nullptr)); + if (!file) { + return SetLastErrorDetail(error, L"install-journal-temp-open"); + } + FILE_ATTRIBUTE_TAG_INFO attributes{}; + BY_HANDLE_FILE_INFORMATION identity{}; + LARGE_INTEGER size{}; + if (!GetFileInformationByHandleEx( + file.get(), FileAttributeTagInfo, &attributes, + sizeof(attributes)) || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0 || + !GetFileInformationByHandle(file.get(), &identity) || + identity.nNumberOfLinks != 1U || + !GetFileSizeEx(file.get(), &size) || size.QuadPart < 0 || + static_cast(size.QuadPart) > kMaximumRecoveryRecordBytes || + !VerifyProtectedFileSystemSecurity( + file.get(), false, L"install-journal-file-security", error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-temp-identity", + ERROR_INVALID_DATA, + L"unpublished journal temp must be a bounded, single-link, protected regular file"); + } + return false; + } + file.reset(); + if (!DeleteFileW(path.c_str())) { + return SetLastErrorDetail(error, L"install-journal-temp-discard"); + } + const DWORD remaining = GetFileAttributesW(path.c_str()); + const DWORD absenceError = remaining == INVALID_FILE_ATTRIBUTES + ? GetLastError() : ERROR_SUCCESS; + if (remaining != INVALID_FILE_ATTRIBUTES || + (absenceError != ERROR_FILE_NOT_FOUND && + absenceError != ERROR_PATH_NOT_FOUND)) { + return SetError(error, L"install-journal-temp-discard", + remaining != INVALID_FILE_ATTRIBUTES + ? ERROR_ALREADY_EXISTS : absenceError, + L"unpublished journal temp absence could not be proven"); + } + return true; +} + +bool SameJournalPackageIdentity( + const PackageInfo& left, + const PackageInfo& right) noexcept { + return _wcsicmp(left.publishedName.c_str(), + right.publishedName.c_str()) == 0 && + left.version == right.version && SamePackageBytes(left, right); +} + +bool SameInstallJournalImmutableState( + const InstallJournalStateData& left, + const InstallJournalStateData& right) noexcept { + if (left.transactionId != right.transactionId || + left.bootIdentifier != right.bootIdentifier || + left.sourceRevision != right.sourceRevision || + left.production != right.production || + left.localTest != right.localTest || + left.brokerRequired != right.brokerRequired || + left.brokerExecutableSha256 != right.brokerExecutableSha256 || + left.brokerTokenPath != right.brokerTokenPath || + _wcsicmp(left.brokerTargetUserSid.c_str(), + right.brokerTargetUserSid.c_str()) != 0 || + !SameJournalPackageIdentity(left.candidate, right.candidate) || + !SamePackageInventory(left.prior.packages, right.prior.packages) || + left.prior.devices.size() != right.prior.devices.size()) { + return false; + } + return left.prior.devices.empty() || + (SameRootBinding(left.prior.devices[0], right.prior.devices[0]) && + left.prior.devices[0].started == right.prior.devices[0].started && + left.prior.devices[0].problem == right.prior.devices[0].problem); +} + +bool SameDurableBrokerProof( + const InstallJournalStateData& left, + const InstallJournalStateData& right) noexcept { + return left.hasBrokerProof == right.hasBrokerProof && + (!left.hasBrokerProof || + (left.brokerProofSuccess == right.brokerProofSuccess && + left.brokerProofChanged == right.brokerProofChanged && + left.brokerDriverRollbackAuthorized == + right.brokerDriverRollbackAuthorized && + left.brokerProofRollback == right.brokerProofRollback && + left.brokerProofExitCode == right.brokerProofExitCode && + left.brokerJournalTransactionId == + right.brokerJournalTransactionId && + left.brokerJournalOuterTransactionId == + right.brokerJournalOuterTransactionId && + left.brokerJournalCandidateSha256 == + right.brokerJournalCandidateSha256 && + left.brokerJournalState == right.brokerJournalState && + left.brokerJournalDigest == right.brokerJournalDigest)); +} + +int ForwardInstallJournalPhaseRank( + InstallJournalPhase phase) noexcept { + switch (phase) { + case InstallJournalPhase::Prepared: return 0; + case InstallJournalPhase::SetupCopyEntered: return 10; + case InstallJournalPhase::SetupCopyReturned: return 11; + case InstallJournalPhase::StageReceiptCaptured: return 12; + case InstallJournalPhase::QuiesceSignalEntered: return 20; + case InstallJournalPhase::QuiesceSignalReturned: return 21; + case InstallJournalPhase::PriorAbiProfileCaptured: return 30; + case InstallJournalPhase::RootRegistrationIntentCaptured: return 39; + case InstallJournalPhase::RootRegistrationEntered: return 40; + case InstallJournalPhase::RootRegistrationReturned: return 41; + case InstallJournalPhase::DiInstallEntered: return 50; + case InstallJournalPhase::DiInstallReturned: return 51; + case InstallJournalPhase::DriverValidated: return 60; + case InstallJournalPhase::BrokerHandoffEntered: return 70; + case InstallJournalPhase::BrokerHandoffReturned: return 71; + case InstallJournalPhase::BrokerChildEntered: return 80; + case InstallJournalPhase::BrokerChildSettled: return 81; + case InstallJournalPhase::BrokerOuterSettlementPending: return 91; + case InstallJournalPhase::BrokerOuterSettled: return 92; + case InstallJournalPhase::PartialRootRemovalEntered: return 82; + case InstallJournalPhase::PartialRootRemovalReturned: return 83; + case InstallJournalPhase::PartialRootRemovalRebootPending: return 84; + case InstallJournalPhase::ForwardValidated: return 90; + case InstallJournalPhase::ForwardRebootPending: return 90; + case InstallJournalPhase::ExactPriorRestored: return 90; + case InstallJournalPhase::RestoreRebootPending: return 90; + case InstallJournalPhase::ManualReconciliationRequired: return 90; + default: return -1; + } +} + +bool IsInstallJournalTerminalPhase(InstallJournalPhase phase) noexcept { + return phase == InstallJournalPhase::BrokerOuterSettled || + phase == InstallJournalPhase::ExactPriorRestored || + phase == InstallJournalPhase::ForwardRebootPending || + phase == InstallJournalPhase::RestoreRebootPending || + phase == InstallJournalPhase::ManualReconciliationRequired; +} + +bool MatchingInstallJournalReturn( + InstallJournalPhase entered, + InstallJournalPhase returned) noexcept { + return (entered == InstallJournalPhase::SetupCopyEntered && + returned == InstallJournalPhase::SetupCopyReturned) || + (entered == InstallJournalPhase::QuiesceSignalEntered && + returned == InstallJournalPhase::QuiesceSignalReturned) || + (entered == InstallJournalPhase::RootRegistrationEntered && + returned == InstallJournalPhase::RootRegistrationReturned) || + (entered == InstallJournalPhase::DiInstallEntered && + returned == InstallJournalPhase::DiInstallReturned) || + (entered == InstallJournalPhase::BrokerHandoffEntered && + returned == InstallJournalPhase::BrokerHandoffReturned) || + (entered == InstallJournalPhase::BrokerChildEntered && + returned == InstallJournalPhase::BrokerChildSettled) || + (entered == InstallJournalPhase::PartialRootRemovalEntered && + returned == InstallJournalPhase::PartialRootRemovalReturned) || + (entered == InstallJournalPhase::SetupUninstallEntered && + returned == InstallJournalPhase::SetupUninstallReturned); +} + +bool LegalForwardInstallJournalPhaseTransition( + InstallJournalPhase previous, + InstallJournalPhase next) noexcept { + if (previous == next) { + return previous == InstallJournalPhase::Prepared || + previous == InstallJournalPhase::SetupCopyReturned || + previous == InstallJournalPhase::QuiesceSignalReturned || + previous == InstallJournalPhase::RootRegistrationReturned || + previous == InstallJournalPhase::DiInstallReturned || + previous == InstallJournalPhase::BrokerHandoffReturned || + previous == InstallJournalPhase::BrokerChildSettled; + } + switch (next) { + case InstallJournalPhase::SetupCopyEntered: + return previous == InstallJournalPhase::Prepared; + case InstallJournalPhase::SetupCopyReturned: + case InstallJournalPhase::QuiesceSignalReturned: + case InstallJournalPhase::RootRegistrationReturned: + case InstallJournalPhase::DiInstallReturned: + case InstallJournalPhase::BrokerHandoffReturned: + case InstallJournalPhase::BrokerChildSettled: + return MatchingInstallJournalReturn(previous, next); + case InstallJournalPhase::StageReceiptCaptured: + return previous == InstallJournalPhase::SetupCopyReturned; + case InstallJournalPhase::QuiesceSignalEntered: + return previous == InstallJournalPhase::Prepared || + previous == InstallJournalPhase::StageReceiptCaptured; + case InstallJournalPhase::PriorAbiProfileCaptured: + return previous == InstallJournalPhase::Prepared || + previous == InstallJournalPhase::StageReceiptCaptured || + previous == InstallJournalPhase::QuiesceSignalReturned; + case InstallJournalPhase::RootRegistrationIntentCaptured: + return previous == InstallJournalPhase::Prepared || + previous == InstallJournalPhase::StageReceiptCaptured || + previous == InstallJournalPhase::QuiesceSignalReturned || + previous == InstallJournalPhase::PriorAbiProfileCaptured; + case InstallJournalPhase::RootRegistrationEntered: + return previous == + InstallJournalPhase::RootRegistrationIntentCaptured; + case InstallJournalPhase::DiInstallEntered: + return previous == InstallJournalPhase::Prepared || + previous == InstallJournalPhase::StageReceiptCaptured || + previous == InstallJournalPhase::QuiesceSignalReturned || + previous == InstallJournalPhase::PriorAbiProfileCaptured || + previous == InstallJournalPhase::RootRegistrationReturned; + case InstallJournalPhase::DriverValidated: + return previous == InstallJournalPhase::Prepared || + previous == InstallJournalPhase::DiInstallReturned; + case InstallJournalPhase::BrokerHandoffEntered: + return previous == InstallJournalPhase::DriverValidated; + case InstallJournalPhase::BrokerChildEntered: + return previous == InstallJournalPhase::BrokerHandoffReturned; + case InstallJournalPhase::ForwardValidated: + case InstallJournalPhase::ForwardRebootPending: + return previous == InstallJournalPhase::DriverValidated || + previous == InstallJournalPhase::BrokerChildSettled; + case InstallJournalPhase::BrokerOuterSettlementPending: + return previous == InstallJournalPhase::ForwardValidated; + case InstallJournalPhase::BrokerOuterSettled: + return previous == + InstallJournalPhase::BrokerOuterSettlementPending; + default: + return false; + } +} + +bool LegalRollbackInstallJournalPhaseTransition( + InstallJournalPhase previous, + InstallJournalPhase next) noexcept { + if (next == InstallJournalPhase::ManualReconciliationRequired || + next == InstallJournalPhase::ExactPriorRestored || + next == InstallJournalPhase::RestoreRebootPending) { + return true; + } + if (next == InstallJournalPhase::RollbackBindingEntered) { + return !IsInstallJournalTerminalPhase(previous); + } + if (previous == next) return true; + if (previous == InstallJournalPhase::BrokerHandoffReturned || + previous == InstallJournalPhase::BrokerChildSettled) { + return next == InstallJournalPhase::RollbackBindingEntered; + } + if (previous == InstallJournalPhase::RollbackBindingEntered) { + return next == InstallJournalPhase::PartialRootRemovalEntered || + next == InstallJournalPhase::RootRegistrationEntered || + next == InstallJournalPhase::DiInstallEntered || + next == InstallJournalPhase::SetupUninstallEntered || + next == InstallJournalPhase::RollbackBindingReturned; + } + if (previous == InstallJournalPhase::RootRegistrationEntered || + previous == InstallJournalPhase::DiInstallEntered || + previous == InstallJournalPhase::SetupUninstallEntered) { + return MatchingInstallJournalReturn(previous, next); + } + if (previous == InstallJournalPhase::PartialRootRemovalEntered) { + return next == InstallJournalPhase::PartialRootRemovalReturned || + next == InstallJournalPhase:: + PartialRootRemovalRebootPending; + } + if (previous == InstallJournalPhase::RootRegistrationReturned) { + return next == InstallJournalPhase::DiInstallEntered || + next == InstallJournalPhase::RollbackBindingReturned; + } + if (previous == InstallJournalPhase::DiInstallReturned) { + return next == InstallJournalPhase::SetupUninstallEntered || + next == InstallJournalPhase::RollbackBindingReturned; + } + if (previous == InstallJournalPhase::PartialRootRemovalReturned) { + return next == InstallJournalPhase::PartialRootRemovalEntered || + next == InstallJournalPhase::PartialRootRemovalRebootPending || + next == InstallJournalPhase::SetupUninstallEntered || + next == InstallJournalPhase::RollbackBindingReturned; + } + if (previous == + InstallJournalPhase::PartialRootRemovalRebootPending) { + return next == InstallJournalPhase::RollbackBindingEntered; + } + if (previous == InstallJournalPhase::SetupUninstallReturned) { + return next == InstallJournalPhase::RollbackBindingReturned; + } + if (previous == InstallJournalPhase::RollbackBindingReturned) { + return next == InstallJournalPhase::ExactPriorRestored || + next == InstallJournalPhase::RestoreRebootPending; + } + return false; +} + +bool ValidateInstallJournalTransition( + const InstallJournalStateData* previous, + const InstallJournalStateData& next, + Error* error) { + if (previous == nullptr) { + if (next.sequence != 0U || + next.phase != InstallJournalPhase::Prepared || + next.direction != InstallJournalDirection::Forward || + next.rollbackAuthorized || next.brokerEntered || + next.brokerSettled || next.hasBrokerProof || + !next.brokerSettlementNonce.empty() || + !next.brokerDriverPendingDigest.empty() || + !next.brokerSettlementRequestSha256.empty() || + !next.brokerGoPendingDigest.empty() || + next.hasPriorAbiProfile || + next.hasRootRegistrationIntent || + !next.rootRegistrationInstanceId.empty() || + next.partialRootRemovalBinding != + InstallJournalStateData::PartialRootRemovalBinding::None || + !next.partialRootRemovalBootIdentifier.empty() || + !next.pendingRebootBootIdentifier.empty() || + next.freshRebootRequired || + next.hasPublishedCandidate || + next.packageStagedHere || next.bindingMutationStarted || + next.rebootRequired || next.deadlineOverrun || + !next.callSucceeded || next.callError != ERROR_SUCCESS) { + return SetError(error, L"install-journal-initial-state", + ERROR_INVALID_DATA, + L"first journal record is not the exact immutable Prepared state"); + } + return true; + } + const InstallJournalStateData& prior = *previous; + if (IsInstallJournalTerminalPhase(prior.phase) || + (prior.direction == InstallJournalDirection::Rollback && + next.direction != InstallJournalDirection::Rollback) || + (prior.rollbackAuthorized && !next.rollbackAuthorized) || + (prior.brokerEntered && !next.brokerEntered) || + (prior.brokerSettled && !next.brokerSettled) || + (prior.packageStagedHere && !next.packageStagedHere) || + (prior.bindingMutationStarted && !next.bindingMutationStarted) || + (prior.rebootRequired && !next.rebootRequired) || + (prior.deadlineOverrun && !next.deadlineOverrun) || + (prior.hasPublishedCandidate && !next.hasPublishedCandidate) || + (prior.hasPriorAbiProfile && !next.hasPriorAbiProfile) || + (prior.hasRootRegistrationIntent && + !next.hasRootRegistrationIntent) || + (prior.partialRootRemovalBinding != + InstallJournalStateData::PartialRootRemovalBinding::None && + next.partialRootRemovalBinding == + InstallJournalStateData::PartialRootRemovalBinding::None) || + (!prior.partialRootRemovalBootIdentifier.empty() && + next.partialRootRemovalBootIdentifier.empty()) || + (prior.hasBrokerProof && !next.hasBrokerProof) || + (!prior.brokerSettlementNonce.empty() && + prior.brokerSettlementNonce != + next.brokerSettlementNonce) || + (!prior.brokerDriverPendingDigest.empty() && + prior.brokerDriverPendingDigest != + next.brokerDriverPendingDigest) || + (!prior.brokerSettlementRequestSha256.empty() && + prior.brokerSettlementRequestSha256 != + next.brokerSettlementRequestSha256) || + (!prior.brokerGoPendingDigest.empty() && + prior.brokerGoPendingDigest != + next.brokerGoPendingDigest)) { + return SetError(error, L"install-journal-monotonic-state", + ERROR_INVALID_DATA, + L"terminal, direction, ownership, or diagnostic state regressed"); + } + const bool pendingRebootEpochChanged = + prior.pendingRebootBootIdentifier != + next.pendingRebootBootIdentifier; + const bool authoritativeRebootReturn = + next.phase == InstallJournalPhase::DiInstallReturned || + next.phase == InstallJournalPhase::RollbackBindingReturned || + next.phase == + InstallJournalPhase::PartialRootRemovalReturned; + if ((pendingRebootEpochChanged && + !next.freshRebootRequired) || + (next.freshRebootRequired && + (!authoritativeRebootReturn || + !next.rebootRequired || + !IsCanonicalBootIdentifier( + next.pendingRebootBootIdentifier))) || + (!next.freshRebootRequired && + pendingRebootEpochChanged) || + (!next.rebootRequired && + !next.pendingRebootBootIdentifier.empty()) || + ((next.phase == InstallJournalPhase::ForwardRebootPending || + next.phase == InstallJournalPhase::RestoreRebootPending) && + next.pendingRebootBootIdentifier.empty())) { + return SetError(error, L"install-journal-reboot-epoch-chain", + ERROR_INVALID_DATA, + L"pending reboot epoch changed outside an authoritative fresh-reboot returned record"); + } + if (prior.hasPublishedCandidate && + !SameJournalPackageIdentity( + prior.publishedCandidate, next.publishedCandidate)) { + return SetError(error, L"install-journal-publication-chain", + ERROR_REVISION_MISMATCH, + L"published candidate identity changed across records"); + } + if (!prior.hasPublishedCandidate && next.hasPublishedCandidate && + next.phase != InstallJournalPhase::Prepared && + next.phase != InstallJournalPhase::StageReceiptCaptured) { + return SetError(error, L"install-journal-publication-chain", + ERROR_INVALID_DATA, + L"candidate publication first appeared outside exact prepublication or stage receipt"); + } + if (!prior.packageStagedHere && next.packageStagedHere && + next.phase != InstallJournalPhase::StageReceiptCaptured) { + return SetError(error, L"install-journal-stage-ownership-chain", + ERROR_INVALID_DATA, + L"transaction-owned stage identity first appeared outside its exact durable receipt"); + } + if (!SamePackageInventory( + prior.expectedInventory, next.expectedInventory)) { + std::vector permittedInventory = + prior.expectedInventory; + if (!prior.packageStagedHere && next.packageStagedHere && + next.hasPublishedCandidate && + !ContainsExactPackage( + permittedInventory, next.publishedCandidate)) { + permittedInventory.push_back(next.publishedCandidate); + std::sort(permittedInventory.begin(), permittedInventory.end(), + [](const PackageInfo& left, const PackageInfo& right) { + return _wcsicmp(left.publishedName.c_str(), + right.publishedName.c_str()) < 0; + }); + } + if (!SamePackageInventory( + permittedInventory, next.expectedInventory)) { + return SetError(error, L"install-journal-inventory-chain", + ERROR_REVISION_MISMATCH, + L"expected package inventory changed outside exact stage publication"); + } + } + if (prior.hasPriorAbiProfile && + !SameAbiCompatibilityProfile( + prior.priorAbiProfile, next.priorAbiProfile)) { + return SetError(error, L"install-journal-prior-abi-profile-chain", + ERROR_REVISION_MISMATCH, + L"durable prior ABI profile changed across records"); + } + if (prior.hasRootRegistrationIntent && + _wcsicmp(prior.rootRegistrationInstanceId.c_str(), + next.rootRegistrationInstanceId.c_str()) != 0) { + return SetError(error, L"install-journal-root-intent-chain", + ERROR_REVISION_MISMATCH, + L"durable generated root registration identity changed across records"); + } + const bool rootRemovalBootChanged = + prior.partialRootRemovalBootIdentifier != + next.partialRootRemovalBootIdentifier; + const bool rootRemovalBindingChanged = + prior.partialRootRemovalBinding != + next.partialRootRemovalBinding; + const bool hasRootRemovalBinding = + next.partialRootRemovalBinding != + InstallJournalStateData::PartialRootRemovalBinding::None; + if (((rootRemovalBootChanged || rootRemovalBindingChanged) && + next.phase != + InstallJournalPhase::PartialRootRemovalEntered) || + (next.partialRootRemovalBootIdentifier.empty() != + !hasRootRemovalBinding) || + (!next.partialRootRemovalBootIdentifier.empty() && + (!next.hasRootRegistrationIntent || + !next.prior.devices.empty() || + next.direction != InstallJournalDirection::Rollback || + !next.rollbackAuthorized)) || + ((next.phase == + InstallJournalPhase::PartialRootRemovalEntered || + next.phase == + InstallJournalPhase::PartialRootRemovalReturned || + next.phase == InstallJournalPhase:: + PartialRootRemovalRebootPending) && + (next.partialRootRemovalBootIdentifier.empty() || + !hasRootRemovalBinding))) { + return SetError(error, + L"install-journal-partial-root-removal-chain", + ERROR_INVALID_DATA, + L"partial root removal boot authority changed outside its exact rollback entry record"); + } + if (prior.hasBrokerProof && !SameDurableBrokerProof(prior, next)) { + return SetError(error, L"install-journal-broker-proof-chain", + ERROR_REVISION_MISMATCH, + L"settled broker proof changed across records"); + } + if (!prior.hasPriorAbiProfile && next.hasPriorAbiProfile && + next.phase != InstallJournalPhase::PriorAbiProfileCaptured) { + return SetError(error, L"install-journal-prior-abi-profile-chain", + ERROR_INVALID_DATA, + L"durable prior ABI profile first appeared outside its capture phase"); + } + if (!prior.hasRootRegistrationIntent && + next.hasRootRegistrationIntent && + (next.phase != + InstallJournalPhase::RootRegistrationIntentCaptured || + next.direction != InstallJournalDirection::Forward || + !next.prior.devices.empty() || + !next.hasPublishedCandidate || + !IsGeneratedRootInstanceIdForDeviceName( + next.rootRegistrationInstanceId, kRootDeviceName))) { + return SetError(error, L"install-journal-root-intent-chain", + ERROR_INVALID_DATA, + L"root registration identity first appeared outside exact forward pre-registration admission"); + } + if (!prior.hasBrokerProof && next.hasBrokerProof && + next.phase != InstallJournalPhase::BrokerChildSettled) { + return SetError(error, L"install-journal-broker-proof-chain", + ERROR_INVALID_DATA, + L"durable broker proof first appeared outside child settlement"); + } + const bool settlementNonceAppeared = + prior.brokerSettlementNonce.empty() && + !next.brokerSettlementNonce.empty(); + const bool settlementReceiptAppeared = + prior.brokerSettlementRequestSha256.empty() && + !next.brokerSettlementRequestSha256.empty(); + const bool brokerPendingDigestAppeared = + prior.brokerGoPendingDigest.empty() && + !next.brokerGoPendingDigest.empty(); + const bool driverPendingDigestAppeared = + prior.brokerDriverPendingDigest.empty() && + !next.brokerDriverPendingDigest.empty(); + if ((settlementNonceAppeared && + next.phase != + InstallJournalPhase::BrokerOuterSettlementPending) || + ((settlementReceiptAppeared || brokerPendingDigestAppeared || + driverPendingDigestAppeared) && + next.phase != InstallJournalPhase::BrokerOuterSettled) || + (settlementReceiptAppeared != brokerPendingDigestAppeared) || + (settlementReceiptAppeared != driverPendingDigestAppeared)) { + return SetError(error, L"install-journal-broker-settlement-chain", + ERROR_INVALID_DATA, + L"outer settlement identity appeared outside its exact durable phase"); + } + if (!prior.brokerEntered && next.brokerEntered && + next.phase != InstallJournalPhase::BrokerHandoffEntered) { + return SetError(error, L"install-journal-broker-chain", + ERROR_INVALID_DATA, + L"broker ownership first appeared outside handoff admission"); + } + if (!prior.brokerSettled && next.brokerSettled && + next.phase != InstallJournalPhase::BrokerChildSettled) { + return SetError(error, L"install-journal-broker-chain", + ERROR_INVALID_DATA, + L"broker settlement first appeared outside child settlement"); + } + if (!prior.rollbackAuthorized && next.rollbackAuthorized && + (next.direction != InstallJournalDirection::Rollback || + (next.phase != InstallJournalPhase::BrokerHandoffReturned && + next.phase != InstallJournalPhase::BrokerChildSettled && + next.phase != InstallJournalPhase::RollbackBindingEntered))) { + return SetError(error, L"install-journal-rollback-authorization-chain", + ERROR_INVALID_DATA, + L"rollback authority first appeared outside an authoritative admission record"); + } + if ((next.phase == InstallJournalPhase::ExactPriorRestored || + next.phase == InstallJournalPhase::RestoreRebootPending) && + next.brokerEntered && + (next.direction != InstallJournalDirection::Rollback || + !next.rollbackAuthorized)) { + return SetError(error, L"install-journal-terminal-authority", + ERROR_INVALID_DATA, + L"prior terminal phase lacks durable broker-safe rollback authority"); + } + if ((next.phase == InstallJournalPhase::ForwardValidated || + next.phase == InstallJournalPhase::ForwardRebootPending || + next.phase == + InstallJournalPhase::BrokerOuterSettlementPending || + next.phase == InstallJournalPhase::BrokerOuterSettled) && + next.brokerRequired && + (!next.brokerEntered || !next.brokerSettled || + !next.hasBrokerProof || !next.brokerProofSuccess || + next.brokerDriverRollbackAuthorized || + next.direction != InstallJournalDirection::Forward || + next.rollbackAuthorized)) { + return SetError(error, L"install-journal-terminal-authority", + ERROR_INVALID_DATA, + L"forward terminal phase lacks exact canonical broker commit authority"); + } + if (prior.direction == InstallJournalDirection::Forward && + next.direction == InstallJournalDirection::Rollback) { + if (!next.rollbackAuthorized || + (next.phase != InstallJournalPhase::BrokerHandoffReturned && + next.phase != InstallJournalPhase::BrokerChildSettled && + next.phase != InstallJournalPhase::RollbackBindingEntered)) { + return SetError(error, L"install-journal-direction-chain", + ERROR_INVALID_DATA, + L"forward ownership changed to rollback without a legal durable admission"); + } + return true; + } + if (next.direction == InstallJournalDirection::Rollback) { + if (!LegalRollbackInstallJournalPhaseTransition( + prior.phase, next.phase)) { + return SetError(error, L"install-journal-phase-chain", + ERROR_INVALID_DATA, + L"rollback journal phase transition is not legal"); + } + return true; + } + if (next.phase == InstallJournalPhase::RollbackBindingEntered || + next.phase == InstallJournalPhase::RollbackBindingReturned || + next.phase == InstallJournalPhase::PartialRootRemovalEntered || + next.phase == InstallJournalPhase::PartialRootRemovalReturned || + next.phase == InstallJournalPhase:: + PartialRootRemovalRebootPending || + next.phase == InstallJournalPhase::SetupUninstallEntered || + next.phase == InstallJournalPhase::SetupUninstallReturned) { + return SetError(error, L"install-journal-phase-chain", + ERROR_INVALID_DATA, + L"rollback-only phase was published in forward direction"); + } + if (next.phase == InstallJournalPhase::ManualReconciliationRequired || + next.phase == InstallJournalPhase::ExactPriorRestored || + next.phase == InstallJournalPhase::RestoreRebootPending) { + return true; + } + if (!LegalForwardInstallJournalPhaseTransition( + prior.phase, next.phase)) { + return SetError(error, L"install-journal-phase-chain", + ERROR_INVALID_DATA, + L"forward journal phase transition is not legal"); + } + return true; +} + +struct LoadedInstallJournal { + InstallRecoveryDirectory directory; + InstallJournalStateData state; + bool hasRecord = false; + bool forwardRootRegistrationEntered = false; + bool forwardDiInstallEntered = false; + bool partialRootRemovalEntered = false; + std::vector evidenceLocks; +}; + +bool ParseInstallJournalEnvelope( + std::string_view record, + const std::filesystem::path& active, + InstallJournalStateData* state, + std::string* digest, + Error* error) { + JsonValue root; + std::string parseMessage; + if (!JsonParser(record).Parse(&root, &parseMessage)) { + std::wstring message; + Utf8ToWide(parseMessage, &message, nullptr); + return SetError(error, L"install-journal-chain", + ERROR_INVALID_DATA, + L"journal envelope is truncated or malformed: " + message); + } + const JsonValue::Object* object = nullptr; + uint64_t schema = 0; + std::string kind; + std::string payloadDigest; + std::string payload; + if (!RequireJournalObject(root, &object, error) || + object->size() != 4U || + !RequireJournalUnsigned(*object, "schema", 2U, &schema, error) || + schema != 2U || + !RequireJournalString(*object, "kind", &kind, error) || + kind != kInstallRecoveryKind || + !RequireJournalString( + *object, "payloadSha256", &payloadDigest, error) || + !RequireJournalString(*object, "payload", &payload, error) || + !IsSha256Digest(payloadDigest)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-chain", ERROR_INVALID_DATA, + L"journal envelope is not the exact v2 contract"); + } + return false; + } + std::string canonicalEnvelope = "{\"schema\":2,\"kind\":"; + AppendJsonAsciiString(&canonicalEnvelope, kInstallRecoveryKind); + canonicalEnvelope.append(",\"payloadSha256\":"); + AppendJsonAsciiString(&canonicalEnvelope, LowerAscii(payloadDigest)); + canonicalEnvelope.append(",\"payload\":"); + AppendJsonUtf8String(&canonicalEnvelope, payload); + canonicalEnvelope.append("}\n"); + if (record != canonicalEnvelope) { + return SetError(error, L"install-journal-canonical-envelope", + ERROR_INVALID_DATA, + L"journal envelope is not in exact canonical byte form"); + } + std::string observedDigest; + if (!Sha256Data(payload, &observedDigest, error) || + _stricmp(observedDigest.c_str(), payloadDigest.c_str()) != 0) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-chain", ERROR_CRC, + L"journal payload hash does not match its published receipt"); + } + return false; + } + if (!ParseInstallJournalPayload(payload, active, state, error)) { + return false; + } + *digest = LowerAscii(std::move(payloadDigest)); + return true; +} + +bool ParseJournalRecordFileName( + std::wstring_view name, + uint64_t* sequence) noexcept { + const std::wstring_view prefix(kInstallRecoveryJournalPrefix); + const std::wstring_view suffix(kInstallRecoveryJournalSuffix); + if (!name.starts_with(prefix) || !name.ends_with(suffix) || + name.size() != prefix.size() + 8U + suffix.size()) { + return false; + } + uint64_t parsed = 0; + for (size_t index = prefix.size(); index < prefix.size() + 8U; ++index) { + if (name[index] < L'0' || name[index] > L'9') { + return false; + } + parsed = parsed * 10U + static_cast(name[index] - L'0'); + } + *sequence = parsed; + return true; +} + +bool ParseJournalTemporaryFileName( + std::wstring_view name, + uint64_t* sequence) noexcept { + const std::wstring_view temporarySuffix( + kInstallRecoveryTemporarySuffix); + return name.ends_with(temporarySuffix) && + ParseJournalRecordFileName( + name.substr(0, name.size() - temporarySuffix.size()), + sequence); +} + +bool InstallJournalTemporarySequenceIsRecoverable( + uint64_t temporarySequence, + size_t publishedRecordCount) noexcept { + return publishedRecordCount < kMaximumInstallRecoveryRecords && + temporarySequence == publishedRecordCount; +} + +bool ValidateLoadedInstallJournalEvidence( + LoadedInstallJournal* loaded, + Error* error) { + WinHandle priorHandle; + WinHandle candidateHandle; + if (!OpenStableDirectory( + loaded->directory.active / kInstallRecoveryPriorDirectory, + true, &priorHandle, error) || + !OpenStableDirectory( + loaded->directory.active / kInstallRecoveryCandidateDirectory, + true, &candidateHandle, error)) { + return false; + } + loaded->evidenceLocks.push_back(std::move(priorHandle)); + loaded->evidenceLocks.push_back(std::move(candidateHandle)); + + const std::filesystem::path brokerDirectory = + loaded->directory.active / kInstallRecoveryBrokerDirectory; + const DWORD brokerDirectoryAttributes = + GetFileAttributesW(brokerDirectory.c_str()); + if (loaded->state.brokerRequired) { + WinHandle brokerDirectoryHandle; + WinHandle brokerImage; + if (!OpenStableDirectory( + brokerDirectory, true, &brokerDirectoryHandle, error) || + !ValidateExactBrokerEvidenceDirectory( + brokerDirectory, error) || + !LockProtectedBrokerImage( + brokerDirectory / kInstallRecoveryBrokerExecutable, + loaded->state.brokerExecutableSha256, + &brokerImage, error)) { + return false; + } + loaded->evidenceLocks.push_back(std::move(brokerDirectoryHandle)); + loaded->evidenceLocks.push_back(std::move(brokerImage)); + } else if (brokerDirectoryAttributes != INVALID_FILE_ATTRIBUTES) { + return SetError(error, L"install-journal-broker-evidence", + ERROR_INVALID_DATA, + L"non-broker transaction contains unexpected broker evidence"); + } else { + const DWORD absenceError = GetLastError(); + if (absenceError != ERROR_FILE_NOT_FOUND && + absenceError != ERROR_PATH_NOT_FOUND) { + return SetError(error, L"install-journal-broker-evidence", + absenceError); + } + } + + const std::filesystem::path candidateDirectory = + loaded->directory.active / kInstallRecoveryCandidateDirectory; + PackageInfo candidateCopy; + bool owned = false; + if (!ValidateExactPackageDirectory(candidateDirectory, error) || + !LoadOwnedPackage(candidateDirectory / L"ViiperUde.inf", true, + loaded->state.localTest, &candidateCopy, &owned, error) || + !owned || !(candidateCopy.version == loaded->state.candidate.version) || + !SamePackageBytes(candidateCopy, loaded->state.candidate)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-candidate-evidence", + ERROR_REVISION_MISMATCH); + } + return false; + } + std::vector candidateLocks; + if (!LockPackageFiles(candidateDirectory, &candidateLocks, error)) { + return false; + } + for (WinHandle& lock : candidateLocks) { + loaded->evidenceLocks.push_back(std::move(lock)); + } + for (size_t index = 0; index < loaded->state.prior.packages.size(); ++index) { + PackageInfo& prior = loaded->state.prior.packages[index]; + const std::filesystem::path directory = + loaded->directory.active / kInstallRecoveryPriorDirectory / + std::to_wstring(index); + WinHandle directoryHandle; + if (!OpenStableDirectory(directory, true, &directoryHandle, error) || + !ValidateExactPackageDirectory(directory, error)) { + return false; + } + loaded->evidenceLocks.push_back(std::move(directoryHandle)); + PackageInfo copy; + owned = false; + if (!LoadOwnedPackage(directory / L"ViiperUde.inf", true, false, + ©, &owned, error) || !owned || + !(copy.version == prior.version) || + !SamePackageBytes(copy, prior)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-prior-evidence", + ERROR_REVISION_MISMATCH); + } + return false; + } + std::vector locks; + if (!LockPackageFiles(directory, &locks, error)) { + return false; + } + for (WinHandle& lock : locks) { + loaded->evidenceLocks.push_back(std::move(lock)); + } + } + std::filesystem::path systemInf; + if (!GetSystemInfDirectory(&systemInf, error)) { + return false; + } + for (PackageInfo& package : loaded->state.prior.packages) { + package.infPath = systemInf / package.publishedName; + } + for (DeviceState& device : loaded->state.prior.devices) { + for (const PackageInfo& package : loaded->state.prior.packages) { + if (_wcsicmp(package.publishedName.c_str(), + device.publishedInf.c_str()) == 0) { + device.package = package; + } + } + } + if (loaded->state.hasPublishedCandidate) { + loaded->state.publishedCandidate.infPath = + systemInf / loaded->state.publishedCandidate.publishedName; + } + return true; +} + +bool LoadInstallJournal( + InstallRecoveryDirectory&& directory, + LoadedInstallJournal* loaded, + Error* error) { + loaded->directory = std::move(directory); + std::map records; + std::optional> temporary; + std::error_code enumerationError; + for (std::filesystem::directory_iterator iterator( + loaded->directory.active, enumerationError), end; + !enumerationError && iterator != end; + iterator.increment(enumerationError)) { + const std::wstring name = iterator->path().filename().wstring(); + const DWORD attributes = GetFileAttributesW(iterator->path().c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES || + (attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + return SetError(error, L"install-journal-discovery", + ERROR_REPARSE_TAG_MISMATCH); + } + if ((attributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (name == kInstallRecoveryPriorDirectory || + name == kInstallRecoveryCandidateDirectory || + name == kInstallRecoveryBrokerDirectory)) { + continue; + } + uint64_t sequence = 0; + if ((attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 && + ParseJournalRecordFileName(name, &sequence)) { + if (sequence >= kMaximumInstallRecoveryRecords || + !records.emplace(sequence, iterator->path()).second) { + return SetError(error, L"install-journal-chain", + ERROR_DUPLICATE_SERVICE_NAME); + } + continue; + } + if ((attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 && + ParseJournalTemporaryFileName(name, &sequence)) { + if (sequence >= kMaximumInstallRecoveryRecords || temporary) { + return SetError(error, L"install-journal-temp-chain", + ERROR_INVALID_DATA, + L"transaction contains more than one canonical unpublished temp or an out-of-range temp"); + } + temporary.emplace(sequence, iterator->path()); + continue; + } + return SetError(error, L"install-journal-discovery", + ERROR_INVALID_DATA, + L"protected transaction directory contains an unexpected entry"); + } + if (enumerationError) { + return SetError(error, L"install-journal-discovery", + static_cast(enumerationError.value())); + } + if ((!records.empty() && + (records.begin()->first != 0U || + records.rbegin()->first + 1U != records.size())) || + (temporary && !InstallJournalTemporarySequenceIsRecoverable( + temporary->first, records.size()))) { + return SetError(error, L"install-journal-chain", ERROR_INVALID_DATA, + L"journal sequence or unpublished temp is missing, stale, or out of order"); + } + if (temporary && + !ValidateAndDiscardInstallJournalTemporaryFile( + temporary->second, error)) { + return false; + } + if (records.empty()) { + loaded->hasRecord = false; + return true; + } + std::string priorDigest(kZeroSha256); + std::optional immutable; + std::optional previousState; + std::optional capturedPriorAbiProfile; + for (const auto& [expectedSequence, path] : records) { + std::string record; + InstallJournalStateData parsed; + std::string digest; + if (!ReadInstallJournalFile(path, &record, error) || + !ParseInstallJournalEnvelope( + record, loaded->directory.active, &parsed, &digest, error) || + parsed.sequence != expectedSequence || + _stricmp(parsed.previousDigest.c_str(), priorDigest.c_str()) != 0 || + (immutable && !SameInstallJournalImmutableState( + *immutable, parsed)) || + !ValidateInstallJournalTransition( + previousState ? &*previousState : nullptr, + parsed, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-chain", ERROR_CRC, + L"journal hash chain or immutable transaction identity changed"); + } + return false; + } + if (parsed.hasPriorAbiProfile) { + if (!capturedPriorAbiProfile && + parsed.phase != InstallJournalPhase::PriorAbiProfileCaptured) { + return SetError(error, + L"install-journal-prior-abi-profile-chain", + ERROR_INVALID_DATA, + L"durable prior ABI profile first appeared outside its capture phase"); + } + if (capturedPriorAbiProfile && + !SameAbiCompatibilityProfile( + *capturedPriorAbiProfile, parsed.priorAbiProfile)) { + return SetError(error, + L"install-journal-prior-abi-profile-chain", + ERROR_REVISION_MISMATCH, + L"durable prior ABI profile changed across phase records"); + } + capturedPriorAbiProfile = parsed.priorAbiProfile; + } else if (capturedPriorAbiProfile) { + return SetError(error, + L"install-journal-prior-abi-profile-chain", + ERROR_INVALID_DATA, + L"durable prior ABI profile disappeared from a later phase record"); + } + if (parsed.direction == InstallJournalDirection::Forward) { + loaded->forwardRootRegistrationEntered = + loaded->forwardRootRegistrationEntered || + parsed.phase == InstallJournalPhase::RootRegistrationEntered; + loaded->forwardDiInstallEntered = + loaded->forwardDiInstallEntered || + parsed.phase == InstallJournalPhase::DiInstallEntered; + } + loaded->partialRootRemovalEntered = + loaded->partialRootRemovalEntered || + parsed.phase == + InstallJournalPhase::PartialRootRemovalEntered; + if (!immutable) { + immutable = parsed; + } + previousState = parsed; + priorDigest = digest; + loaded->state = std::move(parsed); + loaded->state.lastDigest = digest; + } + loaded->state.previousDigest = priorDigest; + loaded->state.sequence = records.size(); + loaded->hasRecord = true; + return ValidateLoadedInstallJournalEvidence(loaded, error); +} + +bool RetireLoadedInstallJournal( + LoadedInstallJournal* loaded, + Error* error) { + loaded->evidenceLocks.clear(); + return RetireInstallRecoveryActiveDirectory( + &loaded->directory, loaded->state.transactionId, + error, false, nullptr); +} + +bool CurrentStateMatchesPrior( + const InstallJournalStateData& state, + uint64_t deadlineUnixMs, + Error* error) { + Snapshot observed; + if (!CaptureSnapshot(&observed, error)) { + return false; + } + if (!SameCapturedRootState(state.prior, observed) || + !SamePackageInventory(state.prior.packages, observed.packages)) { + return SetError(error, L"install-journal-prior-state", + ERROR_REVISION_MISMATCH); + } + if (state.bindingMutationStarted && !state.prior.devices.empty() && + state.prior.devices[0].started) { + if (!state.hasPriorAbiProfile) { + return SetError(error, L"install-journal-prior-abi-profile", + ERROR_REVISION_MISMATCH, + L"started prior root lacks its durable exact ABI profile"); + } + return VerifyAbiHealth(deadlineUnixMs, nullptr, error, + AbiHealthPurpose::RollbackHealth, + &state.priorAbiProfile, nullptr); + } + return true; +} + +bool RootSnapshotIsAuthorizedForInstallRollback( + const InstallJournalStateData& state, + const Snapshot& observed) noexcept { + if (state.prior.devices.empty()) { + if (observed.devices.empty()) return true; + if (observed.devices.size() != 1U || + !state.bindingMutationStarted || + !state.hasPublishedCandidate) { + return false; + } + const DeviceState& current = observed.devices[0]; + if (!IsOwnedGeneratedRootInstanceId(current.instanceId) || + !current.present || + _wcsicmp(current.service.c_str(), kServiceName) != 0 || + _wcsicmp(current.publishedInf.c_str(), + state.publishedCandidate.publishedName.c_str()) != 0 || + !(current.version == state.candidate.version) || + !SamePackageBytes(current.package, state.candidate)) { + return false; + } + return true; + } + if (observed.devices.size() != 1U) { + return false; + } + const DeviceState& prior = state.prior.devices[0]; + const DeviceState& current = observed.devices[0]; + if (_wcsicmp(prior.instanceId.c_str(), current.instanceId.c_str()) != 0 || + !current.present || + _wcsicmp(current.service.c_str(), kServiceName) != 0) { + return false; + } + if (SameRootBinding(prior, current)) return true; + if (state.hasPublishedCandidate && + _wcsicmp(current.publishedInf.c_str(), + state.publishedCandidate.publishedName.c_str()) == 0 && + current.version == state.candidate.version && + SamePackageBytes(current.package, state.candidate)) { + return true; + } + return false; +} + +enum class PartialInstallRootRecoveryAction { + PriorEmpty, + RemoveUnboundExactRoot, + RemoveCandidateBoundExactRoot, + PendingExactRootRemoval, + Manual, +}; + +struct PartialInstallRootRecoveryFacts { + bool priorEmpty = false; + bool bindingMutationStarted = false; + bool forwardRootRegistrationEntered = false; + bool forwardDiInstallEntered = false; + bool partialRootRemovalEntered = false; + size_t relatedRootCount = 0; + bool hardwareIdAbsent = false; + bool exactHardwareId = false; + bool exactClass = false; + bool exactGeneratedInstance = false; + bool present = false; + bool serviceEmpty = false; + bool publishedInfEmpty = false; + bool driverVersionEmpty = false; + bool exactCandidateService = false; + bool exactCandidateInf = false; + bool exactCandidateVersion = false; + bool exactCandidateBytes = false; + bool pendingRemovalLifecycle = false; +}; + +PartialInstallRootRecoveryAction ClassifyPartialInstallRootRecovery( + const PartialInstallRootRecoveryFacts& facts) noexcept { + if (!facts.priorEmpty) { + return PartialInstallRootRecoveryAction::Manual; + } + if (facts.relatedRootCount == 0U) { + return PartialInstallRootRecoveryAction::PriorEmpty; + } + if (facts.relatedRootCount != 1U || + !facts.bindingMutationStarted || + !facts.forwardRootRegistrationEntered || + !facts.exactClass || !facts.exactGeneratedInstance) { + return PartialInstallRootRecoveryAction::Manual; + } + const bool emptyBinding = facts.serviceEmpty && + facts.publishedInfEmpty && facts.driverVersionEmpty; + const bool exactCandidateBinding = + facts.forwardDiInstallEntered && + facts.exactCandidateService && facts.exactCandidateInf && + facts.exactCandidateVersion && facts.exactCandidateBytes; + const bool hasCandidateBindingFragment = + facts.exactCandidateService || facts.exactCandidateInf || + facts.exactCandidateVersion; + const bool canonicalPendingBinding = + (facts.serviceEmpty || facts.exactCandidateService) && + (facts.publishedInfEmpty || facts.exactCandidateInf) && + (facts.driverVersionEmpty || facts.exactCandidateVersion) && + (facts.publishedInfEmpty || facts.exactCandidateBytes) && + (!hasCandidateBindingFragment || facts.forwardDiInstallEntered); + if (facts.partialRootRemovalEntered && + (facts.hardwareIdAbsent || facts.exactHardwareId) && + facts.pendingRemovalLifecycle && + canonicalPendingBinding) { + return PartialInstallRootRecoveryAction::PendingExactRootRemoval; + } + if (!facts.exactHardwareId || !facts.present) { + return PartialInstallRootRecoveryAction::Manual; + } + if (emptyBinding) { + return PartialInstallRootRecoveryAction::RemoveUnboundExactRoot; + } + if (exactCandidateBinding) { + return PartialInstallRootRecoveryAction::RemoveCandidateBoundExactRoot; + } + return PartialInstallRootRecoveryAction::Manual; +} + +bool InstallJournalRecoveryUsesStrictBindingRestore( + const InstallJournalStateData& state) noexcept { + return !state.prior.devices.empty() && + state.bindingMutationStarted; +} + +bool IsInGeneratedRootDeviceNamespace( + const std::wstring& instanceId, + const wchar_t* deviceName) { + const std::wstring prefix = std::wstring(L"ROOT\\") + deviceName + L"\\"; + return instanceId.size() >= prefix.size() && + _wcsnicmp(instanceId.c_str(), prefix.c_str(), prefix.size()) == 0; +} + +bool ReadInstallRecoveryRootInstanceId( + HDEVINFO set, + SP_DEVINFO_DATA& data, + std::wstring* instanceId, + Error* error) { + DWORD required = 0; + SetupDiGetDeviceInstanceIdW(set, &data, nullptr, 0, &required); + if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return SetLastErrorDetail( + error, L"install-journal-raw-root-instance-id"); + } + std::vector value(required); + if (!SetupDiGetDeviceInstanceIdW( + set, &data, value.data(), required, nullptr)) { + return SetLastErrorDetail( + error, L"install-journal-raw-root-instance-id"); + } + *instanceId = value.data(); + return true; +} + +struct InstallRecoveryHardwareIdObservation { + bool absent = false; + bool containsExpected = false; + bool exact = false; +}; + +bool ClassifyCanonicalInstallRecoveryHardwareIds( + const std::vector& value, + InstallRecoveryHardwareIdObservation* observation) noexcept { + *observation = InstallRecoveryHardwareIdObservation{}; + if (value.size() < 3U || value.back() != L'\0' || + value[value.size() - 2U] != L'\0' || value.front() == L'\0') { + return false; + } + size_t cursor = 0; + size_t entries = 0; + while (cursor < value.size() - 1U) { + const auto terminator = std::find( + value.begin() + static_cast(cursor), + value.end() - 1, L'\0'); + if (terminator == value.end() - 1 || + terminator == value.begin() + + static_cast(cursor)) { + return false; + } + const size_t length = static_cast( + terminator - (value.begin() + + static_cast(cursor))); + const size_t expectedLength = wcslen(kHardwareId); + if (length == expectedLength && + _wcsnicmp(value.data() + cursor, + kHardwareId, expectedLength) == 0) { + observation->containsExpected = true; + } + ++entries; + cursor += length + 1U; + } + if (cursor != value.size() - 1U) { + return false; + } + observation->exact = entries == 1U && + observation->containsExpected; + return true; +} + +bool ReadInstallRecoveryHardwareIds( + HDEVINFO set, + SP_DEVINFO_DATA& data, + InstallRecoveryHardwareIdObservation* observation, + Error* error) { + *observation = InstallRecoveryHardwareIdObservation{}; + DWORD type = 0; + DWORD required = 0; + if (SetupDiGetDeviceRegistryPropertyW( + set, &data, SPDRP_HARDWAREID, &type, nullptr, 0, &required)) { + return SetError(error, L"install-journal-raw-root-hardware-id", + ERROR_INVALID_DATA, + L"root hardware ID query returned an invalid zero-length value"); + } + const DWORD queryError = GetLastError(); + if (queryError == ERROR_INVALID_DATA) { + observation->absent = true; + return true; + } + if (queryError != ERROR_INSUFFICIENT_BUFFER || type != REG_MULTI_SZ || + required < 3U * sizeof(wchar_t) || + required % sizeof(wchar_t) != 0U || + required > 64U * 1024U) { + return SetError(error, L"install-journal-raw-root-hardware-id", + queryError == ERROR_SUCCESS ? ERROR_INVALID_DATA : queryError, + L"root hardware ID is unreadable or not an exact MULTI_SZ value"); + } + std::vector value(required / sizeof(wchar_t)); + DWORD returned = 0; + DWORD returnedType = 0; + if (!SetupDiGetDeviceRegistryPropertyW( + set, &data, SPDRP_HARDWAREID, &returnedType, + reinterpret_cast(value.data()), required, &returned)) { + return SetLastErrorDetail( + error, L"install-journal-raw-root-hardware-id"); + } + if (returnedType != REG_MULTI_SZ || returned != required || + !ClassifyCanonicalInstallRecoveryHardwareIds( + value, observation)) { + return SetError(error, L"install-journal-raw-root-hardware-id", + ERROR_INVALID_DATA, + L"root hardware ID changed during observation or is not canonical MULTI_SZ data"); + } + return true; +} + +bool DecodeCanonicalInstallRecoveryString( + const std::vector& buffer, + std::wstring* value) noexcept { + if (buffer.empty() || buffer.back() != L'\0' || + std::find(buffer.begin(), buffer.end() - 1, L'\0') != + buffer.end() - 1) { + return false; + } + value->assign(buffer.data(), buffer.size() - 1U); + return true; +} + +bool ReadCanonicalInstallRecoveryService( + HDEVINFO set, + SP_DEVINFO_DATA& data, + std::wstring* service, + Error* error) { + DWORD type = 0; + DWORD required = 0; + if (SetupDiGetDeviceRegistryPropertyW( + set, &data, SPDRP_SERVICE, &type, nullptr, 0, &required)) { + return SetError(error, L"install-journal-raw-root-service", + ERROR_INVALID_DATA); + } + const DWORD queryError = GetLastError(); + if (queryError == ERROR_INVALID_DATA) { + service->clear(); + return true; + } + if (queryError != ERROR_INSUFFICIENT_BUFFER || type != REG_SZ || + required < sizeof(wchar_t) || + required % sizeof(wchar_t) != 0U || + required > 64U * 1024U) { + return SetError(error, L"install-journal-raw-root-service", + queryError == ERROR_SUCCESS ? ERROR_INVALID_DATA : queryError, + L"root service is unreadable or not canonical REG_SZ data"); + } + std::vector buffer(required / sizeof(wchar_t)); + DWORD returned = 0; + DWORD returnedType = 0; + if (!SetupDiGetDeviceRegistryPropertyW( + set, &data, SPDRP_SERVICE, &returnedType, + reinterpret_cast(buffer.data()), required, &returned)) { + return SetLastErrorDetail( + error, L"install-journal-raw-root-service"); + } + if (returnedType != REG_SZ || returned != required || + !DecodeCanonicalInstallRecoveryString(buffer, service)) { + return SetError(error, L"install-journal-raw-root-service", + ERROR_INVALID_DATA, + L"root service changed during observation or contains hidden string data"); + } + return true; +} + +bool ReadCanonicalInstallRecoveryDevicePropertyString( + HDEVINFO set, + SP_DEVINFO_DATA& data, + const DEVPROPKEY& key, + const wchar_t* phase, + std::wstring* value, + Error* error) { + DEVPROPTYPE type = 0; + DWORD required = 0; + if (SetupDiGetDevicePropertyW( + set, &data, &key, &type, nullptr, 0, &required, 0)) { + return SetError(error, phase, ERROR_INVALID_DATA); + } + const DWORD queryError = GetLastError(); + if (queryError == ERROR_NOT_FOUND) { + value->clear(); + return true; + } + if (queryError != ERROR_INSUFFICIENT_BUFFER || + type != DEVPROP_TYPE_STRING || required < sizeof(wchar_t) || + required % sizeof(wchar_t) != 0U || + required > 64U * 1024U) { + return SetError(error, phase, + queryError == ERROR_SUCCESS ? ERROR_INVALID_DATA : queryError, + L"root device property is unreadable or not canonical string data"); + } + std::vector buffer(required / sizeof(wchar_t)); + DWORD returned = 0; + DEVPROPTYPE returnedType = 0; + if (!SetupDiGetDevicePropertyW( + set, &data, &key, &returnedType, + reinterpret_cast(buffer.data()), required, + &returned, 0)) { + return SetLastErrorDetail(error, phase); + } + if (returnedType != DEVPROP_TYPE_STRING || returned != required || + !DecodeCanonicalInstallRecoveryString(buffer, value)) { + return SetError(error, phase, ERROR_INVALID_DATA, + L"root device property changed during observation or contains hidden string data"); + } + return true; +} + +struct InstallRecoveryRootObservation { + PartialInstallRootRecoveryAction action = + PartialInstallRootRecoveryAction::Manual; + DeviceInfoSet set{INVALID_HANDLE_VALUE}; + SP_DEVINFO_DATA data{}; +}; + +bool ObservePriorEmptyInstallRecoveryRoot( + const LoadedInstallJournal& loaded, + InstallRecoveryRootObservation* observation, + Error* error) { + *observation = InstallRecoveryRootObservation{}; + DeviceInfoSet set = OpenRootDevices(); + if (!set) { + return SetLastErrorDetail(error, L"install-journal-raw-root-open"); + } + struct RelatedRoot { + SP_DEVINFO_DATA data{}; + std::wstring instanceId; + bool hardwareIdAbsent = false; + bool exactHardwareId = false; + }; + std::vector related; + for (DWORD index = 0;; ++index) { + SP_DEVINFO_DATA data{}; + data.cbSize = sizeof(data); + if (!SetupDiEnumDeviceInfo(set.get(), index, &data)) { + if (GetLastError() != ERROR_NO_MORE_ITEMS) { + return SetLastErrorDetail( + error, L"install-journal-raw-root-enumeration"); + } + break; + } + std::wstring instanceId; + if (!ReadInstallRecoveryRootInstanceId( + set.get(), data, &instanceId, error)) { + return false; + } + InstallRecoveryHardwareIdObservation hardwareIds; + if (!ReadInstallRecoveryHardwareIds( + set.get(), data, &hardwareIds, error)) { + return false; + } + const bool inTransactionNamespace = + IsInGeneratedRootDeviceNamespace(instanceId, kRootDeviceName); + if (!hardwareIds.containsExpected && !inTransactionNamespace) { + continue; + } + related.push_back(RelatedRoot{ + data, std::move(instanceId), hardwareIds.absent, + hardwareIds.exact}); + } + + PartialInstallRootRecoveryFacts facts; + facts.priorEmpty = loaded.state.prior.devices.empty(); + facts.bindingMutationStarted = loaded.state.bindingMutationStarted; + facts.forwardRootRegistrationEntered = + loaded.forwardRootRegistrationEntered; + facts.forwardDiInstallEntered = loaded.forwardDiInstallEntered; + facts.partialRootRemovalEntered = + loaded.partialRootRemovalEntered; + facts.relatedRootCount = related.size(); + if (related.size() == 1U) { + RelatedRoot& root = related[0]; + facts.hardwareIdAbsent = root.hardwareIdAbsent; + facts.exactHardwareId = root.exactHardwareId; + if (!ReadDevicePresence( + set.get(), root.data, &facts.present, error)) { + return false; + } + if (!facts.present) { + facts.pendingRemovalLifecycle = true; + } else { + ULONG status = 0; + ULONG problem = 0; + const CONFIGRET configuration = CM_Get_DevNode_Status( + &status, &problem, root.data.DevInst, 0); + if (configuration != CR_SUCCESS) { + return SetError(error, + L"install-journal-raw-root-lifecycle", + ERROR_INVALID_DATA, + L"present receipt-bound root lifecycle could not be observed canonically"); + } + facts.pendingRemovalLifecycle = + problem == CM_PROB_WILL_BE_REMOVED; + } + facts.exactClass = + IsEqualGUID(root.data.ClassGuid, GUID_DEVCLASS_USB) != FALSE; + facts.exactGeneratedInstance = + loaded.state.hasRootRegistrationIntent && + IsGeneratedRootInstanceIdForDeviceName( + root.instanceId, kRootDeviceName) && + _wcsicmp(root.instanceId.c_str(), + loaded.state.rootRegistrationInstanceId.c_str()) == 0; + + std::wstring service; + std::wstring publishedInf; + std::wstring driverVersion; + if (!ReadCanonicalInstallRecoveryService( + set.get(), root.data, &service, error) || + !ReadCanonicalInstallRecoveryDevicePropertyString( + set.get(), root.data, DEVPKEY_Device_DriverInfPath, + L"install-journal-raw-root-driver-inf", + &publishedInf, error) || + !ReadCanonicalInstallRecoveryDevicePropertyString( + set.get(), root.data, DEVPKEY_Device_DriverVersion, + L"install-journal-raw-root-driver-version", + &driverVersion, error)) { + return false; + } + facts.serviceEmpty = service.empty(); + facts.publishedInfEmpty = publishedInf.empty(); + facts.driverVersionEmpty = driverVersion.empty(); + facts.exactCandidateService = + _wcsicmp(service.c_str(), kServiceName) == 0; + facts.exactCandidateInf = loaded.state.hasPublishedCandidate && + _wcsicmp(publishedInf.c_str(), + loaded.state.publishedCandidate.publishedName.c_str()) == 0; + Version observedVersion; + facts.exactCandidateVersion = !driverVersion.empty() && + ParseVersion(driverVersion, &observedVersion) && + observedVersion == loaded.state.candidate.version; + if (facts.exactCandidateInf) { + PackageInfo verified; + bool owned = false; + if (!LoadOwnedPackage( + loaded.state.publishedCandidate.infPath, + true, false, &verified, &owned, error)) { + return false; + } + verified.publishedName = publishedInf; + facts.exactCandidateBytes = owned && + SameJournalPackageIdentity( + verified, loaded.state.publishedCandidate) && + SamePackageBytes(verified, loaded.state.candidate); + } + } + + observation->action = ClassifyPartialInstallRootRecovery(facts); + if (observation->action == PartialInstallRootRecoveryAction::Manual) { + return SetError(error, L"install-journal-raw-root-authority", + related.size() > 1U + ? ERROR_DUPLICATE_SERVICE_NAME : ERROR_REVISION_MISMATCH, + L"prior-empty recovery observed a foreign, ambiguous, or unauthorized partial root topology"); + } + if (observation->action == + PartialInstallRootRecoveryAction::RemoveUnboundExactRoot || + observation->action == + PartialInstallRootRecoveryAction::RemoveCandidateBoundExactRoot) { + observation->set = std::move(set); + observation->data = related[0].data; + } + return true; +} + +bool VerifyInstallJournalRawPriorTopology( + const InstallJournalStateData& state, + Error* error) { + if (!state.hasRootRegistrationIntent || + !state.prior.devices.empty()) { + return true; + } + LoadedInstallJournal active; + active.state = state; + active.forwardRootRegistrationEntered = true; + active.forwardDiInstallEntered = true; + InstallRecoveryRootObservation observation; + if (!ObservePriorEmptyInstallRecoveryRoot( + active, &observation, error)) { + return false; + } + return observation.action == + PartialInstallRootRecoveryAction::PriorEmpty || + SetError(error, L"install-journal-prior-raw-root", + ERROR_REVISION_MISMATCH, + L"prior-empty retirement still has a related root present"); +} + +bool VerifyInstallJournalRawForwardTopology( + const InstallJournalStateData& state, + Error* error) { + if (!state.hasRootRegistrationIntent || + !state.prior.devices.empty()) { + return true; + } + LoadedInstallJournal active; + active.state = state; + active.forwardRootRegistrationEntered = true; + active.forwardDiInstallEntered = true; + InstallRecoveryRootObservation observation; + if (!ObservePriorEmptyInstallRecoveryRoot( + active, &observation, error)) { + return false; + } + return observation.action == PartialInstallRootRecoveryAction:: + RemoveCandidateBoundExactRoot || + SetError(error, L"install-journal-forward-raw-root", + ERROR_REVISION_MISMATCH, + L"forward retirement lacks exactly one receipt-bound candidate root and no related extras"); +} + +bool InstallJournal::VerifyPriorTopologyBeforePackageRollback( + Error* error) const { + if (!impl_ || !impl_->preparedRecord || impl_->retired || + impl_->poisoned || + impl_->state.direction != InstallJournalDirection::Rollback || + !impl_->state.rollbackAuthorized) { + return SetError(error, + L"install-journal-pre-package-root-authority", + ERROR_INVALID_STATE); + } + return VerifyInstallJournalRawPriorTopology(impl_->state, error); +} + +bool InstallJournal::RemoveAuthorizedPriorEmptyRootAfterAdmission( + uint64_t rollbackDeadlineUnixMs, + bool* rebootRequired, + bool* rootRemovalRebootPending, + Error* error) { + if (rootRemovalRebootPending == nullptr) { + return SetError(error, L"install-journal-raw-root-cleanup", + ERROR_INVALID_PARAMETER); + } + *rootRemovalRebootPending = false; + if (!impl_ || !impl_->preparedRecord || impl_->retired || + impl_->poisoned) { + return SetError(error, L"install-journal-raw-root-cleanup", + ERROR_INVALID_STATE); + } + if (!impl_->state.prior.devices.empty() || + !impl_->state.hasRootRegistrationIntent) { + return true; + } + const auto observe = [&](bool removalMayHaveRun, + InstallRecoveryRootObservation* observed, + Error* observationError) { + LoadedInstallJournal active; + active.state = impl_->state; + active.forwardRootRegistrationEntered = + impl_->forwardRootRegistrationEntered; + active.forwardDiInstallEntered = + impl_->forwardDiInstallEntered; + active.partialRootRemovalEntered = + removalMayHaveRun && impl_->partialRootRemovalEntered; + return ObservePriorEmptyInstallRecoveryRoot( + active, observed, observationError); + }; + InstallRecoveryRootObservation observation; + if (!observe(false, &observation, error)) { + return false; + } + if (observation.action == + PartialInstallRootRecoveryAction::PriorEmpty) { + return true; + } + if (observation.action != + PartialInstallRootRecoveryAction::RemoveUnboundExactRoot && + observation.action != PartialInstallRootRecoveryAction:: + RemoveCandidateBoundExactRoot) { + return SetError(error, L"install-journal-raw-root-cleanup", + ERROR_REVISION_MISMATCH, + L"post-admission root topology is outside the exact receipt-bound cleanup authority"); + } + if (!CheckTransactionDeadline(rollbackDeadlineUnixMs, + L"install-rollback-deadline-receipt-root", error)) { + return false; + } + InstallJournalStateData entered = impl_->state; + if (!GetBootIdentifier( + &entered.partialRootRemovalBootIdentifier, error)) { + return false; + } + entered.partialRootRemovalBinding = + observation.action == PartialInstallRootRecoveryAction:: + RemoveCandidateBoundExactRoot + ? InstallJournalStateData::PartialRootRemovalBinding::Candidate + : InstallJournalStateData::PartialRootRemovalBinding::Unbound; + const PackageInfo* publishedCandidate = + impl_->state.hasPublishedCandidate + ? &impl_->state.publishedCandidate : nullptr; + if (!RecordNext(std::move(entered), + InstallJournalPhase::PartialRootRemovalEntered, + publishedCandidate, impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, + impl_->state.rebootRequired, true, ERROR_SUCCESS, + false, false, error)) { + return false; + } + if (!VerifyPackageInventory(impl_->state.expectedInventory, + L"install-partial-root-removal-post-admission-inventory", + error)) { + return false; + } + InstallRecoveryRootObservation confirmed; + if (!observe(false, &confirmed, error)) { + return false; + } + if (confirmed.action == PartialInstallRootRecoveryAction::PriorEmpty || + confirmed.action == + PartialInstallRootRecoveryAction::PendingExactRootRemoval) { + if (!Record(InstallJournalPhase::PartialRootRemovalRebootPending, + publishedCandidate, impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, true, false, + ERROR_SUCCESS_REBOOT_REQUIRED, false, error)) { + return false; + } + *rebootRequired = true; + *rootRemovalRebootPending = true; + return true; + } + if (confirmed.action != observation.action) { + return SetError(error, L"install-journal-raw-root-cleanup", + ERROR_REVISION_MISMATCH, + L"receipt-bound root topology changed after durable removal admission"); + } + + bool freshRemovalReboot = false; + Error removalError; + const bool removed = RemoveDevice( + confirmed.set.get(), confirmed.data, 0, + L"install-rollback-deadline-receipt-root", + nullptr, rebootRequired, &removalError, + &freshRemovalReboot); + Error returnRecordError; + if (!RecordAuthoritativeReturn( + InstallJournalPhase::PartialRootRemovalReturned, + publishedCandidate, impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, + *rebootRequired, freshRemovalReboot, + removed, removed ? ERROR_SUCCESS : removalError.code, + false, &returnRecordError)) { + *error = std::move(returnRecordError); + return false; + } + if (!removed) { + *error = std::move(removalError); + return false; + } + InstallRecoveryRootObservation after; + if (!observe(true, &after, error)) { + return false; + } + if (freshRemovalReboot) { + if (after.action != PartialInstallRootRecoveryAction::PriorEmpty && + after.action != PartialInstallRootRecoveryAction:: + PendingExactRootRemoval) { + return SetError(error, + L"install-journal-raw-root-cleanup", + ERROR_REVISION_MISMATCH, + L"successful reboot-requiring root removal left a noncanonical topology"); + } + if (!Record(InstallJournalPhase::PartialRootRemovalRebootPending, + publishedCandidate, impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, true, true, + ERROR_SUCCESS_REBOOT_REQUIRED, false, error)) { + return false; + } + *rootRemovalRebootPending = true; + return true; + } + if (after.action != PartialInstallRootRecoveryAction::PriorEmpty) { + return SetError(error, L"install-journal-raw-root-cleanup", + ERROR_REVISION_MISMATCH, + L"successful root removal without a restart did not restore exact prior-empty topology"); + } + return true; +} + +bool CurrentRootIsAuthorizedForInstallRollback( + const LoadedInstallJournal& loaded, + InstallRecoveryRootObservation* observation, + Error* error) { + *observation = InstallRecoveryRootObservation{}; + const InstallJournalStateData& state = loaded.state; + if (state.prior.devices.empty()) { + return ObservePriorEmptyInstallRecoveryRoot( + loaded, observation, error); + } + Snapshot observed; + if (!CaptureSnapshot(&observed, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-rollback-root", + ERROR_INVALID_DATA); + } + return false; + } + if (!RootSnapshotIsAuthorizedForInstallRollback(state, observed)) { + return SetError(error, L"install-journal-rollback-root", + ERROR_REVISION_MISMATCH, + L"current root is missing, foreign, duplicated, or bound outside the exact prior/candidate transaction authority"); + } + observation->action = PartialInstallRootRecoveryAction::PriorEmpty; + return true; +} + +bool CurrentStateMatchesForward( + const InstallJournalStateData& state, + uint64_t deadlineUnixMs, + Error* error) { + if (!state.hasPublishedCandidate) { + return SetError(error, L"install-journal-forward-state", + ERROR_INVALID_DATA); + } + std::string expectedBuildIdentity; + return DeriveDriverBuildIdentity( + state.sourceRevision, &expectedBuildIdentity, error) && + VerifyInstalled(state.candidate, + state.publishedCandidate.publishedName, false, + deadlineUnixMs, &expectedBuildIdentity, error) && + VerifyPackageInventory(state.expectedInventory, + L"install-journal-forward-inventory", error); +} + +bool RecoveryStateMatchesPrior( + const LoadedInstallJournal& loaded, + uint64_t deadlineUnixMs, + Error* error) { + if (loaded.state.hasRootRegistrationIntent && + loaded.state.prior.devices.empty()) { + InstallRecoveryRootObservation rawRoot; + if (!ObservePriorEmptyInstallRecoveryRoot( + loaded, &rawRoot, error)) { + return false; + } + if (rawRoot.action != + PartialInstallRootRecoveryAction::PriorEmpty) { + return SetError(error, L"install-journal-prior-raw-root", + ERROR_REVISION_MISMATCH, + L"exact prior-empty restoration still has the transaction root present"); + } + } + return CurrentStateMatchesPrior( + loaded.state, deadlineUnixMs, error); +} + +bool RecoveryStateMatchesForward( + const LoadedInstallJournal& loaded, + uint64_t deadlineUnixMs, + Error* error) { + if (loaded.state.hasRootRegistrationIntent && + loaded.state.prior.devices.empty()) { + InstallRecoveryRootObservation rawRoot; + if (!ObservePriorEmptyInstallRecoveryRoot( + loaded, &rawRoot, error)) { + return false; + } + if (rawRoot.action != PartialInstallRootRecoveryAction:: + RemoveCandidateBoundExactRoot) { + return SetError(error, L"install-journal-forward-raw-root", + ERROR_REVISION_MISMATCH, + L"forward validation lacks the exact receipt-bound candidate root topology"); + } + } + return CurrentStateMatchesForward( + loaded.state, deadlineUnixMs, error); +} + +void SetInstallJournalRecoveryOutcome( + LoadedInstallJournal* loaded, + const wchar_t* phase, + DWORD code, + std::wstring message, + ExitCode exitCode, + Outcome* outcome) { + SetError(&outcome->error, phase, code, std::move(message)); + outcome->exitCode = exitCode; + outcome->rebootRequired = exitCode == ExitCode::RebootRequired; + outcome->rollback = exitCode == ExitCode::RollbackFailed + ? L"failed" : L"not-needed"; + outcome->error.recoveryBackup = loaded->directory.active.wstring(); + outcome->error.recoveryBackupRetained = true; + if (gActiveRecoveryRecord[0] != L'\0') { + outcome->error.recoveryRecord = gActiveRecoveryRecord.data(); + outcome->error.recoveryRecordWritten = gActiveRecoveryRecordWritten; + } +} + +bool InstallJournalNeedsRestoreRebootPending( + const InstallJournalStateData& state, + bool sameBoot) noexcept { + return sameBoot && + state.direction == InstallJournalDirection::Rollback && + state.phase == InstallJournalPhase::RollbackBindingReturned && + state.callSucceeded && state.rebootRequired; +} + +bool InstallJournalRollbackRetryRebootSeed( + const InstallJournalStateData& state, + bool sameBoot) noexcept { + return sameBoot && state.rebootRequired; +} + +bool InstallJournalHasAuthoritativeRollbackSettlement( + const InstallJournalStateData& state) noexcept { + return state.bindingMutationStarted && + state.direction == InstallJournalDirection::Rollback && + state.rollbackAuthorized && + state.phase == InstallJournalPhase::RollbackBindingReturned && + state.callSucceeded; +} + +enum class PartialRootRemovalRecoveryDisposition { + ContinueRollback, + RetryRemoval, + RebootPending, + Manual, +}; + +PartialRootRemovalRecoveryDisposition +ClassifyPartialRootRemovalJournalRecovery( + InstallJournalPhase phase, + bool callSucceeded, + bool freshRebootRequired, + bool sameRemovalBoot, + InstallJournalStateData::PartialRootRemovalBinding recordedBinding, + PartialInstallRootRecoveryAction rootAction) noexcept { + const bool absent = rootAction == + PartialInstallRootRecoveryAction::PriorEmpty; + const bool exactOriginal = + rootAction == + PartialInstallRootRecoveryAction::RemoveUnboundExactRoot || + rootAction == PartialInstallRootRecoveryAction:: + RemoveCandidateBoundExactRoot; + const bool exactPending = rootAction == + PartialInstallRootRecoveryAction::PendingExactRootRemoval; + const bool exactOriginalMatchesRecord = + (recordedBinding == + InstallJournalStateData::PartialRootRemovalBinding::Unbound && + rootAction == PartialInstallRootRecoveryAction:: + RemoveUnboundExactRoot) || + (recordedBinding == InstallJournalStateData:: + PartialRootRemovalBinding::Candidate && + rootAction == PartialInstallRootRecoveryAction:: + RemoveCandidateBoundExactRoot); + if (phase == InstallJournalPhase::PartialRootRemovalEntered) { + if (sameRemovalBoot) { + if (exactOriginal && exactOriginalMatchesRecord) { + return PartialRootRemovalRecoveryDisposition::RetryRemoval; + } + if (absent || exactPending) { + return PartialRootRemovalRecoveryDisposition::RebootPending; + } + } else if (absent) { + return PartialRootRemovalRecoveryDisposition::ContinueRollback; + } + return PartialRootRemovalRecoveryDisposition::Manual; + } + if (phase == InstallJournalPhase::PartialRootRemovalReturned) { + if (!callSucceeded) { + return PartialRootRemovalRecoveryDisposition::Manual; + } + if (freshRebootRequired && sameRemovalBoot) { + return absent || exactPending + ? PartialRootRemovalRecoveryDisposition::RebootPending + : PartialRootRemovalRecoveryDisposition::Manual; + } + return absent + ? PartialRootRemovalRecoveryDisposition::ContinueRollback + : PartialRootRemovalRecoveryDisposition::Manual; + } + if (phase == + InstallJournalPhase::PartialRootRemovalRebootPending) { + if (sameRemovalBoot) { + return absent || exactPending + ? PartialRootRemovalRecoveryDisposition::RebootPending + : PartialRootRemovalRecoveryDisposition::Manual; + } + return absent + ? PartialRootRemovalRecoveryDisposition::ContinueRollback + : PartialRootRemovalRecoveryDisposition::Manual; + } + return PartialRootRemovalRecoveryDisposition::ContinueRollback; +} + +bool IsBrokerOuterSettlementContinuationPhase( + InstallJournalPhase phase) noexcept { + return phase == InstallJournalPhase::BrokerChildSettled || + phase == InstallJournalPhase::ForwardValidated || + phase == InstallJournalPhase::BrokerOuterSettlementPending || + phase == InstallJournalPhase::BrokerOuterSettled; +} + +const std::string& BrokerOuterSettlementPendingDriverDigest( + const InstallJournalStateData& state) noexcept { + return state.phase == InstallJournalPhase::BrokerOuterSettled + ? state.brokerDriverPendingDigest : state.lastDigest; +} + +bool ReconcileInstallJournal( + bool explicitRecovery, + uint64_t deadlineUnixMs, + Outcome* outcome) { + // Automatic admission and the explicit recover command deliberately run + // the same reconciler under the same global transaction mutex. The mode + // changes diagnostics only; it cannot weaken recovery authority. + *outcome = Outcome{}; + InstallRecoveryDirectory directory; + bool exists = false; + Error discoveryError; + if (!directory.OpenChain( + false, nullptr, &exists, &discoveryError)) { + outcome->error = std::move(discoveryError); + outcome->exitCode = ExitCode::RollbackFailed; + return false; + } + if (!exists) { + bool handled = false; + Error settledError; + if (!ReconcileSettledBrokerOuterSettlement( + deadlineUnixMs, &handled, outcome, &settledError)) { + outcome->error = std::move(settledError); + outcome->exitCode = ExitCode::RollbackFailed; + return false; + } + if (handled) { + return false; + } + outcome->success = true; + outcome->exitCode = ExitCode::Success; + return true; + } + if (!PublishInstallRecoveryEvidence(directory.active, 0, &discoveryError)) { + outcome->error = std::move(discoveryError); + outcome->exitCode = ExitCode::RollbackFailed; + return false; + } + LoadedInstallJournal loaded; + if (!LoadInstallJournal( + std::move(directory), &loaded, &discoveryError)) { + SetInstallJournalRecoveryOutcome(&loaded, + L"install-journal-manual-reconciliation", + discoveryError.code == ERROR_SUCCESS + ? ERROR_INVALID_DATA : discoveryError.code, + L"the durable transaction chain or protected package evidence is invalid; no driver mutation was attempted: " + + discoveryError.message, + ExitCode::RollbackFailed, outcome); + return false; + } + if (!loaded.hasRecord) { + if (!GenerateInstallTransactionId( + &loaded.state.transactionId, &outcome->error) || + !RetireLoadedInstallJournal(&loaded, &outcome->error)) { + outcome->exitCode = ExitCode::RollbackFailed; + return false; + } + outcome->success = true; + outcome->exitCode = ExitCode::Success; + return true; + } + gActiveRecoveryRecordWritten = true; + PublishInstallRecoveryEvidence( + loaded.directory.active, loaded.state.sequence - 1U, nullptr); + gActiveRecoveryRecordWritten = true; + + const auto appendInstallJournalRecord = + [&](InstallJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool rebootRequired, + bool freshRebootRequired, + InstallJournalStateData::PartialRootRemovalBinding + partialRootRemovalBinding, + Error* error) { + InstallJournalStateData next = loaded.state; + next.phase = phase; + next.callSucceeded = callSucceeded; + next.callError = callError; + next.rebootRequired = next.rebootRequired || rebootRequired; + next.freshRebootRequired = freshRebootRequired; + if (phase == InstallJournalPhase::PartialRootRemovalEntered) { + if (partialRootRemovalBinding == + InstallJournalStateData:: + PartialRootRemovalBinding::None || + !GetBootIdentifier( + &next.partialRootRemovalBootIdentifier, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, + L"install-journal-partial-root-removal", + ERROR_INVALID_PARAMETER); + } + return false; + } + next.partialRootRemovalBinding = + partialRootRemovalBinding; + } else if (partialRootRemovalBinding != + InstallJournalStateData::PartialRootRemovalBinding::None) { + return SetError(error, + L"install-journal-partial-root-removal", + ERROR_INVALID_PARAMETER); + } + if (freshRebootRequired && + !GetBootIdentifier( + &next.pendingRebootBootIdentifier, error)) { + return false; + } + if (phase == InstallJournalPhase::RollbackBindingEntered) { + next.direction = InstallJournalDirection::Rollback; + next.rollbackAuthorized = true; + } + if (phase == InstallJournalPhase::RootRegistrationEntered || + phase == InstallJournalPhase::RootRegistrationReturned || + phase == InstallJournalPhase::DiInstallEntered || + phase == InstallJournalPhase::DiInstallReturned) { + next.bindingMutationStarted = true; + } + if (!ValidateInstallJournalTransition( + &loaded.state, next, error) || + !WriteInstallJournalRecord( + loaded.directory.active, &next, error)) { + return false; + } + loaded.state = std::move(next); + if (phase == InstallJournalPhase::PartialRootRemovalEntered) { + loaded.partialRootRemovalEntered = true; + } + if (!PublishInstallRecoveryEvidence( + loaded.directory.active, loaded.state.sequence - 1U, error)) { + return false; + } + gActiveRecoveryRecordWritten = true; + return true; + }; + const auto appendPhase = + [&](InstallJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool rebootRequired, + Error* error) { + return appendInstallJournalRecord(phase, callSucceeded, + callError, rebootRequired, false, + InstallJournalStateData::PartialRootRemovalBinding::None, + error); + }; + const auto appendBrokerProof = + [&](const BrokerCommitProof& proof, Error* error) { + if (!BrokerProofFieldsAreCanonical( + proof.success, proof.changed, proof.rollback, + proof.exitCode, proof.driverRollbackAuthorized) || + proof.changed != proof.hasJournalProof || + (proof.changed && + (proof.journalOuterTransactionId != + loaded.state.transactionId || + proof.journalCandidateSha256 != + loaded.state.brokerExecutableSha256))) { + return SetError(error, + L"install-journal-broker-replay-proof", + ERROR_REVISION_MISMATCH, + L"replayed child proof is not bound to the retained outer token and protected image"); + } + InstallJournalStateData next = loaded.state; + next.phase = InstallJournalPhase::BrokerChildSettled; + next.brokerEntered = true; + next.brokerSettled = true; + next.hasBrokerProof = true; + next.brokerProofSuccess = proof.success; + next.brokerProofChanged = proof.changed; + next.brokerProofRollback = proof.rollback; + next.brokerProofExitCode = proof.exitCode; + next.brokerDriverRollbackAuthorized = + proof.driverRollbackAuthorized; + next.brokerJournalTransactionId = + proof.journalTransactionId; + next.brokerJournalOuterTransactionId = + proof.journalOuterTransactionId; + next.brokerJournalCandidateSha256 = + proof.journalCandidateSha256; + next.brokerJournalState = proof.journalState; + next.brokerJournalDigest = proof.journalDigest; + next.callSucceeded = proof.success; + next.callError = proof.exitCode; + if (proof.driverRollbackAuthorized) { + next.direction = InstallJournalDirection::Rollback; + next.rollbackAuthorized = true; + } + if (!ValidateInstallJournalTransition( + &loaded.state, next, error) || + !WriteInstallJournalRecord( + loaded.directory.active, &next, error)) { + return false; + } + loaded.state = std::move(next); + if (!PublishInstallRecoveryEvidence( + loaded.directory.active, + loaded.state.sequence - 1U, error)) { + return false; + } + gActiveRecoveryRecordWritten = true; + return true; + }; + const auto appendOuterSettlementPending = + [&](Error* error) { + InstallJournalStateData next = loaded.state; + if (!GenerateInstallTransactionId( + &next.brokerSettlementNonce, error)) { + return false; + } + next.phase = + InstallJournalPhase::BrokerOuterSettlementPending; + next.callSucceeded = true; + next.callError = ERROR_SUCCESS; + if (!ValidateInstallJournalTransition( + &loaded.state, next, error) || + !WriteInstallJournalRecord( + loaded.directory.active, &next, error)) { + return false; + } + loaded.state = std::move(next); + if (!PublishInstallRecoveryEvidence( + loaded.directory.active, + loaded.state.sequence - 1U, error)) { + return false; + } + gActiveRecoveryRecordWritten = true; + return true; + }; + const auto appendPartialRootRemovalEntered = + [&](InstallJournalStateData::PartialRootRemovalBinding binding, + Error* error) { + return appendInstallJournalRecord( + InstallJournalPhase::PartialRootRemovalEntered, + true, ERROR_SUCCESS, loaded.state.rebootRequired, + false, binding, error); + }; + const auto appendAuthoritativeReturn = + [&](InstallJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool rebootRequired, + bool freshRebootRequired, + Error* error) { + if (phase != InstallJournalPhase::DiInstallReturned && + phase != InstallJournalPhase::RollbackBindingReturned && + phase != InstallJournalPhase:: + PartialRootRemovalReturned) { + return SetError(error, + L"install-journal-reboot-return", + ERROR_INVALID_PARAMETER); + } + return appendInstallJournalRecord(phase, callSucceeded, + callError, rebootRequired, + freshRebootRequired, + InstallJournalStateData::PartialRootRemovalBinding::None, + error); + }; + const auto finishSuccess = [&](bool changed) { + outcome->success = true; + outcome->changed = changed; + outcome->exitCode = ExitCode::Success; + outcome->rollback = changed ? L"succeeded" : L"not-needed"; + return true; + }; + const auto manual = [&](std::wstring message, const Error* cause = nullptr) { + message.insert(0, explicitRecovery + ? L"explicit recover: " : L"automatic admission recovery: "); + if (cause != nullptr && !cause->message.empty()) { + message.append(L"; observed: "); + message.append(cause->message); + } + SetInstallJournalRecoveryOutcome(&loaded, + L"install-journal-manual-reconciliation", + cause != nullptr && cause->code != ERROR_SUCCESS + ? cause->code : ERROR_INSTALL_SUSPEND, + std::move(message), ExitCode::RollbackFailed, outcome); + return false; + }; + const auto rebootPending = [&](const wchar_t* message) { + SetInstallJournalRecoveryOutcome(&loaded, + L"install-journal-reboot-pending", + ERROR_SUCCESS_REBOOT_REQUIRED, message, + ExitCode::RebootRequired, outcome); + return false; + }; + const auto returnPendingBinding = [&]() { + BrokerJournalBinding binding; + binding.present = true; + binding.transactionId = + loaded.state.brokerJournalTransactionId; + binding.outerTransactionId = + loaded.state.brokerJournalOuterTransactionId; + binding.candidateSha256 = + loaded.state.brokerJournalCandidateSha256; + binding.state = loaded.state.brokerJournalState; + binding.digest = loaded.state.brokerJournalDigest; + binding.driverTransactionId = loaded.state.transactionId; + binding.driverDigest = + BrokerOuterSettlementPendingDriverDigest(loaded.state); + binding.settlementNonce = + loaded.state.brokerSettlementNonce; + binding.recovery = "replayed"; + outcome->success = true; + outcome->changed = true; + outcome->exitCode = ExitCode::Success; + outcome->rollback = L"not-needed"; + outcome->brokerBinding = std::move(binding); + // Stop this invocation before it can admit a new package identity. + return false; + }; + + std::string currentBoot; + Error bootError; + if (!GetBootIdentifier(¤tBoot, &bootError)) { + return manual(L"the current boot session cannot be compared with the durable transaction", &bootError); + } + const bool sameBoot = + !loaded.state.pendingRebootBootIdentifier.empty() && + currentBoot == loaded.state.pendingRebootBootIdentifier; + const bool samePartialRootRemovalBoot = + !loaded.state.partialRootRemovalBootIdentifier.empty() && + currentBoot == loaded.state.partialRootRemovalBootIdentifier; + if (loaded.state.phase == InstallJournalPhase::ManualReconciliationRequired) { + return manual(L"a prior authoritative owner retained the transaction for manual reconciliation"); + } + if (loaded.state.phase == InstallJournalPhase::BrokerChildEntered && + loaded.state.direction == InstallJournalDirection::Forward && + !loaded.state.rollbackAuthorized && + !loaded.state.hasBrokerProof) { + InstallOptions recoveryOptions; + recoveryOptions.brokerExecutable = + loaded.directory.active / + kInstallRecoveryBrokerDirectory / + kInstallRecoveryBrokerExecutable; + recoveryOptions.brokerSha256 = + loaded.state.brokerExecutableSha256; + recoveryOptions.brokerToken = loaded.state.brokerTokenPath; + recoveryOptions.brokerTokenSha256 = loaded.state.transactionId; + recoveryOptions.targetUserSid = + loaded.state.brokerTargetUserSid; + recoveryOptions.transactionDeadlineUnixMs = deadlineUnixMs; + bool rollbackAuthorized = false; + bool brokerChanged = false; + BrokerCommitProof replayedProof; + Error replayError; + const bool replaySucceeded = RunBrokerInstall( + recoveryOptions, &rollbackAuthorized, &brokerChanged, + &replayedProof, true, &replayError); + if (!replayedProof.hasJournalProof || !brokerChanged || + rollbackAuthorized != + replayedProof.driverRollbackAuthorized) { + return manual( + L"the retained child recovery invocation did not return one authoritative journal-bound outcome", + &replayError); + } + Error appendError; + if (!appendBrokerProof(replayedProof, &appendError)) { + return manual( + L"the recovered child proof could not be durably bound to the driver journal", + &appendError); + } + if (!replaySucceeded && + !replayedProof.driverRollbackAuthorized) { + return manual( + L"the recovered child state is indeterminate and does not authorize driver rollback", + &replayError); + } + } + const bool exactChangedBrokerCommit = + loaded.state.brokerRequired && loaded.state.brokerEntered && + loaded.state.brokerSettled && loaded.state.hasBrokerProof && + loaded.state.brokerProofSuccess && + loaded.state.brokerProofChanged && + !loaded.state.brokerDriverRollbackAuthorized && + loaded.state.brokerJournalState == "nested-ready" && + loaded.state.direction == InstallJournalDirection::Forward && + !loaded.state.rollbackAuthorized; + const bool brokerSettlementContinuation = + IsBrokerOuterSettlementContinuationPhase(loaded.state.phase); + if (exactChangedBrokerCommit && brokerSettlementContinuation) { + Error validationError; + if (!RecoveryStateMatchesForward( + loaded, deadlineUnixMs, &validationError)) { + return manual( + L"the journal-bound child committed but the exact forward driver state did not revalidate", + &validationError); + } + if (loaded.state.phase == + InstallJournalPhase::BrokerChildSettled) { + Error appendError; + if (!appendPhase(InstallJournalPhase::ForwardValidated, + true, ERROR_SUCCESS, false, &appendError) || + !RecoveryStateMatchesForward( + loaded, deadlineUnixMs, &appendError)) { + return manual( + L"the replayed forward state could not be durably validated", + &appendError); + } + } + if (loaded.state.phase == InstallJournalPhase::ForwardValidated) { + Error appendError; + if (!appendOuterSettlementPending(&appendError)) { + return manual( + L"the replayed forward state could not enter durable outer settlement", + &appendError); + } + } + if (loaded.state.phase == + InstallJournalPhase::BrokerOuterSettlementPending || + loaded.state.phase == + InstallJournalPhase::BrokerOuterSettled) { + return returnPendingBinding(); + } + } + const bool partialRootRemovalPhase = + loaded.state.phase == + InstallJournalPhase::PartialRootRemovalEntered || + loaded.state.phase == + InstallJournalPhase::PartialRootRemovalReturned || + loaded.state.phase == InstallJournalPhase:: + PartialRootRemovalRebootPending; + if (partialRootRemovalPhase) { + InstallRecoveryRootObservation observedRoot; + Error observationError; + if (!ObservePriorEmptyInstallRecoveryRoot( + loaded, &observedRoot, &observationError)) { + return manual( + L"partial root removal topology is not within the exact durable receipt authority", + &observationError); + } + const PartialRootRemovalRecoveryDisposition disposition = + ClassifyPartialRootRemovalJournalRecovery( + loaded.state.phase, loaded.state.callSucceeded, + loaded.state.freshRebootRequired, + samePartialRootRemovalBoot, + loaded.state.partialRootRemovalBinding, + observedRoot.action); + if (disposition == + PartialRootRemovalRecoveryDisposition::Manual) { + return manual( + L"partial root removal outcome and current topology do not prove a safe automatic continuation"); + } + if (disposition == + PartialRootRemovalRecoveryDisposition::RebootPending) { + if (loaded.state.phase != InstallJournalPhase:: + PartialRootRemovalRebootPending) { + Error pendingError; + if (!appendPhase(InstallJournalPhase:: + PartialRootRemovalRebootPending, + loaded.state.phase == InstallJournalPhase:: + PartialRootRemovalReturned && + loaded.state.callSucceeded, + ERROR_SUCCESS_REBOOT_REQUIRED, true, + &pendingError)) { + return manual( + L"partial root removal requires a restart but its durable pending phase could not be published", + &pendingError); + } + } + return rebootPending( + L"receipt-bound root removal must cross the recorded restart before package rollback can continue"); + } + } + if (loaded.state.phase == InstallJournalPhase::ForwardRebootPending && sameBoot) { + return rebootPending( + L"forward driver activation is still pending the recorded restart"); + } + if (loaded.state.phase == InstallJournalPhase::RestoreRebootPending && sameBoot) { + return rebootPending( + L"exact prior-state restoration is still pending the recorded restart"); + } + if (InstallJournalNeedsRestoreRebootPending( + loaded.state, sameBoot)) { + Error pendingError; + if (!appendPhase(InstallJournalPhase::RestoreRebootPending, + true, ERROR_SUCCESS_REBOOT_REQUIRED, true, + &pendingError)) { + return manual( + L"rollback returned with a required restart but its pending phase could not be published", + &pendingError); + } + return rebootPending( + L"exact prior-state rollback returned successfully and still requires the recorded restart"); + } + + const bool forwardTerminal = + loaded.state.phase == InstallJournalPhase::ForwardValidated || + loaded.state.phase == InstallJournalPhase::ForwardRebootPending; + if (forwardTerminal) { + const bool exactBrokerCommit = + loaded.state.brokerRequired && loaded.state.brokerEntered && + loaded.state.brokerSettled && loaded.state.hasBrokerProof && + loaded.state.brokerProofSuccess && + !loaded.state.brokerDriverRollbackAuthorized && + loaded.state.direction == InstallJournalDirection::Forward && + !loaded.state.rollbackAuthorized; + if ((loaded.state.brokerRequired && !exactBrokerCommit) || + (!loaded.state.brokerRequired && loaded.state.brokerEntered)) { + return manual( + L"terminal forward state lacks its exact canonical broker commit authority"); + } + Error validationError; + if (!RecoveryStateMatchesForward( + loaded, deadlineUnixMs, &validationError)) { + return manual( + L"a terminal forward record did not revalidate; exact evidence was retained", + &validationError); + } + if (!RetireLoadedInstallJournal(&loaded, &outcome->error)) { + outcome->exitCode = ExitCode::RollbackFailed; + return false; + } + return finishSuccess(false); + } + const bool restoreTerminal = + loaded.state.phase == InstallJournalPhase::ExactPriorRestored || + loaded.state.phase == InstallJournalPhase::RestoreRebootPending; + if (restoreTerminal) { + if (loaded.state.brokerEntered && + (loaded.state.direction != InstallJournalDirection::Rollback || + !loaded.state.rollbackAuthorized)) { + return manual( + L"terminal prior state lacks durable broker-safe rollback authority"); + } + Error validationError; + if (!RecoveryStateMatchesPrior( + loaded, deadlineUnixMs, &validationError)) { + return manual( + L"a terminal prior-state record did not revalidate; exact evidence was retained", + &validationError); + } + if (!RetireLoadedInstallJournal(&loaded, &outcome->error)) { + outcome->exitCode = ExitCode::RollbackFailed; + return false; + } + return finishSuccess(false); + } + + const bool durableBrokerForwardSuccess = + loaded.state.brokerRequired && loaded.state.brokerEntered && + loaded.state.brokerSettled && loaded.state.hasBrokerProof && + loaded.state.brokerProofSuccess && + !loaded.state.brokerDriverRollbackAuthorized && + loaded.state.direction == InstallJournalDirection::Forward; + const bool durableNonBrokerForwardSuccess = + !loaded.state.brokerRequired && !loaded.state.brokerEntered && + loaded.state.phase == InstallJournalPhase::DriverValidated && + loaded.state.direction == InstallJournalDirection::Forward; + if (loaded.state.hasPublishedCandidate && + (durableBrokerForwardSuccess || durableNonBrokerForwardSuccess)) { + Error forwardValidation; + if (RecoveryStateMatchesForward( + loaded, deadlineUnixMs, &forwardValidation)) { + Error appendError; + if (!appendPhase(InstallJournalPhase::ForwardValidated, + true, ERROR_SUCCESS, false, &appendError) || + !RecoveryStateMatchesForward( + loaded, deadlineUnixMs, &appendError) || + !RetireLoadedInstallJournal(&loaded, &appendError)) { + return manual( + L"the validated forward state could not be terminally recorded and retired", + &appendError); + } + return finishSuccess(false); + } + if (durableBrokerForwardSuccess) { + return manual( + L"the child durably committed but the forward driver state did not revalidate; no driver rollback is authorized", + &forwardValidation); + } + } + + if (loaded.state.brokerEntered && + loaded.state.phase == InstallJournalPhase::BrokerHandoffReturned && + loaded.state.callSucceeded && !loaded.state.brokerSettled && + !loaded.state.hasBrokerProof && + loaded.state.direction == InstallJournalDirection::Forward) { + Error authorizationError; + if (!appendPhase(InstallJournalPhase::RollbackBindingEntered, + true, ERROR_SUCCESS, false, &authorizationError)) { + return manual( + L"pre-child rollback authority could not be durably admitted", + &authorizationError); + } + } + const bool priorRequiresAbiProfile = + loaded.state.bindingMutationStarted && + loaded.state.prior.devices.size() == 1U && + loaded.state.prior.devices[0].started && + loaded.state.prior.devices[0].problem == 0; + if (priorRequiresAbiProfile && !loaded.state.hasPriorAbiProfile) { + Snapshot observedBeforeProfile; + Error profileError; + AbiCompatibilityProfile negotiatedProfile{}; + if (!CaptureSnapshot(&observedBeforeProfile, &profileError) || + !SameCapturedRootState( + loaded.state.prior, observedBeforeProfile) || + !VerifyAbiHealth(deadlineUnixMs, nullptr, &profileError, + AbiHealthPurpose::PristineUpgrade, nullptr, + &negotiatedProfile)) { + if (profileError.code == ERROR_SUCCESS) { + SetError(&profileError, + L"install-journal-prior-abi-profile", + ERROR_REVISION_MISMATCH, + L"missing prior ABI profile cannot be negotiated after the captured binding changed"); + } + return manual( + L"started prior root lacks a durable exact ABI profile; no recovery mutation was attempted", + &profileError); + } + loaded.state.priorAbiProfile = negotiatedProfile; + loaded.state.hasPriorAbiProfile = true; + if (!appendPhase(InstallJournalPhase::PriorAbiProfileCaptured, + true, ERROR_SUCCESS, false, &profileError)) { + return manual( + L"the exact prior ABI profile was negotiated but could not be published before recovery mutation", + &profileError); + } + } + + const bool rollbackWasAuthorized = + loaded.state.direction == InstallJournalDirection::Rollback && + loaded.state.rollbackAuthorized; + Error priorValidation; + const bool priorValid = + RecoveryStateMatchesPrior( + loaded, deadlineUnixMs, &priorValidation); + if (priorValid && + (!loaded.state.bindingMutationStarted || + InstallJournalHasAuthoritativeRollbackSettlement( + loaded.state))) { + if (loaded.state.brokerEntered && + !rollbackWasAuthorized) { + return manual( + L"the driver resembles the prior state, but broker handoff lacks a durable settled rollback authorization; evidence was retained"); + } + Error appendError; + if (!appendPhase(InstallJournalPhase::ExactPriorRestored, + true, ERROR_SUCCESS, false, &appendError) || + !RecoveryStateMatchesPrior( + loaded, deadlineUnixMs, &appendError) || + !RetireLoadedInstallJournal(&loaded, &appendError)) { + return manual( + L"the prior state was present but could not be terminally recorded and retired", + &appendError); + } + return finishSuccess(false); + } + + std::vector currentPackages; + Error inventoryError; + if (!EnumerateOwnedPackages(¤tPackages, &inventoryError)) { + return manual(L"the current Driver Store inventory could not be classified", &inventoryError); + } + if (!loaded.state.hasPublishedCandidate) { + size_t matches = 0; + for (const PackageInfo& package : currentPackages) { + if (package.version == loaded.state.candidate.version && + SamePackageBytes(package, loaded.state.candidate) && + !ContainsExactPackage(loaded.state.prior.packages, package)) { + loaded.state.publishedCandidate = package; + loaded.state.hasPublishedCandidate = true; + ++matches; + } + } + if (matches > 1U) { + return manual(L"more than one exact candidate publication exists"); + } + if (matches == 1U) { + return manual( + L"an exact candidate publication exists without a durable StageReceiptCaptured ownership record; it may be concurrent and will not be removed automatically"); + } + } + + Error forwardValidation; + const bool forwardValid = loaded.state.hasPublishedCandidate && + RecoveryStateMatchesForward( + loaded, deadlineUnixMs, &forwardValidation); + const bool settledForwardBroker = + loaded.state.brokerRequired && loaded.state.brokerEntered && + loaded.state.brokerSettled && loaded.state.hasBrokerProof && + loaded.state.brokerProofSuccess && + !loaded.state.brokerDriverRollbackAuthorized && + loaded.state.direction == InstallJournalDirection::Forward; + if (forwardValid && + ((!loaded.state.brokerRequired && + !loaded.state.brokerEntered && + loaded.state.phase == InstallJournalPhase::DriverValidated && + loaded.state.direction == InstallJournalDirection::Forward) || + settledForwardBroker)) { + Error appendError; + if (!appendPhase(InstallJournalPhase::ForwardValidated, + true, ERROR_SUCCESS, false, &appendError) || + !RecoveryStateMatchesForward( + loaded, deadlineUnixMs, &appendError) || + !RetireLoadedInstallJournal(&loaded, &appendError)) { + return manual( + L"the validated forward state could not be terminally recorded and retired", + &appendError); + } + return finishSuccess(false); + } + + if (loaded.state.brokerEntered && + !rollbackWasAuthorized) { + return manual( + L"broker handoff was entered without a durable, settled rollback authorization; driver evidence was retained and no mutation was attempted"); + } + for (const PackageInfo& priorPackage : loaded.state.prior.packages) { + const size_t exactMatches = static_cast(std::count_if( + currentPackages.begin(), currentPackages.end(), + [&](const PackageInfo& current) { + return _wcsicmp(current.publishedName.c_str(), + priorPackage.publishedName.c_str()) == 0 && + current.version == priorPackage.version && + SamePackageBytes(current, priorPackage); + })); + if (exactMatches != 1U) { + return manual( + L"the exact prior published package name and bytes are not available in the Driver Store; protected package bytes were retained, but automatic republishing cannot promise the same OEM identity"); + } + } + + bool stagedCandidateStillPresent = false; + if (loaded.state.packageStagedHere && + loaded.state.hasPublishedCandidate) { + size_t publishedNameMatches = 0; + for (const PackageInfo& current : currentPackages) { + if (_wcsicmp(current.publishedName.c_str(), + loaded.state.publishedCandidate.publishedName.c_str()) != 0) { + continue; + } + ++publishedNameMatches; + if (!SameJournalPackageIdentity( + current, loaded.state.publishedCandidate)) { + return manual( + L"the staged-here published name now identifies different bytes; no automatic removal was attempted"); + } + stagedCandidateStillPresent = true; + } + if (publishedNameMatches > 1U) { + return manual( + L"the staged-here published identity is duplicated; no automatic removal was attempted"); + } + } + std::vector exactPreRollbackInventory = + loaded.state.prior.packages; + if (stagedCandidateStillPresent) { + exactPreRollbackInventory.push_back( + loaded.state.publishedCandidate); + } + std::sort(exactPreRollbackInventory.begin(), + exactPreRollbackInventory.end(), + [](const PackageInfo& left, const PackageInfo& right) { + return _wcsicmp(left.publishedName.c_str(), + right.publishedName.c_str()) < 0; + }); + if (!SamePackageInventory( + currentPackages, exactPreRollbackInventory)) { + return manual( + L"current Driver Store inventory is not exactly the captured prior set plus the one transaction-owned staged candidate; no rollback mutation was attempted"); + } + Error rootAuthorityError; + InstallRecoveryRootObservation rootAuthority; + if (!CurrentRootIsAuthorizedForInstallRollback( + loaded, &rootAuthority, &rootAuthorityError)) { + return manual( + L"current root topology is outside the exact rollback authority of this transaction; no device mutation was attempted", + &rootAuthorityError); + } + + Error appendError; + if (!appendPhase(InstallJournalPhase::RollbackBindingEntered, + true, ERROR_SUCCESS, false, &appendError)) { + return manual( + L"write-ahead rollback admission could not be published; no recovery mutation was attempted", + &appendError); + } + Error confirmedInventoryError; + if (!VerifyPackageInventory(exactPreRollbackInventory, + L"install-journal-post-admission-inventory", + &confirmedInventoryError)) { + return manual( + L"Driver Store inventory changed after write-ahead rollback admission; no device mutation was attempted", + &confirmedInventoryError); + } + InstallRecoveryRootObservation confirmedRoot; + Error confirmedRootError; + if (!CurrentRootIsAuthorizedForInstallRollback( + loaded, &confirmedRoot, &confirmedRootError)) { + return manual( + L"current root topology changed after write-ahead rollback admission; no device mutation was attempted", + &confirmedRootError); + } + const PackageInfo* stagedCandidate = + loaded.state.packageStagedHere && + loaded.state.hasPublishedCandidate && stagedCandidateStillPresent + ? &loaded.state.publishedCandidate : nullptr; + bool rollbackReboot = InstallJournalRollbackRetryRebootSeed( + loaded.state, sameBoot); + const bool rollbackRebootAtAdmission = rollbackReboot; + Error rollbackError; + const uint64_t rollbackDeadline = + CurrentUnixMilliseconds() + kDriverRollbackCeilingMs; + bool rollbackSucceeded = true; + if (confirmedRoot.action == + PartialInstallRootRecoveryAction::RemoveUnboundExactRoot || + confirmedRoot.action == + PartialInstallRootRecoveryAction::RemoveCandidateBoundExactRoot) { + if (!CheckTransactionDeadline(rollbackDeadline, + L"install-journal-rollback-deadline-partial-root", + &rollbackError)) { + return manual( + L"receipt-bound root removal missed its deadline before durable API admission", + &rollbackError); + } + Error removalEnteredError; + const InstallJournalStateData::PartialRootRemovalBinding + removalBinding = confirmedRoot.action == + PartialInstallRootRecoveryAction:: + RemoveCandidateBoundExactRoot + ? InstallJournalStateData::PartialRootRemovalBinding::Candidate + : InstallJournalStateData::PartialRootRemovalBinding::Unbound; + if (!appendPartialRootRemovalEntered( + removalBinding, &removalEnteredError)) { + return manual( + L"receipt-bound root removal could not publish its exact write-ahead API admission", + &removalEnteredError); + } + Error removalInventoryError; + if (!VerifyPackageInventory(exactPreRollbackInventory, + L"install-journal-partial-root-removal-inventory", + &removalInventoryError)) { + return manual( + L"Driver Store inventory changed after receipt-bound root removal admission; no device API was called", + &removalInventoryError); + } + InstallRecoveryRootObservation removalRoot; + Error removalRootError; + LoadedInstallJournal preCallLoaded; + preCallLoaded.state = loaded.state; + preCallLoaded.forwardRootRegistrationEntered = + loaded.forwardRootRegistrationEntered; + preCallLoaded.forwardDiInstallEntered = + loaded.forwardDiInstallEntered; + preCallLoaded.partialRootRemovalEntered = false; + if (!CurrentRootIsAuthorizedForInstallRollback( + preCallLoaded, &removalRoot, &removalRootError)) { + return manual( + L"root topology changed after receipt-bound root removal admission; no device API was called", + &removalRootError); + } + if (removalRoot.action == + PartialInstallRootRecoveryAction::PriorEmpty || + removalRoot.action == PartialInstallRootRecoveryAction:: + PendingExactRootRemoval) { + Error pendingError; + if (!appendPhase(InstallJournalPhase:: + PartialRootRemovalRebootPending, + false, ERROR_SUCCESS_REBOOT_REQUIRED, true, + &pendingError)) { + return manual( + L"indeterminate receipt-bound root removal could not publish its conservative reboot boundary", + &pendingError); + } + return rebootPending( + L"receipt-bound root removal changed after admission and must cross the recorded restart before package rollback"); + } + if (removalRoot.action != confirmedRoot.action) { + return manual( + L"receipt-bound root identity changed after durable removal admission; no device API was called"); + } + bool freshRemovalReboot = false; + rollbackSucceeded = RemoveDevice( + removalRoot.set.get(), removalRoot.data, 0, + L"install-journal-rollback-deadline-partial-root", + nullptr, &rollbackReboot, &rollbackError, + &freshRemovalReboot); + Error removalReturnedError; + if (!appendAuthoritativeReturn( + InstallJournalPhase::PartialRootRemovalReturned, + rollbackSucceeded, + rollbackSucceeded ? ERROR_SUCCESS : rollbackError.code, + rollbackReboot, freshRemovalReboot, + &removalReturnedError)) { + return manual( + L"receipt-bound root removal returned but its exact authoritative outcome could not be published", + &removalReturnedError); + } + if (!rollbackSucceeded) { + return manual( + L"receipt-bound root removal returned failure; exact evidence was retained", + &rollbackError); + } + InstallRecoveryRootObservation afterRemoval; + Error afterRemovalError; + if (!ObservePriorEmptyInstallRecoveryRoot( + loaded, &afterRemoval, &afterRemovalError)) { + return manual( + L"receipt-bound root removal returned but its resulting topology is not canonical", + &afterRemovalError); + } + if (freshRemovalReboot) { + if (afterRemoval.action != + PartialInstallRootRecoveryAction::PriorEmpty && + afterRemoval.action != PartialInstallRootRecoveryAction:: + PendingExactRootRemoval) { + return manual( + L"reboot-requiring receipt-bound root removal left an unauthorized topology"); + } + Error pendingError; + if (!appendPhase(InstallJournalPhase:: + PartialRootRemovalRebootPending, + true, ERROR_SUCCESS_REBOOT_REQUIRED, true, + &pendingError)) { + return manual( + L"receipt-bound root removal requires a restart but its pending phase could not be published", + &pendingError); + } + return rebootPending( + L"receipt-bound root removal returned successfully and requires the recorded restart before package rollback"); + } + if (afterRemoval.action != + PartialInstallRootRecoveryAction::PriorEmpty) { + return manual( + L"receipt-bound root removal returned without a restart but exact prior-empty topology was not restored"); + } + } + if (rollbackSucceeded) { + rollbackSucceeded = VerifyPackageInventory( + exactPreRollbackInventory, + L"install-journal-pre-package-rollback-inventory", + &rollbackError) && + VerifyInstallJournalRawPriorTopology( + loaded.state, &rollbackError); + } + if (rollbackSucceeded) { + const bool restoreBindingThroughStrictSnapshot = + InstallJournalRecoveryUsesStrictBindingRestore( + loaded.state); + rollbackSucceeded = RollbackInstall( + loaded.state.prior, stagedCandidate, + restoreBindingThroughStrictSnapshot, + loaded.state.hasPriorAbiProfile + ? &loaded.state.priorAbiProfile : nullptr, + rollbackDeadline, &rollbackReboot, &rollbackError); + } + if (!rollbackSucceeded) { + Error ignored; + appendAuthoritativeReturn( + InstallJournalPhase::RollbackBindingReturned, + false, rollbackError.code, rollbackReboot, + rollbackReboot && !rollbackRebootAtAdmission, + &ignored); + appendPhase(InstallJournalPhase::ManualReconciliationRequired, + false, rollbackError.code, rollbackReboot, &ignored); + return manual(L"authoritative exact-prior recovery failed", &rollbackError); + } + Error returnedError; + if (!appendAuthoritativeReturn( + InstallJournalPhase::RollbackBindingReturned, + true, ERROR_SUCCESS, rollbackReboot, + rollbackReboot && !rollbackRebootAtAdmission, + &returnedError)) { + return manual( + L"rollback returned authoritatively, but its returned phase could not be published", + &returnedError); + } + if (rollbackReboot) { + Error pendingError; + if (!appendPhase(InstallJournalPhase::RestoreRebootPending, + true, ERROR_SUCCESS_REBOOT_REQUIRED, true, + &pendingError)) { + return manual( + L"rollback requires reboot but its pending state could not be published", + &pendingError); + } + return rebootPending( + L"exact prior-state rollback completed with a required restart; evidence remains retained"); + } + Error restoredError; + if (!RecoveryStateMatchesPrior( + loaded, rollbackDeadline, &restoredError) || + !appendPhase(InstallJournalPhase::ExactPriorRestored, + true, ERROR_SUCCESS, false, &restoredError) || + !RecoveryStateMatchesPrior( + loaded, rollbackDeadline, &restoredError) || + !RetireLoadedInstallJournal(&loaded, &restoredError)) { + return manual( + L"rollback returned but exact prior-state revalidation or journal retirement failed", + &restoredError); + } + return finishSuccess(true); +} + +struct BrokerSettlementBindingData { + std::string brokerTransactionId; + std::string brokerOuterTransactionId; + std::string brokerCandidateSha256; + std::string brokerNestedDigest; + std::string driverTransactionId; + std::string driverPendingDigest; + std::string settlementNonce; +}; + +struct BrokerSettlementRequestData { + std::string payloadSha256; + std::string bindingSha256; + std::string brokerPendingDigest; + BrokerSettlementBindingData binding; + std::string requestSha256; +}; + +struct BrokerSettlementFinalData { + std::string payloadSha256; + std::string brokerTransactionId; + std::string brokerPendingDigest; + std::string brokerSettledDigest; + std::string driverTransactionId; + std::string driverPendingDigest; + std::string driverSettledDigest; + std::string settlementNonce; + std::string requestSha256; + std::string state; + std::string receiptSha256; +}; + +struct BrokerSettlementAckOptions { + std::filesystem::path requestPath; + std::string requestSha256; + uint64_t transactionDeadlineUnixMs = 0; +}; + +struct BrokerSettlementDiscardOptions { + std::string brokerTransactionId; + std::string brokerDigest; + std::string driverTransactionId; + std::string driverDigest; + std::string settlementNonce; + std::string requestSha256; + std::filesystem::path brokerFinalReceiptPath; + std::string brokerFinalReceiptSha256; + uint64_t transactionDeadlineUnixMs = 0; +}; + +void AppendBrokerSettlementBindingJson( + std::string* output, + const BrokerSettlementBindingData& binding) { + output->append("{\"schema\":1,\"brokerTransactionId\":"); + AppendJsonAsciiString(output, binding.brokerTransactionId); + output->append(",\"brokerOuterTransactionId\":"); + AppendJsonAsciiString(output, binding.brokerOuterTransactionId); + output->append(",\"brokerCandidateSha256\":"); + AppendJsonAsciiString(output, binding.brokerCandidateSha256); + output->append(",\"brokerNestedDigest\":"); + AppendJsonAsciiString(output, binding.brokerNestedDigest); + output->append(",\"driverTransactionId\":"); + AppendJsonAsciiString(output, binding.driverTransactionId); + output->append(",\"driverPendingDigest\":"); + AppendJsonAsciiString(output, binding.driverPendingDigest); + output->append(",\"settlementNonce\":"); + AppendJsonAsciiString(output, binding.settlementNonce); + output->push_back('}'); +} + +bool BuildBrokerSettlementRequestJson( + const BrokerSettlementRequestData& request, + std::string* payload, + std::string* envelope, + Error* error) { + std::string binding; + AppendBrokerSettlementBindingJson(&binding, request.binding); + std::string observedBindingDigest; + if (!Sha256Data(binding, &observedBindingDigest, error) || + observedBindingDigest != request.bindingSha256) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-binding-digest", + ERROR_CRC); + } + return false; + } + payload->assign("{\"schema\":1,\"bindingSha256\":"); + AppendJsonAsciiString(payload, request.bindingSha256); + payload->append(",\"brokerPendingDigest\":"); + AppendJsonAsciiString(payload, request.brokerPendingDigest); + payload->append(",\"binding\":"); + payload->append(binding); + payload->push_back('}'); + std::string observedPayloadDigest; + if (!Sha256Data(*payload, &observedPayloadDigest, error) || + observedPayloadDigest != request.payloadSha256) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-payload-digest", + ERROR_CRC); + } + return false; + } + envelope->assign("{\"schema\":1,\"payloadSha256\":"); + AppendJsonAsciiString(envelope, request.payloadSha256); + envelope->append(",\"payload\":"); + envelope->append(*payload); + envelope->push_back('}'); + return true; +} + +bool ParseBrokerSettlementRequest( + std::string_view contents, + BrokerSettlementRequestData* request, + Error* error) { + if (contents.empty() || + contents.size() > kMaximumBrokerSettlementRequestBytes || + contents.find('\n') != std::string_view::npos || + contents.find('\r') != std::string_view::npos) { + return SetError(error, L"broker-settlement-request-framing", + ERROR_INVALID_DATA); + } + JsonValue root; + std::string parseMessage; + if (!JsonParser(contents).Parse(&root, &parseMessage)) { + return SetError(error, L"broker-settlement-request-parse", + ERROR_INVALID_DATA); + } + const JsonValue::Object* envelopeObject = nullptr; + const JsonValue::Object* payloadObject = nullptr; + const JsonValue::Object* bindingObject = nullptr; + const JsonValue* payloadNode = nullptr; + const JsonValue* bindingNode = nullptr; + uint64_t envelopeSchema = 0; + uint64_t payloadSchema = 0; + uint64_t bindingSchema = 0; + if (!RequireJournalObject(root, &envelopeObject, error) || + envelopeObject->size() != 3U || + !RequireJournalUnsigned(*envelopeObject, "schema", 1U, + &envelopeSchema, error) || envelopeSchema != 1U || + !RequireJournalString(*envelopeObject, "payloadSha256", + &request->payloadSha256, error) || + (payloadNode = ObjectField(*envelopeObject, "payload")) == nullptr || + !RequireJournalObject(*payloadNode, &payloadObject, error) || + payloadObject->size() != 4U || + !RequireJournalUnsigned(*payloadObject, "schema", 1U, + &payloadSchema, error) || payloadSchema != 1U || + !RequireJournalString(*payloadObject, "bindingSha256", + &request->bindingSha256, error) || + !RequireJournalString(*payloadObject, "brokerPendingDigest", + &request->brokerPendingDigest, error) || + (bindingNode = ObjectField(*payloadObject, "binding")) == nullptr || + !RequireJournalObject(*bindingNode, &bindingObject, error) || + bindingObject->size() != 8U || + !RequireJournalUnsigned(*bindingObject, "schema", 1U, + &bindingSchema, error) || bindingSchema != 1U || + !RequireJournalString(*bindingObject, "brokerTransactionId", + &request->binding.brokerTransactionId, error) || + !RequireJournalString(*bindingObject, "brokerOuterTransactionId", + &request->binding.brokerOuterTransactionId, error) || + !RequireJournalString(*bindingObject, "brokerCandidateSha256", + &request->binding.brokerCandidateSha256, error) || + !RequireJournalString(*bindingObject, "brokerNestedDigest", + &request->binding.brokerNestedDigest, error) || + !RequireJournalString(*bindingObject, "driverTransactionId", + &request->binding.driverTransactionId, error) || + !RequireJournalString(*bindingObject, "driverPendingDigest", + &request->binding.driverPendingDigest, error) || + !RequireJournalString(*bindingObject, "settlementNonce", + &request->binding.settlementNonce, error)) { + return false; + } + if (!IsCanonicalLowerHex( + request->binding.brokerTransactionId, 32U) || + !IsCanonicalLowerHex( + request->binding.brokerOuterTransactionId, 64U) || + !IsCanonicalLowerHex( + request->binding.brokerCandidateSha256, 64U) || + !IsCanonicalLowerHex( + request->binding.brokerNestedDigest, 64U) || + !IsCanonicalLowerHex( + request->binding.driverTransactionId, 64U) || + !IsCanonicalLowerHex( + request->binding.driverPendingDigest, 64U) || + !IsCanonicalLowerHex( + request->binding.settlementNonce, 64U) || + !IsCanonicalLowerHex(request->payloadSha256, 64U) || + !IsCanonicalLowerHex(request->bindingSha256, 64U) || + !IsCanonicalLowerHex(request->brokerPendingDigest, 64U)) { + return SetError(error, L"broker-settlement-request-identity", + ERROR_INVALID_DATA); + } + std::string canonicalPayload; + std::string canonicalEnvelope; + if (!BuildBrokerSettlementRequestJson( + *request, &canonicalPayload, &canonicalEnvelope, error) || + canonicalEnvelope != contents || + !Sha256Data(canonicalEnvelope, &request->requestSha256, + error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-request-canonical", + ERROR_INVALID_DATA); + } + return false; + } + return true; +} + +bool BuildBrokerSettlementFinalJson( + const BrokerSettlementFinalData& receipt, + std::string* payload, + std::string* envelope, + Error* error) { + payload->assign("{\"schema\":1,\"brokerTransactionId\":"); + AppendJsonAsciiString(payload, receipt.brokerTransactionId); + payload->append(",\"brokerPendingDigest\":"); + AppendJsonAsciiString(payload, receipt.brokerPendingDigest); + payload->append(",\"brokerSettledDigest\":"); + AppendJsonAsciiString(payload, receipt.brokerSettledDigest); + payload->append(",\"driverTransactionId\":"); + AppendJsonAsciiString(payload, receipt.driverTransactionId); + payload->append(",\"driverPendingDigest\":"); + AppendJsonAsciiString(payload, receipt.driverPendingDigest); + payload->append(",\"driverSettledDigest\":"); + AppendJsonAsciiString(payload, receipt.driverSettledDigest); + payload->append(",\"settlementNonce\":"); + AppendJsonAsciiString(payload, receipt.settlementNonce); + payload->append(",\"requestSha256\":"); + AppendJsonAsciiString(payload, receipt.requestSha256); + payload->append(",\"state\":"); + AppendJsonAsciiString(payload, receipt.state); + payload->push_back('}'); + std::string observedPayloadDigest; + if (!Sha256Data(*payload, &observedPayloadDigest, error) || + observedPayloadDigest != receipt.payloadSha256) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-final-payload-digest", + ERROR_CRC); + } + return false; + } + envelope->assign("{\"schema\":1,\"payloadSha256\":"); + AppendJsonAsciiString(envelope, receipt.payloadSha256); + envelope->append(",\"payload\":"); + envelope->append(*payload); + envelope->push_back('}'); + return true; +} + +bool ParseBrokerSettlementFinal( + std::string_view contents, + BrokerSettlementFinalData* receipt, + Error* error) { + if (contents.empty() || + contents.size() > kMaximumBrokerSettlementRequestBytes || + contents.find('\n') != std::string_view::npos || + contents.find('\r') != std::string_view::npos) { + return SetError(error, L"broker-settlement-final-framing", + ERROR_INVALID_DATA); + } + JsonValue root; + std::string parseMessage; + if (!JsonParser(contents).Parse(&root, &parseMessage)) { + return SetError(error, L"broker-settlement-final-parse", + ERROR_INVALID_DATA); + } + const JsonValue::Object* envelopeObject = nullptr; + const JsonValue::Object* payloadObject = nullptr; + const JsonValue* payloadNode = nullptr; + uint64_t envelopeSchema = 0; + uint64_t payloadSchema = 0; + if (!RequireJournalObject(root, &envelopeObject, error) || + envelopeObject->size() != 3U || + !RequireJournalUnsigned(*envelopeObject, "schema", 1U, + &envelopeSchema, error) || envelopeSchema != 1U || + !RequireJournalString(*envelopeObject, "payloadSha256", + &receipt->payloadSha256, error) || + (payloadNode = ObjectField(*envelopeObject, "payload")) == nullptr || + !RequireJournalObject(*payloadNode, &payloadObject, error) || + payloadObject->size() != 10U || + !RequireJournalUnsigned(*payloadObject, "schema", 1U, + &payloadSchema, error) || payloadSchema != 1U || + !RequireJournalString(*payloadObject, "brokerTransactionId", + &receipt->brokerTransactionId, error) || + !RequireJournalString(*payloadObject, "brokerPendingDigest", + &receipt->brokerPendingDigest, error) || + !RequireJournalString(*payloadObject, "brokerSettledDigest", + &receipt->brokerSettledDigest, error) || + !RequireJournalString(*payloadObject, "driverTransactionId", + &receipt->driverTransactionId, error) || + !RequireJournalString(*payloadObject, "driverPendingDigest", + &receipt->driverPendingDigest, error) || + !RequireJournalString(*payloadObject, "driverSettledDigest", + &receipt->driverSettledDigest, error) || + !RequireJournalString(*payloadObject, "settlementNonce", + &receipt->settlementNonce, error) || + !RequireJournalString(*payloadObject, "requestSha256", + &receipt->requestSha256, error) || + !RequireJournalString(*payloadObject, "state", + &receipt->state, error)) { + return false; + } + if (!IsCanonicalLowerHex(receipt->payloadSha256, 64U) || + !IsCanonicalLowerHex(receipt->brokerTransactionId, 32U) || + !IsCanonicalLowerHex(receipt->brokerPendingDigest, 64U) || + !IsCanonicalLowerHex(receipt->brokerSettledDigest, 64U) || + !IsCanonicalLowerHex(receipt->driverTransactionId, 64U) || + !IsCanonicalLowerHex(receipt->driverPendingDigest, 64U) || + !IsCanonicalLowerHex(receipt->driverSettledDigest, 64U) || + !IsCanonicalLowerHex(receipt->settlementNonce, 64U) || + !IsCanonicalLowerHex(receipt->requestSha256, 64U) || + receipt->state != "outer-settled") { + return SetError(error, L"broker-settlement-final-identity", + ERROR_INVALID_DATA); + } + std::string canonicalPayload; + std::string canonicalEnvelope; + if (!BuildBrokerSettlementFinalJson( + *receipt, &canonicalPayload, &canonicalEnvelope, error) || + canonicalEnvelope != contents || + !Sha256Data(canonicalEnvelope, &receipt->receiptSha256, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-final-canonical", + ERROR_INVALID_DATA); + } + return false; + } + return true; +} + +bool ResolveBrokerSettlementRequestPath( + std::filesystem::path* product, + std::filesystem::path* journalRoot, + std::filesystem::path* active, + std::filesystem::path* request, + Error* error) { + std::filesystem::path programData; + std::filesystem::path ignoredComponent; + std::filesystem::path ignoredTransactions; + std::filesystem::path ignoredActive; + if (!ResolveInstallRecoveryPaths(&programData, product, + &ignoredComponent, &ignoredTransactions, &ignoredActive, + error)) { + return false; + } + *journalRoot = *product / kBrokerTransactionDirectory; + *active = *journalRoot / kBrokerTransactionActiveDirectory; + *request = *active / kBrokerSettlementRequestFile; + if (!request->is_absolute() || + request->lexically_relative(programData).empty()) { + return SetError(error, L"broker-settlement-request-path", + ERROR_INVALID_NAME); + } + return true; +} + +bool ReadProtectedBrokerSettlementArtifact( + const std::filesystem::path& suppliedPath, + const wchar_t* expectedLeaf, + std::string* contents, + Error* error) { + std::filesystem::path product; + std::filesystem::path journalRoot; + std::filesystem::path active; + std::filesystem::path request; + if (!ResolveBrokerSettlementRequestPath( + &product, &journalRoot, &active, &request, error)) { + return false; + } + if (expectedLeaf == nullptr) { + return SetError(error, L"broker-settlement-request-path", + ERROR_INVALID_PARAMETER); + } + const std::filesystem::path expected = + std::wcscmp(expectedLeaf, kBrokerSettlementRequestFile) == 0 + ? request : active / expectedLeaf; + if (expected.filename() != expectedLeaf || + _wcsicmp(suppliedPath.lexically_normal().c_str(), + expected.lexically_normal().c_str()) != 0) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-request-path", + ERROR_INVALID_NAME, + L"settlement request is not the fixed protected active-journal path"); + } + return false; + } + WinHandle productHandle; + WinHandle rootHandle; + WinHandle activeHandle; + if (!OpenStableDirectory(product, false, &productHandle, error) || + !VerifyProtectedProductDirectorySecurity( + productHandle.get(), nullptr, error) || + !OpenStableDirectory(journalRoot, true, &rootHandle, error) || + !OpenStableDirectory(active, true, &activeHandle, error)) { + return false; + } + WinHandle file(CreateFileW( + expected.c_str(), GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_SEQUENTIAL_SCAN, + nullptr)); + if (!file) { + return SetLastErrorDetail(error, + L"broker-settlement-request-open"); + } + FILE_ATTRIBUTE_TAG_INFO attributes{}; + BY_HANDLE_FILE_INFORMATION identity{}; + LARGE_INTEGER size{}; + if (!GetFileInformationByHandleEx(file.get(), FileAttributeTagInfo, + &attributes, sizeof(attributes)) || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0 || + !GetFileInformationByHandle(file.get(), &identity) || + identity.nNumberOfLinks != 1U || + !GetFileSizeEx(file.get(), &size) || size.QuadPart <= 0 || + static_cast(size.QuadPart) > + kMaximumBrokerSettlementRequestBytes || + !VerifyProtectedFileSystemSecurity(file.get(), false, + L"broker-settlement-request-security", error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-request-identity", + ERROR_INVALID_DATA, + L"settlement request must be one bounded protected single-link regular file"); + } + return false; + } + contents->assign(static_cast(size.QuadPart), '\0'); + DWORD read = 0; + if (!ReadFile(file.get(), contents->data(), + static_cast(contents->size()), &read, nullptr) || + static_cast(read) != contents->size()) { + return SetLastErrorDetail(error, + L"broker-settlement-request-read"); + } + char trailing = 0; + DWORD trailingRead = 0; + if (!ReadFile(file.get(), &trailing, 1, &trailingRead, nullptr) || + trailingRead != 0) { + return SetError(error, L"broker-settlement-request-read", + ERROR_FILE_INVALID); + } + return true; +} + +bool ReadProtectedBrokerSettlementRequest( + const std::filesystem::path& suppliedPath, + std::string* contents, + Error* error) { + return ReadProtectedBrokerSettlementArtifact( + suppliedPath, kBrokerSettlementRequestFile, contents, error); +} + +bool ReadProtectedBrokerSettlementFinal( + const std::filesystem::path& suppliedPath, + std::string* contents, + Error* error) { + return ReadProtectedBrokerSettlementArtifact( + suppliedPath, kBrokerSettlementFinalFile, contents, error); +} + +bool OpenInstallJournalTransactionDirectory( + std::string_view driverTransactionId, + const wchar_t* prefix, + InstallRecoveryDirectory* directory, + bool* exists, + Error* error) { + *exists = false; + if (!IsCanonicalLowerHex(driverTransactionId, 64U) || + (prefix != kInstallRecoverySettledPrefix && + prefix != kInstallRecoveryDiscardPrefix) || + !ResolveInstallRecoveryPaths(&directory->programData, + &directory->product, &directory->component, + &directory->transactions, &directory->active, error) || + !OpenStableDirectory(directory->programData, false, + &directory->programDataHandle, error)) { + return false; + } + bool productExists = false; + bool componentExists = false; + bool transactionsExists = false; + if (!OpenExistingInstallRecoveryDirectory(directory->product, false, + &directory->productHandle, &productExists, error)) { + return false; + } + if (!productExists) return true; + if (!VerifyProtectedProductDirectorySecurity( + directory->productHandle.get(), nullptr, error) || + !OpenExistingInstallRecoveryDirectory(directory->component, true, + &directory->componentHandle, &componentExists, error)) { + return false; + } + if (!componentExists) return true; + if (!OpenExistingInstallRecoveryDirectory(directory->transactions, true, + &directory->transactionsHandle, &transactionsExists, error)) { + return false; + } + if (!transactionsExists) return true; + const std::wstring transactionWide( + driverTransactionId.begin(), driverTransactionId.end()); + directory->active = directory->transactions / + (std::wstring(prefix) + transactionWide); + return OpenExistingInstallRecoveryDirectory(directory->active, true, + &directory->activeHandle, exists, error); +} + +bool OpenSettledInstallJournalDirectory( + std::string_view driverTransactionId, + InstallRecoveryDirectory* directory, + bool* exists, + Error* error) { + return OpenInstallJournalTransactionDirectory( + driverTransactionId, kInstallRecoverySettledPrefix, + directory, exists, error); +} + +bool OpenDiscardingInstallJournalDirectory( + std::string_view driverTransactionId, + InstallRecoveryDirectory* directory, + bool* exists, + Error* error) { + return OpenInstallJournalTransactionDirectory( + driverTransactionId, kInstallRecoveryDiscardPrefix, + directory, exists, error); +} + +bool LoadSettlementInstallJournal( + std::string_view driverTransactionId, + LoadedInstallJournal* loaded, + bool* tombstone, + bool* exists, + Error* error) { + *tombstone = false; + *exists = false; + InstallRecoveryDirectory activeDirectory; + bool activeExists = false; + if (!activeDirectory.OpenChain( + false, nullptr, &activeExists, error)) { + return false; + } + if (activeExists) { + if (!LoadInstallJournal( + std::move(activeDirectory), loaded, error) || + !loaded->hasRecord || + loaded->state.transactionId != driverTransactionId) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-driver-identity", + ERROR_REVISION_MISMATCH, + L"active driver journal belongs to a different transaction"); + } + return false; + } + *exists = true; + return true; + } + InstallRecoveryDirectory settledDirectory; + bool settledExists = false; + if (!OpenSettledInstallJournalDirectory( + driverTransactionId, &settledDirectory, + &settledExists, error)) { + return false; + } + if (!settledExists) { + return true; + } + if (!LoadInstallJournal( + std::move(settledDirectory), loaded, error) || + !loaded->hasRecord || + loaded->state.transactionId != driverTransactionId) { + return false; + } + *tombstone = true; + *exists = true; + return true; +} + +bool ValidateBrokerSettlementJournalBinding( + const InstallJournalStateData& state, + const BrokerSettlementRequestData& request, + bool final, + Error* error) { + const BrokerSettlementBindingData& binding = request.binding; + const std::string& expectedDriverPending = final + ? state.brokerDriverPendingDigest : state.lastDigest; + if (!state.brokerRequired || !state.hasBrokerProof || + !state.brokerProofSuccess || !state.brokerProofChanged || + state.brokerDriverRollbackAuthorized || + state.brokerJournalState != "nested-ready" || + state.direction != InstallJournalDirection::Forward || + state.rollbackAuthorized || + binding.brokerTransactionId != + state.brokerJournalTransactionId || + binding.brokerOuterTransactionId != + state.brokerJournalOuterTransactionId || + binding.brokerCandidateSha256 != + state.brokerJournalCandidateSha256 || + binding.brokerNestedDigest != state.brokerJournalDigest || + binding.driverTransactionId != state.transactionId || + binding.driverPendingDigest != expectedDriverPending || + binding.settlementNonce != state.brokerSettlementNonce || + (final && + (request.requestSha256 != + state.brokerSettlementRequestSha256 || + request.brokerPendingDigest != + state.brokerGoPendingDigest))) { + return SetError(error, L"broker-settlement-journal-binding", + ERROR_REVISION_MISMATCH, + L"settlement request does not bind both exact pending journal identities"); + } + return true; +} + +bool ValidateBrokerSettlementFinalBinding( + const BrokerSettlementRequestData& request, + const BrokerSettlementFinalData& receipt, + Error* error) { + if (receipt.brokerTransactionId != + request.binding.brokerTransactionId || + receipt.brokerPendingDigest != request.brokerPendingDigest || + receipt.driverTransactionId != + request.binding.driverTransactionId || + receipt.driverPendingDigest != + request.binding.driverPendingDigest || + receipt.settlementNonce != request.binding.settlementNonce || + receipt.requestSha256 != request.requestSha256 || + receipt.state != "outer-settled" || + receipt.brokerSettledDigest == receipt.brokerPendingDigest || + receipt.driverSettledDigest == receipt.driverPendingDigest) { + return SetError(error, L"broker-settlement-final-binding", + ERROR_REVISION_MISMATCH, + L"protected final receipt does not bind the exact pending request"); + } + return true; +} + +bool ValidateBrokerSettlementFinalJournal( + const InstallJournalStateData& state, + const BrokerSettlementFinalData& receipt, + Error* error) { + if (state.phase != InstallJournalPhase::BrokerOuterSettled || + state.transactionId != receipt.driverTransactionId || + state.lastDigest != receipt.driverSettledDigest || + state.brokerJournalTransactionId != + receipt.brokerTransactionId || + state.brokerDriverPendingDigest != + receipt.driverPendingDigest || + state.brokerGoPendingDigest != + receipt.brokerPendingDigest || + state.brokerSettlementNonce != receipt.settlementNonce || + state.brokerSettlementRequestSha256 != receipt.requestSha256) { + return SetError(error, L"broker-settlement-final-journal", + ERROR_REVISION_MISMATCH, + L"protected final receipt does not bind the exact terminal driver journal"); + } + return true; +} + +bool ReconcileSettledBrokerOuterSettlement( + uint64_t deadlineUnixMs, + bool* handled, + Outcome* outcome, + Error* error) { + *handled = false; + std::filesystem::path product; + std::filesystem::path journalRoot; + std::filesystem::path active; + std::filesystem::path requestPath; + if (!ResolveBrokerSettlementRequestPath( + &product, &journalRoot, &active, &requestPath, error)) { + return false; + } + const DWORD attributes = GetFileAttributesW(requestPath.c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES) { + const DWORD code = GetLastError(); + if (code == ERROR_FILE_NOT_FOUND || code == ERROR_PATH_NOT_FOUND) { + return true; + } + return SetError(error, + L"broker-settlement-replay-request-discovery", code); + } + OuterPackageMutexWitness outerMutex; + if (!outerMutex.VerifyHeldByOuterOwner(error)) { + return false; + } + std::string contents; + BrokerSettlementRequestData request; + if (!ReadProtectedBrokerSettlementRequest( + requestPath, &contents, error) || + !ParseBrokerSettlementRequest(contents, &request, error)) { + return false; + } + const std::filesystem::path finalPath = + active / kBrokerSettlementFinalFile; + std::optional finalReceipt; + const DWORD finalAttributes = GetFileAttributesW(finalPath.c_str()); + if (finalAttributes != INVALID_FILE_ATTRIBUTES) { + std::string finalContents; + BrokerSettlementFinalData parsedFinal; + if (!ReadProtectedBrokerSettlementFinal( + finalPath, &finalContents, error) || + !ParseBrokerSettlementFinal( + finalContents, &parsedFinal, error) || + !ValidateBrokerSettlementFinalBinding( + request, parsedFinal, error)) { + return false; + } + finalReceipt = std::move(parsedFinal); + } else { + const DWORD finalError = GetLastError(); + if (finalError != ERROR_FILE_NOT_FOUND && + finalError != ERROR_PATH_NOT_FOUND) { + return SetError(error, + L"broker-settlement-replay-final-discovery", + finalError); + } + } + InstallRecoveryDirectory settledDirectory; + bool settledExists = false; + if (!OpenSettledInstallJournalDirectory( + request.binding.driverTransactionId, + &settledDirectory, &settledExists, error)) { + return false; + } + if (settledExists) { + LoadedInstallJournal loaded; + if (!LoadInstallJournal(std::move(settledDirectory), + &loaded, error) || !loaded.hasRecord || + loaded.state.phase != + InstallJournalPhase::BrokerOuterSettled || + !ValidateBrokerSettlementJournalBinding( + loaded.state, request, true, error) || + (finalReceipt && + !ValidateBrokerSettlementFinalJournal( + loaded.state, *finalReceipt, error)) || + !RecoveryStateMatchesForward( + loaded, deadlineUnixMs, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-replay-tombstone", + ERROR_REVISION_MISMATCH); + } + return false; + } + } else if (!finalReceipt) { + return SetError(error, L"broker-settlement-replay-tombstone", + ERROR_FILE_NOT_FOUND, + L"published pending request has neither an exact settled driver journal nor a protected final receipt"); + } + outcome->success = true; + outcome->changed = true; + outcome->exitCode = ExitCode::Success; + outcome->rollback = L"not-needed"; + outcome->brokerBinding.present = true; + outcome->brokerBinding.transactionId = + request.binding.brokerTransactionId; + outcome->brokerBinding.outerTransactionId = + request.binding.brokerOuterTransactionId; + outcome->brokerBinding.candidateSha256 = + request.binding.brokerCandidateSha256; + outcome->brokerBinding.state = "nested-ready"; + outcome->brokerBinding.digest = + request.binding.brokerNestedDigest; + outcome->brokerBinding.driverTransactionId = + request.binding.driverTransactionId; + outcome->brokerBinding.driverDigest = + request.binding.driverPendingDigest; + outcome->brokerBinding.settlementNonce = + request.binding.settlementNonce; + outcome->brokerBinding.recovery = "replayed"; + *handled = true; + return true; +} + +bool AppendBrokerOuterSettled( + LoadedInstallJournal* loaded, + const BrokerSettlementRequestData& request, + Error* error) { + InstallJournalStateData next = loaded->state; + next.phase = InstallJournalPhase::BrokerOuterSettled; + next.brokerDriverPendingDigest = + request.binding.driverPendingDigest; + next.brokerSettlementRequestSha256 = request.requestSha256; + next.brokerGoPendingDigest = request.brokerPendingDigest; + next.callSucceeded = true; + next.callError = ERROR_SUCCESS; + if (!ValidateInstallJournalTransition( + &loaded->state, next, error) || + !WriteInstallJournalRecord( + loaded->directory.active, &next, error)) { + return false; + } + loaded->state = std::move(next); + MarkTransactionMutationStarted(); + if (!PublishInstallRecoveryEvidence(loaded->directory.active, + loaded->state.sequence - 1U, error)) { + return false; + } + gActiveRecoveryRecordWritten = true; + return true; +} + +bool AcknowledgeBrokerOuterSettlement( + const BrokerSettlementAckOptions& options, + BrokerSettlementRequestData* receipt, + std::string* driverFinalDigest, + Error* error) { + if (!IsElevated()) { + return SetError(error, L"elevation", ERROR_ELEVATION_REQUIRED); + } + OuterPackageMutexWitness outerMutex; + if (!outerMutex.VerifyHeldByOuterOwner(error)) { + return false; + } + TransactionMutex transactionMutex; + if (!transactionMutex.Acquire(error) || + !CheckTransactionDeadline(options.transactionDeadlineUnixMs, + L"broker-settlement-deadline", error)) { + return false; + } + std::string contents; + if (!ReadProtectedBrokerSettlementRequest( + options.requestPath, &contents, error) || + !ParseBrokerSettlementRequest(contents, receipt, error) || + receipt->requestSha256 != options.requestSha256) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-request-hash", + ERROR_CRC); + } + return false; + } + LoadedInstallJournal loaded; + bool tombstone = false; + bool exists = false; + if (!LoadSettlementInstallJournal( + receipt->binding.driverTransactionId, &loaded, + &tombstone, &exists, error) || !exists) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-driver-journal", + ERROR_FILE_NOT_FOUND); + } + return false; + } + const bool final = + loaded.state.phase == InstallJournalPhase::BrokerOuterSettled; + if ((!final && loaded.state.phase != + InstallJournalPhase::BrokerOuterSettlementPending) || + (tombstone && !final) || + !ValidateBrokerSettlementJournalBinding( + loaded.state, *receipt, final, error) || + !RecoveryStateMatchesForward( + loaded, options.transactionDeadlineUnixMs, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-driver-journal", + ERROR_INVALID_STATE); + } + return false; + } + if (!final && !AppendBrokerOuterSettled( + &loaded, *receipt, error)) { + return false; + } + *driverFinalDigest = loaded.state.lastDigest; + if (!tombstone) { + loaded.evidenceLocks.clear(); + std::filesystem::path retiredPath; + if (!RetireInstallRecoveryActiveDirectory( + &loaded.directory, loaded.state.transactionId, + error, true, &retiredPath)) { + return false; + } + } + return true; +} + +bool DiscardBrokerSettlementTombstone( + const BrokerSettlementDiscardOptions& options, + bool* discarded, + bool* retained, + Error* error) { + *discarded = false; + *retained = false; + if (!IsElevated()) { + return SetError(error, L"elevation", ERROR_ELEVATION_REQUIRED); + } + OuterPackageMutexWitness outerMutex; + if (!outerMutex.VerifyHeldByOuterOwner(error)) { + return false; + } + TransactionMutex transactionMutex; + if (!transactionMutex.Acquire(error) || + !CheckTransactionDeadline(options.transactionDeadlineUnixMs, + L"broker-settlement-discard-deadline", error)) { + return false; + } + std::string finalContents; + BrokerSettlementFinalData finalReceipt; + if (!ReadProtectedBrokerSettlementFinal( + options.brokerFinalReceiptPath, &finalContents, error) || + !ParseBrokerSettlementFinal( + finalContents, &finalReceipt, error) || + finalReceipt.receiptSha256 != + options.brokerFinalReceiptSha256 || + finalReceipt.brokerTransactionId != + options.brokerTransactionId || + finalReceipt.brokerSettledDigest != options.brokerDigest || + finalReceipt.driverTransactionId != + options.driverTransactionId || + finalReceipt.driverSettledDigest != options.driverDigest || + finalReceipt.settlementNonce != options.settlementNonce || + finalReceipt.requestSha256 != options.requestSha256) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-discard-final-receipt", + ERROR_REVISION_MISMATCH, + L"discard request does not match the protected broker-final receipt"); + } + return false; + } + std::filesystem::path brokerProduct; + std::filesystem::path brokerRoot; + std::filesystem::path brokerActive; + std::filesystem::path requestPath; + std::string requestContents; + BrokerSettlementRequestData request; + if (!ResolveBrokerSettlementRequestPath( + &brokerProduct, &brokerRoot, &brokerActive, + &requestPath, error) || + !ReadProtectedBrokerSettlementRequest( + requestPath, &requestContents, error) || + !ParseBrokerSettlementRequest( + requestContents, &request, error) || + !ValidateBrokerSettlementFinalBinding( + request, finalReceipt, error)) { + return false; + } + InstallRecoveryDirectory activeDirectory; + bool activeExists = false; + if (!activeDirectory.OpenChain( + false, nullptr, &activeExists, error)) { + return false; + } + if (activeExists) { + return SetError(error, L"broker-settlement-discard-active", + ERROR_INSTALL_ALREADY_RUNNING, + L"an active driver journal blocks inert tombstone cleanup"); + } + InstallRecoveryDirectory settledDirectory; + bool settledExists = false; + if (!OpenSettledInstallJournalDirectory( + options.driverTransactionId, &settledDirectory, + &settledExists, error)) { + return false; + } + InstallRecoveryDirectory discardingDirectory; + bool discardingExists = false; + if (!OpenDiscardingInstallJournalDirectory( + options.driverTransactionId, &discardingDirectory, + &discardingExists, error) || + (settledExists && discardingExists)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-discard-identity", + ERROR_ALREADY_EXISTS, + L"settled and discarding driver tombstones cannot coexist"); + } + return false; + } + if (!settledExists) { + if (discardingExists) { + const std::filesystem::path inert = + discardingDirectory.active; + discardingDirectory.activeHandle.reset(); + std::error_code ignored; + std::filesystem::remove_all(inert, ignored); + if (ignored) { + *retained = true; + std::wstring diagnostic = + L"VIIPER: inert driver settlement cleanup retained after error "; + diagnostic += std::to_wstring(ignored.value()); + diagnostic += L".\n"; + OutputDebugStringW(diagnostic.c_str()); + } + } + return true; + } + LoadedInstallJournal loaded; + if (!LoadInstallJournal(std::move(settledDirectory), + &loaded, error) || !loaded.hasRecord || + !ValidateBrokerSettlementFinalJournal( + loaded.state, finalReceipt, error) || + !RecoveryStateMatchesForward( + loaded, options.transactionDeadlineUnixMs, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-discard-binding", + ERROR_REVISION_MISMATCH); + } + return false; + } + const std::filesystem::path settled = loaded.directory.active; + const std::filesystem::path discarding = + loaded.directory.transactions / + (std::wstring(kInstallRecoveryDiscardPrefix) + + std::wstring(options.driverTransactionId.begin(), + options.driverTransactionId.end())); + loaded.evidenceLocks.clear(); + loaded.directory.activeHandle.reset(); + if (!MoveFileExW(settled.c_str(), discarding.c_str(), + MOVEFILE_WRITE_THROUGH)) { + return SetLastErrorDetail(error, + L"broker-settlement-discard-rename", + L"settled driver tombstone could not be atomically made inert"); + } + const DWORD settledAttributes = GetFileAttributesW(settled.c_str()); + const DWORD settledError = settledAttributes == INVALID_FILE_ATTRIBUTES + ? GetLastError() : ERROR_SUCCESS; + if (settledAttributes != INVALID_FILE_ATTRIBUTES || + (settledError != ERROR_FILE_NOT_FOUND && + settledError != ERROR_PATH_NOT_FOUND)) { + return SetError(error, L"broker-settlement-discard-absence", + settledAttributes != INVALID_FILE_ATTRIBUTES + ? ERROR_ALREADY_EXISTS : settledError); + } + WinHandle discardingHandle; + if (!OpenStableDirectory( + discarding, true, &discardingHandle, error)) { + return false; + } + *discarded = true; + discardingHandle.reset(); + // The atomic rename is the authoritative discard. Recursive cleanup is + // inert and retryable from the exact transaction-bound discarding name. + std::error_code removalError; + std::filesystem::remove_all(discarding, removalError); + if (removalError) { + *retained = true; + std::wstring diagnostic = + L"VIIPER: inert driver settlement cleanup retained after error "; + diagnostic += std::to_wstring(removalError.value()); + diagnostic += L".\n"; + OutputDebugStringW(diagnostic.c_str()); + } + return true; +} + +void EmitBrokerSettlementAck( + const BrokerSettlementRequestData& request, + std::string_view driverFinalDigest) { + std::cout + << "journal-settlement operation=broker-settlement-ack " + << "brokerTransactionId=" + << request.binding.brokerTransactionId + << " brokerPendingDigest=" << request.brokerPendingDigest + << " driverTransactionId=" + << request.binding.driverTransactionId + << " driverPendingDigest=" + << request.binding.driverPendingDigest + << " settlementNonce=" << request.binding.settlementNonce + << " requestSha256=" << request.requestSha256 + << " state=outer-settled digest=" << driverFinalDigest + << "\n"; + std::cout.flush(); +} + +void EmitBrokerSettlementDiscard( + const BrokerSettlementDiscardOptions& options, + bool discarded, + bool retained) { + std::cout + << "journal-discard operation=broker-settlement-discard " + << "brokerTransactionId=" << options.brokerTransactionId + << " brokerDigest=" << options.brokerDigest + << " driverTransactionId=" << options.driverTransactionId + << " driverDigest=" << options.driverDigest + << " settlementNonce=" << options.settlementNonce + << " requestSha256=" << options.requestSha256 + << " discarded=" << (discarded ? 1 : 0) + << " retained=" << (retained ? 1 : 0) << "\n"; + std::cout.flush(); +} + +const char* RemoveJournalPhaseName(RemoveJournalPhase phase) noexcept { + switch (phase) { + case RemoveJournalPhase::Prepared: return "Prepared"; + case RemoveJournalPhase::DeviceRemovalEntered: return "DeviceRemovalEntered"; + case RemoveJournalPhase::DeviceRemovalReturned: return "DeviceRemovalReturned"; + case RemoveJournalPhase::DeviceRemovalCommitted: return "DeviceRemovalCommitted"; + case RemoveJournalPhase::PackageRemovalEntered: return "PackageRemovalEntered"; + case RemoveJournalPhase::PackageRemovalReturned: return "PackageRemovalReturned"; + case RemoveJournalPhase::PackageRemovalCommitted: return "PackageRemovalCommitted"; + case RemoveJournalPhase::RollbackAdmitted: return "RollbackAdmitted"; + case RemoveJournalPhase::RollbackPackageEntered: return "RollbackPackageEntered"; + case RemoveJournalPhase::RollbackPackageReturned: return "RollbackPackageReturned"; + case RemoveJournalPhase::RollbackPackageCommitted: return "RollbackPackageCommitted"; + case RemoveJournalPhase::RollbackBindingEntered: return "RollbackBindingEntered"; + case RemoveJournalPhase::RollbackBindingReturned: return "RollbackBindingReturned"; + case RemoveJournalPhase::ForwardValidated: return "ForwardValidated"; + case RemoveJournalPhase::ExactPriorRestored: return "ExactPriorRestored"; + case RemoveJournalPhase::ForwardRebootPending: return "ForwardRebootPending"; + case RemoveJournalPhase::RestoreRebootPending: return "RestoreRebootPending"; + case RemoveJournalPhase::ManualReconciliationRequired: + return "ManualReconciliationRequired"; + } + return "ManualReconciliationRequired"; +} + +std::optional ParseRemoveJournalPhase( + std::string_view value) noexcept { + for (RemoveJournalPhase phase : { + RemoveJournalPhase::Prepared, + RemoveJournalPhase::DeviceRemovalEntered, + RemoveJournalPhase::DeviceRemovalReturned, + RemoveJournalPhase::DeviceRemovalCommitted, + RemoveJournalPhase::PackageRemovalEntered, + RemoveJournalPhase::PackageRemovalReturned, + RemoveJournalPhase::PackageRemovalCommitted, + RemoveJournalPhase::RollbackAdmitted, + RemoveJournalPhase::RollbackPackageEntered, + RemoveJournalPhase::RollbackPackageReturned, + RemoveJournalPhase::RollbackPackageCommitted, + RemoveJournalPhase::RollbackBindingEntered, + RemoveJournalPhase::RollbackBindingReturned, + RemoveJournalPhase::ForwardValidated, + RemoveJournalPhase::ExactPriorRestored, + RemoveJournalPhase::ForwardRebootPending, + RemoveJournalPhase::RestoreRebootPending, + RemoveJournalPhase::ManualReconciliationRequired}) { + if (value == RemoveJournalPhaseName(phase)) return phase; + } + return std::nullopt; +} + +const char* RemoveJournalDirectionName( + RemoveJournalDirection direction) noexcept { + return direction == RemoveJournalDirection::Rollback + ? "rollback" : "forward"; +} + +std::optional ParseRemoveJournalDirection( + std::string_view value) noexcept { + if (value == "forward") return RemoveJournalDirection::Forward; + if (value == "rollback") return RemoveJournalDirection::Rollback; + return std::nullopt; +} + +struct RemoveRecoveryDirectory { + std::filesystem::path programData; + std::filesystem::path root; + std::filesystem::path active; + WinHandle programDataHandle; + WinHandle rootHandle; + WinHandle activeHandle; + bool activeCreated = false; + + bool OpenChain(bool createActive, bool* exists, Error* error) { + *exists = false; + std::filesystem::path ignoredProduct; + std::filesystem::path ignoredComponent; + std::filesystem::path ignoredTransactions; + std::filesystem::path ignoredActive; + if (!ResolveInstallRecoveryPaths( + &programData, &ignoredProduct, &ignoredComponent, + &ignoredTransactions, &ignoredActive, error)) { + return false; + } + root = programData / kRemoveRecoveryRootDirectory; + active = root / kRemoveRecoveryActiveDirectory; + if (!OpenStableDirectory( + programData, false, &programDataHandle, error)) { + return false; + } + if (!createActive) { + bool rootExists = false; + bool activeExists = false; + if (!OpenExistingInstallRecoveryDirectory( + root, true, &rootHandle, &rootExists, error)) { + return false; + } + if (!rootExists) return true; + if (!OpenExistingInstallRecoveryDirectory( + active, true, &activeHandle, &activeExists, error)) { + return false; + } + *exists = activeExists; + return true; + } + bool created = false; + if (!CreateOrOpenInstallRecoveryDirectory( + root, true, true, &rootHandle, &created, error)) { + return false; + } + const bool opened = CreateOrOpenInstallRecoveryDirectory( + active, false, true, &activeHandle, &created, error); + activeCreated = created; + if (!opened) return false; + *exists = true; + return true; + } +}; + +bool PublishRemoveRecoveryEvidence( + const std::filesystem::path& active, + uint64_t sequence, + Error* error) { + std::wostringstream name; + name << kInstallRecoveryJournalPrefix << std::setw(8) + << std::setfill(L'0') << sequence + << kInstallRecoveryJournalSuffix; + const std::filesystem::path record = active / name.str(); + const std::wstring activeValue = active.wstring(); + const std::wstring recordValue = record.wstring(); + if (activeValue.empty() || recordValue.empty() || + activeValue.size() >= gActiveBackupRoot.size() || + recordValue.size() >= gActiveRecoveryRecord.size()) { + return SetError(error, L"remove-journal-evidence", + ERROR_FILENAME_EXCED_RANGE); + } + ClearActiveRecoveryEvidence(); + std::copy(activeValue.begin(), activeValue.end(), + gActiveBackupRoot.begin()); + std::copy(recordValue.begin(), recordValue.end(), + gActiveRecoveryRecord.begin()); + gActiveBackupRootRetained = true; + return true; +} + +enum class RemoveRetirementTestFault { + None, + TemporaryTree, + TemporaryTreeActiveAbsencePostcheck, + TemporaryTreeRetainSettledTombstone, +}; + +bool RetireRemoveRecoveryActiveDirectory( + RemoveRecoveryDirectory* directory, + std::string_view transactionId, + Error* error, + RemoveRetirementTestFault testFault = + RemoveRetirementTestFault::None) { + if (directory == nullptr || !IsSha256Digest(transactionId) || + directory->active.filename() != kRemoveRecoveryActiveDirectory) { + return SetError(error, L"remove-journal-retire-identity", + ERROR_INVALID_PARAMETER); + } + const std::wstring transactionIdWide( + transactionId.begin(), transactionId.end()); + const std::filesystem::path tombstone = directory->root / + (std::wstring(kRemoveRecoverySettledPrefix) + transactionIdWide); + directory->activeHandle.reset(); + if (!MoveFileExW(directory->active.c_str(), tombstone.c_str(), + MOVEFILE_WRITE_THROUGH)) { + if (error != nullptr) { + error->recoveryBackup = directory->active.wstring(); + error->recoveryBackupRetained = true; + } + return SetLastErrorDetail(error, L"remove-journal-retire-rename", + L"terminal remove journal could not be atomically moved out of admission"); + } + const DWORD activeAttributes = testFault == + RemoveRetirementTestFault:: + TemporaryTreeActiveAbsencePostcheck + ? FILE_ATTRIBUTE_DIRECTORY + : GetFileAttributesW(directory->active.c_str()); + const DWORD activeError = activeAttributes == INVALID_FILE_ATTRIBUTES + ? GetLastError() : ERROR_SUCCESS; + if (activeAttributes != INVALID_FILE_ATTRIBUTES || + (activeError != ERROR_FILE_NOT_FOUND && + activeError != ERROR_PATH_NOT_FOUND)) { + if (error != nullptr) { + error->recoveryBackup = tombstone.wstring(); + error->recoveryBackupRetained = true; + } + return SetError(error, L"remove-journal-retire-active-absence", + activeAttributes != INVALID_FILE_ATTRIBUTES + ? ERROR_ALREADY_EXISTS : activeError, + L"atomic retirement did not prove remove active-v2 absent"); + } + WinHandle tombstoneHandle; + bool tombstoneVerified = false; + if (testFault == RemoveRetirementTestFault::None) { + tombstoneVerified = OpenStableDirectory( + tombstone, true, &tombstoneHandle, error); + } else { + tombstoneHandle.reset(CreateFileW(tombstone.c_str(), + FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | + FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + nullptr)); + FILE_ATTRIBUTE_TAG_INFO attributes{}; + tombstoneVerified = tombstoneHandle && + GetFileInformationByHandleEx(tombstoneHandle.get(), + FileAttributeTagInfo, &attributes, sizeof(attributes)) && + (attributes.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (attributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0; + if (!tombstoneVerified) { + SetLastErrorDetail(error, + L"self-test-remove-journal-retire-tombstone"); + } + } + if (!tombstoneVerified) { + if (error != nullptr) { + error->recoveryBackup = tombstone.wstring(); + error->recoveryBackupRetained = true; + } + return false; + } + ClearActiveRecoveryEvidence(); + tombstoneHandle.reset(); + + // The write-through rename plus the verified absence/open checks above are + // the authoritative retirement boundary. Cleanup is best-effort and must + // never re-admit a terminal transaction or convert it into rollback. + std::error_code removalError; + if (testFault == RemoveRetirementTestFault:: + TemporaryTreeRetainSettledTombstone) { + removalError = std::make_error_code(std::errc::permission_denied); + } else { + std::filesystem::remove_all(tombstone, removalError); + } + if (removalError) { + const std::wstring retained = tombstone.wstring(); + if (!retained.empty() && + retained.size() < gRetainedRemoveTombstone.size()) { + std::copy(retained.begin(), retained.end(), + gRetainedRemoveTombstone.begin()); + gRetainedRemoveTombstoneError = + static_cast(removalError.value()); + } + std::wstring diagnostic = + L"VIIPER: settled remove journal tombstone retained at \""; + diagnostic += retained; + diagnostic += L"\" after cleanup error "; + diagnostic += std::to_wstring(removalError.value()); + diagnostic += L"; active admission remains retired.\n"; + OutputDebugStringW(diagnostic.c_str()); + } + return true; +} + +struct RemoveJournalStateData { + RemoveJournalPhase phase = RemoveJournalPhase::Prepared; + RemoveJournalDirection direction = RemoveJournalDirection::Forward; + uint64_t sequence = 0; + std::string previousDigest = std::string(kZeroSha256); + std::string lastDigest; + std::string transactionId; + std::string bootIdentifier; + std::string pendingRebootBootIdentifier; + Snapshot prior; + bool hasPriorAbiProfile = false; + AbiCompatibilityProfile priorAbiProfile{}; + uint32_t packageCursor = 0; + uint32_t activePackageIndex = UINT32_MAX; + bool deviceMutationEntered = false; + bool bindingMutationEntered = false; + bool rebootRequired = false; + bool freshRebootRequired = false; + bool callSucceeded = true; + DWORD callError = ERROR_SUCCESS; + bool deadlineOverrun = false; +}; + +bool RemoveJournalPhaseIsPackageCall( + RemoveJournalPhase phase) noexcept { + return phase == RemoveJournalPhase::PackageRemovalEntered || + phase == RemoveJournalPhase::PackageRemovalReturned || + phase == RemoveJournalPhase::RollbackPackageEntered || + phase == RemoveJournalPhase::RollbackPackageReturned; +} + +bool RemoveJournalPhaseIsAuthoritativeReturn( + RemoveJournalPhase phase) noexcept { + return phase == RemoveJournalPhase::DeviceRemovalReturned || + phase == RemoveJournalPhase::PackageRemovalReturned || + phase == RemoveJournalPhase::RollbackPackageReturned || + phase == RemoveJournalPhase::RollbackBindingReturned; +} + +bool ValidateRemoveJournalStateShape( + const RemoveJournalStateData& state, + Error* error) { + const bool pending = + state.phase == RemoveJournalPhase::ForwardRebootPending || + state.phase == RemoveJournalPhase::RestoreRebootPending; + const bool packageCall = + RemoveJournalPhaseIsPackageCall(state.phase); + const bool priorNeedsProfile = state.prior.devices.size() == 1U && + state.prior.devices[0].started && + state.prior.devices[0].problem == 0; + if (!IsSha256Digest(state.previousDigest) || + !IsSha256Digest(state.transactionId) || + !IsCanonicalBootIdentifier(state.bootIdentifier) || + (!state.pendingRebootBootIdentifier.empty() && + !IsCanonicalBootIdentifier( + state.pendingRebootBootIdentifier)) || + state.sequence >= kMaximumRemoveRecoveryRecords || + state.prior.packages.size() > 32U || + state.prior.devices.size() > 1U || + state.packageCursor > state.prior.packages.size() || + (packageCall && + state.activePackageIndex >= state.prior.packages.size()) || + (!packageCall && state.activePackageIndex != UINT32_MAX) || + (state.direction == RemoveJournalDirection::Forward && + (state.phase == RemoveJournalPhase::RollbackAdmitted || + state.phase == RemoveJournalPhase::RollbackPackageEntered || + state.phase == RemoveJournalPhase::RollbackPackageReturned || + state.phase == RemoveJournalPhase::RollbackPackageCommitted || + state.phase == RemoveJournalPhase::RollbackBindingEntered || + state.phase == RemoveJournalPhase::RollbackBindingReturned || + state.phase == RemoveJournalPhase::ExactPriorRestored || + state.phase == RemoveJournalPhase::RestoreRebootPending)) || + (state.direction == RemoveJournalDirection::Rollback && + (state.phase == RemoveJournalPhase::DeviceRemovalEntered || + state.phase == RemoveJournalPhase::DeviceRemovalReturned || + state.phase == RemoveJournalPhase::DeviceRemovalCommitted || + state.phase == RemoveJournalPhase::PackageRemovalEntered || + state.phase == RemoveJournalPhase::PackageRemovalReturned || + state.phase == RemoveJournalPhase::PackageRemovalCommitted || + state.phase == RemoveJournalPhase::ForwardValidated || + state.phase == RemoveJournalPhase::ForwardRebootPending)) || + (state.prior.devices.empty() && + (state.deviceMutationEntered || + state.bindingMutationEntered || + state.phase == RemoveJournalPhase::DeviceRemovalEntered || + state.phase == RemoveJournalPhase::DeviceRemovalReturned || + state.phase == RemoveJournalPhase::DeviceRemovalCommitted || + state.phase == RemoveJournalPhase::RollbackBindingEntered || + state.phase == RemoveJournalPhase::RollbackBindingReturned)) || + (pending && state.pendingRebootBootIdentifier.empty()) || + (!state.rebootRequired && + !state.pendingRebootBootIdentifier.empty()) || + (state.freshRebootRequired && + (!state.rebootRequired || + state.pendingRebootBootIdentifier.empty() || + !RemoveJournalPhaseIsAuthoritativeReturn(state.phase))) || + (state.hasPriorAbiProfile != priorNeedsProfile) || + (state.hasPriorAbiProfile && + !IsKnownAbiCompatibilityProfile(state.priorAbiProfile))) { + return SetError(error, L"remove-journal-state", + ERROR_INVALID_DATA); + } + for (size_t index = 0; index < state.prior.packages.size(); ++index) { + const PackageInfo& package = state.prior.packages[index]; + if (!IsSafePublishedInfName(package.publishedName) || + !IsSha256Digest(package.infSha256) || + !IsSha256Digest(package.sysSha256) || + !IsSha256Digest(package.catSha256) || + std::any_of(state.prior.packages.begin(), + state.prior.packages.begin() + + static_cast(index), + [&](const PackageInfo& earlier) { + return _wcsicmp(earlier.publishedName.c_str(), + package.publishedName.c_str()) == 0; + })) { + return SetError(error, L"remove-journal-prior-package", + ERROR_INVALID_DATA); + } + } + if (!state.prior.devices.empty()) { + const DeviceState& device = state.prior.devices[0]; + const size_t matches = static_cast(std::count_if( + state.prior.packages.begin(), state.prior.packages.end(), + [&](const PackageInfo& package) { + return _wcsicmp(package.publishedName.c_str(), + device.publishedInf.c_str()) == 0 && + package.version == device.version && + SamePackageBytes(package, device.package); + })); + if (!device.present || + !IsOwnedGeneratedRootInstanceId(device.instanceId) || + _wcsicmp(device.service.c_str(), kServiceName) != 0 || + matches != 1U) { + return SetError(error, L"remove-journal-prior-device", + ERROR_INVALID_DATA); + } + } + return true; +} + +void AppendRemoveJournalSnapshot( + std::string* payload, + const RemoveJournalStateData& state) { + payload->append(",\"priorAbiProfile\":"); + if (state.hasPriorAbiProfile) { + payload->append("{\"minor\":"); + payload->append(std::to_string(state.priorAbiProfile.minor)); + payload->append(",\"capabilities\":"); + payload->append(std::to_string( + state.priorAbiProfile.capabilities)); + payload->append(",\"statsSize\":"); + payload->append(std::to_string(state.priorAbiProfile.statsSize)); + payload->append(",\"hasReservedPortFields\":"); + payload->append(state.priorAbiProfile.hasReservedPortFields + ? "true}" : "false}"); + } else { + payload->append("null"); + } + payload->append(",\"priorPackages\":["); + for (size_t index = 0; index < state.prior.packages.size(); ++index) { + if (index != 0) payload->push_back(','); + AppendPackageIdentityJson(payload, state.prior.packages[index], + std::wstring(kRemoveRecoveryPriorDirectory) + L"/" + + std::to_wstring(index) + L"/ViiperUde.inf"); + } + payload->append("],\"priorDevices\":["); + for (size_t index = 0; index < state.prior.devices.size(); ++index) { + if (index != 0) payload->push_back(','); + const DeviceState& device = state.prior.devices[index]; + payload->append("{\"instanceId\":"); + AppendJsonString(payload, device.instanceId); + payload->append(",\"present\":"); + payload->append(device.present ? "true" : "false"); + payload->append(",\"started\":"); + payload->append(device.started ? "true" : "false"); + payload->append(",\"problem\":"); + payload->append(std::to_string(device.problem)); + payload->append(",\"service\":"); + AppendJsonString(payload, device.service); + payload->append(",\"publishedInf\":"); + AppendJsonString(payload, device.publishedInf); + payload->append(",\"version\":"); + AppendJsonString(payload, VersionToString(device.version)); + payload->append(",\"packageInfSha256\":"); + AppendJsonAsciiString(payload, + LowerAscii(device.package.infSha256)); + payload->append(",\"packageSysSha256\":"); + AppendJsonAsciiString(payload, + LowerAscii(device.package.sysSha256)); + payload->append(",\"packageCatSha256\":"); + AppendJsonAsciiString(payload, + LowerAscii(device.package.catSha256)); + payload->push_back('}'); + } + payload->push_back(']'); +} + +bool BuildRemoveJournalPayload( + const RemoveJournalStateData& state, + std::string* payload, + Error* error) { + if (!ValidateRemoveJournalStateShape(state, error)) return false; + payload->clear(); + payload->append("{\"sequence\":"); + payload->append(std::to_string(state.sequence)); + payload->append(",\"previousSha256\":"); + AppendJsonAsciiString(payload, LowerAscii(state.previousDigest)); + payload->append(",\"phase\":"); + AppendJsonAsciiString(payload, RemoveJournalPhaseName(state.phase)); + payload->append(",\"direction\":"); + AppendJsonAsciiString(payload, + RemoveJournalDirectionName(state.direction)); + payload->append(",\"transactionId\":"); + AppendJsonAsciiString(payload, state.transactionId); + payload->append(",\"bootIdentifier\":"); + AppendJsonAsciiString(payload, state.bootIdentifier); + payload->append(",\"pendingRebootBootIdentifier\":"); + if (state.pendingRebootBootIdentifier.empty()) { + payload->append("null"); + } else { + AppendJsonAsciiString(payload, + state.pendingRebootBootIdentifier); + } + payload->append(",\"packageCursor\":"); + payload->append(std::to_string(state.packageCursor)); + payload->append(",\"activePackageIndex\":"); + if (state.activePackageIndex == UINT32_MAX) { + payload->append("null"); + } else { + payload->append(std::to_string(state.activePackageIndex)); + } + payload->append(",\"deviceMutationEntered\":"); + payload->append(state.deviceMutationEntered ? "true" : "false"); + payload->append(",\"bindingMutationEntered\":"); + payload->append(state.bindingMutationEntered ? "true" : "false"); + payload->append(",\"rebootRequired\":"); + payload->append(state.rebootRequired ? "true" : "false"); + payload->append(",\"freshRebootRequired\":"); + payload->append(state.freshRebootRequired ? "true" : "false"); + payload->append(",\"callSucceeded\":"); + payload->append(state.callSucceeded ? "true" : "false"); + payload->append(",\"callError\":"); + payload->append(std::to_string(state.callError)); + payload->append(",\"deadlineOverrun\":"); + payload->append(state.deadlineOverrun ? "true" : "false"); + AppendRemoveJournalSnapshot(payload, state); + payload->push_back('}'); + if (payload->size() > kMaximumRecoveryRecordBytes) { + return SetError(error, L"remove-journal-size", + ERROR_FILE_TOO_LARGE); + } + return true; +} + +bool WriteRemoveJournalRecord( + const std::filesystem::path& active, + RemoveJournalStateData* state, + Error* error) { + std::string payload; + std::string digest; + if (!BuildRemoveJournalPayload(*state, &payload, error) || + !Sha256Data(payload, &digest, error)) { + return false; + } + std::string record = "{\"schema\":2,\"kind\":"; + AppendJsonAsciiString(&record, kRemoveRecoveryKind); + record.append(",\"payloadSha256\":"); + AppendJsonAsciiString(&record, digest); + record.append(",\"payload\":"); + AppendJsonUtf8String(&record, payload); + record.append("}\n"); + if (record.size() > kMaximumRecoveryRecordBytes) { + return SetError(error, L"remove-journal-size", + ERROR_FILE_TOO_LARGE); + } + std::wostringstream finalName; + finalName << kInstallRecoveryJournalPrefix << std::setw(8) + << std::setfill(L'0') << state->sequence + << kInstallRecoveryJournalSuffix; + const std::filesystem::path finalPath = active / finalName.str(); + const std::filesystem::path temporaryPath = active / + (finalName.str() + kInstallRecoveryTemporarySuffix); + LocalSecurityDescriptor security; + if (!security.Initialize(kRecoveryRecordSecurity, + L"remove-journal-file-security", error)) { + return false; + } + WinHandle file(CreateFileW(temporaryPath.c_str(), + GENERIC_READ | GENERIC_WRITE | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, security.attributes(), CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_WRITE_THROUGH, + nullptr)); + if (!file) return SetLastErrorDetail(error, L"remove-journal-create"); + const auto discard = [&]() noexcept { + file.reset(); + DeleteFileW(temporaryPath.c_str()); + }; + FILE_ATTRIBUTE_TAG_INFO attributes{}; + if (!GetFileInformationByHandleEx(file.get(), FileAttributeTagInfo, + &attributes, sizeof(attributes)) || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0 || + !VerifyProtectedFileSystemSecurity(file.get(), false, + L"remove-journal-file-security", error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"remove-journal-create", + ERROR_REPARSE_TAG_MISMATCH); + } + discard(); + return false; + } + size_t offset = 0; + while (offset < record.size()) { + DWORD written = 0; + const DWORD requested = static_cast(std::min( + record.size() - offset, MAXDWORD)); + if (!WriteFile(file.get(), record.data() + offset, requested, + &written, nullptr) || written == 0) { + const DWORD code = GetLastError() == ERROR_SUCCESS + ? ERROR_WRITE_FAULT : GetLastError(); + SetError(error, L"remove-journal-write", code); + discard(); + return false; + } + offset += written; + } + if (!FlushFileBuffers(file.get())) { + SetLastErrorDetail(error, L"remove-journal-flush"); + discard(); + return false; + } + file.reset(); + if (!MoveFileExW(temporaryPath.c_str(), finalPath.c_str(), + MOVEFILE_WRITE_THROUGH)) { + const DWORD code = GetLastError(); + DeleteFileW(temporaryPath.c_str()); + return SetError(error, L"remove-journal-publish", code); + } + file.reset(CreateFileW(finalPath.c_str(), + GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_SEQUENTIAL_SCAN, + nullptr)); + if (!file || !VerifyProtectedFileSystemSecurity(file.get(), false, + L"remove-journal-file-security", error)) { + if (!file) SetLastErrorDetail(error, L"remove-journal-reopen"); + return false; + } + std::string observed(record.size(), '\0'); + DWORD read = 0; + if (!ReadFile(file.get(), observed.data(), + static_cast(observed.size()), &read, nullptr) || + read != observed.size() || observed != record) { + return SetError(error, L"remove-journal-readback", ERROR_CRC, + L"published remove record differs from flushed bytes"); + } + char trailing = 0; + DWORD trailingRead = 0; + if (!ReadFile(file.get(), &trailing, 1, &trailingRead, nullptr) || + trailingRead != 0) { + return SetError(error, L"remove-journal-readback", + ERROR_FILE_INVALID); + } + state->lastDigest = digest; + state->previousDigest = digest; + ++state->sequence; + gActiveRecoveryRecordWritten = true; + return true; +} + +struct LoadedRemoveJournal { + RemoveRecoveryDirectory directory; + RemoveJournalStateData state; + std::vector priorBackups; + std::vector evidenceLocks; + bool hasRecord = false; + bool poisoned = false; +}; + +bool RetireLoadedRemoveJournal( + LoadedRemoveJournal* loaded, + Error* error, + RemoveRetirementTestFault testFault = + RemoveRetirementTestFault::None) { + if (loaded == nullptr || !loaded->hasRecord || loaded->poisoned || + loaded->state.sequence == 0U || + !IsSha256Digest(loaded->state.transactionId) || + !IsSha256Digest(loaded->state.lastDigest) || + _stricmp(loaded->state.lastDigest.c_str(), + loaded->state.previousDigest.c_str()) != 0 || + loaded->state.rebootRequired || + loaded->state.freshRebootRequired || + !loaded->state.pendingRebootBootIdentifier.empty() || + !loaded->state.callSucceeded || + !((loaded->state.phase == RemoveJournalPhase::ForwardValidated && + loaded->state.direction == RemoveJournalDirection::Forward) || + (loaded->state.phase == RemoveJournalPhase::ExactPriorRestored && + loaded->state.direction == + RemoveJournalDirection::Rollback))) { + return SetError(error, L"remove-journal-retire-state", + ERROR_INVALID_STATE, + L"only a durable terminal remove record may release protected evidence locks"); + } + if (!ValidateRemoveJournalStateShape(loaded->state, error)) { + return false; + } + + const std::string transactionId = loaded->state.transactionId; + loaded->priorBackups.clear(); + loaded->evidenceLocks.clear(); + return RetireRemoveRecoveryActiveDirectory( + &loaded->directory, transactionId, error, testFault); +} + +bool AppendRemoveJournalRecord( + LoadedRemoveJournal* loaded, + RemoveJournalStateData next, + Error* error); + +bool ParseRemoveJournalPayload( + std::string_view payload, + const std::filesystem::path& active, + RemoveJournalStateData* state, + Error* error) { + JsonValue root; + std::string parseMessage; + if (!JsonParser(payload).Parse(&root, &parseMessage)) { + std::wstring message; + Utf8ToWide(parseMessage, &message, nullptr); + return SetError(error, L"remove-journal-parse", + ERROR_INVALID_DATA, + L"remove payload is malformed: " + message); + } + const JsonValue::Object* object = nullptr; + uint64_t sequence = 0; + uint64_t packageCursor = 0; + uint64_t callError = 0; + std::string previous; + std::string phase; + std::string direction; + if (!RequireJournalObject(root, &object, error) || + !RequireJournalUnsigned(*object, "sequence", + kMaximumRemoveRecoveryRecords - 1U, &sequence, error) || + !RequireJournalString(*object, "previousSha256", + &previous, error) || + !RequireJournalString(*object, "phase", &phase, error) || + !RequireJournalString(*object, "direction", &direction, error) || + !RequireJournalString(*object, "transactionId", + &state->transactionId, error) || + !RequireJournalString(*object, "bootIdentifier", + &state->bootIdentifier, error) || + !RequireJournalUnsigned(*object, "packageCursor", UINT32_MAX, + &packageCursor, error) || + !RequireJournalBool(*object, "deviceMutationEntered", + &state->deviceMutationEntered, error) || + !RequireJournalBool(*object, "bindingMutationEntered", + &state->bindingMutationEntered, error) || + !RequireJournalBool(*object, "rebootRequired", + &state->rebootRequired, error) || + !RequireJournalBool(*object, "freshRebootRequired", + &state->freshRebootRequired, error) || + !RequireJournalBool(*object, "callSucceeded", + &state->callSucceeded, error) || + !RequireJournalUnsigned(*object, "callError", MAXDWORD, + &callError, error) || + !RequireJournalBool(*object, "deadlineOverrun", + &state->deadlineOverrun, error)) { + return false; + } + const auto parsedPhase = ParseRemoveJournalPhase(phase); + const auto parsedDirection = ParseRemoveJournalDirection(direction); + if (!parsedPhase || !parsedDirection || + !IsSha256Digest(previous)) { + return SetError(error, L"remove-journal-state", + ERROR_INVALID_DATA); + } + state->phase = *parsedPhase; + state->direction = *parsedDirection; + state->sequence = sequence; + state->previousDigest = LowerAscii(std::move(previous)); + state->packageCursor = static_cast(packageCursor); + state->callError = static_cast(callError); + + const JsonValue* pendingNode = ObjectField( + *object, "pendingRebootBootIdentifier"); + if (pendingNode == nullptr) { + return SetError(error, L"remove-journal-reboot-epoch", + ERROR_INVALID_DATA); + } + if (std::holds_alternative(pendingNode->value)) { + state->pendingRebootBootIdentifier.clear(); + } else { + const auto* value = std::get_if( + &pendingNode->value); + if (value == nullptr || !IsCanonicalBootIdentifier(*value)) { + return SetError(error, L"remove-journal-reboot-epoch", + ERROR_INVALID_DATA); + } + state->pendingRebootBootIdentifier = *value; + } + const JsonValue* activeIndexNode = ObjectField( + *object, "activePackageIndex"); + if (activeIndexNode == nullptr) { + return SetError(error, L"remove-journal-package-index", + ERROR_INVALID_DATA); + } + if (std::holds_alternative( + activeIndexNode->value)) { + state->activePackageIndex = UINT32_MAX; + } else { + const int64_t* value = std::get_if( + &activeIndexNode->value); + if (value == nullptr || *value < 0 || + static_cast(*value) > UINT32_MAX) { + return SetError(error, L"remove-journal-package-index", + ERROR_INVALID_DATA); + } + state->activePackageIndex = static_cast(*value); + } + + const JsonValue* profileNode = ObjectField( + *object, "priorAbiProfile"); + if (profileNode == nullptr) { + return SetError(error, L"remove-journal-prior-abi-profile", + ERROR_INVALID_DATA); + } + if (std::holds_alternative(profileNode->value)) { + state->hasPriorAbiProfile = false; + } else { + const JsonValue::Object* profile = nullptr; + uint64_t minor = 0; + uint64_t capabilities = 0; + uint64_t statsSize = 0; + if (!RequireJournalObject(*profileNode, &profile, error) || + profile->size() != 4U || + !RequireJournalUnsigned(*profile, "minor", UINT16_MAX, + &minor, error) || + !RequireJournalUnsigned(*profile, "capabilities", UINT32_MAX, + &capabilities, error) || + !RequireJournalUnsigned(*profile, "statsSize", MAXDWORD, + &statsSize, error) || + !RequireJournalBool(*profile, "hasReservedPortFields", + &state->priorAbiProfile.hasReservedPortFields, error)) { + return false; + } + state->priorAbiProfile.minor = + static_cast(minor); + state->priorAbiProfile.capabilities = + static_cast(capabilities); + state->priorAbiProfile.statsSize = + static_cast(statsSize); + state->hasPriorAbiProfile = true; + } + + const JsonValue::Array* packages = nullptr; + const JsonValue::Array* devices = nullptr; + if (!RequireJournalArray(*object, "priorPackages", &packages, error) || + !RequireJournalArray(*object, "priorDevices", &devices, error) || + packages->size() > 32U || devices->size() > 1U) { + return SetError(error, L"remove-journal-prior", + ERROR_INVALID_DATA); + } + state->prior.packages.clear(); + for (size_t index = 0; index < packages->size(); ++index) { + PackageInfo package; + std::filesystem::path backupInf; + const std::filesystem::path expected = active / + kRemoveRecoveryPriorDirectory / std::to_wstring(index) / + L"ViiperUde.inf"; + if (!ParseJournalPackageIdentity((*packages)[index], active, + true, &package, &backupInf, error) || + backupInf != expected) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"remove-journal-prior-package-path", + ERROR_INVALID_NAME); + } + return false; + } + state->prior.packages.push_back(std::move(package)); + } + state->prior.devices.clear(); + for (const JsonValue& value : *devices) { + const JsonValue::Object* deviceObject = nullptr; + std::string instanceId; + std::string service; + std::string publishedInf; + std::string version; + std::string infSha; + std::string sysSha; + std::string catSha; + uint64_t problem = 0; + DeviceState device; + if (!RequireJournalObject(value, &deviceObject, error) || + !RequireJournalString(*deviceObject, "instanceId", + &instanceId, error) || + !RequireJournalBool(*deviceObject, "present", + &device.present, error) || + !RequireJournalBool(*deviceObject, "started", + &device.started, error) || + !RequireJournalUnsigned(*deviceObject, "problem", MAXDWORD, + &problem, error) || + !RequireJournalString(*deviceObject, "service", + &service, error) || + !RequireJournalString(*deviceObject, "publishedInf", + &publishedInf, error) || + !RequireJournalString(*deviceObject, "version", + &version, error) || + !RequireJournalString(*deviceObject, "packageInfSha256", + &infSha, error) || + !RequireJournalString(*deviceObject, "packageSysSha256", + &sysSha, error) || + !RequireJournalString(*deviceObject, "packageCatSha256", + &catSha, error) || + !Utf8ToWide(instanceId, &device.instanceId, error) || + !Utf8ToWide(service, &device.service, error) || + !Utf8ToWide(publishedInf, &device.publishedInf, error)) { + return false; + } + std::wstring wideVersion; + if (!Utf8ToWide(version, &wideVersion, error) || + !ParseVersion(wideVersion, &device.version) || + !IsSha256Digest(infSha) || !IsSha256Digest(sysSha) || + !IsSha256Digest(catSha)) { + return SetError(error, L"remove-journal-prior-device", + ERROR_INVALID_DATA); + } + device.problem = static_cast(problem); + size_t matches = 0; + for (const PackageInfo& package : state->prior.packages) { + if (_wcsicmp(package.publishedName.c_str(), + device.publishedInf.c_str()) == 0 && + package.version == device.version && + _stricmp(package.infSha256.c_str(), infSha.c_str()) == 0 && + _stricmp(package.sysSha256.c_str(), sysSha.c_str()) == 0 && + _stricmp(package.catSha256.c_str(), catSha.c_str()) == 0) { + device.package = package; + ++matches; + } + } + if (matches != 1U) { + return SetError(error, + L"remove-journal-prior-device-package", + ERROR_REVISION_MISMATCH); + } + state->prior.devices.push_back(std::move(device)); + } + std::string canonical; + if (!BuildRemoveJournalPayload(*state, &canonical, error) || + canonical != payload) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"remove-journal-canonical-payload", + ERROR_INVALID_DATA); + } + return false; + } + return true; +} + +bool ParseRemoveJournalEnvelope( + std::string_view record, + const std::filesystem::path& active, + RemoveJournalStateData* state, + std::string* digest, + Error* error) { + JsonValue root; + std::string message; + if (!JsonParser(record).Parse(&root, &message)) { + return SetError(error, L"remove-journal-chain", + ERROR_INVALID_DATA, + L"remove journal envelope is truncated or malformed"); + } + const JsonValue::Object* object = nullptr; + uint64_t schema = 0; + std::string kind; + std::string payloadDigest; + std::string payload; + if (!RequireJournalObject(root, &object, error) || + object->size() != 4U || + !RequireJournalUnsigned(*object, "schema", 2U, + &schema, error) || schema != 2U || + !RequireJournalString(*object, "kind", &kind, error) || + kind != kRemoveRecoveryKind || + !RequireJournalString(*object, "payloadSha256", + &payloadDigest, error) || + !RequireJournalString(*object, "payload", &payload, error) || + !IsSha256Digest(payloadDigest)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"remove-journal-chain", + ERROR_INVALID_DATA); + } + return false; + } + std::string canonical = "{\"schema\":2,\"kind\":"; + AppendJsonAsciiString(&canonical, kRemoveRecoveryKind); + canonical.append(",\"payloadSha256\":"); + AppendJsonAsciiString(&canonical, LowerAscii(payloadDigest)); + canonical.append(",\"payload\":"); + AppendJsonUtf8String(&canonical, payload); + canonical.append("}\n"); + std::string observed; + if (record != canonical || + !Sha256Data(payload, &observed, error) || + _stricmp(observed.c_str(), payloadDigest.c_str()) != 0 || + !ParseRemoveJournalPayload(payload, active, state, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"remove-journal-chain", ERROR_CRC); + } + return false; + } + *digest = LowerAscii(std::move(payloadDigest)); + return true; +} + +bool SameRemoveJournalImmutableState( + const RemoveJournalStateData& left, + const RemoveJournalStateData& right) noexcept { + if (left.transactionId != right.transactionId || + left.bootIdentifier != right.bootIdentifier || + left.hasPriorAbiProfile != right.hasPriorAbiProfile || + (left.hasPriorAbiProfile && + !SameAbiCompatibilityProfile(left.priorAbiProfile, + right.priorAbiProfile)) || + !SamePackageInventory(left.prior.packages, + right.prior.packages) || + left.prior.devices.size() != right.prior.devices.size()) { + return false; + } + return left.prior.devices.empty() || + (SameRootBinding(left.prior.devices[0], right.prior.devices[0]) && + left.prior.devices[0].started == + right.prior.devices[0].started && + left.prior.devices[0].problem == + right.prior.devices[0].problem); +} + +bool LegalRemoveJournalTransition( + RemoveJournalPhase previous, + RemoveJournalPhase next, + RemoveJournalDirection direction) noexcept { + if (next == RemoveJournalPhase::ManualReconciliationRequired) { + return true; + } + if (direction == RemoveJournalDirection::Forward) { + switch (previous) { + case RemoveJournalPhase::Prepared: + return next == RemoveJournalPhase::DeviceRemovalEntered || + next == RemoveJournalPhase::PackageRemovalEntered || + next == RemoveJournalPhase::ForwardValidated; + case RemoveJournalPhase::DeviceRemovalEntered: + return next == RemoveJournalPhase::DeviceRemovalReturned || + next == RemoveJournalPhase::DeviceRemovalCommitted || + next == RemoveJournalPhase::ForwardRebootPending; + case RemoveJournalPhase::DeviceRemovalReturned: + return next == RemoveJournalPhase::DeviceRemovalCommitted || + next == RemoveJournalPhase::ForwardRebootPending; + case RemoveJournalPhase::DeviceRemovalCommitted: + return next == RemoveJournalPhase::PackageRemovalEntered || + next == RemoveJournalPhase::ForwardValidated; + case RemoveJournalPhase::PackageRemovalEntered: + return next == RemoveJournalPhase::PackageRemovalReturned || + next == RemoveJournalPhase::PackageRemovalCommitted || + next == RemoveJournalPhase::ForwardRebootPending; + case RemoveJournalPhase::PackageRemovalReturned: + return next == RemoveJournalPhase::PackageRemovalCommitted || + next == RemoveJournalPhase::ForwardRebootPending; + case RemoveJournalPhase::PackageRemovalCommitted: + return next == RemoveJournalPhase::PackageRemovalEntered || + next == RemoveJournalPhase::ForwardValidated; + case RemoveJournalPhase::ForwardRebootPending: + return next == RemoveJournalPhase::DeviceRemovalEntered || + next == RemoveJournalPhase::DeviceRemovalCommitted || + next == RemoveJournalPhase::PackageRemovalCommitted || + next == RemoveJournalPhase::PackageRemovalEntered || + next == RemoveJournalPhase::ForwardValidated; + default: + return false; + } + } + switch (previous) { + case RemoveJournalPhase::RollbackAdmitted: + return next == RemoveJournalPhase::RestoreRebootPending || + next == RemoveJournalPhase::RollbackPackageEntered || + next == RemoveJournalPhase::RollbackBindingEntered || + next == RemoveJournalPhase::ExactPriorRestored; + case RemoveJournalPhase::RollbackPackageCommitted: + case RemoveJournalPhase::RestoreRebootPending: + return next == RemoveJournalPhase::RollbackPackageEntered || + next == RemoveJournalPhase::RollbackBindingEntered || + next == RemoveJournalPhase::ExactPriorRestored; + case RemoveJournalPhase::RollbackPackageEntered: + return next == RemoveJournalPhase::RollbackPackageReturned || + next == RemoveJournalPhase::RollbackPackageCommitted; + case RemoveJournalPhase::RollbackPackageReturned: + return next == RemoveJournalPhase::RollbackPackageCommitted || + next == RemoveJournalPhase::RestoreRebootPending; + case RemoveJournalPhase::RollbackBindingEntered: + return next == RemoveJournalPhase::RollbackBindingReturned || + next == RemoveJournalPhase::ExactPriorRestored; + case RemoveJournalPhase::RollbackBindingReturned: + return next == RemoveJournalPhase::ExactPriorRestored || + next == RemoveJournalPhase::RestoreRebootPending; + default: + return false; + } +} + +bool ValidateRemoveJournalTransition( + const RemoveJournalStateData* previous, + const RemoveJournalStateData& next, + Error* error) { + if (!ValidateRemoveJournalStateShape(next, error)) return false; + if (previous == nullptr) { + return (next.phase == RemoveJournalPhase::Prepared && + next.direction == RemoveJournalDirection::Forward && + next.sequence == 0U && + next.previousDigest == kZeroSha256 && + !next.deviceMutationEntered && + !next.bindingMutationEntered && + next.packageCursor == 0U) || + SetError(error, L"remove-journal-initial-state", + ERROR_INVALID_DATA); + } + const RemoveJournalStateData& prior = *previous; + const bool packageCursorAdvanced = + next.packageCursor != prior.packageCursor; + if (!SameRemoveJournalImmutableState(prior, next) || + next.packageCursor < prior.packageCursor || + (packageCursorAdvanced && + (next.phase != RemoveJournalPhase::PackageRemovalCommitted || + next.packageCursor != prior.packageCursor + 1U)) || + (prior.deviceMutationEntered && !next.deviceMutationEntered) || + (prior.bindingMutationEntered && !next.bindingMutationEntered) || + (!prior.deviceMutationEntered && next.deviceMutationEntered && + next.phase != RemoveJournalPhase::DeviceRemovalEntered) || + (!prior.bindingMutationEntered && next.bindingMutationEntered && + next.phase != RemoveJournalPhase::RollbackBindingEntered) || + (prior.direction == RemoveJournalDirection::Rollback && + next.direction != RemoveJournalDirection::Rollback)) { + return SetError(error, L"remove-journal-transition", + ERROR_INVALID_DATA, + L"remove journal immutable or sticky authority changed"); + } + const bool consumesCrossedRebootEpoch = + !prior.pendingRebootBootIdentifier.empty() && + prior.rebootRequired && !next.rebootRequired && + next.pendingRebootBootIdentifier.empty() && + (prior.phase == RemoveJournalPhase::ForwardRebootPending || + prior.phase == RemoveJournalPhase::RestoreRebootPending || + (prior.phase == RemoveJournalPhase::RollbackAdmitted && + prior.direction == RemoveJournalDirection::Rollback) || + RemoveJournalPhaseIsAuthoritativeReturn(prior.phase)); + if (!prior.pendingRebootBootIdentifier.empty() && + next.pendingRebootBootIdentifier != + prior.pendingRebootBootIdentifier && + !next.freshRebootRequired && !consumesCrossedRebootEpoch) { + return SetError(error, L"remove-journal-reboot-epoch-chain", + ERROR_INVALID_DATA); + } + if (prior.direction == RemoveJournalDirection::Forward && + next.direction == RemoveJournalDirection::Rollback) { + const bool directPendingAdmission = + next.phase == RemoveJournalPhase::RestoreRebootPending && + prior.rebootRequired && next.rebootRequired && + !prior.pendingRebootBootIdentifier.empty() && + next.pendingRebootBootIdentifier == + prior.pendingRebootBootIdentifier && + !next.freshRebootRequired; + return ((next.phase == RemoveJournalPhase::RollbackAdmitted || + directPendingAdmission) && + next.packageCursor == prior.packageCursor) || + SetError(error, L"remove-journal-direction-chain", + ERROR_INVALID_DATA); + } + if (!LegalRemoveJournalTransition( + prior.phase, next.phase, next.direction)) { + return SetError(error, L"remove-journal-phase-chain", + ERROR_INVALID_DATA); + } + if (next.phase == RemoveJournalPhase::PackageRemovalEntered && + (next.activePackageIndex != next.packageCursor || + next.packageCursor != prior.packageCursor)) { + return SetError(error, L"remove-journal-package-chain", + ERROR_INVALID_DATA); + } + if (next.phase == RemoveJournalPhase::PackageRemovalCommitted && + next.packageCursor != prior.packageCursor + 1U) { + return SetError(error, L"remove-journal-package-chain", + ERROR_INVALID_DATA); + } + if ((next.phase == RemoveJournalPhase::PackageRemovalReturned || + next.phase == RemoveJournalPhase::RollbackPackageReturned) && + (prior.activePackageIndex == UINT32_MAX || + next.activePackageIndex != prior.activePackageIndex)) { + return SetError(error, L"remove-journal-package-return-chain", + ERROR_INVALID_DATA); + } + if (next.phase == RemoveJournalPhase::RollbackPackageEntered && + next.activePackageIndex == UINT32_MAX) { + return SetError(error, L"remove-journal-package-admission-chain", + ERROR_INVALID_DATA); + } + return true; +} + +bool ValidateLoadedRemoveJournalEvidence( + LoadedRemoveJournal* loaded, + Error* error) { + WinHandle priorHandle; + const std::filesystem::path priorRoot = + loaded->directory.active / kRemoveRecoveryPriorDirectory; + if (!OpenStableDirectory( + priorRoot, true, &priorHandle, error)) { + return false; + } + loaded->evidenceLocks.push_back(std::move(priorHandle)); + loaded->priorBackups.clear(); + for (size_t index = 0; + index < loaded->state.prior.packages.size(); ++index) { + PackageInfo& expected = loaded->state.prior.packages[index]; + const std::filesystem::path directory = + priorRoot / std::to_wstring(index); + WinHandle directoryHandle; + PackageInfo copy; + bool owned = false; + if (!OpenStableDirectory(directory, true, + &directoryHandle, error) || + !ValidateExactPackageDirectory(directory, error) || + !LoadOwnedPackage(directory / L"ViiperUde.inf", true, + false, ©, &owned, error) || !owned || + !(copy.version == expected.version) || + !SamePackageBytes(copy, expected)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"remove-journal-prior-evidence", + ERROR_REVISION_MISMATCH); + } + return false; + } + loaded->evidenceLocks.push_back(std::move(directoryHandle)); + std::vector locks; + if (!LockPackageFiles(directory, &locks, error)) return false; + for (WinHandle& lock : locks) { + loaded->evidenceLocks.push_back(std::move(lock)); + } + expected.infPath = directory / L"ViiperUde.inf"; + loaded->priorBackups.push_back(PackageBackup{ + expected, directory, expected.infPath, {}}); + } + for (DeviceState& device : loaded->state.prior.devices) { + for (const PackageInfo& package : + loaded->state.prior.packages) { + if (_wcsicmp(package.publishedName.c_str(), + device.publishedInf.c_str()) == 0) { + device.package = package; + } + } + } + return true; +} + +bool LoadRemoveJournal( + RemoveRecoveryDirectory&& directory, + LoadedRemoveJournal* loaded, + Error* error) { + loaded->directory = std::move(directory); + std::map records; + std::optional> temporary; + std::error_code enumerationError; + for (std::filesystem::directory_iterator iterator( + loaded->directory.active, enumerationError), end; + !enumerationError && iterator != end; + iterator.increment(enumerationError)) { + const std::wstring name = iterator->path().filename().wstring(); + const DWORD attributes = GetFileAttributesW( + iterator->path().c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES || + (attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + return SetError(error, L"remove-journal-discovery", + ERROR_REPARSE_TAG_MISMATCH); + } + if ((attributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + name == kRemoveRecoveryPriorDirectory) { + continue; + } + uint64_t sequence = 0; + if ((attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 && + ParseJournalRecordFileName(name, &sequence)) { + if (sequence >= kMaximumRemoveRecoveryRecords || + !records.emplace(sequence, iterator->path()).second) { + return SetError(error, L"remove-journal-chain", + ERROR_DUPLICATE_SERVICE_NAME); + } + continue; + } + if ((attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 && + ParseJournalTemporaryFileName(name, &sequence)) { + if (sequence >= kMaximumRemoveRecoveryRecords || temporary) { + return SetError(error, L"remove-journal-temp-chain", + ERROR_INVALID_DATA); + } + temporary.emplace(sequence, iterator->path()); + continue; + } + return SetError(error, L"remove-journal-discovery", + ERROR_INVALID_DATA, + L"protected remove transaction has an unexpected entry"); + } + if (enumerationError) { + return SetError(error, L"remove-journal-discovery", + static_cast(enumerationError.value())); + } + if ((!records.empty() && + (records.begin()->first != 0U || + records.rbegin()->first + 1U != records.size())) || + (temporary && temporary->first != records.size())) { + return SetError(error, L"remove-journal-chain", + ERROR_INVALID_DATA, + L"remove journal sequence is absent or non-contiguous"); + } + if (temporary && + !ValidateAndDiscardInstallJournalTemporaryFile( + temporary->second, error)) { + return false; + } + if (records.empty()) { + loaded->hasRecord = false; + return true; + } + std::string priorDigest(kZeroSha256); + std::optional immutable; + std::optional previousState; + for (const auto& [expectedSequence, path] : records) { + std::string record; + std::string digest; + RemoveJournalStateData parsed; + if (!ReadInstallJournalFile(path, &record, error) || + !ParseRemoveJournalEnvelope(record, + loaded->directory.active, &parsed, &digest, error) || + parsed.sequence != expectedSequence || + _stricmp(parsed.previousDigest.c_str(), + priorDigest.c_str()) != 0 || + (immutable && !SameRemoveJournalImmutableState( + *immutable, parsed)) || + !ValidateRemoveJournalTransition( + previousState ? &*previousState : nullptr, + parsed, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"remove-journal-chain", ERROR_CRC); + } + return false; + } + if (!immutable) immutable = parsed; + previousState = parsed; + priorDigest = digest; + loaded->state = std::move(parsed); + loaded->state.lastDigest = digest; + } + loaded->state.previousDigest = priorDigest; + loaded->state.sequence = records.size(); + loaded->hasRecord = true; + return ValidateLoadedRemoveJournalEvidence(loaded, error); +} + +bool AppendRemoveJournalRecord( + LoadedRemoveJournal* loaded, + RemoveJournalStateData next, + Error* error) { + if (loaded == nullptr || !loaded->hasRecord || loaded->poisoned) { + return SetError(error, L"remove-journal-record", + ERROR_INVALID_STATE); + } + next.sequence = loaded->state.sequence; + next.previousDigest = loaded->state.previousDigest; + next.lastDigest = loaded->state.lastDigest; + if (!ValidateRemoveJournalTransition( + &loaded->state, next, error)) { + return false; + } + if (!WriteRemoveJournalRecord( + loaded->directory.active, &next, error)) { + loaded->poisoned = true; + return false; + } + loaded->state = std::move(next); + if (!PublishRemoveRecoveryEvidence( + loaded->directory.active, + loaded->state.sequence - 1U, error)) { + loaded->poisoned = true; + return false; + } + return true; +} + +bool PrepareRemoveJournal( + const Snapshot& capturedPrior, + const AbiCompatibilityProfile* priorAbiProfile, + LoadedRemoveJournal* loaded, + Error* error) { + bool exists = false; + if (!loaded->directory.OpenChain(true, &exists, error) || !exists || + !PublishRemoveRecoveryEvidence( + loaded->directory.active, 0U, error)) { + return false; + } + const std::filesystem::path priorRoot = + loaded->directory.active / kRemoveRecoveryPriorDirectory; + WinHandle priorHandle; + bool created = false; + if (!CreateOrOpenInstallRecoveryDirectory( + priorRoot, false, true, &priorHandle, &created, error) || + !created || + !BackupPackagesIntoDirectory(capturedPrior.packages, + priorRoot, &loaded->priorBackups, error)) { + return false; + } + loaded->evidenceLocks.push_back(std::move(priorHandle)); + loaded->state = RemoveJournalStateData{}; + loaded->state.prior = capturedPrior; + for (size_t index = 0; + index < loaded->state.prior.packages.size(); ++index) { + loaded->state.prior.packages[index].infPath = + loaded->priorBackups[index].infPath; + } + for (DeviceState& device : loaded->state.prior.devices) { + for (const PackageInfo& package : + loaded->state.prior.packages) { + if (_wcsicmp(package.publishedName.c_str(), + device.publishedInf.c_str()) == 0) { + device.package = package; + } + } + } + if (priorAbiProfile != nullptr) { + loaded->state.hasPriorAbiProfile = true; + loaded->state.priorAbiProfile = *priorAbiProfile; + } + if (!GenerateInstallTransactionId( + &loaded->state.transactionId, error) || + !GetBootIdentifier(&loaded->state.bootIdentifier, error) || + !ValidateRemoveJournalTransition(nullptr, + loaded->state, error) || + !WriteRemoveJournalRecord(loaded->directory.active, + &loaded->state, error)) { + loaded->poisoned = gActiveRecoveryRecordWritten; + return false; + } + loaded->hasRecord = true; + return PublishRemoveRecoveryEvidence( + loaded->directory.active, + loaded->state.sequence - 1U, error); +} + +enum class RemoveRootShape { + ExactPrior, + Absent, + PendingRemoval, + Manual, +}; + +bool CrossedRemoveRebootStillPendingRequiresManual( + RemoveJournalPhase phase, + bool callSucceeded, + bool rebootRequired, + bool freshRebootRequired, + bool samePendingBoot, + RemoveRootShape root) noexcept { + const bool crossedAuthoritativeRebootBoundary = + phase == RemoveJournalPhase::ForwardRebootPending || + (phase == RemoveJournalPhase::DeviceRemovalReturned && + callSucceeded && freshRebootRequired); + return root == RemoveRootShape::PendingRemoval && + crossedAuthoritativeRebootBoundary && + rebootRequired && !samePendingBoot; +} + +bool ReusesInterruptedRemoveBindingAdmission( + RemoveJournalPhase phase) noexcept { + return phase == RemoveJournalPhase::RollbackBindingEntered; +} + +bool ObserveRemoveRootShape( + const RemoveJournalStateData& state, + RemoveRootShape* shape, + Error* error) { + *shape = RemoveRootShape::Manual; + DeviceInfoSet set = OpenRootDevices(); + if (!set) { + return SetLastErrorDetail(error, + L"remove-journal-raw-root-open"); + } + struct RelatedRoot { + SP_DEVINFO_DATA data{}; + std::wstring instanceId; + InstallRecoveryHardwareIdObservation hardwareIds; + }; + std::vector related; + for (DWORD index = 0;; ++index) { + SP_DEVINFO_DATA data{}; + data.cbSize = sizeof(data); + if (!SetupDiEnumDeviceInfo(set.get(), index, &data)) { + if (GetLastError() != ERROR_NO_MORE_ITEMS) { + return SetLastErrorDetail(error, + L"remove-journal-raw-root-enumeration"); + } + break; + } + std::wstring instanceId; + InstallRecoveryHardwareIdObservation hardwareIds; + if (!ReadInstallRecoveryRootInstanceId( + set.get(), data, &instanceId, error) || + !ReadInstallRecoveryHardwareIds( + set.get(), data, &hardwareIds, error)) { + return false; + } + const bool transactionNamespace = + IsInGeneratedRootDeviceNamespace( + instanceId, kRootDeviceName); + const bool exactPriorInstance = + state.prior.devices.size() == 1U && + _wcsicmp(instanceId.c_str(), + state.prior.devices[0].instanceId.c_str()) == 0; + if (!hardwareIds.containsExpected && !transactionNamespace && + !exactPriorInstance) { + continue; + } + related.push_back(RelatedRoot{ + data, std::move(instanceId), hardwareIds}); + } + if (related.empty()) { + *shape = RemoveRootShape::Absent; + return true; + } + if (related.size() != 1U || state.prior.devices.empty()) { + return SetError(error, L"remove-journal-raw-root-authority", + related.size() > 1U + ? ERROR_DUPLICATE_SERVICE_NAME + : ERROR_REVISION_MISMATCH, + L"remove recovery found a foreign or ambiguous related root"); + } + RelatedRoot& root = related[0]; + const DeviceState& prior = state.prior.devices[0]; + bool present = false; + if (_wcsicmp(root.instanceId.c_str(), prior.instanceId.c_str()) != 0 || + IsEqualGUID(root.data.ClassGuid, GUID_DEVCLASS_USB) == FALSE || + !ReadDevicePresence(set.get(), root.data, &present, error)) { + return SetError(error, L"remove-journal-raw-root-authority", + ERROR_REVISION_MISMATCH, + L"related root does not match the captured exact instance and class"); + } + ULONG status = 0; + ULONG problem = 0; + const CONFIGRET configuration = CM_Get_DevNode_Status( + &status, &problem, root.data.DevInst, 0); + if (present && configuration != CR_SUCCESS) { + return SetError(error, L"remove-journal-raw-root-lifecycle", + ERROR_INVALID_DATA); + } + std::wstring service; + std::wstring publishedInf; + std::wstring driverVersion; + if (!ReadCanonicalInstallRecoveryService( + set.get(), root.data, &service, error) || + !ReadCanonicalInstallRecoveryDevicePropertyString( + set.get(), root.data, DEVPKEY_Device_DriverInfPath, + L"remove-journal-raw-root-driver-inf", + &publishedInf, error) || + !ReadCanonicalInstallRecoveryDevicePropertyString( + set.get(), root.data, DEVPKEY_Device_DriverVersion, + L"remove-journal-raw-root-driver-version", + &driverVersion, error)) { + return false; + } + Version observedVersion{}; + const bool exactBinding = + root.hardwareIds.exact && present && + _wcsicmp(service.c_str(), prior.service.c_str()) == 0 && + _wcsicmp(publishedInf.c_str(), prior.publishedInf.c_str()) == 0 && + ParseVersion(driverVersion, &observedVersion) && + observedVersion == prior.version; + if (exactBinding) { + *shape = RemoveRootShape::ExactPrior; + return true; + } + const bool removalWasAdmitted = state.deviceMutationEntered; + const bool pendingLifecycle = !present || + (configuration == CR_SUCCESS && problem == CM_PROB_WILL_BE_REMOVED); + const bool canonicalBindingFragment = + (service.empty() || + _wcsicmp(service.c_str(), prior.service.c_str()) == 0) && + (publishedInf.empty() || + _wcsicmp(publishedInf.c_str(), prior.publishedInf.c_str()) == 0) && + (driverVersion.empty() || + (ParseVersion(driverVersion, &observedVersion) && + observedVersion == prior.version)); + if (removalWasAdmitted && pendingLifecycle && + (root.hardwareIds.absent || root.hardwareIds.exact) && + canonicalBindingFragment) { + *shape = RemoveRootShape::PendingRemoval; + return true; + } + return SetError(error, L"remove-journal-raw-root-authority", + ERROR_REVISION_MISMATCH, + L"related root is outside exact prior, absent, or admitted-pending authority"); +} + +bool ObserveRemovePackagePrefix( + const RemoveJournalStateData& state, + uint32_t* removedPrefix, + std::vector* observed, + Error* error) { + if (!EnumerateOwnedPackages(observed, error)) return false; + for (const PackageInfo& current : *observed) { + const size_t matches = static_cast(std::count_if( + state.prior.packages.begin(), state.prior.packages.end(), + [&](const PackageInfo& prior) { + return SameJournalPackageIdentity(current, prior); + })); + if (matches != 1U) { + return SetError(error, + L"remove-journal-package-authority", + ERROR_REVISION_MISMATCH, + L"current Driver Store contains an identity outside the captured remove inventory"); + } + } + uint32_t prefix = 0; + while (prefix < state.prior.packages.size() && + std::none_of(observed->begin(), observed->end(), + [&](const PackageInfo& current) { + return SameJournalPackageIdentity( + current, state.prior.packages[prefix]); + })) { + ++prefix; + } + for (size_t index = prefix; + index < state.prior.packages.size(); ++index) { + const size_t matches = static_cast(std::count_if( + observed->begin(), observed->end(), + [&](const PackageInfo& current) { + return SameJournalPackageIdentity( + current, state.prior.packages[index]); + })); + if (matches != 1U) { + return SetError(error, + L"remove-journal-package-authority", + ERROR_REVISION_MISMATCH, + L"current Driver Store is not one exact captured suffix"); + } + } + *removedPrefix = prefix; + return true; +} + +bool CurrentRemoveStateMatchesPrior( + const RemoveJournalStateData& state, + uint64_t deadlineUnixMs, + Error* error) { + RemoveRootShape root = RemoveRootShape::Manual; + Snapshot observed; + if (!ObserveRemoveRootShape(state, &root, error) || + root != (state.prior.devices.empty() + ? RemoveRootShape::Absent + : RemoveRootShape::ExactPrior) || + !CaptureSnapshot(&observed, error) || + !SameCapturedRootState(state.prior, observed) || + !SamePackageInventory(state.prior.packages, + observed.packages)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"remove-journal-prior-state", + ERROR_REVISION_MISMATCH); + } + return false; + } + if (state.hasPriorAbiProfile && + !VerifyAbiHealth(deadlineUnixMs, nullptr, error, + AbiHealthPurpose::RollbackHealth, + &state.priorAbiProfile, nullptr)) { + return false; + } + return true; +} + +bool CurrentRemoveStateIsUninstalled( + const RemoveJournalStateData& state, + Error* error) { + RemoveRootShape root = RemoveRootShape::Manual; + std::vector packages; + uint32_t removedPrefix = 0; + return ObserveRemoveRootShape(state, &root, error) && + root == RemoveRootShape::Absent && + ObserveRemovePackagePrefix( + state, &removedPrefix, &packages, error) && + removedPrefix == state.prior.packages.size() && + packages.empty(); +} + +bool RecordRemoveJournalPhase( + LoadedRemoveJournal* loaded, + RemoveJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool rebootRequired, + bool freshRebootRequired, + bool deadlineOverrun, + Error* error, + std::optional activePackageIndex = std::nullopt, + std::optional packageCursor = std::nullopt) { + RemoveJournalStateData next = loaded->state; + next.phase = phase; + next.callSucceeded = callSucceeded; + next.callError = callError; + next.deadlineOverrun = deadlineOverrun; + next.freshRebootRequired = freshRebootRequired; + next.rebootRequired = rebootRequired; + next.activePackageIndex = activePackageIndex.value_or(UINT32_MAX); + if (packageCursor) next.packageCursor = *packageCursor; + if (phase == RemoveJournalPhase::DeviceRemovalEntered) { + next.deviceMutationEntered = true; + } + if (phase == RemoveJournalPhase::RollbackAdmitted || + phase == RemoveJournalPhase::RestoreRebootPending) { + next.direction = RemoveJournalDirection::Rollback; + } + if (phase == RemoveJournalPhase::RollbackBindingEntered) { + next.bindingMutationEntered = true; + } + if (freshRebootRequired) { + if (!GetBootIdentifier( + &next.pendingRebootBootIdentifier, error)) { + return false; + } + } + if (!rebootRequired) { + next.pendingRebootBootIdentifier.clear(); + } + return AppendRemoveJournalRecord( + loaded, std::move(next), error); +} + +void SetRemoveJournalRecoveryOutcome( + LoadedRemoveJournal* loaded, + const wchar_t* phase, + DWORD code, + std::wstring message, + ExitCode exitCode, + Outcome* outcome) { + SetError(&outcome->error, phase, code, std::move(message)); + outcome->exitCode = exitCode; + outcome->rebootRequired = exitCode == ExitCode::RebootRequired; + if (outcome->error.recoveryBackup.empty()) { + outcome->error.recoveryBackup = + loaded->directory.active.wstring(); + outcome->error.recoveryBackupRetained = true; + } + if (gActiveRecoveryRecord[0] != L'\0') { + outcome->error.recoveryRecord = + gActiveRecoveryRecord.data(); + outcome->error.recoveryRecordWritten = + gActiveRecoveryRecordWritten; + } +} + +bool ObserveRemovePackageSubset( + const RemoveJournalStateData& state, + std::vector* present, + std::vector* observed, + Error* error) { + if (!EnumerateOwnedPackages(observed, error)) return false; + present->assign(state.prior.packages.size(), false); + for (const PackageInfo& current : *observed) { + size_t matchedIndex = state.prior.packages.size(); + size_t matches = 0; + for (size_t index = 0; + index < state.prior.packages.size(); ++index) { + if (SameJournalPackageIdentity( + current, state.prior.packages[index])) { + matchedIndex = index; + ++matches; + } + } + if (matches != 1U || (*present)[matchedIndex]) { + return SetError(error, + L"remove-journal-package-subset", + ERROR_REVISION_MISMATCH, + L"current Driver Store is not an exact subset of captured identities"); + } + (*present)[matchedIndex] = true; + } + return true; +} + +bool InvokeRemovePackageMutation( + const PackageInfo& package, + uint64_t deadlineUnixMs, + bool* rebootRequired, + bool* freshRebootRequired, + Error* error) { + if (!CheckTransactionDeadline(deadlineUnixMs, + L"remove-journal-package-deadline", error)) { + return false; + } + BOOL reboot = FALSE; + MarkTransactionMutationStarted(); + const BOOL removed = InvokeAuthoritativeSynchronousMutation( + deadlineUnixMs, L"DiUninstallDriverW", [&]() { + return DiUninstallDriverW( + nullptr, package.infPath.c_str(), 0, &reboot); + }); + const DWORD code = removed ? ERROR_SUCCESS : GetLastError(); + *freshRebootRequired = reboot != FALSE; + *rebootRequired = *rebootRequired || reboot != FALSE; + if (!removed) { + return SetError(error, L"remove-driver-package", code); + } + if (gLastSynchronousMutationTimedOut) { + return SetError(error, L"remove-driver-package-timeout", + ERROR_TIMEOUT, + L"package removal returned after its deadline; authoritative outcome is retained"); + } + return true; +} + +bool InvokeRestorePackageMutation( + const PackageInfo& package, + uint64_t deadlineUnixMs, + bool* rebootRequired, + bool* freshRebootRequired, + Error* error) { + if (!CheckTransactionDeadline(deadlineUnixMs, + L"remove-journal-restore-package-deadline", error)) { + return false; + } + BOOL reboot = FALSE; + MarkTransactionMutationStarted(); + const BOOL installed = InvokeAuthoritativeSynchronousMutation( + deadlineUnixMs, L"DiInstallDriverW", [&]() { + return DiInstallDriverW( + nullptr, package.infPath.c_str(), 0, &reboot); + }); + const DWORD code = installed ? ERROR_SUCCESS : GetLastError(); + *freshRebootRequired = reboot != FALSE; + *rebootRequired = *rebootRequired || reboot != FALSE; + if (!installed) { + return SetError(error, L"remove-rollback-package", code); + } + if (gLastSynchronousMutationTimedOut) { + return SetError(error, L"remove-rollback-package-timeout", + ERROR_TIMEOUT, + L"package restoration returned after its deadline; authoritative outcome is retained"); + } + return true; +} + +bool RetireRemoveJournalAsPrior( + LoadedRemoveJournal* loaded, + uint64_t deadlineUnixMs, + Outcome* outcome) { + Error error; + if (!CurrentRemoveStateMatchesPrior( + loaded->state, deadlineUnixMs, &error) || + (loaded->state.phase != RemoveJournalPhase::ExactPriorRestored && + !RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::ExactPriorRestored, + true, ERROR_SUCCESS, false, false, false, &error)) || + !CurrentRemoveStateMatchesPrior( + loaded->state, deadlineUnixMs, &error) || + !RetireLoadedRemoveJournal(loaded, &error)) { + SetRemoveJournalRecoveryOutcome(loaded, + L"remove-journal-prior-retire", error.code, + L"exact prior state could not be proven and atomically retired", + ExitCode::RollbackFailed, outcome); + if (error.code != ERROR_SUCCESS) { + outcome->error = std::move(error); + if (outcome->error.recoveryBackup.empty()) { + outcome->error.recoveryBackup = + loaded->directory.active.wstring(); + outcome->error.recoveryBackupRetained = true; + } + if (gActiveRecoveryRecord[0] != L'\0') { + outcome->error.recoveryRecord = + gActiveRecoveryRecord.data(); + outcome->error.recoveryRecordWritten = + gActiveRecoveryRecordWritten; + } + } + return false; + } + outcome->success = true; + outcome->rollback = L"succeeded"; + outcome->exitCode = ExitCode::Success; + return true; +} + +bool RetireRemoveJournalAsUninstalled( + LoadedRemoveJournal* loaded, + Outcome* outcome) { + Error error; + if (!CurrentRemoveStateIsUninstalled(loaded->state, &error) || + (loaded->state.phase != RemoveJournalPhase::ForwardValidated && + !RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::ForwardValidated, + true, ERROR_SUCCESS, false, false, false, &error)) || + !CurrentRemoveStateIsUninstalled(loaded->state, &error) || + !RetireLoadedRemoveJournal(loaded, &error)) { + SetRemoveJournalRecoveryOutcome(loaded, + L"remove-journal-forward-retire", error.code, + L"exact uninstalled state could not be proven and atomically retired", + ExitCode::RollbackFailed, outcome); + if (error.code != ERROR_SUCCESS) { + outcome->error = std::move(error); + if (outcome->error.recoveryBackup.empty()) { + outcome->error.recoveryBackup = + loaded->directory.active.wstring(); + outcome->error.recoveryBackupRetained = true; + } + if (gActiveRecoveryRecord[0] != L'\0') { + outcome->error.recoveryRecord = + gActiveRecoveryRecord.data(); + outcome->error.recoveryRecordWritten = + gActiveRecoveryRecordWritten; + } + } + return false; + } + outcome->success = true; + outcome->changed = true; + outcome->exitCode = ExitCode::Success; + return true; +} + +bool FailRemoveJournalManual( + LoadedRemoveJournal* loaded, + std::wstring message, + const Error* cause, + Outcome* outcome) { + Error appendError; + if (loaded->state.phase != + RemoveJournalPhase::ManualReconciliationRequired) { + RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::ManualReconciliationRequired, + false, cause != nullptr ? cause->code : ERROR_INVALID_DATA, + loaded->state.rebootRequired, false, false, &appendError); + } + SetRemoveJournalRecoveryOutcome(loaded, + L"remove-journal-manual-reconciliation", + cause != nullptr && cause->code != ERROR_SUCCESS + ? cause->code : ERROR_INVALID_DATA, + std::move(message), ExitCode::RollbackFailed, outcome); + if (cause != nullptr) { + const std::wstring backup = !cause->recoveryBackup.empty() + ? cause->recoveryBackup : outcome->error.recoveryBackup; + const std::wstring record = outcome->error.recoveryRecord; + const bool backupRetained = !cause->recoveryBackup.empty() + ? cause->recoveryBackupRetained + : outcome->error.recoveryBackupRetained; + const bool recordWritten = + outcome->error.recoveryRecordWritten; + outcome->error = *cause; + outcome->error.recoveryBackup = backup; + outcome->error.recoveryRecord = record; + outcome->error.recoveryBackupRetained = backupRetained; + outcome->error.recoveryRecordWritten = recordWritten; + } + return false; +} + +bool ReturnRemoveJournalRebootPending( + LoadedRemoveJournal* loaded, + const std::string& currentBoot, + Outcome* outcome) { + const RemoveJournalPhase pendingPhase = + loaded->state.direction == RemoveJournalDirection::Rollback + ? RemoveJournalPhase::RestoreRebootPending + : RemoveJournalPhase::ForwardRebootPending; + Error error; + if (loaded->state.phase != pendingPhase) { + RemoveJournalStateData next = loaded->state; + next.phase = pendingPhase; + next.callSucceeded = true; + next.callError = ERROR_SUCCESS_REBOOT_REQUIRED; + next.deadlineOverrun = false; + next.freshRebootRequired = false; + next.rebootRequired = true; + next.activePackageIndex = UINT32_MAX; + if (next.pendingRebootBootIdentifier.empty()) { + next.pendingRebootBootIdentifier = currentBoot; + } + if (!AppendRemoveJournalRecord( + loaded, std::move(next), &error)) { + return FailRemoveJournalManual(loaded, + L"required restart boundary could not be published", + &error, outcome); + } + } + SetRemoveJournalRecoveryOutcome(loaded, + L"remove-journal-reboot-pending", + ERROR_SUCCESS_REBOOT_REQUIRED, + L"the protected remove transaction requires the recorded Windows restart before continuing", + ExitCode::RebootRequired, outcome); + return false; +} + +bool RunRemoveRollbackRecovery( + LoadedRemoveJournal* loaded, + const std::string& currentBoot, + uint64_t deadlineUnixMs, + Outcome* outcome) { + const bool samePendingBoot = + !loaded->state.pendingRebootBootIdentifier.empty() && + loaded->state.pendingRebootBootIdentifier == currentBoot; + if (loaded->state.rebootRequired && samePendingBoot) { + return ReturnRemoveJournalRebootPending( + loaded, currentBoot, outcome); + } + if (loaded->state.phase == + RemoveJournalPhase::ExactPriorRestored) { + return RetireRemoveJournalAsPrior( + loaded, deadlineUnixMs, outcome); + } + RemoveRootShape root = RemoveRootShape::Manual; + std::vector packagePresent; + std::vector observedPackages; + Error observationError; + if (!ObserveRemoveRootShape(loaded->state, &root, + &observationError) || + !ObserveRemovePackageSubset(loaded->state, + &packagePresent, &observedPackages, &observationError)) { + return FailRemoveJournalManual(loaded, + L"rollback admission observed topology outside the exact captured subset", + &observationError, outcome); + } + if (root == RemoveRootShape::PendingRemoval) { + return FailRemoveJournalManual(loaded, + L"captured root still has an indeterminate removal lifecycle after its recorded restart", + nullptr, outcome); + } + if (loaded->state.phase == + RemoveJournalPhase::RollbackPackageReturned && + !loaded->state.callSucceeded) { + return FailRemoveJournalManual(loaded, + L"authoritative protected-package restoration returned failure", + nullptr, outcome); + } + if (loaded->state.phase == + RemoveJournalPhase::RollbackPackageEntered || + loaded->state.phase == + RemoveJournalPhase::RollbackPackageReturned) { + const uint32_t interruptedIndex = + loaded->state.activePackageIndex; + if (interruptedIndex >= packagePresent.size()) { + return FailRemoveJournalManual(loaded, + L"interrupted rollback package index is outside the immutable prior inventory", + nullptr, outcome); + } + if (packagePresent[interruptedIndex]) { + Error commitError; + if (!RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::RollbackPackageCommitted, + true, ERROR_SUCCESS, false, false, false, + &commitError)) { + return FailRemoveJournalManual(loaded, + L"observable exact package restoration could not be durably committed", + &commitError, outcome); + } + } else if (loaded->state.phase == + RemoveJournalPhase::RollbackPackageReturned) { + return FailRemoveJournalManual(loaded, + L"successful rollback package return did not restore its exact published identity after the recorded restart", + nullptr, outcome); + } + } + for (size_t index = 0; index < packagePresent.size(); ++index) { + if (packagePresent[index]) continue; + bool reboot = false; + Error error; + const bool reusingInterruptedAdmission = + loaded->state.phase == + RemoveJournalPhase::RollbackPackageEntered && + loaded->state.activePackageIndex == index; + if ((!reusingInterruptedAdmission && + !RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::RollbackPackageEntered, + true, ERROR_SUCCESS, false, false, false, &error, + static_cast(index))) || + !ObserveRemoveRootShape(loaded->state, &root, &error) || + (root != RemoveRootShape::Absent && + root != RemoveRootShape::ExactPrior) || + !ObserveRemovePackageSubset(loaded->state, + &packagePresent, &observedPackages, &error) || + packagePresent[index]) { + return FailRemoveJournalManual(loaded, + L"rollback package authority changed after durable admission", + &error, outcome); + } + bool freshReboot = false; + const bool installed = InvokeRestorePackageMutation( + loaded->state.prior.packages[index], deadlineUnixMs, + &reboot, &freshReboot, &error); + Error returnError; + if (!RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::RollbackPackageReturned, + installed, + installed ? ERROR_SUCCESS : error.code, + reboot, freshReboot, + gLastSynchronousMutationTimedOut, &returnError, + static_cast(index))) { + return FailRemoveJournalManual(loaded, + L"authoritative rollback package return could not be recorded", + &returnError, outcome); + } + if (!installed) { + return FailRemoveJournalManual(loaded, + L"exact protected package restoration failed", + &error, outcome); + } + if (reboot) { + return ReturnRemoveJournalRebootPending( + loaded, currentBoot, outcome); + } + if (!ObserveRemovePackageSubset(loaded->state, + &packagePresent, &observedPackages, &error) || + !packagePresent[index]) { + return FailRemoveJournalManual(loaded, + L"restored bytes did not regain the exact captured published package identity", + &error, outcome); + } + if (!RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::RollbackPackageCommitted, + true, ERROR_SUCCESS, reboot, false, false, &error)) { + return FailRemoveJournalManual(loaded, + L"exact package restoration commit could not be recorded", + &error, outcome); + } + } + Error error; + if (!ObserveRemovePackageSubset(loaded->state, + &packagePresent, &observedPackages, &error) || + std::any_of(packagePresent.begin(), packagePresent.end(), + [](bool present) { return !present; })) { + return FailRemoveJournalManual(loaded, + L"exact prior package inventory is incomplete before binding restoration", + &error, outcome); + } + if (CurrentRemoveStateMatchesPrior( + loaded->state, deadlineUnixMs, &error)) { + return RetireRemoveJournalAsPrior( + loaded, deadlineUnixMs, outcome); + } + error = Error{}; + if (!ObserveRemoveRootShape(loaded->state, &root, &error) || + root != RemoveRootShape::Absent) { + return FailRemoveJournalManual(loaded, + L"root topology is not exactly absent or prior before binding rollback", + &error, outcome); + } + if (loaded->state.prior.devices.empty()) { + return RetireRemoveJournalAsPrior( + loaded, deadlineUnixMs, outcome); + } + const bool reusingInterruptedBindingAdmission = + ReusesInterruptedRemoveBindingAdmission(loaded->state.phase); + if ((!reusingInterruptedBindingAdmission && + !RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::RollbackBindingEntered, + true, ERROR_SUCCESS, false, false, false, &error)) || + !ObserveRemoveRootShape(loaded->state, &root, &error) || + root != RemoveRootShape::Absent || + !VerifyPackageInventory(loaded->state.prior.packages, + L"remove-journal-pre-binding-inventory", &error)) { + return FailRemoveJournalManual(loaded, + L"binding rollback authority changed after durable admission", + &error, outcome); + } + Snapshot restorable = loaded->state.prior; + std::filesystem::path systemInf; + if (!GetSystemInfDirectory(&systemInf, &error)) { + return FailRemoveJournalManual(loaded, + L"system INF directory could not be resolved for exact binding rollback", + &error, outcome); + } + for (PackageInfo& package : restorable.packages) { + package.infPath = systemInf / package.publishedName; + } + for (DeviceState& device : restorable.devices) { + for (const PackageInfo& package : restorable.packages) { + if (_wcsicmp(package.publishedName.c_str(), + device.publishedInf.c_str()) == 0) { + device.package = package; + } + } + } + bool reboot = false; + const bool authoritativeRestored = + InvokeAuthoritativeSynchronousMutation( + deadlineUnixMs, L"remove-rollback-binding", [&]() { + return RestorePriorBinding(restorable, + RestorePriorBindingPolicy::RemoveJournalExactAbsence, + deadlineUnixMs, &reboot, &error); + }); + bool restored = authoritativeRestored; + if (restored && gLastSynchronousMutationTimedOut) { + restored = SetError(&error, + L"remove-rollback-binding-timeout", ERROR_TIMEOUT, + L"binding restoration returned after its deadline; authoritative outcome is retained"); + } + const bool freshReboot = reboot; + Error returnError; + if (!RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::RollbackBindingReturned, + restored, restored ? ERROR_SUCCESS : error.code, + reboot, freshReboot, + gLastSynchronousMutationTimedOut, &returnError)) { + return FailRemoveJournalManual(loaded, + L"authoritative binding rollback return could not be recorded", + &returnError, outcome); + } + if (!restored) { + return FailRemoveJournalManual(loaded, + L"exact captured root restoration failed", + &error, outcome); + } + if (reboot) { + return ReturnRemoveJournalRebootPending( + loaded, currentBoot, outcome); + } + return RetireRemoveJournalAsPrior( + loaded, deadlineUnixMs, outcome); +} + +bool AdmitRemoveRollback( + LoadedRemoveJournal* loaded, + DWORD failureCode, + const std::string& currentBoot, + Outcome* outcome) { + Error error; + const RemoveJournalPhase admissionPhase = loaded->state.rebootRequired + ? RemoveJournalPhase::RestoreRebootPending + : RemoveJournalPhase::RollbackAdmitted; + if (!RecordRemoveJournalPhase(loaded, + admissionPhase, + false, failureCode, + loaded->state.rebootRequired, false, false, &error)) { + return FailRemoveJournalManual(loaded, + L"rollback authority could not be durably admitted", + &error, outcome); + } + const uint64_t rollbackDeadline = FreshRemoveRollbackDeadline(); + return RunRemoveRollbackRecovery( + loaded, currentBoot, rollbackDeadline, outcome); +} + +bool RunRemoveForwardRecovery( + LoadedRemoveJournal* loaded, + const std::string& currentBoot, + uint64_t deadlineUnixMs, + Outcome* outcome) { + const bool samePendingBoot = + !loaded->state.pendingRebootBootIdentifier.empty() && + loaded->state.pendingRebootBootIdentifier == currentBoot; + if (loaded->state.rebootRequired && samePendingBoot) { + return ReturnRemoveJournalRebootPending( + loaded, currentBoot, outcome); + } + if (loaded->state.phase == RemoveJournalPhase::ForwardValidated) { + return RetireRemoveJournalAsUninstalled(loaded, outcome); + } + if ((loaded->state.phase == + RemoveJournalPhase::DeviceRemovalReturned || + loaded->state.phase == + RemoveJournalPhase::PackageRemovalReturned) && + !loaded->state.callSucceeded) { + return AdmitRemoveRollback(loaded, + loaded->state.callError, currentBoot, outcome); + } + + RemoveRootShape root = RemoveRootShape::Manual; + std::vector packages; + uint32_t removedPrefix = 0; + Error error; + if (!ObserveRemoveRootShape(loaded->state, &root, &error) || + !ObserveRemovePackagePrefix(loaded->state, + &removedPrefix, &packages, &error)) { + return FailRemoveJournalManual(loaded, + L"forward remove topology is outside the exact captured prefix authority", + &error, outcome); + } + if (removedPrefix < loaded->state.packageCursor || + removedPrefix > loaded->state.packageCursor + 1U) { + return FailRemoveJournalManual(loaded, + L"Driver Store removal progress skipped an unadmitted package boundary", + nullptr, outcome); + } + const bool hadPriorRoot = !loaded->state.prior.devices.empty(); + if (root == RemoveRootShape::PendingRemoval) { + if (CrossedRemoveRebootStillPendingRequiresManual( + loaded->state.phase, loaded->state.callSucceeded, + loaded->state.rebootRequired, + loaded->state.freshRebootRequired, + samePendingBoot, root)) { + return FailRemoveJournalManual(loaded, + L"the root remains pending removal after the recorded restart; no fresh authoritative reboot evidence permits another restart loop", + nullptr, outcome); + } + return ReturnRemoveJournalRebootPending( + loaded, currentBoot, outcome); + } + if (hadPriorRoot && root == RemoveRootShape::ExactPrior) { + if (loaded->state.phase == + RemoveJournalPhase::DeviceRemovalReturned && + loaded->state.callSucceeded) { + return FailRemoveJournalManual(loaded, + L"successful non-pending device removal left the exact prior root present", + nullptr, outcome); + } + if (loaded->state.phase != RemoveJournalPhase::Prepared && + loaded->state.phase != + RemoveJournalPhase::DeviceRemovalEntered && + loaded->state.phase != + RemoveJournalPhase::ForwardRebootPending) { + return FailRemoveJournalManual(loaded, + L"prior root reappeared after a committed removal boundary", + nullptr, outcome); + } + const bool reusingInterruptedDeviceAdmission = + loaded->state.phase == + RemoveJournalPhase::DeviceRemovalEntered; + if ((!reusingInterruptedDeviceAdmission && + !RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::DeviceRemovalEntered, + true, ERROR_SUCCESS, false, false, false, &error)) || + !VerifyPackageInventory(loaded->state.prior.packages, + L"remove-journal-device-post-admission-inventory", + &error) || + !ObserveRemoveRootShape(loaded->state, &root, &error) || + root != RemoveRootShape::ExactPrior) { + return FailRemoveJournalManual(loaded, + L"device removal authority changed after durable admission", + &error, outcome); + } + bool reboot = false; + bool mutationStarted = false; + const bool authoritativeRemoved = + InvokeAuthoritativeSynchronousMutation( + deadlineUnixMs, L"DiUninstallDevice", [&]() { + return RemoveExactCapturedDevice( + loaded->state.prior.devices[0], deadlineUnixMs, + &mutationStarted, &reboot, &error); + }); + bool removed = authoritativeRemoved; + if (removed && gLastSynchronousMutationTimedOut) { + removed = SetError(&error, + L"remove-devnode-timeout", ERROR_TIMEOUT, + L"device removal returned after its deadline; authoritative outcome is retained"); + } + const bool freshReboot = reboot; + Error returnError; + if (!RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::DeviceRemovalReturned, + removed, removed ? ERROR_SUCCESS : error.code, + reboot, freshReboot, + gLastSynchronousMutationTimedOut, &returnError)) { + return FailRemoveJournalManual(loaded, + L"authoritative device removal return could not be recorded", + &returnError, outcome); + } + if (!removed) { + return AdmitRemoveRollback(loaded, error.code, + currentBoot, outcome); + } + if (reboot) { + return ReturnRemoveJournalRebootPending( + loaded, currentBoot, outcome); + } + if (!ObserveRemoveRootShape(loaded->state, &root, &error) || + root != RemoveRootShape::Absent || + !VerifyPackageInventory(loaded->state.prior.packages, + L"remove-journal-device-commit-inventory", &error) || + !RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::DeviceRemovalCommitted, + true, ERROR_SUCCESS, false, false, false, &error)) { + return AdmitRemoveRollback(loaded, + error.code, currentBoot, outcome); + } + } else if (root == RemoveRootShape::Absent) { + if (hadPriorRoot && + !loaded->state.deviceMutationEntered) { + return FailRemoveJournalManual(loaded, + L"captured root disappeared before durable device-removal admission", + nullptr, outcome); + } + if (hadPriorRoot && + (loaded->state.phase == + RemoveJournalPhase::DeviceRemovalEntered || + loaded->state.phase == + RemoveJournalPhase::DeviceRemovalReturned || + loaded->state.phase == + RemoveJournalPhase::ForwardRebootPending)) { + if (!RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::DeviceRemovalCommitted, + true, ERROR_SUCCESS, false, false, false, &error)) { + return FailRemoveJournalManual(loaded, + L"observed device removal could not be durably committed", + &error, outcome); + } + } + } else { + return FailRemoveJournalManual(loaded, + L"root topology is neither exact prior nor exact absent", + nullptr, outcome); + } + + if (removedPrefix == loaded->state.packageCursor + 1U) { + const bool admissibleObservedEffect = + loaded->state.phase == + RemoveJournalPhase::PackageRemovalEntered || + (loaded->state.phase == + RemoveJournalPhase::PackageRemovalReturned && + loaded->state.callSucceeded) || + loaded->state.phase == + RemoveJournalPhase::ForwardRebootPending; + if (!admissibleObservedEffect || + !RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::PackageRemovalCommitted, + true, ERROR_SUCCESS, false, false, false, &error, + std::nullopt, removedPrefix)) { + return FailRemoveJournalManual(loaded, + L"observed package removal lacks exact durable API authority", + &error, outcome); + } + } + while (loaded->state.packageCursor < + loaded->state.prior.packages.size()) { + const uint32_t index = loaded->state.packageCursor; + if (!ObserveRemoveRootShape(loaded->state, &root, &error) || + root != RemoveRootShape::Absent || + !ObserveRemovePackagePrefix(loaded->state, + &removedPrefix, &packages, &error) || + removedPrefix != index) { + return FailRemoveJournalManual(loaded, + L"package removal precondition changed outside the exact captured suffix", + &error, outcome); + } + const PackageInfo* current = nullptr; + for (const PackageInfo& package : packages) { + if (SameJournalPackageIdentity(package, + loaded->state.prior.packages[index])) { + current = &package; + } + } + const bool reusingInterruptedPackageAdmission = + loaded->state.phase == + RemoveJournalPhase::PackageRemovalEntered && + loaded->state.activePackageIndex == index; + if (current == nullptr || + (!reusingInterruptedPackageAdmission && + !RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::PackageRemovalEntered, + true, ERROR_SUCCESS, false, false, false, &error, + index)) || + !ObserveRemoveRootShape(loaded->state, &root, &error) || + root != RemoveRootShape::Absent || + !ObserveRemovePackagePrefix(loaded->state, + &removedPrefix, &packages, &error) || + removedPrefix != index) { + return FailRemoveJournalManual(loaded, + L"package removal authority changed after durable admission", + &error, outcome); + } + current = nullptr; + for (const PackageInfo& package : packages) { + if (SameJournalPackageIdentity(package, + loaded->state.prior.packages[index])) { + current = &package; + } + } + if (current == nullptr) { + return FailRemoveJournalManual(loaded, + L"admitted exact package disappeared before its API call", + nullptr, outcome); + } + bool reboot = false; + bool freshReboot = false; + const bool removed = InvokeRemovePackageMutation(*current, + deadlineUnixMs, &reboot, &freshReboot, &error); + Error returnError; + if (!RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::PackageRemovalReturned, + removed, removed ? ERROR_SUCCESS : error.code, + reboot, freshReboot, + gLastSynchronousMutationTimedOut, &returnError, index)) { + return FailRemoveJournalManual(loaded, + L"authoritative package removal return could not be recorded", + &returnError, outcome); + } + if (!removed) { + return AdmitRemoveRollback(loaded, error.code, + currentBoot, outcome); + } + if (reboot) { + return ReturnRemoveJournalRebootPending( + loaded, currentBoot, outcome); + } + if (!ObserveRemovePackagePrefix(loaded->state, + &removedPrefix, &packages, &error) || + removedPrefix != index + 1U || + !ObserveRemoveRootShape(loaded->state, &root, &error) || + root != RemoveRootShape::Absent || + !RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::PackageRemovalCommitted, + true, ERROR_SUCCESS, false, false, false, &error, + std::nullopt, index + 1U)) { + return AdmitRemoveRollback(loaded, + error.code, currentBoot, outcome); + } + } + return RetireRemoveJournalAsUninstalled(loaded, outcome); +} + +bool ReconcileRemoveJournal( + bool explicitRecovery, + uint64_t deadlineUnixMs, + Outcome* outcome) { + RemoveRecoveryDirectory directory; + bool exists = false; + if (!directory.OpenChain(false, &exists, &outcome->error)) { + outcome->exitCode = ExitCode::RollbackFailed; + return false; + } + if (!exists) return true; + LoadedRemoveJournal loaded; + if (!LoadRemoveJournal( + std::move(directory), &loaded, &outcome->error)) { + outcome->exitCode = ExitCode::RollbackFailed; + outcome->error.recoveryBackup = loaded.directory.active.wstring(); + outcome->error.recoveryBackupRetained = true; + return false; + } + if (!loaded.hasRecord) { + std::string abandonedIdentity; + if (!GenerateInstallTransactionId( + &abandonedIdentity, &outcome->error) || + !RetireRemoveRecoveryActiveDirectory( + &loaded.directory, abandonedIdentity, + &outcome->error)) { + outcome->exitCode = ExitCode::RollbackFailed; + outcome->error.recoveryBackup = + loaded.directory.active.wstring(); + outcome->error.recoveryBackupRetained = true; + return false; + } + outcome->success = true; + outcome->exitCode = ExitCode::Success; + return true; + } + PublishRemoveRecoveryEvidence(loaded.directory.active, + loaded.state.sequence - 1U, nullptr); + + InstallRecoveryDirectory installDirectory; + bool installExists = false; + Error concurrencyError; + if (!installDirectory.OpenChain(false, nullptr, + &installExists, &concurrencyError) || installExists) { + return FailRemoveJournalManual(&loaded, + L"remove and install journals are simultaneously active; no automatic mutation is authorized", + installExists ? nullptr : &concurrencyError, outcome); + } + if (loaded.state.phase == + RemoveJournalPhase::ManualReconciliationRequired) { + return FailRemoveJournalManual(&loaded, + L"remove journal is latched for manual reconciliation", + nullptr, outcome); + } + std::string currentBoot; + if (!GetBootIdentifier(¤tBoot, &outcome->error)) { + return FailRemoveJournalManual(&loaded, + L"current boot epoch cannot be compared with the durable remove boundary", + &outcome->error, outcome); + } + const uint64_t recoveryDeadline = explicitRecovery + ? deadlineUnixMs + : std::min(deadlineUnixMs, + CurrentUnixMilliseconds() + kDriverRollbackCeilingMs); + return loaded.state.direction == RemoveJournalDirection::Rollback + ? RunRemoveRollbackRecovery(&loaded, currentBoot, + recoveryDeadline, outcome) + : RunRemoveForwardRecovery(&loaded, currentBoot, + recoveryDeadline, outcome); +} + +struct RemoveOptions { + uint64_t transactionDeadlineUnixMs = 0; +}; + +Outcome Remove(const RemoveOptions& options) { + Outcome outcome; + if (!ValidateTransactionDeadlineBudget( + options.transactionDeadlineUnixMs, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + if (!IsElevated()) { + SetError(&outcome.error, L"elevation", ERROR_ELEVATION_REQUIRED); + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + TransactionMutex mutex; + if (!mutex.Acquire(&outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + Outcome recoveryOutcome; + if (!ReconcileRemoveJournal( + false, options.transactionDeadlineUnixMs, &recoveryOutcome) || + !ReconcileInstallJournal( + false, options.transactionDeadlineUnixMs, &recoveryOutcome)) { + return recoveryOutcome; + } + if (!CheckTransactionDeadline( + options.transactionDeadlineUnixMs, + L"remove-deadline-before-snapshot", &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + Snapshot prior; + if (!CaptureSnapshot(&prior, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + if (prior.devices.size() > 1 || + (!prior.devices.empty() && !prior.devices[0].present)) { + SetError(&outcome.error, L"remove-topology", ERROR_DUPLICATE_SERVICE_NAME, + L"removal requires zero devices or one present exact owned root devnode"); + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + RemoveJournalStateData observationState; + observationState.prior = prior; + RemoveRootShape root = RemoveRootShape::Manual; + if (!ObserveRemoveRootShape( + observationState, &root, &outcome.error) || + root != (prior.devices.empty() + ? RemoveRootShape::Absent + : RemoveRootShape::ExactPrior)) { + if (outcome.error.code == ERROR_SUCCESS) { + SetError(&outcome.error, L"remove-raw-topology", + ERROR_REVISION_MISMATCH, + L"removal requires one exact captured root or exact root absence with no related partial roots"); + } + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + if (prior.devices.empty() && prior.packages.empty()) { + outcome.success = true; + outcome.exitCode = ExitCode::Success; + return outcome; + } + std::optional priorAbiProfile; + if (!prior.devices.empty() && prior.devices[0].started) { + AbiCompatibilityProfile negotiated{}; + if (!VerifyAbiHealth(options.transactionDeadlineUnixMs, + nullptr, &outcome.error, + AbiHealthPurpose::ExactCandidate, nullptr, + &negotiated)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + priorAbiProfile = negotiated; + } + { + LoadedRemoveJournal journal; + if (!PrepareRemoveJournal(prior, + priorAbiProfile ? &*priorAbiProfile : nullptr, + &journal, &outcome.error)) { + outcome.exitCode = journal.hasRecord || journal.poisoned + ? ExitCode::RollbackFailed + : ExitCode::PreflightRejected; + if (!journal.directory.active.empty()) { + outcome.error.recoveryBackup = + journal.directory.active.wstring(); + outcome.error.recoveryBackupRetained = true; + } + return outcome; + } + } + Outcome transactionOutcome; + const bool reconciled = ReconcileRemoveJournal( + true, options.transactionDeadlineUnixMs, + &transactionOutcome); + if (!reconciled) return transactionOutcome; + Snapshot finalState; + if (!CaptureSnapshot(&finalState, &outcome.error)) { + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + if (finalState.devices.empty() && finalState.packages.empty()) { + outcome.success = true; + outcome.changed = true; + outcome.exitCode = ExitCode::Success; + return outcome; + } + SetError(&outcome.error, L"remove-transaction-rolled-back", + ERROR_OPERATION_ABORTED, + L"removal could not commit and the exact prior state was restored"); + outcome.changed = true; + outcome.rollback = L"succeeded"; + outcome.exitCode = ExitCode::Failure; + return outcome; +} + +Outcome Recover(uint64_t transactionDeadlineUnixMs) { + Outcome outcome; + if (!ValidateTransactionDeadlineBudget( + transactionDeadlineUnixMs, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + if (!IsElevated()) { + SetError(&outcome.error, L"elevation", ERROR_ELEVATION_REQUIRED); + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + TransactionMutex mutex; + if (!mutex.Acquire(&outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + if (!ReconcileRemoveJournal( + true, transactionDeadlineUnixMs, &outcome) || + !ReconcileInstallJournal( + true, transactionDeadlineUnixMs, &outcome)) { + return outcome; + } + outcome.success = true; + outcome.exitCode = ExitCode::Success; + return outcome; +} + +enum class InstallJournalRecoveryModelAction { + RetirePrior, + RetireForward, + RollbackPrior, + RebootPending, + Manual, +}; + +enum class BrokerOuterRecoveryModelAction { + ReplayChild, + ValidateForward, + PublishPending, + EmitBinding, + ReplayAcknowledgement, + Manual, +}; + +BrokerOuterRecoveryModelAction ClassifyBrokerOuterRecoveryModel( + InstallJournalPhase phase, + bool hasProof, + bool proofSuccess, + bool proofChanged, + bool rollbackAuthorized) noexcept { + if (phase == InstallJournalPhase::BrokerChildEntered && + !hasProof && !rollbackAuthorized) { + return BrokerOuterRecoveryModelAction::ReplayChild; + } + const bool exactForwardProof = hasProof && proofSuccess && + proofChanged && !rollbackAuthorized; + if (!exactForwardProof) { + return BrokerOuterRecoveryModelAction::Manual; + } + switch (phase) { + case InstallJournalPhase::BrokerChildSettled: + return BrokerOuterRecoveryModelAction::ValidateForward; + case InstallJournalPhase::ForwardValidated: + return BrokerOuterRecoveryModelAction::PublishPending; + case InstallJournalPhase::BrokerOuterSettlementPending: + return BrokerOuterRecoveryModelAction::EmitBinding; + case InstallJournalPhase::BrokerOuterSettled: + return BrokerOuterRecoveryModelAction::ReplayAcknowledgement; + default: + return BrokerOuterRecoveryModelAction::Manual; + } +} + +InstallJournalRecoveryModelAction ClassifyInstallJournalRecoveryModel( + InstallJournalPhase phase, + bool chainValid, + bool securityValid, + bool sameBoot, + bool priorValid, + bool forwardValid, + bool brokerEntered, + bool brokerSettled, + bool brokerSucceeded) noexcept { + if (!chainValid || !securityValid || + phase == InstallJournalPhase::ManualReconciliationRequired) { + return InstallJournalRecoveryModelAction::Manual; + } + if ((phase == InstallJournalPhase::ForwardRebootPending || + phase == InstallJournalPhase::RestoreRebootPending) && + sameBoot) { + return InstallJournalRecoveryModelAction::RebootPending; + } + if (priorValid) { + return InstallJournalRecoveryModelAction::RetirePrior; + } + if (forwardValid && + (phase == InstallJournalPhase::ForwardValidated || + phase == InstallJournalPhase::ForwardRebootPending || + (!brokerEntered && phase == InstallJournalPhase::DriverValidated) || + (brokerSettled && brokerSucceeded))) { + return InstallJournalRecoveryModelAction::RetireForward; + } + if (brokerEntered && !brokerSettled) { + return InstallJournalRecoveryModelAction::Manual; + } + return InstallJournalRecoveryModelAction::RollbackPrior; +} + +bool RunInstallJournalModelSelfTest(Error* error) { + const std::wstring modelTargetUserSid = + L"S-1-5-21-1-2-3-1001"; + std::wstring modelProductSecurity; + if (!ProductDirectoryMaskIsReadExecuteOnly(0x001200a9U) || + !ProductDirectoryMaskIsReadExecuteOnly( + GENERIC_READ | GENERIC_EXECUTE) || + ProductDirectoryMaskIsReadExecuteOnly( + GENERIC_READ | GENERIC_WRITE) || + ProductDirectoryMaskIsReadExecuteOnly( + 0x001200a9U | FILE_ADD_FILE) || + ProductDirectoryMaskIsReadExecuteOnly( + 0x001200a9U | FILE_DELETE_CHILD) || + ProductDirectoryMaskIsReadExecuteOnly( + 0x001200a9U | DELETE) || + ProductDirectoryMaskIsReadExecuteOnly( + 0x001200a9U | WRITE_DAC) || + ProductDirectoryMaskIsReadExecuteOnly( + 0x001200a9U | WRITE_OWNER) || + !BuildInstallRecoveryProductDirectorySecurity( + modelTargetUserSid, &modelProductSecurity, error) || + modelProductSecurity != + L"O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + L"(A;OICI;GRGX;;;S-1-5-21-1-2-3-1001)" || + !InstallRecoveryChainHasActive(true, true, true, true) || + InstallRecoveryChainHasActive(true, false, false, false) || + InstallRecoveryChainHasActive(true, true, false, false) || + InstallRecoveryChainHasActive(true, true, true, false) || + InstallRecoveryChainHasActive(false, false, false, false)) { + return SetError(error, + L"self-test-install-journal-product-security", + ERROR_INVALID_DATA); + } + for (InstallJournalPhase phase : { + InstallJournalPhase::Prepared, + InstallJournalPhase::SetupCopyEntered, + InstallJournalPhase::SetupCopyReturned, + InstallJournalPhase::StageReceiptCaptured, + InstallJournalPhase::QuiesceSignalEntered, + InstallJournalPhase::QuiesceSignalReturned, + InstallJournalPhase::RootRegistrationIntentCaptured, + InstallJournalPhase::RootRegistrationEntered, + InstallJournalPhase::RootRegistrationReturned, + InstallJournalPhase::DiInstallEntered, + InstallJournalPhase::DiInstallReturned, + InstallJournalPhase::PriorAbiProfileCaptured, + InstallJournalPhase::DriverValidated, + InstallJournalPhase::BrokerHandoffEntered, + InstallJournalPhase::BrokerHandoffReturned, + InstallJournalPhase::BrokerChildEntered, + InstallJournalPhase::BrokerChildSettled, + InstallJournalPhase::RollbackBindingEntered, + InstallJournalPhase::PartialRootRemovalEntered, + InstallJournalPhase::PartialRootRemovalReturned, + InstallJournalPhase::PartialRootRemovalRebootPending, + InstallJournalPhase::RollbackBindingReturned, + InstallJournalPhase::SetupUninstallEntered, + InstallJournalPhase::SetupUninstallReturned, + InstallJournalPhase::ForwardValidated, + InstallJournalPhase::ExactPriorRestored, + InstallJournalPhase::ForwardRebootPending, + InstallJournalPhase::RestoreRebootPending, + InstallJournalPhase::ManualReconciliationRequired}) { + const char* name = InstallJournalPhaseName(phase); + const std::optional parsed = + ParseInstallJournalPhase(name); + if (!parsed || *parsed != phase) { + return SetError(error, L"self-test-install-journal-phase", + ERROR_INVALID_DATA); + } + } + if (ParseInstallJournalPhase("setup-copy-entered") || + ParseInstallJournalDirection("Forward") || + ParseInstallJournalDirection("rollback ") || + ParseInstallJournalDirection("forward") != + InstallJournalDirection::Forward || + ParseInstallJournalDirection("rollback") != + InstallJournalDirection::Rollback || + InstallJournalPhaseRequiresPriorAbiProfile( + InstallJournalPhase::DriverValidated) || + !InstallJournalPhaseRequiresPriorAbiProfile( + InstallJournalPhase::DiInstallEntered) || + !IsKnownAbiCompatibilityProfile(kAbiCompatibilityProfiles[0]) || + !IsSafeRecoveryRelativePath( + std::filesystem::path(L"candidate") / L"ViiperUde.inf") || + IsSafeRecoveryRelativePath(std::filesystem::path(L"..") / L"escape") || + IsSafeRecoveryRelativePath( + std::filesystem::path(L"C:\\Windows\\INF\\oem1.inf"))) { + return SetError(error, L"self-test-install-journal-security-model", + ERROR_INVALID_DATA); + } + InstallJournalStateData finalBindingModel; + finalBindingModel.phase = InstallJournalPhase::BrokerOuterSettled; + finalBindingModel.lastDigest = std::string(64, 'a'); + finalBindingModel.brokerDriverPendingDigest = std::string(64, 'b'); + if (!IsBrokerOuterSettlementContinuationPhase( + InstallJournalPhase::BrokerOuterSettled) || + IsBrokerOuterSettlementContinuationPhase( + InstallJournalPhase::Prepared) || + BrokerOuterSettlementPendingDriverDigest(finalBindingModel) != + finalBindingModel.brokerDriverPendingDigest || + ClassifyBrokerOuterRecoveryModel( + InstallJournalPhase::BrokerChildEntered, + false, false, false, false) != + BrokerOuterRecoveryModelAction::ReplayChild || + ClassifyBrokerOuterRecoveryModel( + InstallJournalPhase::BrokerChildSettled, + true, true, true, false) != + BrokerOuterRecoveryModelAction::ValidateForward || + ClassifyBrokerOuterRecoveryModel( + InstallJournalPhase::ForwardValidated, + true, true, true, false) != + BrokerOuterRecoveryModelAction::PublishPending || + ClassifyBrokerOuterRecoveryModel( + InstallJournalPhase::BrokerOuterSettlementPending, + true, true, true, false) != + BrokerOuterRecoveryModelAction::EmitBinding || + ClassifyBrokerOuterRecoveryModel( + InstallJournalPhase::BrokerOuterSettled, + true, true, true, false) != + BrokerOuterRecoveryModelAction::ReplayAcknowledgement || + ClassifyBrokerOuterRecoveryModel( + InstallJournalPhase::BrokerChildSettled, + true, false, true, false) != + BrokerOuterRecoveryModelAction::Manual) { + return SetError(error, + L"self-test-broker-outer-cutpoint-model", + ERROR_INVALID_DATA, + L"child exit, proof, pending, acknowledgement, or retirement cut was not classified deterministically"); + } + uint64_t recordSequence = 0; + if (!ParseJournalRecordFileName( + L"journal-00000042.json", &recordSequence) || + recordSequence != 42U || + ParseJournalRecordFileName(L"journal-42.json", &recordSequence) || + ParseJournalRecordFileName( + L"journal-00000042.json.tmp", &recordSequence) || + !ParseJournalTemporaryFileName( + L"journal-00000042.json.tmp", &recordSequence) || + recordSequence != 42U || + ParseJournalTemporaryFileName( + L"journal-00000042.json.tmp.tmp", &recordSequence) || + !InstallJournalTemporarySequenceIsRecoverable(42U, 42U) || + InstallJournalTemporarySequenceIsRecoverable(41U, 42U) || + InstallJournalTemporarySequenceIsRecoverable(43U, 42U)) { + return SetError(error, L"self-test-install-journal-cutpoint", + ERROR_INVALID_DATA); + } + + InstallJournalStateData state; + state.transactionId = std::string(64, 'a'); + state.bootIdentifier = std::string(32, 'b'); + state.sourceRevision = std::string(40, 'c'); + state.candidate.version.parts = {1, 2, 3, 4}; + state.candidate.infSha256 = std::string(64, 'd'); + state.candidate.sysSha256 = std::string(64, 'e'); + state.candidate.catSha256 = std::string(64, 'f'); + std::string payload; + std::string digest; + if (!BuildInstallJournalPayload(state, &payload, error) || + !Sha256Data(payload, &digest, error)) { + return false; + } + Error transitionError; + if (!ValidateInstallJournalTransition(nullptr, state, + &transitionError)) { + *error = std::move(transitionError); + return false; + } + InstallJournalStateData entered = state; + entered.sequence = 1U; + entered.phase = InstallJournalPhase::SetupCopyEntered; + InstallJournalStateData returned = entered; + returned.sequence = 2U; + returned.phase = InstallJournalPhase::SetupCopyReturned; + InstallJournalStateData receipt = returned; + receipt.sequence = 3U; + receipt.phase = InstallJournalPhase::StageReceiptCaptured; + if (!ValidateInstallJournalTransition(&state, entered, error) || + !ValidateInstallJournalTransition(&entered, returned, error) || + !ValidateInstallJournalTransition(&returned, receipt, error)) { + return false; + } + InstallJournalStateData illegalSkip = state; + illegalSkip.sequence = 1U; + illegalSkip.phase = InstallJournalPhase::BrokerChildEntered; + Error illegalTransition; + if (ValidateInstallJournalTransition( + &state, illegalSkip, &illegalTransition) || + illegalTransition.code == ERROR_SUCCESS) { + return SetError(error, L"self-test-install-journal-phase-chain", + ERROR_INVALID_DATA); + } + InstallJournalStateData sticky = returned; + sticky.deadlineOverrun = true; + InstallJournalStateData cleared = sticky; + cleared.phase = InstallJournalPhase::StageReceiptCaptured; + cleared.deadlineOverrun = false; + Error stickyError; + if (ValidateInstallJournalTransition( + &sticky, cleared, &stickyError) || + stickyError.code == ERROR_SUCCESS) { + return SetError(error, L"self-test-install-journal-sticky-chain", + ERROR_INVALID_DATA); + } + for (InstallJournalPhase interrupted : { + InstallJournalPhase::RollbackBindingEntered, + InstallJournalPhase::RootRegistrationEntered, + InstallJournalPhase::RootRegistrationReturned, + InstallJournalPhase::DiInstallEntered, + InstallJournalPhase::DiInstallReturned, + InstallJournalPhase::SetupUninstallEntered, + InstallJournalPhase::SetupUninstallReturned, + InstallJournalPhase::RollbackBindingReturned}) { + InstallJournalStateData interruptedState = state; + interruptedState.direction = InstallJournalDirection::Rollback; + interruptedState.rollbackAuthorized = true; + interruptedState.phase = interrupted; + InstallJournalStateData readmitted = interruptedState; + readmitted.phase = InstallJournalPhase::RollbackBindingEntered; + if (!ValidateInstallJournalTransition( + &interruptedState, readmitted, error)) { + return false; + } + } + struct CanonicalBrokerProofCase { + bool success; + bool changed; + const char* rollback; + DWORD exitCode; + bool rollbackAuthorized; + }; + constexpr std::array brokerProofCases{{ + {true, false, "not-needed", 0U, false}, + {true, true, "not-needed", 0U, false}, + {false, false, "not-needed", 4U, true}, + {false, true, "succeeded", 1U, true}, + {false, true, "failed", 3U, false}, + }}; + for (const CanonicalBrokerProofCase& proof : brokerProofCases) { + if (!BrokerProofFieldsAreCanonical( + proof.success, proof.changed, proof.rollback, + proof.exitCode, proof.rollbackAuthorized)) { + return SetError(error, + L"self-test-install-journal-broker-proof", + ERROR_INVALID_DATA); + } + } + if (BrokerProofFieldsAreCanonical( + false, false, "not-needed", 1U, true)) { + return SetError(error, + L"self-test-install-journal-broker-proof", + ERROR_INVALID_DATA); + } + std::string envelope = "{\"schema\":2,\"kind\":"; + AppendJsonAsciiString(&envelope, kInstallRecoveryKind); + envelope.append(",\"payloadSha256\":"); + AppendJsonAsciiString(&envelope, digest); + envelope.append(",\"payload\":"); + AppendJsonUtf8String(&envelope, payload); + envelope.append("}\n"); + InstallJournalStateData parsedState; + std::string parsedDigest; + const std::filesystem::path modelRoot = + std::filesystem::path(L"C:\\ProgramData\\VIIPER\\UdeCx\\Transactions\\active-v2"); + if (!ParseInstallJournalEnvelope( + envelope, modelRoot, &parsedState, &parsedDigest, error) || + parsedDigest != digest || parsedState.sequence != 0U || + parsedState.previousDigest != kZeroSha256 || + !SameJournalPackageIdentity( + parsedState.candidate, state.candidate)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"self-test-install-journal-chain", + ERROR_INVALID_DATA); + } + return false; + } + for (const CanonicalBrokerProofCase& proof : brokerProofCases) { + InstallJournalStateData proofState = state; + proofState.phase = InstallJournalPhase::BrokerChildSettled; + proofState.brokerRequired = true; + proofState.brokerExecutableSha256 = std::string(64, '1'); + proofState.brokerTokenPath = + L"C:\\ProgramData\\VIIPER\\package.token"; + proofState.brokerTargetUserSid = modelTargetUserSid; + proofState.brokerEntered = true; + proofState.brokerSettled = true; + proofState.hasBrokerProof = true; + proofState.brokerProofSuccess = proof.success; + proofState.brokerProofChanged = proof.changed; + proofState.brokerProofRollback = proof.rollback; + proofState.brokerProofExitCode = proof.exitCode; + proofState.brokerDriverRollbackAuthorized = + proof.rollbackAuthorized; + if (proof.changed) { + proofState.brokerJournalTransactionId = + std::string(32, '9'); + proofState.brokerJournalOuterTransactionId = + proofState.transactionId; + proofState.brokerJournalCandidateSha256 = + proofState.brokerExecutableSha256; + proofState.brokerJournalState = proof.success + ? "nested-ready" + : proof.rollbackAuthorized + ? "rollback-settled" : "manual"; + proofState.brokerJournalDigest = std::string(64, '8'); + } + proofState.rollbackAuthorized = proof.rollbackAuthorized; + proofState.direction = proof.rollbackAuthorized + ? InstallJournalDirection::Rollback + : InstallJournalDirection::Forward; + std::string proofPayload; + std::string proofDigest; + if (!BuildInstallJournalPayload(proofState, &proofPayload, error) || + !Sha256Data(proofPayload, &proofDigest, error)) { + return false; + } + std::string proofEnvelope = "{\"schema\":2,\"kind\":"; + AppendJsonAsciiString(&proofEnvelope, kInstallRecoveryKind); + proofEnvelope.append(",\"payloadSha256\":"); + AppendJsonAsciiString(&proofEnvelope, proofDigest); + proofEnvelope.append(",\"payload\":"); + AppendJsonUtf8String(&proofEnvelope, proofPayload); + proofEnvelope.append("}\n"); + InstallJournalStateData proofRoundTrip; + std::string observedProofDigest; + if (!ParseInstallJournalEnvelope( + proofEnvelope, modelRoot, &proofRoundTrip, + &observedProofDigest, error) || + observedProofDigest != proofDigest || + !SameDurableBrokerProof(proofState, proofRoundTrip) || + proofRoundTrip.direction != proofState.direction || + proofRoundTrip.rollbackAuthorized != + proofState.rollbackAuthorized) { + return SetError(error, + L"self-test-install-journal-broker-proof-roundtrip", + ERROR_INVALID_DATA); + } + } + BrokerSettlementRequestData settlementRequest; + settlementRequest.binding.brokerTransactionId = + std::string(32, '1'); + settlementRequest.binding.brokerOuterTransactionId = + std::string(64, '2'); + settlementRequest.binding.brokerCandidateSha256 = + std::string(64, '3'); + settlementRequest.binding.brokerNestedDigest = + std::string(64, '4'); + settlementRequest.binding.driverTransactionId = + std::string(64, '5'); + settlementRequest.binding.driverPendingDigest = + std::string(64, '6'); + settlementRequest.binding.settlementNonce = + std::string(64, '7'); + settlementRequest.brokerPendingDigest = std::string(64, '8'); + std::string settlementBindingJson; + AppendBrokerSettlementBindingJson( + &settlementBindingJson, settlementRequest.binding); + if (!Sha256Data(settlementBindingJson, + &settlementRequest.bindingSha256, error)) { + return false; + } + std::string settlementPayload = + "{\"schema\":1,\"bindingSha256\":"; + AppendJsonAsciiString( + &settlementPayload, settlementRequest.bindingSha256); + settlementPayload.append(",\"brokerPendingDigest\":"); + AppendJsonAsciiString( + &settlementPayload, settlementRequest.brokerPendingDigest); + settlementPayload.append(",\"binding\":"); + settlementPayload.append(settlementBindingJson); + settlementPayload.push_back('}'); + if (!Sha256Data(settlementPayload, + &settlementRequest.payloadSha256, error)) { + return false; + } + std::string canonicalSettlementPayload; + std::string canonicalSettlementEnvelope; + BrokerSettlementRequestData parsedSettlement; + Error malformedSettlementError; + if (!BuildBrokerSettlementRequestJson(settlementRequest, + &canonicalSettlementPayload, + &canonicalSettlementEnvelope, error) || + !ParseBrokerSettlementRequest(canonicalSettlementEnvelope, + &parsedSettlement, error) || + parsedSettlement.binding.driverPendingDigest != + settlementRequest.binding.driverPendingDigest || + parsedSettlement.requestSha256.empty() || + ParseBrokerSettlementRequest( + canonicalSettlementEnvelope + " ", + &parsedSettlement, &malformedSettlementError) || + malformedSettlementError.code == ERROR_SUCCESS) { + return SetError(error, + L"self-test-broker-settlement-envelope", + ERROR_INVALID_DATA, + L"canonical settlement parsing or corruption rejection changed"); + } + BrokerSettlementFinalData settlementFinal; + settlementFinal.brokerTransactionId = + settlementRequest.binding.brokerTransactionId; + settlementFinal.brokerPendingDigest = + settlementRequest.brokerPendingDigest; + settlementFinal.brokerSettledDigest = std::string(64, 'a'); + settlementFinal.driverTransactionId = + settlementRequest.binding.driverTransactionId; + settlementFinal.driverPendingDigest = + settlementRequest.binding.driverPendingDigest; + settlementFinal.driverSettledDigest = std::string(64, 'b'); + settlementFinal.settlementNonce = + settlementRequest.binding.settlementNonce; + settlementFinal.requestSha256 = + parsedSettlement.requestSha256; + settlementFinal.state = "outer-settled"; + std::string settlementFinalPayload = + "{\"schema\":1,\"brokerTransactionId\":"; + AppendJsonAsciiString(&settlementFinalPayload, + settlementFinal.brokerTransactionId); + settlementFinalPayload.append(",\"brokerPendingDigest\":"); + AppendJsonAsciiString(&settlementFinalPayload, + settlementFinal.brokerPendingDigest); + settlementFinalPayload.append(",\"brokerSettledDigest\":"); + AppendJsonAsciiString(&settlementFinalPayload, + settlementFinal.brokerSettledDigest); + settlementFinalPayload.append(",\"driverTransactionId\":"); + AppendJsonAsciiString(&settlementFinalPayload, + settlementFinal.driverTransactionId); + settlementFinalPayload.append(",\"driverPendingDigest\":"); + AppendJsonAsciiString(&settlementFinalPayload, + settlementFinal.driverPendingDigest); + settlementFinalPayload.append(",\"driverSettledDigest\":"); + AppendJsonAsciiString(&settlementFinalPayload, + settlementFinal.driverSettledDigest); + settlementFinalPayload.append(",\"settlementNonce\":"); + AppendJsonAsciiString(&settlementFinalPayload, + settlementFinal.settlementNonce); + settlementFinalPayload.append(",\"requestSha256\":"); + AppendJsonAsciiString(&settlementFinalPayload, + settlementFinal.requestSha256); + settlementFinalPayload.append(",\"state\":\"outer-settled\"}"); + if (!Sha256Data(settlementFinalPayload, + &settlementFinal.payloadSha256, error)) { + return false; + } + std::string canonicalFinalPayload; + std::string canonicalFinalEnvelope; + BrokerSettlementFinalData parsedFinal; + Error malformedFinalError; + if (!BuildBrokerSettlementFinalJson(settlementFinal, + &canonicalFinalPayload, &canonicalFinalEnvelope, error) || + !ParseBrokerSettlementFinal(canonicalFinalEnvelope, + &parsedFinal, error) || + !ValidateBrokerSettlementFinalBinding( + parsedSettlement, parsedFinal, error) || + parsedFinal.brokerSettledDigest != + settlementFinal.brokerSettledDigest || + parsedFinal.receiptSha256.empty() || + ParseBrokerSettlementFinal(canonicalFinalEnvelope + " ", + &parsedFinal, &malformedFinalError) || + malformedFinalError.code == ERROR_SUCCESS) { + return SetError(error, + L"self-test-broker-settlement-final-envelope", + ERROR_INVALID_DATA, + L"canonical final receipt parsing or corruption rejection changed"); + } + InstallJournalStateData outerChild = state; + outerChild.phase = InstallJournalPhase::BrokerChildSettled; + outerChild.brokerRequired = true; + outerChild.brokerExecutableSha256 = std::string(64, '3'); + outerChild.brokerTokenPath = + L"C:\\ProgramData\\VIIPER\\package.token"; + outerChild.brokerTargetUserSid = modelTargetUserSid; + outerChild.brokerEntered = true; + outerChild.brokerSettled = true; + outerChild.hasBrokerProof = true; + outerChild.brokerProofSuccess = true; + outerChild.brokerProofChanged = true; + outerChild.brokerProofRollback = "not-needed"; + outerChild.brokerProofExitCode = ERROR_SUCCESS; + outerChild.brokerJournalTransactionId = std::string(32, '1'); + outerChild.brokerJournalOuterTransactionId = + outerChild.transactionId; + outerChild.brokerJournalCandidateSha256 = + outerChild.brokerExecutableSha256; + outerChild.brokerJournalState = "nested-ready"; + outerChild.brokerJournalDigest = std::string(64, '4'); + InstallJournalStateData outerForward = outerChild; + outerForward.phase = InstallJournalPhase::ForwardValidated; + InstallJournalStateData outerPending = outerForward; + outerPending.phase = + InstallJournalPhase::BrokerOuterSettlementPending; + outerPending.brokerSettlementNonce = std::string(64, '7'); + InstallJournalStateData outerFinal = outerPending; + outerFinal.phase = InstallJournalPhase::BrokerOuterSettled; + outerFinal.brokerDriverPendingDigest = std::string(64, '6'); + outerFinal.brokerSettlementRequestSha256 = std::string(64, '9'); + outerFinal.brokerGoPendingDigest = std::string(64, '8'); + if (!ValidateInstallJournalTransition( + &outerChild, outerForward, error) || + !ValidateInstallJournalTransition( + &outerForward, outerPending, error) || + !ValidateInstallJournalTransition( + &outerPending, outerFinal, error)) { + return SetError(error, + L"self-test-broker-settlement-phase-chain", + ERROR_INVALID_DATA); + } + InstallJournalStateData durableReceipt = returned; + durableReceipt.phase = InstallJournalPhase::StageReceiptCaptured; + durableReceipt.hasPublishedCandidate = true; + durableReceipt.publishedCandidate = durableReceipt.candidate; + durableReceipt.publishedCandidate.publishedName = L"oem42.inf"; + durableReceipt.packageStagedHere = true; + durableReceipt.expectedInventory.push_back( + durableReceipt.publishedCandidate); + if (!ValidateInstallJournalTransition( + &returned, durableReceipt, error)) { + return false; + } + InstallJournalStateData rootIntent = durableReceipt; + rootIntent.phase = + InstallJournalPhase::RootRegistrationIntentCaptured; + rootIntent.hasRootRegistrationIntent = true; + rootIntent.rootRegistrationInstanceId = + L"ROOT\\VIIPERUDE\\0042"; + InstallJournalStateData rootRegistrationEntered = rootIntent; + rootRegistrationEntered.phase = + InstallJournalPhase::RootRegistrationEntered; + rootRegistrationEntered.bindingMutationStarted = true; + if (!ValidateInstallJournalTransition( + &durableReceipt, rootIntent, error) || + !ValidateInstallJournalTransition( + &rootIntent, rootRegistrationEntered, error)) { + return false; + } + InstallJournalStateData changedRootIntent = + rootRegistrationEntered; + changedRootIntent.phase = + InstallJournalPhase::RootRegistrationReturned; + changedRootIntent.rootRegistrationInstanceId = + L"ROOT\\VIIPERUDE\\0043"; + Error changedRootIntentError; + if (ValidateInstallJournalTransition( + &rootRegistrationEntered, changedRootIntent, + &changedRootIntentError) || + changedRootIntentError.code == ERROR_SUCCESS) { + return SetError(error, + L"self-test-install-journal-root-intent-chain", + ERROR_INVALID_DATA); + } + std::string rootIntentPayload; + if (!BuildInstallJournalPayload( + rootIntent, &rootIntentPayload, error) || + rootIntentPayload.find( + "\"rootRegistrationInstanceId\":\"ROOT\\\\VIIPERUDE\\\\0042\"") == + std::string::npos) { + return SetError(error, + L"self-test-install-journal-root-intent-roundtrip", + ERROR_INVALID_DATA); + } + + PartialInstallRootRecoveryFacts partialRoot; + partialRoot.priorEmpty = true; + partialRoot.bindingMutationStarted = true; + partialRoot.forwardRootRegistrationEntered = true; + if (ClassifyPartialInstallRootRecovery(partialRoot) != + PartialInstallRootRecoveryAction::PriorEmpty) { + return SetError(error, + L"self-test-install-journal-partial-root-before-register", + ERROR_INVALID_DATA); + } + partialRoot.relatedRootCount = 1U; + partialRoot.exactHardwareId = true; + partialRoot.exactClass = true; + partialRoot.exactGeneratedInstance = true; + partialRoot.present = true; + partialRoot.serviceEmpty = true; + partialRoot.publishedInfEmpty = true; + partialRoot.driverVersionEmpty = true; + if (ClassifyPartialInstallRootRecovery(partialRoot) != + PartialInstallRootRecoveryAction::RemoveUnboundExactRoot) { + return SetError(error, + L"self-test-install-journal-partial-root-after-register", + ERROR_INVALID_DATA, + L"failed/timed-out DIF_REGISTER exact receipt root was not cleanup-authorized"); + } + partialRoot.serviceEmpty = false; + partialRoot.publishedInfEmpty = false; + partialRoot.driverVersionEmpty = false; + partialRoot.exactCandidateService = true; + partialRoot.exactCandidateInf = true; + partialRoot.exactCandidateVersion = true; + partialRoot.exactCandidateBytes = true; + if (ClassifyPartialInstallRootRecovery(partialRoot) != + PartialInstallRootRecoveryAction::Manual) { + return SetError(error, + L"self-test-install-journal-partial-root-before-diinstall", + ERROR_INVALID_DATA); + } + partialRoot.forwardDiInstallEntered = true; + if (ClassifyPartialInstallRootRecovery(partialRoot) != + PartialInstallRootRecoveryAction::RemoveCandidateBoundExactRoot) { + return SetError(error, + L"self-test-install-journal-partial-root-after-diinstall", + ERROR_INVALID_DATA); + } + PartialInstallRootRecoveryFacts pendingRoot = partialRoot; + pendingRoot.partialRootRemovalEntered = true; + pendingRoot.present = false; + pendingRoot.pendingRemovalLifecycle = true; + if (ClassifyPartialInstallRootRecovery(pendingRoot) != + PartialInstallRootRecoveryAction::PendingExactRootRemoval) { + return SetError(error, + L"self-test-install-journal-partial-root-pending-candidate", + ERROR_INVALID_DATA); + } + pendingRoot.exactHardwareId = false; + pendingRoot.hardwareIdAbsent = true; + pendingRoot.serviceEmpty = true; + pendingRoot.exactCandidateService = false; + pendingRoot.driverVersionEmpty = true; + pendingRoot.exactCandidateVersion = false; + if (ClassifyPartialInstallRootRecovery(pendingRoot) != + PartialInstallRootRecoveryAction::PendingExactRootRemoval) { + return SetError(error, + L"self-test-install-journal-partial-root-pending-cleared", + ERROR_INVALID_DATA); + } + pendingRoot.pendingRemovalLifecycle = false; + if (ClassifyPartialInstallRootRecovery(pendingRoot) != + PartialInstallRootRecoveryAction::Manual) { + return SetError(error, + L"self-test-install-journal-partial-root-not-pending", + ERROR_INVALID_DATA); + } + for (const auto mutateUnauthorized : { + 0, 1, 2, 3, 4, 5}) { + PartialInstallRootRecoveryFacts unauthorized = partialRoot; + switch (mutateUnauthorized) { + case 0: unauthorized.relatedRootCount = 2U; break; + case 1: unauthorized.exactHardwareId = false; break; + case 2: unauthorized.exactClass = false; break; + case 3: unauthorized.exactGeneratedInstance = false; break; + case 4: unauthorized.present = false; break; + case 5: unauthorized.forwardRootRegistrationEntered = false; break; + } + if (ClassifyPartialInstallRootRecovery(unauthorized) != + PartialInstallRootRecoveryAction::Manual) { + return SetError(error, + L"self-test-install-journal-partial-root-manual", + ERROR_INVALID_DATA); + } + } + if (InstallJournalRecoveryUsesStrictBindingRestore( + rootRegistrationEntered)) { + return SetError(error, + L"self-test-install-journal-prior-empty-strict-restore", + ERROR_INVALID_DATA); + } + + InstallJournalStateData partialRollback = rootRegistrationEntered; + partialRollback.sequence += 1U; + partialRollback.phase = InstallJournalPhase::RollbackBindingEntered; + partialRollback.direction = InstallJournalDirection::Rollback; + partialRollback.rollbackAuthorized = true; + InstallJournalStateData partialRemovalEntered = partialRollback; + partialRemovalEntered.sequence += 1U; + partialRemovalEntered.phase = + InstallJournalPhase::PartialRootRemovalEntered; + partialRemovalEntered.partialRootRemovalBootIdentifier = + std::string(32, '1'); + partialRemovalEntered.partialRootRemovalBinding = + InstallJournalStateData::PartialRootRemovalBinding::Unbound; + InstallJournalStateData partialRemovalReturned = + partialRemovalEntered; + partialRemovalReturned.sequence += 1U; + partialRemovalReturned.phase = + InstallJournalPhase::PartialRootRemovalReturned; + InstallJournalStateData partialRemovalPending = + partialRemovalEntered; + partialRemovalPending.sequence += 1U; + partialRemovalPending.phase = InstallJournalPhase:: + PartialRootRemovalRebootPending; + partialRemovalPending.rebootRequired = true; + partialRemovalPending.callSucceeded = false; + partialRemovalPending.callError = ERROR_SUCCESS_REBOOT_REQUIRED; + if (!ValidateInstallJournalTransition( + &rootRegistrationEntered, partialRollback, error) || + !ValidateInstallJournalTransition( + &partialRollback, partialRemovalEntered, error) || + !ValidateInstallJournalTransition( + &partialRemovalEntered, partialRemovalReturned, error) || + !ValidateInstallJournalTransition( + &partialRemovalEntered, partialRemovalPending, error)) { + return false; + } + InstallJournalStateData illegalRemovalShape = + partialRemovalReturned; + illegalRemovalShape.partialRootRemovalBinding = + InstallJournalStateData::PartialRootRemovalBinding::Candidate; + Error illegalRemovalShapeError; + if (ValidateInstallJournalTransition( + &partialRemovalEntered, illegalRemovalShape, + &illegalRemovalShapeError) || + illegalRemovalShapeError.code == ERROR_SUCCESS) { + return SetError(error, + L"self-test-install-journal-partial-root-shape-chain", + ERROR_INVALID_DATA); + } + std::string partialRemovalPayload; + std::string partialRemovalDigest; + if (!BuildInstallJournalPayload( + partialRemovalEntered, &partialRemovalPayload, error) || + partialRemovalPayload.find( + "\"partialRootRemovalBinding\":\"unbound\"") == + std::string::npos || + !Sha256Data(partialRemovalPayload, + &partialRemovalDigest, error)) { + return false; + } + InstallJournalStateData partialRemovalFreshReturn = + partialRemovalEntered; + partialRemovalFreshReturn.sequence += 1U; + partialRemovalFreshReturn.phase = + InstallJournalPhase::PartialRootRemovalReturned; + partialRemovalFreshReturn.rebootRequired = true; + partialRemovalFreshReturn.freshRebootRequired = true; + partialRemovalFreshReturn.pendingRebootBootIdentifier = + std::string(32, '2'); + InstallJournalStateData partialRemovalFreshPending = + partialRemovalFreshReturn; + partialRemovalFreshPending.sequence += 1U; + partialRemovalFreshPending.phase = InstallJournalPhase:: + PartialRootRemovalRebootPending; + partialRemovalFreshPending.freshRebootRequired = false; + if (!ValidateInstallJournalTransition( + &partialRemovalEntered, partialRemovalFreshReturn, error) || + !ValidateInstallJournalTransition( + &partialRemovalFreshReturn, + partialRemovalFreshPending, error)) { + return false; + } + std::string partialFreshPayload; + std::string partialFreshDigest; + if (!BuildInstallJournalPayload( + partialRemovalFreshReturn, &partialFreshPayload, error) || + !Sha256Data(partialFreshPayload, + &partialFreshDigest, error)) { + return false; + } + std::string partialFreshEnvelope = "{\"schema\":2,\"kind\":"; + AppendJsonAsciiString(&partialFreshEnvelope, kInstallRecoveryKind); + partialFreshEnvelope.append(",\"payloadSha256\":"); + AppendJsonAsciiString(&partialFreshEnvelope, partialFreshDigest); + partialFreshEnvelope.append(",\"payload\":"); + AppendJsonUtf8String(&partialFreshEnvelope, partialFreshPayload); + partialFreshEnvelope.append("}\n"); + InstallJournalStateData partialFreshRoundTrip; + std::string observedPartialFreshDigest; + if (!ParseInstallJournalEnvelope( + partialFreshEnvelope, modelRoot, &partialFreshRoundTrip, + &observedPartialFreshDigest, error) || + observedPartialFreshDigest != partialFreshDigest || + partialFreshRoundTrip.partialRootRemovalBinding != + InstallJournalStateData::PartialRootRemovalBinding::Unbound || + partialFreshRoundTrip.partialRootRemovalBootIdentifier != + partialRemovalFreshReturn.partialRootRemovalBootIdentifier || + partialFreshRoundTrip.pendingRebootBootIdentifier != + partialRemovalFreshReturn.pendingRebootBootIdentifier || + !partialFreshRoundTrip.freshRebootRequired) { + return SetError(error, + L"self-test-install-journal-partial-root-roundtrip", + ERROR_INVALID_DATA); + } + for (const InstallJournalStateData* interruptedPartial : { + &partialRemovalReturned, &partialRemovalFreshPending}) { + InstallJournalStateData readmittedPartial = + *interruptedPartial; + readmittedPartial.sequence += 1U; + readmittedPartial.phase = + InstallJournalPhase::RollbackBindingEntered; + readmittedPartial.freshRebootRequired = false; + if (!ValidateInstallJournalTransition( + interruptedPartial, readmittedPartial, error)) { + return false; + } + } + struct PartialRemovalRecoveryCase { + InstallJournalPhase phase; + bool callSucceeded; + bool freshRebootRequired; + bool sameBoot; + InstallJournalStateData::PartialRootRemovalBinding binding; + PartialInstallRootRecoveryAction root; + PartialRootRemovalRecoveryDisposition expected; + }; + const std::array + partialRemovalCases{{ + {InstallJournalPhase::PartialRootRemovalEntered, + true, false, true, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::RemoveUnboundExactRoot, + PartialRootRemovalRecoveryDisposition::RetryRemoval}, + {InstallJournalPhase::PartialRootRemovalEntered, + true, false, true, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::RemoveCandidateBoundExactRoot, + PartialRootRemovalRecoveryDisposition::Manual}, + {InstallJournalPhase::PartialRootRemovalEntered, + true, false, true, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PriorEmpty, + PartialRootRemovalRecoveryDisposition::RebootPending}, + {InstallJournalPhase::PartialRootRemovalEntered, + true, false, false, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PriorEmpty, + PartialRootRemovalRecoveryDisposition::ContinueRollback}, + {InstallJournalPhase::PartialRootRemovalEntered, + true, false, false, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::RemoveUnboundExactRoot, + PartialRootRemovalRecoveryDisposition::Manual}, + {InstallJournalPhase::PartialRootRemovalReturned, + true, false, true, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PriorEmpty, + PartialRootRemovalRecoveryDisposition::ContinueRollback}, + {InstallJournalPhase::PartialRootRemovalReturned, + true, false, true, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PendingExactRootRemoval, + PartialRootRemovalRecoveryDisposition::Manual}, + {InstallJournalPhase::PartialRootRemovalReturned, + true, true, true, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PendingExactRootRemoval, + PartialRootRemovalRecoveryDisposition::RebootPending}, + {InstallJournalPhase::PartialRootRemovalReturned, + true, true, false, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PriorEmpty, + PartialRootRemovalRecoveryDisposition::ContinueRollback}, + {InstallJournalPhase::PartialRootRemovalReturned, + false, false, true, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PriorEmpty, + PartialRootRemovalRecoveryDisposition::Manual}, + {InstallJournalPhase::PartialRootRemovalRebootPending, + true, false, true, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PendingExactRootRemoval, + PartialRootRemovalRecoveryDisposition::RebootPending}, + {InstallJournalPhase::PartialRootRemovalRebootPending, + true, false, false, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PriorEmpty, + PartialRootRemovalRecoveryDisposition::ContinueRollback}, + {InstallJournalPhase::PartialRootRemovalRebootPending, + true, false, false, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PendingExactRootRemoval, + PartialRootRemovalRecoveryDisposition::Manual}, + }}; + for (const PartialRemovalRecoveryCase& test : + partialRemovalCases) { + if (ClassifyPartialRootRemovalJournalRecovery( + test.phase, test.callSucceeded, + test.freshRebootRequired, test.sameBoot, + test.binding, test.root) != test.expected) { + return SetError(error, + L"self-test-install-journal-partial-root-recovery-matrix", + ERROR_INVALID_DATA); + } + } + + std::vector canonicalHardwareId( + std::begin(kHardwareId), std::end(kHardwareId)); + canonicalHardwareId.push_back(L'\0'); + InstallRecoveryHardwareIdObservation hardwareObservation; + std::vector malformedHardwareId = canonicalHardwareId; + malformedHardwareId.push_back(L'x'); + if (!ClassifyCanonicalInstallRecoveryHardwareIds( + canonicalHardwareId, &hardwareObservation) || + !hardwareObservation.containsExpected || + !hardwareObservation.exact || + ClassifyCanonicalInstallRecoveryHardwareIds( + malformedHardwareId, &hardwareObservation)) { + return SetError(error, + L"self-test-install-journal-raw-hardware-id", + ERROR_INVALID_DATA); + } + const std::vector canonicalService{ + L'V', L'i', L'i', L'p', L'e', L'r', L'U', L'd', L'e', L'\0'}; + std::vector hiddenService = canonicalService; + hiddenService.push_back(L'x'); + hiddenService.push_back(L'\0'); + const std::vector hiddenAfterEmpty{ + L'\0', L'x', L'\0'}; + std::wstring decodedService; + if (!DecodeCanonicalInstallRecoveryString( + canonicalService, &decodedService) || + decodedService != kServiceName || + DecodeCanonicalInstallRecoveryString( + hiddenService, &decodedService) || + DecodeCanonicalInstallRecoveryString( + hiddenAfterEmpty, &decodedService)) { + return SetError(error, + L"self-test-install-journal-raw-string", + ERROR_INVALID_DATA); + } + InstallJournalStateData ambiguousOwnership = durableReceipt; + ambiguousOwnership.phase = InstallJournalPhase::RollbackBindingEntered; + ambiguousOwnership.direction = InstallJournalDirection::Rollback; + ambiguousOwnership.rollbackAuthorized = true; + Error ownershipError; + if (ValidateInstallJournalTransition( + &returned, ambiguousOwnership, &ownershipError) || + ownershipError.code == ERROR_SUCCESS) { + return SetError(error, + L"self-test-install-journal-stage-ownership", + ERROR_INVALID_DATA); + } + + InstallJournalStateData rootAuthority = durableReceipt; + rootAuthority.bindingMutationStarted = true; + DeviceState priorDevice; + priorDevice.instanceId = L"ROOT\\VIIPERUDE\\0000"; + priorDevice.present = true; + priorDevice.service = kServiceName; + priorDevice.publishedInf = L"oem41.inf"; + priorDevice.version.parts = {1, 2, 3, 3}; + priorDevice.package = rootAuthority.candidate; + priorDevice.package.version = priorDevice.version; + priorDevice.package.publishedName = priorDevice.publishedInf; + rootAuthority.prior.devices = {priorDevice}; + Snapshot priorRootSnapshot; + priorRootSnapshot.devices = {priorDevice}; + Snapshot candidateRootSnapshot = priorRootSnapshot; + candidateRootSnapshot.devices[0].publishedInf = + rootAuthority.publishedCandidate.publishedName; + candidateRootSnapshot.devices[0].version = rootAuthority.candidate.version; + candidateRootSnapshot.devices[0].package = rootAuthority.candidate; + candidateRootSnapshot.devices[0].package.publishedName = + rootAuthority.publishedCandidate.publishedName; + Snapshot foreignRootSnapshot = candidateRootSnapshot; + foreignRootSnapshot.devices[0].instanceId = L"ROOT\\VIIPERUDE\\9999"; + Snapshot extraRootSnapshot = candidateRootSnapshot; + extraRootSnapshot.devices.push_back(candidateRootSnapshot.devices[0]); + if (!RootSnapshotIsAuthorizedForInstallRollback( + rootAuthority, priorRootSnapshot) || + !RootSnapshotIsAuthorizedForInstallRollback( + rootAuthority, candidateRootSnapshot) || + RootSnapshotIsAuthorizedForInstallRollback( + rootAuthority, foreignRootSnapshot) || + RootSnapshotIsAuthorizedForInstallRollback( + rootAuthority, extraRootSnapshot)) { + return SetError(error, + L"self-test-install-journal-root-authority", + ERROR_INVALID_DATA); + } + std::vector exactInventory{ + priorDevice.package, rootAuthority.publishedCandidate}; + std::vector extraInventory = exactInventory; + PackageInfo externalPackage = rootAuthority.candidate; + externalPackage.publishedName = L"oem99.inf"; + externalPackage.version.parts[3] += 5; + extraInventory.push_back(externalPackage); + std::vector conflictingInventory = exactInventory; + conflictingInventory[1].sysSha256[0] = '0'; + if (SamePackageInventory(exactInventory, extraInventory) || + SamePackageInventory(exactInventory, conflictingInventory)) { + return SetError(error, + L"self-test-install-journal-inventory-authority", + ERROR_INVALID_DATA); + } + InstallJournalStateData rebootCutpoint = state; + rebootCutpoint.direction = InstallJournalDirection::Rollback; + rebootCutpoint.rollbackAuthorized = true; + rebootCutpoint.bindingMutationStarted = true; + rebootCutpoint.phase = InstallJournalPhase::RollbackBindingReturned; + rebootCutpoint.callSucceeded = true; + rebootCutpoint.rebootRequired = true; + rebootCutpoint.freshRebootRequired = true; + rebootCutpoint.pendingRebootBootIdentifier = + std::string(32, 'd'); + if (!InstallJournalNeedsRestoreRebootPending(rebootCutpoint, true) || + InstallJournalNeedsRestoreRebootPending(rebootCutpoint, false) || + !InstallJournalRollbackRetryRebootSeed(rebootCutpoint, true) || + InstallJournalRollbackRetryRebootSeed(rebootCutpoint, false) || + !InstallJournalHasAuthoritativeRollbackSettlement( + rebootCutpoint)) { + return SetError(error, + L"self-test-install-journal-reboot-cutpoint", + ERROR_INVALID_DATA); + } + InstallJournalStateData rollbackEntered = state; + rollbackEntered.direction = InstallJournalDirection::Rollback; + rollbackEntered.rollbackAuthorized = true; + rollbackEntered.phase = + InstallJournalPhase::RollbackBindingEntered; + InstallJournalStateData firstBootReturn = rollbackEntered; + firstBootReturn.phase = + InstallJournalPhase::RollbackBindingReturned; + firstBootReturn.rebootRequired = true; + firstBootReturn.freshRebootRequired = true; + firstBootReturn.pendingRebootBootIdentifier = + std::string(32, 'd'); + InstallJournalStateData retryEntered = firstBootReturn; + retryEntered.phase = + InstallJournalPhase::RollbackBindingEntered; + retryEntered.freshRebootRequired = false; + InstallJournalStateData laterBootReturn = retryEntered; + laterBootReturn.phase = + InstallJournalPhase::RollbackBindingReturned; + laterBootReturn.freshRebootRequired = true; + laterBootReturn.pendingRebootBootIdentifier = + std::string(32, 'e'); + InstallJournalStateData laterBootPending = laterBootReturn; + laterBootPending.phase = + InstallJournalPhase::RestoreRebootPending; + laterBootPending.freshRebootRequired = false; + if (!ValidateInstallJournalTransition( + &rollbackEntered, firstBootReturn, error) || + !ValidateInstallJournalTransition( + &firstBootReturn, retryEntered, error) || + !ValidateInstallJournalTransition( + &retryEntered, laterBootReturn, error) || + !ValidateInstallJournalTransition( + &laterBootReturn, laterBootPending, error) || + laterBootPending.pendingRebootBootIdentifier == + laterBootPending.bootIdentifier) { + return SetError(error, + L"self-test-install-journal-reboot-epoch", + ERROR_INVALID_DATA, + L"later-boot rollback NeedReboot did not replace and retain the pending epoch"); + } + InstallJournalStateData illegalEpochChange = laterBootPending; + illegalEpochChange.phase = + InstallJournalPhase::ExactPriorRestored; + illegalEpochChange.pendingRebootBootIdentifier = + std::string(32, 'f'); + Error illegalEpochError; + if (ValidateInstallJournalTransition( + &laterBootPending, illegalEpochChange, + &illegalEpochError) || + illegalEpochError.code == ERROR_SUCCESS) { + return SetError(error, + L"self-test-install-journal-reboot-epoch-chain", + ERROR_INVALID_DATA); + } + std::string rebootPayload; + std::string rebootDigest; + if (!BuildInstallJournalPayload( + laterBootReturn, &rebootPayload, error) || + !Sha256Data(rebootPayload, &rebootDigest, error)) { + return false; + } + std::string rebootEnvelope = "{\"schema\":2,\"kind\":"; + AppendJsonAsciiString(&rebootEnvelope, kInstallRecoveryKind); + rebootEnvelope.append(",\"payloadSha256\":"); + AppendJsonAsciiString(&rebootEnvelope, rebootDigest); + rebootEnvelope.append(",\"payload\":"); + AppendJsonUtf8String(&rebootEnvelope, rebootPayload); + rebootEnvelope.append("}\n"); + InstallJournalStateData rebootRoundTrip; + std::string observedRebootDigest; + if (!ParseInstallJournalEnvelope( + rebootEnvelope, modelRoot, &rebootRoundTrip, + &observedRebootDigest, error) || + rebootRoundTrip.pendingRebootBootIdentifier != + laterBootReturn.pendingRebootBootIdentifier || + !rebootRoundTrip.freshRebootRequired || + observedRebootDigest != rebootDigest) { + return SetError(error, + L"self-test-install-journal-reboot-epoch-roundtrip", + ERROR_INVALID_DATA); + } + AbiCompatibilityProfile malformedProfile = + kAbiCompatibilityProfiles[0]; + ++malformedProfile.statsSize; + if (IsKnownAbiCompatibilityProfile(malformedProfile)) { + return SetError(error, + L"self-test-install-journal-abi-profile", + ERROR_INVALID_DATA); + } + std::string truncated = envelope.substr(0, envelope.size() - 3U); + Error truncatedError; + if (ParseInstallJournalEnvelope( + truncated, modelRoot, &parsedState, &parsedDigest, + &truncatedError) || truncatedError.code == ERROR_SUCCESS) { + return SetError(error, L"self-test-install-journal-truncated-chain", + ERROR_INVALID_DATA); + } + std::string tampered = envelope; + const size_t payloadOffset = tampered.find("previousSha256"); + if (payloadOffset == std::string::npos) { + return SetError(error, L"self-test-install-journal-chain", + ERROR_INVALID_DATA); + } + tampered[payloadOffset] = 'P'; + Error tamperedError; + if (ParseInstallJournalEnvelope( + tampered, modelRoot, &parsedState, &parsedDigest, + &tamperedError) || tamperedError.code == ERROR_SUCCESS) { + return SetError(error, L"self-test-install-journal-hash-chain", + ERROR_INVALID_DATA); + } + + struct RecoveryCase { + InstallJournalPhase phase; + bool chainValid; + bool securityValid; + bool sameBoot; + bool priorValid; + bool forwardValid; + bool brokerEntered; + bool brokerSettled; + bool brokerSucceeded; + InstallJournalRecoveryModelAction expected; + }; + const std::array cases{{ + {InstallJournalPhase::Prepared, true, true, true, true, + false, false, false, false, + InstallJournalRecoveryModelAction::RetirePrior}, + {InstallJournalPhase::DriverValidated, true, true, true, false, + true, false, false, false, + InstallJournalRecoveryModelAction::RetireForward}, + {InstallJournalPhase::SetupCopyReturned, true, true, true, false, + false, false, false, false, + InstallJournalRecoveryModelAction::RollbackPrior}, + {InstallJournalPhase::BrokerChildEntered, true, true, true, false, + true, true, false, false, + InstallJournalRecoveryModelAction::Manual}, + {InstallJournalPhase::ForwardRebootPending, true, true, true, false, + false, false, false, false, + InstallJournalRecoveryModelAction::RebootPending}, + {InstallJournalPhase::RestoreRebootPending, true, true, false, true, + false, false, false, false, + InstallJournalRecoveryModelAction::RetirePrior}, + {InstallJournalPhase::ForwardValidated, false, true, false, false, + true, false, false, false, + InstallJournalRecoveryModelAction::Manual}, + {InstallJournalPhase::ForwardValidated, true, false, false, false, + true, false, false, false, + InstallJournalRecoveryModelAction::Manual}, + }}; + for (const RecoveryCase& test : cases) { + if (ClassifyInstallJournalRecoveryModel( + test.phase, test.chainValid, test.securityValid, + test.sameBoot, test.priorValid, test.forwardValid, + test.brokerEntered, test.brokerSettled, + test.brokerSucceeded) != test.expected) { + return SetError(error, L"self-test-install-journal-recovery-model", + ERROR_INVALID_DATA); + } + } + return true; +} + +enum class RemoveJournalRecoveryModelAction { + ContinueForward, + ContinueRollback, + AdmitRollback, + RebootPending, + RetireForward, + RetirePrior, + Manual, +}; + +RemoveJournalRecoveryModelAction ClassifyRemoveJournalRecoveryModel( + RemoveJournalPhase phase, + RemoveJournalDirection direction, + bool chainValid, + bool securityValid, + bool samePendingBoot, + bool priorValid, + bool forwardValid, + bool callSucceeded) noexcept { + if (!chainValid || !securityValid || + phase == RemoveJournalPhase::ManualReconciliationRequired) { + return RemoveJournalRecoveryModelAction::Manual; + } + if ((phase == RemoveJournalPhase::ForwardRebootPending || + phase == RemoveJournalPhase::RestoreRebootPending) && + samePendingBoot) { + return RemoveJournalRecoveryModelAction::RebootPending; + } + if (forwardValid && direction == RemoveJournalDirection::Forward && + (phase == RemoveJournalPhase::ForwardValidated || + phase == RemoveJournalPhase::ForwardRebootPending)) { + return RemoveJournalRecoveryModelAction::RetireForward; + } + if (priorValid && + (direction == RemoveJournalDirection::Rollback || + phase == RemoveJournalPhase::Prepared)) { + return RemoveJournalRecoveryModelAction::RetirePrior; + } + if (direction == RemoveJournalDirection::Forward && + (phase == RemoveJournalPhase::DeviceRemovalReturned || + phase == RemoveJournalPhase::PackageRemovalReturned) && + !callSucceeded) { + return RemoveJournalRecoveryModelAction::AdmitRollback; + } + return direction == RemoveJournalDirection::Rollback + ? RemoveJournalRecoveryModelAction::ContinueRollback + : RemoveJournalRecoveryModelAction::ContinueForward; +} + +bool RunRemoveJournalRetirementSelfTest(Error* error) { + std::string rootIdentity; + if (!GenerateInstallTransactionId(&rootIdentity, error)) return false; + std::error_code pathError; + const std::filesystem::path temporary = + std::filesystem::temp_directory_path(pathError); + if (pathError) { + return SetError(error, L"self-test-remove-retire-temp", + static_cast(pathError.value())); + } + const std::filesystem::path testRoot = temporary / + (L"VIIPER-UdeCx-remove-retire-" + + std::wstring(rootIdentity.begin(), rootIdentity.end())); + if (!std::filesystem::create_directory(testRoot, pathError) || pathError) { + return SetError(error, L"self-test-remove-retire-root", + pathError ? static_cast(pathError.value()) + : ERROR_ALREADY_EXISTS); + } + struct Cleanup final { + std::filesystem::path root; + ~Cleanup() { + std::error_code ignored; + std::filesystem::remove_all(root, ignored); + } + } cleanup{testRoot}; + + const auto prepareLockedJournal = [&] ( + std::wstring_view caseName, + char transactionDigit, + LoadedRemoveJournal* loaded) { + loaded->directory.root = testRoot / caseName; + loaded->directory.active = loaded->directory.root / + kRemoveRecoveryActiveDirectory; + const std::filesystem::path prior = loaded->directory.active / + kRemoveRecoveryPriorDirectory; + std::error_code createError; + if (!std::filesystem::create_directories(prior, createError) || + createError) { + return SetError(error, L"self-test-remove-retire-tree", + createError ? static_cast(createError.value()) + : ERROR_ALREADY_EXISTS); + } + const std::filesystem::path evidence = prior / L"evidence.bin"; + WinHandle file(CreateFileW(evidence.c_str(), GENERIC_READ, + FILE_SHARE_READ, nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, + nullptr)); + WinHandle directory(CreateFileW(prior.c_str(), FILE_READ_ATTRIBUTES, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + nullptr)); + if (!file || !directory) { + return SetLastErrorDetail(error, + L"self-test-remove-retire-evidence-open"); + } + PackageBackup backup; + backup.locks.push_back(std::move(file)); + loaded->priorBackups.push_back(std::move(backup)); + loaded->evidenceLocks.push_back(std::move(directory)); + loaded->state.phase = RemoveJournalPhase::ForwardValidated; + loaded->state.direction = RemoveJournalDirection::Forward; + loaded->state.sequence = 1U; + loaded->state.transactionId = std::string(64, transactionDigit); + loaded->state.bootIdentifier = std::string(32, 'd'); + loaded->state.previousDigest = std::string(64, 'e'); + loaded->state.lastDigest = loaded->state.previousDigest; + loaded->hasRecord = true; + return true; + }; + const auto activeIsAbsent = [] (const std::filesystem::path& active) { + const DWORD attributes = GetFileAttributesW(active.c_str()); + if (attributes != INVALID_FILE_ATTRIBUTES) return false; + const DWORD code = GetLastError(); + return code == ERROR_FILE_NOT_FOUND || code == ERROR_PATH_NOT_FOUND; + }; + + LoadedRemoveJournal shareBlocked; + if (!prepareLockedJournal(L"share", 'a', &shareBlocked)) return false; + const std::filesystem::path blockedDestination = + shareBlocked.directory.root / L"blocked"; + if (MoveFileExW(shareBlocked.directory.active.c_str(), + blockedDestination.c_str(), MOVEFILE_WRITE_THROUGH)) { + return SetError(error, L"self-test-remove-retire-share-mode", + ERROR_INVALID_DATA, + L"a parent rename unexpectedly bypassed a descendant handle without FILE_SHARE_DELETE"); + } + *error = Error{}; + if (!RetireLoadedRemoveJournal(&shareBlocked, error, + RemoveRetirementTestFault::TemporaryTree) || + !shareBlocked.priorBackups.empty() || + !shareBlocked.evidenceLocks.empty() || + !activeIsAbsent(shareBlocked.directory.active)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"self-test-remove-retire-share-release", + ERROR_INVALID_DATA); + } + return false; + } + + LoadedRemoveJournal postcheck; + if (!prepareLockedJournal(L"postcheck", 'b', &postcheck)) return false; + const std::filesystem::path postcheckTombstone = + postcheck.directory.root / + (std::wstring(kRemoveRecoverySettledPrefix) + + std::wstring(64, L'b')); + Error postcheckError; + if (RetireLoadedRemoveJournal(&postcheck, &postcheckError, + RemoveRetirementTestFault:: + TemporaryTreeActiveAbsencePostcheck) || + postcheckError.recoveryBackup != postcheckTombstone.wstring() || + !postcheckError.recoveryBackupRetained || + !activeIsAbsent(postcheck.directory.active) || + !std::filesystem::is_directory(postcheckTombstone, pathError) || + pathError) { + return SetError(error, L"self-test-remove-retire-postcheck", + ERROR_INVALID_DATA, + L"post-rename failure did not preserve the exact settled tombstone evidence"); + } + + LoadedRemoveJournal cleanupFailure; + if (!prepareLockedJournal(L"cleanup", 'c', &cleanupFailure)) return false; + const std::filesystem::path retainedTombstone = + cleanupFailure.directory.root / + (std::wstring(kRemoveRecoverySettledPrefix) + + std::wstring(64, L'c')); + Error cleanupError; + if (!RetireLoadedRemoveJournal(&cleanupFailure, &cleanupError, + RemoveRetirementTestFault:: + TemporaryTreeRetainSettledTombstone) || + cleanupError.code != ERROR_SUCCESS || + !activeIsAbsent(cleanupFailure.directory.active) || + !std::filesystem::is_directory(retainedTombstone, pathError) || + pathError || + std::wstring(gRetainedRemoveTombstone.data()) != + retainedTombstone.wstring() || + gRetainedRemoveTombstoneError == ERROR_SUCCESS) { + return SetError(error, L"self-test-remove-retire-cleanup", + ERROR_INVALID_DATA, + L"settled cleanup failure changed terminal success or re-admitted active recovery"); + } + ClearRemoveRetirementWarning(); + return true; +} + +bool RunRemoveJournalModelSelfTest(Error* error) { + if (!RunRemoveJournalRetirementSelfTest(error)) return false; + for (RemoveJournalPhase phase : { + RemoveJournalPhase::Prepared, + RemoveJournalPhase::DeviceRemovalEntered, + RemoveJournalPhase::DeviceRemovalReturned, + RemoveJournalPhase::DeviceRemovalCommitted, + RemoveJournalPhase::PackageRemovalEntered, + RemoveJournalPhase::PackageRemovalReturned, + RemoveJournalPhase::PackageRemovalCommitted, + RemoveJournalPhase::RollbackAdmitted, + RemoveJournalPhase::RollbackPackageEntered, + RemoveJournalPhase::RollbackPackageReturned, + RemoveJournalPhase::RollbackPackageCommitted, + RemoveJournalPhase::RollbackBindingEntered, + RemoveJournalPhase::RollbackBindingReturned, + RemoveJournalPhase::ForwardValidated, + RemoveJournalPhase::ExactPriorRestored, + RemoveJournalPhase::ForwardRebootPending, + RemoveJournalPhase::RestoreRebootPending, + RemoveJournalPhase::ManualReconciliationRequired}) { + const char* name = RemoveJournalPhaseName(phase); + if (!ParseRemoveJournalPhase(name) || + *ParseRemoveJournalPhase(name) != phase) { + return SetError(error, + L"self-test-remove-journal-phase-roundtrip", + ERROR_INVALID_DATA); + } + } + PackageInfo package; + package.publishedName = L"oem42.inf"; + package.version.parts = {0, 1, 0, 38}; + package.infSha256 = std::string(64, 'a'); + package.sysSha256 = std::string(64, 'b'); + package.catSha256 = std::string(64, 'c'); + RemoveJournalStateData prepared; + prepared.transactionId = std::string(64, 'd'); + prepared.bootIdentifier = std::string(32, 'e'); + prepared.prior.packages.push_back(package); + if (!ValidateRemoveJournalTransition(nullptr, prepared, error)) { + return false; + } + prepared.sequence = 1U; + prepared.previousDigest = std::string(64, 'f'); + prepared.lastDigest = prepared.previousDigest; + RemoveJournalStateData entered = prepared; + entered.phase = RemoveJournalPhase::PackageRemovalEntered; + entered.activePackageIndex = 0U; + RemoveJournalStateData returned = entered; + returned.phase = RemoveJournalPhase::PackageRemovalReturned; + RemoveJournalStateData committed = returned; + committed.phase = RemoveJournalPhase::PackageRemovalCommitted; + committed.activePackageIndex = UINT32_MAX; + committed.packageCursor = 1U; + if (!ValidateRemoveJournalTransition(&prepared, entered, error) || + !ValidateRemoveJournalTransition(&entered, returned, error) || + !ValidateRemoveJournalTransition(&returned, committed, error)) { + return false; + } + DeviceState capturedTarget; + capturedTarget.instanceId = L"ROOT\\VIIPERUDE\\0000"; + capturedTarget.present = true; + capturedTarget.service = kServiceName; + capturedTarget.publishedInf = package.publishedName; + capturedTarget.version = package.version; + capturedTarget.package = package; + std::vector exactTargets{capturedTarget}; + std::vector duplicateTargets{ + capturedTarget, capturedTarget}; + DeviceState reboundTarget = capturedTarget; + reboundTarget.publishedInf = L"oem43.inf"; + if (!IsExactCapturedRemoveTarget(capturedTarget, exactTargets) || + IsExactCapturedRemoveTarget(capturedTarget, duplicateTargets) || + IsExactCapturedRemoveTarget( + capturedTarget, std::vector{reboundTarget})) { + return SetError(error, + L"self-test-remove-journal-single-device-authority", + ERROR_INVALID_DATA); + } + if (!CrossedRemoveRebootStillPendingRequiresManual( + RemoveJournalPhase::ForwardRebootPending, false, true, false, false, + RemoveRootShape::PendingRemoval) || + CrossedRemoveRebootStillPendingRequiresManual( + RemoveJournalPhase::ForwardRebootPending, false, true, false, true, + RemoveRootShape::PendingRemoval) || + !CrossedRemoveRebootStillPendingRequiresManual( + RemoveJournalPhase::DeviceRemovalReturned, true, true, true, false, + RemoveRootShape::PendingRemoval) || + CrossedRemoveRebootStillPendingRequiresManual( + RemoveJournalPhase::DeviceRemovalReturned, false, true, true, false, + RemoveRootShape::PendingRemoval) || + CrossedRemoveRebootStillPendingRequiresManual( + RemoveJournalPhase::DeviceRemovalReturned, true, true, false, false, + RemoveRootShape::PendingRemoval) || + CrossedRemoveRebootStillPendingRequiresManual( + RemoveJournalPhase::DeviceRemovalReturned, true, true, true, true, + RemoveRootShape::PendingRemoval) || + !ReusesInterruptedRemoveBindingAdmission( + RemoveJournalPhase::RollbackBindingEntered) || + ReusesInterruptedRemoveBindingAdmission( + RemoveJournalPhase::RollbackAdmitted)) { + return SetError(error, + L"self-test-remove-journal-recovery-cutpoint", + ERROR_INVALID_DATA); + } + if (!RestorePriorBindingTopologyAdmitsMutation( + RestorePriorBindingPolicy::RemoveJournalExactAbsence, 1U, 0U) || + RestorePriorBindingTopologyAdmitsMutation( + RestorePriorBindingPolicy::RemoveJournalExactAbsence, 0U, 0U) || + RestorePriorBindingTopologyAdmitsMutation( + RestorePriorBindingPolicy::RemoveJournalExactAbsence, 1U, 1U) || + RestorePriorBindingTopologyAdmitsMutation( + RestorePriorBindingPolicy::RemoveJournalExactAbsence, 1U, 2U) || + !RestorePriorBindingTopologyAdmitsMutation( + RestorePriorBindingPolicy::InstallRollbackReconcile, 1U, 1U)) { + return SetError(error, + L"self-test-remove-journal-binding-exact-absence-race", + ERROR_INVALID_DATA, + L"remove rollback admitted mutation after a concurrent root appeared at its inner snapshot"); + } + const uint64_t deadlineNow = CurrentUnixMilliseconds(); + const uint64_t expiredForwardDeadline = deadlineNow == 0U + ? 0U : deadlineNow - 1U; + const uint64_t freshRollbackDeadline = FreshRemoveRollbackDeadline(); + const uint64_t deadlineAfter = CurrentUnixMilliseconds(); + if (freshRollbackDeadline <= expiredForwardDeadline || + freshRollbackDeadline < SaturatingDeadlineAfter( + deadlineNow, kDriverRollbackCeilingMs) || + freshRollbackDeadline > SaturatingDeadlineAfter( + deadlineAfter, kDriverRollbackCeilingMs) || + SaturatingDeadlineAfter( + std::numeric_limits::max() - 1U, 2U) != + std::numeric_limits::max()) { + return SetError(error, + L"self-test-remove-journal-fresh-rollback-deadline", + ERROR_INVALID_DATA); + } + + RemoveJournalStateData failedForwardReturn = entered; + failedForwardReturn.phase = + RemoveJournalPhase::PackageRemovalReturned; + failedForwardReturn.callSucceeded = false; + failedForwardReturn.callError = ERROR_GEN_FAILURE; + failedForwardReturn.rebootRequired = true; + failedForwardReturn.freshRebootRequired = true; + failedForwardReturn.pendingRebootBootIdentifier = + std::string(32, '1'); + RemoveJournalStateData directRollbackPending = failedForwardReturn; + directRollbackPending.phase = + RemoveJournalPhase::RestoreRebootPending; + directRollbackPending.direction = RemoveJournalDirection::Rollback; + directRollbackPending.activePackageIndex = UINT32_MAX; + directRollbackPending.freshRebootRequired = false; + RemoveJournalStateData crossedDirectRollback = directRollbackPending; + crossedDirectRollback.phase = + RemoveJournalPhase::RollbackPackageEntered; + crossedDirectRollback.activePackageIndex = 0U; + crossedDirectRollback.rebootRequired = false; + crossedDirectRollback.pendingRebootBootIdentifier.clear(); + crossedDirectRollback.callSucceeded = true; + crossedDirectRollback.callError = ERROR_SUCCESS; + RemoveJournalStateData legacyRollbackAdmission = failedForwardReturn; + legacyRollbackAdmission.phase = RemoveJournalPhase::RollbackAdmitted; + legacyRollbackAdmission.direction = RemoveJournalDirection::Rollback; + legacyRollbackAdmission.activePackageIndex = UINT32_MAX; + legacyRollbackAdmission.freshRebootRequired = false; + RemoveJournalStateData legacySameBootPending = legacyRollbackAdmission; + legacySameBootPending.phase = RemoveJournalPhase::RestoreRebootPending; + RemoveJournalStateData legacyCrossedRollback = legacyRollbackAdmission; + legacyCrossedRollback.phase = + RemoveJournalPhase::RollbackPackageEntered; + legacyCrossedRollback.activePackageIndex = 0U; + legacyCrossedRollback.rebootRequired = false; + legacyCrossedRollback.pendingRebootBootIdentifier.clear(); + legacyCrossedRollback.callSucceeded = true; + legacyCrossedRollback.callError = ERROR_SUCCESS; + if (!ValidateRemoveJournalTransition( + &entered, failedForwardReturn, error) || + !ValidateRemoveJournalTransition( + &failedForwardReturn, directRollbackPending, error) || + !ValidateRemoveJournalTransition( + &directRollbackPending, crossedDirectRollback, error) || + !ValidateRemoveJournalTransition( + &failedForwardReturn, legacyRollbackAdmission, error) || + !ValidateRemoveJournalTransition( + &legacyRollbackAdmission, legacySameBootPending, error) || + !ValidateRemoveJournalTransition( + &legacyRollbackAdmission, legacyCrossedRollback, error)) { + return SetError(error, + L"self-test-remove-journal-rollback-reboot-admission", + error->code == ERROR_SUCCESS ? ERROR_INVALID_DATA : error->code); + } + + RemoveJournalStateData bindingPrepared; + bindingPrepared.transactionId = std::string(64, '7'); + bindingPrepared.bootIdentifier = std::string(32, '8'); + bindingPrepared.prior.packages.push_back(package); + bindingPrepared.prior.devices.push_back(capturedTarget); + if (!ValidateRemoveJournalTransition(nullptr, bindingPrepared, error)) { + return false; + } + bindingPrepared.sequence = 1U; + bindingPrepared.previousDigest = std::string(64, '9'); + bindingPrepared.lastDigest = bindingPrepared.previousDigest; + RemoveJournalStateData deviceRemovalEntered = bindingPrepared; + deviceRemovalEntered.phase = + RemoveJournalPhase::DeviceRemovalEntered; + deviceRemovalEntered.deviceMutationEntered = true; + RemoveJournalStateData deviceRemovalReturned = deviceRemovalEntered; + deviceRemovalReturned.phase = + RemoveJournalPhase::DeviceRemovalReturned; + deviceRemovalReturned.rebootRequired = true; + deviceRemovalReturned.freshRebootRequired = true; + deviceRemovalReturned.pendingRebootBootIdentifier = + bindingPrepared.bootIdentifier; + if (!ValidateRemoveJournalTransition( + &bindingPrepared, deviceRemovalEntered, error) || + !ValidateRemoveJournalTransition( + &deviceRemovalEntered, deviceRemovalReturned, error) || + !CrossedRemoveRebootStillPendingRequiresManual( + deviceRemovalReturned.phase, + deviceRemovalReturned.callSucceeded, + deviceRemovalReturned.rebootRequired, + deviceRemovalReturned.freshRebootRequired, + false, RemoveRootShape::PendingRemoval)) { + return SetError(error, + L"self-test-remove-journal-device-returned-pending-cut", + error->code == ERROR_SUCCESS ? ERROR_INVALID_DATA : error->code, + L"a crossed successful device-removal reboot return could request a second reboot before its pending cutpoint"); + } + RemoveJournalStateData bindingRollback = bindingPrepared; + bindingRollback.phase = RemoveJournalPhase::RollbackAdmitted; + bindingRollback.direction = RemoveJournalDirection::Rollback; + bindingRollback.callSucceeded = false; + bindingRollback.callError = ERROR_GEN_FAILURE; + RemoveJournalStateData bindingEntered = bindingRollback; + bindingEntered.phase = RemoveJournalPhase::RollbackBindingEntered; + bindingEntered.bindingMutationEntered = true; + bindingEntered.callSucceeded = true; + bindingEntered.callError = ERROR_SUCCESS; + RemoveJournalStateData bindingExactEffect = bindingEntered; + bindingExactEffect.phase = RemoveJournalPhase::ExactPriorRestored; + if (!ValidateRemoveJournalTransition( + &bindingPrepared, bindingRollback, error) || + !ValidateRemoveJournalTransition( + &bindingRollback, bindingEntered, error) || + !ReusesInterruptedRemoveBindingAdmission(bindingEntered.phase) || + !ValidateRemoveJournalTransition( + &bindingEntered, bindingExactEffect, error)) { + return SetError(error, + L"self-test-remove-journal-binding-cutpoint", + error->code == ERROR_SUCCESS ? ERROR_INVALID_DATA : error->code); + } + RemoveJournalStateData illegalSkip = returned; + illegalSkip.phase = RemoveJournalPhase::PackageRemovalCommitted; + illegalSkip.activePackageIndex = UINT32_MAX; + illegalSkip.packageCursor = 2U; + Error illegalError; + if (ValidateRemoveJournalTransition( + &returned, illegalSkip, &illegalError) || + illegalError.code == ERROR_SUCCESS) { + return SetError(error, + L"self-test-remove-journal-package-cutpoint", + ERROR_INVALID_DATA); + } + RemoveJournalStateData rebootReturn = returned; + rebootReturn.rebootRequired = true; + rebootReturn.freshRebootRequired = true; + rebootReturn.pendingRebootBootIdentifier = + std::string(32, '1'); + RemoveJournalStateData rebootPending = rebootReturn; + rebootPending.phase = RemoveJournalPhase::ForwardRebootPending; + rebootPending.activePackageIndex = UINT32_MAX; + rebootPending.freshRebootRequired = false; + if (!ValidateRemoveJournalTransition( + &entered, rebootReturn, error) || + !ValidateRemoveJournalTransition( + &rebootReturn, rebootPending, error)) { + return false; + } + RemoveJournalStateData illegalEpoch = rebootPending; + illegalEpoch.phase = RemoveJournalPhase::PackageRemovalEntered; + illegalEpoch.activePackageIndex = 0U; + illegalEpoch.pendingRebootBootIdentifier = + std::string(32, '2'); + illegalEpoch.freshRebootRequired = false; + illegalError = Error{}; + if (ValidateRemoveJournalTransition( + &rebootPending, illegalEpoch, &illegalError) || + illegalError.code == ERROR_SUCCESS) { + return SetError(error, + L"self-test-remove-journal-reboot-epoch", + ERROR_INVALID_DATA); + } + RemoveJournalStateData crossedEpoch = rebootPending; + crossedEpoch.phase = RemoveJournalPhase::PackageRemovalEntered; + crossedEpoch.activePackageIndex = 0U; + crossedEpoch.rebootRequired = false; + crossedEpoch.pendingRebootBootIdentifier.clear(); + if (!ValidateRemoveJournalTransition( + &rebootPending, crossedEpoch, error)) { + return SetError(error, + L"self-test-remove-journal-crossed-reboot-epoch", + ERROR_INVALID_DATA); + } + std::string payload; + RemoveJournalStateData canonical = prepared; + canonical.sequence = 0U; + canonical.previousDigest = std::string(kZeroSha256); + canonical.lastDigest.clear(); + if (!BuildRemoveJournalPayload(canonical, &payload, error)) { + return false; + } + std::string digest; + if (!Sha256Data(payload, &digest, error)) return false; + std::string envelope = "{\"schema\":2,\"kind\":"; + AppendJsonAsciiString(&envelope, kRemoveRecoveryKind); + envelope.append(",\"payloadSha256\":"); + AppendJsonAsciiString(&envelope, digest); + envelope.append(",\"payload\":"); + AppendJsonUtf8String(&envelope, payload); + envelope.append("}\n"); + RemoveJournalStateData roundTrip; + std::string roundTripDigest; + if (!ParseRemoveJournalEnvelope(envelope, + std::filesystem::path( + LR"(C:\ProgramData\VIIPER-UdeCx-RemoveTransactions\active-v2)"), + &roundTrip, &roundTripDigest, error) || + roundTripDigest != digest || + !SameRemoveJournalImmutableState(canonical, roundTrip)) { + return SetError(error, + L"self-test-remove-journal-canonical-roundtrip", + ERROR_INVALID_DATA); + } + struct RecoveryCase { + RemoveJournalPhase phase; + RemoveJournalDirection direction; + bool chain; + bool security; + bool sameBoot; + bool prior; + bool forward; + bool callSucceeded; + RemoveJournalRecoveryModelAction expected; + }; + const std::array cases{{ + {RemoveJournalPhase::Prepared, + RemoveJournalDirection::Forward, true, true, false, + true, false, true, + RemoveJournalRecoveryModelAction::RetirePrior}, + {RemoveJournalPhase::PackageRemovalEntered, + RemoveJournalDirection::Forward, true, true, false, + false, false, true, + RemoveJournalRecoveryModelAction::ContinueForward}, + {RemoveJournalPhase::PackageRemovalReturned, + RemoveJournalDirection::Forward, true, true, false, + false, false, false, + RemoveJournalRecoveryModelAction::AdmitRollback}, + {RemoveJournalPhase::ForwardRebootPending, + RemoveJournalDirection::Forward, true, true, true, + false, false, true, + RemoveJournalRecoveryModelAction::RebootPending}, + {RemoveJournalPhase::ForwardValidated, + RemoveJournalDirection::Forward, true, true, false, + false, true, true, + RemoveJournalRecoveryModelAction::RetireForward}, + {RemoveJournalPhase::RollbackAdmitted, + RemoveJournalDirection::Rollback, true, true, false, + false, false, true, + RemoveJournalRecoveryModelAction::ContinueRollback}, + {RemoveJournalPhase::RollbackAdmitted, + RemoveJournalDirection::Rollback, false, true, false, + true, false, true, + RemoveJournalRecoveryModelAction::Manual}, + }}; + for (const RecoveryCase& test : cases) { + if (ClassifyRemoveJournalRecoveryModel(test.phase, + test.direction, test.chain, test.security, + test.sameBoot, test.prior, test.forward, + test.callSucceeded) != test.expected) { + return SetError(error, + L"self-test-remove-journal-recovery-model", + ERROR_INVALID_DATA); + } + } + return true; +} + +Outcome SelfTest(); + +Outcome Status() { + Outcome outcome; + const Outcome deterministic = SelfTest(); + if (!deterministic.success) { + return deterministic; + } + Snapshot snapshot; + if (!CaptureSnapshot(&snapshot, &outcome.error)) { + return outcome; + } + if (snapshot.devices.size() > 1) { + SetError(&outcome.error, L"status-topology", ERROR_DUPLICATE_SERVICE_NAME); + return outcome; + } + outcome.success = true; + outcome.exitCode = ExitCode::Success; + std::wcout << L"devices=" << snapshot.devices.size() + << L" packages=" << snapshot.packages.size(); + if (snapshot.devices.size() == 1) { + std::wcout << L" present=" << (snapshot.devices[0].present ? 1 : 0) + << L" started=" << (snapshot.devices[0].started ? 1 : 0) + << L" version=" << VersionToString(snapshot.devices[0].version) + << L" publishedInf=" << snapshot.devices[0].publishedInf + << L" problem=" << snapshot.devices[0].problem; + } + std::wcout << L"\n"; + return outcome; +} + +Outcome SelfTest() { + Outcome outcome; + if (!RunInstallJournalModelSelfTest(&outcome.error) || + !RunRemoveJournalModelSelfTest(&outcome.error)) { + return outcome; + } + InstallOptions brokerCommandOptions; + brokerCommandOptions.brokerExecutable = LR"(C:\Program Files\VIIPER\viiper.exe)"; + brokerCommandOptions.brokerToken = LR"(C:\ProgramData\VIIPER\package.token)"; + brokerCommandOptions.brokerTokenSha256 = std::string(64, 'a'); + brokerCommandOptions.brokerSha256 = std::string(64, 'b'); + brokerCommandOptions.targetUserSid = L"S-1-5-21-1-2-3-1001"; + brokerCommandOptions.transactionDeadlineUnixMs = 123456789; + const std::wstring brokerCommandLine = + BuildBrokerCommitCommandLine(brokerCommandOptions); + if (brokerCommandLine.find(L" --expected-token-sha-256 ") == std::wstring::npos || + brokerCommandLine.find(L" --expected-broker-sha-256 ") == std::wstring::npos) { + SetError(&outcome.error, L"self-test-broker-command", ERROR_INVALID_DATA, + L"nested broker command does not match the compiled Kong CLI contract"); + return outcome; + } + Version one{}; + Version two{}; + if (!ParseVersion(L"1.2.3.4", &one) || !ParseVersion(L"1.2.4.0", &two) || + !(one < two) || ParseVersion(L"1.2.3", nullptr) || + ParseVersion(L"1.2.3.70000", nullptr)) { + SetError(&outcome.error, L"self-test-version", ERROR_INVALID_DATA); + return outcome; + } + PackageInfo candidate; + candidate.version = two; + candidate.infSha256 = "candidate-inf"; + candidate.sysSha256 = "candidate-sys"; + candidate.catSha256 = "candidate-cat"; + CandidateDisposition disposition = CandidateDisposition::Exact; + bool downgrade = true; + Error classificationError; + if (!ClassifyCandidatePackage( + candidate, {}, std::nullopt, &disposition, &downgrade, &classificationError) || + disposition != CandidateDisposition::InstallRequired || downgrade) { + SetError(&outcome.error, L"self-test-package-classification", ERROR_INVALID_DATA, + L"an absent candidate was not classified as an install"); + return outcome; + } + PackageInfo exact = candidate; + classificationError = {}; + if (!ClassifyCandidatePackage( + candidate, {exact}, std::nullopt, &disposition, &downgrade, &classificationError) || + disposition != CandidateDisposition::Exact || downgrade) { + SetError(&outcome.error, L"self-test-package-classification", ERROR_INVALID_DATA, + L"an exact same-version candidate was not classified as repair-only"); + return outcome; + } + if (!RequiresDriverMutation(CandidateDisposition::InstallRequired, false) || + !RequiresDriverMutation(CandidateDisposition::Exact, false) || + RequiresDriverMutation(CandidateDisposition::Exact, true) || + !RequiresPristineRuntimeProof( + CandidateDisposition::InstallRequired, false, true, true) || + !RequiresPristineRuntimeProof( + CandidateDisposition::Exact, false, true, true) || + RequiresPristineRuntimeProof( + CandidateDisposition::Exact, true, true, true) || + RequiresPristineRuntimeProof( + CandidateDisposition::InstallRequired, false, false, false) || + RequiresPristineRuntimeProof( + CandidateDisposition::Exact, false, true, false)) { + SetError(&outcome.error, L"self-test-pristine-runtime-decision", ERROR_INVALID_DATA, + L"pristine-runtime admission does not cover exactly the running-root mutation boundary"); + return outcome; + } + PackageInfo conflict = candidate; + conflict.infSha256 = "different-inf"; + classificationError = {}; + if (ClassifyCandidatePackage( + candidate, {conflict}, std::nullopt, + &disposition, &downgrade, &classificationError) || + classificationError.phase != L"version-policy") { + SetError(&outcome.error, L"self-test-package-classification", ERROR_INVALID_DATA, + L"same-version content replacement was not rejected"); + return outcome; + } + conflict = candidate; + conflict.sysSha256 = "different-sys"; + classificationError = {}; + if (ClassifyCandidatePackage( + candidate, {conflict}, std::nullopt, + &disposition, &downgrade, &classificationError) || + classificationError.phase != L"version-policy") { + SetError(&outcome.error, L"self-test-package-classification", ERROR_INVALID_DATA, + L"same-version SYS replacement was not rejected"); + return outcome; + } + conflict = candidate; + conflict.catSha256 = "different-cat"; + classificationError = {}; + if (ClassifyCandidatePackage( + candidate, {conflict}, std::nullopt, + &disposition, &downgrade, &classificationError) || + classificationError.phase != L"version-policy") { + SetError(&outcome.error, L"self-test-package-classification", ERROR_INVALID_DATA, + L"same-version catalog replacement was not rejected"); + return outcome; + } + PackageInfo newer = candidate; + newer.version.parts[3] += 1; + classificationError = {}; + if (ClassifyCandidatePackage( + candidate, {newer}, std::nullopt, + &disposition, &downgrade, &classificationError) || + classificationError.phase != L"version-policy") { + SetError(&outcome.error, L"self-test-package-classification", ERROR_INVALID_DATA, + L"implicit downgrade was not rejected"); + return outcome; + } + classificationError = {}; + if (!ClassifyCandidatePackage( + candidate, {newer}, newer.version, + &disposition, &downgrade, &classificationError) || + disposition != CandidateDisposition::InstallRequired || !downgrade) { + SetError(&outcome.error, L"self-test-package-classification", ERROR_INVALID_DATA, + L"exact controlled-downgrade guard was not honored"); + return outcome; + } + Version wrongDowngradeGuard = newer.version; + ++wrongDowngradeGuard.parts[3]; + classificationError = {}; + if (ClassifyCandidatePackage( + candidate, {newer}, wrongDowngradeGuard, + &disposition, &downgrade, &classificationError) || + classificationError.phase != L"version-policy") { + SetError(&outcome.error, L"self-test-package-classification", ERROR_INVALID_DATA, + L"incorrect controlled-downgrade guard was accepted"); + return outcome; + } + std::string buildIdentity; + if (!DeriveDriverBuildIdentity( + "0123456789abcdef0123456789abcdef01234567", + &buildIdentity, &outcome.error) || + buildIdentity != + "9a8c5a75d8c54569f3a8f7e1b2c9a68b8b40bf06494285fa93b56895a98ba3fe") { + if (outcome.error.code == ERROR_SUCCESS) { + SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); + } + return outcome; + } + const std::array abiPurposes{ + AbiHealthPurpose::ExactCandidate, + AbiHealthPurpose::PristineUpgrade, + AbiHealthPurpose::PristineRecheck, + AbiHealthPurpose::RollbackHealth, + }; + const std::array retryCodes{ + ERROR_REVISION_MISMATCH, ERROR_INVALID_PARAMETER, + }; + const std::array retryPhases{ + L"abi-negotiate", L"abi-negotiate-result", + }; + for (const AbiHealthPurpose purpose : abiPurposes) { + for (const DWORD code : retryCodes) { + for (const std::wstring& phase : retryPhases) { + Error retryError; + retryError.code = code; + retryError.phase = phase; + const bool expected = purpose == AbiHealthPurpose::PristineUpgrade; + if (IsAbiRetryEligible(purpose, nullptr, retryError) != expected || + IsAbiRetryEligible(purpose, &buildIdentity, retryError)) { + SetError(&outcome.error, L"self-test-abi-retry", ERROR_INVALID_DATA, + L"ABI retry escaped the strict pristine-upgrade mismatch boundary"); + return outcome; + } + } + } + } + Error unrelatedRetryError; + unrelatedRetryError.code = ERROR_ACCESS_DENIED; + unrelatedRetryError.phase = L"abi-negotiate"; + Error wrongPhaseRetryError; + wrongPhaseRetryError.code = ERROR_REVISION_MISMATCH; + wrongPhaseRetryError.phase = L"abi-negotiate-timeout"; + if (IsAbiRetryEligible( + AbiHealthPurpose::PristineUpgrade, nullptr, unrelatedRetryError) || + IsAbiRetryEligible( + AbiHealthPurpose::PristineUpgrade, nullptr, wrongPhaseRetryError)) { + SetError(&outcome.error, L"self-test-abi-retry", ERROR_INVALID_DATA, + L"ABI retry accepted a non-version negotiation failure"); + return outcome; + } + + const auto makeNegotiationResponse = [](const AbiCompatibilityProfile& profile) { + VIIPER_UDE_NEGOTIATE_RESPONSE response{}; + response.Header.Magic = VIIPER_UDE_MAGIC; + response.Header.Major = VIIPER_UDE_ABI_MAJOR; + response.Header.Minor = profile.minor; + response.Header.Size = sizeof(response); + response.ClientNonce = 0x123456789abcdef0ULL; + response.DriverNonce = 1; + response.Capabilities = profile.capabilities; + response.MaxDevices = VIIPER_UDE_MAX_DEVICES; + response.MaxDescriptorBytes = VIIPER_UDE_MAX_DESCRIPTOR_BYTES; + response.MaxTransferBytes = VIIPER_UDE_MAX_TRANSFER_BYTES; + response.MaxIsoPackets = VIIPER_UDE_MAX_ISO_PACKETS; + response.MaxPendingOperations = VIIPER_UDE_MAX_PENDING_OPERATIONS; + return response; + }; + const auto negotiationValidationIsExhaustive = + [&](const AbiCompatibilityProfile& profile) { + const VIIPER_UDE_NEGOTIATE_RESPONSE response = + makeNegotiationResponse(profile); + const auto rejects = [&](auto mutate) { + VIIPER_UDE_NEGOTIATE_RESPONSE changed = response; + mutate(changed); + return !AbiNegotiationResponseMatchesProfile( + changed, sizeof(changed), response.ClientNonce, profile); + }; + return AbiNegotiationResponseMatchesProfile( + response, sizeof(response), response.ClientNonce, profile) && + !AbiNegotiationResponseMatchesProfile( + response, sizeof(response) - 1, response.ClientNonce, profile) && + rejects([](auto& value) { value.Header.Magic ^= 1; }) && + rejects([](auto& value) { ++value.Header.Major; }) && + rejects([](auto& value) { ++value.Header.Minor; }) && + rejects([](auto& value) { ++value.Header.Size; }) && + rejects([](auto& value) { value.Header.Flags = 1; }) && + rejects([](auto& value) { ++value.ClientNonce; }) && + rejects([](auto& value) { value.DriverNonce = 0; }) && + rejects([](auto& value) { ++value.Capabilities; }) && + rejects([](auto& value) { ++value.MaxDevices; }) && + rejects([](auto& value) { ++value.MaxDescriptorBytes; }) && + rejects([](auto& value) { ++value.MaxTransferBytes; }) && + rejects([](auto& value) { ++value.MaxIsoPackets; }) && + rejects([](auto& value) { ++value.MaxPendingOperations; }); + }; + const auto statsValidationIsExhaustive = + [](const AbiCompatibilityProfile& profile) { + VIIPER_UDE_STATS stats{}; + stats.Header.Magic = VIIPER_UDE_MAGIC; + stats.Header.Major = VIIPER_UDE_ABI_MAJOR; + stats.Header.Minor = profile.minor; + stats.Header.Size = profile.statsSize; + const auto rejects = [&](auto mutate) { + VIIPER_UDE_STATS changed = stats; + mutate(changed); + return !StatsRecordMatchesProfile( + changed, profile.statsSize, profile); + }; + const bool commonFieldsExact = StatsRecordMatchesProfile( + stats, profile.statsSize, profile) && + !StatsRecordMatchesProfile(stats, profile.statsSize - 1, profile) && + rejects([](auto& value) { value.Header.Magic ^= 1; }) && + rejects([](auto& value) { ++value.Header.Major; }) && + rejects([](auto& value) { ++value.Header.Minor; }) && + rejects([](auto& value) { ++value.Header.Size; }) && + rejects([](auto& value) { value.Header.Flags = 1; }); + stats.ReservedPorts = VIIPER_UDE_MAX_DEVICES + 1; + stats.Reserved = 1; + const bool reservedRangeExact = profile.hasReservedPortFields + ? !StatsRecordMatchesProfile(stats, profile.statsSize, profile) + : StatsRecordMatchesProfile(stats, profile.statsSize, profile); + return commonFieldsExact && reservedRangeExact; + }; + for (const AbiCompatibilityProfile& profile : kAbiCompatibilityProfiles) { + if (!negotiationValidationIsExhaustive(profile) || + !statsValidationIsExhaustive(profile)) { + SetError(&outcome.error, L"self-test-abi-profile-validation", + ERROR_INVALID_DATA, + L"an ABI profile response or statistics field escaped exact validation"); + return outcome; + } + } + + VIIPER_UDE_STATS pristineStats{}; + const auto rejectsNonzeroRuntimeCounter = [](auto member) { + VIIPER_UDE_STATS stats{}; + stats.*member = 1; + return !RuntimeStatsArePristine(stats, kAbiCompatibilityProfiles[0]); + }; + if (!RuntimeStatsArePristine(pristineStats, kAbiCompatibilityProfiles[0]) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::OperationsDequeued) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::OperationsCompleted) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::OperationsCancelled) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::OperationsPurged) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::LateCompletions) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::InvalidMessages) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::QueueExhaustions) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::IsoPackets) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::BytesToDevice) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::BytesFromDevice) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::NotificationEvents) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::NotificationEventOverflows) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::ActiveDevices) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::PendingOperations) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::WaitingDequeues) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::CleanupRetries) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::InputReportsSubmitted) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::InputReportsCompleted) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::ReservedPorts)) { + SetError(&outcome.error, L"self-test-pristine-runtime-stats", ERROR_INVALID_DATA, + L"a nonzero runtime counter escaped the pre-mutation reboot boundary"); + return outcome; + } + pristineStats.ReservedPorts = 1; + if (RuntimeStatsArePristine(pristineStats, kAbiCompatibilityProfiles[1]) || + RuntimeStatsArePristine(pristineStats, kAbiCompatibilityProfiles[2]) || + !RuntimeStatsArePristine(pristineStats, kAbiCompatibilityProfiles[3]) || + !RuntimeStatsArePristine(pristineStats, kAbiCompatibilityProfiles[4])) { + SetError(&outcome.error, L"self-test-pristine-runtime-stats", ERROR_INVALID_DATA, + L"a legacy ABI inspected a counter outside its returned statistics record"); + return outcome; + } + JsonValue value; + std::string message; + if (!JsonParser(R"({"schema":1,"files":[]})").Parse(&value, &message) || + JsonParser(R"({"schema":1,"schema":1})").Parse(&value, &message) || + JsonParser(R"({"schema":1.0})").Parse(&value, &message) || + !IsSafePublishedInfName(L"oem42.inf") || IsSafePublishedInfName(L"..\\oem42.inf")) { + SetError(&outcome.error, L"self-test-contract", ERROR_INVALID_DATA); + return outcome; + } + if (!IsOwnedGeneratedRootInstanceId(L"ROOT\\VIIPERUDE\\0000") || + !IsOwnedGeneratedRootInstanceId(L"root\\usb\\0042") || + IsOwnedGeneratedRootInstanceId(L"ROOT\\VIIPER\\UDE\\0000") || + IsOwnedGeneratedRootInstanceId(L"ROOT\\USB\\42") || + IsOwnedGeneratedRootInstanceId(L"ROOT\\USB\\00A0")) { + SetError(&outcome.error, L"self-test-root-instance-id", ERROR_INVALID_DATA, + L"generated root instance namespace validation is not exact"); + return outcome; + } + PackageInfo priorPackage; + priorPackage.publishedName = L"OEM7.INF"; + PackageInfo preservedPackage; + preservedPackage.publishedName = L"oem7.inf"; + PackageInfo newPackage; + newPackage.publishedName = L"oem9.inf"; + newPackage.sysSha256 = "new-sys"; + PackageInfo changedPackage = preservedPackage; + changedPackage.sysSha256 = "changed"; + if (!SamePackageInventory({priorPackage}, {preservedPackage}) || + SamePackageInventory({priorPackage}, {preservedPackage, newPackage}) || + SamePackageInventory({priorPackage}, {changedPackage}) || + !ContainsExactPackage({priorPackage}, preservedPackage) || + ContainsExactPackage({priorPackage}, newPackage)) { + SetError(&outcome.error, L"self-test-rollback-inventory", ERROR_INVALID_DATA, + L"rollback package inventory comparison is not exact and name-bound"); + return outcome; + } + DeviceState capturedRoot; + capturedRoot.instanceId = L"ROOT\\VIIPERUDE\\0000"; + capturedRoot.present = true; + capturedRoot.started = true; + capturedRoot.service = kServiceName; + capturedRoot.publishedInf = L"oem7.inf"; + capturedRoot.version = one; + capturedRoot.package = priorPackage; + capturedRoot.package.infSha256 = "prior-inf"; + capturedRoot.package.sysSha256 = "prior-sys"; + capturedRoot.package.catSha256 = "prior-cat"; + Snapshot capturedRootSnapshot; + capturedRootSnapshot.devices.push_back(capturedRoot); + Snapshot observedRootSnapshot = capturedRootSnapshot; + observedRootSnapshot.packages.push_back(newPackage); + if (!SameCapturedRootState(capturedRootSnapshot, observedRootSnapshot)) { + SetError(&outcome.error, L"self-test-stage-root-invariance", ERROR_INVALID_DATA, + L"add-only package publication changed the captured root comparison"); + return outcome; + } + observedRootSnapshot = capturedRootSnapshot; + DeviceState concurrentRoot = capturedRoot; + concurrentRoot.instanceId = L"ROOT\\VIIPERUDE\\0001"; + observedRootSnapshot.devices.push_back(std::move(concurrentRoot)); + if (SameCapturedRootState(capturedRootSnapshot, observedRootSnapshot)) { + SetError(&outcome.error, L"self-test-stage-root-invariance", ERROR_INVALID_DATA, + L"a concurrently registered second root escaped global topology verification"); + return outcome; + } + observedRootSnapshot = capturedRootSnapshot; + observedRootSnapshot.devices[0].started = false; + if (SameCapturedRootState(capturedRootSnapshot, observedRootSnapshot)) { + SetError(&outcome.error, L"self-test-stage-root-invariance", ERROR_INVALID_DATA, + L"a root lifecycle change escaped post-stage verification"); + return outcome; + } + observedRootSnapshot = capturedRootSnapshot; + observedRootSnapshot.devices[0].publishedInf = L"oem9.inf"; + if (SameCapturedRootState(capturedRootSnapshot, observedRootSnapshot)) { + SetError(&outcome.error, L"self-test-stage-root-invariance", ERROR_INVALID_DATA, + L"a root package rebind escaped post-stage verification"); + return outcome; + } + if (!SameCapturedRootState(Snapshot{}, Snapshot{}) || + SameCapturedRootState(Snapshot{}, capturedRootSnapshot)) { + SetError(&outcome.error, L"self-test-stage-root-invariance", ERROR_INVALID_DATA, + L"absent-root post-stage verification is not exact"); + return outcome; + } + DeviceState stoppedRoot = capturedRoot; + stoppedRoot.started = false; + stoppedRoot.problem = CM_PROB_DISABLED; + DeviceState restoredStoppedRoot = stoppedRoot; + if (!RollbackLifecycleStateMatches(stoppedRoot, restoredStoppedRoot)) { + SetError(&outcome.error, L"self-test-rollback-lifecycle", ERROR_INVALID_DATA, + L"an exact stopped/problem rollback state was rejected"); + return outcome; + } + restoredStoppedRoot.started = true; + restoredStoppedRoot.problem = 0; + if (RollbackLifecycleStateMatches(stoppedRoot, restoredStoppedRoot)) { + SetError(&outcome.error, L"self-test-rollback-lifecycle", ERROR_INVALID_DATA, + L"rollback accepted a captured stopped root that was unexpectedly started"); + return outcome; + } + restoredStoppedRoot = stoppedRoot; + ++restoredStoppedRoot.problem; + if (RollbackLifecycleStateMatches(stoppedRoot, restoredStoppedRoot) || + !RollbackLifecycleStateMatches(capturedRoot, capturedRoot)) { + SetError(&outcome.error, L"self-test-rollback-lifecycle", ERROR_INVALID_DATA, + L"rollback lifecycle comparison is not exact for stopped or running roots"); + return outcome; + } + if (!IsSafeRecoveryRelativePath( + std::filesystem::path(L"0") / L"ViiperUde.inf") || + IsSafeRecoveryRelativePath(std::filesystem::path(L"..") / L"escape") || + IsSafeRecoveryRelativePath( + std::filesystem::path(L"0") / L".." / L"escape") || + IsSafeRecoveryRelativePath(std::filesystem::path(LR"(C:\escape)")) || + IsSafeRecoveryRelativePath( + std::filesystem::path(L"0") / L"ViiperUde.inf:stream")) { + SetError(&outcome.error, L"self-test-recovery-path", ERROR_INVALID_DATA, + L"rollback recovery relative-path validation is not fail-closed"); + return outcome; + } + if (!IsSafeTargetUserSid(L"S-1-5-21-1-2-3-1001") || + IsSafeTargetUserSid(L"S-1-5-21-bad") || + QuoteWindowsArgument(LR"(C:\Program Files\VIIPER\viiper.exe)") != + LR"("C:\Program Files\VIIPER\viiper.exe")" || + QuoteWindowsArgument(LR"(value\"quoted)") != LR"("value\\\"quoted")") { + SetError(&outcome.error, L"self-test-broker-command", ERROR_INVALID_DATA); + return outcome; + } + const std::string brokerSuccess = + "result=success operation=native-package-broker-commit changed=0 " + "rollback=not-needed exitCode=0\n"; + const std::string brokerPreflightFailure = + "result=error operation=native-package-broker-commit changed=0 " + "rollback=not-needed exitCode=4\n"; + const std::string brokerNestedReady = + "result=success operation=native-package-broker-commit changed=1 " + "rollback=not-needed exitCode=0\n" + "journal-proof operation=native-package-broker-commit " + "transactionId=11111111111111111111111111111111 " + "outerTransactionId=2222222222222222222222222222222222222222222222222222222222222222 " + "candidateSha256=3333333333333333333333333333333333333333333333333333333333333333 " + "state=nested-ready " + "digest=4444444444444444444444444444444444444444444444444444444444444444\n"; + const std::string brokerRollbackSettled = + "result=error operation=native-package-broker-commit changed=1 " + "rollback=succeeded exitCode=1\n" + "journal-proof operation=native-package-broker-commit " + "transactionId=11111111111111111111111111111111 " + "outerTransactionId=2222222222222222222222222222222222222222222222222222222222222222 " + "candidateSha256=3333333333333333333333333333333333333333333333333333333333333333 " + "state=rollback-settled " + "digest=4444444444444444444444444444444444444444444444444444444444444444\n"; + const std::string brokerManual = + "result=error operation=native-package-broker-commit changed=1 " + "rollback=failed exitCode=3\n" + "journal-proof operation=native-package-broker-commit " + "transactionId=11111111111111111111111111111111 " + "outerTransactionId=2222222222222222222222222222222222222222222222222222222222222222 " + "candidateSha256=3333333333333333333333333333333333333333333333333333333333333333 " + "state=manual " + "digest=4444444444444444444444444444444444444444444444444444444444444444\n"; + BrokerCommitProof brokerProof; + Error brokerProofError; + if (!ParseBrokerCommitProof( + brokerSuccess, ERROR_SUCCESS, &brokerProof, &brokerProofError) || + !brokerProof.success || brokerProof.changed || + brokerProof.driverRollbackAuthorized || + brokerProof.rollback != "not-needed") { + SetError(&outcome.error, L"self-test-broker-proof", ERROR_INVALID_DATA, + L"valid broker success proof was rejected or misclassified"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (!ParseBrokerCommitProof( + brokerPreflightFailure, 4, &brokerProof, &brokerProofError) || + brokerProof.success || brokerProof.changed || + !brokerProof.driverRollbackAuthorized) { + SetError(&outcome.error, L"self-test-broker-proof", ERROR_INVALID_DATA, + L"pre-mutation broker failure proof was rejected or misclassified"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + const std::wstring expectedBrokerDiagnostic = + L"outer native package transaction mutex is not held"; + if (!ParseBrokerCommitProof( + brokerPreflightFailure + std::string(kBrokerDiagnosticPrefix) + + "outer native package transaction mutex is not held\n", + 4, &brokerProof, &brokerProofError) || + brokerProof.success || brokerProof.changed || + !brokerProof.driverRollbackAuthorized || + brokerProof.diagnostic != expectedBrokerDiagnostic) { + SetError(&outcome.error, L"self-test-broker-diagnostic", ERROR_INVALID_DATA, + L"exact nested broker error diagnostic was rejected or changed proof authority"); + return outcome; + } + Error mappedBrokerError; + if (SetBrokerCommitFailure(brokerProof, &mappedBrokerError) || + mappedBrokerError.code != ERROR_INSTALL_FAILURE || + !mappedBrokerError.nestedExitCode || *mappedBrokerError.nestedExitCode != 4 || + mappedBrokerError.phase != L"broker-preflight" || + mappedBrokerError.message.find(expectedBrokerDiagnostic) == std::wstring::npos) { + SetError(&outcome.error, L"self-test-broker-diagnostic", ERROR_INVALID_DATA, + L"nested broker application exit was not separated from the outer Win32 failure"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + const std::string unsafeBrokerDiagnostic = + brokerPreflightFailure + std::string(kBrokerDiagnosticPrefix) + + "left\tmiddle\x01" + "right\x7f" + "\xe2\x80\xae" + "tail \"quoted\" \\ path\n"; + if (!ParseBrokerCommitProof( + unsafeBrokerDiagnostic, 4, &brokerProof, &brokerProofError) || + brokerProof.diagnostic != L"left?middle?right??tail \"quoted\" \\ path" || + brokerProof.success || brokerProof.changed || + !brokerProof.driverRollbackAuthorized) { + SetError(&outcome.error, L"self-test-broker-diagnostic", ERROR_INVALID_DATA, + L"nested broker diagnostic controls were not sanitized without changing proof authority"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + const std::string oversizedBrokerDiagnostic( + kMaximumBrokerDiagnosticCharacters + 32U, 'x'); + if (!ParseBrokerCommitProof( + brokerPreflightFailure + std::string(kBrokerDiagnosticPrefix) + + oversizedBrokerDiagnostic + "\n", + 4, &brokerProof, &brokerProofError) || + brokerProof.diagnostic.size() != kMaximumBrokerDiagnosticCharacters || + !brokerProof.diagnostic.ends_with(L"...") || + brokerProof.success || brokerProof.changed || + !brokerProof.driverRollbackAuthorized) { + SetError(&outcome.error, L"self-test-broker-diagnostic", ERROR_INVALID_DATA, + L"nested broker diagnostic was not deterministically capped"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (!ParseBrokerCommitProof( + brokerPreflightFailure + std::string(kBrokerDiagnosticPrefix) + + std::string("\xc3\x28", 2) + "\n", + 4, &brokerProof, &brokerProofError) || + !brokerProof.diagnostic.empty() || brokerProof.success || brokerProof.changed || + !brokerProof.driverRollbackAuthorized) { + SetError(&outcome.error, L"self-test-broker-diagnostic", ERROR_INVALID_DATA, + L"malformed UTF-8 diagnostic changed canonical broker proof authority"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (!ParseBrokerCommitProof( + brokerPreflightFailure + std::string(kBrokerDiagnosticPrefix) + "first\n" + + std::string(kBrokerDiagnosticPrefix) + "second\n", + 4, &brokerProof, &brokerProofError) || + !brokerProof.diagnostic.empty() || brokerProof.success || brokerProof.changed || + !brokerProof.driverRollbackAuthorized) { + SetError(&outcome.error, L"self-test-broker-diagnostic", ERROR_INVALID_DATA, + L"ambiguous diagnostics changed canonical broker proof authority"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (!ParseBrokerCommitProof( + brokerSuccess + std::string(kBrokerDiagnosticPrefix) + "contradiction\n", + ERROR_SUCCESS, &brokerProof, &brokerProofError) || + !brokerProof.success || brokerProof.changed || + brokerProof.driverRollbackAuthorized || !brokerProof.diagnostic.empty()) { + SetError(&outcome.error, L"self-test-broker-diagnostic", ERROR_INVALID_DATA, + L"diagnostic text overrode a canonical broker success proof"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (!ParseBrokerCommitProof( + brokerPreflightFailure + std::string(kBrokerDiagnosticPrefix) + "unterminated", + 4, &brokerProof, &brokerProofError) || + !brokerProof.diagnostic.empty() || brokerProof.success || brokerProof.changed || + !brokerProof.driverRollbackAuthorized) { + SetError(&outcome.error, L"self-test-broker-diagnostic", ERROR_INVALID_DATA, + L"unterminated diagnostic changed canonical broker proof authority"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (!ParseBrokerCommitProof( + brokerNestedReady, ERROR_SUCCESS, + &brokerProof, &brokerProofError) || + !brokerProof.success || !brokerProof.changed || + brokerProof.driverRollbackAuthorized || + !brokerProof.hasJournalProof || + brokerProof.journalState != "nested-ready") { + SetError(&outcome.error, L"self-test-broker-proof", + ERROR_INVALID_DATA, + L"nested-ready broker proof was rejected or unbound"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (!ParseBrokerCommitProof( + brokerRollbackSettled, + 1, &brokerProof, &brokerProofError) || + brokerProof.success || !brokerProof.changed || + !brokerProof.driverRollbackAuthorized) { + SetError(&outcome.error, L"self-test-broker-proof", ERROR_INVALID_DATA, + L"settled broker rollback proof was rejected or misclassified"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (!ParseBrokerCommitProof( + brokerManual, + 3, &brokerProof, &brokerProofError) || + brokerProof.success || !brokerProof.changed || + brokerProof.driverRollbackAuthorized) { + SetError(&outcome.error, L"self-test-broker-proof", ERROR_INVALID_DATA, + L"indeterminate broker rollback proof was not kept fail-closed"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (ParseBrokerCommitProof( + brokerSuccess + brokerSuccess, ERROR_SUCCESS, + &brokerProof, &brokerProofError) || + brokerProofError.phase != L"broker-proof") { + SetError(&outcome.error, L"self-test-broker-proof", ERROR_INVALID_DATA, + L"duplicate broker outcomes were not rejected"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (ParseBrokerCommitProof( + "result=error exitCode=04 rollback=not-needed changed=0 " + "operation=native-package-broker-commit\n", + 4, &brokerProof, &brokerProofError) || + brokerProofError.phase != L"broker-proof") { + SetError(&outcome.error, L"self-test-broker-proof", ERROR_INVALID_DATA, + L"noncanonical broker outcome was accepted"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (ParseBrokerCommitProof( + "result=error operation=native-package-broker-commit changed=0 " + "rollback=not-needed exitCode=4", + 4, &brokerProof, &brokerProofError) || + brokerProofError.phase != L"broker-proof") { + SetError(&outcome.error, L"self-test-broker-proof", ERROR_INVALID_DATA, + L"unterminated broker outcome was accepted"); + return outcome; + } + if (!IsProductionHardwareVerificationUsage({kHardwareVerificationOid}) || + IsProductionHardwareVerificationUsage( + {kHardwareVerificationOid, kAttestationVerificationOid}) || + IsProductionHardwareVerificationUsage({kAttestationVerificationOid}) || + IsProductionHardwareVerificationUsage({})) { + SetError(&outcome.error, L"self-test-production-eku", ERROR_INVALID_DATA); + return outcome; + } + outcome.success = true; + outcome.exitCode = ExitCode::Success; + return outcome; +} + +bool ParseInheritedEventHandle( + const wchar_t* value, + const wchar_t* name, + HANDLE* handle, + Error* error) { + const std::wstring text = value == nullptr ? L"" : value; + if (text.empty() || text.size() > 20 || + !std::all_of(text.begin(), text.end(), [](wchar_t character) { + return character >= L'0' && character <= L'9'; + })) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + std::wstring(name) + L" handle must contain only decimal digits"); + } + const wchar_t* begin = text.data(); + wchar_t* end = nullptr; + errno = 0; + const unsigned long long parsed = std::wcstoull(begin, &end, 10); + if (errno == ERANGE || end == begin || end != begin + text.size() || parsed == 0 || + parsed > static_cast(std::numeric_limits::max())) { + return SetError(error, L"arguments", ERROR_INVALID_HANDLE, + std::wstring(name) + L" handle is outside the process handle range"); + } + const HANDLE candidate = reinterpret_cast(static_cast(parsed)); + if (candidate == INVALID_HANDLE_VALUE) { + return SetError(error, L"arguments", ERROR_INVALID_HANDLE, + std::wstring(name) + L" handle is invalid"); + } + DWORD flags = 0; + if (!GetHandleInformation(candidate, &flags) || (flags & HANDLE_FLAG_INHERIT) == 0) { + return SetError(error, L"arguments", ERROR_INVALID_HANDLE, + std::wstring(name) + L" handle was not explicitly inherited"); + } + const DWORD wait = WaitForSingleObject(candidate, 0); + if (wait != WAIT_TIMEOUT) { + return SetError(error, L"arguments", ERROR_INVALID_HANDLE, + std::wstring(name) + L" event must begin nonsignaled and waitable"); + } + *handle = candidate; + return true; +} + +bool ParseInstallOptions(int argc, wchar_t** argv, InstallOptions* options, Error* error) { + if (argc < 8) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER); + } + options->infPath = argv[2]; + bool manifestSeen = false; + bool manifestHashSeen = false; + bool revisionSeen = false; + bool modeSeen = false; + bool infHashSeen = false; + bool sysHashSeen = false; + bool catHashSeen = false; + bool brokerSeen = false; + bool brokerHashSeen = false; + bool brokerTokenSeen = false; + bool brokerTokenHashSeen = false; + bool targetUserSeen = false; + bool transactionDeadlineSeen = false; + bool brokerQuiesceRequestSeen = false; + bool brokerQuiesceReadySeen = false; + bool brokerQuiesceAbortSeen = false; + bool brokerHandoffSeen = false; + for (int index = 3; index < argc; ++index) { + const std::wstring argument = argv[index]; + if (_wcsicmp(argument.c_str(), L"--manifest") == 0 && index + 1 < argc && !manifestSeen) { + options->manifestPath = argv[++index]; + manifestSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--manifest-sha256") == 0 && + index + 1 < argc && !manifestHashSeen) { + const std::wstring wide = argv[++index]; + options->manifestSha256.clear(); + options->manifestSha256.reserve(wide.size()); + for (const wchar_t value : wide) { + if (value > 0x7f) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"manifest SHA-256 must contain ASCII hexadecimal characters"); + } + options->manifestSha256.push_back(static_cast(value)); + } + if (options->manifestSha256.size() != 64 || + !std::all_of(options->manifestSha256.begin(), options->manifestSha256.end(), + [](unsigned char value) { return std::isxdigit(value) != 0; })) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"manifest SHA-256 must contain exactly 64 hexadecimal characters"); + } + manifestHashSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--source-revision") == 0 && + index + 1 < argc && !revisionSeen) { + const std::wstring wide = argv[++index]; + options->sourceRevision.clear(); + options->sourceRevision.reserve(wide.size()); + for (const wchar_t value : wide) { + if (value > 0x7f) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"source revision must contain ASCII hexadecimal characters"); + } + options->sourceRevision.push_back(static_cast(value)); + } + if (!IsHexRevision(options->sourceRevision)) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"source revision must contain exactly 40 or 64 hexadecimal characters"); + } + revisionSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--validation-mode") == 0 && + index + 1 < argc && !modeSeen) { + const std::wstring mode = argv[++index]; + if (_wcsicmp(mode.c_str(), L"production") == 0) { + options->production = true; + options->localTest = false; + } else if (_wcsicmp(mode.c_str(), L"controlled-test") == 0) { + options->production = false; + options->localTest = false; + } else if (_wcsicmp(mode.c_str(), L"local-test") == 0) { + options->production = false; + options->localTest = true; + } else { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"validation mode must be production, controlled-test, or local-test"); + } + modeSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--expected-inf-sha256") == 0 && + index + 1 < argc && !infHashSeen) { + if (!CopySha256Argument( + argv[++index], L"runtime INF", &options->expectedInfSha256, error)) { + return false; + } + infHashSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--expected-sys-sha256") == 0 && + index + 1 < argc && !sysHashSeen) { + if (!CopySha256Argument( + argv[++index], L"runtime SYS", &options->expectedSysSha256, error)) { + return false; + } + sysHashSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--expected-cat-sha256") == 0 && + index + 1 < argc && !catHashSeen) { + if (!CopySha256Argument( + argv[++index], L"runtime CAT", &options->expectedCatSha256, error)) { + return false; + } + catHashSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--allow-controlled-downgrade") == 0 && + index + 1 < argc && !options->expectedDowngradeFrom) { + Version expected{}; + if (!ParseVersion(argv[++index], &expected)) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"controlled downgrade requires the exact installed four-part version"); + } + options->expectedDowngradeFrom = expected; + } else if (_wcsicmp(argument.c_str(), L"--broker-executable") == 0 && + index + 1 < argc && !brokerSeen) { + options->brokerExecutable = argv[++index]; + brokerSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--broker-sha256") == 0 && + index + 1 < argc && !brokerHashSeen) { + const std::wstring wide = argv[++index]; + options->brokerSha256.clear(); + options->brokerSha256.reserve(wide.size()); + for (const wchar_t value : wide) { + if (value > 0x7f) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"broker SHA-256 must contain ASCII hexadecimal characters"); + } + options->brokerSha256.push_back(static_cast(value)); + } + if (options->brokerSha256.size() != 64 || + !std::all_of(options->brokerSha256.begin(), options->brokerSha256.end(), + [](unsigned char value) { return std::isxdigit(value) != 0; })) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"broker SHA-256 must contain exactly 64 hexadecimal characters"); + } + brokerHashSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--target-user-sid") == 0 && + index + 1 < argc && !targetUserSeen) { + options->targetUserSid = argv[++index]; + targetUserSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--broker-token") == 0 && + index + 1 < argc && !brokerTokenSeen) { + options->brokerToken = argv[++index]; + brokerTokenSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--broker-token-sha256") == 0 && + index + 1 < argc && !brokerTokenHashSeen) { + const std::wstring wide = argv[++index]; + options->brokerTokenSha256.clear(); + options->brokerTokenSha256.reserve(wide.size()); + for (const wchar_t value : wide) { + if (value > 0x7f) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"broker token SHA-256 must contain ASCII hexadecimal characters"); + } + options->brokerTokenSha256.push_back(static_cast(value)); + } + if (options->brokerTokenSha256.size() != 64 || + !std::all_of(options->brokerTokenSha256.begin(), options->brokerTokenSha256.end(), + [](unsigned char value) { return std::isxdigit(value) != 0; })) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"broker token SHA-256 must contain exactly 64 hexadecimal characters"); + } + brokerTokenHashSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--transaction-deadline-unix-ms") == 0 && + index + 1 < argc && !transactionDeadlineSeen) { + const std::wstring value = argv[++index]; + if (value.empty() || value.size() > 20 || + !std::all_of(value.begin(), value.end(), [](wchar_t character) { + return character >= L'0' && character <= L'9'; + })) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"transaction deadline must contain only Unix-millisecond digits"); + } + const wchar_t* begin = value.data(); + wchar_t* end = nullptr; + errno = 0; + const unsigned long long parsed = std::wcstoull(begin, &end, 10); + if (errno == ERANGE || end == begin || end != begin + value.size() || parsed == 0) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"transaction deadline must be positive Unix milliseconds"); + } + options->transactionDeadlineUnixMs = static_cast(parsed); + transactionDeadlineSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--broker-quiesce-request-handle") == 0 && + index + 1 < argc && !brokerQuiesceRequestSeen) { + if (!ParseInheritedEventHandle(argv[++index], L"broker quiesce request", + &options->brokerQuiesceRequest, error)) { + return false; + } + brokerQuiesceRequestSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--broker-quiesce-ready-handle") == 0 && + index + 1 < argc && !brokerQuiesceReadySeen) { + if (!ParseInheritedEventHandle(argv[++index], L"broker quiesce ready", + &options->brokerQuiesceReady, error)) { + return false; + } + brokerQuiesceReadySeen = true; + } else if (_wcsicmp(argument.c_str(), L"--broker-quiesce-abort-handle") == 0 && + index + 1 < argc && !brokerQuiesceAbortSeen) { + if (!ParseInheritedEventHandle(argv[++index], L"broker quiesce abort", + &options->brokerQuiesceAbort, error)) { + return false; + } + brokerQuiesceAbortSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--broker-handoff-handle") == 0 && + index + 1 < argc && !brokerHandoffSeen) { + if (!ParseInheritedEventHandle(argv[++index], L"broker handoff", + &options->brokerHandoff, error)) { + return false; + } + brokerHandoffSeen = true; + } else { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"unknown, duplicate, or incomplete install option"); + } + } + if (!manifestSeen || !manifestHashSeen || !revisionSeen || !modeSeen || + !infHashSeen || !sysHashSeen || !catHashSeen || + !transactionDeadlineSeen || + brokerSeen != targetUserSeen || brokerSeen != brokerHashSeen || + brokerSeen != brokerTokenSeen || brokerSeen != brokerTokenHashSeen || + brokerSeen != brokerQuiesceRequestSeen || brokerSeen != brokerQuiesceReadySeen || + brokerSeen != brokerQuiesceAbortSeen || brokerSeen != brokerHandoffSeen) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"manifest, its installer hash, source revision, validation mode, and exact INF/SYS/CAT hashes are required; broker executable, hashes, protected token, target SID, and inherited quiescence/handoff events must be supplied together"); + } + if (brokerSeen) { + const std::set coordinationHandles{ + reinterpret_cast(options->brokerQuiesceRequest), + reinterpret_cast(options->brokerQuiesceReady), + reinterpret_cast(options->brokerQuiesceAbort), + reinterpret_cast(options->brokerHandoff), + }; + if (coordinationHandles.size() != 4) { + return SetError(error, L"arguments", ERROR_INVALID_HANDLE, + L"broker quiescence and handoff require four distinct inherited events"); + } + } + return true; +} + +bool ParseRemoveOptions(int argc, wchar_t** argv, RemoveOptions* options, Error* error) { + if (argc == 2) { + options->transactionDeadlineUnixMs = + CurrentUnixMilliseconds() + kMaximumTransactionDurationMs; + return true; + } + if (argc != 4 || + _wcsicmp(argv[2], L"--transaction-deadline-unix-ms") != 0) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"remove accepts only an optional absolute transaction deadline"); + } + const std::wstring value = argv[3]; + if (value.empty() || value.size() > 20 || + !std::all_of(value.begin(), value.end(), [](wchar_t character) { + return character >= L'0' && character <= L'9'; + })) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"transaction deadline must contain only Unix-millisecond digits"); + } + const wchar_t* begin = value.data(); + wchar_t* end = nullptr; + errno = 0; + const unsigned long long parsed = std::wcstoull(begin, &end, 10); + if (errno == ERANGE || end == begin || end != begin + value.size() || parsed == 0) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"transaction deadline must be positive Unix milliseconds"); + } + options->transactionDeadlineUnixMs = static_cast(parsed); + return true; +} + +bool CopyCanonicalSettlementHex( + const wchar_t* value, + size_t length, + std::string* output, + Error* error) { + const std::wstring wide = value == nullptr ? L"" : value; + if (wide.size() != length) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"settlement identity has the wrong length"); + } + output->clear(); + output->reserve(wide.size()); + for (wchar_t character : wide) { + if (!((character >= L'0' && character <= L'9') || + (character >= L'a' && character <= L'f'))) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"settlement identity must be canonical lowercase hexadecimal"); + } + output->push_back(static_cast(character)); + } + return true; +} + +bool ParseSettlementDeadline( + const wchar_t* value, + uint64_t* deadline, + Error* error) { + const std::wstring text = value == nullptr ? L"" : value; + if (text.empty() || text.size() > 20U || + !std::all_of(text.begin(), text.end(), [](wchar_t character) { + return character >= L'0' && character <= L'9'; + })) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"transaction deadline must contain only Unix-millisecond digits"); + } + const wchar_t* begin = text.data(); + wchar_t* end = nullptr; + errno = 0; + const unsigned long long parsed = std::wcstoull(begin, &end, 10); + if (errno == ERANGE || end == begin || + end != begin + text.size() || parsed == 0U) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"transaction deadline must be positive Unix milliseconds"); + } + *deadline = static_cast(parsed); + return ValidateTransactionDeadlineBudget(*deadline, error); +} + +bool ParseBrokerSettlementAckOptions( + int argc, + wchar_t** argv, + BrokerSettlementAckOptions* options, + Error* error) { + if (argc != 8 || _wcsicmp(argv[2], L"--request") != 0 || + _wcsicmp(argv[4], L"--request-sha256") != 0 || + _wcsicmp(argv[6], + L"--transaction-deadline-unix-ms") != 0) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER); + } + options->requestPath = argv[3]; + return CopyCanonicalSettlementHex( + argv[5], 64U, &options->requestSha256, error) && + ParseSettlementDeadline(argv[7], + &options->transactionDeadlineUnixMs, error); +} + +bool ParseBrokerSettlementDiscardOptions( + int argc, + wchar_t** argv, + BrokerSettlementDiscardOptions* options, + Error* error) { + if (argc != 20 || + _wcsicmp(argv[2], L"--broker-transaction-id") != 0 || + _wcsicmp(argv[4], L"--broker-settled-digest") != 0 || + _wcsicmp(argv[6], L"--driver-transaction-id") != 0 || + _wcsicmp(argv[8], L"--driver-settled-digest") != 0 || + _wcsicmp(argv[10], L"--settlement-nonce") != 0 || + _wcsicmp(argv[12], L"--request-sha256") != 0 || + _wcsicmp(argv[14], L"--broker-final-receipt") != 0 || + _wcsicmp(argv[16], + L"--broker-final-receipt-sha256") != 0 || + _wcsicmp(argv[18], + L"--transaction-deadline-unix-ms") != 0) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER); + } + options->brokerFinalReceiptPath = argv[15]; + return CopyCanonicalSettlementHex(argv[3], 32U, + &options->brokerTransactionId, error) && + CopyCanonicalSettlementHex(argv[5], 64U, + &options->brokerDigest, error) && + CopyCanonicalSettlementHex(argv[7], 64U, + &options->driverTransactionId, error) && + CopyCanonicalSettlementHex(argv[9], 64U, + &options->driverDigest, error) && + CopyCanonicalSettlementHex(argv[11], 64U, + &options->settlementNonce, error) && + CopyCanonicalSettlementHex(argv[13], 64U, + &options->requestSha256, error) && + CopyCanonicalSettlementHex(argv[17], 64U, + &options->brokerFinalReceiptSha256, error) && + ParseSettlementDeadline(argv[19], + &options->transactionDeadlineUnixMs, error); +} + +void Usage() { + std::wcerr + << L"usage:\n" + << L" ViiperUdeCtl.exe install --manifest --manifest-sha256 <64 hex> " + L"--source-revision <40-or-64 hex> --validation-mode " + L"--expected-inf-sha256 <64 hex> --expected-sys-sha256 <64 hex> " + L"--expected-cat-sha256 <64 hex> " + L"--transaction-deadline-unix-ms " + L"[--allow-controlled-downgrade ] " + L"--broker-executable --broker-sha256 <64 hex> " + L"--broker-token --broker-token-sha256 <64 hex> " + L"--target-user-sid " + L"--broker-quiesce-request-handle " + L"--broker-quiesce-ready-handle " + L"--broker-quiesce-abort-handle " + L"--broker-handoff-handle \n" + << L" ViiperUdeCtl.exe verify --manifest --manifest-sha256 <64 hex> " + L"--source-revision <40-or-64 hex> --validation-mode " + L"--expected-inf-sha256 <64 hex> --expected-sys-sha256 <64 hex> " + L"--expected-cat-sha256 <64 hex> " + L"--transaction-deadline-unix-ms \n" + << L" ViiperUdeCtl.exe remove [--transaction-deadline-unix-ms ]\n" + << L" ViiperUdeCtl.exe recover [--transaction-deadline-unix-ms ]\n" + << L" ViiperUdeCtl.exe broker-settlement-ack --request --request-sha256 <64 hex> --transaction-deadline-unix-ms \n" + << L" ViiperUdeCtl.exe broker-settlement-discard --broker-transaction-id <32 hex> --broker-settled-digest <64 hex> --driver-transaction-id <64 hex> --driver-settled-digest <64 hex> --settlement-nonce <64 hex> --request-sha256 <64 hex> --broker-final-receipt --broker-final-receipt-sha256 <64 hex> --transaction-deadline-unix-ms \n" + << L" ViiperUdeCtl.exe status\n" + << L" ViiperUdeCtl.exe self-test\n"; +} + +} // namespace + +int RunViiperUdeCtl(int argc, wchar_t** argv) { + ClearActiveRecoveryEvidence(); + ClearRemoveRetirementWarning(); + gTransactionMutationStarted = false; + if (argc >= 2 && + _wcsicmp(argv[1], L"broker-settlement-ack") == 0) { + BrokerSettlementAckOptions options; + Error error; + if (!ParseBrokerSettlementAckOptions( + argc, argv, &options, &error)) { + Usage(); + Outcome outcome; + outcome.error = std::move(error); + outcome.exitCode = ExitCode::Usage; + EmitOutcome(L"broker-settlement-ack", outcome); + return static_cast(outcome.exitCode); + } + BrokerSettlementRequestData receipt; + std::string driverFinalDigest; + if (!AcknowledgeBrokerOuterSettlement( + options, &receipt, &driverFinalDigest, &error)) { + Outcome outcome; + outcome.changed = gTransactionMutationStarted; + outcome.error = std::move(error); + outcome.rollback = outcome.changed ? L"failed" : L"not-needed"; + outcome.exitCode = outcome.changed + ? ExitCode::RollbackFailed : ExitCode::PreflightRejected; + EmitOutcome(L"broker-settlement-ack", outcome); + return static_cast(outcome.exitCode); + } + EmitBrokerSettlementAck(receipt, driverFinalDigest); + return 0; + } + if (argc >= 2 && + _wcsicmp(argv[1], L"broker-settlement-discard") == 0) { + BrokerSettlementDiscardOptions options; + Error error; + if (!ParseBrokerSettlementDiscardOptions( + argc, argv, &options, &error)) { + Usage(); + Outcome outcome; + outcome.error = std::move(error); + outcome.exitCode = ExitCode::Usage; + EmitOutcome(L"broker-settlement-discard", outcome); + return static_cast(outcome.exitCode); + } + bool discarded = false; + bool retained = false; + if (!DiscardBrokerSettlementTombstone( + options, &discarded, &retained, &error)) { + Outcome outcome; + outcome.error = std::move(error); + outcome.exitCode = ExitCode::PreflightRejected; + EmitOutcome(L"broker-settlement-discard", outcome); + return static_cast(outcome.exitCode); + } + EmitBrokerSettlementDiscard(options, discarded, retained); + return 0; + } + if (argc >= 3 && + (_wcsicmp(argv[1], L"install") == 0 || _wcsicmp(argv[1], L"verify") == 0)) { + InstallOptions options; + Error argumentError; + if (!ParseInstallOptions(argc, argv, &options, &argumentError)) { + Usage(); + Outcome outcome; + outcome.error = std::move(argumentError); + outcome.exitCode = ExitCode::Usage; + EmitOutcome(argv[1], outcome); + return static_cast(outcome.exitCode); + } + if (_wcsicmp(argv[1], L"install") == 0 && + (options.production || options.localTest) && + options.brokerExecutable.empty()) { + Outcome outcome; + SetError(&outcome.error, L"broker-required", ERROR_INVALID_PARAMETER, + L"production and local-test driver installation require the authenticated broker transaction"); + outcome.exitCode = ExitCode::PreflightRejected; + EmitOutcome(argv[1], outcome); + return static_cast(outcome.exitCode); + } + Outcome outcome = + _wcsicmp(argv[1], L"verify") == 0 ? Verify(options) : Install(options); + EmitOutcome(argv[1], outcome); + return static_cast(outcome.exitCode); + } + if (argc >= 2 && _wcsicmp(argv[1], L"remove") == 0) { + RemoveOptions options; + Error argumentError; + if (!ParseRemoveOptions(argc, argv, &options, &argumentError)) { + Usage(); + Outcome outcome; + outcome.error = std::move(argumentError); + outcome.exitCode = ExitCode::Usage; + EmitOutcome(L"remove", outcome); + return static_cast(outcome.exitCode); + } + Outcome outcome = Remove(options); + EmitOutcome(L"remove", outcome); + return static_cast(outcome.exitCode); + } + if (argc >= 2 && _wcsicmp(argv[1], L"recover") == 0) { + RemoveOptions options; + Error argumentError; + if (!ParseRemoveOptions(argc, argv, &options, &argumentError)) { + Usage(); + Outcome outcome; + outcome.error = std::move(argumentError); + outcome.exitCode = ExitCode::Usage; + EmitOutcome(L"recover", outcome); + return static_cast(outcome.exitCode); + } + Outcome outcome = Recover(options.transactionDeadlineUnixMs); + EmitOutcome(L"recover", outcome); + return static_cast(outcome.exitCode); + } + if (argc == 2 && _wcsicmp(argv[1], L"status") == 0) { + Outcome outcome = Status(); + EmitOutcome(L"status", outcome); + return static_cast(outcome.exitCode); + } + if (argc == 2 && _wcsicmp(argv[1], L"self-test") == 0) { + Outcome outcome = SelfTest(); + EmitOutcome(L"self-test", outcome); + return static_cast(outcome.exitCode); + } + Usage(); + Outcome outcome; + SetError(&outcome.error, L"arguments", ERROR_INVALID_PARAMETER); + outcome.exitCode = ExitCode::Usage; + EmitOutcome(L"unknown", outcome); + return static_cast(outcome.exitCode); +} + +const wchar_t* ExceptionOperation(int argc, wchar_t** argv) noexcept { + if (argc < 2 || argv == nullptr || argv[1] == nullptr) { + return L"unknown"; + } + for (const wchar_t* operation : + {L"install", L"verify", L"remove", L"recover", L"status", + L"self-test", L"broker-settlement-ack", + L"broker-settlement-discard"}) { + if (_wcsicmp(argv[1], operation) == 0) { + return operation; + } + } + return L"unknown"; +} + +int wmain(int argc, wchar_t** argv) { + try { + return RunViiperUdeCtl(argc, argv); + } catch (...) { + const wchar_t* operation = ExceptionOperation(argc, argv); + const bool changed = gTransactionMutationStarted; + const ExitCode exitCode = changed + ? ExitCode::RollbackFailed : ExitCode::PreflightRejected; + std::fwprintf(stderr, + L"result=error operation=%ls changed=%d rebootRequired=0 " + L"rollback=%ls exitCode=%d phase=\"unhandled-cpp-exception\" " + L"win32Error=%lu message=\"%ls\"", + operation, changed ? 1 : 0, changed ? L"failed" : L"not-needed", + static_cast(exitCode), static_cast(ERROR_GEN_FAILURE), + changed + ? L"unhandled C++ exception after transaction mutation; external reconciliation is required" + : L"unhandled C++ exception before transaction mutation"); + if (gActiveRecoveryRecord[0] != L'\0') { + std::fwprintf(stderr, + L" recoveryRecord=\"%ls\" recoveryRecordWritten=%d", + gActiveRecoveryRecord.data(), + gActiveRecoveryRecordWritten ? 1 : 0); + } + if (gActiveBackupRootRetained && gActiveBackupRoot[0] != L'\0') { + std::fwprintf(stderr, + L" recoveryBackup=\"%ls\" recoveryBackupRetained=1", + gActiveBackupRoot.data()); + } + std::fwprintf(stderr, L"\n"); + std::fflush(stderr); + return static_cast(exitCode); + } +} diff --git a/native/udecx/tools/ViiperUdeInputProbe.cpp b/native/udecx/tools/ViiperUdeInputProbe.cpp new file mode 100644 index 00000000..2a655ebc --- /dev/null +++ b/native/udecx/tools/ViiperUdeInputProbe.cpp @@ -0,0 +1,467 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +class DeviceInfoSet final { +public: + explicit DeviceInfoSet(HDEVINFO value = INVALID_HANDLE_VALUE) : value_(value) {} + ~DeviceInfoSet() { + if (value_ != INVALID_HANDLE_VALUE) SetupDiDestroyDeviceInfoList(value_); + } + DeviceInfoSet(const DeviceInfoSet&) = delete; + DeviceInfoSet& operator=(const DeviceInfoSet&) = delete; + HDEVINFO get() const { return value_; } +private: + HDEVINFO value_; +}; + +class Handle final { +public: + explicit Handle(HANDLE value = INVALID_HANDLE_VALUE) : value_(value) {} + ~Handle() { + if (value_ != INVALID_HANDLE_VALUE && value_ != nullptr) CloseHandle(value_); + } + Handle(const Handle&) = delete; + Handle& operator=(const Handle&) = delete; + Handle(Handle&& other) noexcept : value_(other.value_) { + other.value_ = INVALID_HANDLE_VALUE; + } + Handle& operator=(Handle&& other) noexcept { + if (this != &other) { + if (value_ != INVALID_HANDLE_VALUE && value_ != nullptr) CloseHandle(value_); + value_ = other.value_; + other.value_ = INVALID_HANDLE_VALUE; + } + return *this; + } + HANDLE get() const { return value_; } + bool valid() const { return value_ != INVALID_HANDLE_VALUE && value_ != nullptr; } +private: + HANDLE value_; +}; + +class PreparsedData final { +public: + explicit PreparsedData(PHIDP_PREPARSED_DATA value = nullptr) : value_(value) {} + ~PreparsedData() { if (value_ != nullptr) HidD_FreePreparsedData(value_); } + PreparsedData(const PreparsedData&) = delete; + PreparsedData& operator=(const PreparsedData&) = delete; + PHIDP_PREPARSED_DATA get() const { return value_; } +private: + PHIDP_PREPARSED_DATA value_; +}; + +std::string Win32Error(const char* operation) { + return std::string(operation) + " failed with Win32 error " + + std::to_string(GetLastError()); +} + +std::string WideToUtf8(const std::wstring& value) { + if (value.empty()) return {}; + const int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), nullptr, 0, nullptr, nullptr); + if (size <= 0) throw std::runtime_error(Win32Error("WideCharToMultiByte")); + std::string result(static_cast(size), '\0'); + if (WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), result.data(), size, nullptr, nullptr) != size) { + throw std::runtime_error("WideCharToMultiByte returned a short conversion"); + } + return result; +} + +std::wstring Utf8ToWide(const std::string& value) { + if (value.empty()) return {}; + const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), nullptr, 0); + if (size <= 0) throw std::runtime_error(Win32Error("MultiByteToWideChar")); + std::wstring result(static_cast(size), L'\0'); + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), result.data(), size) != size) { + throw std::runtime_error("MultiByteToWideChar returned a short conversion"); + } + return result; +} + +std::set EnumerateHidPaths() { + GUID hidGuid{}; + HidD_GetHidGuid(&hidGuid); + DeviceInfoSet devices(SetupDiGetClassDevsW( + &hidGuid, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE)); + if (devices.get() == INVALID_HANDLE_VALUE) { + throw std::runtime_error(Win32Error("SetupDiGetClassDevs(HID)")); + } + + std::set paths; + for (DWORD index = 0;; ++index) { + SP_DEVICE_INTERFACE_DATA interfaceData{}; + interfaceData.cbSize = sizeof(interfaceData); + if (!SetupDiEnumDeviceInterfaces( + devices.get(), nullptr, &hidGuid, index, &interfaceData)) { + if (GetLastError() == ERROR_NO_MORE_ITEMS) break; + throw std::runtime_error(Win32Error("SetupDiEnumDeviceInterfaces(HID)")); + } + + DWORD required = 0; + SetupDiGetDeviceInterfaceDetailW( + devices.get(), &interfaceData, nullptr, 0, &required, nullptr); + if (GetLastError() != ERROR_INSUFFICIENT_BUFFER || + required < sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W)) { + throw std::runtime_error(Win32Error("SetupDiGetDeviceInterfaceDetail(size)")); + } + std::vector storage(required); + auto* detail = reinterpret_cast(storage.data()); + detail->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W); + if (!SetupDiGetDeviceInterfaceDetailW( + devices.get(), &interfaceData, detail, required, nullptr, nullptr)) { + throw std::runtime_error(Win32Error("SetupDiGetDeviceInterfaceDetail(HID)")); + } + paths.emplace(detail->DevicePath); + } + return paths; +} + +void WriteSnapshot(const std::filesystem::path& path) { + std::ofstream output(path, std::ios::binary | std::ios::trunc); + if (!output) throw std::runtime_error("could not create HID snapshot"); + for (const auto& devicePath : EnumerateHidPaths()) { + output << WideToUtf8(devicePath) << "\n"; + } + output.flush(); + if (!output) throw std::runtime_error("could not write HID snapshot"); +} + +std::set ReadSnapshot(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + if (!input) throw std::runtime_error("could not open HID snapshot"); + std::set result; + std::string line; + while (std::getline(input, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (!line.empty()) result.emplace(Utf8ToWide(line)); + } + if (!input.eof()) throw std::runtime_error("could not read HID snapshot"); + return result; +} + +struct OpenHid final { + Handle file; + USHORT inputReportLength = 0; + USHORT outputReportLength = 0; + std::wstring path; +}; + +std::unique_ptr TryOpenGamepad( + const std::wstring& path, + USHORT vendorId, + USHORT productId, + DWORD desiredAccess) { + Handle file(CreateFileW(path.c_str(), desiredAccess, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, + FILE_FLAG_OVERLAPPED, nullptr)); + if (!file.valid()) return nullptr; + + HIDD_ATTRIBUTES attributes{}; + attributes.Size = sizeof(attributes); + if (!HidD_GetAttributes(file.get(), &attributes) || + attributes.VendorID != vendorId || attributes.ProductID != productId) { + return nullptr; + } + + PHIDP_PREPARSED_DATA rawData = nullptr; + if (!HidD_GetPreparsedData(file.get(), &rawData)) return nullptr; + PreparsedData data(rawData); + HIDP_CAPS caps{}; + if (HidP_GetCaps(data.get(), &caps) != HIDP_STATUS_SUCCESS || + caps.UsagePage != 0x01 || caps.Usage != 0x05 || caps.InputReportByteLength == 0) { + return nullptr; + } + + auto result = std::make_unique(); + result->file = std::move(file); + result->inputReportLength = caps.InputReportByteLength; + result->outputReportLength = caps.OutputReportByteLength; + result->path = path; + return result; +} + +std::unique_ptr WaitForNewGamepad( + const std::set& baseline, + USHORT vendorId, + USHORT productId, + DWORD desiredAccess, + std::chrono::seconds timeout) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + do { + std::unique_ptr match; + for (const auto& path : EnumerateHidPaths()) { + if (baseline.contains(path)) continue; + auto candidate = TryOpenGamepad(path, vendorId, productId, desiredAccess); + if (!candidate) continue; + if (match) { + throw std::runtime_error( + "more than one new matching gamepad collection appeared; refusing an ambiguous latency measurement"); + } + match = std::move(candidate); + } + if (match) return match; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } while (std::chrono::steady_clock::now() < deadline); + throw std::runtime_error("the virtual HID gamepad collection did not appear before timeout"); +} + +std::uint32_t ParseUnsigned(const wchar_t* value, const wchar_t* name, std::uint32_t maximum) { + wchar_t* end = nullptr; + const unsigned long parsed = wcstoul(value, &end, 0); + if (value == end || *end != L'\0' || parsed > maximum) { + throw std::runtime_error("invalid " + WideToUtf8(name)); + } + return static_cast(parsed); +} + +void CancelAndDrainOverlapped(HANDLE file, OVERLAPPED& overlapped) { + if (!CancelIoEx(file, &overlapped)) { + const DWORD error = GetLastError(); + if (error != ERROR_NOT_FOUND) { + throw std::runtime_error( + "CancelIoEx failed with Win32 error " + std::to_string(error)); + } + } + DWORD ignored = 0; + if (!GetOverlappedResult(file, &overlapped, &ignored, TRUE)) { + const DWORD error = GetLastError(); + if (error != ERROR_OPERATION_ABORTED && error != ERROR_NOT_FOUND) { + throw std::runtime_error( + "draining cancelled overlapped I/O failed with Win32 error " + + std::to_string(error)); + } + } +} + +int Measure( + const std::filesystem::path& snapshotPath, + USHORT vendorId, + USHORT productId, + std::size_t markerOffset, + std::size_t sampleCount) { + const auto baseline = ReadSnapshot(snapshotPath); + auto device = WaitForNewGamepad( + baseline, vendorId, productId, GENERIC_READ, std::chrono::seconds(30)); + if (markerOffset >= device->inputReportLength) { + throw std::runtime_error("marker offset exceeds the HID input report length"); + } + + Handle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + if (!event.valid()) throw std::runtime_error(Win32Error("CreateEvent")); + std::vector report(device->inputReportLength); + OVERLAPPED overlapped{}; + overlapped.hEvent = event.get(); + + LARGE_INTEGER frequency{}; + if (!QueryPerformanceFrequency(&frequency) || frequency.QuadPart <= 0) { + throw std::runtime_error(Win32Error("QueryPerformanceFrequency")); + } + // Reports already buffered during PnP enumeration predate the producer's + // timestamp. Flush only this probe handle, then continuously use ReadFile + // as prescribed by HIDClass rather than polling HidD_GetInputReport. + if (!HidD_FlushQueue(device->file.get())) { + throw std::runtime_error(Win32Error("HidD_FlushQueue")); + } + std::cout << "READY " << frequency.QuadPart << " " + << device->inputReportLength << " " << WideToUtf8(device->path) << "\n"; + std::cout.flush(); + + std::size_t matches = 0; + int previousMarker = -1; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + while (matches < sampleCount && std::chrono::steady_clock::now() < deadline) { + ResetEvent(event.get()); + std::fill(report.begin(), report.end(), std::uint8_t{0}); + DWORD transferred = 0; + BOOL completed = ReadFile(device->file.get(), report.data(), + static_cast(report.size()), &transferred, &overlapped); + if (!completed) { + const DWORD error = GetLastError(); + if (error != ERROR_IO_PENDING) { + throw std::runtime_error(Win32Error("ReadFile(HID)")); + } + DWORD wait = WAIT_TIMEOUT; + while (wait == WAIT_TIMEOUT && std::chrono::steady_clock::now() < deadline) { + const auto remaining = std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()); + const DWORD waitMilliseconds = remaining.count() <= 0 + ? 0 + : static_cast(std::min(remaining.count(), 1000)); + wait = WaitForSingleObject(event.get(), waitMilliseconds); + } + if (wait == WAIT_TIMEOUT) { + CancelAndDrainOverlapped(device->file.get(), overlapped); + break; + } + if (wait != WAIT_OBJECT_0 || + !GetOverlappedResult(device->file.get(), &overlapped, &transferred, FALSE)) { + throw std::runtime_error(Win32Error("GetOverlappedResult(HID)")); + } + } + if (transferred <= markerOffset) continue; + const int marker = report[markerOffset]; + if ((marker != 0xFD && marker != 0xFE) || marker == previousMarker) continue; + previousMarker = marker; + LARGE_INTEGER observed{}; + QueryPerformanceCounter(&observed); + std::cout << "MATCH " << marker << " " << observed.QuadPart << "\n"; + std::cout.flush(); + ++matches; + } + if (matches != sampleCount) { + throw std::runtime_error("timed out before observing every unique input marker"); + } + return 0; +} + +std::vector BuildFeedbackReport( + const std::wstring& controllerKind, + USHORT outputReportLength) { + if (_wcsicmp(controllerKind.c_str(), L"dualshock4") == 0) { + if (outputReportLength != 32) { + throw std::runtime_error( + "DualShock 4 HID output report length is not the expected 32 bytes"); + } + std::vector report(outputReportLength); + report[0] = 0x05; + report[4] = 0x23; + report[5] = 0xA7; + report[6] = 0x11; + report[7] = 0x52; + report[8] = 0xC3; + report[9] = 0x04; + report[10] = 0x09; + return report; + } + if (_wcsicmp(controllerKind.c_str(), L"dualsense") == 0 || + _wcsicmp(controllerKind.c_str(), L"dualsense-edge") == 0) { + if (outputReportLength != 48) { + throw std::runtime_error( + "DualSense HID output report length is not the expected 48 bytes"); + } + std::vector report(outputReportLength); + report[0] = 0x02; + report[1] = 0x0F; // compatible rumble and both adaptive triggers + report[2] = 0x14; // player LEDs and lightbar + report[3] = 0x22; + report[4] = 0x88; + report[11] = 0x21; + report[12] = 0xFC; + report[13] = 0x03; + report[20] = 0x44; + report[22] = 0x25; + report[23] = 0x40; + report[24] = 0x05; + report[31] = 0x55; + report[44] = 0x24; + report[45] = 0x11; + report[46] = 0x52; + report[47] = 0xC3; + return report; + } + throw std::runtime_error("unsupported feedback controller kind"); +} + +int SendFeedback( + const std::filesystem::path& snapshotPath, + USHORT vendorId, + USHORT productId, + const std::wstring& controllerKind) { + const auto baseline = ReadSnapshot(snapshotPath); + auto device = WaitForNewGamepad( + baseline, vendorId, productId, GENERIC_READ | GENERIC_WRITE, + std::chrono::seconds(30)); + if (device->outputReportLength == 0) { + throw std::runtime_error("the virtual HID gamepad has no output report"); + } + auto report = BuildFeedbackReport(controllerKind, device->outputReportLength); + + Handle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + if (!event.valid()) throw std::runtime_error(Win32Error("CreateEvent")); + OVERLAPPED overlapped{}; + overlapped.hEvent = event.get(); + DWORD transferred = 0; + BOOL completed = WriteFile(device->file.get(), report.data(), + static_cast(report.size()), &transferred, &overlapped); + if (!completed) { + const DWORD error = GetLastError(); + if (error != ERROR_IO_PENDING) { + throw std::runtime_error(Win32Error("WriteFile(HID output)")); + } + const DWORD wait = WaitForSingleObject(event.get(), 10000); + if (wait == WAIT_TIMEOUT) { + CancelAndDrainOverlapped(device->file.get(), overlapped); + throw std::runtime_error("timed out writing the HID output report"); + } + if (wait != WAIT_OBJECT_0 || + !GetOverlappedResult(device->file.get(), &overlapped, &transferred, FALSE)) { + throw std::runtime_error(Win32Error("GetOverlappedResult(HID output)")); + } + } + if (transferred != static_cast(report.size())) { + throw std::runtime_error("the HID output report completed with a short write"); + } + std::cout << "WROTE " << transferred << " " << WideToUtf8(device->path) << "\n"; + return 0; +} + +} // namespace + +int wmain(int argc, wchar_t** argv) { + try { + if (argc == 3 && _wcsicmp(argv[1], L"snapshot") == 0) { + WriteSnapshot(argv[2]); + return 0; + } + if (argc == 8 && _wcsicmp(argv[1], L"measure") == 0) { + const auto vendorId = static_cast(ParseUnsigned(argv[3], L"vendor ID", 0xFFFF)); + const auto productId = static_cast(ParseUnsigned(argv[4], L"product ID", 0xFFFF)); + const auto offset = static_cast(ParseUnsigned(argv[5], L"marker offset", 4095)); + const auto samples = static_cast(ParseUnsigned(argv[6], L"sample count", 10000)); + // argv[7] is a versioned invocation token. Requiring it catches a + // stale helper copied from a different native ABI package. + if (wcscmp(argv[7], L"qpc-v1") != 0) { + throw std::runtime_error("unsupported latency probe contract"); + } + if (samples == 0) throw std::runtime_error("sample count must be nonzero"); + return Measure(argv[2], vendorId, productId, offset, samples); + } + if (argc == 7 && _wcsicmp(argv[1], L"feedback") == 0) { + const auto vendorId = static_cast(ParseUnsigned(argv[3], L"vendor ID", 0xFFFF)); + const auto productId = static_cast(ParseUnsigned(argv[4], L"product ID", 0xFFFF)); + if (wcscmp(argv[6], L"hid-output-v1") != 0) { + throw std::runtime_error("unsupported HID output probe contract"); + } + return SendFeedback(argv[2], vendorId, productId, argv[5]); + } + std::wcerr << L"Usage:\n" + << L" ViiperUdeInputProbe.exe snapshot \n" + << L" ViiperUdeInputProbe.exe measure qpc-v1\n" + << L" ViiperUdeInputProbe.exe feedback hid-output-v1\n"; + return 2; + } catch (const std::exception& error) { + std::cerr << "VIIPER UDE input probe failed: " << error.what() << "\n"; + return 1; + } +} diff --git a/native/udecx/tools/ViiperUdeMediaProbe.cpp b/native/udecx/tools/ViiperUdeMediaProbe.cpp new file mode 100644 index 00000000..d5819e63 --- /dev/null +++ b/native/udecx/tools/ViiperUdeMediaProbe.cpp @@ -0,0 +1,571 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr double kPi = 3.14159265358979323846; + +template +class ComPtr final { +public: + ComPtr() = default; + ~ComPtr() { reset(); } + ComPtr(const ComPtr&) = delete; + ComPtr& operator=(const ComPtr&) = delete; + ComPtr(ComPtr&& other) noexcept : value_(other.value_) { other.value_ = nullptr; } + ComPtr& operator=(ComPtr&& other) noexcept { + if (this != &other) { + reset(); + value_ = other.value_; + other.value_ = nullptr; + } + return *this; + } + T* get() const { return value_; } + T** put() { + reset(); + return &value_; + } + T* operator->() const { return value_; } + explicit operator bool() const { return value_ != nullptr; } + void reset() { + if (value_ != nullptr) { + value_->Release(); + value_ = nullptr; + } + } +private: + T* value_ = nullptr; +}; + +class Handle final { +public: + explicit Handle(HANDLE value = nullptr) : value_(value) {} + ~Handle() { if (value_ != nullptr) CloseHandle(value_); } + Handle(const Handle&) = delete; + Handle& operator=(const Handle&) = delete; + HANDLE get() const { return value_; } +private: + HANDLE value_; +}; + +class ComApartment final { +public: + ComApartment() { + const HRESULT result = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + if (FAILED(result)) { + throw std::runtime_error("CoInitializeEx failed: 0x" + hex(result)); + } + initialized_ = true; + } + ~ComApartment() { if (initialized_) CoUninitialize(); } + static std::string hex(HRESULT value) { + char buffer[16]{}; + sprintf_s(buffer, "%08lX", static_cast(value)); + return buffer; + } +private: + bool initialized_ = false; +}; + +[[noreturn]] void ThrowHRESULT(const char* operation, HRESULT result) { + throw std::runtime_error(std::string(operation) + " failed: 0x" + ComApartment::hex(result)); +} + +void CheckHRESULT(const char* operation, HRESULT result) { + if (FAILED(result)) ThrowHRESULT(operation, result); +} + +std::string WideToUtf8(const std::wstring& value) { + if (value.empty()) return {}; + const int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), nullptr, 0, nullptr, nullptr); + if (size <= 0) throw std::runtime_error("WideCharToMultiByte failed"); + std::string result(static_cast(size), '\0'); + if (WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), result.data(), size, nullptr, nullptr) != size) { + throw std::runtime_error("WideCharToMultiByte returned a short conversion"); + } + return result; +} + +std::wstring Utf8ToWide(const std::string& value) { + if (value.empty()) return {}; + const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), nullptr, 0); + if (size <= 0) throw std::runtime_error("MultiByteToWideChar failed"); + std::wstring result(static_cast(size), L'\0'); + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), result.data(), size) != size) { + throw std::runtime_error("MultiByteToWideChar returned a short conversion"); + } + return result; +} + +struct EndpointSet { + std::set render; + std::set capture; +}; + +struct MediaFormat final { + DWORD sampleRate = 0; + WORD channels = 0; +}; + +struct RenderStats final { + uint64_t frames = 0; + uint64_t bufferFrames = 0; + uint64_t events = 0; + uint64_t underruns = 0; + double maximumEventGapMilliseconds = 0.0; + MediaFormat format{}; +}; + +struct CaptureStats final { + uint64_t frames = 0; + uint64_t nonSilentFrames = 0; + uint64_t packets = 0; + uint64_t discontinuities = 0; + uint64_t timestampErrors = 0; + uint64_t positionRegressions = 0; + uint64_t qpcRegressions = 0; + double maximumEventGapMilliseconds = 0.0; + MediaFormat format{}; +}; + +struct ExpectedMediaFormat final { + DWORD renderSampleRate = 0; + WORD renderChannels = 0; + DWORD captureSampleRate = 0; + WORD captureChannels = 0; +}; + +ExpectedMediaFormat ExpectedFormatFor(const std::wstring& controller) { + if (_wcsicmp(controller.c_str(), L"dualsense") == 0 || + _wcsicmp(controller.c_str(), L"dualsenseedge") == 0) { + return ExpectedMediaFormat{48000, 4, 48000, 2}; + } + if (_wcsicmp(controller.c_str(), L"dualshock4") == 0) { + return ExpectedMediaFormat{32000, 2, 16000, 1}; + } + throw std::runtime_error("unsupported controller media contract: " + WideToUtf8(controller)); +} + +double EventGapMilliseconds(std::chrono::steady_clock::time_point previous, + std::chrono::steady_clock::time_point current) { + return std::chrono::duration(current - previous).count(); +} + +std::set Enumerate(EDataFlow flow) { + ComApartment apartment; + ComPtr enumerator; + CheckHRESULT("CoCreateInstance(MMDeviceEnumerator)", CoCreateInstance( + __uuidof(MMDeviceEnumerator), nullptr, CLSCTX_INPROC_SERVER, + __uuidof(IMMDeviceEnumerator), reinterpret_cast(enumerator.put()))); + ComPtr collection; + CheckHRESULT("EnumAudioEndpoints", enumerator->EnumAudioEndpoints( + flow, DEVICE_STATE_ACTIVE, collection.put())); + UINT count = 0; + CheckHRESULT("IMMDeviceCollection::GetCount", collection->GetCount(&count)); + std::set result; + for (UINT index = 0; index < count; ++index) { + ComPtr device; + CheckHRESULT("IMMDeviceCollection::Item", collection->Item(index, device.put())); + LPWSTR id = nullptr; + CheckHRESULT("IMMDevice::GetId", device->GetId(&id)); + result.emplace(id); + CoTaskMemFree(id); + } + return result; +} + +EndpointSet EnumerateEndpoints() { + return EndpointSet{Enumerate(eRender), Enumerate(eCapture)}; +} + +void WriteSnapshot(const std::filesystem::path& path, const EndpointSet& endpoints) { + std::ofstream output(path, std::ios::binary | std::ios::trunc); + if (!output) throw std::runtime_error("could not create endpoint snapshot"); + for (const auto& id : endpoints.render) output << "R\t" << WideToUtf8(id) << "\n"; + for (const auto& id : endpoints.capture) output << "C\t" << WideToUtf8(id) << "\n"; + output.flush(); + if (!output) throw std::runtime_error("could not write endpoint snapshot"); +} + +EndpointSet ReadSnapshot(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + if (!input) throw std::runtime_error("could not open endpoint snapshot"); + EndpointSet result; + std::string line; + while (std::getline(input, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line.size() < 3 || line[1] != '\t') { + throw std::runtime_error("invalid endpoint snapshot record"); + } + auto& destination = line[0] == 'R' ? result.render : result.capture; + if (line[0] != 'R' && line[0] != 'C') { + throw std::runtime_error("invalid endpoint snapshot flow"); + } + destination.insert(Utf8ToWide(line.substr(2))); + } + return result; +} + +std::vector Difference(const std::set& current, + const std::set& baseline) { + std::vector result; + std::set_difference(current.begin(), current.end(), baseline.begin(), baseline.end(), + std::back_inserter(result)); + return result; +} + +ComPtr OpenEndpoint(const std::wstring& endpointId) { + ComPtr enumerator; + CheckHRESULT("CoCreateInstance(MMDeviceEnumerator)", CoCreateInstance( + __uuidof(MMDeviceEnumerator), nullptr, CLSCTX_INPROC_SERVER, + __uuidof(IMMDeviceEnumerator), reinterpret_cast(enumerator.put()))); + ComPtr result; + CheckHRESULT("IMMDeviceEnumerator::GetDevice", enumerator->GetDevice( + endpointId.c_str(), result.put())); + return result; +} + +bool IsFloatFormat(const WAVEFORMATEX* format) { + if (format->wFormatTag == WAVE_FORMAT_IEEE_FLOAT) return true; + if (format->wFormatTag != WAVE_FORMAT_EXTENSIBLE || + format->cbSize < sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) return false; + const auto* extensible = reinterpret_cast(format); + return IsEqualGUID(extensible->SubFormat, KSDATAFORMAT_SUBTYPE_IEEE_FLOAT) != FALSE; +} + +bool IsPCMFormat(const WAVEFORMATEX* format) { + if (format->wFormatTag == WAVE_FORMAT_PCM) return true; + if (format->wFormatTag != WAVE_FORMAT_EXTENSIBLE || + format->cbSize < sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) return false; + const auto* extensible = reinterpret_cast(format); + return IsEqualGUID(extensible->SubFormat, KSDATAFORMAT_SUBTYPE_PCM) != FALSE; +} + +void FillTone(BYTE* data, UINT32 frames, const WAVEFORMATEX* format, double& phase) { + if (format->nChannels == 0 || format->nSamplesPerSec == 0 || format->nBlockAlign == 0) { + throw std::runtime_error("audio endpoint returned an invalid mix format"); + } + const UINT32 sampleBytes = format->nBlockAlign / format->nChannels; + if (sampleBytes == 0 || sampleBytes * format->nChannels != format->nBlockAlign) { + throw std::runtime_error("audio endpoint returned an unsupported block alignment"); + } + const bool floating = IsFloatFormat(format); + const bool pcm = IsPCMFormat(format); + if (!floating && !pcm) throw std::runtime_error("audio endpoint mix format is neither PCM nor float"); + if ((floating && sampleBytes != 4) || (!floating && sampleBytes != 1 && sampleBytes != 2 && + sampleBytes != 3 && sampleBytes != 4)) { + throw std::runtime_error("audio endpoint mix format has an unsupported sample width"); + } + + constexpr double frequency = 523.251130601; + constexpr double amplitude = 0.08; + const double increment = 2.0 * kPi * frequency / format->nSamplesPerSec; + for (UINT32 frame = 0; frame < frames; ++frame) { + const double sample = std::sin(phase) * amplitude; + phase += increment; + if (phase >= 2.0 * kPi) phase -= 2.0 * kPi; + for (WORD channel = 0; channel < format->nChannels; ++channel) { + BYTE* destination = data + static_cast(frame) * format->nBlockAlign + + static_cast(channel) * sampleBytes; + if (floating) { + *reinterpret_cast(destination) = static_cast(sample); + } else if (sampleBytes == 1) { + destination[0] = static_cast(std::clamp(128.0 + sample * 127.0, 0.0, 255.0)); + } else { + const auto scaled = static_cast(std::llround(sample * + (sampleBytes == 2 ? 32767.0 : sampleBytes == 3 ? 8388607.0 : 2147483647.0))); + for (UINT32 byte = 0; byte < sampleBytes; ++byte) { + destination[byte] = static_cast((scaled >> (byte * 8)) & 0xff); + } + } + } + } +} + +RenderStats ExerciseRender(const std::wstring& endpointId, std::chrono::seconds duration) { + ComApartment apartment; + auto device = OpenEndpoint(endpointId); + ComPtr client; + CheckHRESULT("IMMDevice::Activate(IAudioClient)", device->Activate( + __uuidof(IAudioClient), CLSCTX_INPROC_SERVER, nullptr, + reinterpret_cast(client.put()))); + WAVEFORMATEX* rawFormat = nullptr; + CheckHRESULT("IAudioClient::GetMixFormat", client->GetMixFormat(&rawFormat)); + std::unique_ptr format(rawFormat, CoTaskMemFree); + CheckHRESULT("IAudioClient::Initialize(render)", client->Initialize( + AUDCLNT_SHAREMODE_SHARED, + AUDCLNT_STREAMFLAGS_EVENTCALLBACK | AUDCLNT_STREAMFLAGS_NOPERSIST, + 0, 0, format.get(), nullptr)); + Handle event(CreateEventW(nullptr, FALSE, FALSE, nullptr)); + if (event.get() == nullptr) throw std::runtime_error("CreateEvent(render) failed"); + CheckHRESULT("IAudioClient::SetEventHandle(render)", client->SetEventHandle(event.get())); + ComPtr render; + CheckHRESULT("IAudioClient::GetService(IAudioRenderClient)", client->GetService( + __uuidof(IAudioRenderClient), reinterpret_cast(render.put()))); + UINT32 bufferFrames = 0; + CheckHRESULT("IAudioClient::GetBufferSize(render)", client->GetBufferSize(&bufferFrames)); + BYTE* data = nullptr; + double phase = 0.0; + CheckHRESULT("IAudioRenderClient::GetBuffer(prime)", render->GetBuffer(bufferFrames, &data)); + FillTone(data, bufferFrames, format.get(), phase); + CheckHRESULT("IAudioRenderClient::ReleaseBuffer(prime)", render->ReleaseBuffer(bufferFrames, 0)); + CheckHRESULT("IAudioClient::Start(render)", client->Start()); + + RenderStats stats{}; + stats.frames = bufferFrames; + stats.bufferFrames = bufferFrames; + stats.format = MediaFormat{format->nSamplesPerSec, format->nChannels}; + auto previousEvent = std::chrono::steady_clock::now(); + bool warmedUp = false; + const auto deadline = std::chrono::steady_clock::now() + duration; + while (std::chrono::steady_clock::now() < deadline) { + const DWORD wait = WaitForSingleObject(event.get(), 2000); + if (wait != WAIT_OBJECT_0) throw std::runtime_error("render event timed out"); + const auto eventTime = std::chrono::steady_clock::now(); + if (stats.events != 0) { + stats.maximumEventGapMilliseconds = std::max( + stats.maximumEventGapMilliseconds, + EventGapMilliseconds(previousEvent, eventTime)); + } + previousEvent = eventTime; + ++stats.events; + UINT32 padding = 0; + CheckHRESULT("IAudioClient::GetCurrentPadding", client->GetCurrentPadding(&padding)); + if (padding > bufferFrames) throw std::runtime_error("render padding exceeds buffer size"); + if (warmedUp && padding == 0) ++stats.underruns; + warmedUp = true; + const UINT32 available = bufferFrames - padding; + if (available == 0) continue; + CheckHRESULT("IAudioRenderClient::GetBuffer", render->GetBuffer(available, &data)); + FillTone(data, available, format.get(), phase); + CheckHRESULT("IAudioRenderClient::ReleaseBuffer", render->ReleaseBuffer(available, 0)); + stats.frames += available; + } + CheckHRESULT("IAudioClient::Stop(render)", client->Stop()); + return stats; +} + +CaptureStats ExerciseCapture(const std::wstring& endpointId, std::chrono::seconds duration) { + ComApartment apartment; + auto device = OpenEndpoint(endpointId); + ComPtr client; + CheckHRESULT("IMMDevice::Activate(IAudioClient)", device->Activate( + __uuidof(IAudioClient), CLSCTX_INPROC_SERVER, nullptr, + reinterpret_cast(client.put()))); + WAVEFORMATEX* rawFormat = nullptr; + CheckHRESULT("IAudioClient::GetMixFormat(capture)", client->GetMixFormat(&rawFormat)); + std::unique_ptr format(rawFormat, CoTaskMemFree); + CheckHRESULT("IAudioClient::Initialize(capture)", client->Initialize( + AUDCLNT_SHAREMODE_SHARED, + AUDCLNT_STREAMFLAGS_EVENTCALLBACK | AUDCLNT_STREAMFLAGS_NOPERSIST, + 0, 0, format.get(), nullptr)); + Handle event(CreateEventW(nullptr, FALSE, FALSE, nullptr)); + if (event.get() == nullptr) throw std::runtime_error("CreateEvent(capture) failed"); + CheckHRESULT("IAudioClient::SetEventHandle(capture)", client->SetEventHandle(event.get())); + ComPtr capture; + CheckHRESULT("IAudioClient::GetService(IAudioCaptureClient)", client->GetService( + __uuidof(IAudioCaptureClient), reinterpret_cast(capture.put()))); + CheckHRESULT("IAudioClient::Start(capture)", client->Start()); + + CaptureStats stats{}; + stats.format = MediaFormat{format->nSamplesPerSec, format->nChannels}; + auto previousEvent = std::chrono::steady_clock::now(); + uint64_t previousDevicePosition = 0; + uint64_t previousQpcPosition = 0; + bool havePosition = false; + bool firstPacket = true; + const auto deadline = std::chrono::steady_clock::now() + duration; + while (std::chrono::steady_clock::now() < deadline) { + const DWORD wait = WaitForSingleObject(event.get(), 2000); + if (wait != WAIT_OBJECT_0) throw std::runtime_error("capture event timed out"); + const auto eventTime = std::chrono::steady_clock::now(); + if (stats.packets != 0) { + stats.maximumEventGapMilliseconds = std::max( + stats.maximumEventGapMilliseconds, + EventGapMilliseconds(previousEvent, eventTime)); + } + previousEvent = eventTime; + for (;;) { + UINT32 packetFrames = 0; + CheckHRESULT("IAudioCaptureClient::GetNextPacketSize", capture->GetNextPacketSize(&packetFrames)); + if (packetFrames == 0) break; + BYTE* data = nullptr; + DWORD flags = 0; + uint64_t devicePosition = 0; + uint64_t qpcPosition = 0; + CheckHRESULT("IAudioCaptureClient::GetBuffer", capture->GetBuffer( + &data, &packetFrames, &flags, &devicePosition, &qpcPosition)); + if (!firstPacket && (flags & AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY) != 0) { + ++stats.discontinuities; + } + if (!firstPacket && (flags & AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR) != 0) { + ++stats.timestampErrors; + } + if (havePosition) { + if (devicePosition < previousDevicePosition) ++stats.positionRegressions; + if (qpcPosition < previousQpcPosition) ++stats.qpcRegressions; + } + previousDevicePosition = devicePosition; + previousQpcPosition = qpcPosition; + havePosition = true; + firstPacket = false; + ++stats.packets; + stats.frames += packetFrames; + if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) == 0 && data != nullptr) { + for (UINT32 frame = 0; frame < packetFrames; ++frame) { + const BYTE* sample = data + static_cast(frame) * format->nBlockAlign; + if (std::any_of(sample, sample + format->nBlockAlign, + [](BYTE value) { return value != 0; })) { + ++stats.nonSilentFrames; + } + } + } + CheckHRESULT("IAudioCaptureClient::ReleaseBuffer", capture->ReleaseBuffer(packetFrames)); + } + } + CheckHRESULT("IAudioClient::Stop(capture)", client->Stop()); + return stats; +} + +void ValidateFrameCount(const char* lane, uint64_t frames, DWORD sampleRate, + int seconds, uint64_t allowance) { + const uint64_t expected = static_cast(sampleRate) * + static_cast(seconds); + const uint64_t minimum = expected * 95 / 100; + const uint64_t maximum = expected * 105 / 100 + allowance; + if (frames < minimum || frames > maximum) { + throw std::runtime_error(std::string(lane) + " frame cadence is outside the 5% contract: got " + + std::to_string(frames) + ", expected approximately " + std::to_string(expected)); + } +} + +int Exercise(const std::filesystem::path& snapshotPath, int seconds, + const std::wstring& controller) { + const EndpointSet baseline = ReadSnapshot(snapshotPath); + std::vector render; + std::vector capture; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + do { + const EndpointSet current = EnumerateEndpoints(); + render = Difference(current.render, baseline.render); + capture = Difference(current.capture, baseline.capture); + if (render.size() == 1 && capture.size() == 1) break; + if (render.size() > 1 || capture.size() > 1) { + throw std::runtime_error("more than one new active endpoint appeared; refusing an ambiguous media test"); + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } while (std::chrono::steady_clock::now() < deadline); + if (render.size() != 1 || capture.size() != 1) { + throw std::runtime_error("the virtual controller did not expose exactly one new render and capture endpoint"); + } + + std::exception_ptr renderError; + std::exception_ptr captureError; + RenderStats renderStats{}; + CaptureStats captureStats{}; + const auto duration = std::chrono::seconds(seconds); + std::thread renderThread([&] { + try { renderStats = ExerciseRender(render[0], duration); } + catch (...) { renderError = std::current_exception(); } + }); + std::thread captureThread([&] { + try { captureStats = ExerciseCapture(capture[0], duration); } + catch (...) { captureError = std::current_exception(); } + }); + renderThread.join(); + captureThread.join(); + if (renderError) std::rethrow_exception(renderError); + if (captureError) std::rethrow_exception(captureError); + if (renderStats.frames == 0 || captureStats.frames == 0) { + throw std::runtime_error("CoreAudio endpoint completed no frames"); + } + const ExpectedMediaFormat expected = ExpectedFormatFor(controller); + if (renderStats.format.sampleRate != expected.renderSampleRate || + renderStats.format.channels != expected.renderChannels) { + throw std::runtime_error("render mix format does not match the virtual controller descriptor"); + } + if (captureStats.format.sampleRate != expected.captureSampleRate || + captureStats.format.channels != expected.captureChannels) { + throw std::runtime_error("capture mix format does not match the virtual controller descriptor"); + } + ValidateFrameCount("render", renderStats.frames, renderStats.format.sampleRate, + seconds, renderStats.bufferFrames); + ValidateFrameCount("capture", captureStats.frames, captureStats.format.sampleRate, + seconds, 0); + if (renderStats.underruns != 0) { + throw std::runtime_error("render stream exhausted its CoreAudio buffer " + + std::to_string(renderStats.underruns) + " time(s)"); + } + if (captureStats.discontinuities != 0 || captureStats.timestampErrors != 0 || + captureStats.positionRegressions != 0 || captureStats.qpcRegressions != 0) { + throw std::runtime_error("capture stream reported a discontinuity or non-monotonic clock"); + } + if (captureStats.nonSilentFrames < captureStats.frames / 2) { + throw std::runtime_error("capture stream did not preserve the injected non-silent microphone PCM"); + } + std::cout << "renderFrames=" << renderStats.frames + << " renderEvents=" << renderStats.events + << " renderBufferFrames=" << renderStats.bufferFrames + << " renderUnderruns=" << renderStats.underruns + << " renderMaxEventGapMs=" << renderStats.maximumEventGapMilliseconds + << " captureFrames=" << captureStats.frames + << " captureNonSilentFrames=" << captureStats.nonSilentFrames + << " capturePackets=" << captureStats.packets + << " captureDiscontinuities=" << captureStats.discontinuities + << " captureTimestampErrors=" << captureStats.timestampErrors + << " capturePositionRegressions=" << captureStats.positionRegressions + << " captureQpcRegressions=" << captureStats.qpcRegressions + << " captureMaxEventGapMs=" << captureStats.maximumEventGapMilliseconds + << "\n"; + return 0; +} + +} // namespace + +int wmain(int argc, wchar_t** argv) { + try { + if (argc == 3 && _wcsicmp(argv[1], L"snapshot") == 0) { + WriteSnapshot(argv[2], EnumerateEndpoints()); + return 0; + } + if (argc == 5 && _wcsicmp(argv[1], L"exercise") == 0) { + const int seconds = _wtoi(argv[3]); + if (seconds < 1 || seconds > 300) throw std::runtime_error("duration must be 1 through 300 seconds"); + return Exercise(argv[2], seconds, argv[4]); + } + std::wcerr << L"Usage:\n" + << L" ViiperUdeMediaProbe.exe snapshot \n" + << L" ViiperUdeMediaProbe.exe exercise \n"; + return 2; + } catch (const std::exception& error) { + std::cerr << "VIIPER UDE media probe failed: " << error.what() << "\n"; + return 1; + } +} diff --git a/usb/device.go b/usb/device.go index 7863acf3..e69f240e 100644 --- a/usb/device.go +++ b/usb/device.go @@ -1,12 +1,24 @@ package usb -import "context" +import ( + "context" + "time" +) + +// Transfer directions belong to the USB device contract, not to any concrete +// transport. Keep these values aligned with the USB host convention used by +// both the legacy USB/IP adapter and the native UdeCx broker. +const ( + DirectionOut uint32 = 0 + DirectionIn uint32 = 1 +) // Device is the minimal interface a device must implement. // It only handles non-EP0 (interrupt/bulk) transfers. type Device interface { // HandleTransfer processes a non-EP0 transfer (interrupt/bulk). - // ep is the endpoint number (without direction). dir is usbip.DirIn or usbip.DirOut. + // ep is the endpoint number (without direction). dir is DirectionIn or + // DirectionOut. // For IN transfers the implementation should block until data is available or ctx is // cancelled, then return the payload. For OUT transfers, consume 'out' and return nil. HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte @@ -14,6 +26,76 @@ type Device interface { GetDeviceSpecificArgs() map[string]any } +// InterruptInputDevice is an optional allocation-free interrupt-IN contract. +// Native transports may keep one endpoint-sized buffer and ask the device to +// encode directly into it instead of allocating a new report for every input +// sample. Implementations must block until input is available or ctx is +// cancelled, must not retain dst, and must be safe when different endpoints +// are read concurrently. Native transports impose the endpoint's USB service +// interval as a deadline. Stateful controllers may encode their cached state +// when that deadline expires; event-only devices may return DeadlineExceeded +// and keep waiting. A successful call returns the number of bytes written to +// dst; zero-length successful reports are invalid. +// +// HandleTransfer remains the compatibility contract for USB/IP and for devices +// which do not implement this interface. +type InterruptInputDevice interface { + ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) +} + +// InterruptInputEndpointSelector lets a device restrict the interrupt-IN +// endpoints owned by the native producer lane. Descriptors may expose +// auxiliary interrupt pipes which intentionally remain pending until a +// protocol-specific event occurs. Starting a periodic state publisher for +// those pipes would invent traffic and can break device enumeration. +// +// ep is the endpoint number without the direction bit, matching +// ReadInterruptInput. Devices which do not implement this interface retain the +// compatibility behavior of publishing every interrupt-IN endpoint. +type InterruptInputEndpointSelector interface { + SupportsInterruptInputEndpoint(ep uint32) bool +} + +// ScheduledInterruptInputDevice is the allocation-free deadline extension of +// InterruptInputDevice. Native transports keep one reusable timer per active +// endpoint and pass its channel here instead of creating a new timer-backed +// context for every USB service interval. Implementations must preserve the +// same behavior as ReadInterruptInput: ctx closes for lifecycle cancellation, +// while deadline firing represents context.DeadlineExceeded for this one read. +// Stateful devices may encode their current cached state at that boundary; +// event-only devices return context.DeadlineExceeded. +// +// The implementation must consume at most one value from deadline and must not +// retain either deadline or dst after returning. +type ScheduledInterruptInputDevice interface { + InterruptInputDevice + ReadScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, + ) (int, error) +} + +// ClassifiedScheduledInterruptInputDevice identifies whether a scheduled +// report came from a newly queued controller state or from the deadline replay +// of the current state. Native transports use that distinction to preserve +// discrete edges while coalescing only idle cadence snapshots when Windows has +// not yet posted its next interrupt poll. +type ClassifiedScheduledInterruptInputDevice interface { + ScheduledInterruptInputDevice + ReadClassifiedScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, + ) (written int, transition bool, err error) +} + +// IsochronousInputDevice is the corresponding optional caller-buffer contract +// for isochronous IN packets. The transport supplies exactly the packet region +// owned by the current URB. The native scheduler invokes this at the packet's +// service time, so implementations must not wait for source data: they return +// a legal zero packet when capture has not arrived. Implementations may return +// a shorter legal packet, must not retain dst, and must honor cancellation. +type IsochronousInputDevice interface { + ReadIsochronousInput(ctx context.Context, ep uint32, dst []byte) (int, error) +} + // ControlDevice is an optional interface for devices that need to handle // control transfers on endpoint 0 (EP0). // @@ -44,3 +126,11 @@ type InterfaceAltSettingDevice interface { type EndpointResetDevice interface { ResetEndpoint(endpointAddress uint8) } + +// InterruptInputLifecycleDevice discards retained pre-boundary controller +// transitions without changing the device's current state. Native transport +// reset, purge, configuration, and power boundaries invoke it after joining +// the old publisher so stale edges cannot replay into the next generation. +type InterruptInputLifecycleDevice interface { + InvalidateInterruptInput(endpointAddress uint8) +} diff --git a/usb/device_test.go b/usb/device_test.go new file mode 100644 index 00000000..d2f60142 --- /dev/null +++ b/usb/device_test.go @@ -0,0 +1,9 @@ +package usb + +import "testing" + +func TestTransferDirectionWireContract(t *testing.T) { + if DirectionOut != 0 || DirectionIn != 1 { + t.Fatalf("USB transfer directions changed: OUT=%d IN=%d", DirectionOut, DirectionIn) + } +} diff --git a/usb/usbdesc.go b/usb/usbdesc.go index 84e34ff2..0c67cad0 100644 --- a/usb/usbdesc.go +++ b/usb/usbdesc.go @@ -253,6 +253,69 @@ func (d Descriptor) Bytes() []byte { return b.Bytes() } +// ConfigurationBytes builds the complete active USB configuration descriptor, +// including IADs, alternate interfaces, HID/class descriptors, and endpoints. +// USB/IP emits these logical bytes directly. The native UdeCx host also uses +// this encoder after applying only the endpoint scheduling projection required +// by USBHUB3 for full-speed devices; interface topology and class data remain +// identical. +func (d Descriptor) ConfigurationBytes() ([]byte, error) { + var b bytes.Buffer + configValue := d.Configuration.BConfigurationValue + if configValue == 0 { + configValue = 1 + } + attrs := d.Configuration.BMAttributes + if attrs == 0 { + attrs = 0x80 // Bus powered. + } + maxPower := d.Configuration.BMaxPower + if maxPower == 0 { + maxPower = 50 // 100 mA, expressed in 2 mA units. + } + h := ConfigHeader{ + BNumInterfaces: d.NumInterfaces(), + BConfigurationValue: configValue, + IConfiguration: d.Configuration.IConfiguration, + BMAttributes: attrs, + BMaxPower: maxPower, + } + h.Write(&b) + for _, iface := range d.Interfaces { + for _, iad := range d.Associations { + if iad.BFirstInterface == iface.Descriptor.BInterfaceNumber && + iface.Descriptor.BAlternateSetting == 0 { + iad.Write(&b) + } + } + iface.Descriptor.Write(&b) + if iface.HID != nil { + hidDescriptor, err := iface.HID.DescriptorBytes() + if err != nil { + return nil, fmt.Errorf("build HID descriptor for interface %d: %w", + iface.Descriptor.BInterfaceNumber, err) + } + b.Write([]byte(hidDescriptor)) + } + for _, classDescriptor := range iface.ClassDescriptors { + b.Write([]byte(classDescriptor.Bytes())) + } + for _, endpoint := range iface.Endpoints { + endpoint.Write(&b) + for _, classDescriptor := range endpoint.ClassDescriptors { + b.Write([]byte(classDescriptor.Bytes())) + } + } + } + + data := b.Bytes() + if len(data) > 0xffff { + return nil, fmt.Errorf("USB configuration descriptor exceeds 65535 bytes: %d", len(data)) + } + binary.LittleEndian.PutUint16(data[2:4], uint16(len(data))) + return append([]byte(nil), data...), nil +} + // ConfigHeader represents the USB configuration descriptor header (9 bytes). type ConfigHeader struct { WTotalLength uint16 // LE, to be patched after building diff --git a/viiperclient/client.go b/viiperclient/client.go index dbb1327c..266ec741 100644 --- a/viiperclient/client.go +++ b/viiperclient/client.go @@ -131,9 +131,11 @@ func (c *Client) DeviceAddCtx(ctx context.Context, busID uint32, devType string, return parse[viipertypes.Device](raw) } -// DeviceRemove removes a device from the specified bus by its device ID. +// DeviceRemove removes a USB/IP device from the specified bus by its device ID. // The devID parameter is the device number (e.g., "1") on the given bus. // Active USB-IP connections to the device will be closed. +// Native UDE callers must use DeviceRemoveRegistered so the exact correlation +// receipt is compared atomically and an ID-reusing successor is preserved. // Returns the removed device's bus and device ID or an error if not found. func (c *Client) DeviceRemove(busID uint32, devID string) (*viipertypes.DeviceRemoveResponse, error) { return c.DeviceRemoveCtx(context.Background(), busID, devID) @@ -149,6 +151,56 @@ func (c *Client) DeviceRemoveCtx(ctx context.Context, busID uint32, devID string return parse[viipertypes.DeviceRemoveResponse](raw) } +// DeviceRemoveNative conditionally removes the exact native registration +// identified by the immutable add/list receipt. +func (c *Client) DeviceRemoveNative( + busID uint32, devID string, native *viipertypes.NativeUDEDeviceInfo, +) (*viipertypes.DeviceRemoveResponse, error) { + return c.DeviceRemoveNativeCtx(context.Background(), busID, devID, native) +} + +func (c *Client) DeviceRemoveNativeCtx( + ctx context.Context, busID uint32, devID string, native *viipertypes.NativeUDEDeviceInfo, +) (*viipertypes.DeviceRemoveResponse, error) { + if native == nil { + return nil, errors.New("native UDE removal requires the exact correlation receipt") + } + request := viipertypes.NativeUDEDeviceRemoveRequest{ + DevID: devID, Transport: "native-ude", NativeUDE: native, + } + pathParams := map[string]string{"id": fmt.Sprintf("%d", busID)} + const path = "bus/{id}/remove-native" + raw, err := c.transport.DoCtx(ctx, path, request, pathParams) + if err != nil { + return nil, err + } + return parse[viipertypes.DeviceRemoveResponse](raw) +} + +// DeviceRemoveRegistered selects the only safe removal contract for the +// transport recorded in a DeviceAdd/DevicesList result. +func (c *Client) DeviceRemoveRegistered( + device *viipertypes.Device, +) (*viipertypes.DeviceRemoveResponse, error) { + return c.DeviceRemoveRegisteredCtx(context.Background(), device) +} + +func (c *Client) DeviceRemoveRegisteredCtx( + ctx context.Context, device *viipertypes.Device, +) (*viipertypes.DeviceRemoveResponse, error) { + if device == nil { + return nil, errors.New("device removal requires a device registration") + } + switch device.Transport { + case "native-ude": + return c.DeviceRemoveNativeCtx(ctx, device.BusID, device.DevID, device.NativeUDE) + case "", "usbip": + return c.DeviceRemoveCtx(ctx, device.BusID, device.DevID) + default: + return nil, fmt.Errorf("unsupported device transport %q", device.Transport) + } +} + // DevicesList retrieves a list of all devices attached to the specified bus. // Each device entry includes bus ID, device ID, VID, PID, and device type. func (c *Client) DevicesList(busID uint32) (*viipertypes.DevicesListResponse, error) { diff --git a/viiperclient/client_test.go b/viiperclient/client_test.go index 48c14414..e8100660 100644 --- a/viiperclient/client_test.go +++ b/viiperclient/client_test.go @@ -115,3 +115,72 @@ func TestContextCancellation(t *testing.T) { _, err := c.BusListCtx(ctx) assert.Error(t, err) } + +func TestDeviceRemoveRegisteredUsesTransportScopedAuthority(t *testing.T) { + tests := []struct { + name string + device *viipertypes.Device + wantPath string + wantErr string + }{ + { + name: "native exact receipt", + device: &viipertypes.Device{ + BusID: 1, DevID: "1", Transport: "native-ude", + NativeUDE: &viipertypes.NativeUDEDeviceInfo{ + DeviceID: "4294967297", DeviceGeneration: 2, + ControllerSessionID: "17", ControllerInstanceID: `ROOT\VIIPERUDE\0000`, + USB20PortNumber: 1, + }, + }, + wantPath: "bus/{id}/remove-native", + }, + { + name: "usbip legacy id", + device: &viipertypes.Device{ + BusID: 1, DevID: "1", Transport: "usbip", + }, + wantPath: "bus/{id}/remove", + }, + { + name: "native missing receipt", + device: &viipertypes.Device{ + BusID: 1, DevID: "1", Transport: "native-ude", + }, + wantErr: "exact correlation receipt", + }, + { + name: "unknown transport", + device: &viipertypes.Device{ + BusID: 1, DevID: "1", Transport: "future", + }, + wantErr: "unsupported device transport", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var gotPath string + var gotPayload any + client := viiperclient.WithTransport(viiperclient.NewMockTransport( + func(path string, payload any, _ map[string]string) (string, error) { + gotPath, gotPayload = path, payload + return `{"busId":1,"devId":"1"}`, nil + }, + )) + _, err := client.DeviceRemoveRegistered(test.device) + if test.wantErr != "" { + assert.ErrorContains(t, err, test.wantErr) + assert.Empty(t, gotPath) + return + } + assert.NoError(t, err) + assert.Equal(t, test.wantPath, gotPath) + if test.device.Transport == "native-ude" { + request, ok := gotPayload.(viipertypes.NativeUDEDeviceRemoveRequest) + assert.True(t, ok) + assert.Equal(t, test.device.DevID, request.DevID) + assert.Equal(t, test.device.NativeUDE, request.NativeUDE) + } + }) + } +} diff --git a/viiperclient/stream.go b/viiperclient/stream.go index 2e6b6ea0..eff150e6 100644 --- a/viiperclient/stream.go +++ b/viiperclient/stream.go @@ -40,6 +40,12 @@ func (c *Client) OpenStream(ctx context.Context, busID uint32, devID string) (*D if err != nil { return nil, fmt.Errorf("dial: %w", err) } + keepConn := false + defer func() { + if !keepConn { + _ = conn.Close() + } + }() if tcpConn, ok := conn.(*net.TCPConn); ok { if err := tcpConn.SetNoDelay(true); err != nil { @@ -58,16 +64,15 @@ func (c *Client) OpenStream(ctx context.Context, busID uint32, devID string) (*D return nil, err } sessionKey := auth.DeriveSessionKey(key, serverNonce, clientNonce) - conn, err = auth.WrapConn(conn, sessionKey) + secureConn, err := auth.WrapClientConn(conn, sessionKey) if err != nil { - conn.Close() // nolint return nil, err } + conn = secureConn } streamPath := fmt.Sprintf("bus/%d/%s\x00", busID, devID) if _, err := conn.Write([]byte(streamPath)); err != nil { - conn.Close() // nolint return nil, fmt.Errorf("write stream path: %w", err) } @@ -76,6 +81,7 @@ func (c *Client) OpenStream(ctx context.Context, busID uint32, devID string) (*D BusID: busID, DevID: devID, } + keepConn = true return ds, nil } diff --git a/viiperclient/stream_test.go b/viiperclient/stream_test.go index 6af6ef41..8a61a342 100644 --- a/viiperclient/stream_test.go +++ b/viiperclient/stream_test.go @@ -8,6 +8,7 @@ import ( "log/slog" "net" "strings" + "sync/atomic" "testing" "time" @@ -28,6 +29,8 @@ import ( "github.com/stretchr/testify/require" ) +var streamOperationBusID atomic.Uint32 + func TestOpenStream_NotSupportedWithMockTransport(t *testing.T) { c := testClient(map[string]string{}, nil) _, err := c.OpenStream(context.Background(), 1, "1") @@ -105,13 +108,11 @@ func TestDeviceStream_Operations(t *testing.T) { tests := []struct { name string - busID uint32 customRegistration bool op operation }{ { - name: "read deadline timeout", - busID: 201, + name: "read deadline timeout", op: func(t *testing.T, stream *viiperclient.DeviceStream) { // Force immediate timeout by setting deadline in the past. require.NoError(t, stream.SetReadDeadline(time.Now().Add(-10*time.Millisecond))) @@ -128,7 +129,6 @@ func TestDeviceStream_Operations(t *testing.T) { }, { name: "closed stream read/write errors", - busID: 202, customRegistration: true, op: func(t *testing.T, stream *viiperclient.DeviceStream) { require.NoError(t, stream.Close()) @@ -145,6 +145,10 @@ func TestDeviceStream_Operations(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // The server deliberately keeps a disconnected device alive for its + // reconnection grace period. A fresh ID makes -count repetitions + // independent without weakening that production lifecycle behavior. + busID := 200_000 + streamOperationBusID.Add(1) usbSrv := usb.New(usb.ServerConfig{Addr: "127.0.0.1:0"}, slog.Default(), log.NewRaw(nil)) ln, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) @@ -168,12 +172,12 @@ func TestDeviceStream_Operations(t *testing.T) { require.NoError(t, apiSrv.Start()) defer apiSrv.Close() //nolint:errcheck - b, err := virtualbus.NewWithBusID(tt.busID) + b, err := virtualbus.NewWithBusID(busID) require.NoError(t, err) require.NoError(t, usbSrv.AddBus(b)) c := viiperclient.New(addr) - stream, devResp, err := c.AddDeviceAndConnect(context.Background(), tt.busID, "xbox360", nil) + stream, devResp, err := c.AddDeviceAndConnect(context.Background(), busID, "xbox360", nil) require.NoError(t, err) require.NotNil(t, devResp) require.NotNil(t, stream) @@ -215,10 +219,10 @@ func TestEncryptedStream(t *testing.T) { } sessionKey := auth.DeriveSessionKey(key, serverNonce, clientNonce) - secureConn, err := auth.WrapConn(conn, sessionKey) + conn, err = auth.WrapServerConn(conn, sessionKey) assert.NoError(t, err) - rr := bufio.NewReader(secureConn) + rr := bufio.NewReader(conn) line, err := rr.ReadString('\x00') if err != nil { return diff --git a/viiperclient/transport.go b/viiperclient/transport.go index 646bb5d4..a260ce9d 100644 --- a/viiperclient/transport.go +++ b/viiperclient/transport.go @@ -102,7 +102,7 @@ func (t *Transport) DoCtx(ctx context.Context, path string, payload any, pathPar if err != nil { return "", fmt.Errorf("dial: %w", err) } - defer conn.Close() //nolint:errcheck + defer func() { _ = conn.Close() }() if tcpConn, ok := conn.(*net.TCPConn); ok { if err := tcpConn.SetNoDelay(true); err != nil { @@ -129,11 +129,11 @@ func (t *Transport) DoCtx(ctx context.Context, path string, payload any, pathPar return "", err } sessionKey := auth.DeriveSessionKey(key, serverNonce, clientNonce) - conn, err = auth.WrapConn(conn, sessionKey) + secureConn, err := auth.WrapClientConn(conn, sessionKey) if err != nil { - conn.Close() // nolint return "", err } + conn = secureConn } if _, err := conn.Write(append(lineBytes, '\x00')); err != nil { diff --git a/viiperclient/transport_test.go b/viiperclient/transport_test.go index 346a0da4..36cfc384 100644 --- a/viiperclient/transport_test.go +++ b/viiperclient/transport_test.go @@ -197,16 +197,16 @@ func TestEncryptedTransport(t *testing.T) { } sessionKey := auth.DeriveSessionKey(key, serverNonce, clientNonce) - secureConn, err := auth.WrapConn(conn, sessionKey) + conn, err = auth.WrapServerConn(conn, sessionKey) assert.NoError(t, err) - rr := bufio.NewReader(secureConn) + rr := bufio.NewReader(conn) line, err := rr.ReadString('\x00') if err != nil { return } - _, err = secureConn.Write([]byte(line)) + _, err = conn.Write([]byte(line)) assert.NoError(t, err) } diff --git a/viipertypes/native_remove_test.go b/viipertypes/native_remove_test.go new file mode 100644 index 00000000..f1b77281 --- /dev/null +++ b/viipertypes/native_remove_test.go @@ -0,0 +1,36 @@ +package viipertypes + +import ( + "encoding/json" + "testing" +) + +func TestNativeUDEDeviceRemoveRequestRejectsAmbiguousJSON(t *testing.T) { + valid := `{"devId":"1","transport":"native-ude","nativeUde":{"deviceId":"4294967297","deviceGeneration":1,"controllerSessionId":"17","controllerInstanceId":"ROOT\\VIIPERUDE\\0000","usb20PortNumber":1,"usb30PortNumber":0}}` + tests := []struct { + name string + payload string + valid bool + }{ + {"canonical", valid, true}, + {"unknown top-level", valid[:len(valid)-1] + `,"extra":1}`, false}, + {"unknown nested", `{"devId":"1","transport":"native-ude","nativeUde":{"deviceId":"4294967297","deviceGeneration":1,"controllerSessionId":"17","controllerInstanceId":"ROOT\\VIIPERUDE\\0000","usb20PortNumber":1,"usb30PortNumber":0,"extra":1}}`, false}, + {"noncanonical top-level case", `{"DevId":"1","transport":"native-ude","nativeUde":{"deviceId":"4294967297","deviceGeneration":1,"controllerSessionId":"17","controllerInstanceId":"ROOT\\VIIPERUDE\\0000","usb20PortNumber":1,"usb30PortNumber":0}}`, false}, + {"noncanonical nested case", `{"devId":"1","transport":"native-ude","nativeUde":{"DeviceId":"4294967297","deviceGeneration":1,"controllerSessionId":"17","controllerInstanceId":"ROOT\\VIIPERUDE\\0000","usb20PortNumber":1,"usb30PortNumber":0}}`, false}, + {"duplicate top-level", `{"devId":"1","devId":"2","transport":"native-ude","nativeUde":null}`, false}, + {"duplicate nested", `{"devId":"1","transport":"native-ude","nativeUde":{"deviceId":"4294967297","deviceId":"4294967298","deviceGeneration":1,"controllerSessionId":"17","controllerInstanceId":"ROOT\\VIIPERUDE\\0000","usb20PortNumber":1,"usb30PortNumber":0}}`, false}, + {"trailing value", valid + `{}`, false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var request NativeUDEDeviceRemoveRequest + err := json.Unmarshal([]byte(test.payload), &request) + if test.valid && err != nil { + t.Fatalf("canonical request rejected: %v", err) + } + if !test.valid && err == nil { + t.Fatal("ambiguous request accepted") + } + }) + } +} diff --git a/viipertypes/structs.go b/viipertypes/structs.go index c3b5dc71..17a4eebd 100644 --- a/viipertypes/structs.go +++ b/viipertypes/structs.go @@ -1,8 +1,10 @@ package viipertypes import ( + "bytes" "encoding/json" "fmt" + "io" "math" "strconv" "strings" @@ -33,8 +35,33 @@ func (e APIError) Error() string { // -- type PingResponse struct { - Server string `json:"server"` - Version string `json:"version"` + Server string `json:"server"` + Version string `json:"version"` + Transport string `json:"transport,omitempty"` + Ready *bool `json:"ready,omitempty"` + NativeUDE *NativeUDEInfo `json:"nativeUde,omitempty"` +} + +// NativeUDEInfo is the negotiated kernel contract for the active native +// transport. It is additive to the historical ping response so older clients +// continue to work while safety-conscious clients can fail closed unless the +// exact ABI and capabilities they require are live. +type NativeUDEInfo struct { + ABIMajor uint16 `json:"abiMajor"` + ABIMinor uint16 `json:"abiMinor"` + Capabilities uint32 `json:"capabilities"` + ExpectedDriverPackageVersion string `json:"expectedDriverPackageVersion"` + // LoadedDriverBuildIdentity is the lowercase SHA-256 identity returned by + // the currently loaded kernel image during ABI negotiation. It is not an + // on-disk hash or a broker-computed status echo. + LoadedDriverBuildIdentity string `json:"loadedDriverBuildIdentity"` + ControllerSessionID string `json:"controllerSessionId"` + ControllerInstanceID string `json:"controllerInstanceId"` + MaxDevices uint32 `json:"maxDevices"` + MaxDescriptorBytes uint32 `json:"maxDescriptorBytes"` + MaxTransferBytes uint32 `json:"maxTransferBytes"` + MaxIsoPackets uint32 `json:"maxIsoPackets"` + MaxPendingOperations uint32 `json:"maxPendingOperations"` } type BusListResponse struct { @@ -50,14 +77,28 @@ type BusRemoveResponse struct { } type Device struct { - BusID uint32 `json:"busId"` - DevID string `json:"devId"` - Vid string `json:"vid"` - Pid string `json:"pid"` - Type string `json:"type"` - DeviceSpecific map[string]any `json:"deviceSpecific"` - USBIPPort int32 `json:"usbipPort,omitempty"` - USBIPOwnerSerial string `json:"usbipOwnerSerial,omitempty"` + BusID uint32 `json:"busId"` + DevID string `json:"devId"` + Vid string `json:"vid"` + Pid string `json:"pid"` + Type string `json:"type"` + DeviceSpecific map[string]any `json:"deviceSpecific"` + Transport string `json:"transport"` + NativeUDE *NativeUDEDeviceInfo `json:"nativeUde,omitempty"` + USBIPPort int32 `json:"usbipPort,omitempty"` + USBIPOwnerSerial string `json:"usbipOwnerSerial,omitempty"` +} + +// NativeUDEDeviceInfo is the exact kernel/controller receipt used to +// correlate one API device with its Windows HID and UAC descendants. DeviceID +// is decimal text so every JSON consumer preserves the full uint64 value. +type NativeUDEDeviceInfo struct { + DeviceID string `json:"deviceId"` + DeviceGeneration uint32 `json:"deviceGeneration"` + ControllerSessionID string `json:"controllerSessionId"` + ControllerInstanceID string `json:"controllerInstanceId"` + USB20PortNumber uint32 `json:"usb20PortNumber"` + USB30PortNumber uint32 `json:"usb30PortNumber"` } type DevicesListResponse struct { @@ -69,6 +110,146 @@ type DeviceRemoveResponse struct { DevID string `json:"devId"` } +// NativeUDEDeviceRemoveRequest is a compare-and-remove request. Native clients +// must echo the exact correlation receipt returned by add/list so a delayed +// cleanup cannot remove a successor that reused the same bus and device IDs. +type NativeUDEDeviceRemoveRequest struct { + DevID string `json:"devId"` + Transport string `json:"transport"` + NativeUDE *NativeUDEDeviceInfo `json:"nativeUde"` +} + +// UnmarshalJSON rejects unknown, duplicate, and trailing fields. The echoed +// correlation receipt is mutation authority, so ambiguous JSON is not +// accepted even when encoding/json could otherwise choose a last value. +func (r *NativeUDEDeviceRemoveRequest) UnmarshalJSON(data []byte) error { + if err := rejectDuplicateJSONKeys(data); err != nil { + return err + } + if err := validateNativeRemoveJSONFieldNames(data); err != nil { + return err + } + type requestAlias NativeUDEDeviceRemoveRequest + var decoded requestAlias + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&decoded); err != nil { + return err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return fmt.Errorf("native remove request contains trailing JSON") + } + return fmt.Errorf("native remove request contains trailing JSON: %w", err) + } + *r = NativeUDEDeviceRemoveRequest(decoded) + return nil +} + +func validateNativeRemoveJSONFieldNames(data []byte) error { + var top map[string]json.RawMessage + if err := json.Unmarshal(data, &top); err != nil { + return err + } + topFields := []string{"devId", "transport", "nativeUde"} + if len(top) != len(topFields) { + return fmt.Errorf("native remove request must contain exactly devId, transport, and nativeUde") + } + for _, field := range topFields { + if _, ok := top[field]; !ok { + return fmt.Errorf("native remove request is missing canonical JSON field %q", field) + } + } + + var native map[string]json.RawMessage + if err := json.Unmarshal(top["nativeUde"], &native); err != nil { + return fmt.Errorf("nativeUde must be an object: %w", err) + } + nativeFields := []string{ + "deviceId", "deviceGeneration", "controllerSessionId", + "controllerInstanceId", "usb20PortNumber", "usb30PortNumber", + } + if len(native) != len(nativeFields) { + return fmt.Errorf("nativeUde must contain the exact correlation receipt fields") + } + for _, field := range nativeFields { + if _, ok := native[field]; !ok { + return fmt.Errorf("nativeUde is missing canonical JSON field %q", field) + } + } + return nil +} + +func rejectDuplicateJSONKeys(data []byte) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := walkUniqueJSONValue(decoder); err != nil { + return err + } + if _, err := decoder.Token(); err != io.EOF { + if err == nil { + return fmt.Errorf("native remove request contains trailing JSON") + } + return fmt.Errorf("native remove request contains trailing JSON: %w", err) + } + return nil +} + +func walkUniqueJSONValue(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, ok := token.(json.Delim) + if !ok { + return nil + } + switch delimiter { + case '{': + seen := make(map[string]struct{}) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return fmt.Errorf("native remove request contains a non-string JSON object key") + } + if _, duplicate := seen[key]; duplicate { + return fmt.Errorf("native remove request contains duplicate JSON field %q", key) + } + seen[key] = struct{}{} + if err := walkUniqueJSONValue(decoder); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil { + return err + } + if closing != json.Delim('}') { + return fmt.Errorf("native remove request has malformed JSON object") + } + case '[': + for decoder.More() { + if err := walkUniqueJSONValue(decoder); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil { + return err + } + if closing != json.Delim(']') { + return fmt.Errorf("native remove request has malformed JSON array") + } + default: + return fmt.Errorf("native remove request has unexpected JSON delimiter %q", delimiter) + } + return nil +} + type DeviceCreateRequest struct { Type *string `json:"type"` IDVendor *uint16 `json:"idVendor,omitempty"`