From 47b661037afeb5e8700803ca630e5d70c2332b78 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 3 Nov 2026 18:58:54 +0100 Subject: [PATCH 1/3] Render the WinGet and Chocolatey packages of a published release packaging/build-feeds.ps1 renders two packages per feed from the files of one published release: DonislawDev.BetterWindowsServices and DonislawDev.BetterWindowsServices.CLI on WinGet, better-windows-services and better-windows-services-cli on Chocolatey. It submits nothing. - The window's WinGet manifest lists the Windows installer first, with the ProductCode read out of that file, and the window archive second for --scope user. WinGet finds an installed program for a manifest by the installer's ProductCode, not by AppsAndFeaturesEntries. - Both archives put their own folder on PATH instead of a link. The folders hold our files only, and no link named bws.exe is made. - The Chocolatey window package runs the same installer and finds it again by its UpgradeCode. The command line package unpacks the release archive and gets one shim. - The script refuses a folder that is not the published release: the sums, every hash, the version of each program and of the installer, the pinned signature with a timestamp, and the installer's UpgradeCode. FeedGuards holds the shape of the templates, the published names and the refusals, and 15 mutation entries prove it. PwshScript is now the one runner the guards start packaging scripts with. Co-Authored-By: Claude Opus 5.5 --- packaging/build-feeds.ps1 | 570 ++++++++++++++++++ packaging/chocolatey/package.nuspec.in | 29 + .../tools/chocolateyinstall.cli.ps1.in | 23 + .../tools/chocolateyinstall.window.ps1.in | 33 + .../tools/chocolateyuninstall.window.ps1.in | 34 ++ packaging/winget/installer.cli.yaml.in | 27 + packaging/winget/installer.window.yaml.in | 69 +++ packaging/winget/locale.en-US.yaml.in | 26 + packaging/winget/version.yaml.in | 8 + tests/Bws.Architecture.Tests/FeedGuards.cs | 351 +++++++++++ .../Bws.Architecture.Tests/InstallerGuards.cs | 33 +- tests/Bws.Architecture.Tests/PwshScript.cs | 61 ++ tests/mutation/entries.ps1 | 104 ++++ 13 files changed, 1337 insertions(+), 31 deletions(-) create mode 100644 packaging/build-feeds.ps1 create mode 100644 packaging/chocolatey/package.nuspec.in create mode 100644 packaging/chocolatey/tools/chocolateyinstall.cli.ps1.in create mode 100644 packaging/chocolatey/tools/chocolateyinstall.window.ps1.in create mode 100644 packaging/chocolatey/tools/chocolateyuninstall.window.ps1.in create mode 100644 packaging/winget/installer.cli.yaml.in create mode 100644 packaging/winget/installer.window.yaml.in create mode 100644 packaging/winget/locale.en-US.yaml.in create mode 100644 packaging/winget/version.yaml.in create mode 100644 tests/Bws.Architecture.Tests/FeedGuards.cs create mode 100644 tests/Bws.Architecture.Tests/PwshScript.cs diff --git a/packaging/build-feeds.ps1 b/packaging/build-feeds.ps1 new file mode 100644 index 00000000..a248b4ea --- /dev/null +++ b/packaging/build-feeds.ps1 @@ -0,0 +1,570 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Render the WinGet manifests and the Chocolatey packages of one published release. + +.DESCRIPTION + Four packages, two per feed (the owner's decision of 2026-11-03): + + the window the command line + WinGet DonislawDev.BetterWindowsServices DonislawDev.BetterWindowsServices.CLI + Chocolatey better-windows-services better-windows-services-cli + installs the Windows installer, and in WinGet bws-cli-win-x64.zip + the window zip second, for --scope user + + The window's packages install the Windows installer because only an installer puts a program in the + Start menu: WinGet makes no shortcut for a portable package and its manifest has no field for one. The + installer carries the command line too, so the window's packages put bws on the machine PATH. The order + and the reasons stand in the templates, beside the lines they are about. The same script, renderer and + guard shape as the owner's ChronoMock (its packaging/build-feeds.ps1, 2026-11-02), which was measured on + both feeds before this one was written. + + Every value comes from one place. The release folder is the one a person downloads from the release + page. Its SHA256SUMS is the one file taken as it is: every other file is hashed here and has to match + its line in it, and what ties those files to this project is their signature, not the sums. The + installer, and the program in each archive, has to be signed by the certificate packaging/codesign.json + pins, with a timestamp, the way verify-release.yml checks a published release. The installer has to + carry the tag's version and the UpgradeCode build-msi.ps1 builds with, and each archive has to hold its + program at the version of the tag. Our file names carry no version, so these checks are what tell a + folder of another release apart. + The archive names, the program in each and the installer's file name come from packaging/components.json + - the register sign-release.ps1 signs from and verify-release.yml checks from, so the three cannot name + two different programs. The installer's name, UpgradeCode and shortcut sentence come from build-msi.ps1 + (its -SourceOnly output, the source the installer was built from), the release date from CHANGELOG.md, + the templates from packaging/winget and packaging/chocolatey. + + Written to -OutDir, which must not exist yet, under a parent that does, outside the repository: + winget/manifests/d/DonislawDev/BetterWindowsServices// the three files of one WinGet pull request + winget/manifests/d/DonislawDev/BetterWindowsServices/CLI// and the three of the other + chocolatey/better-windows-services/ and chocolatey/better-windows-services-cli/ one folder per `choco pack` + Rendered into a working folder beside it and renamed at the end, so a refused run leaves nothing, and a + second run given the same -OutDir is refused rather than mixed into the first. + + Nothing here submits anything. A pull request to microsoft/winget-pkgs and a `choco push` publish under + the project's name to feeds somebody else moderates, so they stay a step a person says yes to. + + Exit codes: 0 rendered, 1 refused - one line on stderr naming the input and why. + +.PARAMETER Tag + The published release, e.g. v0.5.0. A release candidate has no installer, so it gets no packages. + +.PARAMETER Release + The folder holding that release's SHA256SUMS, BetterWindowsServices-win-x64.msi, + BetterWindowsServices-win-x64.zip and bws-cli-win-x64.zip, as downloaded from the release page. + +.PARAMETER OutDir + Where the packages go. Must not exist, and must be outside the repository. +#> +[CmdletBinding(PositionalBinding = $false)] +param( + [Parameter(Mandatory)] [string] $Tag, + [Parameter(Mandatory)] [string] $Release, + [Parameter(Mandatory)] [string] $OutDir +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$root = Split-Path -Parent $PSScriptRoot +$wingetTemplates = Join-Path $PSScriptRoot 'winget' +$chocoTemplates = Join-Path $PSScriptRoot 'chocolatey' + +# The manifest schema. Take it from manifests merged into microsoft/winget-pkgs, not from that repository's +# schema folder: a version can be documented there before the validation service accepts it (refused within +# minutes on 2026-08-25 in a sibling project). On 2026-10-30 the repository's bot called 1.12.0 deprecated +# on a pending pull request and asked for 1.28.0, and 1.28.0 defines every field used here. +$WingetSchema = '1.28.0' + +# A refusal is one plain line on stderr and exit 1, the way build-msi.ps1 refuses. `exit` still runs the +# `finally` that removes the working folders. +function Stop-Render([string] $message) { + [Console]::Error.WriteLine("build-feeds: $message") + exit 1 +} + +# What went wrong in a .NET or COM call, without the line PowerShell wraps it in (in the language of the +# machine, which is not always English). +function Get-Reason([System.Management.Automation.ErrorRecord] $record) { + $exception = $record.Exception + if ($exception.InnerException) { $exception = $exception.InnerException } + return $exception.Message +} + +# --- what the release has to be ------------------------------------------------------------------------ + +function Get-ReleaseVersion([string] $releaseTag) { + if ($releaseTag.Contains('-')) { + Stop-Render "$releaseTag is a release candidate. Candidates get no installer, so they get no packages" + } + if ($releaseTag -notmatch '^v(\d+)\.(\d+)\.(\d+)$') { + Stop-Render "'$releaseTag' is not a release tag. Pass it the way the release is tagged, for example v0.5.0" + } + return "$([int]$Matches[1]).$([int]$Matches[2]).$([int]$Matches[3])" +} + +# SHA256SUMS of the release as name -> lower-case hash. A release's file is under a kilobyte, so anything over +# a megabyte is not one, and a name listed twice is a file that cannot be trusted either way. +function Read-Sums([string] $path) { + $item = Get-Item -LiteralPath $path -ErrorAction SilentlyContinue + if (-not $item -or $item.PSIsContainer) { Stop-Render "$path is not there. Download SHA256SUMS of the release into -Release" } + if ($item.Length -gt 1MB) { Stop-Render "$path holds $($item.Length) bytes, and a release's SHA256SUMS is under a kilobyte" } + try { + $text = [System.IO.File]::ReadAllText($path, [System.Text.UTF8Encoding]::new($false, $true)) + } + catch { + Stop-Render "$path is not UTF-8 text" + } + $sums = @{} + foreach ($line in ($text -split "`r?`n")) { + if (-not $line) { continue } + if ($line -notmatch '^([0-9a-fA-F]{64}) [ *]?(\S+)$') { Stop-Render "$path has a line that is not ' ': $line" } + $name = $Matches[2] + if ($sums.ContainsKey($name)) { Stop-Render "$path lists $name twice" } + $sums[$name] = $Matches[1].ToLowerInvariant() + } + return $sums +} + +# The file's hash, computed here, and the one SHA256SUMS gives for it, which must agree. +function Assert-Hashed([hashtable] $sums, [string] $folder, [string] $name) { + if (-not $sums.ContainsKey($name)) { Stop-Render "SHA256SUMS in $folder has no line for $name" } + $path = Join-Path $folder $name + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { Stop-Render "$name is not in $folder. Download it from the release page" } + $hash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($hash -ne $sums[$name]) { Stop-Render "$name in $folder hashes to $hash, and SHA256SUMS says $($sums[$name])" } + return $hash +} + +# Property rows of an installer database. Every COM object is released on every path: a held database +# keeps the file open. +function Read-MsiProperties([string] $msi) { + $installer = $null + $database = $null + $view = $null + $properties = @{} + try { + $installer = New-Object -ComObject WindowsInstaller.Installer + $database = $installer.OpenDatabase($msi, 0) + $view = $database.OpenView('SELECT `Property`, `Value` FROM `Property`') + [void]$view.Execute() + while ($true) { + $record = $view.Fetch() + if ($null -eq $record) { break } + try { $properties[$record.StringData(1)] = $record.StringData(2) } + finally { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($record) } + } + [void]$view.Close() + } + catch { + Stop-Render "$msi could not be read as an installer database: $(Get-Reason $_)" + } + finally { + foreach ($held in @($view, $database, $installer)) { + if ($null -ne $held) { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($held) } + } + [System.GC]::Collect() + [System.GC]::WaitForPendingFinalizers() + } + return $properties +} + +# The program in each archive, as its name in the archive - the same "/" sign-release.ps1 +# builds its $OURS from, out of the same register, so what is signed and what is packaged are one list. +function Get-PackagedPrograms($register) { + $programs = [ordered]@{} + foreach ($entry in $register.packages.PSObject.Properties) { + $zip = [string]$entry.Value.zip + $folder = [string]$entry.Value.folder + $executable = [string]$entry.Value.executable + if (-not $zip -or -not $folder -or -not $executable) { + Stop-Render "packaging/components.json names the package '$($entry.Name)' without its zip, folder and executable" + } + $programs[$entry.Name] = [ordered]@{ Zip = $zip; Program = "$folder/$executable" } + } + foreach ($needed in @('gui', 'cli')) { + if (-not $programs.Contains($needed)) { Stop-Render "packaging/components.json has no '$needed' package, and the feeds package both halves" } + } + return $programs +} + +# The program a package starts has to be in its archive at that path and at the tag's version. It is copied +# out into the scratch folder so that its signature can be asked, and the copy is returned. An archive of +# another release is refused here and not by the person who installs it. +function Expand-Checked([string] $zip, [string] $program, [string] $version, [string] $scratch) { + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zipName = Split-Path -Leaf $zip + $archive = $null + try { + try { + $archive = [System.IO.Compression.ZipFile]::OpenRead($zip) + } + catch { + Stop-Render "$zipName could not be read as an archive: $(Get-Reason $_)" + } + $entry = $archive.GetEntry($program) + if ($null -eq $entry) { Stop-Render "$zipName holds no $program, which its package starts and the release signs" } + $copy = Join-Path $scratch ([guid]::NewGuid().ToString('N') + [System.IO.Path]::GetExtension($program)) + try { + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $copy) + } + catch { + Stop-Render "$program could not be read out of $zipName`: $(Get-Reason $_)" + } + } + finally { + if ($null -ne $archive) { $archive.Dispose() } + } + # The product version of our programs carries the commit after a plus, 0.5.0+c9827633... for v0.5.0 + # (read 2026-11-03 from both published executables), so the version is the part before it. The commit + # is not asked: that needs the tag in the clone, and the guard runs on clones that have none. + $found = [string](Get-Item -LiteralPath $copy).VersionInfo.ProductVersion + if (($found -split '\+', 2)[0] -ne $version) { Stop-Render "$program in $zipName is version '$found', and the tag is $version" } + return $copy +} + +# Why a file is not signed the way a published release is, or nothing when it is. The questions +# verify-release.yml asks: Valid says only that somebody's certificate signed it, so the certificate has to +# be the one packaging/codesign.json pins, and without a timestamp a signature dies with the certificate. +function Get-SignatureFault([string] $path, [string] $pin) { + $signature = Get-AuthenticodeSignature -LiteralPath $path + if ($signature.Status -ne 'Valid') { return "signature status $($signature.Status)" } + $signer = [Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($signature.SignerCertificate.RawData)).ToLowerInvariant() + if ($signer -ne $pin) { return "signed by a certificate with SHA-256 $signer" } + if (-not $signature.TimeStamperCertificate) { return 'signed with no timestamp' } + return '' +} + +# What build-msi.ps1 builds the installer from, asked of the script itself rather than copied out of it. +function Get-InstallerIdentity([string] $releaseTag) { + # A process of its own: its refusals end the process they run in. + $pwsh = Join-Path $PSHOME 'pwsh.exe' + $source = (& $pwsh -NoProfile -NonInteractive -File (Join-Path $PSScriptRoot 'build-msi.ps1') -Tag $releaseTag -SourceOnly) -join "`n" + if ($LASTEXITCODE -ne 0) { Stop-Render "build-msi.ps1 -SourceOnly refused $releaseTag (its line above says why)" } + $identity = @{} + $patterns = [ordered]@{ + Name = ']*?\sName="([^"]+)"' + UpgradeCode = ']*?\sUpgradeCode="([0-9A-F-]{36})"' + Shortcut = ']*?\sDescription="([^"]+)"' + } + foreach ($key in $patterns.Keys) { + $found = [regex]::Matches($source, $patterns[$key]) + if ($found.Count -ne 1) { Stop-Render "build-msi.ps1 -SourceOnly gave $($found.Count) values for the installer's $key, and one is needed" } + $identity[$key] = $found[0].Groups[1].Value + } + return $identity +} + +function Get-ReleaseDate([string] $version) { + $changelog = Join-Path $root 'CHANGELOG.md' + $dates = [regex]::Matches([System.IO.File]::ReadAllText($changelog), "(?m)^## \[$([regex]::Escape($version))\] - (\d{4}-\d{2}-\d{2})\s*$") + if ($dates.Count -ne 1) { Stop-Render "CHANGELOG.md dates $version $($dates.Count) times, and a release is dated once" } + return $dates[0].Groups[1].Value +} + +# --- where the packages go ----------------------------------------------------------------------------- + +# A path with every link and junction on the way to it followed, so a link does not get round the refusal +# to write into the repository. Segments that do not exist yet are kept as written. +function Resolve-RealPath([string] $path) { + $full = [System.IO.Path]::GetFullPath($path) + $current = [System.IO.Path]::GetPathRoot($full) + foreach ($segment in $full.Substring($current.Length).Split([System.IO.Path]::DirectorySeparatorChar, [StringSplitOptions]::RemoveEmptyEntries)) { + $next = Join-Path $current $segment + $item = Get-Item -LiteralPath $next -Force -ErrorAction SilentlyContinue + if ($item -and $item.LinkType) { $next = $item.ResolveLinkTarget($true).FullName } + $current = $next + } + return $current.TrimEnd([System.IO.Path]::DirectorySeparatorChar) +} + +function Assert-Destination([string] $path) { + $real = Resolve-RealPath $path + $repo = Resolve-RealPath $root + if ($real -eq $repo -or $real.StartsWith($repo + [System.IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + Stop-Render "$path is inside the repository. Render outside it, so nothing rendered can be committed by mistake" + } + if (Test-Path -LiteralPath $path) { Stop-Render "$path exists already. Give a folder that does not, so nothing old is mixed in" } + $parent = Split-Path -Parent ([System.IO.Path]::GetFullPath($path)) + if (-not (Test-Path -LiteralPath $parent -PathType Container)) { Stop-Render "$parent, the folder $path would go in, does not exist" } +} + +# --- rendering ----------------------------------------------------------------------------------------- + +# A value goes into YAML, XML and PowerShell alike, so it holds nothing any of them reads as syntax: no quote +# of either kind, no backtick or dollar for PowerShell, no angle bracket or ampersand for XML, no doubled +# brace that would read as a placeholder. Prose is written without apostrophes for that reason. +$unsafe = [regex]'[''"`$<>&]|\{\{|\}\}|\r' + +# Fills every placeholder. A value of several lines placed on a line of its own gets that line's indentation +# on each of its lines, which is what a YAML block needs, and blank lines stay blank. An unknown placeholder +# is refused before anything is replaced, because a refusal from inside the replacement would be raised in a +# .NET callback rather than end the run. +function Expand-Template([string] $templatePath, [System.Collections.IDictionary] $values, [System.Collections.Generic.HashSet[string]] $used) { + $text = [System.IO.File]::ReadAllText($templatePath).Replace("`r`n", "`n") + $placeholder = '(?m)(^[ ]*)?\{\{([A-Z0-9_]+)\}\}' + foreach ($asked in [regex]::Matches($text, $placeholder)) { + $name = $asked.Groups[2].Value + if (-not $values.Contains($name)) { Stop-Render "$templatePath asks for {{$name}}, which build-feeds.ps1 does not give" } + [void]$used.Add($name) + } + $evaluator = [System.Text.RegularExpressions.MatchEvaluator] { + param($m) + $indent = $m.Groups[1].Value + $lines = ([string]$values[$m.Groups[2].Value]).Split("`n") + return (($lines | ForEach-Object { if ($_) { $indent + $_ } else { '' } }) -join "`n") + }.GetNewClosure() + $out = [regex]::Replace($text, $placeholder, $evaluator) + if ($out.Contains('{{') -or $out.Contains('}}')) { Stop-Render "$templatePath still holds a doubled brace after rendering" } + if ($out -match '[^\x09\x0A\x20-\x7E]') { + # Windows PowerShell 5.1 reads a script without a byte order mark as ANSI, and every file here is + # read by somebody else's parser, so ASCII is the one encoding nothing can get wrong. + Stop-Render "$templatePath renders to text that is not ASCII" + } + return $out +} + +function Write-Rendered([string] $path, [string] $text) { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $path) | Out-Null + [System.IO.File]::WriteAllText($path, $text, [System.Text.UTF8Encoding]::new($false)) +} + +# --- the run ------------------------------------------------------------------------------------------- + +$version = Get-ReleaseVersion $Tag +# Against the location of this session, as PowerShell resolves a path. .NET resolves a relative path against +# the process folder, which Set-Location does not move, so `out` given after a cd would be another folder. +$OutDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutDir) +$Release = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Release) +Assert-Destination $OutDir +$releaseDir = [System.IO.Path]::GetFullPath($Release) +if (-not (Test-Path -LiteralPath $releaseDir -PathType Container)) { Stop-Render "$Release is not a folder. Download the release files into one" } + +$register = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'components.json') | ConvertFrom-Json +$product = $register.product +$publisher = ($product.supplier -split ':\s*', 2)[-1] +foreach ($field in @($product.name, $publisher, $product.homepage, $product.source, $product.license, $product.copyright)) { + if (-not $field) { Stop-Render 'packaging/components.json is missing a product field the packages name' } +} +$msiName = [string]$register.installer.file +if (-not $msiName) { Stop-Render 'packaging/components.json names no installer file, and the window packages install it' } +$packaged = Get-PackagedPrograms $register +$appZipName = $packaged.gui.Zip +$cliZipName = $packaged.cli.Zip +# WinGet takes the path inside the archive the Windows way. +$appZipExe = $packaged.gui.Program.Replace('/', '\') +$cliZipExe = $packaged.cli.Program.Replace('/', '\') + +$identity = Get-InstallerIdentity $Tag +$releaseDate = Get-ReleaseDate $version +$sums = Read-Sums (Join-Path $releaseDir 'SHA256SUMS') +$msiHash = Assert-Hashed $sums $releaseDir $msiName +$appZipHash = Assert-Hashed $sums $releaseDir $appZipName +$cliZipHash = Assert-Hashed $sums $releaseDir $cliZipName +$pin = (Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'codesign.json') | ConvertFrom-Json).certificate_sha256 +if ($pin -notmatch '^[0-9a-f]{64}$') { Stop-Render 'packaging/codesign.json carries no usable certificate_sha256' } +$msiPath = Join-Path $releaseDir $msiName + +$scratch = Join-Path ([System.IO.Path]::GetTempPath()) ('bws-feeds-' + [guid]::NewGuid().ToString('N')) +$work = Join-Path (Split-Path -Parent ([System.IO.Path]::GetFullPath($OutDir))) ('.' + (Split-Path -Leaf $OutDir) + '.partial-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) +try { + New-Item -ItemType Directory -Force -Path $scratch | Out-Null + # The archives first, so that one of another release is refused by what it holds before its signatures + # are asked. + $copies = [ordered]@{} + foreach ($half in $packaged.Values) { + $copies["$($half.Program) in $($half.Zip)"] = Expand-Checked (Join-Path $releaseDir $half.Zip) $half.Program $version $scratch + } + + # Every signature is asked before any is refused, so one refusal names every file that is not ours. + $faults = [System.Collections.Generic.List[string]]::new() + $fault = Get-SignatureFault $msiPath $pin + if ($fault) { $faults.Add("$msiName ($fault)") } + foreach ($name in $copies.Keys) { + $fault = Get-SignatureFault $copies[$name] $pin + if ($fault) { $faults.Add("$name ($fault)") } + } + if ($faults.Count) { + Stop-Render "not signed by the certificate packaging/codesign.json pins ($pin), with a timestamp: $($faults -join ', ')" + } + + $msiProperties = Read-MsiProperties $msiPath + if ($msiProperties['ProductVersion'] -ne $version) { + Stop-Render "$msiName is version '$($msiProperties['ProductVersion'])', and the tag is $version" + } + $upgradeCode = "{$($identity.UpgradeCode)}" + if ($msiProperties['UpgradeCode'] -ne $upgradeCode) { + Stop-Render "$msiName carries UpgradeCode $($msiProperties['UpgradeCode']), and build-msi.ps1 builds with $upgradeCode" + } + # Every build of the installer has a ProductCode of its own, so it comes from the file and from nowhere else. + $productCode = [string]$msiProperties['ProductCode'] + if ($productCode -notmatch '^\{[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}\}$') { + Stop-Render "$msiName carries no ProductCode WinGet can match on ('$productCode')" + } + + # The Chocolatey icon. Moderation takes a CDN address and refuses raw.githubusercontent.com and + # github.com/.../raw alike, and an address on a branch would change the icon of an approved package with + # every commit, so it is jsDelivr pinned to the tag, and the file has to be in the tag for that to answer. + # SVG, because the tags carry no PNG: the validator's rule CPMR0058 is "Use PNG or SVG for package icons" + # (docs.chocolatey.org, read 2026-11-03), and jsDelivr answered 200 image/svg+xml with the 1585 bytes of + # the file for v0.5.0 that day. + $iconPath = 'site/assets/icon.svg' + & git -C $root cat-file -e "$($Tag):$iconPath" 2>$null + if ($LASTEXITCODE -ne 0) { Stop-Render "$iconPath is not in $Tag in this clone, so the icon address would not answer. Fetch the tag" } + $repoPath = ([uri]$product.source).AbsolutePath.Trim('/') + + $download = "$($product.source)/releases/download/$Tag" + $shared = [ordered]@{ + VERSION = $version + RELEASE_DATE = $releaseDate + WINGET_SCHEMA = $WingetSchema + PUBLISHER = $publisher + REPO_URL = $product.source + ISSUES_URL = "$($product.source)/issues" + PROJECT_URL = $product.homepage + LICENSE = $product.license + LICENSE_URL = "$($product.source)/blob/$Tag/LICENSE" + COPYRIGHT = $product.copyright + RELEASE_NOTES_URL = "$($product.source)/releases/tag/$Tag" + PACKAGE_SOURCE_URL = "$($product.source)/tree/main/packaging/chocolatey" + ICON_URL = "https://cdn.jsdelivr.net/gh/$repoPath@$Tag/$iconPath" + } + + # Every sentence below is one the README makes about the program, and none is new here. + $audit = 'It freezes every service into a snapshot file and later says what changed since, configuration ' + + 'apart from what merely started or stopped, and reads from the System log who changed a start type or ' + + 'installed a service.' + $offline = 'It works on the local machine and never connects to the internet.' + $elevated = 'Looking needs no administrator rights, and changing a service does.' + $close = 'Close the window and every bws that is running before an upgrade.' + # Owner's decision of 2026-11-03: the command keeps its name, and the packages say so. + $sameName = 'The command is named bws, as is the command of Bitwarden Secrets Manager CLI. With both ' + + 'installed, the one found first on PATH answers, and the machine PATH comes before the PATH of an account.' + $tags = @('services', 'windows-services', 'services-msc', 'service-manager', 'drivers', 'sysadmin', 'administration', 'audit', 'snapshot', 'diff') + + $packages = @( + [ordered]@{ + Kind = 'window' + WingetId = 'DonislawDev.BetterWindowsServices' + ChocoId = 'better-windows-services' + Title = $identity.Name + Moniker = 'betterwindowsservices' + Short = $identity.Shortcut + Tags = $tags + @('gui') + Body = ("$($identity.Name) is services.msc rebuilt for people who administer Windows machines - a " + + 'window and a command line over one engine. Ask the machine a question instead of scrolling: ' + + 'start:auto !status:running is what should be up and is not. Every stop, start, restart and start ' + + 'type change is shown as a plan, with the services it takes down with it, before anything runs.' + + "`n`n$audit It shows what services.msc hides - who signed the binary, triggers, privileges, the " + + 'security descriptor, the process and its memory - and says when it could not read a field instead ' + + "of showing it empty. $offline") + WingetHow = ("The Windows installer puts $($identity.Name) in the Start menu of every account, puts its " + + 'folder - the window and the bws command line - on the machine PATH, and asks for administrator ' + + "rights. $close With --scope user you get the portable window instead: no administrator rights, " + + 'and no Start menu entry either - start it by typing BetterWindowsServices in a new terminal. The ' + + "command line alone is the package DonislawDev.BetterWindowsServices.CLI. $elevated $sameName") + ChocoHow = ("This package runs the Windows installer of the release. It puts $($identity.Name) in the Start " + + 'menu of every account, and puts its folder - the window and the bws command line - on the machine PATH. ' + + "$close The command line alone is the package better-windows-services-cli. $elevated $sameName") + Own = [ordered]@{ + MSI_URL = "$download/$msiName" + MSI_SHA256 = $msiHash.ToUpperInvariant() + MSI_PRODUCT_CODE = $productCode + MSI_UPGRADE_CODE = $upgradeCode + APP_ZIP_URL = "$download/$appZipName" + APP_ZIP_SHA256 = $appZipHash.ToUpperInvariant() + APP_ZIP_EXE = $appZipExe + } + Chocolatey = @('chocolateyinstall', 'chocolateyuninstall') + }, + [ordered]@{ + Kind = 'cli' + WingetId = 'DonislawDev.BetterWindowsServices.CLI' + ChocoId = 'better-windows-services-cli' + Title = "$($identity.Name) CLI" + Moniker = 'betterwindowsservices-cli' + Short = 'Manage and audit Windows services from the command line, with dry runs, snapshots and a diff of what changed' + Tags = $tags + @('cli', 'command-line', 'automation') + Body = ("bws is the command line of $($identity.Name), for scripts, scheduled tasks and CI. bws list " + + '--query asks the machine a question in the language of the search box of the window. Every stop, ' + + 'start, restart and start type change is a plan first and can be previewed with --dry-run.' + + "`n`n$audit Every command takes --json, every ending has an exit code of its own, and a diff that " + + "finds drift ends with 5. $offline") + WingetHow = ("Type bws --help in a new terminal to see the commands. $elevated The window, with a Start " + + "menu entry and the same command line, is the package DonislawDev.BetterWindowsServices. $sameName") + ChocoHow = ("Type bws --help in a new terminal to see the commands. $elevated The window, with a Start " + + "menu entry and the same command line, is the package better-windows-services. $sameName") + Own = [ordered]@{ + CLI_ZIP_URL = "$download/$cliZipName" + CLI_ZIP_SHA256 = $cliZipHash.ToUpperInvariant() + CLI_ZIP_EXE = $cliZipExe + } + Chocolatey = @('chocolateyinstall') + } + ) + + foreach ($package in $packages) { + $values = [ordered]@{} + foreach ($key in $shared.Keys) { $values[$key] = $shared[$key] } + foreach ($key in $package.Own.Keys) { $values[$key] = $package.Own[$key] } + $values['WINGET_ID'] = $package.WingetId + $values['CHOCO_ID'] = $package.ChocoId + $values['TITLE'] = $package.Title + $values['MONIKER'] = $package.Moniker + $values['SHORT_DESCRIPTION'] = $package.Short + $values['DESCRIPTION'] = $package.Body + $values['WINGET_HOW_TO_START'] = $package.WingetHow + $values['CHOCO_HOW_TO_START'] = $package.ChocoHow + $values['WINGET_TAGS'] = ($package.Tags | ForEach-Object { "- $_" }) -join "`n" + $values['CHOCO_TAGS'] = $package.Tags -join ' ' + foreach ($key in $values.Keys) { + $multiLine = $key -in @('DESCRIPTION', 'WINGET_TAGS') + $value = [string]$values[$key] + if (-not $value) { Stop-Render "the $($package.Kind) package has no value for $key" } + if ($unsafe.IsMatch($value) -or (-not $multiLine -and $value.Contains("`n"))) { + Stop-Render "the value of $key for the $($package.Kind) package would break a file it lands in: $value" + } + } + + # The WinGet folder is the path the files take in microsoft/winget-pkgs: the identifier's first letter, + # then one folder per part of the identifier, then the version. + $idParts = $package.WingetId.Split('.') + $wingetDir = Join-Path $work (Join-Path 'winget/manifests' (Join-Path $idParts[0].Substring(0, 1).ToLowerInvariant() (($idParts -join '/') + "/$version"))) + $files = [ordered]@{ + (Join-Path $wingetDir "$($package.WingetId).yaml") = Join-Path $wingetTemplates 'version.yaml.in' + (Join-Path $wingetDir "$($package.WingetId).locale.en-US.yaml") = Join-Path $wingetTemplates 'locale.en-US.yaml.in' + (Join-Path $wingetDir "$($package.WingetId).installer.yaml") = Join-Path $wingetTemplates "installer.$($package.Kind).yaml.in" + (Join-Path $work "chocolatey/$($package.ChocoId)/$($package.ChocoId).nuspec") = Join-Path $chocoTemplates 'package.nuspec.in' + } + foreach ($script in $package.Chocolatey) { + $files[(Join-Path $work "chocolatey/$($package.ChocoId)/tools/$script.ps1")] = Join-Path $chocoTemplates "tools/$script.$($package.Kind).ps1.in" + } + + $used = [System.Collections.Generic.HashSet[string]]::new() + foreach ($target in $files.Keys) { + $template = $files[$target] + if (-not (Test-Path -LiteralPath $template -PathType Leaf)) { Stop-Render "there is no template at $template" } + Write-Rendered $target (Expand-Template $template $values $used) + } + # A value nobody asks for is a template that lost a line, which rendering would otherwise not notice. + $unused = @($values.Keys | Where-Object { -not $used.Contains($_) }) + if ($unused) { Stop-Render "no template of the $($package.Kind) package asks for $($unused -join ', ')" } + } + + # A rename, which fails when the destination is there. Move-Item would put the folder INSIDE an existing + # one, so a second run given the same -OutDir, past the check at the start while this one rendered, + # would nest its packages in this run's and still say rendered. + try { + [System.IO.Directory]::Move($work, $OutDir) + } + catch { + Stop-Render "$OutDir could not be made from the rendered folder, so nothing was put there: $(Get-Reason $_)" + } + $work = $null +} +finally { + if ($work -and (Test-Path -LiteralPath $work)) { Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $scratch) { Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue } +} + +Write-Output "build-feeds: $Tag rendered into $OutDir" +Get-ChildItem -LiteralPath $OutDir -Recurse -File | ForEach-Object { Write-Output (' ' + $_.FullName.Substring(([System.IO.Path]::GetFullPath($OutDir)).Length + 1)) } diff --git a/packaging/chocolatey/package.nuspec.in b/packaging/chocolatey/package.nuspec.in new file mode 100644 index 00000000..77695447 --- /dev/null +++ b/packaging/chocolatey/package.nuspec.in @@ -0,0 +1,29 @@ + + + + + {{CHOCO_ID}} + {{VERSION}} + {{PACKAGE_SOURCE_URL}} + {{PUBLISHER}} + {{TITLE}} + {{PUBLISHER}} + {{PROJECT_URL}} + {{ICON_URL}} + {{COPYRIGHT}} + {{LICENSE_URL}} + false + {{REPO_URL}} + {{PROJECT_URL}} + {{ISSUES_URL}} + {{CHOCO_TAGS}} + {{SHORT_DESCRIPTION}} + + {{RELEASE_NOTES_URL}} + + + + + diff --git a/packaging/chocolatey/tools/chocolateyinstall.cli.ps1.in b/packaging/chocolatey/tools/chocolateyinstall.cli.ps1.in new file mode 100644 index 00000000..850b6594 --- /dev/null +++ b/packaging/chocolatey/tools/chocolateyinstall.cli.ps1.in @@ -0,0 +1,23 @@ +# Rendered by packaging/build-feeds.ps1 - do not edit the generated copy. +# +# The package DOWNLOADS the release archive instead of carrying it: the archive +# is the same file, checksum and all, that the release page publishes, and a +# package with no binaries inside owes the moderators no VERIFICATION.txt. +# +# Windows PowerShell 5.1 is what Chocolatey runs package scripts with, so this +# file is ASCII and uses no PowerShell 7 syntax. +$ErrorActionPreference = 'Stop' +$toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition + +# Exactly as the archive holds it: one folder with bws, its licence and its +# notices. Chocolatey makes one shim for the one program in it, so bws answers +# by name in a new terminal. +Install-ChocolateyZipPackage ` + -PackageName $env:ChocolateyPackageName ` + -Url64bit '{{CLI_ZIP_URL}}' ` + -Checksum64 '{{CLI_ZIP_SHA256}}' ` + -ChecksumType64 'sha256' ` + -UnzipLocation $toolsDir + +Write-Host '' +Write-Host 'Type bws --help to see the commands.' diff --git a/packaging/chocolatey/tools/chocolateyinstall.window.ps1.in b/packaging/chocolatey/tools/chocolateyinstall.window.ps1.in new file mode 100644 index 00000000..b96f5361 --- /dev/null +++ b/packaging/chocolatey/tools/chocolateyinstall.window.ps1.in @@ -0,0 +1,33 @@ +# Rendered by packaging/build-feeds.ps1 - do not edit the generated copy. +# +# The package runs the Windows installer of the release - the same signed file, +# checksum and all, that the release page publishes and that WinGet installs +# first. Everything this package puts on the machine comes from that installer: +# the Start menu entry for every account, its folder on the machine PATH with +# both programs in it, and the entry in Installed apps. So the two feeds install +# one thing in one shape, the one packaging/test-installer.ps1 installs on a +# clean runner, rather than one a script of ours makes and has to take away +# again. The package carries no binaries, so it owes the moderators no +# VERIFICATION.txt, and makes no shims. +# +# Windows PowerShell 5.1 is what Chocolatey runs package scripts with, so this +# file is ASCII and uses no PowerShell 7 syntax. +$ErrorActionPreference = 'Stop' + +$packageArgs = @{ + packageName = $env:ChocolateyPackageName + fileType = 'msi' + url64bit = '{{MSI_URL}}' + checksum64 = '{{MSI_SHA256}}' + checksumType64 = 'sha256' + softwareName = '{{TITLE}}' + silentArgs = "/qn /norestart /l*v `"$env:TEMP\$env:ChocolateyPackageName.$env:ChocolateyPackageVersion.install.log`"" + # An upgrade answers 0 even with a bws still running: the installer closes + # nothing, moves the file in use aside and puts the new one in place + # (measured 2026-10-14 on Windows Server 2025, packaging/test-installer.ps1). + # The copy left running can stop with an error, which is why the description + # asks to close the window and every bws first. 3010 and 1641 are the + # successes that ask for a restart or start one. + validExitCodes = @(0, 3010, 1641) +} +Install-ChocolateyPackage @packageArgs diff --git a/packaging/chocolatey/tools/chocolateyuninstall.window.ps1.in b/packaging/chocolatey/tools/chocolateyuninstall.window.ps1.in new file mode 100644 index 00000000..08e749c3 --- /dev/null +++ b/packaging/chocolatey/tools/chocolateyuninstall.window.ps1.in @@ -0,0 +1,34 @@ +# Rendered by packaging/build-feeds.ps1 - do not edit the generated copy. +# +# The installer is found by its UpgradeCode, the product's identity for good, +# and not by its name in Installed apps: a name can match another program, and +# the UpgradeCode finds the copy whichever build of it is installed - this +# package's, or one installed from the release page by hand. Windows Installer +# then takes away everything it put down, the Start menu entry and the PATH +# entry included, and leaves the window's own file in the account's +# AppData\Roaming\BetterWindowsServices alone. +# +# Windows PowerShell 5.1 is what Chocolatey runs package scripts with, so this +# file is ASCII and uses no PowerShell 7 syntax. +$ErrorActionPreference = 'Stop' + +$upgradeCode = '{{MSI_UPGRADE_CODE}}' +$installer = New-Object -ComObject WindowsInstaller.Installer +$products = @($installer.RelatedProducts($upgradeCode)) + +if ($products.Count -eq 0) { + # Removed already, in Settings or with msiexec. Nothing is left to take + # away, and refusing here would leave Chocolatey listing a package that is + # not on the machine. + Write-Warning '{{TITLE}} was not installed any more, so there was nothing to uninstall.' + return +} + +foreach ($productCode in $products) { + # 1605 and 1614: the product went away between the lookup and the uninstall. + Uninstall-ChocolateyPackage ` + -PackageName $env:ChocolateyPackageName ` + -FileType 'msi' ` + -SilentArgs "$productCode /qn /norestart" ` + -ValidExitCodes @(0, 3010, 1605, 1614, 1641) +} diff --git a/packaging/winget/installer.cli.yaml.in b/packaging/winget/installer.cli.yaml.in new file mode 100644 index 00000000..11f3589f --- /dev/null +++ b/packaging/winget/installer.cli.yaml.in @@ -0,0 +1,27 @@ +# Rendered by packaging/build-feeds.ps1 - do not edit the generated copy. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.{{WINGET_SCHEMA}}.schema.json + +PackageIdentifier: {{WINGET_ID}} +PackageVersion: {{VERSION}} +ReleaseDate: {{RELEASE_DATE}} +# Windows 10 1809 and Windows Server 2019, the oldest Windows the README promises. +MinimumOSVersion: 10.0.17763.0 +InstallerType: zip +NestedInstallerType: portable +NestedInstallerFiles: +- RelativeFilePath: {{CLI_ZIP_EXE}} +# WinGet then makes no link in its own links folder and puts the folder bws is +# in on PATH instead (PortableInstaller.cpp in winget-cli, read by the sibling +# project 2026-11-02). That folder holds bws, its licence and its notices and +# nothing else. Without the field WinGet would make a link named bws.exe, and +# the WinGet package of Bitwarden Secrets Manager CLI makes one of that name +# too (PortableCommandAlias: bws, read 2026-11-03). +ArchiveBinariesDependOnPath: true +Commands: +- bws +Installers: +- Architecture: x64 + InstallerUrl: {{CLI_ZIP_URL}} + InstallerSha256: {{CLI_ZIP_SHA256}} +ManifestType: installer +ManifestVersion: {{WINGET_SCHEMA}} diff --git a/packaging/winget/installer.window.yaml.in b/packaging/winget/installer.window.yaml.in new file mode 100644 index 00000000..dc22d0c9 --- /dev/null +++ b/packaging/winget/installer.window.yaml.in @@ -0,0 +1,69 @@ +# Rendered by packaging/build-feeds.ps1 - do not edit the generated copy. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.{{WINGET_SCHEMA}}.schema.json + +PackageIdentifier: {{WINGET_ID}} +PackageVersion: {{VERSION}} +ReleaseDate: {{RELEASE_DATE}} +# Windows 10 1809 and Windows Server 2019, the oldest Windows the README promises. +MinimumOSVersion: 10.0.17763.0 +# Two installers, and their ORDER is load-bearing. +# +# Only the Windows installer puts the window in the Start menu. A portable +# package cannot have a shortcut at all: the manifest schema has no field for +# one, WinGet's portable installer makes none, and microsoft/winget-cli#2299 +# asking for it has been open since 2022. The installer comes FIRST because +# that is what decides a fresh install: recent clients prefer it over portable +# when the user has set no preference (microsoft/winget-cli#6123), and older +# ones keep the first installer that applies when nothing else tells two apart. +# +# The archive comes SECOND, with no scope, for --scope user: a person without +# administrator rights, for whom the installer is out of reach because it is +# for the whole machine. The description says that such an install gets no +# Start menu entry. Keep it once it is published: WinGet upgrades only within +# the kind that is installed, and portable is compatible with nothing but +# portable, so a manifest without it would leave every such install where it +# is for good. +Installers: +- Architecture: x64 + InstallerType: wix + Scope: machine + # Windows Installer asks for elevation itself. + ElevationRequirement: elevatesSelf + InstallerUrl: {{MSI_URL}} + InstallerSha256: {{MSI_SHA256}} + # The ProductCode of THIS release's installer, read out of it - every build + # gets a new one. WinGet finds an installed program for a manifest by the + # installer's ProductCode and never by AppsAndFeaturesEntries + # (SearchSourceUsingManifest in winget-cli, read 2026-11-03). Without it an + # upgrade or an uninstall from a manifest found nothing: "No app found + # matching input criteria" in 5 of 5 cases on Windows Server 2025 that day. + ProductCode: '{{MSI_PRODUCT_CODE}}' + # How WinGet recognises the installed program from one release to the next, + # an install made from the release page by hand included: the UpgradeCode + # never changes, while every build gets a new ProductCode. + AppsAndFeaturesEntries: + - UpgradeCode: '{{MSI_UPGRADE_CODE}}' + # The installer puts its folder on the machine PATH, and both programs are in it. + Commands: + - BetterWindowsServices + - bws +- Architecture: x64 + InstallerType: zip + NestedInstallerType: portable + NestedInstallerFiles: + - RelativeFilePath: {{APP_ZIP_EXE}} + # WinGet then makes no link in its own links folder and puts the folder the + # window is in on PATH instead (PortableInstaller.cpp in winget-cli, read by + # the sibling project 2026-11-02). That folder holds the window, its licence + # and its notices and nothing else, so nothing on PATH can be loaded by + # another program. And the window always runs from its own file: it restarts + # itself as administrator from the path it was started by + # (Environment.ProcessPath in src/Bws.Gui/Elevation.cs), which through a link + # would be the link. + ArchiveBinariesDependOnPath: true + InstallerUrl: {{APP_ZIP_URL}} + InstallerSha256: {{APP_ZIP_SHA256}} + Commands: + - BetterWindowsServices +ManifestType: installer +ManifestVersion: {{WINGET_SCHEMA}} diff --git a/packaging/winget/locale.en-US.yaml.in b/packaging/winget/locale.en-US.yaml.in new file mode 100644 index 00000000..7467dfbf --- /dev/null +++ b/packaging/winget/locale.en-US.yaml.in @@ -0,0 +1,26 @@ +# Rendered by packaging/build-feeds.ps1 - do not edit the generated copy. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.{{WINGET_SCHEMA}}.schema.json + +PackageIdentifier: {{WINGET_ID}} +PackageVersion: {{VERSION}} +PackageLocale: en-US +Publisher: {{PUBLISHER}} +PublisherUrl: {{REPO_URL}} +PublisherSupportUrl: {{ISSUES_URL}} +Author: {{PUBLISHER}} +PackageName: '{{TITLE}}' +PackageUrl: {{PROJECT_URL}} +License: {{LICENSE}} +LicenseUrl: {{LICENSE_URL}} +Copyright: '{{COPYRIGHT}}' +ShortDescription: '{{SHORT_DESCRIPTION}}' +Description: |- + {{DESCRIPTION}} + + {{WINGET_HOW_TO_START}} +Moniker: {{MONIKER}} +Tags: +{{WINGET_TAGS}} +ReleaseNotesUrl: {{RELEASE_NOTES_URL}} +ManifestType: defaultLocale +ManifestVersion: {{WINGET_SCHEMA}} diff --git a/packaging/winget/version.yaml.in b/packaging/winget/version.yaml.in new file mode 100644 index 00000000..222198ab --- /dev/null +++ b/packaging/winget/version.yaml.in @@ -0,0 +1,8 @@ +# Rendered by packaging/build-feeds.ps1 - do not edit the generated copy. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.{{WINGET_SCHEMA}}.schema.json + +PackageIdentifier: {{WINGET_ID}} +PackageVersion: {{VERSION}} +DefaultLocale: en-US +ManifestType: version +ManifestVersion: {{WINGET_SCHEMA}} diff --git a/tests/Bws.Architecture.Tests/FeedGuards.cs b/tests/Bws.Architecture.Tests/FeedGuards.cs new file mode 100644 index 00000000..7dd1e4ea --- /dev/null +++ b/tests/Bws.Architecture.Tests/FeedGuards.cs @@ -0,0 +1,351 @@ +using System.IO.Compression; +using System.Reflection; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Bws.Architecture.Tests; + +/// +/// The WinGet and Chocolatey packages, and the script that renders them (packaging/build-feeds.ps1). +/// +/// What a package does on a machine is not asked here. That is measured by installing it with the +/// feed's own client on the throwaway virtual machine (docs, PROJEKT-FEEDY, 2026-11-03). These guards hold +/// what such a measurement cannot see coming: the shape it rests on, the names that are public for good, +/// the one source of the UpgradeCode, and the refusals of the script. Rendering a real release needs its +/// files signed by our certificate, so every refusal asked here comes before the signatures or is theirs, +/// on release folders built here: this assembly as both programs - its product version is the build's - +/// and a copy of the pwsh.exe that runs the script as the installer, validly signed by somebody else. +/// +/// The same guard as the owner's ChronoMock (crates/cli/tests/feeds.rs, 2026-11-02), less its check that +/// two lists of signed binaries agree: here the signing, the release check and the packages all read one +/// register, packaging/components.json. +/// +public sealed class FeedGuards +{ + /// The installer's stand-in: a file validly signed by somebody else's certificate. + private static readonly Lazy SignedBySomebodyElse = new(() => Path.Combine(PwshScript.Home(), "pwsh.exe")); + + [Fact] + public void The_window_installs_the_installer_first_and_the_archive_second() + { + var (entries, outside) = Installers(Text("packaging/winget/installer.window.yaml.in")); + Assert.True(entries.Count == 2, $"The window manifest lists {entries.Count} installers, and it lists two:{Environment.NewLine}{string.Join(Environment.NewLine, entries)}"); + foreach (var line in new[] { "Architecture: x64", "InstallerType: wix", "Scope: machine", "ElevationRequirement: elevatesSelf", + "InstallerUrl: {{MSI_URL}}", "InstallerSha256: {{MSI_SHA256}}", "ProductCode: '{{MSI_PRODUCT_CODE}}'", "UpgradeCode: '{{MSI_UPGRADE_CODE}}'" }) + { + Assert.True(HasLine(entries[0], line), $"The FIRST installer has no line '{line}' - a fresh install takes the first that applies, and WinGet finds an installed one by its ProductCode:{Environment.NewLine}{entries[0]}"); + } + + // The archive, for --scope user: no scope of its own, and its own folder on PATH rather than a link, + // so the window restarts as administrator from its own file (Environment.ProcessPath). + foreach (var line in new[] { "Architecture: x64", "InstallerType: zip", "NestedInstallerType: portable", "RelativeFilePath: {{APP_ZIP_EXE}}", + "ArchiveBinariesDependOnPath: true", "InstallerUrl: {{APP_ZIP_URL}}", "InstallerSha256: {{APP_ZIP_SHA256}}" }) + { + Assert.True(HasLine(entries[1], line), $"The SECOND installer has no line '{line}':{Environment.NewLine}{entries[1]}"); + } + + Assert.DoesNotContain("Scope:", entries[1], StringComparison.Ordinal); + foreach (var field in new[] { "InstallerType", "Scope", "NestedInstallerType", "ArchiveBinariesDependOnPath" }) + { + Assert.False(outside.Contains(field, StringComparison.Ordinal), $"{field} stands at the top of the window manifest, and it belongs in each installer's entry: the two are of two kinds."); + } + } + + [Fact] + public void The_command_line_is_reached_through_its_own_folder_on_path_and_not_through_a_link_named_bws() + { + // A link would be named bws.exe, as the link of Bitwarden Secrets Manager CLI is (backlog 635). + var manifest = Text("packaging/winget/installer.cli.yaml.in"); + foreach (var line in new[] { "InstallerType: zip", "NestedInstallerType: portable", "RelativeFilePath: {{CLI_ZIP_EXE}}", + "ArchiveBinariesDependOnPath: true", "InstallerUrl: {{CLI_ZIP_URL}}", "InstallerSha256: {{CLI_ZIP_SHA256}}", "bws" }) + { + Assert.True(HasLine(manifest, line), $"The command line manifest has no line '{line}':{Environment.NewLine}{manifest}"); + } + + Assert.Single(Installers(manifest).Entries); + Assert.DoesNotContain("PortableCommandAlias", manifest, StringComparison.Ordinal); + } + + [Fact] + public void The_chocolatey_window_runs_the_installer_and_finds_it_again_by_its_upgrade_code() + { + var install = Text("packaging/chocolatey/tools/chocolateyinstall.window.ps1.in"); + foreach (var line in new[] { "fileType = 'msi'", "url64bit = '{{MSI_URL}}'", "checksum64 = '{{MSI_SHA256}}'", + "checksumType64 = 'sha256'", "validExitCodes = @(0, 3010, 1641)", "Install-ChocolateyPackage @packageArgs" }) + { + Assert.True(install.Contains(line, StringComparison.Ordinal), $"The window's install script has no line saying {line}"); + } + + // The installer makes the Start menu entry and the PATH entry, so the package makes nothing of its own + // that it would have to take away again. + foreach (var absent in new[] { "Install-ChocolateyZipPackage", "Install-ChocolateyShortcut", "Install-BinFile" }) + { + Assert.DoesNotContain(absent, install, StringComparison.Ordinal); + } + + var uninstall = Text("packaging/chocolatey/tools/chocolateyuninstall.window.ps1.in"); + foreach (var line in new[] { "$upgradeCode = '{{MSI_UPGRADE_CODE}}'", ".RelatedProducts($upgradeCode)", "-FileType 'msi'" }) + { + Assert.True(uninstall.Contains(line, StringComparison.Ordinal), $"The window's uninstall script has no line saying {line}"); + } + } + + [Fact] + public void The_chocolatey_command_line_unpacks_the_release_archive_and_makes_nothing_else() + { + var install = Text("packaging/chocolatey/tools/chocolateyinstall.cli.ps1.in"); + foreach (var line in new[] { "Install-ChocolateyZipPackage", "-Url64bit '{{CLI_ZIP_URL}}'", "-Checksum64 '{{CLI_ZIP_SHA256}}'", "-UnzipLocation $toolsDir" }) + { + Assert.True(install.Contains(line, StringComparison.Ordinal), $"The command line's install script has no line saying {line}"); + } + + Assert.DoesNotContain("Install-ChocolateyShortcut", install, StringComparison.Ordinal); + Assert.False(File.Exists(Path.Combine(SourceTree.Root(), "packaging", "chocolatey", "tools", "chocolateyuninstall.cli.ps1.in")), + "The command line package makes nothing outside its own folder, so Chocolatey takes it all away and needs no uninstall script."); + } + + [Fact] + public void The_package_names_are_the_published_ones() + { + // Public for good: a person installs and upgrades by them, and a new one is a new package beside the + // old. The owner chose these on 2026-11-03, and the command line's moniker leaves out bws, which + // Bitwarden Secrets Manager CLI has in WinGet (backlog 635). + var script = Text("packaging/build-feeds.ps1"); + foreach (var (key, name) in new[] { ("WingetId", "DonislawDev.BetterWindowsServices"), ("WingetId", "DonislawDev.BetterWindowsServices.CLI"), + ("ChocoId", "better-windows-services"), ("ChocoId", "better-windows-services-cli"), + ("Moniker", "betterwindowsservices"), ("Moniker", "betterwindowsservices-cli") }) + { + Assert.True(Regex.IsMatch(script, $@"(?m)^\s*{key}\s*=\s*'{Regex.Escape(name)}'\s*$", RegexOptions.None, TimeSpan.FromSeconds(5)), $"build-feeds.ps1 does not say {key} = '{name}'."); + } + } + + [Fact] + public void The_upgrade_code_reaches_the_packages_only_through_the_installer_source() + { + var code = Regex.Match(File.ReadAllText(Path.Combine(SourceTree.Root(), "packaging", "build-msi.ps1")), @"(?m)^\$UpgradeCode = '([0-9A-F-]{36})'", RegexOptions.None, TimeSpan.FromSeconds(5)).Groups[1].Value; + Assert.True(code.Length == 36, "build-msi.ps1 no longer assigns $UpgradeCode, so this guard does not know what to look for."); + var files = new[] { "build-feeds.ps1" }.Select(name => Path.Combine(SourceTree.Root(), "packaging", name)) + .Concat(new[] { "winget", "chocolatey" }.SelectMany(dir => Directory.EnumerateFiles(Path.Combine(SourceTree.Root(), "packaging", dir), "*", SearchOption.AllDirectories))) + .ToArray(); + Assert.True(files.Length >= 9, $"Read {files.Length} files, so this guard is not reading the templates."); + foreach (var file in files) + { + Assert.False(File.ReadAllText(file).Contains(code, StringComparison.OrdinalIgnoreCase), + $"{Path.GetFileName(file)} carries the UpgradeCode itself. It comes from build-msi.ps1 -SourceOnly, so a copy cannot stay behind when the script changes."); + } + } + + [Fact] + public void A_release_that_gets_past_every_other_check_stops_at_the_signatures_and_names_every_file() + { + var dir = Case(); + try + { + var (code, said) = BuildFeeds(Tag(), ReleaseFolder(dir, "none"), Path.Combine(dir, "out")); + Assert.True(code == 1 && said.Contains("not signed by the certificate packaging/codesign.json pins", StringComparison.Ordinal), $"The fixture should stop at the signatures, and exited {code}:{Environment.NewLine}{said}"); + Assert.Contains($"{Installer()} (signed by a certificate with SHA-256 ", said, StringComparison.Ordinal); + foreach (var (_, zip, program) in Programs()) + { + Assert.Contains($"{program} in {zip} (signature status NotSigned)", said, StringComparison.Ordinal); + } + + Assert.Equal(["release"], Left(dir)); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Theory] + [InlineData("remove:SHA256SUMS", "is not there")] + [InlineData("unlist:BetterWindowsServices-win-x64.msi", "has no line for BetterWindowsServices-win-x64.msi")] + [InlineData("remove:bws-cli-win-x64.zip", "bws-cli-win-x64.zip is not in")] + [InlineData("overwrite:BetterWindowsServices-win-x64.zip", "hashes to")] + [InlineData("append:not a sum", "has a line that is not")] + [InlineData("twice", "twice")] + [InlineData("remove-folder", "is not a folder")] + [InlineData("not-an-archive:cli", "bws-cli-win-x64.zip could not be read as an archive")] + [InlineData("without:cli", "bws-cli-win-x64.zip holds no bws/bws.exe")] + [InlineData("as-text:gui", "BetterWindowsServices/BetterWindowsServices.exe in BetterWindowsServices-win-x64.zip is version ''")] + [InlineData("foreign:gui", "BetterWindowsServices/BetterWindowsServices.exe in BetterWindowsServices-win-x64.zip is version '7.")] + public void A_folder_that_is_not_a_published_release_is_refused_and_leaves_nothing(string change, string says) + { + var dir = Case(); + try + { + var release = ReleaseFolder(dir, change); + Change(release, change); + var (code, said) = BuildFeeds(Tag(), release, Path.Combine(dir, "out")); + Assert.True(code == 1 && said.Contains(says, StringComparison.Ordinal), $"{change}: exited {code} and said:{Environment.NewLine}{said}{Environment.NewLine}want exit 1 and \"{says}\""); + Assert.DoesNotContain(Left(dir), name => name != "release"); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Theory] + [InlineData("v0.5.0-rc.1", "is a release candidate")] + [InlineData("0.5.0", "is not a release tag")] + public void A_tag_that_is_no_release_gets_no_packages(string tag, string says) + { + var (code, said) = BuildFeeds(tag, Path.GetTempPath(), Path.Combine(Path.GetTempPath(), "bws-feeds-guard-never-" + Guid.NewGuid().ToString("N"))); + Assert.True(code == 1 && said.Contains(says, StringComparison.Ordinal), $"-Tag {tag} exited {code} and said:{Environment.NewLine}{said}"); + } + + [Fact] + public void The_packages_are_rendered_neither_into_the_repository_nor_into_a_folder_that_exists() + { + var inside = Path.Combine(SourceTree.Root(), "artifacts", "feeds-guard-" + Guid.NewGuid().ToString("N")); + var (code, said) = BuildFeeds(Tag(), Path.GetTempPath(), inside); + Assert.True(code == 1 && said.Contains("is inside the repository", StringComparison.Ordinal), $"A destination in the repository exited {code}:{Environment.NewLine}{said}"); + Assert.False(Directory.Exists(inside), "Nothing was to be written into the repository."); + + var taken = Case(); + try + { + (code, said) = BuildFeeds(Tag(), Path.GetTempPath(), taken); + Assert.True(code == 1 && said.Contains("exists already", StringComparison.Ordinal), $"A destination that exists exited {code}:{Environment.NewLine}{said}"); + } + finally + { + Directory.Delete(taken, recursive: true); + } + } + + /// + /// Builds a release folder that gets as far as the signatures: the installer as a copy of pwsh.exe, each + /// archive holding its program as this assembly plus its licence, and a SHA256SUMS listing the three as + /// they are. The change names one thing the folder holds otherwise: an archive that is not one, an + /// archive without its program, a program of no version, or a program of another release. + /// + private static string ReleaseFolder(string dir, string change) + { + var release = Directory.CreateDirectory(Path.Combine(dir, "release")).FullName; + File.Copy(SignedBySomebodyElse.Value, Path.Combine(release, Installer())); + foreach (var (half, zip, program) in Programs()) + { + var path = Path.Combine(release, zip); + if (change == "not-an-archive:" + half) + { + File.WriteAllText(path, "stand-in for " + zip); + continue; + } + + var source = change == "foreign:" + half ? SignedBySomebodyElse.Value : change == "as-text:" + half ? null : typeof(FeedGuards).Assembly.Location; + var licence = (program[..program.IndexOf('/', StringComparison.Ordinal)] + "/LICENSE", (string?)null); + WriteArchive(path, change == "without:" + half ? [licence] : [(program, source), licence]); + } + + var names = new[] { Installer() }.Concat(Programs().Select(p => p.Zip)); + File.WriteAllText(Path.Combine(release, "SHA256SUMS"), string.Concat(names.Select(name => $"{Sha256(Path.Combine(release, name))} {name}\n"))); + return release; + } + + /// The changes made to a release folder once it is built, each one thing a published one never is. + private static void Change(string release, string change) + { + var sums = Path.Combine(release, "SHA256SUMS"); + var colon = change.IndexOf(':', StringComparison.Ordinal); + var (kind, what) = colon < 0 ? (change, "") : (change[..colon], change[(colon + 1)..]); + switch (kind) + { + case "remove": File.Delete(Path.Combine(release, what)); break; + case "unlist": File.WriteAllLines(sums, File.ReadAllLines(sums).Where(line => !line.EndsWith(what, StringComparison.Ordinal))); break; + case "overwrite": File.WriteAllText(Path.Combine(release, what), "other bytes"); break; + case "append": File.AppendAllText(sums, what + "\n"); break; + case "twice": File.AppendAllText(sums, File.ReadAllLines(sums)[0] + "\n"); break; + case "remove-folder": Directory.Delete(release, recursive: true); break; + } + } + + /// An archive of the named entries, each a copy of its source file, or a line of text when it has none. + private static void WriteArchive(string path, (string Name, string? Source)[] entries) + { + using var stream = File.Create(path); + using var archive = new ZipArchive(stream, ZipArchiveMode.Create); + foreach (var (name, source) in entries) + { + Pack(archive, name, source); + } + } + + private static void Pack(ZipArchive archive, string name, string? source) + { + if (source is not null) + { + archive.CreateEntryFromFile(source, name, CompressionLevel.NoCompression); + return; + } + + using var writer = new StreamWriter(archive.CreateEntry(name).Open()); + writer.Write("stand-in for " + name); + } + + /// Each half of the release as its key in the register, its archive and its program in it. + private static (string Half, string Zip, string Program)[] Programs() + { + var packages = Register().GetProperty("packages"); + return new[] { "gui", "cli" }.Select(half => + { + var package = packages.GetProperty(half); + return (half, package.GetProperty("zip").GetString()!, package.GetProperty("folder").GetString() + "/" + package.GetProperty("executable").GetString()); + }).ToArray(); + } + + private static string Installer() => Register().GetProperty("installer").GetProperty("file").GetString()!; + + private static JsonElement Register() => + JsonDocument.Parse(File.ReadAllText(Path.Combine(SourceTree.Root(), "packaging", "components.json"))).RootElement; + + /// The tag of the version this build carries, which the release commit dates in CHANGELOG.md. + private static string Tag() => + "v" + typeof(FeedGuards).Assembly.GetCustomAttribute()!.InformationalVersion.Split('+')[0]; + + private static (int Code, string Said) BuildFeeds(string tag, string release, string outDir) => + PwshScript.Run("packaging/build-feeds.ps1", TimeSpan.FromSeconds(120), "-Tag", tag, "-Release", release, "-OutDir", outDir); + + private static string Case() => Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "bws-feeds-guard-" + Guid.NewGuid().ToString("N"))).FullName; + + private static string[] Left(string dir) => Directory.EnumerateFileSystemEntries(dir).Select(Path.GetFileName).OfType().ToArray(); + + private static string Sha256(string path) => Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(path))); + + /// + /// The entries of the top-level Installers list of a WinGet manifest, each as its own lines, and the lines + /// outside the list. Enough YAML for the files this project writes, not a parser. + /// + private static (List Entries, string Outside) Installers(string manifest) + { + var lines = manifest.Split('\n').Select(line => line.TrimEnd('\r')).ToArray(); + var start = Array.IndexOf(lines, "Installers:"); + Assert.True(start >= 0, $"The manifest has no Installers list:{Environment.NewLine}{manifest}"); + var end = Array.FindIndex(lines, start + 1, line => line.Length > 0 && line[0] != ' ' && line[0] != '-'); + end = end < 0 ? lines.Length : end; + + // An entry starts with a dash at the start of its line, and every line under it belongs to it. + var entries = new List(); + foreach (var line in lines[(start + 1)..end]) + { + if (line.StartsWith("- ", StringComparison.Ordinal) || entries.Count == 0) + { + entries.Add(line); + continue; + } + + entries[^1] += "\n" + line; + } + + return (entries, string.Join('\n', lines[..start].Concat(lines[end..]))); + } + + private static bool HasLine(string text, string wanted) => + text.Split('\n').Any(line => line.Trim().TrimStart('-').Trim() == wanted); + + /// A file of the repository with its whole-line comments taken out, so a word in the explanation of a line never counts as the line. + private static string Text(string relative) => + string.Join('\n', File.ReadAllLines(Path.Combine(SourceTree.Root(), relative.Replace('/', Path.DirectorySeparatorChar))).Where(line => !line.TrimStart().StartsWith('#'))); +} diff --git a/tests/Bws.Architecture.Tests/InstallerGuards.cs b/tests/Bws.Architecture.Tests/InstallerGuards.cs index fe3fb2ff..14086b94 100644 --- a/tests/Bws.Architecture.Tests/InstallerGuards.cs +++ b/tests/Bws.Architecture.Tests/InstallerGuards.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Text.Json; using System.Xml.Linq; @@ -231,34 +230,6 @@ private static string Text(string relative) } /// build-msi.ps1 as its own process, the way the signing calls it, with what it said on both channels. - private static (int Code, string Said) BuildMsi(params string[] arguments) - { - var start = new ProcessStartInfo("pwsh") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; - foreach (var argument in new[] { "-NoProfile", "-File", Path.Combine(SourceTree.Root(), "packaging", "build-msi.ps1") }.Concat(arguments)) - { - start.ArgumentList.Add(argument); - } - - // Both channels are read as they arrive, so the limit below is the one that holds: a read to the - // end would wait for pwsh to close its output, and a pwsh that hangs never would - the test run - // would hang with it (review of #68). Each channel has its own builder, and the events of one - // channel come one after another. - var output = new System.Text.StringBuilder(); - var error = new System.Text.StringBuilder(); - using var process = new Process { StartInfo = start }; - process.OutputDataReceived += (_, line) => output.AppendLine(line.Data); - process.ErrorDataReceived += (_, line) => error.AppendLine(line.Data); - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - if (!process.WaitForExit(TimeSpan.FromSeconds(60))) - { - process.Kill(entireProcessTree: true); - Assert.Fail($"build-msi.ps1 did not finish in 60 s and was ended. It had said:{Environment.NewLine}{output}{error}"); - } - - // Without a limit once it has exited: this is what waits for the last line of both channels. - process.WaitForExit(); - return (process.ExitCode, output.ToString() + error); - } + private static (int Code, string Said) BuildMsi(params string[] arguments) => + PwshScript.Run("packaging/build-msi.ps1", TimeSpan.FromSeconds(60), arguments); } diff --git a/tests/Bws.Architecture.Tests/PwshScript.cs b/tests/Bws.Architecture.Tests/PwshScript.cs new file mode 100644 index 00000000..84cd419b --- /dev/null +++ b/tests/Bws.Architecture.Tests/PwshScript.cs @@ -0,0 +1,61 @@ +using System.Diagnostics; +using System.Text; + +namespace Bws.Architecture.Tests; + +/// +/// A script of the repository run as its own process of PowerShell 7, the way the release ritual runs it, +/// with what it said on both channels. One copy for the guards that ask packaging scripts for their +/// refusals - InstallerGuards asks build-msi.ps1 and FeedGuards asks build-feeds.ps1. +/// +internal static class PwshScript +{ + internal static (int Code, string Said) Run(string script, TimeSpan limit, params string[] arguments) + { + var path = Path.Combine(SourceTree.Root(), script.Replace('/', Path.DirectorySeparatorChar)); + return Start(new[] { "-NoProfile", "-NonInteractive", "-File", path }.Concat(arguments), limit, script); + } + + /// + /// The folder of the pwsh.exe that runs these scripts. The pwsh found on PATH can be an execution alias + /// (on the owner's machine it is the one in WindowsApps), and an alias copies as nothing, so a guard that + /// needs the file asks pwsh where it lives. + /// + internal static string Home() + { + var (code, said) = Start(["-NoProfile", "-NonInteractive", "-Command", "$PSHOME"], TimeSpan.FromSeconds(30), "pwsh -Command $PSHOME"); + Assert.True(code == 0 && Directory.Exists(said.Trim()), $"pwsh did not name the folder it runs from (exit {code}):{Environment.NewLine}{said}"); + return said.Trim(); + } + + private static (int Code, string Said) Start(IEnumerable arguments, TimeSpan limit, string what) + { + var start = new ProcessStartInfo("pwsh") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; + foreach (var argument in arguments) + { + start.ArgumentList.Add(argument); + } + + // Both channels are read as they arrive, so the limit below is the one that holds: a read to the + // end would wait for pwsh to close its output, and a pwsh that hangs never would - the test run + // would hang with it (review of #68). Each channel has its own builder, and the events of one + // channel come one after another. + var output = new StringBuilder(); + var error = new StringBuilder(); + using var process = new Process { StartInfo = start }; + process.OutputDataReceived += (_, line) => output.AppendLine(line.Data); + process.ErrorDataReceived += (_, line) => error.AppendLine(line.Data); + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + if (!process.WaitForExit(limit)) + { + process.Kill(entireProcessTree: true); + Assert.Fail($"{what} did not finish in {limit.TotalSeconds} s and was ended. It had said:{Environment.NewLine}{output}{error}"); + } + + // Without a limit once it has exited: this is what waits for the last line of both channels. + process.WaitForExit(); + return (process.ExitCode, output.ToString() + error); + } +} diff --git a/tests/mutation/entries.ps1 b/tests/mutation/entries.ps1 index e2ee8c35..aaf38f5a 100644 --- a/tests/mutation/entries.ps1 +++ b/tests/mutation/entries.ps1 @@ -13142,6 +13142,110 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : To = 'if ($false) {' Test = 'SigningRitualGuards.An_asset_without_a_digest_is_downloaded_and_hashed' } + @{ + Name = 'feeds: the window installs the installer first' + File = 'packaging\winget\installer.window.yaml.in' + From = ' InstallerType: wix' + To = ' InstallerType: msi' + Test = 'FeedGuards.The_window_installs_the_installer_first_and_the_archive_second' + } + @{ + Name = 'feeds: WinGet finds the installed installer by its ProductCode' + File = 'packaging\winget\installer.window.yaml.in' + From = " ProductCode: '{{MSI_PRODUCT_CODE}}'" + To = " Tags: '{{MSI_PRODUCT_CODE}}'" + Test = 'FeedGuards.The_window_installs_the_installer_first_and_the_archive_second' + } + @{ Name = 'feeds: the window archive puts its own folder on PATH, not a link' + File = 'packaging\winget\installer.window.yaml.in' + From = ' ArchiveBinariesDependOnPath: true' + To = ' ArchiveBinariesDependOnPath: false' + Test = 'FeedGuards.The_window_installs_the_installer_first_and_the_archive_second' + } + @{ + Name = 'feeds: the command line puts its own folder on PATH, not a link named bws' + File = 'packaging\winget\installer.cli.yaml.in' + From = 'ArchiveBinariesDependOnPath: true' + To = 'ArchiveBinariesDependOnPath: false' + Test = 'FeedGuards.The_command_line_is_reached_through_its_own_folder_on_path_and_not_through_a_link_named_bws' + } + @{ + Name = 'feeds: the chocolatey window finds the installer by its UpgradeCode' + File = 'packaging\chocolatey\tools\chocolateyuninstall.window.ps1.in' + From = '$installer.RelatedProducts($upgradeCode)' + To = '$installer.RelatedProducts(''{00000000-0000-0000-0000-000000000000}'')' + Test = 'FeedGuards.The_chocolatey_window_runs_the_installer_and_finds_it_again_by_its_upgrade_code' + } + @{ + Name = 'feeds: an upgrade that owes a restart is a success in chocolatey' + File = 'packaging\chocolatey\tools\chocolateyinstall.window.ps1.in' + From = 'validExitCodes = @(0, 3010, 1641)' + To = 'validExitCodes = @(0)' + Test = 'FeedGuards.The_chocolatey_window_runs_the_installer_and_finds_it_again_by_its_upgrade_code' + } + @{ + Name = 'feeds: the command line package is the published one' + File = 'packaging\build-feeds.ps1' + From = 'WingetId = ''DonislawDev.BetterWindowsServices.CLI''' + To = 'WingetId = ''DonislawDev.BetterWindowsServices.Cli''' + Test = 'FeedGuards.The_package_names_are_the_published_ones' + } + @{ + Name = 'feeds: the command line moniker leaves out bws' + File = 'packaging\build-feeds.ps1' + From = 'Moniker = ''betterwindowsservices-cli''' + To = 'Moniker = ''bws''' + Test = 'FeedGuards.The_package_names_are_the_published_ones' + } + @{ + Name = 'feeds: a release candidate gets no packages' + File = 'packaging\build-feeds.ps1' + From = 'if ($releaseTag.Contains(''-'')) {' + To = 'if ($false) {' + Test = 'FeedGuards.A_tag_that_is_no_release_gets_no_packages' + } + @{ + Name = 'feeds: every file is hashed against its line in the sums' + File = 'packaging\build-feeds.ps1' + From = 'if ($hash -ne $sums[$name]) {' + To = 'if ($false) {' + Test = 'FeedGuards.A_folder_that_is_not_a_published_release_is_refused_and_leaves_nothing' + } + @{ + Name = 'feeds: a name the sums list twice is refused' + File = 'packaging\build-feeds.ps1' + From = 'if ($sums.ContainsKey($name)) {' + To = 'if ($false) {' + Test = 'FeedGuards.A_folder_that_is_not_a_published_release_is_refused_and_leaves_nothing' + } + @{ + Name = 'feeds: every program is at the version of the tag' + File = 'packaging\build-feeds.ps1' + From = 'if (($found -split ''\+'', 2)[0] -ne $version) {' + To = 'if ($false) {' + Test = 'FeedGuards.A_folder_that_is_not_a_published_release_is_refused_and_leaves_nothing' + } + @{ + Name = 'feeds: the installer is held to the pinned certificate' + File = 'packaging\build-feeds.ps1' + From = '$fault = Get-SignatureFault $msiPath $pin' + To = '$fault = ''''' + Test = 'FeedGuards.A_release_that_gets_past_every_other_check_stops_at_the_signatures_and_names_every_file' + } + @{ + Name = 'feeds: nothing is rendered into the repository' + File = 'packaging\build-feeds.ps1' + From = 'if ($real -eq $repo -or $real.StartsWith(' + To = 'if ($false -and $real.StartsWith(' + Test = 'FeedGuards.The_packages_are_rendered_neither_into_the_repository_nor_into_a_folder_that_exists' + } + @{ + Name = 'feeds: the UpgradeCode reaches the window manifest only from the installer source' + File = 'packaging\winget\installer.window.yaml.in' + From = ' - UpgradeCode: ''{{MSI_UPGRADE_CODE}}''' + To = ' - UpgradeCode: ''{FD038501-7A2F-49C9-A587-E8DF3EFB9718}''' + Test = 'FeedGuards.The_upgrade_code_reaches_the_packages_only_through_the_installer_source' + } @{ Name = 'installer phase D: the published page is asked for the installer' File = '.github\workflows\verify-release.yml' From ce04b1b38d78d3bd7eded12350dbb6affb19097e Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 3 Nov 2026 19:38:52 +0100 Subject: [PATCH 2/3] Bound what a release archive may write, and hold the package text to the program The renderer copied each program out of its archive before asking its signature, and with no limit. The .NET reader stops a compressed entry at the size its header declares but reads a stored one to the end of its data, so the copy now refuses a declared size past 1 GiB and counts the bytes it writes, refusing an entry that holds more than it declares. Three sentences of the package descriptions said more than the program does: - what should be up and is not is mismatch:stopped, the question the window asks, since start:auto !status:running also answers per-user templates and services a trigger starts, which are stopped by design - --json is taken by the commands that read or change a service, and license, tool and tool fix refuse it - the tool reaches no network unless --follow-network is passed FeedGuards binds each WinGet id, Chocolatey id and moniker to the block of its own package, so two packages with their names swapped no longer pass, and gains two archives whose headers lie about the size of their program. The Chocolatey uninstall script releases its Windows Installer objects on every path. SigningRitualGuards runs pwsh through PwshScript, which leaves one copy of the process runner. Three mutation entries show the new checks can fail. Co-Authored-By: Claude Opus 5.5 --- packaging/build-feeds.ps1 | 41 +++++++++-- .../tools/chocolateyuninstall.window.ps1.in | 13 +++- tests/Bws.Architecture.Tests/FeedGuards.cs | 69 +++++++++++++++++-- tests/Bws.Architecture.Tests/PwshScript.cs | 8 ++- .../SigningRitualGuards.cs | 31 +-------- tests/mutation/entries.ps1 | 24 ++++++- 6 files changed, 140 insertions(+), 46 deletions(-) diff --git a/packaging/build-feeds.ps1 b/packaging/build-feeds.ps1 index a248b4ea..8835f48a 100644 --- a/packaging/build-feeds.ps1 +++ b/packaging/build-feeds.ps1 @@ -75,6 +75,11 @@ $chocoTemplates = Join-Path $PSScriptRoot 'chocolatey' # on a pending pull request and asked for 1.28.0, and 1.28.0 defines every field used here. $WingetSchema = '1.28.0' +# The most bytes one program may take out of its archive. The biggest is the window, 172,065,200 bytes in +# v0.5.0 (read 2026-11-03), so this leaves it room to grow for years and still stops an archive that would +# fill the disk before its signature is asked. +$ProgramCap = 1GB + # A refusal is one plain line on stderr and exit 1, the way build-msi.ps1 refuses. `exit` still runs the # `finally` that removes the working folders. function Stop-Render([string] $message) { @@ -203,13 +208,32 @@ function Expand-Checked([string] $zip, [string] $program, [string] $version, [st } $entry = $archive.GetEntry($program) if ($null -eq $entry) { Stop-Render "$zipName holds no $program, which its package starts and the release signs" } + if ($entry.Length -gt $ProgramCap) { Stop-Render "$zipName declares $program at $($entry.Length) bytes, and no program of ours comes near $ProgramCap" } $copy = Join-Path $scratch ([guid]::NewGuid().ToString('N') + [System.IO.Path]::GetExtension($program)) + # Copied by hand and counted, not by ExtractToFile: the .NET reader stops a compressed entry at the + # size its header declares, but reads a stored one to the end of its data whatever the header says + # (tools/feeds/zip-bound-probe.ps1 on .NET 10.0.14, 2026-11-03). So the count is what bounds the + # bytes written, and an archive whose entry holds more than it declares is refused as it is. + $from = $null + $into = $null try { - [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $copy) + $from = $entry.Open() + $into = [System.IO.File]::Open($copy, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) + $buffer = [byte[]]::new(1MB) + $written = [long]0 + while (($read = $from.Read($buffer, 0, $buffer.Length)) -gt 0) { + $written += $read + if ($written -gt $entry.Length) { Stop-Render "$zipName holds more of $program than the $($entry.Length) bytes it declares" } + $into.Write($buffer, 0, $read) + } } catch { Stop-Render "$program could not be read out of $zipName`: $(Get-Reason $_)" } + finally { + if ($null -ne $into) { $into.Dispose() } + if ($null -ne $from) { $from.Dispose() } + } } finally { if ($null -ne $archive) { $archive.Dispose() } @@ -429,11 +453,16 @@ try { ICON_URL = "https://cdn.jsdelivr.net/gh/$repoPath@$Tag/$iconPath" } - # Every sentence below is one the README makes about the program, and none is new here. + # Every sentence below is one the README makes about the program, held to what the program does. Three + # are narrower than the README's short list: what should be up and is not is asked the way the window + # asks it, mismatch:stopped, because start:auto !status:running also answers per-user templates and + # services a trigger starts, which are stopped where they are meant to be (7 against 1 on 2026-09-23); + # --json is taken by the commands that read or change a service, and license, tool and tool fix refuse + # it with exit 2 (bws 0.5.0, 2026-11-03); and --follow-network is the one way off the machine. $audit = 'It freezes every service into a snapshot file and later says what changed since, configuration ' + 'apart from what merely started or stopped, and reads from the System log who changed a start type or ' + 'installed a service.' - $offline = 'It works on the local machine and never connects to the internet.' + $offline = 'It works on the local machine and reaches no network unless you pass --follow-network.' $elevated = 'Looking needs no administrator rights, and changing a service does.' $close = 'Close the window and every bws that is running before an upgrade.' # Owner's decision of 2026-11-03: the command keeps its name, and the packages say so. @@ -452,7 +481,7 @@ try { Tags = $tags + @('gui') Body = ("$($identity.Name) is services.msc rebuilt for people who administer Windows machines - a " + 'window and a command line over one engine. Ask the machine a question instead of scrolling: ' + - 'start:auto !status:running is what should be up and is not. Every stop, start, restart and start ' + + 'mismatch:stopped is what should be up and is not. Every stop, start, restart and start ' + 'type change is shown as a plan, with the services it takes down with it, before anything runs.' + "`n`n$audit It shows what services.msc hides - who signed the binary, triggers, privileges, the " + 'security descriptor, the process and its memory - and says when it could not read a field instead ' + @@ -487,8 +516,8 @@ try { Body = ("bws is the command line of $($identity.Name), for scripts, scheduled tasks and CI. bws list " + '--query asks the machine a question in the language of the search box of the window. Every stop, ' + 'start, restart and start type change is a plan first and can be previewed with --dry-run.' + - "`n`n$audit Every command takes --json, every ending has an exit code of its own, and a diff that " + - "finds drift ends with 5. $offline") + "`n`n$audit Every command that reads or changes a service takes --json, every ending has an exit " + + "code of its own, and a diff that finds drift ends with 5. $offline") WingetHow = ("Type bws --help in a new terminal to see the commands. $elevated The window, with a Start " + "menu entry and the same command line, is the package DonislawDev.BetterWindowsServices. $sameName") ChocoHow = ("Type bws --help in a new terminal to see the commands. $elevated The window, with a Start " + diff --git a/packaging/chocolatey/tools/chocolateyuninstall.window.ps1.in b/packaging/chocolatey/tools/chocolateyuninstall.window.ps1.in index 08e749c3..fa081c57 100644 --- a/packaging/chocolatey/tools/chocolateyuninstall.window.ps1.in +++ b/packaging/chocolatey/tools/chocolateyuninstall.window.ps1.in @@ -14,7 +14,18 @@ $ErrorActionPreference = 'Stop' $upgradeCode = '{{MSI_UPGRADE_CODE}}' $installer = New-Object -ComObject WindowsInstaller.Installer -$products = @($installer.RelatedProducts($upgradeCode)) +$related = $null +try { + $related = $installer.RelatedProducts($upgradeCode) + # Copied into strings, so nothing below holds a COM object. + $products = @(foreach ($code in $related) { [string]$code }) +} +finally { + # Released on every path, the way build-feeds.ps1 releases what it opens. + foreach ($held in @($related, $installer)) { + if ($null -ne $held) { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($held) } + } +} if ($products.Count -eq 0) { # Removed already, in Settings or with msiexec. Nothing is left to take diff --git a/tests/Bws.Architecture.Tests/FeedGuards.cs b/tests/Bws.Architecture.Tests/FeedGuards.cs index 7dd1e4ea..b6e1c0e0 100644 --- a/tests/Bws.Architecture.Tests/FeedGuards.cs +++ b/tests/Bws.Architecture.Tests/FeedGuards.cs @@ -1,6 +1,8 @@ +using System.Buffers.Binary; using System.IO.Compression; using System.Reflection; using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Text.RegularExpressions; @@ -26,6 +28,17 @@ public sealed class FeedGuards /// The installer's stand-in: a file validly signed by somebody else's certificate. private static readonly Lazy SignedBySomebodyElse = new(() => Path.Combine(PwshScript.Home(), "pwsh.exe")); + /// + /// The size an archive's headers declare for its program, where a change has it lie: far past what the + /// script copies out of an archive, or less than the program it holds - stored, as these archives store + /// it, so the .NET reader returns every byte whatever the header says. + /// + private static readonly Dictionary Declared = new(StringComparer.Ordinal) + { + ["declares-huge:cli"] = 0xF0000000, + ["declares-less:cli"] = 1000, + }; + [Fact] public void The_window_installs_the_installer_first_and_the_archive_second() { @@ -111,12 +124,15 @@ public void The_package_names_are_the_published_ones() // Public for good: a person installs and upgrades by them, and a new one is a new package beside the // old. The owner chose these on 2026-11-03, and the command line's moniker leaves out bws, which // Bitwarden Secrets Manager CLI has in WinGet (backlog 635). + // Each name inside the block of its own package, from its Kind line to the next one: the two blocks + // with their names swapped would still hold all six, and the window would render as the command line. var script = Text("packaging/build-feeds.ps1"); - foreach (var (key, name) in new[] { ("WingetId", "DonislawDev.BetterWindowsServices"), ("WingetId", "DonislawDev.BetterWindowsServices.CLI"), - ("ChocoId", "better-windows-services"), ("ChocoId", "better-windows-services-cli"), - ("Moniker", "betterwindowsservices"), ("Moniker", "betterwindowsservices-cli") }) + foreach (var (kind, key, name) in new[] { ("window", "WingetId", "DonislawDev.BetterWindowsServices"), ("cli", "WingetId", "DonislawDev.BetterWindowsServices.CLI"), + ("window", "ChocoId", "better-windows-services"), ("cli", "ChocoId", "better-windows-services-cli"), + ("window", "Moniker", "betterwindowsservices"), ("cli", "Moniker", "betterwindowsservices-cli") }) { - Assert.True(Regex.IsMatch(script, $@"(?m)^\s*{key}\s*=\s*'{Regex.Escape(name)}'\s*$", RegexOptions.None, TimeSpan.FromSeconds(5)), $"build-feeds.ps1 does not say {key} = '{name}'."); + var pattern = $@"(?m)^\s*Kind\s*=\s*'{kind}'\s*$(?:(?!^\s*Kind\s*=)[\s\S])*?^\s*{key}\s*=\s*'{Regex.Escape(name)}'\s*$"; + Assert.True(Regex.IsMatch(script, pattern, RegexOptions.None, TimeSpan.FromSeconds(5)), $"The {kind} package of build-feeds.ps1 does not say {key} = '{name}'."); } } @@ -170,6 +186,8 @@ public void A_release_that_gets_past_every_other_check_stops_at_the_signatures_a [InlineData("without:cli", "bws-cli-win-x64.zip holds no bws/bws.exe")] [InlineData("as-text:gui", "BetterWindowsServices/BetterWindowsServices.exe in BetterWindowsServices-win-x64.zip is version ''")] [InlineData("foreign:gui", "BetterWindowsServices/BetterWindowsServices.exe in BetterWindowsServices-win-x64.zip is version '7.")] + [InlineData("declares-huge:cli", "bws-cli-win-x64.zip declares bws/bws.exe at 4026531840 bytes")] + [InlineData("declares-less:cli", "bws-cli-win-x64.zip holds more of bws/bws.exe than the 1000 bytes it declares")] public void A_folder_that_is_not_a_published_release_is_refused_and_leaves_nothing(string change, string says) { var dir = Case(); @@ -220,7 +238,8 @@ public void The_packages_are_rendered_neither_into_the_repository_nor_into_a_fol /// Builds a release folder that gets as far as the signatures: the installer as a copy of pwsh.exe, each /// archive holding its program as this assembly plus its licence, and a SHA256SUMS listing the three as /// they are. The change names one thing the folder holds otherwise: an archive that is not one, an - /// archive without its program, a program of no version, or a program of another release. + /// archive without its program, a program of no version, a program of another release, or an archive + /// whose headers declare a size its program does not have. /// private static string ReleaseFolder(string dir, string change) { @@ -238,6 +257,10 @@ private static string ReleaseFolder(string dir, string change) var source = change == "foreign:" + half ? SignedBySomebodyElse.Value : change == "as-text:" + half ? null : typeof(FeedGuards).Assembly.Location; var licence = (program[..program.IndexOf('/', StringComparison.Ordinal)] + "/LICENSE", (string?)null); WriteArchive(path, change == "without:" + half ? [licence] : [(program, source), licence]); + if (Declared.TryGetValue(change, out var declared) && change.EndsWith(":" + half, StringComparison.Ordinal)) + { + Declare(path, program, declared); + } } var names = new[] { Installer() }.Concat(Programs().Select(p => p.Zip)); @@ -273,6 +296,35 @@ private static void WriteArchive(string path, (string Name, string? Source)[] en } } + /// + /// Sets the size the local and the central header of an archive declare for one entry, and leaves its + /// bytes as they are - an archive that lies about what it holds, which only a hand makes. + /// + private static void Declare(string path, string name, uint size) + { + var bytes = File.ReadAllBytes(path); + var named = Encoding.UTF8.GetBytes(name); + var patched = 0; + + // Each header as its signature, where it keeps the uncompressed size, the name's length and the name. + foreach (var (signature, sizeAt, lengthAt, nameAt) in new[] { (0x04034b50u, 22, 26, 30), (0x02014b50u, 24, 28, 46) }) + { + for (var at = 0; at + nameAt + named.Length <= bytes.Length; at++) + { + var header = bytes.AsSpan(at); + if (BinaryPrimitives.ReadUInt32LittleEndian(header) == signature && BinaryPrimitives.ReadUInt16LittleEndian(header[lengthAt..]) == named.Length + && header.Slice(nameAt, named.Length).SequenceEqual(named)) + { + BinaryPrimitives.WriteUInt32LittleEndian(header[sizeAt..], size); + patched++; + } + } + } + + Assert.True(patched == 2, $"Found {patched} headers naming {name} in {Path.GetFileName(path)}, and an archive holding it once has two."); + File.WriteAllBytes(path, bytes); + } + private static void Pack(ZipArchive archive, string name, string? source) { if (source is not null) @@ -298,8 +350,11 @@ private static (string Half, string Zip, string Program)[] Programs() private static string Installer() => Register().GetProperty("installer").GetProperty("file").GetString()!; - private static JsonElement Register() => - JsonDocument.Parse(File.ReadAllText(Path.Combine(SourceTree.Root(), "packaging", "components.json"))).RootElement; + private static JsonElement Register() + { + using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(SourceTree.Root(), "packaging", "components.json"))); + return document.RootElement.Clone(); + } /// The tag of the version this build carries, which the release commit dates in CHANGELOG.md. private static string Tag() => diff --git a/tests/Bws.Architecture.Tests/PwshScript.cs b/tests/Bws.Architecture.Tests/PwshScript.cs index 84cd419b..83a6e268 100644 --- a/tests/Bws.Architecture.Tests/PwshScript.cs +++ b/tests/Bws.Architecture.Tests/PwshScript.cs @@ -5,8 +5,9 @@ namespace Bws.Architecture.Tests; /// /// A script of the repository run as its own process of PowerShell 7, the way the release ritual runs it, -/// with what it said on both channels. One copy for the guards that ask packaging scripts for their -/// refusals - InstallerGuards asks build-msi.ps1 and FeedGuards asks build-feeds.ps1. +/// with what it said on both channels. One copy for every guard that runs pwsh: InstallerGuards asks +/// build-msi.ps1 and FeedGuards asks build-feeds.ps1 for their refusals, and SigningRitualGuards runs its +/// driver of sign-release.ps1 through . /// internal static class PwshScript { @@ -28,7 +29,8 @@ internal static string Home() return said.Trim(); } - private static (int Code, string Said) Start(IEnumerable arguments, TimeSpan limit, string what) + /// pwsh with these arguments as they are, ended after the limit, and named in the failure as . + internal static (int Code, string Said) Start(IEnumerable arguments, TimeSpan limit, string what) { var start = new ProcessStartInfo("pwsh") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; foreach (var argument in arguments) diff --git a/tests/Bws.Architecture.Tests/SigningRitualGuards.cs b/tests/Bws.Architecture.Tests/SigningRitualGuards.cs index f406a1f8..ddfdeee6 100644 --- a/tests/Bws.Architecture.Tests/SigningRitualGuards.cs +++ b/tests/Bws.Architecture.Tests/SigningRitualGuards.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -209,33 +208,9 @@ private static string Run(string call, string scenario) } /// The driver as its own process, with what it said on both channels. - private static string Execute(string folder, string call) - { - var start = new ProcessStartInfo("pwsh") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; - foreach (var argument in new[] { "-NoProfile", "-File", Path.Combine(folder, "driver.ps1"), "-Script", Path.Combine(SourceTree.Root(), Script.Replace('/', Path.DirectorySeparatorChar)), "-Fake", folder, "-Call", call }) - { - start.ArgumentList.Add(argument); - } - - // Both channels read as they arrive and a limit on the wait, for the reason InstallerGuards gives - // beside the same lines: a pwsh that hangs must not hang the test run with it. - var output = new StringBuilder(); - var error = new StringBuilder(); - using var process = new Process { StartInfo = start }; - process.OutputDataReceived += (_, line) => output.AppendLine(line.Data); - process.ErrorDataReceived += (_, line) => error.AppendLine(line.Data); - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - if (!process.WaitForExit(TimeSpan.FromSeconds(90))) - { - process.Kill(entireProcessTree: true); - Assert.Fail($"The driver did not finish in 90 s and was ended. It had said:{Environment.NewLine}{output}{error}"); - } - - process.WaitForExit(); - return output.ToString() + error; - } + private static string Execute(string folder, string call) => + PwshScript.Start(["-NoProfile", "-File", Path.Combine(folder, "driver.ps1"), "-Script", Path.Combine(SourceTree.Root(), Script.Replace('/', Path.DirectorySeparatorChar)), "-Fake", folder, "-Call", call], + TimeSpan.FromSeconds(90), "The driver").Said; private static string Digest(string text) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text))); diff --git a/tests/mutation/entries.ps1 b/tests/mutation/entries.ps1 index aaf38f5a..b68fcb95 100644 --- a/tests/mutation/entries.ps1 +++ b/tests/mutation/entries.ps1 @@ -13156,7 +13156,8 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : To = " Tags: '{{MSI_PRODUCT_CODE}}'" Test = 'FeedGuards.The_window_installs_the_installer_first_and_the_archive_second' } - @{ Name = 'feeds: the window archive puts its own folder on PATH, not a link' + @{ + Name = 'feeds: the window archive puts its own folder on PATH, not a link' File = 'packaging\winget\installer.window.yaml.in' From = ' ArchiveBinariesDependOnPath: true' To = ' ArchiveBinariesDependOnPath: false' @@ -13246,6 +13247,27 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : To = ' - UpgradeCode: ''{FD038501-7A2F-49C9-A587-E8DF3EFB9718}''' Test = 'FeedGuards.The_upgrade_code_reaches_the_packages_only_through_the_installer_source' } + @{ + Name = 'feeds: an archive that declares a program past the cap is refused before a byte is copied' + File = 'packaging\build-feeds.ps1' + From = 'if ($entry.Length -gt $ProgramCap) {' + To = 'if ($false) {' + Test = 'FeedGuards.A_folder_that_is_not_a_published_release_is_refused_and_leaves_nothing' + } + @{ + Name = 'feeds: the copy out of an archive stops at the size the archive declares' + File = 'packaging\build-feeds.ps1' + From = 'if ($written -gt $entry.Length) {' + To = 'if ($false) {' + Test = 'FeedGuards.A_folder_that_is_not_a_published_release_is_refused_and_leaves_nothing' + } + @{ + Name = 'feeds: the command line names stand in the command line block' + File = 'packaging\build-feeds.ps1' + From = 'Kind = ''cli''' + To = 'Kind = ''window''' + Test = 'FeedGuards.The_package_names_are_the_published_ones' + } @{ Name = 'installer phase D: the published page is asked for the installer' File = '.github\workflows\verify-release.yml' From 69521a2e0e1a21dcbe90a5854bd42738c3c266e6 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 3 Nov 2026 19:53:00 +0100 Subject: [PATCH 3/3] Move the zip fixtures of FeedGuards into a class of their own The header patching added for the archives that lie about their size took FeedGuards.cs to 308 lines of code, the fifth test file within 70% of the 406-line ceiling where four are allowed. The archives FeedGuards writes - from named entries, and with a header that lies about the size of an entry - are the bytes of the zip format rather than rules of a package, so they move unchanged to ReleaseArchive. Co-Authored-By: Claude Opus 5.5 --- tests/Bws.Architecture.Tests/FeedGuards.cs | 59 +--------------- .../Bws.Architecture.Tests/ReleaseArchive.cs | 67 +++++++++++++++++++ 2 files changed, 69 insertions(+), 57 deletions(-) create mode 100644 tests/Bws.Architecture.Tests/ReleaseArchive.cs diff --git a/tests/Bws.Architecture.Tests/FeedGuards.cs b/tests/Bws.Architecture.Tests/FeedGuards.cs index b6e1c0e0..76a485b2 100644 --- a/tests/Bws.Architecture.Tests/FeedGuards.cs +++ b/tests/Bws.Architecture.Tests/FeedGuards.cs @@ -1,8 +1,5 @@ -using System.Buffers.Binary; -using System.IO.Compression; using System.Reflection; using System.Security.Cryptography; -using System.Text; using System.Text.Json; using System.Text.RegularExpressions; @@ -256,10 +253,10 @@ private static string ReleaseFolder(string dir, string change) var source = change == "foreign:" + half ? SignedBySomebodyElse.Value : change == "as-text:" + half ? null : typeof(FeedGuards).Assembly.Location; var licence = (program[..program.IndexOf('/', StringComparison.Ordinal)] + "/LICENSE", (string?)null); - WriteArchive(path, change == "without:" + half ? [licence] : [(program, source), licence]); + ReleaseArchive.Write(path, change == "without:" + half ? [licence] : [(program, source), licence]); if (Declared.TryGetValue(change, out var declared) && change.EndsWith(":" + half, StringComparison.Ordinal)) { - Declare(path, program, declared); + ReleaseArchive.Declare(path, program, declared); } } @@ -285,58 +282,6 @@ private static void Change(string release, string change) } } - /// An archive of the named entries, each a copy of its source file, or a line of text when it has none. - private static void WriteArchive(string path, (string Name, string? Source)[] entries) - { - using var stream = File.Create(path); - using var archive = new ZipArchive(stream, ZipArchiveMode.Create); - foreach (var (name, source) in entries) - { - Pack(archive, name, source); - } - } - - /// - /// Sets the size the local and the central header of an archive declare for one entry, and leaves its - /// bytes as they are - an archive that lies about what it holds, which only a hand makes. - /// - private static void Declare(string path, string name, uint size) - { - var bytes = File.ReadAllBytes(path); - var named = Encoding.UTF8.GetBytes(name); - var patched = 0; - - // Each header as its signature, where it keeps the uncompressed size, the name's length and the name. - foreach (var (signature, sizeAt, lengthAt, nameAt) in new[] { (0x04034b50u, 22, 26, 30), (0x02014b50u, 24, 28, 46) }) - { - for (var at = 0; at + nameAt + named.Length <= bytes.Length; at++) - { - var header = bytes.AsSpan(at); - if (BinaryPrimitives.ReadUInt32LittleEndian(header) == signature && BinaryPrimitives.ReadUInt16LittleEndian(header[lengthAt..]) == named.Length - && header.Slice(nameAt, named.Length).SequenceEqual(named)) - { - BinaryPrimitives.WriteUInt32LittleEndian(header[sizeAt..], size); - patched++; - } - } - } - - Assert.True(patched == 2, $"Found {patched} headers naming {name} in {Path.GetFileName(path)}, and an archive holding it once has two."); - File.WriteAllBytes(path, bytes); - } - - private static void Pack(ZipArchive archive, string name, string? source) - { - if (source is not null) - { - archive.CreateEntryFromFile(source, name, CompressionLevel.NoCompression); - return; - } - - using var writer = new StreamWriter(archive.CreateEntry(name).Open()); - writer.Write("stand-in for " + name); - } - /// Each half of the release as its key in the register, its archive and its program in it. private static (string Half, string Zip, string Program)[] Programs() { diff --git a/tests/Bws.Architecture.Tests/ReleaseArchive.cs b/tests/Bws.Architecture.Tests/ReleaseArchive.cs new file mode 100644 index 00000000..d0e9ec77 --- /dev/null +++ b/tests/Bws.Architecture.Tests/ReleaseArchive.cs @@ -0,0 +1,67 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using System.Text; + +namespace Bws.Architecture.Tests; + +/// +/// The archives FeedGuards puts in the release folders it builds: written from named entries, and when a +/// case needs one, an archive whose headers lie about the size of an entry. The bytes of the zip format +/// live here, so the guard keeps to what a package and its script must do. +/// +internal static class ReleaseArchive +{ + /// An archive of the named entries, each a copy of its source file, or a line of text when it has none. + internal static void Write(string path, (string Name, string? Source)[] entries) + { + using var stream = File.Create(path); + using var archive = new ZipArchive(stream, ZipArchiveMode.Create); + foreach (var (name, source) in entries) + { + Pack(archive, name, source); + } + } + + /// + /// Sets the size the local and the central header of an archive declare for one entry, and leaves its + /// bytes as they are - an archive that lies about what it holds, which only a hand makes. + /// + internal static void Declare(string path, string name, uint size) + { + var bytes = File.ReadAllBytes(path); + var named = Encoding.UTF8.GetBytes(name); + var patched = 0; + + // Each header as its signature, where it keeps the uncompressed size, the name's length and the name. + foreach (var (signature, sizeAt, lengthAt, nameAt) in new[] { (0x04034b50u, 22, 26, 30), (0x02014b50u, 24, 28, 46) }) + { + for (var at = 0; at + nameAt + named.Length <= bytes.Length; at++) + { + var header = bytes.AsSpan(at); + if (BinaryPrimitives.ReadUInt32LittleEndian(header) == signature && BinaryPrimitives.ReadUInt16LittleEndian(header[lengthAt..]) == named.Length + && header.Slice(nameAt, named.Length).SequenceEqual(named)) + { + BinaryPrimitives.WriteUInt32LittleEndian(header[sizeAt..], size); + patched++; + } + } + } + + Assert.True(patched == 2, $"Found {patched} headers naming {name} in {Path.GetFileName(path)}, and an archive holding it once has two."); + File.WriteAllBytes(path, bytes); + } + + // Stored, not compressed: the .NET reader returns every byte of a stored entry whatever its header + // declares, which is what the lying archives of FeedGuards rely on. + private static void Pack(ZipArchive archive, string name, string? source) + { + if (source is not null) + { + archive.CreateEntryFromFile(source, name, CompressionLevel.NoCompression); + return; + } + + using var writer = new StreamWriter(archive.CreateEntry(name).Open()); + writer.Write("stand-in for " + name); + } +}