fix(install): resolve uv shims before salvage and validate the copy in place

Install-Uv accepted any file at $HermesHome\bin\uv.exe, and copied whatever
`Get-Command uv` returned into that location. Chocolatey's bin\uv.exe is a
ShimGen launcher that locates ..\lib\uv\tools\uv.exe RELATIVE to itself, so
the copy is dead on arrival; `& exe --version` does not throw on a nonzero
exit, so the launcher passed the try/catch and the Python stage then failed
with "Python 3.11 not available" (#110350). The re-run path trusted the same
broken copy again.

Building on KoNit-K's Test-ManagedUvBinary and its three call sites:

- Test-ManagedUvBinary merges stderr, relaxes the error preference, and
  returns the `uv <version>` line only on exit 0 -- a launcher's error text
  can no longer surface as "Managed uv found (Cannot find file ...)".
- Resolve-UvShimTarget maps a candidate to the standalone binary before the
  copy: `<name>.shim` sidecar (Scoop), the Chocolatey bin\ -> lib\<pkg>\tools\
  layout, symlinks (winget Links\); other reparse points (WindowsApps
  app-execution aliases) have no copyable file and skip the salvage.
- The salvage rung validates the candidate where it lives, copies, then
  validates the COPY at its new location and removes it on failure, so the
  stage fails honestly instead of reporting success over a dead launcher.
- scripts/tests/test-install-ps1-uv-shim-validation.ps1 drives the real
  Install-Uv with compiled fake uv binaries (a working uv and a
  location-relative launcher) under stubbed installer rungs; wired into
  installer-tests.yml for pwsh 7 and Windows PowerShell 5.1.

Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>
This commit is contained in:
teknium1
2026-09-18 22:15:35 -07:00
committed by Teknium
parent 3662a1926d
commit d0dbf2cbb6
3 changed files with 270 additions and 19 deletions

View File

@@ -766,16 +766,63 @@ function Get-PowerShellHostExe {
}
function Test-ManagedUvBinary {
# Native nonzero exits do not enter `catch`. Probe $LASTEXITCODE after
# `--version` so Chocolatey relative shims and other broken copies are
# not trusted just because the file exists.
# `& exe` never throws on a nonzero exit, so Test-Path plus a bare
# `--version` accepted the dead Chocolatey launcher a previous run had
# copied into bin\ (issue #110350). Accept only exit 0 AND a line that
# looks like `uv <version>`; stderr is merged and the error preference
# relaxed so a launcher's error text cannot turn into an exception under
# a caller's Stop preference. Returns the version line or $null.
param([Parameter(Mandatory = $true)][string]$Path)
$global:LASTEXITCODE = 0
$version = & $Path --version
if ($LASTEXITCODE -ne 0) {
return $false
$prevEAP = $ErrorActionPreference
try {
$ErrorActionPreference = "Continue"
$global:LASTEXITCODE = 0
$output = @(& $Path --version 2>&1 | ForEach-Object { "$_" })
$exitCode = $LASTEXITCODE
} catch {
return $null
} finally {
$ErrorActionPreference = $prevEAP
}
return $version
if ($exitCode -ne 0) { return $null }
$line = $output | Where-Object { $_ -match '^uv\s+\d+\.\d+' } | Select-Object -First 1
if ($line) { return $line.Trim() }
return $null
}
function Resolve-UvShimTarget {
# Package-manager launchers locate the real uv RELATIVE to their own
# location, so a copied launcher is dead on arrival (issue #110350).
# Map the well-known ones to the standalone binary: a `<name>.shim`
# sidecar (`path = ...`; Scoop, and Chocolatey shims that carry one) and
# the Chocolatey ShimGen layout bin\uv.exe -> lib\uv\tools\uv.exe. A
# symlink (winget Links\) resolves to its target; any other reparse point
# (WindowsApps app-execution alias) has no copyable file, so return $null
# and let the caller skip the salvage. Anything else is returned as-is.
param([Parameter(Mandatory = $true)][string]$ExePath)
$item = Get-Item -LiteralPath $ExePath -Force -ErrorAction SilentlyContinue
if (-not $item) { return $null }
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
if ($item.LinkType -eq "SymbolicLink" -and $item.Target) {
$linkTarget = @($item.Target)[0]
if (Test-Path -LiteralPath $linkTarget -PathType Leaf) { return $linkTarget }
}
return $null
}
$dir = Split-Path $ExePath -Parent
$stem = [IO.Path]::GetFileNameWithoutExtension($ExePath)
$targets = @()
$sidecar = Join-Path $dir "$stem.shim"
if (Test-Path -LiteralPath $sidecar -PathType Leaf) {
$pathLine = @(Get-Content -LiteralPath $sidecar -ErrorAction SilentlyContinue) |
Where-Object { $_ -match '^\s*path\s*=\s*"?([^"]+?)"?\s*$' } | Select-Object -First 1
if ($pathLine -and ($pathLine -match '^\s*path\s*=\s*"?([^"]+?)"?\s*$')) { $targets += $Matches[1] }
}
$targets += Join-Path (Split-Path $dir -Parent) "lib\$stem\tools\$stem.exe"
foreach ($target in $targets) {
if (Test-Path -LiteralPath $target -PathType Leaf) { return $target }
}
return $ExePath
}
function Install-Uv {
@@ -855,19 +902,27 @@ function Install-Uv {
if (Test-Path $defaultUv) { $existingUv = $defaultUv }
}
if ($existingUv) {
Write-Info "Salvaging existing uv from $existingUv"
try {
# Verify the salvaged binary actually runs before
# trusting it as the managed uv.
Copy-Item $existingUv $managedUv -Force
$salvagedVersion = Test-ManagedUvBinary $managedUv
if (-not $salvagedVersion) {
Write-Info "Copied uv at $managedUv failed validation; continuing fallback"
# Validate the candidate where it lives (a shim runs fine in
# place), resolve launchers to the real binary, and validate
# the COPY at its new location -- that last check is the one
# that catches a relocated launcher.
$salvageSource = Resolve-UvShimTarget $existingUv
if (-not $salvageSource) {
Write-Info "Existing uv at $existingUv is an app-execution alias; cannot be copied"
} elseif (-not (Test-ManagedUvBinary $salvageSource)) {
Write-Info "Existing uv at $salvageSource does not run; not salvaging it"
} else {
Write-Info "Salvaging existing uv from $salvageSource"
try {
Copy-Item $salvageSource $managedUv -Force
if (-not (Test-ManagedUvBinary $managedUv)) {
Write-Info "Copied uv at $managedUv failed validation; continuing fallback"
Remove-Item $managedUv -Force -ErrorAction SilentlyContinue
}
} catch {
Write-Info "Existing uv at $salvageSource could not be salvaged: $_"
Remove-Item $managedUv -Force -ErrorAction SilentlyContinue
}
} catch {
Write-Info "Existing uv at $existingUv could not be salvaged: $_"
Remove-Item $managedUv -Force -ErrorAction SilentlyContinue
}
}
}

View File

@@ -0,0 +1,188 @@
# Behavioral tests for install.ps1 managed-uv acceptance (issue #110350).
#
# `& uv.exe --version` never throws on a nonzero exit, so Install-Uv used to
# trust any file at $HermesHome\bin\uv.exe -- including the Chocolatey ShimGen
# launcher it had itself copied there, which resolves the real uv RELATIVE to
# its own location and is therefore dead after the copy. The installer is
# dot-sourced without running its entry point; the uv installer rungs and PATH
# lookup are replaced with in-process stubs; the fake uv binaries are tiny
# compiled console apps so the real spawn/exit-code contract is exercised.
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path))
$installScript = Join-Path $repoRoot 'scripts\install.ps1'
$testRoot = Join-Path $env:TEMP ("hermes-uv-shim-test-" + [Guid]::NewGuid().ToString('N'))
$HermesHome = Join-Path $testRoot 'home'
$InstallDir = Join-Path $testRoot 'missing-checkout'
New-Item -ItemType Directory -Force -Path $testRoot | Out-Null
. $installScript -HermesHome $HermesHome -InstallDir $InstallDir
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$script:Failures = 0
function Assert-Equal {
param($Expected, $Actual, [string]$Label)
if ($Expected -ceq $Actual) {
Write-Host "PASS: $Label"
} else {
Write-Host "FAIL: $Label"
Write-Host " expected: [$Expected]"
Write-Host " actual: [$Actual]"
$script:Failures++
}
}
# -- fake uv binaries -------------------------------------------------------
# Compiled with Windows PowerShell 5.1 (always present on a Windows host) so
# the result is a real console .exe regardless of which host runs this test.
function New-FakeExe {
param([string]$Name, [string]$Source)
$srcPath = Join-Path $testRoot "$Name.cs"
$exePath = Join-Path $testRoot "$Name.exe"
[IO.File]::WriteAllText($srcPath, $Source)
$winPs = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
& $winPs -NoProfile -ExecutionPolicy Bypass -Command "Add-Type -Path '$srcPath' -OutputAssembly '$exePath' -OutputType ConsoleApplication" | Out-Null
if (-not (Test-Path $exePath)) { throw "failed to compile $Name" }
return $exePath
}
# A standalone uv: always answers `uv 0.1.0`.
$okExe = New-FakeExe 'fake-uv-ok' @'
public static class FakeUv {
public static int Main(string[] args) {
System.Console.WriteLine("uv 0.1.0");
return 0;
}
}
'@
# A ShimGen-style launcher: works only while ..\lib\uv\tools\uv.exe exists
# relative to ITS OWN location -- exactly what Chocolatey's bin\uv.exe does.
$shimExe = New-FakeExe 'fake-uv-shim' @'
using System.IO;
public static class FakeUvShim {
public static int Main(string[] args) {
string self = System.Reflection.Assembly.GetExecutingAssembly().Location;
string target = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(self), "..", "lib", "uv", "tools", "uv.exe"));
if (File.Exists(target)) {
System.Console.WriteLine("uv 0.1.0");
return 0;
}
System.Console.Error.WriteLine("Cannot find file at '..\\lib\\uv\\tools\\uv.exe' (" + target + "). This usually indicates a missing or moved file.");
return 1;
}
}
'@
# Chocolatey layout: bin\uv.exe (shim) -> lib\uv\tools\uv.exe (real).
$chocoRoot = Join-Path $testRoot 'chocolatey'
New-Item -ItemType Directory -Force -Path (Join-Path $chocoRoot 'bin'), (Join-Path $chocoRoot 'lib\uv\tools') | Out-Null
$chocoShim = Join-Path $chocoRoot 'bin\uv.exe'
$chocoReal = Join-Path $chocoRoot 'lib\uv\tools\uv.exe'
Copy-Item $shimExe $chocoShim
Copy-Item $okExe $chocoReal
# Scoop layout: shims\uv.exe + shims\uv.shim (`path = "..."`) -> apps\uv\current\uv.exe.
$scoopRoot = Join-Path $testRoot 'scoop'
New-Item -ItemType Directory -Force -Path (Join-Path $scoopRoot 'shims'), (Join-Path $scoopRoot 'apps\uv\current') | Out-Null
$scoopShim = Join-Path $scoopRoot 'shims\uv.exe'
$scoopReal = Join-Path $scoopRoot 'apps\uv\current\uv.exe'
Copy-Item $shimExe $scoopShim
Copy-Item $okExe $scoopReal
"path = `"$scoopReal`"" | Set-Content -LiteralPath (Join-Path $scoopRoot 'shims\uv.shim') -Encoding Ascii
# A relocated launcher: the shim copied somewhere without a lib\ tree.
$strayDir = Join-Path $testRoot 'stray'
New-Item -ItemType Directory -Force -Path $strayDir | Out-Null
$strayShim = Join-Path $strayDir 'uv.exe'
Copy-Item $shimExe $strayShim
Write-Host '-- Test-ManagedUvBinary --'
Assert-Equal 'uv 0.1.0' (Test-ManagedUvBinary $okExe) 'standalone uv is accepted with its version line'
Assert-Equal 'uv 0.1.0' (Test-ManagedUvBinary $chocoShim) 'launcher runs at its original location'
Assert-Equal $null (Test-ManagedUvBinary $strayShim) 'relocated launcher (exit 1, no throw) is rejected'
Assert-Equal $null (Test-ManagedUvBinary (Join-Path $testRoot 'absent\uv.exe')) 'missing path is rejected'
Write-Host ''
Write-Host '-- Resolve-UvShimTarget --'
Assert-Equal $chocoReal (Resolve-UvShimTarget $chocoShim) 'Chocolatey shim resolves to lib\uv\tools\uv.exe'
Assert-Equal $scoopReal (Resolve-UvShimTarget $scoopShim) 'Scoop shim resolves through its .shim sidecar'
Assert-Equal $okExe (Resolve-UvShimTarget $okExe) 'plain executable resolves to itself'
# -- Install-Uv flow with stubbed installer rungs and PATH lookup ------------
$managedUv = Join-Path $HermesHome 'bin\uv.exe'
$script:InstallerCalls = 0
$script:FakePathUv = $null
$script:InfoLog = @()
$env:USERPROFILE = $testRoot # no ~\.local\bin\uv.exe candidate
function Invoke-FakeUvInstaller { $script:InstallerCalls++ }
function Get-PowerShellHostExe { 'Invoke-FakeUvInstaller' }
function Get-Command {
[CmdletBinding()]
param([Parameter(Position = 0)][string]$Name, [object]$CommandType)
if ($Name -eq 'uv' -and $script:FakePathUv) {
return [pscustomobject]@{ Source = $script:FakePathUv }
}
return $null
}
function Write-Info { param([string]$Message) $script:InfoLog += $Message }
function Write-Success { param([string]$Message) }
function Write-Err { param([string]$Message) }
function Invoke-InstallUvScenario {
param([string]$PreplacedManaged, [string]$PathUv)
if (Test-Path $HermesHome) { Remove-Item -LiteralPath $HermesHome -Recurse -Force }
New-Item -ItemType Directory -Force -Path (Join-Path $HermesHome 'bin') | Out-Null
if ($PreplacedManaged) { Copy-Item $PreplacedManaged $managedUv }
$script:FakePathUv = $PathUv
$script:InstallerCalls = 0
$script:InfoLog = @()
$script:UvCmd = $null
return (Install-Uv)
}
Write-Host ''
Write-Host '-- working uv already at the managed location --'
$ok = Invoke-InstallUvScenario -PreplacedManaged $okExe
Assert-Equal $true $ok 'stage succeeds'
Assert-Equal 0 $script:InstallerCalls 'no reinstall attempted'
Assert-Equal $managedUv $script:UvCmd 'managed uv is the resolved command'
Write-Host ''
Write-Host '-- broken uv pre-placed at the managed location (re-run recovery) --'
$ok = Invoke-InstallUvScenario -PreplacedManaged $strayShim
Assert-Equal $false $ok 'stage fails honestly when nothing valid can be installed'
Assert-Equal 2 $script:InstallerCalls 'broken copy is replaced: both installer rungs run'
Assert-Equal $false (Test-Path $managedUv) 'nothing broken is left at the managed location'
$ok = Invoke-InstallUvScenario -PreplacedManaged $strayShim -PathUv $chocoShim
Assert-Equal $true $ok 'broken managed copy is replaced from a Chocolatey uv on PATH'
Assert-Equal 'uv 0.1.0' (Test-ManagedUvBinary $managedUv) 'the salvaged managed copy works at its new location'
Assert-Equal (Get-Item $okExe).Length (Get-Item $managedUv).Length 'the real binary was copied, not the launcher'
Write-Host ''
Write-Host '-- broken candidate on PATH is not copied --'
$ok = Invoke-InstallUvScenario -PathUv $strayShim
Assert-Equal $false $ok 'stage fails instead of trusting a dead launcher'
Assert-Equal $false (Test-Path $managedUv) 'dead launcher was never copied into bin'
Assert-Equal $true (@($script:InfoLog -like '*does not run*').Count -gt 0) 'the reason is logged'
Write-Host ''
Write-Host '-- working uv on PATH is salvaged --'
$ok = Invoke-InstallUvScenario -PathUv $okExe
Assert-Equal $true $ok 'stage succeeds via salvage'
Assert-Equal 'uv 0.1.0' (Test-ManagedUvBinary $managedUv) 'salvaged copy validates'
if ($script:Failures -gt 0) {
Write-Host ''
Write-Host "$script:Failures assertion(s) failed"
exit 1
}
Write-Host ''
Write-Host 'all assertions passed'
if (Test-Path $testRoot) {
Remove-Item -LiteralPath $testRoot -Recurse -Force
}