diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index b1d38b0..7765576 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -14,6 +14,9 @@ jobs: scenario: - { name: single, script: ./test-local-update.ps1 } - { name: dual, script: ./test-local-update-dual.ps1 } + # Regression guard: server ships a byte-different external updater (same version), which + # is installed eagerly and so must be trusted from the signed manifest at launch time. + - { name: updater-mismatch, script: ./test-local-update-updater-mismatch.ps1 } steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 46a2b84..a55bdf0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,16 @@ env: SFTP_BASE_PATH: downloads/RawDevLauncher/v2 BRANCH_NAME: ${{ github.event.inputs.branch || 'stable' }} + # RESCUE (transitional). Publish THIS external-updater binary instead of the freshly built one. + # A client on an old version rejects any updater whose bytes differ from the copy it embeds, so it + # cannot self-update once the updater is rebuilt. Serving the exact binary it embeds lets its old + # integrity check pass, so it can pull the fixed app exe. The binary MUST be byte-identical to that + # client's embedded updater (verified for 3.0.2). This does not change the app, only which updater + # is published. CLEAR IT the release after the fleet has moved off the broken version (otherwise a + # patched client re-downloads it every launch). Unlike COMPAT_UPDATER below, this needs no next-gen + # channel — it simply substitutes the primary updater, so no NEXT_ORIGIN guard applies. + RESCUE_UPDATER: tools/AnakinRaW.ExternalUpdater.exe + # Migration-release values. Leave empty for a normal release; populate to enable. # # Origin URL of the next-generation channel, written into the manifest's componentOriginInfo. @@ -97,9 +107,14 @@ jobs: - name: Publish self-update release shell: pwsh run: | + # RESCUE_UPDATER, when set, replaces the freshly built updater with a pinned binary so old + # clients can still self-update (see env comment). Falls back to the build output. + $updaterExe = if ($env:RESCUE_UPDATER) { $env:RESCUE_UPDATER } else { "./releases/net481/$env:UPDATER_EXE" } + if (-not (Test-Path $updaterExe)) { throw "Updater exe not found at '$updaterExe'." } + Write-Host "Publishing updater: $updaterExe" & $env:PUBLISH_SCRIPT ` -AppExePath "./releases/net481/$env:TOOL_EXE" ` - -UpdaterExePath "./releases/net481/$env:UPDATER_EXE" ` + -UpdaterExePath "$updaterExe" ` -EmbeddedTrustCertPath "$env:EMBEDDED_TRUST_CERT" ` -Origin "$env:ORIGIN_BASE" ` -SftpBasePath "$env:SFTP_BASE_PATH" ` @@ -125,4 +140,6 @@ jobs: name: v${{ steps.nbgv.outputs.SemVer2 }} tag_name: v${{ steps.nbgv.outputs.SemVer2 }} token: ${{ secrets.GITHUB_TOKEN }} - generate_release_notes: false \ No newline at end of file + generate_release_notes: false + files: | + ./releases/net481/$env:TOOL_EXE \ No newline at end of file diff --git a/ModdingToolBase b/ModdingToolBase index 6f01037..d575a11 160000 --- a/ModdingToolBase +++ b/ModdingToolBase @@ -1 +1 @@ -Subproject commit 6f010371a67c8e427a9a1dbeaad659e0fcc5f422 +Subproject commit d575a112196400d2710ed7d8b1d0759316c8b712 diff --git a/deploy-local.ps1 b/deploy-local.ps1 index ca2d090..7613e36 100644 --- a/deploy-local.ps1 +++ b/deploy-local.ps1 @@ -2,7 +2,11 @@ param( [string]$InstalledVersion = "0.0.1-local", [string]$ServerVersion = "99.99.99-local", [switch]$DualPublish, - [string]$CompatibilityUpdater + [string]$CompatibilityUpdater, + + # Make the server's updater byte-different from the installed app's embedded copy (same version, + # still runnable) so the cycle exercises the launch-time integrity check. See block below. + [switch]$PerturbServerUpdater ) $ErrorActionPreference = "Stop" @@ -32,6 +36,20 @@ try { Set-NbgvVersion -Snapshot $nbgv -Version $ServerVersion dotnet build $toolProj --configuration Release -f net481 --output $serverBuildDir /p:DebugType=None /p:DebugSymbols=false /p:LocalDeploy=true + if ($PerturbServerUpdater) { + # Make the server's updater differ byte-for-byte from the installed app's embedded copy. + # Deterministic builds otherwise produce identical updaters, so the cycle never exercises an + # updater whose bytes changed. Appended trailing bytes change the SHA-256 but are ignored by + # the PE loader (the exe still runs) and leave the version untouched — i.e. a same-version + # rebuild, which must be trusted from the signed manifest at launch. + $serverUpdater = Join-Path $serverBuildDir "AnakinRaW.ExternalUpdater.exe" + if (-not (Test-Path $serverUpdater)) { throw "Server external updater not found at '$serverUpdater'." } + Write-Host "--- Perturbing server external updater (trailing bytes) to force a hash mismatch ---" -ForegroundColor Cyan + $marker = [Text.Encoding]::ASCII.GetBytes("/*INTEGRITY-REGRESSION*/") + $fs = [IO.File]::Open($serverUpdater, [IO.FileMode]::Append, [IO.FileAccess]::Write) + try { $fs.Write($marker, 0, $marker.Length) } finally { $fs.Dispose() } + } + $publishParams = @{ AppExePath = Join-Path $serverBuildDir "RaW-DevLauncher.exe" UpdaterExePath = Join-Path $serverBuildDir "AnakinRaW.ExternalUpdater.exe" diff --git a/test-local-update-updater-mismatch.ps1 b/test-local-update-updater-mismatch.ps1 new file mode 100644 index 0000000..3937fb9 --- /dev/null +++ b/test-local-update-updater-mismatch.ps1 @@ -0,0 +1,40 @@ +# ========================================================================================= +# Regression scenario for the external-updater launch-time integrity check. +# +# Deploys via deploy-local.ps1 -PerturbServerUpdater (server ships an updater byte-different +# from the installed app's embedded copy, same version) and runs the shared end-to-end cycle. +# The updater installs eagerly, so it must be trusted from the signed manifest at launch. +# Pre-fix this threw "match none of the trusted hashes" at step [1/5]. Windows-only. +# ========================================================================================= + +#Requires -Version 7.0 + +[CmdletBinding()] +param( + [string]$InstalledVersion = '0.0.1-local', + [string]$ServerVersion = '99.99.99-local', + [string]$Branch = 'beta' +) + +$ErrorActionPreference = 'Stop' + +$root = $PSScriptRoot +if ([string]::IsNullOrEmpty($root)) { $root = Get-Location } + +& (Join-Path $root 'deploy-local.ps1') ` + -InstalledVersion $InstalledVersion ` + -ServerVersion $ServerVersion ` + -PerturbServerUpdater +if ($LASTEXITCODE -ne 0) { throw "deploy-local.ps1 -PerturbServerUpdater failed (exit $LASTEXITCODE)." } + +$serverDir = Join-Path $root '.local_deploy\server' +$serverUri = "file:///$(((Resolve-Path $serverDir).Path -replace '\\','/'))" + +& (Join-Path $root 'ModdingToolBase\scripts\Test-LocalUpdateCycle.ps1') ` + -AppExePath (Join-Path $root '.local_deploy\install\RaW-DevLauncher.exe') ` + -ServerUri $serverUri ` + -Branch $Branch ` + -NoUpdateMessage 'No update available.' ` + -ExpectedNewVersion $ServerVersion + +exit $LASTEXITCODE diff --git a/tools/AnakinRaW.ExternalUpdater.exe b/tools/AnakinRaW.ExternalUpdater.exe new file mode 100644 index 0000000..2bb788e Binary files /dev/null and b/tools/AnakinRaW.ExternalUpdater.exe differ