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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
188
scripts/tests/test-install-ps1-uv-shim-validation.ps1
Normal file
188
scripts/tests/test-install-ps1-uv-shim-validation.ps1
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user