# Copyright (c) The mukti Contributors # SPDX-License-Identifier: MIT OR Apache-2.0 # Installs a pre-built cargo-nextest binary on Windows. # # Generated by mukti-bin generate-install-scripts. # # This works on PowerShell 5.1 and PowerShell 7, and as a file, script block, # or piped to iex. # # iex runs this in the caller's scope, so everything lives in one # block that is then invoked so that variables and functions within the caller # aren't overwritten. Errors are thrown. try { & { # Without PositionalBinding = $false, PowerShell binds a stray argument # to the options in order (so "install.ps1 stray" would install version # "stray"). That's a kind of argument we would like to reject. # # With PositionalBinding = $false, PowerShell refuses any argument that # is not one of the options below. # # Note that in the typical `irm | iex` style of invocation, arguments # aren't provided. [CmdletBinding(PositionalBinding = $false)] param( [string]$Version, [string]$InstallDir, [string]$Target, [string]$BaseUrl ) # These apply to this block only. Set-StrictMode -Off $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' # These are assigned before Import-Module so its catch block can use # $docsUrl. $project = 'cargo-nextest' $binName = 'cargo-nextest' $exeName = "$binName.exe" $defaultBaseUrl = 'https://get.nexte.st' $docsUrl = 'https://nexte.st/docs/installation/pre-built-binaries/' # Import this script's required modules by name. Otherwise, Windows # PowerShell 5.1 without a module cache will analyze every module, which # can take a long time, especially in GitHub Actions runners which tend # to have a lot of extraneous modules. try { Import-Module Microsoft.PowerShell.Management, Microsoft.PowerShell.Utility, Microsoft.PowerShell.Archive } catch { throw ("could not load the PowerShell modules this script needs: $($_.Exception.Message)`n" + "Microsoft.PowerShell.Archive ships with PowerShell 5.1 and later; see $docsUrl for other ways to install") } # Arguments for the post-install command -- empty means there is no # post-install step. $postInstallArgs = @('nextest', 'self', 'setup', '--source', 'install-script') $postInstallMinVersion = @(0, 9, 152) $manifestHeader = 'mukti-install-manifest' $manifestFormat = '1' $manifestFileName = 'install-manifest.txt' $archiveFormat = 'zip' # Used for both the backup directory's prefix and the file in it. # # The project's own self-updater may use the same name, in which case it'll # clean up after this script, too. $backupName = "__${project}_backup" # An explicitly empty option is an error. (But note that an empty # environment variable counts as unset.) foreach ($name in 'Version', 'InstallDir', 'Target', 'BaseUrl') { if ($PSBoundParameters.ContainsKey($name) -and -not $PSBoundParameters[$name]) { throw "option -$name requires a non-empty value`nto use the default, leave the option out" } } if (-not $Version) { $Version = $env:NEXTEST_INSTALL_VERSION } if (-not $InstallDir) { $InstallDir = $env:NEXTEST_INSTALL_DIR } if (-not $Target) { $Target = $env:NEXTEST_INSTALL_TARGET } if (-not $BaseUrl) { $BaseUrl = $env:NEXTEST_INSTALL_BASE_URL } function Write-Info { param([string]$Message) Write-Information -MessageData "info: $Message" -InformationAction Continue } # Returns 'ok' or 'not-found', and throws on any other failure. function Get-Url { param([string]$Url, [string]$OutFile) # We want to reject https to http redirects. Windows PowerShell 5.1 # follows such a redirect. Ask for the response object, which says # where the response really came from. The throw below is reported # by the catch as a failed download. # # (PowerShell 7 refuses such redirects by itself.) $checkFinalUri = $PSVersionTable.PSEdition -ne 'Core' # __MUKTI_TEST_STALL_SECONDS is an undocumented hook so that tests can time out quickly. $timeoutSeconds = 60 if ($env:__MUKTI_TEST_STALL_SECONDS -match '^[0-9]+$') { $timeoutSeconds = [int]$env:__MUKTI_TEST_STALL_SECONDS } # TimeoutSec bounds the wait for the response headers (5.1 and 7), # not the whole download. # # PowerShell 7.4 added OperationTimeoutSeconds which sets a timeout # on each chunk of data received. $webRequestArgs = @{ Uri = $Url OutFile = $OutFile UseBasicParsing = $true PassThru = $checkFinalUri TimeoutSec = $timeoutSeconds } if ($PSVersionTable.PSVersion -ge [version]'7.4') { $webRequestArgs['OperationTimeoutSeconds'] = $timeoutSeconds } $attempt = 0 while ($true) { $attempt++ try { $response = Invoke-WebRequest @webRequestArgs break } catch { $status = 0 $failedResponse = $_.Exception.Response if ($failedResponse) { $status = [int]$failedResponse.StatusCode } if ($status -eq 404) { return 'not-found' } # Match curl --retry 3's behavior for which scenarios can be # retried: timeouts, and the listed HTTP status codes. $transient = @(408, 429, 500, 502, 503, 504) -contains $status $inner = $_.Exception while ($inner -and -not $transient) { # * PowerShell 7 puts a TimeoutException somewhere in the # inner-exception chain (header and body stalls). # * Windows PowerShell 5.1 throws a WebException with # Status Timeout. $transient = ($inner -is [System.TimeoutException]) -or (($inner -is [System.Net.WebException]) -and ($inner.Status -eq [System.Net.WebExceptionStatus]::Timeout)) $inner = $inner.InnerException } if ($attempt -gt 3 -or -not $transient) { throw "failed to download ${Url}: $($_.Exception.Message)`ncheck the network connection, then try again" } Start-Sleep -Seconds $attempt } } if ($checkFinalUri) { $finalUri = $response.BaseResponse.ResponseUri if ($finalUri.Scheme -cne 'https') { throw "failed to download ${Url}: the response came from `"$finalUri`", which is not an https URL`nrefusing a redirect from https to plain http" } } return 'ok' } if (-not $Version) { $Version = 'latest' } # The version becomes a URL path segment, so restrict its characters. if ($Version -notmatch '^[0-9A-Za-z.+-]+$') { throw "invalid version: $Version`nexpected `"latest`", a series like `"0.9`", or a version like `"0.9.100`"" } if (-not $BaseUrl) { $BaseUrl = $defaultBaseUrl } # The base URL gets a path appended, which a query string or fragment # would swallow. if ($BaseUrl -match '[?#]') { throw "invalid base URL: $BaseUrl`nthe base URL must not contain `"?`" or `"#`"" } if ($BaseUrl -cnotmatch '^https://') { throw "invalid base URL: $BaseUrl`nthe base URL must start with `"https://`"" } $BaseUrl = $BaseUrl.TrimEnd('/') # Targets that can run on this host, best first. if ($Target) { if ($Target -notmatch '^[0-9A-Za-z._-]+$') { throw "invalid target: $Target`nexpected a target triple like x86_64-pc-windows-msvc" } $candidates = @($Target) } else { # These are undocumented hooks for tests, standing in for what the machine-level lookup below # returns and for the process's own two variables. They also let the tests run this branch on # platforms other than Windows. $machineArch = $env:__MUKTI_TEST_MACHINE_ARCHITECTURE if ($machineArch) { $processArch = $env:__MUKTI_TEST_PROCESSOR_ARCHITECTURE $processArchW6432 = $env:__MUKTI_TEST_PROCESSOR_ARCHITEW6432 } else { if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw "install.ps1 only supports Windows`non other platforms, use $defaultBaseUrl/install.sh; see $docsUrl" } $processArch = $env:PROCESSOR_ARCHITECTURE $processArchW6432 = $env:PROCESSOR_ARCHITEW6432 # The process's architecture can be emulated, e.g. an x64 or x86 PowerShell on ARM64 Windows. # We want to ensure that we download the ARM64 binary in such cases -- 'Machine' provides # that. try { $machineArch = [Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITECTURE', 'Machine') } catch { $machineArch = $null } } if (@('AMD64', 'ARM64', 'x86') -ccontains $machineArch) { $arch = $machineArch } else { # If the machine-level value isn't available, fall back to # PROCESSOR_ARCHITEW6432 or PROCESSOR_ARCHITECTURE. $arch = $processArchW6432 if (-not $arch) { $arch = $processArch } } if ($processArch -and $arch -cne $processArch) { Write-Info "PowerShell is an $processArch process on an $arch machine; preferring $arch builds" } if ($arch -ceq 'AMD64') { $candidates = @('x86_64-pc-windows-msvc', 'i686-pc-windows-msvc') } elseif ($arch -ceq 'ARM64') { $candidates = @('aarch64-pc-windows-msvc', 'x86_64-pc-windows-msvc', 'i686-pc-windows-msvc') } elseif ($arch -ceq 'x86') { $candidates = @('i686-pc-windows-msvc') } else { throw ("no $project build is known for this platform (architecture: $arch)`n" + "see $docsUrl for supported platforms and other ways to install`n" + "to force a specific build, pass -Target or set `$env:NEXTEST_INSTALL_TARGET") } } if (-not $InstallDir) { if ($env:CARGO_HOME) { $InstallDir = Join-Path $env:CARGO_HOME 'bin' } elseif ($HOME) { $InstallDir = Join-Path (Join-Path $HOME '.cargo') 'bin' } else { throw ("could not determine where to install: neither CARGO_HOME nor the home directory is set`n" + "pass -InstallDir or set `$env:NEXTEST_INSTALL_DIR") } } # PowerShell treats square brackets in paths as wildcards. if ($InstallDir -match '[\[\]]') { throw ("cannot install to ${InstallDir}: the path contains `"[`" or `"]`", which PowerShell treats as wildcards`n" + "choose another directory with -InstallDir or `$env:NEXTEST_INSTALL_DIR") } # Make the path absolute, since .NET APIs resolve relative paths against # the process cwd, not the PowerShell location. try { $InstallDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($InstallDir) $null = [IO.Directory]::CreateDirectory($InstallDir) } catch { throw ("could not create the install directory ${InstallDir}: $($_.Exception.Message)`n" + "choose another directory with -InstallDir or `$env:NEXTEST_INSTALL_DIR") } # The temp dir lives in the install dir so that the final move is a same-volume rename. $tmpDir = Join-Path $InstallDir (".$project-install." + [IO.Path]::GetRandomFileName()) try { $null = [IO.Directory]::CreateDirectory($tmpDir) } catch { throw ("could not create a temporary directory in ${InstallDir}: $($_.Exception.Message)`n" + "check that the directory is writable, or choose another with -InstallDir or `$env:NEXTEST_INSTALL_DIR") } # Windows PowerShell 5.1 with an explicit protocol list may lack TLS 1.2. # Add it for this run and put the old list back after. $oldSecurityProtocol = [Net.ServicePointManager]::SecurityProtocol $restoreSecurityProtocol = $false try { if ($PSVersionTable.PSEdition -ne 'Core' -and [int]$oldSecurityProtocol -ne 0) { [Net.ServicePointManager]::SecurityProtocol = $oldSecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $restoreSecurityProtocol = $true } $manifestUrl = "$BaseUrl/$Version/$manifestFileName" $manifestPath = Join-Path $tmpDir 'manifest' if ((Get-Url -Url $manifestUrl -OutFile $manifestPath) -ne 'ok') { throw ("$project version `"$Version`" was not found (no manifest at $manifestUrl)`n" + "expected `"latest`", a series like `"0.9`", or a released version like `"0.9.100`"") } $lines = @(Get-Content -LiteralPath $manifestPath) $header = @() if ($lines.Count -gt 0) { $header = @($lines[0].Trim() -split '\s+') } if ($header.Count -lt 1 -or $header[0] -cne $manifestHeader) { throw ("the response from $manifestUrl is not an install manifest`n" + "check the base URL, and that nothing on the network is rewriting the response") } if ($header.Count -lt 2 -or $header[1] -cne $manifestFormat) { throw ("the install manifest at $manifestUrl uses unsupported format $($header[1]) (expected format $manifestFormat)`n" + "download the current install script from $defaultBaseUrl/install.ps1 and try again") } $manifestProject = '' $manifestVersion = '' $manifestStatus = '' $malformedArchiveLine = $null # Build a map of (sha256, url) in manifest order. $archives = [System.Collections.Specialized.OrderedDictionary]::new([StringComparer]::Ordinal) foreach ($line in ($lines | Select-Object -Skip 1)) { $fields = @($line.Trim() -split '\s+') if ($fields[0] -ceq 'project' -and $fields.Count -ge 2) { $manifestProject = $fields[1] } elseif ($fields[0] -ceq 'version' -and $fields.Count -ge 2) { $manifestVersion = $fields[1] } elseif ($fields[0] -ceq 'status' -and $fields.Count -ge 2) { $manifestStatus = $fields[1] } elseif ($fields[0] -ceq 'archive') { # The linux-gnu lines carry exactly one extra field, # min-glibc=MAJOR.MINOR, which Windows does not need. $isGnu = $fields.Count -ge 2 -and $fields[1] -clike '*-linux-gnu*' $wellFormed = if ($isGnu) { $fields.Count -eq 6 -and $fields[5] -cmatch '^min-glibc=[0-9]+\.[0-9]+$' } else { $fields.Count -eq 5 } if (-not $wellFormed) { # An archive line always has a target, format, checksum, # and URL. A short line must result in an error, not in # a skip. if ($null -eq $malformedArchiveLine) { $malformedArchiveLine = $line.Trim() } } elseif ($fields[2] -ceq $archiveFormat -and -not $archives.Contains($fields[1])) { $archives.Add($fields[1], @($fields[3], $fields[4])) } } } if ($manifestProject -cne $project) { throw "the install manifest at $manifestUrl is for project `"$manifestProject`", not `"$project`"`ncheck the base URL" } if ($null -ne $malformedArchiveLine) { throw ("the install manifest at $manifestUrl has a malformed archive line: `"$malformedArchiveLine`"`n" + "the manifest may be incomplete; try again, and if this keeps happening, see $docsUrl") } if ($manifestVersion -notmatch '^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$') { throw ("the install manifest at $manifestUrl has a missing or malformed version: `"$manifestVersion`"`n" + "expected MAJOR.MINOR.PATCH, with an optional prerelease or build suffix`n" + "this is a problem with the release; see $docsUrl for other ways to install") } $major = [int]$Matches[1] $minor = [int]$Matches[2] $patch = [int]$Matches[3] $isPrerelease = [bool]$Matches[4] $matchesRequest = if ($Version -ceq $manifestVersion) { $true } elseif ($Version -ceq 'latest') { -not $isPrerelease } elseif ($Version -cmatch '^0\.([0-9]+)$') { -not $isPrerelease -and $major -eq 0 -and "$minor" -ceq $Matches[1] } elseif ($Version -cmatch '^([0-9]+)$') { -not $isPrerelease -and "$major" -ceq $Matches[1] } else { $false } if (-not $matchesRequest) { throw ("the install manifest at $manifestUrl is for $project $manifestVersion, which version `"$Version`" cannot resolve to`n" + "the server may be serving a stale or misplaced manifest; try again, and if this keeps happening, see $docsUrl") } # Whether the version is at or above the post-install minimum. # (A prerelease of the minimum counts as older.) $minMajor, $minMinor, $minPatch = $postInstallMinVersion $atLeastMin = ($major -gt $minMajor) -or ($major -eq $minMajor -and ( ($minor -gt $minMinor) -or ($minor -eq $minMinor -and ( ($patch -gt $minPatch) -or ($patch -eq $minPatch -and -not $isPrerelease))))) if ($manifestStatus -ceq 'yanked') { Write-Warning "$project $manifestVersion has been yanked; installing it anyway since it was requested" } elseif ($manifestStatus -cne 'active') { throw ("the install manifest at $manifestUrl has an unknown release status: `"$manifestStatus`"`n" + "download the current install script from $defaultBaseUrl/install.ps1 and try again") } $chosenTarget = $null foreach ($candidate in $candidates) { if ($archives.Contains($candidate)) { $chosenTarget = $candidate break } } if (-not $chosenTarget) { $available = '(none)' if ($archives.Count -gt 0) { $available = @($archives.Keys) -join ' ' } throw ("$project $manifestVersion has no $archiveFormat archive for this platform`n" + "looked for: $($candidates -join ' ')`n" + "targets available for this version: $available`n" + "see $docsUrl for supported platforms and other ways to install") } $expectedSha256, $archiveUrl = $archives[$chosenTarget] if ($expectedSha256 -cnotmatch '^[0-9a-f]{64}$') { throw "the install manifest at $manifestUrl has a malformed SHA-256 checksum for target $chosenTarget" } if ($archiveUrl -cnotmatch '^https://') { throw "the install manifest at $manifestUrl has an archive URL for target $chosenTarget that is not an https URL: `"$archiveUrl`"" } Write-Info "installing $project $manifestVersion ($chosenTarget) to $InstallDir" Write-Info "downloading $archiveUrl" $archivePath = Join-Path $tmpDir "archive.$archiveFormat" if ((Get-Url -Url $archiveUrl -OutFile $archivePath) -ne 'ok') { throw ("the archive for $chosenTarget was not found at $archiveUrl`n" + "the release may have been removed; see $docsUrl for other ways to install") } try { $actualSha256 = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() } catch { throw "could not compute a SHA-256 checksum for ${archivePath}: $($_.Exception.Message)`nnothing was installed" } if ($actualSha256 -cne $expectedSha256) { throw ("checksum mismatch for $archiveUrl`n" + "expected SHA-256: $expectedSha256`n" + " actual SHA-256: $actualSha256`n" + "the download may be incomplete or corrupted; nothing was installed`n" + "try again, and if this keeps happening, see $docsUrl") } $extractDir = Join-Path $tmpDir 'extract' try { Expand-Archive -LiteralPath $archivePath -DestinationPath $extractDir -Force } catch { throw "failed to extract the downloaded archive: $($_.Exception.Message)`nnothing was installed" } $newExe = Join-Path $extractDir $exeName if (-not [IO.File]::Exists($newExe)) { throw ("the downloaded archive did not contain $exeName; nothing was installed`n" + "this is a problem with the release; see $docsUrl for other ways to install") } $dest = Join-Path $InstallDir $exeName if ([IO.Directory]::Exists($dest)) { throw "cannot install to ${dest}: it is a directory" } # Remove backups left by earlier runs. # # Here, we remove directories that only hold a backup file. If some # other file was dropped into the directory, removing it is a bit # too scary. # # This is best-effort: a directory that cannot be listed (for # example, one that a concurrent run is removing) is skipped rather # than failing. try { $oldBackups = @(Get-ChildItem -LiteralPath $InstallDir -Directory -Force -Filter "$backupName*") } catch { Write-Warning "could not look for old backups in ${InstallDir}: $($_.Exception.Message)" $oldBackups = @() } foreach ($oldBackup in $oldBackups) { try { $unexpected = @(Get-ChildItem -LiteralPath $oldBackup.FullName -Force | Where-Object { $_.PSIsContainer -or $_.Name -cne $backupName }) } catch { continue } if ($unexpected.Count -eq 0) { Remove-Item -LiteralPath $oldBackup.FullName -Recurse -Force -ErrorAction SilentlyContinue } } # Windows cannot overwrite a running executable but can rename it, so # move the old binary aside first. $backupDir = $null $backupPath = $null if ([IO.File]::Exists($dest)) { $backupDir = Join-Path $InstallDir ($backupName + [IO.Path]::GetRandomFileName()) try { $null = [IO.Directory]::CreateDirectory($backupDir) } catch { throw ("could not create ${backupDir} to move the existing $dest out of the way: $($_.Exception.Message)`n" + "check that ${InstallDir} is writable; nothing was changed") } $backupPath = Join-Path $backupDir $backupName try { [IO.File]::Move($dest, $backupPath) } catch { $moveError = $_.Exception.Message Remove-Item -LiteralPath $backupDir -Recurse -Force -ErrorAction SilentlyContinue throw ("could not move the existing $dest out of the way: $moveError`n" + "if another program has $exeName open, close it and run this script again; nothing was changed") } } try { [IO.File]::Move($newExe, $dest) } catch { $moveError = $_.Exception.Message if (-not $backupPath) { throw "failed to move the new binary to ${dest}: $moveError" } try { [IO.File]::Move($backupPath, $dest) } catch { throw ("failed to move the new binary to ${dest}: $moveError`n" + "restoring the previous binary also failed; it is at $backupPath") } throw "failed to move the new binary to ${dest}: $moveError`nthe previous binary was left in place" } if ($backupDir) { # This would fail while the old binary is still running. But that's # okay because a later run cleans it up. Remove-Item -LiteralPath $backupDir -Recurse -Force -ErrorAction SilentlyContinue } if ($postInstallArgs.Count -gt 0 -and $atLeastMin) { $postInstallDisplay = "$binName $($postInstallArgs -join ' ')" Write-Info "running post-install step: $postInstallDisplay" $startError = $null $exitCode = 0 # With Stop, anything the command writes to stderr can become an # error. Only its exit code should be used to decide this. $ErrorActionPreference = 'Continue' try { & $dest @postInstallArgs | Out-Host $exitCode = $LASTEXITCODE } catch { $startError = $_.Exception.Message } finally { $ErrorActionPreference = 'Stop' } if ($null -ne $startError) { throw ("$project $manifestVersion ($chosenTarget) was installed to $dest, but it cannot run on this system: $startError`n" + "the $chosenTarget build may not match this system; to install a different build, pass -Target or set `$env:NEXTEST_INSTALL_TARGET`n" + "see $docsUrl for supported platforms and other ways to install") } # A negative exit code is an NTSTATUS from the loader, for # example 0xC0000135 (a DLL is missing). In that case, Retrying # the step will not help. if ($exitCode -lt 0) { $ntStatus = '0x{0:X8}' -f $exitCode throw ("$project $manifestVersion ($chosenTarget) was installed to $dest, but Windows could not start it (status $ntStatus)`n" + "the $chosenTarget build may not match this system; to install a different build, pass -Target or set `$env:NEXTEST_INSTALL_TARGET`n" + "see $docsUrl for supported platforms and other ways to install") } if ($exitCode -ne 0) { throw ("$project $manifestVersion was installed to $dest, but the post-install step ($postInstallDisplay) failed with exit code $exitCode`n" + "the binary is in place; run `"$dest $($postInstallArgs -join ' ')`" to retry the step") } } Write-Info "installed $project $manifestVersion to $dest" $pathDirs = @("$env:PATH".Split([IO.Path]::PathSeparator) | ForEach-Object { $_.TrimEnd('\', '/') }) if ($pathDirs -notcontains $InstallDir.TrimEnd('\', '/')) { Write-Info "$InstallDir is not in your PATH; add it to run $binName" } } finally { if ($restoreSecurityProtocol) { [Net.ServicePointManager]::SecurityProtocol = $oldSecurityProtocol } Remove-Item -LiteralPath $tmpDir -Recurse -Force -ErrorAction SilentlyContinue if ([IO.Directory]::Exists($tmpDir)) { Write-Warning "could not remove the temporary directory $tmpDir; it is safe to delete" } } } @args } catch [System.Management.Automation.ParameterBindingException] { # We can end up here in a couple of different cases: # # 1. A stray argument, e.g., `install.ps1 stray`. PowerShell refuses # "stray" and prints an error, but that only ends the statement that # was given the argument (the block above), not the script. So throw # here with an augmented error message. # # 2. A programmer error in this script, such as "Get-ChildItem -Bogus x" # inside the block. We can't do much to help the user here, so just # pass the error on as-is. # # How do we distinguish between the two? By using the command's name. If the # name is present, it is case 2; otherwise, it is case 1 (since it comes # from a block without a name). # # Note that this catch must always bubble up the error somehow so the user # can see what's wrong. Without a `throw`, the exception would get # swallowed. if ($_.Exception.CommandInvocation.MyCommand.Name) { throw } throw "$($_.Exception.Message)`nthe options are -Version, -InstallDir, -Target, and -BaseUrl" }