diff --git a/CMakeLists.txt b/CMakeLists.txt index e815971..39c8880 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.4.1 LANGUAGES CXX RC) +project(KSwitcher VERSION 0.4.2 LANGUAGES CXX RC) if(NOT WIN32) message(FATAL_ERROR "KSwitcher uses native Windows APIs") endif() diff --git a/README.md b/README.md index 72a050f..5deeff8 100644 --- a/README.md +++ b/README.md @@ -346,3 +346,13 @@ It still uses the normal instance lock; close a running KSwitcher first if measu Application code is MIT licensed; preserves the original [Typlo](https://github.com/bitcodepro/Typlo) attribution in `LICENSE`. Dictionary data has its own CC BY-SA 4.0 license as described above. + +## Compatibility update bridge (v0.4.2) + +This two-executable release can be installed by the v0.3.x–v0.4.1 updater. +It upgrades the updater to discover stable `release-vX.Y.Z` tags and validate +manifest-based packages. Use Update again after this version starts to install +the full current release, including the standalone screenshot editor. +The screenshot editor remains embedded only in this transitional release. +Future full releases must use the `release-v` prefix so unmigrated clients +continue to discover v0.4.2 first rather than skip this bridge. diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index 28de3a6..e1b4b3f 100644 Binary files a/Release/KSwitcher-win-x64.zip and b/Release/KSwitcher-win-x64.zip differ diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index 059b0e1..e751403 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -7c29980c5d3b8862b1d78c06d1c3daa2b2bff24b56d200616971b92d784b51e6 KSwitcher-win-x64.zip +7f0ee55eb5b56a6227431c1951ac2751208d3d0c7c34e4d1d616e180c37b1d9f KSwitcher-win-x64.zip diff --git a/resources/app.rc b/resources/app.rc index dd255f4..3a0b83f 100644 --- a/resources/app.rc +++ b/resources/app.rc @@ -6,8 +6,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur" 1 RT_MANIFEST "app.manifest" 201 RCDATA "update.ps1" 1 VERSIONINFO - FILEVERSION 0,4,1,0 - PRODUCTVERSION 0,4,1,0 + FILEVERSION 0,4,2,0 + PRODUCTVERSION 0,4,2,0 FILEOS VOS_NT_WINDOWS32 FILETYPE VFT_APP BEGIN @@ -17,7 +17,7 @@ BEGIN BEGIN VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "ProductName", "KSwitcher" - VALUE "FileVersion", "0.4.1" + VALUE "FileVersion", "0.4.2" END END BLOCK "VarFileInfo" diff --git a/resources/update.ps1 b/resources/update.ps1 index 982f03c..cf327fc 100644 --- a/resources/update.ps1 +++ b/resources/update.ps1 @@ -9,8 +9,8 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest function Get-StableVersion([string]$Tag) { - if ($Tag -notmatch '^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null } - try { return [version]($Tag -replace '^v','') } catch { return $null } + if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null } + try { return [version]($Tag -replace '^(?:release-)?v','') } catch { return $null } } function Assert-ReleaseUrl([string]$Url) { $uri = [uri]$Url @@ -71,7 +71,7 @@ function Assert-Checksum([string]$Archive,[string]$Checksum) { finally { $sha.Dispose(); $stream.Dispose() } if ($actual -ne $expected) { throw 'Release checksum verification failed.' } } -function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) { +function Expand-LegacyRelease([string]$Archive,[string]$Destination,[version]$Expected) { Add-Type -AssemblyName System.IO.Compression.FileSystem $zip = [IO.Compression.ZipFile]::OpenRead($Archive) try { @@ -80,7 +80,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected foreach ($entry in $zip.Entries) { $name = $entry.FullName.Replace('\','/') if ($name.EndsWith('/') -and $name -eq 'dictionaries/') { continue } - if ($name -cnotmatch '^(KSwitcher(?:\.Config)?\.exe|LICENSE|json-LICENSE\.txt|README\.md|dictionaries/[a-zA-Z0-9_.-]+)$' -or + if ($name -cnotmatch '^(KSwitcher(?:\.(?:Config|Screenshot))?\.exe|LICENSE|json-LICENSE\.txt|README\.md|dictionaries/[a-zA-Z0-9_.-]+)$' -or $name.EndsWith('/.') -or $name.EndsWith('/..') -or $name.EndsWith('.') -or (($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or !$seen.Add($name)) { throw "Unexpected or unsafe archive entry: $name" @@ -98,7 +98,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected throw 'Incomplete release: KSwitcher.Config.exe' } } finally { $zip.Dispose() } - foreach ($binary in @('KSwitcher.exe','KSwitcher.Config.exe')) { + foreach ($binary in @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe')) { $exe = Join-Path $Destination $binary if (!(Test-Path -LiteralPath $exe)) { continue } $version = [Diagnostics.FileVersionInfo]::GetVersionInfo($exe) @@ -112,6 +112,75 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected [BitConverter]::ToUInt16($bytes,$pe+4) -ne 0x8664) { throw 'Release is not a Windows x64 executable.' } } } +function Get-Sha256([IO.Stream]$Stream) { + $sha=[Security.Cryptography.SHA256]::Create() + try { return [BitConverter]::ToString($sha.ComputeHash($Stream)).Replace('-','').ToLowerInvariant() } + finally { $sha.Dispose() } +} +function Assert-PackagePath([string]$Name) { + if (!$Name -or $Name.Length -gt 220 -or $Name -match '[\\:<>"|?*]' -or $Name.StartsWith('/')) { throw "Unsafe package path: $Name" } + $parts=$Name.Split('/') + foreach ($part in $parts) { + if (!$part -or $part -in @('.','..') -or $part -match '[. ]$|[\x00-\x1f]' -or + $part -match '^(?i:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\.|$)') { throw "Unsafe package path: $Name" } + } + if ($parts[0] -ieq 'data' -or $parts[0] -like '.kswitcher-*') { throw "Protected user path: $Name" } +} +function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) { + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip=[IO.Compression.ZipFile]::OpenRead($Archive) + try { + $manifests=@($zip.Entries | Where-Object FullName -CEQ 'release-files.json') + if (!$manifests.Count) { + if ($Expected -gt [version]'0.4.2') { throw 'Release manifest is missing.' } + $zip.Dispose(); $zip=$null + Expand-LegacyRelease $Archive $Destination $Expected + return + } + if ($manifests.Count -ne 1 -or $manifests[0].Length -gt 256KB) { throw 'Invalid release manifest.' } + $reader=[IO.StreamReader]::new($manifests[0].Open()) + try { $manifest=$reader.ReadToEnd() | ConvertFrom-Json } finally { $reader.Dispose() } + if ($manifest.schema -ne 1 -or (Get-StableVersion $manifest.version) -ne $Expected -or + @($manifest.files).Count -lt 1 -or @($manifest.files).Count -gt 1000) { throw 'Unsupported or mismatched release manifest.' } + $declared=[Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($file in $manifest.files) { + Assert-PackagePath $file.path + if ($file.path -ieq 'release-files.json' -or $declared.ContainsKey($file.path) -or + $file.sha256 -notmatch '^[a-fA-F0-9]{64}$' -or $file.size -lt 0 -or $file.size -gt 100MB) { throw 'Invalid manifest file entry.' } + $declared.Add($file.path,$file) + } + if (!$declared.ContainsKey('KSwitcher.exe')) { throw 'Main executable is missing from the manifest.' } + $seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $total=0L + foreach ($entry in $zip.Entries) { + $name=$entry.FullName.Replace('\','/') + if ($name.EndsWith('/')) { Assert-PackagePath $name.TrimEnd('/'); continue } + Assert-PackagePath $name + if (!$seen.Add($name) -or (($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000) { throw 'Duplicate or linked archive entry.' } + if ($name -ceq 'release-files.json') { continue } + if (!$declared.ContainsKey($name)) { throw "Undeclared package file: $name" } + $file=$declared[$name] + if ($entry.Length -ne $file.size) { throw "Package file size mismatch: $name" } + $total+=$entry.Length + if ($total -gt 100MB -or $zip.Entries.Count -gt 1100) { throw 'Release expands beyond allowed limits.' } + $stream=$entry.Open() + try { $hash=Get-Sha256 $stream } finally { $stream.Dispose() } + if ($hash -ne $file.sha256) { throw "Package file checksum mismatch: $name" } + } + if ($seen.Count -ne $declared.Count+1) { throw 'Archive does not contain every manifest file.' } + [IO.Directory]::CreateDirectory($Destination) | Out-Null + Assert-PlainDirectory $Destination + foreach ($entry in $zip.Entries) { + if ($entry.FullName.EndsWith('/')) { continue } + $target=Join-Path $Destination $entry.FullName + [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($target)) | Out-Null + Assert-PlainDirectory ([IO.Path]::GetDirectoryName($target)) + [IO.Compression.ZipFileExtensions]::ExtractToFile($entry,$target,$false) + } + $info=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $Destination 'KSwitcher.exe')) + if ($info.ProductName -ne 'KSwitcher' -or (Get-StableVersion $info.FileVersion) -ne $Expected) { throw 'Main executable identity/version mismatch.' } + } finally { if ($zip) { $zip.Dispose() } } +} function Assert-PlainDirectory([string]$Directory) { $item = Get-Item -LiteralPath $Directory -Force while ($item) { @@ -124,7 +193,7 @@ function Install-Files([string]$Stage,[string]$Target,[string]$Backup,[Collectio foreach ($file in @(Get-ChildItem -LiteralPath $Stage -File -Recurse)) { $relative = $file.FullName.Substring($Stage.Length+1) $destination = Join-Path $Target $relative - if ($relative.StartsWith('dictionaries\') -and (Test-Path -LiteralPath $destination)) { continue } + if ($relative.StartsWith('dictionaries\',[StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $destination)) { continue } [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null Assert-PlainDirectory ([IO.Path]::GetDirectoryName($destination)) if (Test-Path -LiteralPath $destination) { @@ -177,6 +246,8 @@ try { Receive-File $release.checksum $checksum 64KB Assert-Checksum $archive $checksum Expand-Release $archive $stage (Get-StableVersion $release.version) + $screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe')) + if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' } Write-Result @{state='prepared';version=$release.version} $ready.Set() | Out-Null if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' } diff --git a/src/update.h b/src/update.h index c4b6518..138cf11 100644 --- a/src/update.h +++ b/src/update.h @@ -2,7 +2,7 @@ #include "platform.h" namespace ks { -inline constexpr wchar_t AppVersion[] = L"0.4.1"; +inline constexpr wchar_t AppVersion[] = L"0.4.2"; inline constexpr UINT UpdateMessage = WM_APP + 7; struct UpdateResult { diff --git a/tests/update.ps1 b/tests/update.ps1 index 3695e4d..cfe9029 100644 --- a/tests/update.ps1 +++ b/tests/update.ps1 @@ -22,6 +22,9 @@ try { 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' @@ -34,9 +37,19 @@ try { 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) @@ -57,6 +70,19 @@ try { $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 @@ -69,12 +95,14 @@ try { 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')