param([string]$Executable = (Join-Path $PSScriptRoot '../build/KSwitcher.exe'), [switch]$Network) $ErrorActionPreference='Stop' . (Join-Path $PSScriptRoot '../resources/update.ps1') -Library $script:checks=0 function Assert($Value,[string]$Name) { if (!$Value) { throw $Name }; ++$script:checks } function Reject([scriptblock]$Action,[string]$Name) { $rejected=$false; try { & $Action } catch { $rejected=$true }; Assert $rejected $Name } $root=Join-Path ([IO.Path]::GetTempPath()) ('KSwitcher-update-tests-'+[guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($root) | Out-Null try { $binaryInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path -LiteralPath $Executable).Path) $binaryVersion = Get-StableVersion $binaryInfo.FileVersion Assert ($null -ne $binaryVersion) 'executable has a stable version' Assert ($binaryInfo.FileDescription -ceq ('KSwitcher ' + [char]0x2014 + ' native keyboard and clipboard utility')) 'executable description preserves Unicode em dash' Assert ((Get-StableVersion 'v0.10.0') -gt (Get-StableVersion 'v0.2.9')) 'numeric versions' Assert ($null -eq (Get-StableVersion 'v1.0.0-beta')) 'prerelease rejected' Assert ($null -eq (Get-StableVersion 'v1.0.0;whoami')) 'invalid version rejected' $assets=@( @{name='KSwitcher-win-x64.zip';browser_download_url='https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/v0.2.1/KSwitcher-win-x64.zip'}, @{name='SHA256SUMS.txt';browser_download_url='https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/v0.2.1/SHA256SUMS.txt'} ) $release=@{draft=$false;prerelease=$false;tag_name='v0.2.1';assets=$assets} Assert ((Select-Release @($release) ([version]'0.2.0')).version -eq 'v0.2.1') 'newer release' Assert ($null -eq (Select-Release @($release) ([version]'0.2.1'))) 'same version' Assert ($null -eq (Select-Release @($release) ([version]'0.3.0'))) 'no downgrade' Assert ((Get-StableVersion 'release-v0.5.0') -eq [version]'0.5.0') 'modern stable release tag' $modern=@{draft=$false;prerelease=$false;tag_name='release-v0.5.0';assets=$assets} Assert ((Select-Release @($release,$modern) ([version]'0.4.2')).version -eq 'release-v0.5.0') 'bridge discovers modern channel' $draft=@{draft=$true;prerelease=$false;tag_name='v9.0.0';assets=$assets} Assert ((Select-Release @($draft,$release) ([version]'0.2.0')).version -eq 'v0.2.1') 'draft ignored' Reject { Select-Release @(@{draft=$false;prerelease=$false;tag_name='v2.0.0';assets=@()}) ([version]'0.2.0') } 'incomplete release rejected' foreach ($url in @('http://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/v1/a','https://evil.example/a','https://gitea.hrynco.com/other/a','https://gitea.hrynco.com:8443/hrynco/hrynco-kswitcher/releases/download/v1/a')) { Reject { Assert-ReleaseUrl $url } 'untrusted URL rejected' } $source=Join-Path $root 'source'; [IO.Directory]::CreateDirectory($source) | Out-Null Copy-Item -LiteralPath $Executable -Destination (Join-Path $source 'KSwitcher.exe') $configurator = Join-Path (Split-Path $Executable) 'KSwitcher.Config.exe' if (Test-Path -LiteralPath $configurator) { Copy-Item -LiteralPath $configurator -Destination (Join-Path $source 'KSwitcher.Config.exe') } $screenshot = Join-Path (Split-Path $Executable) 'KSwitcher.Screenshot.exe' if (Test-Path -LiteralPath $screenshot) { Copy-Item -LiteralPath $screenshot -Destination (Join-Path $source 'KSwitcher.Screenshot.exe') } foreach ($name in @('LICENSE','json-LICENSE.txt','README.md')) { [IO.File]::WriteAllText((Join-Path $source $name),'fixture') } [IO.Directory]::CreateDirectory((Join-Path $source 'dictionaries')) | Out-Null [IO.File]::WriteAllText((Join-Path $source 'dictionaries/en.txt'),'new dictionary') function Write-TestManifest($Folder) { $entries=@(Get-ChildItem -LiteralPath $Folder -File -Recurse | Where-Object Name -ne 'release-files.json' | ForEach-Object { @{path=$_.FullName.Substring($Folder.Length+1).Replace('\','/');size=$_.Length;sha256=(Get-FileHash -LiteralPath $_.FullName).Hash} }) @{schema=1;version=$binaryVersion.ToString();files=$entries} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $Folder 'release-files.json') -Encoding utf8 } [IO.File]::WriteAllText((Join-Path $source 'future-tool.exe'),'future companion fixture') Write-TestManifest $source Add-Type -AssemblyName System.IO.Compression.FileSystem $archive=Join-Path $root 'release.zip' [IO.Compression.ZipFile]::CreateFromDirectory($source,$archive) $sum=Join-Path $root 'SHA256SUMS.txt' ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash+' KSwitcher-win-x64.zip') | Set-Content -LiteralPath $sum Assert-Checksum $archive $sum; ++$script:checks & { function Get-FileHash { throw 'Get-FileHash unavailable in inherited module environment' } Assert-Checksum $archive $sum } ++$script:checks $stage=Join-Path $root 'stage' Expand-Release $archive $stage $binaryVersion; ++$script:checks Reject { Expand-Release $archive (Join-Path $root 'wrong-version') ([version]'9.0.0') } 'version mismatch rejected' ('0'*64+' KSwitcher-win-x64.zip') | Set-Content -LiteralPath $sum Reject { Assert-Checksum $archive $sum } 'corrupt package rejected' $bad=Join-Path $root 'bad.zip' $z=[IO.Compression.ZipFile]::Open($bad,[IO.Compression.ZipArchiveMode]::Create) $null=$z.CreateEntry('../escape.exe'); $z.Dispose() Reject { Expand-Release $bad (Join-Path $root 'badstage') ([version]'0.2.1') } 'zip traversal rejected' foreach ($unsafe in @('../escape','C:/escape','data/settings.json','DATA/history.bin','folder/../escape','folder/file:stream','CON.txt','folder/file.','/absolute','folder\file')) { Reject { Assert-PackagePath $unsafe } 'unsafe or protected manifest path rejected' } $corrupt=Join-Path $root 'corrupt.zip' Copy-Item -LiteralPath $archive -Destination $corrupt $z=[IO.Compression.ZipFile]::Open($corrupt,[IO.Compression.ZipArchiveMode]::Update) $z.GetEntry('future-tool.exe').Delete() $writer=[IO.StreamWriter]::new($z.CreateEntry('future-tool.exe').Open()); $writer.Write('tampered'); $writer.Dispose(); $z.Dispose() Reject { Expand-Release $corrupt (Join-Path $root 'corruptstage') $binaryVersion } 'manifest detects tampered companion' $extra=Join-Path $root 'extra.zip'; Copy-Item -LiteralPath $archive -Destination $extra $z=[IO.Compression.ZipFile]::Open($extra,[IO.Compression.ZipArchiveMode]::Update) $null=$z.CreateEntry('undeclared.dll'); $z.Dispose() Reject { Expand-Release $extra (Join-Path $root 'extrastage') $binaryVersion } 'undeclared file rejected' $target=Join-Path $root 'install'; $backup=Join-Path $root 'backup' [IO.Directory]::CreateDirectory((Join-Path $target 'data')) | Out-Null [IO.Directory]::CreateDirectory((Join-Path $target 'dictionaries')) | Out-Null [IO.File]::WriteAllText((Join-Path $target 'data/settings.json'),'personal settings') [IO.File]::WriteAllText((Join-Path $target 'dictionaries/en.txt'),'personal dictionary') [IO.File]::WriteAllText((Join-Path $target 'KSwitcher.exe'),'old binary fixture') $changed=[Collections.Generic.List[string]]::new() Install-Files $stage $target $backup $changed Assert ((Get-FileHash -LiteralPath (Join-Path $target 'KSwitcher.exe')).Hash -eq (Get-FileHash -LiteralPath $Executable).Hash) 'replacement installed' if (Test-Path -LiteralPath $configurator) { Assert ((Get-FileHash -LiteralPath (Join-Path $target 'KSwitcher.Config.exe')).Hash -eq (Get-FileHash -LiteralPath $configurator).Hash) 'configurator installed' } Assert ([IO.File]::ReadAllText((Join-Path $target 'future-tool.exe')) -eq 'future companion fixture') 'new companion name installs without changing updater' Assert ([IO.File]::ReadAllText((Join-Path $target 'data/settings.json')) -eq 'personal settings') 'settings preserved' Assert ([IO.File]::ReadAllText((Join-Path $target 'dictionaries/en.txt')) -eq 'personal dictionary') 'dictionary preserved' Undo-Files $target $backup $changed Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'rollback restores original' Assert (!(Test-Path -LiteralPath (Join-Path $target 'README.md'))) 'rollback removes newly introduced files' Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Config.exe'))) 'rollback removes newly introduced configurator' Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Screenshot.exe'))) 'rollback removes newly introduced screenshot editor' # Fail after the original exe has moved to backup, as if the staged file vanished. $stage2=Join-Path $root 'stage2'; [IO.Directory]::CreateDirectory($stage2) | Out-Null [IO.File]::WriteAllText((Join-Path $stage2 'KSwitcher.exe'),'new binary fixture') $locked=[IO.File]::Open((Join-Path $stage2 'KSwitcher.exe'),[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None) $changed2=[Collections.Generic.List[string]]::new() try { Reject { Install-Files $stage2 $target $backup $changed2 } 'locked staged file fails safely' } finally { $locked.Dispose() } Undo-Files $target $backup $changed2 Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'partial install rollback' if ($Network) { $metadata=Join-Path $root 'live-releases.json' Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' $metadata 2MB $live=Select-Release (Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json) ([version]'0.0.0') Assert ($null -ne $live) 'live repository exposes a complete stable release' $liveZip=Join-Path $root 'live.zip'; $liveSum=Join-Path $root 'live-sums.txt' Receive-File $live.zip $liveZip 50MB Receive-File $live.checksum $liveSum 64KB Assert-Checksum $liveZip $liveSum; ++$script:checks Expand-Release $liveZip (Join-Path $root 'live-stage') (Get-StableVersion $live.version); ++$script:checks Reject { Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/kswitcher-updater-missing-test-repository' (Join-Path $root 'missing') 2MB } 'server failure reported' Write-Output "Downloaded and validated $($live.version) in a disposable test directory; no application was updated." } Write-Output "Passed $script:checks updater regression checks" } finally { if ([IO.Path]::GetDirectoryName($root) -eq [IO.Path]::GetTempPath().TrimEnd('\') -and (Split-Path -Leaf $root) -like 'KSwitcher-update-tests-*') { Remove-Item -LiteralPath $root -Recurse -Force } }