/
DemienMedich
/
BodyweightBase
Обзор
Документация
Войти
/
DemienMedich
/
BodyweightBase
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
develop
verify_package.ps1
337 строк
15 KB
DemienMedich
Harden package verification and clean startup
18 июл 2026, 21:15
18 июл 2026, 21:15
35f16e9
Код
Авторство
О чём код?
param( [string]$ArchivePath = (Join-Path $PSScriptRoot "dist\BodyweightBaseCpp.zip"), [string]$HashPath = "", [string]$ExtractRoot = (Join-Path $PSScriptRoot "dist\package-smoke"), [int]$SmokeSeconds = 5, [int]$ExpectedExerciseFrames = 0, [switch]$AllowMissingHash, [switch]$KeepExtracted ) $ErrorActionPreference = "Stop" if ($SmokeSeconds -lt 1) { throw "SmokeSeconds must be at least 1" } function Resolve-FullPath([string]$Path) { if ([System.IO.Path]::IsPathRooted($Path)) { return [System.IO.Path]::GetFullPath($Path) } return [System.IO.Path]::GetFullPath((Join-Path (Get-Location) $Path)) } function Read-KeyValueManifest([string]$Path) { $values = @{} foreach ($line in Get-Content -LiteralPath $Path) { if ($line -notmatch '^(?<key>[A-Za-z][A-Za-z0-9]+)=(?<value>.*)$') { continue } $key = $Matches.key if ($values.ContainsKey($key)) { throw "Duplicate package manifest key: $key" } $values[$key] = $Matches.value } return $values } function Assert-ManifestValue([hashtable]$Manifest, [string]$Key, [string]$Expected) { if (-not $Manifest.ContainsKey($Key)) { throw "Package manifest is missing $Key" } if ([string]$Manifest[$Key] -cne $Expected) { throw "Unexpected package manifest value for ${Key}: expected '$Expected', got '$($Manifest[$Key])'" } } function Invoke-AppSmoke( [string]$ExePath, [string]$WorkingDirectory, [string]$AppDataDirectory, [string[]]$Arguments, [string]$Mode ) { $startInfo = [System.Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $ExePath $startInfo.WorkingDirectory = $WorkingDirectory $startInfo.RedirectStandardError = $true $startInfo.UseShellExecute = $false $startInfo.Environment["APPDATA"] = $AppDataDirectory $startInfo.Environment["QT_FORCE_STDERR_LOGGING"] = "1" foreach ($argument in $Arguments) { if ($argument -notmatch '^--[a-z0-9-]+$') { throw "Unsupported smoke argument: $argument" } } $startInfo.Arguments = $Arguments -join " " $process = [System.Diagnostics.Process]::Start($startInfo) Start-Sleep -Seconds $SmokeSeconds $wasRunning = -not $process.HasExited if ($wasRunning) { try { if (-not $process.HasExited) { $process.Kill() } } catch [System.InvalidOperationException] { # The process can exit in the narrow window between HasExited and Kill. } $process.WaitForExit() } $stderr = $process.StandardError.ReadToEnd() if (-not $wasRunning) { throw "$Mode UI exited before smoke timeout with code $($process.ExitCode): $stderr" } return [pscustomobject]@{ Mode = $Mode Started = $true ExitCode = $process.ExitCode Stderr = $stderr } } $archiveFullPath = Resolve-FullPath $ArchivePath if ([string]::IsNullOrWhiteSpace($HashPath)) { $HashPath = "$archiveFullPath.sha256" } $hashFullPath = Resolve-FullPath $HashPath $extractFullPath = Resolve-FullPath $ExtractRoot $distFullPath = Resolve-FullPath (Join-Path $PSScriptRoot "dist") if (-not (Test-Path $archiveFullPath)) { throw "Archive was not found: $archiveFullPath" } $actualHash = (Get-FileHash -LiteralPath $archiveFullPath -Algorithm SHA256).Hash if (-not (Test-Path $hashFullPath)) { if (-not $AllowMissingHash) { throw "Archive SHA256 sidecar was not found: $hashFullPath" } } else { $expectedHash = ((Get-Content -LiteralPath $hashFullPath -Raw).Trim() -split "\s+")[0] if ($expectedHash -notmatch '^[A-Fa-f0-9]{64}$') { throw "Archive SHA256 sidecar is malformed: $hashFullPath" } if ($expectedHash -ne $actualHash) { throw "Archive SHA256 mismatch. Expected $expectedHash, got $actualHash" } } $distPrefix = $distFullPath.TrimEnd( [System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar $extractInsideDist = $extractFullPath.StartsWith( $distPrefix, [System.StringComparison]::OrdinalIgnoreCase) if ((Test-Path $extractFullPath) -and -not $extractInsideDist) { throw "Refusing to remove extract directory outside dist: $extractFullPath" } if (-not $extractInsideDist) { throw "Extract directory must be a child of dist: $extractFullPath" } if (Test-Path $extractFullPath) { Remove-Item -LiteralPath $extractFullPath -Recurse -Force } New-Item -ItemType Directory -Path $extractFullPath | Out-Null $statePath = Join-Path $env:APPDATA "BodyweightBaseCpp\native-state.json" $beforeStateHash = if (Test-Path $statePath) { (Get-FileHash -LiteralPath $statePath -Algorithm SHA256).Hash } else { "" } try { Expand-Archive -LiteralPath $archiveFullPath -DestinationPath $extractFullPath -Force $packageDirectory = Join-Path $extractFullPath "BodyweightBaseCpp" $exePath = Join-Path $packageDirectory "BodyweightBaseCppApp.exe" $frameDirectory = Join-Path $packageDirectory "Assets\ExerciseFrames" $manifestPath = Join-Path $packageDirectory "PACKAGE_MANIFEST.txt" $thirdPartyNoticesPath = Join-Path $packageDirectory "THIRD_PARTY_NOTICES.txt" $privacyNoticePath = Join-Path $packageDirectory "PRIVACY_AND_DATA.txt" $smokeAppDataRoot = Join-Path $extractFullPath "smoke-appdata" $desktopSmokeAppData = Join-Path $smokeAppDataRoot "desktop" $mobileSmokeAppData = Join-Path $smokeAppDataRoot "mobile" if (-not (Test-Path $exePath)) { throw "Packaged executable was not found after extraction: $exePath" } if (-not (Test-Path $manifestPath)) { throw "Package manifest was not found after extraction: $manifestPath" } if (-not (Test-Path $thirdPartyNoticesPath)) { throw "Third-party notices file was not found after extraction: $thirdPartyNoticesPath" } if (-not (Test-Path $privacyNoticePath)) { throw "Privacy and data notice file was not found after extraction: $privacyNoticePath" } $manifestValues = Read-KeyValueManifest $manifestPath Assert-ManifestValue $manifestValues "AssetAuditPassed" "true" Assert-ManifestValue $manifestValues "IncludesThirdPartyNotices" "true" Assert-ManifestValue $manifestValues "IncludesPrivacyNotice" "true" Assert-ManifestValue $manifestValues "IncludesQtRuntime" "true" Assert-ManifestValue $manifestValues "IncludesVcRedist" "true" $thirdPartyNoticesText = Get-Content -LiteralPath $thirdPartyNoticesPath -Raw if ($thirdPartyNoticesText -notmatch "Qt 6\.8\.3" -or $thirdPartyNoticesText -notmatch "not legal advice") { throw "Third-party notices file does not contain the expected Qt release notice" } $privacyNoticeText = Get-Content -LiteralPath $privacyNoticePath -Raw if ($privacyNoticeText -notmatch "native-state\.json" -or $privacyNoticeText -notmatch "network sync") { throw "Privacy and data notice file does not describe local state and sync behavior" } if (-not (Test-Path $frameDirectory)) { throw "Exercise frame directory was not found after extraction: $frameDirectory" } $manifestFrameCount = 0 if (-not $manifestValues.ContainsKey("ExerciseFrameCount") -or -not [int]::TryParse([string]$manifestValues["ExerciseFrameCount"], [ref]$manifestFrameCount) -or $manifestFrameCount -lt 1) { throw "Package manifest has no valid positive ExerciseFrameCount" } if ($ExpectedExerciseFrames -le 0) { $ExpectedExerciseFrames = $manifestFrameCount } elseif ($manifestFrameCount -ne $ExpectedExerciseFrames) { throw "Package manifest frame count differs from the requested count. Expected $ExpectedExerciseFrames, got $manifestFrameCount" } $frameFiles = @(Get-ChildItem -LiteralPath $frameDirectory -Recurse -File) $unexpectedFrameFiles = @($frameFiles | Where-Object { $_.Extension -cne ".png" -or $_.DirectoryName -cne $frameDirectory }) if ($unexpectedFrameFiles.Count -gt 0) { throw "Package contains unsupported exercise frame files: $($unexpectedFrameFiles.FullName -join ', ')" } $frameCount = $frameFiles.Count if ($frameCount -ne $ExpectedExerciseFrames) { throw "Unexpected exercise frame count. Expected $ExpectedExerciseFrames, got $frameCount" } if (-not $manifestValues.ContainsKey("ExerciseFrameHashManifest")) { throw "Package manifest does not name an exercise frame hash manifest" } $frameHashManifestName = [string]$manifestValues["ExerciseFrameHashManifest"] if ([string]::IsNullOrWhiteSpace($frameHashManifestName) -or [System.IO.Path]::GetFileName($frameHashManifestName) -cne $frameHashManifestName) { throw "Unsafe exercise frame hash manifest name: $frameHashManifestName" } $frameHashManifestPath = Join-Path $packageDirectory $frameHashManifestName if (-not (Test-Path $frameHashManifestPath)) { throw "Exercise frame hash manifest was not found: $frameHashManifestPath" } $frameHashes = @{} $frameDirectoryFullPath = [System.IO.Path]::GetFullPath($frameDirectory) $frameDirectoryPrefix = $frameDirectoryFullPath.TrimEnd( [System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar foreach ($line in Get-Content -LiteralPath $frameHashManifestPath) { if ([string]::IsNullOrWhiteSpace($line)) { continue } if ($line -cnotmatch '^(?<hash>[A-Fa-f0-9]{64}) (?<path>Assets/ExerciseFrames/[^/]+\.png)$') { throw "Malformed exercise frame hash entry: $line" } $relativePath = $Matches.path if ($frameHashes.ContainsKey($relativePath)) { throw "Duplicate exercise frame hash entry: $relativePath" } $candidatePath = [System.IO.Path]::GetFullPath( (Join-Path $packageDirectory $relativePath.Replace('/', '\'))) if (-not $candidatePath.StartsWith( $frameDirectoryPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw "Exercise frame hash path escapes its directory: $relativePath" } $frameHashes[$relativePath] = $Matches.hash.ToUpperInvariant() } $actualFramePaths = @{} foreach ($frameFile in $frameFiles) { $relativePath = $frameFile.FullName.Substring($packageDirectory.Length + 1).Replace('\', '/') $actualFramePaths[$relativePath] = $true if (-not $frameHashes.ContainsKey($relativePath)) { throw "Exercise frame is missing from the hash manifest: $relativePath" } $actualFrameHash = (Get-FileHash -LiteralPath $frameFile.FullName -Algorithm SHA256).Hash if ($actualFrameHash -cne $frameHashes[$relativePath]) { throw "Exercise frame SHA256 mismatch: $relativePath" } } foreach ($relativePath in $frameHashes.Keys) { if (-not $actualFramePaths.ContainsKey($relativePath)) { throw "Hash manifest references a missing exercise frame: $relativePath" } } if ($frameHashes.Count -ne $frameCount) { throw "Exercise frame hash entry count differs from the packaged frame count" } $payloadFiles = @(Get-ChildItem -LiteralPath $packageDirectory -Recurse -File | Where-Object { $_.FullName -cne $manifestPath }) $payloadFileCount = $payloadFiles.Count $payloadSizeBytes = [long](($payloadFiles | Measure-Object Length -Sum).Sum) $manifestPayloadFileCount = 0 $manifestPayloadSizeBytes = [long]0 if (-not $manifestValues.ContainsKey("PayloadFileCount") -or -not [int]::TryParse([string]$manifestValues["PayloadFileCount"], [ref]$manifestPayloadFileCount) -or $manifestPayloadFileCount -ne $payloadFileCount) { throw "Package payload file count does not match PACKAGE_MANIFEST.txt" } if (-not $manifestValues.ContainsKey("PayloadSizeBytes") -or -not [long]::TryParse([string]$manifestValues["PayloadSizeBytes"], [ref]$manifestPayloadSizeBytes) -or $manifestPayloadSizeBytes -ne $payloadSizeBytes) { throw "Package payload size does not match PACKAGE_MANIFEST.txt" } $desktopSmoke = Invoke-AppSmoke -ExePath $exePath -WorkingDirectory $packageDirectory ` -AppDataDirectory $desktopSmokeAppData -Arguments @() -Mode "Desktop" $mobileSmoke = Invoke-AppSmoke -ExePath $exePath -WorkingDirectory $packageDirectory ` -AppDataDirectory $mobileSmokeAppData -Arguments @("--mobile") -Mode "Mobile" $desktopSmokeStatePath = Join-Path $desktopSmokeAppData "BodyweightBaseCpp\native-state.json" $mobileSmokeStatePath = Join-Path $mobileSmokeAppData "BodyweightBaseCpp\native-state.json" if (-not (Test-Path $desktopSmokeStatePath)) { throw "Desktop smoke did not create starter state from the packaged resource" } if (-not (Test-Path $mobileSmokeStatePath)) { throw "Mobile smoke did not create starter state from the packaged resource" } $stderr = $desktopSmoke.Stderr + "`n" + $mobileSmoke.Stderr $afterStateHash = if (Test-Path $statePath) { (Get-FileHash -LiteralPath $statePath -Algorithm SHA256).Hash } else { "" } $qmlErrorLines = (($stderr -split "`r?`n") | Where-Object { $_ -match "qrc:|Main.qml|ReferenceError|TypeError|Unable to assign|Cannot assign|Error:" }).Count $imageWarnings = (($stderr -split "`r?`n") | Where-Object { $_ -match "QML Image|Cannot open|No such file|image" }).Count if ($qmlErrorLines -gt 0) { throw "QML smoke test reported $qmlErrorLines error lines: $stderr" } if ($imageWarnings -gt 0) { throw "Image smoke test reported $imageWarnings warning lines: $stderr" } if ($beforeStateHash -ne $afterStateHash) { throw "Smoke launch changed native-state.json" } [pscustomobject]@{ Archive = $archiveFullPath Sha256 = $actualHash ExtractedExeExists = (Test-Path $exePath) ManifestExists = (Test-Path $manifestPath) ThirdPartyNoticesExists = (Test-Path $thirdPartyNoticesPath) PrivacyNoticeExists = (Test-Path $privacyNoticePath) ExerciseFrameCount = $frameCount ExerciseFrameHashesVerified = ($frameHashes.Count -eq $frameCount) PayloadFileCount = $payloadFileCount ArchiveHashSidecarVerified = (Test-Path $hashFullPath) AppStarted = ($desktopSmoke.Started -and $mobileSmoke.Started) DesktopStarted = $desktopSmoke.Started MobileStarted = $mobileSmoke.Started DesktopStarterStateCreated = (Test-Path $desktopSmokeStatePath) MobileStarterStateCreated = (Test-Path $mobileSmokeStatePath) DesktopExitCode = $desktopSmoke.ExitCode MobileExitCode = $mobileSmoke.ExitCode NativeHashUnchangedOnLaunch = ($beforeStateHash -eq $afterStateHash) QmlErrorLines = $qmlErrorLines ImageWarnings = $imageWarnings } | ConvertTo-Json -Depth 3 } finally { if (-not $KeepExtracted -and (Test-Path $extractFullPath) -and $extractInsideDist) { Remove-Item -LiteralPath $extractFullPath -Recurse -Force } }