fix(cli): harden tht installer replacement

This commit is contained in:
2026-08-15 22:10:59 +02:00
parent 5e13396669
commit a2d5a244de
4 changed files with 73 additions and 16 deletions
+35 -2
View File
@@ -37,6 +37,40 @@ function Add-InstallDirectoryToUserPath([string]$InstallDirectory) {
return $true return $true
} }
function Replace-InstalledBinary([string]$StagedBinary, [string]$TargetBinary) {
$backupBinary = $null
$replacedExistingBinary = $false
try {
if (Test-Path -LiteralPath $TargetBinary -PathType Leaf) {
$backupBinary = Join-Path (Split-Path -Parent $TargetBinary) ('.tht-previous-' + [guid]::NewGuid() + '.exe')
[System.IO.File]::Replace($StagedBinary, $TargetBinary, $backupBinary, $true)
$replacedExistingBinary = $true
} else {
[System.IO.File]::Move($StagedBinary, $TargetBinary)
}
& $TargetBinary --help | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'installed tht did not pass its help check' }
if ($backupBinary -and (Test-Path -LiteralPath $backupBinary -PathType Leaf)) {
Remove-Item -LiteralPath $backupBinary -Force -ErrorAction Stop
}
} catch {
$installFailure = $_
if ($replacedExistingBinary -and $backupBinary -and (Test-Path -LiteralPath $backupBinary -PathType Leaf)) {
try {
[System.IO.File]::Replace($backupBinary, $TargetBinary, $null, $true)
$backupBinary = $null
} catch {
throw "tht replacement failed; the previous executable remains at $backupBinary. Original failure: $installFailure. Restore failure: $_"
}
}
throw $installFailure
} finally {
Remove-Item -LiteralPath $StagedBinary -Force -ErrorAction SilentlyContinue
}
}
$repositoryRoot = Split-Path -Parent $PSScriptRoot $repositoryRoot = Split-Path -Parent $PSScriptRoot
$buildOutput = $null $buildOutput = $null
$sourceArtifactForInstall = $null $sourceArtifactForInstall = $null
@@ -86,9 +120,8 @@ try {
Copy-Item -LiteralPath $sourceArtifactForInstall -Destination $stagedBinary -Force Copy-Item -LiteralPath $sourceArtifactForInstall -Destination $stagedBinary -Force
& $stagedBinary --help | Out-Null & $stagedBinary --help | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'native artifact did not pass its help check' } if ($LASTEXITCODE -ne 0) { throw 'native artifact did not pass its help check' }
Move-Item -LiteralPath $stagedBinary -Destination (Join-Path $installDirectory 'tht.exe') -Force Replace-InstalledBinary $stagedBinary (Join-Path $installDirectory 'tht.exe')
} finally { } finally {
Remove-Item -LiteralPath $stagedBinary -Force -ErrorAction SilentlyContinue
if ($buildOutput) { Remove-Item -LiteralPath $buildOutput -Recurse -Force -ErrorAction SilentlyContinue } if ($buildOutput) { Remove-Item -LiteralPath $buildOutput -Recurse -Force -ErrorAction SilentlyContinue }
} }
+3 -1
View File
@@ -43,7 +43,9 @@ validate_packaged_artifact() {
expected=${THT_BUILD_ARTIFACT_SHA256:-} expected=${THT_BUILD_ARTIFACT_SHA256:-}
[[ "$expected" =~ ^[[:xdigit:]]{64}$ ]] || fail "THT_BUILD_ARTIFACT_SHA256 must be a 64-character SHA-256 digest" [[ "$expected" =~ ^[[:xdigit:]]{64}$ ]] || fail "THT_BUILD_ARTIFACT_SHA256 must be a 64-character SHA-256 digest"
actual=$(sha256 "$artifact") actual=$(sha256 "$artifact")
[[ "${actual,,}" == "${expected,,}" ]] || fail "packaged tht artifact checksum does not match" actual=$(printf '%s' "$actual" | tr '[:upper:]' '[:lower:]')
expected=$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]')
[[ "$actual" == "$expected" ]] || fail "packaged tht artifact checksum does not match"
} }
build_artifact() { build_artifact() {
+32 -12
View File
@@ -9,7 +9,7 @@ if (-not (Test-Path -LiteralPath $installer)) {
$isWindowsHost = $env:OS -eq 'Windows_NT' $isWindowsHost = $env:OS -eq 'Windows_NT'
if (-not $isWindowsHost) { if (-not $isWindowsHost) {
$installerText = Get-Content -LiteralPath $installer -Raw $installerText = Get-Content -LiteralPath $installer -Raw
foreach ($requiredText in @('THT_INSTALL_DIRECTORY', 'LOCALAPPDATA', 'SetEnvironmentVariable', 'build-tht.sh', 'Move-Item')) { foreach ($requiredText in @('THT_INSTALL_DIRECTORY', 'LOCALAPPDATA', 'SetEnvironmentVariable', 'build-tht.sh', 'File]::Replace')) {
if (-not $installerText.Contains($requiredText)) { if (-not $installerText.Contains($requiredText)) {
throw "Windows installer is missing required behavior: $requiredText" throw "Windows installer is missing required behavior: $requiredText"
} }
@@ -20,37 +20,57 @@ if (-not $isWindowsHost) {
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("tht installer test {0}" -f [guid]::NewGuid()) $temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("tht installer test {0}" -f [guid]::NewGuid())
$installDirectory = Join-Path $temporaryRoot 'command directory' $installDirectory = Join-Path $temporaryRoot 'command directory'
$artifact = Join-Path $temporaryRoot 'tht.exe' $artifactOne = Join-Path $temporaryRoot 'tht-one.exe'
$artifactTwo = Join-Path $temporaryRoot 'tht-two.exe'
$brokenArtifact = Join-Path $temporaryRoot 'tht-broken.exe'
$userPathStore = Join-Path $temporaryRoot 'user-path.txt' $userPathStore = Join-Path $temporaryRoot 'user-path.txt'
New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null
try { try {
$program = @' $programOne = @'
using System; using System;
public static class Program { public static class ProgramOne {
public static void Main(string[] args) { public static void Main(string[] args) {
if (args.Length == 1 && args[0] == "--help") { Console.WriteLine("Usage: tht"); return; } if (args.Length == 1 && args[0] == "--help") { Console.WriteLine("Usage: tht"); return; }
if (args.Length == 1 && args[0] == "version") { Console.WriteLine("tht test version"); return; } if (args.Length == 1 && args[0] == "version") { Console.WriteLine("tht test version one"); return; }
Environment.Exit(2); Environment.Exit(2);
} }
} }
'@ '@
Add-Type -TypeDefinition $program -OutputAssembly $artifact -OutputType ConsoleApplication $programTwo = $programOne.Replace('ProgramOne', 'ProgramTwo').Replace('tht test version one', 'tht test version two')
$brokenProgram = $programOne.Replace('ProgramOne', 'BrokenProgram').Replace('Console.WriteLine("Usage: tht"); return;', 'Environment.Exit(9);')
Add-Type -TypeDefinition $programOne -OutputAssembly $artifactOne -OutputType ConsoleApplication
Add-Type -TypeDefinition $programTwo -OutputAssembly $artifactTwo -OutputType ConsoleApplication
Add-Type -TypeDefinition $brokenProgram -OutputAssembly $brokenArtifact -OutputType ConsoleApplication
[System.IO.File]::WriteAllText($userPathStore, 'C:\Existing Bin') [System.IO.File]::WriteAllText($userPathStore, 'C:\Existing Bin')
$env:THT_INSTALL_DIRECTORY = $installDirectory $env:THT_INSTALL_DIRECTORY = $installDirectory
$env:THT_BUILD_ARTIFACT = $artifact $env:THT_BUILD_ARTIFACT = $artifactOne
$env:THT_BUILD_ARTIFACT_SHA256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $artifact).Hash.ToLowerInvariant() $env:THT_BUILD_ARTIFACT_SHA256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactOne).Hash.ToLowerInvariant()
$env:THT_USER_PATH_FILE = $userPathStore $env:THT_USER_PATH_FILE = $userPathStore
& $installer & $installer
$installed = Join-Path $installDirectory 'tht.exe' $installed = Join-Path $installDirectory 'tht.exe'
if (-not (Test-Path -LiteralPath $installed)) { throw 'installer did not create tht.exe' } if (-not (Test-Path -LiteralPath $installed)) { throw 'installer did not create tht.exe' }
if ((& $installed version) -ne 'tht test version') { throw 'installed tht.exe did not return version' } if ((& $installed version) -ne 'tht test version one') { throw 'installed tht.exe did not return version one' }
$firstBytes = [System.IO.File]::ReadAllBytes($installed)
$env:THT_BUILD_ARTIFACT = $brokenArtifact
$env:THT_BUILD_ARTIFACT_SHA256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $brokenArtifact).Hash.ToLowerInvariant()
$brokenInstallFailed = $false
try {
& $installer
} catch {
$brokenInstallFailed = $true
}
if (-not $brokenInstallFailed) { throw 'installer accepted an invalid staged executable' }
if ((& $installed version) -ne 'tht test version one') { throw 'invalid staged executable replaced the existing command' }
$env:THT_BUILD_ARTIFACT = $artifactTwo
$env:THT_BUILD_ARTIFACT_SHA256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactTwo).Hash.ToLowerInvariant()
& $installer
if ((& $installed version) -ne 'tht test version two') { throw 'installer did not replace the existing command' }
& $installer & $installer
if ((& $installed version) -ne 'tht test version') { throw 'installer was not idempotent' } if ((& $installed version) -ne 'tht test version two') { throw 'installer was not idempotent' }
if (-not ([System.IO.File]::ReadAllBytes($installed).Length -eq $firstBytes.Length)) { throw 'replacement changed the unexpected binary' }
$storedPath = [System.IO.File]::ReadAllText($userPathStore) $storedPath = [System.IO.File]::ReadAllText($userPathStore)
$segments = $storedPath -split ';' | Where-Object { $_ -ne '' } $segments = $storedPath -split ';' | Where-Object { $_ -ne '' }
+3 -1
View File
@@ -41,13 +41,15 @@ write_artifact "$artifact_one" "tht test version one"
write_artifact "$artifact_two" "tht test version two" write_artifact "$artifact_two" "tht test version two"
system_path=$PATH system_path=$PATH
installer_bash=${THT_TEST_BASH:-/bin/bash}
test -x "$installer_bash" || fail "THT_TEST_BASH must name an executable Bash interpreter"
run_installer() { run_installer() {
local artifact=$1 local artifact=$1
THT_INSTALL_DIRECTORY="$test_directory" \ THT_INSTALL_DIRECTORY="$test_directory" \
THT_BUILD_ARTIFACT="$artifact" \ THT_BUILD_ARTIFACT="$artifact" \
THT_BUILD_ARTIFACT_SHA256="$(sha256 "$artifact")" \ THT_BUILD_ARTIFACT_SHA256="$(sha256 "$artifact")" \
PATH="$test_directory:$system_path" \ PATH="$test_directory:$system_path" \
bash "$installer" "$installer_bash" "$installer"
} }
run_installer "$artifact_one" run_installer "$artifact_one"