diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 1fbb5987e..76d3ef87f 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -225,10 +225,6 @@ jobs: -CertificateName '${{ secrets.CODE_SIGNING_CERTIFICATE_NAME }}' ` -TimestampServer '${{ vars.CODE_SIGNING_TIMESTAMP_SERVER }}' - - name: Generate integrity tree - shell: pwsh - run: .\scripts\generate-integrity-tree.ps1 -Path $PWD/unigetui_bin -MinOutput - - name: Build installer shell: pwsh run: | @@ -236,6 +232,8 @@ jobs: $OutputDir = Join-Path $PWD "output" New-Item $OutputDir -ItemType Directory -ErrorAction SilentlyContinue | Out-Null + .\scripts\refresh-integrity-tree.ps1 -Path $PWD/unigetui_bin -FailOnUnexpectedFiles + # Configure Inno Setup to use AzureSignTool $IssPath = "UniGetUI.iss" @@ -262,6 +260,8 @@ jobs: $Platform = '${{ matrix.platform }}' New-Item "output" -ItemType Directory -ErrorAction SilentlyContinue | Out-Null + .\scripts\refresh-integrity-tree.ps1 -Path $PWD/unigetui_bin -FailOnUnexpectedFiles + # Zip Compress-Archive -Path "unigetui_bin/*" -DestinationPath "output/UniGetUI.$Platform.zip" -CompressionLevel Optimal diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 1102d9915..d5cfab4f6 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -94,15 +94,14 @@ Get-ChildItem $PublishDir | Move-Item -Destination $BinDir -Force # WingetUI.exe alias for backward compat Copy-Item (Join-Path $BinDir "UniGetUI.exe") (Join-Path $BinDir "WingetUI.exe") -Force -# --- Integrity tree --- -Write-Host "`n=== Generating integrity tree ===" -ForegroundColor Cyan -& (Join-Path $PSScriptRoot "generate-integrity-tree.ps1") -Path $BinDir -MinOutput - # --- Package output --- if (Test-Path $OutputPath) { Remove-Item $OutputPath -Recurse -Force } New-Item $OutputPath -ItemType Directory | Out-Null $ZipPath = Join-Path $OutputPath "UniGetUI.$Platform.zip" +Write-Host "`n=== Refreshing integrity tree before zip packaging ===" -ForegroundColor Cyan +& (Join-Path $PSScriptRoot "refresh-integrity-tree.ps1") -Path $BinDir -FailOnUnexpectedFiles + Write-Host "`n=== Creating zip: $ZipPath ===" -ForegroundColor Cyan Compress-Archive -Path (Join-Path $BinDir "*") -DestinationPath $ZipPath -CompressionLevel Optimal @@ -124,6 +123,9 @@ if (-not $SkipInstaller) { $IssPath = Join-Path $RepoRoot "UniGetUI.iss" $IssContent = Get-Content $IssPath -Raw + Write-Host "`n=== Refreshing integrity tree before installer packaging ===" -ForegroundColor Cyan + & (Join-Path $PSScriptRoot "refresh-integrity-tree.ps1") -Path $BinDir -FailOnUnexpectedFiles + try { $IssContentNoSign = $IssContent -Replace '(?m)^SignTool=.*$', '; SignTool=azsign (disabled for local build)' $IssContentNoSign = $IssContentNoSign -Replace '(?m)^SignedUninstaller=yes', 'SignedUninstaller=no' diff --git a/scripts/refresh-integrity-tree.ps1 b/scripts/refresh-integrity-tree.ps1 new file mode 100644 index 000000000..1899f876e --- /dev/null +++ b/scripts/refresh-integrity-tree.ps1 @@ -0,0 +1,48 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Regenerates IntegrityTree.json and validates it against the current folder contents. + +.PARAMETER Path + The directory whose IntegrityTree.json should be refreshed and validated. + +.PARAMETER FailOnUnexpectedFiles + Fail validation if files exist in the directory tree but are not present in + IntegrityTree.json. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory, Position = 0)] + [string] $Path, + + [switch] $FailOnUnexpectedFiles +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $Path -PathType Container)) { + throw "The directory '$Path' does not exist." +} + +$Path = (Resolve-Path $Path).Path +$GenerateScriptPath = Join-Path $PSScriptRoot 'generate-integrity-tree.ps1' +$VerifyScriptPath = Join-Path $PSScriptRoot 'verify-integrity-tree.ps1' + +if (-not (Test-Path $GenerateScriptPath -PathType Leaf)) { + throw "Integrity tree generator not found at '$GenerateScriptPath'." +} + +if (-not (Test-Path $VerifyScriptPath -PathType Leaf)) { + throw "Integrity tree validator not found at '$VerifyScriptPath'." +} + +Write-Host "Refreshing integrity tree in $Path..." +& $GenerateScriptPath -Path $Path -MinOutput + +$ValidationParameters = @{ Path = $Path } +if ($FailOnUnexpectedFiles) { + $ValidationParameters.FailOnUnexpectedFiles = $true +} + +& $VerifyScriptPath @ValidationParameters \ No newline at end of file diff --git a/scripts/sign.ps1 b/scripts/sign.ps1 index ab90696ea..be2a0f97e 100644 --- a/scripts/sign.ps1 +++ b/scripts/sign.ps1 @@ -106,6 +106,27 @@ function Invoke-BatchSign { } } +function Update-IntegrityTree { + param( + [string] $RootPath + ) + + if (-not $RootPath -or -not (Test-Path $RootPath -PathType Container)) { + return + } + + $TreeRefreshScriptPath = Join-Path $PSScriptRoot "refresh-integrity-tree.ps1" + if (-not (Test-Path $TreeRefreshScriptPath -PathType Leaf)) { + Write-Warning "Integrity tree refresh script not found at $TreeRefreshScriptPath" + return + } + + & $TreeRefreshScriptPath -Path $RootPath -FailOnUnexpectedFiles + if ($LASTEXITCODE -ne 0) { + throw "refresh-integrity-tree.ps1 failed with exit code $LASTEXITCODE" + } +} + # --- Sign binaries in BinDir --- if ($FileListPath -and (Test-Path $FileListPath)) { Write-Host "`n=== Signing binaries from list: $FileListPath ===" -ForegroundColor Cyan @@ -118,6 +139,7 @@ if ($FileListPath -and (Test-Path $FileListPath)) { Write-Warning "No .exe or .dll files found in $BinDir" } else { Invoke-BatchSign -Files ($filesToSign | ForEach-Object { $_.FullName }) + Update-IntegrityTree -RootPath $BinDir Write-Host "Binary signing complete." } } diff --git a/scripts/verify-integrity-tree.ps1 b/scripts/verify-integrity-tree.ps1 new file mode 100644 index 000000000..211376cb4 --- /dev/null +++ b/scripts/verify-integrity-tree.ps1 @@ -0,0 +1,104 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Validates a generated IntegrityTree.json against the files in a directory. + +.DESCRIPTION + This mirrors UniGetUI runtime integrity verification and can optionally fail + if the directory contains files that are not listed in IntegrityTree.json. + +.PARAMETER Path + The directory containing IntegrityTree.json and the files to validate. + +.PARAMETER FailOnUnexpectedFiles + Fail validation if files exist in the directory tree but are not present in + IntegrityTree.json. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory, Position = 0)] + [string] $Path, + + [switch] $FailOnUnexpectedFiles +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $Path -PathType Container)) { + throw "The directory '$Path' does not exist." +} + +$Path = (Resolve-Path $Path).Path +$IntegrityTreePath = Join-Path $Path 'IntegrityTree.json' + +if (-not (Test-Path $IntegrityTreePath -PathType Leaf)) { + throw "IntegrityTree.json was not found in '$Path'." +} + +$rawData = Get-Content $IntegrityTreePath -Raw + +try { + $data = ConvertFrom-Json $rawData -AsHashtable +} +catch { + throw "IntegrityTree.json is not valid JSON: $($_.Exception.Message)" +} + +if ($null -eq $data) { + throw 'IntegrityTree.json did not deserialize into a JSON object.' +} + +$missingFiles = New-Object System.Collections.Generic.List[string] +$mismatchedFiles = New-Object System.Collections.Generic.List[string] +$unexpectedFiles = New-Object System.Collections.Generic.List[string] + +$expectedFiles = @{} +foreach ($entry in $data.GetEnumerator()) { + $relativePath = [string] $entry.Key + $expectedHash = [string] $entry.Value + $expectedFiles[$relativePath] = $true + + $fullPath = Join-Path $Path $relativePath + if (-not (Test-Path $fullPath -PathType Leaf)) { + $missingFiles.Add($relativePath) + continue + } + + $currentHash = (Get-FileHash $fullPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($currentHash -ne $expectedHash.ToLowerInvariant()) { + $mismatchedFiles.Add("$relativePath|expected=$expectedHash|got=$currentHash") + } +} + +if ($FailOnUnexpectedFiles) { + Get-ChildItem $Path -Recurse -File | ForEach-Object { + $relativePath = $_.FullName.Substring($Path.Length).TrimStart('\', '/') -replace '\\', '/' + if ($relativePath -eq 'IntegrityTree.json') { + return + } + + if (-not $expectedFiles.ContainsKey($relativePath)) { + $unexpectedFiles.Add($relativePath) + } + } +} + +if ($missingFiles.Count -or $mismatchedFiles.Count -or $unexpectedFiles.Count) { + if ($missingFiles.Count) { + Write-Error "Missing files listed in IntegrityTree.json:`n - $($missingFiles -join "`n - ")" + } + + if ($mismatchedFiles.Count) { + Write-Error "Files with mismatched SHA256 values:`n - $($mismatchedFiles -join "`n - ")" + } + + if ($unexpectedFiles.Count) { + Write-Error "Unexpected files not present in IntegrityTree.json:`n - $($unexpectedFiles -join "`n - ")" + } + + throw 'Integrity tree validation failed.' +} + +$validatedFileCount = $data.Count +Write-Host "Integrity tree validation succeeded for $validatedFileCount file(s) in $Path" \ No newline at end of file diff --git a/src/UniGetUI/UniGetUI.csproj b/src/UniGetUI/UniGetUI.csproj index 70f5890de..5ad04153e 100644 --- a/src/UniGetUI/UniGetUI.csproj +++ b/src/UniGetUI/UniGetUI.csproj @@ -49,6 +49,13 @@ + + + +