From a2d5a244de8c09dc681c6a788fecb31176bf1a76 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sat, 15 Aug 2026 22:10:59 +0200 Subject: [PATCH] fix(cli): harden tht installer replacement --- scripts/install-tht.ps1 | 37 ++++++++++++++++++++++++++++-- scripts/install-tht.sh | 4 +++- scripts/test-install-tht.ps1 | 44 ++++++++++++++++++++++++++---------- scripts/test-install-tht.sh | 4 +++- 4 files changed, 73 insertions(+), 16 deletions(-) diff --git a/scripts/install-tht.ps1 b/scripts/install-tht.ps1 index 9374d0b4..9dccece7 100644 --- a/scripts/install-tht.ps1 +++ b/scripts/install-tht.ps1 @@ -37,6 +37,40 @@ function Add-InstallDirectoryToUserPath([string]$InstallDirectory) { 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 $buildOutput = $null $sourceArtifactForInstall = $null @@ -86,9 +120,8 @@ try { Copy-Item -LiteralPath $sourceArtifactForInstall -Destination $stagedBinary -Force & $stagedBinary --help | Out-Null 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 { - Remove-Item -LiteralPath $stagedBinary -Force -ErrorAction SilentlyContinue if ($buildOutput) { Remove-Item -LiteralPath $buildOutput -Recurse -Force -ErrorAction SilentlyContinue } } diff --git a/scripts/install-tht.sh b/scripts/install-tht.sh index d59db5ca..58acdd9a 100644 --- a/scripts/install-tht.sh +++ b/scripts/install-tht.sh @@ -43,7 +43,9 @@ validate_packaged_artifact() { expected=${THT_BUILD_ARTIFACT_SHA256:-} [[ "$expected" =~ ^[[:xdigit:]]{64}$ ]] || fail "THT_BUILD_ARTIFACT_SHA256 must be a 64-character SHA-256 digest" 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() { diff --git a/scripts/test-install-tht.ps1 b/scripts/test-install-tht.ps1 index edd5c11c..9b837006 100644 --- a/scripts/test-install-tht.ps1 +++ b/scripts/test-install-tht.ps1 @@ -9,7 +9,7 @@ if (-not (Test-Path -LiteralPath $installer)) { $isWindowsHost = $env:OS -eq 'Windows_NT' if (-not $isWindowsHost) { $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)) { 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()) $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' New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null try { - $program = @' + $programOne = @' using System; -public static class Program { +public static class ProgramOne { public static void Main(string[] args) { 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); } } '@ - 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') $env:THT_INSTALL_DIRECTORY = $installDirectory - $env:THT_BUILD_ARTIFACT = $artifact - $env:THT_BUILD_ARTIFACT_SHA256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $artifact).Hash.ToLowerInvariant() + $env:THT_BUILD_ARTIFACT = $artifactOne + $env:THT_BUILD_ARTIFACT_SHA256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactOne).Hash.ToLowerInvariant() $env:THT_USER_PATH_FILE = $userPathStore & $installer $installed = Join-Path $installDirectory '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' } - $firstBytes = [System.IO.File]::ReadAllBytes($installed) + if ((& $installed version) -ne 'tht test version one') { throw 'installed tht.exe did not return version one' } + + $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 - if ((& $installed version) -ne 'tht test version') { throw 'installer was not idempotent' } - if (-not ([System.IO.File]::ReadAllBytes($installed).Length -eq $firstBytes.Length)) { throw 'replacement changed the unexpected binary' } + if ((& $installed version) -ne 'tht test version two') { throw 'installer was not idempotent' } $storedPath = [System.IO.File]::ReadAllText($userPathStore) $segments = $storedPath -split ';' | Where-Object { $_ -ne '' } diff --git a/scripts/test-install-tht.sh b/scripts/test-install-tht.sh index 58b9ca5c..a2c24d6f 100644 --- a/scripts/test-install-tht.sh +++ b/scripts/test-install-tht.sh @@ -41,13 +41,15 @@ write_artifact "$artifact_one" "tht test version one" write_artifact "$artifact_two" "tht test version two" 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() { local artifact=$1 THT_INSTALL_DIRECTORY="$test_directory" \ THT_BUILD_ARTIFACT="$artifact" \ THT_BUILD_ARTIFACT_SHA256="$(sha256 "$artifact")" \ PATH="$test_directory:$system_path" \ - bash "$installer" + "$installer_bash" "$installer" } run_installer "$artifact_one"