diff --git a/packaging/build-feeds.ps1 b/packaging/build-feeds.ps1 new file mode 100644 index 0000000..8835f48 --- /dev/null +++ b/packaging/build-feeds.ps1 @@ -0,0 +1,599 @@ +#!/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' + +# 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) { + [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" } + 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 { + $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() } + } + # 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, 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 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. + $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: ' + + '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 ' + + "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 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 " + + "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 0000000..7769544 --- /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 0000000..850b659 --- /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 0000000..b96f536 --- /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 0000000..fa081c5 --- /dev/null +++ b/packaging/chocolatey/tools/chocolateyuninstall.window.ps1.in @@ -0,0 +1,45 @@ +# 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 +$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 + # 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 0000000..11f3589 --- /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 0000000..dc22d0c --- /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 0000000..7467dfb --- /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 0000000..222198a --- /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 0000000..76a485b --- /dev/null +++ b/tests/Bws.Architecture.Tests/FeedGuards.cs @@ -0,0 +1,351 @@ +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")); + + /// + /// 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() + { + 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). + // 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 (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") }) + { + 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}'."); + } + } + + [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.")] + [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(); + 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, 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) + { + 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); + ReleaseArchive.Write(path, change == "without:" + half ? [licence] : [(program, source), licence]); + if (Declared.TryGetValue(change, out var declared) && change.EndsWith(":" + half, StringComparison.Ordinal)) + { + ReleaseArchive.Declare(path, program, declared); + } + } + + 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; + } + } + + /// 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() + { + 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() => + "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 fe3fb2f..14086b9 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 0000000..83a6e26 --- /dev/null +++ b/tests/Bws.Architecture.Tests/PwshScript.cs @@ -0,0 +1,63 @@ +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 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 +{ + 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(); + } + + /// 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) + { + 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/Bws.Architecture.Tests/ReleaseArchive.cs b/tests/Bws.Architecture.Tests/ReleaseArchive.cs new file mode 100644 index 0000000..d0e9ec7 --- /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); + } +} diff --git a/tests/Bws.Architecture.Tests/SigningRitualGuards.cs b/tests/Bws.Architecture.Tests/SigningRitualGuards.cs index f406a1f..ddfdeee 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 e2ee8c3..b68fcb9 100644 --- a/tests/mutation/entries.ps1 +++ b/tests/mutation/entries.ps1 @@ -13142,6 +13142,132 @@ 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 = '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'