From 7f8d5711ea09c8afe3887d26163fb8650865deca Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:58:58 -0400 Subject: [PATCH] Unify release selection across clients --- .github/workflows/ci.yml | 3 + install.ps1 | 168 +++++-------------- install.sh | 160 +++++------------- scripts/test-install-windows.ps1 | 168 +++++++++++++++++++ src/bin/dosh-client.rs | 267 ++++++++++++------------------- tests/installer_runtime.rs | 265 ++++++++++++++++++++++++++++++ tests/release_scripts.rs | 49 +++--- 7 files changed, 641 insertions(+), 439 deletions(-) create mode 100644 scripts/test-install-windows.ps1 create mode 100644 tests/installer_runtime.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61d0c00..710d72d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,9 @@ jobs: $errors | ForEach-Object { Write-Error $_ } exit 1 } + - name: Windows installer runtime test + shell: pwsh + run: scripts/test-install-windows.ps1 - name: cmd installer smoke check shell: pwsh run: | diff --git a/install.ps1 b/install.ps1 index be8a354..de00011 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,14 +11,17 @@ param( [string]$BinaryBase = $env:DOSH_BINARY_BASE, [string]$BinaryName = $env:DOSH_BINARY_NAME, [string]$BinaryVersion = $(if ($env:DOSH_BINARY_VERSION) { $env:DOSH_BINARY_VERSION } else { "latest" }), + [switch]$BinaryExact = $($env:DOSH_BINARY_EXACT -and $env:DOSH_BINARY_EXACT -ne "0"), [string]$UpdateCache = $(if ($env:DOSH_UPDATE_CACHE) { $env:DOSH_UPDATE_CACHE } elseif ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA "dosh\source" } else { Join-Path $HOME ".cache\dosh\source" }), [switch]$BinaryRequired = $($env:DOSH_BINARY_REQUIRED -and $env:DOSH_BINARY_REQUIRED -ne "0"), + [switch]$FromCurrent, [switch]$ForceConfig ) $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" $Quiet = $env:DOSH_UPDATE_QUIET -and $env:DOSH_UPDATE_QUIET -ne "0" +$script:ResolvedSourceRef = $null function Write-Info($Message) { if (-not $Quiet) { @@ -121,96 +124,18 @@ function Release-DownloadUrl { if (-not $web) { return $null } - if ($BinaryVersion -eq "latest") { - return "$web/releases/latest/download/$name" - } - "$web/releases/download/$BinaryVersion/$name" -} - -function Release-LatestTagDownloadUrl { - if ($BinaryUrl -or $BinaryBase -or $BinaryVersion -ne "latest") { + $releaseRef = Source-ReleaseRef + if (-not $releaseRef) { return $null } - $web = Repo-WebBase $Repo - if (-not $web) { - return $null - } - try { - $response = Invoke-WebRequest -UseBasicParsing -Uri "$web/releases/latest" -MaximumRedirection 5 - $effective = $null - if ($response.BaseResponse.ResponseUri) { - $effective = $response.BaseResponse.ResponseUri.AbsoluteUri - } elseif ($response.BaseResponse.RequestMessage -and $response.BaseResponse.RequestMessage.RequestUri) { - $effective = $response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri - } - $prefix = "$web/releases/tag/" - if ($effective -and $effective.StartsWith($prefix)) { - $tag = $effective.Substring($prefix.Length) - if ($tag) { - return "$web/releases/download/$tag/$(Release-ArtifactName)" - } - } - } - catch { - return $null - } - return $null -} - -function Version-Core($Value) { - $base = $Value.TrimStart("v").Split("+")[0] - $base.Split("-")[0] -} - -function Version-Prerelease($Value) { - $base = $Value.TrimStart("v").Split("+")[0] - $dash = $base.IndexOf("-") - if ($dash -lt 0) { - return "" - } - $base.Substring($dash + 1).ToLowerInvariant() -} - -function Version-Parts($Value) { - [regex]::Matches((Version-Core $Value), "\d+") | ForEach-Object { [int64]$_.Value } -} - -function Compare-Prerelease($Left, $Right) { - if (-not $Left -and -not $Right) { - return 0 - } - if (-not $Left) { - return 1 - } - if (-not $Right) { - return -1 - } - if ($Left -eq $Right) { - return 0 - } - if ($Left -match "(?i)^rc(\d+)$" -and $Right -match "(?i)^rc(\d+)$") { - $leftRc = [int64]([regex]::Match($Left, "(?i)^rc(\d+)$").Groups[1].Value) - $rightRc = [int64]([regex]::Match($Right, "(?i)^rc(\d+)$").Groups[1].Value) - return $leftRc.CompareTo($rightRc) - } - return [string]::CompareOrdinal($Left, $Right) -} - -function Compare-DoshVersion($Left, $Right) { - $leftParts = @(Version-Parts $Left) - $rightParts = @(Version-Parts $Right) - $width = [Math]::Max($leftParts.Count, $rightParts.Count) - for ($i = 0; $i -lt $width; $i++) { - $l = if ($i -lt $leftParts.Count) { $leftParts[$i] } else { 0 } - $r = if ($i -lt $rightParts.Count) { $rightParts[$i] } else { 0 } - if ($l -lt $r) { return -1 } - if ($l -gt $r) { return 1 } - } - return Compare-Prerelease (Version-Prerelease $Left) (Version-Prerelease $Right) + "$web/releases/download/$releaseRef/$name" } function Current-SourceVersion { - if (Test-Path "Cargo.toml") { + if ($FromCurrent) { + if (-not (Test-Path "Cargo.toml")) { + return $null + } $raw = Get-Content "Cargo.toml" -Raw } else { $web = Repo-WebBase $Repo @@ -230,47 +155,23 @@ function Current-SourceVersion { return $null } -function Latest-ReleaseTag { - $web = Repo-WebBase $Repo - if (-not $web) { +function Source-ReleaseRef { + if ($BinaryUrl -or $BinaryBase) { return $null } - try { - $response = Invoke-WebRequest -UseBasicParsing -Uri "$web/releases/latest" -MaximumRedirection 5 - $effective = $null - if ($response.BaseResponse.ResponseUri) { - $effective = $response.BaseResponse.ResponseUri.AbsoluteUri - } elseif ($response.BaseResponse.RequestMessage -and $response.BaseResponse.RequestMessage.RequestUri) { - $effective = $response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri - } - $prefix = "$web/releases/tag/" - if ($effective -and $effective.StartsWith($prefix)) { - return $effective.Substring($prefix.Length) - } + if ($BinaryVersion -ne "latest" -and $BinaryExact) { + return $BinaryVersion } - catch { - return $null + $current = Current-SourceVersion + if ($current) { + return "v$($current.TrimStart('v'))" + } + if ($BinaryVersion -ne "latest") { + return $BinaryVersion } return $null } -function Latest-ReleaseIsStale { - if ($BinaryUrl -or $BinaryBase -or $BinaryVersion -ne "latest") { - return $false - } - $latest = Latest-ReleaseTag - $current = Current-SourceVersion - if (-not $latest -or -not $current) { - return $false - } - $latestVersion = $latest.TrimStart("v") - if ((Compare-DoshVersion $latestVersion $current) -lt 0) { - Write-Warning "latest release $latestVersion is older than source $current; skipping stale prebuilt" - return $true - } - return $false -} - function Verify-ArchiveChecksum($Url, $Archive) { $checksumPath = "$Archive.sha256" try { @@ -475,16 +376,11 @@ if ($env:DOSH_INSTALL_BINDIR_FILE) { Apply-PendingBinaryReplacements $bindir function Install-Prebuilt { - if (Latest-ReleaseIsStale) { - return $false - } - $url = Release-LatestTagDownloadUrl - if (-not $url) { - $url = Release-DownloadUrl - } + $url = Release-DownloadUrl if (-not $url) { return $false } + $script:ResolvedSourceRef = Source-ReleaseRef $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-bin-" + [guid]::NewGuid()) $zip = Join-Path $tmp (Release-ArtifactName) $extract = Join-Path $tmp "extract" @@ -520,7 +416,10 @@ function Install-Prebuilt { function Install-FromSource { Ensure-Cargo - if (Test-Path "Cargo.toml") { + if ($FromCurrent) { + if (-not (Test-Path "Cargo.toml")) { + throw "-FromCurrent requires a Dosh checkout with Cargo.toml" + } $src = (Get-Location).Path } else { if (-not $Repo) { @@ -530,21 +429,26 @@ function Install-FromSource { $sourceCache = Assert-SafeUpdateCache $UpdateCache $parent = Split-Path -Parent $sourceCache New-Item -ItemType Directory -Force -Path $parent | Out-Null + $sourceRef = if ($script:ResolvedSourceRef) { $script:ResolvedSourceRef } else { "main" } if (Test-Path (Join-Path $sourceCache ".git")) { git -C $sourceCache remote set-url origin $Repo - $fetchArgs = @("-C", $sourceCache, "fetch", "--depth", "1", "origin", "main") + $fetchArgs = @("-C", $sourceCache, "fetch", "--depth", "1", "origin", $sourceRef) if ($Quiet) { - $fetchArgs = @("-C", $sourceCache, "fetch", "-q", "--depth", "1", "origin", "main") + $fetchArgs = @("-C", $sourceCache, "fetch", "-q", "--depth", "1", "origin", $sourceRef) } git @fetchArgs - git -C $sourceCache checkout -q -B main FETCH_HEAD + if ($sourceRef -eq "main") { + git -C $sourceCache checkout -q -B main FETCH_HEAD + } else { + git -C $sourceCache checkout -q --detach FETCH_HEAD + } } else { if (Test-Path $sourceCache) { Remove-Item -Recurse -Force $sourceCache } - $cloneArgs = @("clone", "--depth", "1", "--branch", "main", $Repo, $sourceCache) + $cloneArgs = @("clone", "--depth", "1", "--branch", $sourceRef, $Repo, $sourceCache) if ($Quiet) { - $cloneArgs = @("clone", "-q", "--depth", "1", "--branch", "main", $Repo, $sourceCache) + $cloneArgs = @("clone", "-q", "--depth", "1", "--branch", $sourceRef, $Repo, $sourceCache) } git @cloneArgs | Out-Null } diff --git a/install.sh b/install.sh index 44d3a6e..291bc77 100755 --- a/install.sh +++ b/install.sh @@ -17,6 +17,7 @@ binary_url="${DOSH_BINARY_URL:-}" binary_base="${DOSH_BINARY_BASE:-}" binary_name="${DOSH_BINARY_NAME:-}" binary_version="${DOSH_BINARY_VERSION:-latest}" +binary_exact="${DOSH_BINARY_EXACT:-0}" binary_required="${DOSH_BINARY_REQUIRED:-0}" usage() { @@ -47,7 +48,9 @@ Environment alternatives: DOSH_BINARY_NAME NAME Release tarball name; defaults to dosh-OS-ARCH.tar.gz DOSH_BINARY_VERSION TAG - Release tag when deriving DOSH_BINARY_BASE; default latest + Requested release tag; repository version wins unless exact + DOSH_BINARY_EXACT=1 + Require DOSH_BINARY_VERSION exactly, including downgrades DOSH_BINARY_REQUIRED=1 Fail instead of falling back to source when binary install fails EOF @@ -173,6 +176,7 @@ config_dir="$HOME/.config/dosh" data_dir="$HOME/.local/share/dosh" systemd_user_dir="$HOME/.config/systemd/user" src_dir="" +resolved_source_ref="" mkdir -p "$bindir" "$config_dir" "$data_dir" @@ -228,97 +232,13 @@ release_download_url() { return 1 fi web_base="$(repo_web_base "$repo")" || return 1 - if [ "$binary_version" = "latest" ]; then - printf '%s/releases/latest/download/%s\n' "$web_base" "$(release_artifact_name)" - else - printf '%s/releases/download/%s/%s\n' "$web_base" "$binary_version" "$(release_artifact_name)" - fi -} - -release_latest_tag_download_url() { - if [ -n "$binary_url" ] || [ -n "$binary_base" ] || [ "$binary_version" != "latest" ] || [ -z "$repo" ]; then - return 1 - fi - web_base="$(repo_web_base "$repo")" || return 1 - latest_url="$(curl -fsSL -o /dev/null -w '%{url_effective}' "$web_base/releases/latest" 2>/dev/null || true)" - case "$latest_url" in - "$web_base"/releases/tag/*) - tag="${latest_url##"$web_base"/releases/tag/}" - [ -n "$tag" ] || return 1 - printf '%s/releases/download/%s/%s\n' "$web_base" "$tag" "$(release_artifact_name)" - ;; - *) - return 1 - ;; - esac -} - -version_core() { - value="${1#v}" - value="${value%%+*}" - printf '%s\n' "${value%%-*}" -} - -version_prerelease() { - value="${1#v}" - value="${value%%+*}" - case "$value" in - *-*) printf '%s\n' "${value#*-}" ;; - *) printf '\n' ;; - esac -} - -version_core_part() { - version="$1" - index="$2" - version_core "$version" \ - | sed 's/[^0-9][^0-9]*/ /g' \ - | awk -v index="$index" '{ value=$index; if (value == "") value=0; print value }' -} - -prerelease_less_than() { - left="$1" - right="$2" - if [ -z "$left" ] && [ -z "$right" ]; then - return 1 - fi - if [ -z "$left" ]; then - return 1 - fi - if [ -z "$right" ]; then - return 0 - fi - if [ "$left" = "$right" ]; then - return 1 - fi - left_rc="$(printf '%s\n' "$left" | sed -n 's/^[Rr][Cc]\([0-9][0-9]*\)$/\1/p')" - right_rc="$(printf '%s\n' "$right" | sed -n 's/^[Rr][Cc]\([0-9][0-9]*\)$/\1/p')" - if [ -n "$left_rc" ] && [ -n "$right_rc" ]; then - [ "$left_rc" -lt "$right_rc" ] - return - fi - first="$(printf '%s\n%s\n' "$left" "$right" | LC_ALL=C sort | sed -n '1p')" - [ "$first" = "$left" ] -} - -version_less_than() { - left="$1" - right="$2" - for index in 1 2 3 4 5 6 7 8; do - lpart="$(version_core_part "$left" "$index")" - rpart="$(version_core_part "$right" "$index")" - if [ "$lpart" -lt "$rpart" ]; then - return 0 - fi - if [ "$lpart" -gt "$rpart" ]; then - return 1 - fi - done - prerelease_less_than "$(version_prerelease "$left")" "$(version_prerelease "$right")" + release_ref="$(release_source_ref)" || return 1 + printf '%s/releases/download/%s/%s\n' "$web_base" "$release_ref" "$(release_artifact_name)" } current_source_version() { - if [ -f Cargo.toml ]; then + if [ "$from_current" -eq 1 ]; then + [ -f Cargo.toml ] || return 1 sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | sed -n '1p' return 0 fi @@ -329,32 +249,21 @@ current_source_version() { | sed -n '1p' } -latest_release_tag() { - [ -n "$repo" ] || return 1 - web_base="$(repo_web_base "$repo")" || return 1 - latest_url="$(curl -fsSL -o /dev/null -w '%{url_effective}' "$web_base/releases/latest" 2>/dev/null || true)" - case "$latest_url" in - "$web_base"/releases/tag/*) - tag="${latest_url##"$web_base"/releases/tag/}" - [ -n "$tag" ] || return 1 - printf '%s\n' "$tag" - ;; - *) - return 1 - ;; - esac -} - -latest_release_is_stale() { - if [ -n "$binary_url" ] || [ -n "$binary_base" ] || [ "$binary_version" != "latest" ]; then +release_source_ref() { + if [ -n "$binary_url" ] || [ -n "$binary_base" ]; then return 1 fi - latest="$(latest_release_tag || true)" + if [ "$binary_version" != "latest" ] && [ "$binary_exact" = "1" ]; then + printf '%s\n' "$binary_version" + return 0 + fi current="$(current_source_version || true)" - [ -n "$latest" ] && [ -n "$current" ] || return 1 - latest="${latest#v}" - if version_less_than "$latest" "$current"; then - echo "latest release $latest is older than source $current; skipping stale prebuilt" >&2 + if [ -n "$current" ]; then + printf 'v%s\n' "${current#v}" + return 0 + fi + if [ "$binary_version" != "latest" ]; then + printf '%s\n' "$binary_version" return 0 fi return 1 @@ -450,10 +359,8 @@ verify_archive_version() { } try_install_prebuilt() { - if latest_release_is_stale; then - return 1 - fi - download_url="$(release_latest_tag_download_url || release_download_url)" || return 1 + download_url="$(release_download_url)" || return 1 + resolved_source_ref="$(release_source_ref || true)" tmpdir="$(mktemp -d)" archive="$tmpdir/$(release_artifact_name)" checksum_file="$archive.sha256" @@ -490,7 +397,11 @@ try_install_prebuilt() { install_from_source() { ensure_cargo - if [ "$from_current" -eq 1 ] || [ -f Cargo.toml ]; then + if [ "$from_current" -eq 1 ]; then + [ -f Cargo.toml ] || { + echo "--from-current requires a Dosh checkout with Cargo.toml" >&2 + exit 2 + } src_dir="$(pwd)" else if [ -z "$repo" ]; then @@ -500,22 +411,27 @@ install_from_source() { update_cache="$(safe_update_cache_path "$update_cache")" need git mkdir -p "$(dirname "$update_cache")" + source_ref="${resolved_source_ref:-main}" if [ -d "$update_cache/.git" ]; then [ "$quiet" != "1" ] && echo "Updating Dosh source" git -C "$update_cache" remote set-url origin "$repo" if [ "$quiet" = "1" ]; then - git -C "$update_cache" fetch -q --depth 1 origin main + git -C "$update_cache" fetch -q --depth 1 origin "$source_ref" else - git -C "$update_cache" fetch --depth 1 origin main + git -C "$update_cache" fetch --depth 1 origin "$source_ref" + fi + if [ "$source_ref" = "main" ]; then + git -C "$update_cache" checkout -q -B main FETCH_HEAD + else + git -C "$update_cache" checkout -q --detach FETCH_HEAD fi - git -C "$update_cache" checkout -q -B main FETCH_HEAD else [ "$quiet" != "1" ] && echo "Downloading Dosh source" rm -rf "$update_cache" if [ "$quiet" = "1" ]; then - git clone -q --depth 1 --branch main "$repo" "$update_cache" >/dev/null + git clone -q --depth 1 --branch "$source_ref" "$repo" "$update_cache" >/dev/null else - git clone --depth 1 --branch main "$repo" "$update_cache" >/dev/null + git clone --depth 1 --branch "$source_ref" "$repo" "$update_cache" >/dev/null fi fi src_dir="$update_cache" diff --git a/scripts/test-install-windows.ps1 b/scripts/test-install-windows.ps1 new file mode 100644 index 0000000..238442d --- /dev/null +++ b/scripts/test-install-windows.ps1 @@ -0,0 +1,168 @@ +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$version = "9.8.7-rc3" +$root = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-installer-test-" + [guid]::NewGuid()) +$webRoot = Join-Path $root "web" +$repoRoot = Join-Path $webRoot "repo" +$releaseRoot = Join-Path $repoRoot "releases\download\v$version" +$rawRoot = Join-Path $repoRoot "raw\branch\main" +$stageRoot = Join-Path $root "stage\dosh" +$requestLog = Join-Path $root "requests.log" +$prefix = Join-Path $root "prefix" +$home = Join-Path $root "home" +$unrelated = Join-Path $root "unrelated" +$job = $null +$savedBinaryVersion = $env:DOSH_BINARY_VERSION +$savedBinaryExact = $env:DOSH_BINARY_EXACT + +function Invoke-TestInstaller($HomePath, $WorkingDirectory, $InstallPrefix, $Repository) { + $savedHome = $env:HOME + $savedUserProfile = $env:USERPROFILE + try { + $env:HOME = $HomePath + $env:USERPROFILE = $HomePath + Push-Location $WorkingDirectory + try { + $powershell = (Get-Process -Id $PID).Path + & $powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "..\install.ps1") -Role client -Repo $Repository -Prefix $InstallPrefix -BinaryRequired + if ($LASTEXITCODE -ne 0) { + throw "Windows installer exited with status $LASTEXITCODE" + } + } + finally { + Pop-Location + } + } + finally { + $env:HOME = $savedHome + $env:USERPROFILE = $savedUserProfile + } +} + +try { + New-Item -ItemType Directory -Force -Path $releaseRoot, $rawRoot, (Join-Path $stageRoot "bin"), $home, $unrelated | Out-Null + @" +[package] +name = "dosh" +version = "$version" +"@ | Set-Content -Encoding ascii -NoNewline (Join-Path $rawRoot "Cargo.toml") + @" +[package] +name = "not-dosh" +version = "99.0.0" +"@ | Set-Content -Encoding ascii -NoNewline (Join-Path $unrelated "Cargo.toml") + "$version`n" | Set-Content -Encoding ascii -NoNewline (Join-Path $stageRoot "VERSION") + "fixture" | Set-Content -Encoding ascii -NoNewline (Join-Path $stageRoot "bin\dosh-client.exe") + + $archive = Join-Path $releaseRoot "dosh-windows-x86_64.zip" + Compress-Archive -Force -Path $stageRoot -DestinationPath $archive + $digest = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant() + "$digest dosh-windows-x86_64.zip`n" | Set-Content -Encoding ascii -NoNewline "$archive.sha256" + + $probe = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $probe.Start() + $port = ([System.Net.IPEndPoint]$probe.LocalEndpoint).Port + $probe.Stop() + $listenPrefix = "http://127.0.0.1:$port/" + $job = Start-Job -ArgumentList $webRoot, $listenPrefix, $requestLog -ScriptBlock { + param($Root, $Prefix, $Log) + $listener = [System.Net.HttpListener]::new() + $listener.Prefixes.Add($Prefix) + $listener.Start() + try { + while ($listener.IsListening) { + $context = $listener.GetContext() + $path = [System.Uri]::UnescapeDataString($context.Request.Url.AbsolutePath.TrimStart('/')) + Add-Content -Encoding ascii -Path $Log -Value $context.Request.Url.AbsolutePath + $file = Join-Path $Root ($path.Replace('/', [System.IO.Path]::DirectorySeparatorChar)) + if (Test-Path -LiteralPath $file -PathType Leaf) { + $bytes = [System.IO.File]::ReadAllBytes($file) + $context.Response.StatusCode = 200 + $context.Response.ContentLength64 = $bytes.Length + if ($context.Request.HttpMethod -ne "HEAD") { + $context.Response.OutputStream.Write($bytes, 0, $bytes.Length) + } + } else { + $context.Response.StatusCode = 404 + } + $context.Response.Close() + } + } + finally { + $listener.Stop() + } + } + + $ready = $false + for ($attempt = 0; $attempt -lt 50; $attempt++) { + try { + Invoke-WebRequest -UseBasicParsing -Uri "${listenPrefix}repo/raw/branch/main/Cargo.toml" | Out-Null + $ready = $true + break + } + catch { + Start-Sleep -Milliseconds 100 + } + } + if (-not $ready) { + throw "installer fixture HTTP server did not start" + } + + $env:DOSH_BINARY_VERSION = "v0.1.17" + Remove-Item Env:DOSH_BINARY_EXACT -ErrorAction SilentlyContinue + Invoke-TestInstaller $home $unrelated $prefix "${listenPrefix}repo.git" + + if (-not (Test-Path (Join-Path $prefix "bin\dosh-client.exe"))) { + throw "Windows installer did not install dosh-client.exe" + } + $requests = Get-Content $requestLog -Raw + if ($requests -notmatch "/repo/raw/branch/main/Cargo.toml") { + throw "Windows installer did not read the repository release version" + } + if ($requests -notmatch "/repo/releases/download/v$([regex]::Escape($version))/dosh-windows-x86_64.zip") { + throw "Windows installer did not request the repository-declared release artifact" + } + if ($requests -match "/releases/latest" -or $requests -match "99.0.0") { + throw "Windows installer used a stable redirect or unrelated Cargo.toml" + } + + "fixture-v2" | Set-Content -Encoding ascii -NoNewline (Join-Path $stageRoot "bin\dosh-client.exe") + Compress-Archive -Force -Path $stageRoot -DestinationPath $archive + $digest = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant() + "$digest dosh-windows-x86_64.zip`n" | Set-Content -Encoding ascii -NoNewline "$archive.sha256" + + $installedAlias = Join-Path $prefix "bin\dosh.exe" + $lock = [System.IO.File]::Open($installedAlias, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::None) + try { + Invoke-TestInstaller $home $unrelated $prefix "${listenPrefix}repo.git" + $pending = @(Get-ChildItem (Join-Path $prefix "bin") -Filter "dosh.exe.pending.*") + if ($pending.Count -ne 1) { + throw "locked Windows executable was not staged exactly once" + } + } + finally { + $lock.Dispose() + } + $replaced = $false + for ($attempt = 0; $attempt -lt 100; $attempt++) { + if ((Get-Content $installedAlias -Raw) -eq "fixture-v2" -and -not (Get-ChildItem (Join-Path $prefix "bin") -Filter "dosh.exe.pending.*")) { + $replaced = $true + break + } + Start-Sleep -Milliseconds 100 + } + if (-not $replaced) { + throw "staged Windows executable was not applied after its lock was released" + } + Write-Host "Windows installer runtime test passed" +} +finally { + $env:DOSH_BINARY_VERSION = $savedBinaryVersion + $env:DOSH_BINARY_EXACT = $savedBinaryExact + if ($job) { + Stop-Job $job -ErrorAction SilentlyContinue + Remove-Job $job -Force -ErrorAction SilentlyContinue + } + Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue +} diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index 6d94ef8..731599c 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -4313,27 +4313,6 @@ fn update_installer_url(raw_base: &str, os: &str) -> String { format!("{raw_base}/raw/branch/main/{}", update_installer_name(os)) } -fn latest_release_download_url(repo: &str, artifact: &str) -> Option { - let web = repo - .strip_suffix(".git") - .unwrap_or(repo) - .trim_end_matches('/'); - if !web.starts_with("http://") && !web.starts_with("https://") { - return None; - } - Some(format!("{web}/releases/latest/download/{artifact}")) -} - -fn release_tag_from_effective_url(web: &str, effective: &str) -> Option { - let prefix = format!("{web}/releases/tag/"); - let tag = effective.strip_prefix(&prefix)?; - if tag.is_empty() { - None - } else { - Some(tag.to_string()) - } -} - fn release_version_from_tag(tag: &str) -> &str { tag.strip_prefix('v').unwrap_or(tag) } @@ -4346,20 +4325,6 @@ fn update_version_status(local: &str, latest: &str) -> &'static str { } } -fn update_binary_version_for_installer(local: &str, latest_tag: Option<&str>) -> Option { - let latest = latest_tag.map(release_version_from_tag)?; - if compare_dotted_versions(latest, local) == std::cmp::Ordering::Less { - Some(format!("v{local}")) - } else { - None - } -} - -fn effective_update_artifact_tag(local: &str, latest_tag: Option<&str>) -> Option { - update_binary_version_for_installer(local, latest_tag) - .or_else(|| latest_tag.map(ToOwned::to_owned)) -} - fn compare_dotted_versions(left: &str, right: &str) -> std::cmp::Ordering { let left = parse_comparable_version(left); let right = parse_comparable_version(right); @@ -4447,7 +4412,7 @@ fn parse_dotted_version(version: &str) -> Vec { .collect() } -fn latest_release_tag(repo: &str) -> Result> { +fn repository_release_tag(repo: &str) -> Result> { let web = repo .strip_suffix(".git") .unwrap_or(repo) @@ -4455,28 +4420,30 @@ fn latest_release_tag(repo: &str) -> Result> { if !web.starts_with("http://") && !web.starts_with("https://") { return Ok(None); } - let latest_url = format!("{web}/releases/latest"); + let manifest_url = format!("{web}/raw/branch/main/Cargo.toml"); let output = if cfg!(windows) { - let script = windows_effective_url_script(&latest_url); - windows_powershell_output(&script, &format!("resolve latest release for {web}"))? + let script = windows_url_contents_script(&manifest_url); + windows_powershell_output(&script, &format!("read release version from {web}"))? } else { Command::new("curl") .arg("-fsSL") - .arg("-o") - .arg("/dev/null") - .arg("-w") - .arg("%{url_effective}") - .arg(latest_url) + .arg(manifest_url) .stdin(Stdio::null()) .output() - .with_context(|| format!("resolve latest release for {web}"))? + .with_context(|| format!("read release version from {web}"))? }; if !output.status.success() { return Ok(None); } - let effective = String::from_utf8_lossy(&output.stdout); - let effective = effective.trim(); - Ok(release_tag_from_effective_url(web, effective)) + let manifest = + String::from_utf8(output.stdout).context("repository Cargo.toml is not UTF-8")?; + let parsed: toml::Value = toml::from_str(&manifest).context("parse repository Cargo.toml")?; + let version = parsed + .get("package") + .and_then(|package| package.get("version")) + .and_then(toml::Value::as_str) + .filter(|version| !version.is_empty()); + Ok(version.map(|version| format!("v{}", version.trim_start_matches('v')))) } fn release_tag_download_url(repo: &str, tag: &str, artifact: &str) -> Option { @@ -4524,7 +4491,7 @@ fn run_update( let update_remote_server = options.role.updates_remote_server_from_os(os); if options.check_only { let local_version = env!("CARGO_PKG_VERSION"); - let latest_tag = latest_release_tag(&repo)?; + let release_tag = repository_release_tag(&repo)?; println!("dosh {local_version}"); println!("repo: {repo}"); if let Some(local_installer_role) = local_installer_role { @@ -4538,7 +4505,7 @@ fn run_update( ); println!("remote_server: {}", config.server); } - match latest_tag.as_deref() { + match release_tag.as_deref() { Some(tag) => { let latest_version = release_version_from_tag(tag); println!( @@ -4553,22 +4520,14 @@ fn run_update( update_remote_server, &repo, &artifact, + release_tag.as_deref(), ) { - LocalPrebuiltCheckTarget::Url(latest_url) => { - let mut status = "missing"; - let mut display_url = latest_url.clone(); - if let Some(tag_url) = - effective_update_artifact_tag(local_version, latest_tag.as_deref()) - .as_deref() - .and_then(|tag| release_tag_download_url(&repo, tag, &artifact)) - { - display_url = tag_url; - if url_reachable(&display_url)? { - status = "available"; - } - } else if url_reachable(&latest_url)? { - status = "available"; - } + LocalPrebuiltCheckTarget::Url(display_url) => { + let status = if url_reachable(&display_url)? { + "available" + } else { + "missing; source fallback will build the same tag" + }; println!("prebuilt: {status} ({artifact})"); println!("prebuilt_url: {display_url}"); } @@ -4578,6 +4537,9 @@ fn run_update( LocalPrebuiltCheckTarget::RemoteOnly => { println!("prebuilt: skipped for remote-only update"); } + LocalPrebuiltCheckTarget::ReleaseUnknown => { + println!("prebuilt: unavailable; repository release version is unknown"); + } } return Ok(()); } @@ -4586,10 +4548,7 @@ fn run_update( .join("dosh") .join("source"); let use_prebuilt = std::env::var("DOSH_USE_PREBUILT").unwrap_or_else(|_| "1".to_string()); - let binary_version = update_binary_version_for_installer( - env!("CARGO_PKG_VERSION"), - latest_release_tag(&repo)?.as_deref(), - ); + let binary_version = repository_release_tag(&repo)?; if update_remote_server { run_remote_server_update( config, @@ -4619,6 +4578,7 @@ enum LocalPrebuiltCheckTarget { Url(String), NonHttpRepo, RemoteOnly, + ReleaseUnknown, } fn local_prebuilt_check_target( @@ -4626,6 +4586,7 @@ fn local_prebuilt_check_target( update_remote_server: bool, repo: &str, artifact: &str, + release_tag: Option<&str>, ) -> LocalPrebuiltCheckTarget { if local_installer_role.is_none() { return if update_remote_server { @@ -4634,7 +4595,10 @@ fn local_prebuilt_check_target( LocalPrebuiltCheckTarget::NonHttpRepo }; } - latest_release_download_url(repo, artifact) + let Some(release_tag) = release_tag else { + return LocalPrebuiltCheckTarget::ReleaseUnknown; + }; + release_tag_download_url(repo, release_tag, artifact) .map(LocalPrebuiltCheckTarget::Url) .unwrap_or(LocalPrebuiltCheckTarget::NonHttpRepo) } @@ -4727,6 +4691,7 @@ fn unix_update_script( ); if let Some(version) = binary_version { env.push_str(&format!(" DOSH_BINARY_VERSION={}", shell_word(version))); + env.push_str(" DOSH_BINARY_EXACT=1"); } let mut script = format!( "curl -fsSL {} | {} sh -s -- {}", @@ -4758,6 +4723,7 @@ fn remote_unix_server_update_script( ); if let Some(version) = binary_version { env.push_str(&format!(" DOSH_BINARY_VERSION={}", shell_word(version))); + env.push_str(" DOSH_BINARY_EXACT=1"); } format!( "curl -fsSL {} | {} sh -s -- server", @@ -4795,6 +4761,7 @@ fn windows_update_script( "$env:DOSH_BINARY_VERSION={};", powershell_string(version) )); + script.push_str("$env:DOSH_BINARY_EXACT='1';"); } if config.server != "user@example.com" { script.push_str(&format!( @@ -4809,19 +4776,12 @@ fn windows_update_script( script } -fn windows_effective_url_script(url: &str) -> String { +fn windows_url_contents_script(url: &str) -> String { let url = powershell_string(url); format!( "$ErrorActionPreference='Stop';\ $ProgressPreference='SilentlyContinue';\ - $response=Invoke-WebRequest -UseBasicParsing -Uri {url} -MaximumRedirection 5;\ - if ($response.BaseResponse.ResponseUri) {{ \ - $response.BaseResponse.ResponseUri.AbsoluteUri \ - }} elseif ($response.BaseResponse.RequestMessage -and $response.BaseResponse.RequestMessage.RequestUri) {{ \ - $response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri \ - }} else {{ \ - {url} \ - }}" + (Invoke-WebRequest -UseBasicParsing -Uri {url} -MaximumRedirection 5).Content" ) } @@ -10950,39 +10910,38 @@ mod tests { SshPathTokenContext, StartupGateMode, StatusAction, TERMINAL_CLEANUP, TERMINAL_SNAPSHOT_RESET, UpdateOptions, UpdateRole, auth_allows, cache_key, cache_server_prefix, cleanup_stream_state, clear_cached_credentials, - effective_update_artifact_tag, ensure_tui_safe_status_overlay, expand_ssh_path_tokens, - first_resolved_addr, imported_host_block, input_contains_focus_in, input_matches_escape, - is_local_status_target, is_resume_response_for_client, latest_release_download_url, - load_first_native_identity_with_prompt, local_symlink_target_is_dir, - local_username_from_env, native_proxy_udp_warning, newest_client_trace_path_from, - note_authenticated_contact, note_snapshot_rendered, parse_dynamic_forward, - parse_escape_key, parse_local_forward, parse_remote_forward, parse_single_remote_path, - parse_ssh_config, parse_trace_line, parse_trace_options, parse_trace_report_options, - parse_trace_summary, parse_update_options, post_submit_hold_duration, - queue_or_send_stream_data, queue_pending_user_input, queue_stale_pending_user_input, - raw_contains_host_table, recv_response_until, refresh_live_addr, release_artifact_name_for, - release_tag_download_url, release_tag_from_effective_url, release_version_from_tag, - remote_unix_server_update_script, render_frame_bytes, render_status_overlay, requested_env, - resolve_forward_agent_endpoint, resolved_startup_command, retire_stream_state, - retransmit_stream_closes, retransmit_stream_eofs, retransmit_stream_opens, - retransmit_stream_window_adjusts, rewrite_forward_command, sanitize_trace_name, - selected_predict_mode, selected_udp_host, send_stream_eof, server_version_mismatch, - should_flush_terminal_input_after_contact, should_health_log_client_start, - should_hold_during_startup_gate, should_hold_post_submit_input, - should_reconnect_before_input_for_local_sleep, should_repaint_idle_terminal, - should_strip_unowned_terminal_reports, socket_bind_display, split_after_command_submit, - split_forward_spec, split_trace_tokens, ssh_command_target, ssh_config_uses_proxy, - ssh_config_word_for_os, ssh_destination_host, ssh_username, ssh_with_user, startup_command, - status_ssh_target, strip_stale_mouse_reports, strip_terminal_focus_reports, - strip_unowned_terminal_reports, summarize_trace_file, summarize_trace_file_with_mode, - terminal_private_mode_transition, toml_bare_key_or_quoted, top_trace_events, - trace_report_warnings, unix_update_script, update_binary_version_for_installer, - update_installer_url, update_version_status, upsert_managed_block, valid_forward_host, - vscode_command_candidates, vscode_fallback_command, vscode_safe_alias, - wake_repaint_retry_deadline, windows_command_word, windows_deferred_update_script, - windows_effective_url_script, windows_mode_from_readonly, + ensure_tui_safe_status_overlay, expand_ssh_path_tokens, first_resolved_addr, + imported_host_block, input_contains_focus_in, input_matches_escape, is_local_status_target, + is_resume_response_for_client, load_first_native_identity_with_prompt, + local_symlink_target_is_dir, local_username_from_env, native_proxy_udp_warning, + newest_client_trace_path_from, note_authenticated_contact, note_snapshot_rendered, + parse_dynamic_forward, parse_escape_key, parse_local_forward, parse_remote_forward, + parse_single_remote_path, parse_ssh_config, parse_trace_line, parse_trace_options, + parse_trace_report_options, parse_trace_summary, parse_update_options, + post_submit_hold_duration, queue_or_send_stream_data, queue_pending_user_input, + queue_stale_pending_user_input, raw_contains_host_table, recv_response_until, + refresh_live_addr, release_artifact_name_for, release_tag_download_url, + release_version_from_tag, remote_unix_server_update_script, render_frame_bytes, + render_status_overlay, requested_env, resolve_forward_agent_endpoint, + resolved_startup_command, retire_stream_state, retransmit_stream_closes, + retransmit_stream_eofs, retransmit_stream_opens, retransmit_stream_window_adjusts, + rewrite_forward_command, sanitize_trace_name, selected_predict_mode, selected_udp_host, + send_stream_eof, server_version_mismatch, should_flush_terminal_input_after_contact, + should_health_log_client_start, should_hold_during_startup_gate, + should_hold_post_submit_input, should_reconnect_before_input_for_local_sleep, + should_repaint_idle_terminal, should_strip_unowned_terminal_reports, socket_bind_display, + split_after_command_submit, split_forward_spec, split_trace_tokens, ssh_command_target, + ssh_config_uses_proxy, ssh_config_word_for_os, ssh_destination_host, ssh_username, + ssh_with_user, startup_command, status_ssh_target, strip_stale_mouse_reports, + strip_terminal_focus_reports, strip_unowned_terminal_reports, summarize_trace_file, + summarize_trace_file_with_mode, terminal_private_mode_transition, toml_bare_key_or_quoted, + top_trace_events, trace_report_warnings, unix_update_script, update_installer_url, + update_version_status, upsert_managed_block, valid_forward_host, vscode_command_candidates, + vscode_fallback_command, vscode_safe_alias, wake_repaint_retry_deadline, + windows_command_word, windows_deferred_update_script, windows_mode_from_readonly, windows_powershell_command_candidates, windows_readonly_from_mode, windows_update_script, - windows_url_reachable_script, windows_vt_input_mode, windows_vt_output_mode, + windows_url_contents_script, windows_url_reachable_script, windows_vt_input_mode, + windows_vt_output_mode, }; use dosh::config::{ClientConfig, CommandExtension, HostConfig}; use dosh::native::EnvVar; @@ -13421,9 +13380,10 @@ mod tests { false, "https://git.palav.dev/Palav/dosh.git", "dosh-windows-x86_64.zip", + Some("v1.0.0-rc42"), ), super::LocalPrebuiltCheckTarget::Url( - "https://git.palav.dev/Palav/dosh/releases/latest/download/dosh-windows-x86_64.zip" + "https://git.palav.dev/Palav/dosh/releases/download/v1.0.0-rc42/dosh-windows-x86_64.zip" .to_string() ) ); @@ -13433,6 +13393,7 @@ mod tests { false, "git@git.palav.dev:Palav/dosh.git", "dosh-windows-x86_64.zip", + Some("v1.0.0-rc42"), ), super::LocalPrebuiltCheckTarget::NonHttpRepo ); @@ -13442,9 +13403,20 @@ mod tests { UpdateRole::Server.updates_remote_server_from_os("windows"), "https://git.palav.dev/Palav/dosh.git", "dosh-windows-x86_64.zip", + Some("v1.0.0-rc42"), ), super::LocalPrebuiltCheckTarget::RemoteOnly ); + assert_eq!( + super::local_prebuilt_check_target( + Some("client"), + false, + "https://git.palav.dev/Palav/dosh.git", + "dosh-windows-x86_64.zip", + None, + ), + super::LocalPrebuiltCheckTarget::ReleaseUnknown + ); } #[test] @@ -13552,12 +13524,13 @@ mod tests { #[test] fn windows_update_probes_are_powershell_native() { - let latest = - windows_effective_url_script("https://git.palav.dev/Palav/dosh/releases/latest"); - assert!(latest.contains("Invoke-WebRequest -UseBasicParsing")); - assert!(latest.contains("-MaximumRedirection 5")); - assert!(latest.contains("$response.BaseResponse.ResponseUri.AbsoluteUri")); - assert!(!latest.contains("curl")); + let manifest = windows_url_contents_script( + "https://git.palav.dev/Palav/dosh/raw/branch/main/Cargo.toml", + ); + assert!(manifest.contains("Invoke-WebRequest -UseBasicParsing")); + assert!(manifest.contains("-MaximumRedirection 5")); + assert!(manifest.contains(".Content")); + assert!(!manifest.contains("curl")); let reachable = windows_url_reachable_script( "https://git.palav.dev/Palav/dosh/releases/latest/download/dosh-windows-x86_64.zip", @@ -13578,36 +13551,7 @@ mod tests { } #[test] - fn update_uses_local_tag_when_release_latest_is_older() { - assert_eq!( - update_binary_version_for_installer("1.0.0-rc41", Some("v1.0.0-rc37")).as_deref(), - Some("v1.0.0-rc41") - ); - assert_eq!( - update_binary_version_for_installer("1.0.0-rc41", Some("v1.0.0-rc41")), - None - ); - assert_eq!( - update_binary_version_for_installer("1.0.0-rc41", Some("v1.0.0-rc42")), - None - ); - assert_eq!( - update_binary_version_for_installer("1.0.0", Some("v1.0.0-rc42")).as_deref(), - Some("v1.0.0") - ); - assert_eq!( - update_binary_version_for_installer("1.0.0-rc42", Some("v1.0.0")), - None - ); - assert_eq!( - effective_update_artifact_tag("1.0.0-rc41", Some("v1.0.0-rc37")).as_deref(), - Some("v1.0.0-rc41") - ); - assert_eq!( - effective_update_artifact_tag("1.0.0-rc41", Some("v1.0.0-rc42")).as_deref(), - Some("v1.0.0-rc42") - ); - assert_eq!(effective_update_artifact_tag("1.0.0-rc41", None), None); + fn update_pins_the_repository_release_for_every_platform() { let config = ClientConfig::default(); let unix = unix_update_script( &config, @@ -13619,6 +13563,7 @@ mod tests { Some("v1.0.0-rc41"), ); assert!(unix.contains("DOSH_BINARY_VERSION='v1.0.0-rc41'")); + assert!(unix.contains("DOSH_BINARY_EXACT=1")); assert!(unix.contains("| DOSH_REPO=")); assert!(unix.contains(" sh -s -- 'both'")); @@ -13632,6 +13577,7 @@ mod tests { Some("v1.0.0-rc41"), ); assert!(windows.contains("$env:DOSH_BINARY_VERSION='v1.0.0-rc41';")); + assert!(windows.contains("$env:DOSH_BINARY_EXACT='1';")); } #[test] @@ -13687,17 +13633,23 @@ mod tests { } #[test] - fn release_download_url_uses_latest_release_asset() { + fn release_download_url_uses_explicit_repository_release() { assert_eq!( - latest_release_download_url( + release_tag_download_url( "https://git.palav.dev/Palav/dosh.git", + "v1.0.0-rc42", "dosh-linux-x86_64.tar.gz" ) .unwrap(), - "https://git.palav.dev/Palav/dosh/releases/latest/download/dosh-linux-x86_64.tar.gz" + "https://git.palav.dev/Palav/dosh/releases/download/v1.0.0-rc42/dosh-linux-x86_64.tar.gz" ); assert!( - latest_release_download_url("git@git.palav.dev:Palav/dosh.git", "artifact").is_none() + release_tag_download_url( + "git@git.palav.dev:Palav/dosh.git", + "v1.0.0-rc42", + "artifact" + ) + .is_none() ); } @@ -13726,15 +13678,7 @@ mod tests { } #[test] - fn release_tag_parses_effective_latest_url() { - assert_eq!( - release_tag_from_effective_url( - "https://example.com/owner/dosh", - "https://example.com/owner/dosh/releases/tag/v1.0.0" - ) - .as_deref(), - Some("v1.0.0") - ); + fn release_tag_builds_exact_artifact_url() { assert_eq!(release_version_from_tag("v1.0.0"), "1.0.0"); assert_eq!(release_version_from_tag("2026.06.28"), "2026.06.28"); assert_eq!( @@ -13748,7 +13692,6 @@ mod tests { "https://example.com/owner/dosh/releases/download/v1.0.0/dosh-linux-x86_64.tar.gz" ) ); - assert!(release_tag_from_effective_url("https://example.com/owner/dosh", "").is_none()); } #[test] diff --git a/tests/installer_runtime.rs b/tests/installer_runtime.rs new file mode 100644 index 0000000..efcb8d3 --- /dev/null +++ b/tests/installer_runtime.rs @@ -0,0 +1,265 @@ +#![cfg(unix)] + +use sha2::{Digest, Sha256}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tempfile::TempDir; + +const VERSION: &str = "9.8.7-rc3"; + +fn write_executable(path: &Path, body: &str) { + fs::write(path, body).unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); +} + +struct InstallerFixture { + root: TempDir, + fake_bin: PathBuf, + archive: PathBuf, + checksum: PathBuf, + curl_log: PathBuf, + git_log: PathBuf, + version: String, +} + +impl InstallerFixture { + fn new(version: &str) -> Self { + let root = tempfile::tempdir().unwrap(); + let fake_bin = root.path().join("fake-bin"); + let stage = root.path().join("stage/dosh"); + fs::create_dir_all(fake_bin.as_path()).unwrap(); + fs::create_dir_all(stage.join("bin")).unwrap(); + fs::write(stage.join("VERSION"), format!("{version}\n")).unwrap(); + write_executable( + &stage.join("bin/dosh-client"), + "#!/bin/sh\nprintf 'fixture dosh\\n'\n", + ); + + let archive = root.path().join("dosh-macos-aarch64.tar.gz"); + let status = Command::new("tar") + .args(["-C", root.path().join("stage").to_str().unwrap(), "-czf"]) + .arg(&archive) + .arg("dosh") + .status() + .unwrap(); + assert!(status.success()); + let digest = Sha256::digest(fs::read(&archive).unwrap()); + let checksum = root.path().join("dosh-macos-aarch64.tar.gz.sha256"); + fs::write( + &checksum, + format!("{digest:x} dosh-macos-aarch64.tar.gz\n"), + ) + .unwrap(); + + write_executable( + &fake_bin.join("uname"), + "#!/bin/sh\ncase \"${1:-}\" in -s) echo Darwin;; -m) echo arm64;; *) echo Darwin;; esac\n", + ); + let curl_log = root.path().join("curl.log"); + let git_log = root.path().join("git.log"); + write_executable( + &fake_bin.join("curl"), + r#"#!/bin/sh +printf '%s\n' "$*" >>"$TEST_CURL_LOG" +out= +previous= +url= +for arg in "$@"; do + if [ "$previous" = "-o" ]; then out="$arg"; fi + previous="$arg" + case "$arg" in http://*|https://*) url="$arg";; esac +done +case "$url" in + */raw/branch/main/Cargo.toml) + printf '[package]\nname = "dosh"\nversion = "%s"\n' "$TEST_RELEASE_VERSION" + ;; + *.sha256) + cp "$TEST_ARCHIVE_CHECKSUM" "$out" + ;; + */releases/download/*) + [ "${TEST_FAIL_ARCHIVE:-0}" = 1 ] && exit 22 + cp "$TEST_ARCHIVE" "$out" + ;; + *) exit 22;; +esac +"#, + ); + + Self { + root, + fake_bin, + archive, + checksum, + curl_log, + git_log, + version: version.to_string(), + } + } + + fn command(&self, cwd: &Path) -> Command { + let mut command = Command::new("sh"); + command + .arg(format!("{}/install.sh", env!("CARGO_MANIFEST_DIR"))) + .args([ + "client", + "--repo", + "https://example.invalid/Palav/dosh.git", + "--prefix", + ]) + .arg(self.root.path().join("prefix")) + .current_dir(cwd) + .env("HOME", self.root.path().join("home")) + .env( + "PATH", + format!( + "{}:{}", + self.fake_bin.display(), + std::env::var("PATH").unwrap_or_default() + ), + ) + .env("DOSH_BINARY_REQUIRED", "1") + .env("TEST_RELEASE_VERSION", &self.version) + .env("TEST_ARCHIVE", &self.archive) + .env("TEST_ARCHIVE_CHECKSUM", &self.checksum) + .env("TEST_CURL_LOG", &self.curl_log) + .env("TEST_GIT_LOG", &self.git_log); + command + } + + fn installed_client(&self) -> PathBuf { + self.root.path().join("prefix/bin/dosh-client") + } +} + +#[test] +fn unix_installer_uses_repository_version_even_inside_an_unrelated_rust_project() { + let fixture = InstallerFixture::new(VERSION); + let unrelated = fixture.root.path().join("unrelated"); + fs::create_dir_all(&unrelated).unwrap(); + fs::write( + unrelated.join("Cargo.toml"), + "[package]\nname = \"not-dosh\"\nversion = \"99.0.0\"\n", + ) + .unwrap(); + + let output = fixture.command(&unrelated).output().unwrap(); + assert!( + output.status.success(), + "installer failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(fixture.installed_client().is_file()); + let requests = fs::read_to_string(&fixture.curl_log).unwrap(); + assert!(requests.contains("/raw/branch/main/Cargo.toml")); + assert!(requests.contains(&format!( + "/releases/download/v{VERSION}/dosh-macos-aarch64.tar.gz" + ))); + assert!(!requests.contains("/releases/latest")); + assert!(!requests.contains("99.0.0")); +} + +#[test] +fn unix_installer_source_fallback_checks_out_the_same_release_tag() { + let fixture = InstallerFixture::new(VERSION); + write_executable( + &fixture.fake_bin.join("git"), + r#"#!/bin/sh +printf '%s\n' "$*" >>"$TEST_GIT_LOG" +if [ "${1:-}" = clone ]; then + for destination in "$@"; do :; done + mkdir -p "$destination/.git" +fi +"#, + ); + write_executable( + &fixture.fake_bin.join("cargo"), + "#!/bin/sh\nmkdir -p target/release\nprintf '#!/bin/sh\\n' >target/release/dosh-client\nchmod +x target/release/dosh-client\n", + ); + let cwd = fixture.root.path().join("plain"); + fs::create_dir_all(&cwd).unwrap(); + let mut command = fixture.command(&cwd); + command + .env("DOSH_BINARY_REQUIRED", "0") + .env("TEST_FAIL_ARCHIVE", "1"); + + let output = command.output().unwrap(); + assert!( + output.status.success(), + "installer failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(fixture.installed_client().is_file()); + let git = fs::read_to_string(&fixture.git_log).unwrap(); + assert!(git.contains(&format!("clone --depth 1 --branch v{VERSION}"))); + assert!(!git.contains("--branch main")); +} + +#[test] +fn unix_installer_resolves_a_stable_repository_version_without_latest_redirects() { + let version = "1.2.3"; + let fixture = InstallerFixture::new(version); + let cwd = fixture.root.path().join("plain"); + fs::create_dir_all(&cwd).unwrap(); + + let output = fixture.command(&cwd).output().unwrap(); + assert!( + output.status.success(), + "installer failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let requests = fs::read_to_string(&fixture.curl_log).unwrap(); + assert!(requests.contains(&format!( + "/releases/download/v{version}/dosh-macos-aarch64.tar.gz" + ))); + assert!(!requests.contains("/releases/latest")); +} + +#[test] +fn unix_installer_repairs_a_legacy_updater_that_requests_its_old_tag() { + let fixture = InstallerFixture::new(VERSION); + let cwd = fixture.root.path().join("plain"); + fs::create_dir_all(&cwd).unwrap(); + let mut command = fixture.command(&cwd); + command.env("DOSH_BINARY_VERSION", "v0.1.17"); + + let output = command.output().unwrap(); + assert!( + output.status.success(), + "installer failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let requests = fs::read_to_string(&fixture.curl_log).unwrap(); + assert!(requests.contains(&format!( + "/releases/download/v{VERSION}/dosh-macos-aarch64.tar.gz" + ))); + assert!(!requests.contains("/releases/download/v0.1.17/")); +} + +#[test] +fn unix_installer_honors_an_explicit_exact_release_pin() { + let pinned = "0.1.17"; + let fixture = InstallerFixture::new(pinned); + let cwd = fixture.root.path().join("plain"); + fs::create_dir_all(&cwd).unwrap(); + let mut command = fixture.command(&cwd); + command + .env("TEST_RELEASE_VERSION", VERSION) + .env("DOSH_BINARY_VERSION", format!("v{pinned}")) + .env("DOSH_BINARY_EXACT", "1"); + + let output = command.output().unwrap(); + assert!( + output.status.success(), + "installer failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let requests = fs::read_to_string(&fixture.curl_log).unwrap(); + assert!(requests.contains(&format!( + "/releases/download/v{pinned}/dosh-macos-aarch64.tar.gz" + ))); + assert!(!requests.contains(&format!("/releases/download/v{VERSION}/"))); +} diff --git a/tests/release_scripts.rs b/tests/release_scripts.rs index 4d70436..6f720eb 100644 --- a/tests/release_scripts.rs +++ b/tests/release_scripts.rs @@ -94,6 +94,8 @@ fn release_gates_test_all_targets() { assert!(windows_client.contains("runs-on: windows-latest")); assert!(windows_client.contains("name: Test Windows client")); assert!(windows_client.contains("name: PowerShell syntax check")); + assert!(windows_client.contains("name: Windows installer runtime test")); + assert!(windows_client.contains("run: scripts/test-install-windows.ps1")); assert!(windows_client.contains("shell: pwsh")); assert!( windows_client @@ -138,28 +140,24 @@ fn one_dot_zero_gates_default_to_full_length_soaks() { } #[test] -fn installers_skip_stale_latest_release_prebuilts() { +fn installers_resolve_the_repository_declared_release_without_stable_redirects() { let install = include_str!("../install.sh"); - assert!(install.contains("latest_release_is_stale")); assert!(install.contains("current_source_version")); - assert!(install.contains("latest release $latest is older than source $current")); - assert!(install.contains("version_prerelease")); - assert!(install.contains("prerelease_less_than")); - assert!( - install.contains("if latest_release_is_stale; then"), - "unix installer must skip stale latest before downloading a prebuilt" - ); + assert!(install.contains("release_source_ref")); + assert!(install.contains("printf 'v%s\\n' \"${current#v}\"")); + assert!(install.contains("DOSH_BINARY_EXACT=1")); + assert!(!install.contains("/releases/latest")); + assert!(install.contains("resolved_source_ref=\"$(release_source_ref || true)\"")); + assert!(install.contains("checkout -q --detach FETCH_HEAD")); let ps1 = include_str!("../install.ps1"); - assert!(ps1.contains("Latest-ReleaseIsStale")); assert!(ps1.contains("Current-SourceVersion")); - assert!(ps1.contains("latest release $latestVersion is older than source $current")); - assert!(ps1.contains("Version-Prerelease")); - assert!(ps1.contains("Compare-Prerelease")); - assert!( - ps1.contains("if (Latest-ReleaseIsStale)"), - "windows installer must skip stale latest before downloading a prebuilt" - ); + assert!(ps1.contains("Source-ReleaseRef")); + assert!(ps1.contains("\"v$($current.TrimStart('v'))\"")); + assert!(ps1.contains("DOSH_BINARY_EXACT")); + assert!(!ps1.contains("/releases/latest")); + assert!(ps1.contains("$script:ResolvedSourceRef = Source-ReleaseRef")); + assert!(ps1.contains("checkout -q --detach FETCH_HEAD")); } #[test] @@ -198,15 +196,16 @@ fn windows_installer_reuses_persistent_source_update_cache() { assert!(ps1.contains("$full -eq $localAppData -or $full -eq $localAppDataDosh")); assert!(ps1.contains("$sourceCache = Assert-SafeUpdateCache $UpdateCache")); assert!(ps1.contains( - "$fetchArgs = @(\"-C\", $sourceCache, \"fetch\", \"--depth\", \"1\", \"origin\", \"main\")" + "$fetchArgs = @(\"-C\", $sourceCache, \"fetch\", \"--depth\", \"1\", \"origin\", $sourceRef)" )); - assert!(ps1.contains("$fetchArgs = @(\"-C\", $sourceCache, \"fetch\", \"-q\", \"--depth\", \"1\", \"origin\", \"main\")")); + assert!(ps1.contains("$fetchArgs = @(\"-C\", $sourceCache, \"fetch\", \"-q\", \"--depth\", \"1\", \"origin\", $sourceRef)")); assert!(ps1.contains("git @fetchArgs")); assert!(ps1.contains("git -C $sourceCache checkout -q -B main FETCH_HEAD")); + assert!(ps1.contains("git -C $sourceCache checkout -q --detach FETCH_HEAD")); assert!(ps1.contains( - "$cloneArgs = @(\"clone\", \"--depth\", \"1\", \"--branch\", \"main\", $Repo, $sourceCache)" + "$cloneArgs = @(\"clone\", \"--depth\", \"1\", \"--branch\", $sourceRef, $Repo, $sourceCache)" )); - assert!(ps1.contains("$cloneArgs = @(\"clone\", \"-q\", \"--depth\", \"1\", \"--branch\", \"main\", $Repo, $sourceCache)")); + assert!(ps1.contains("$cloneArgs = @(\"clone\", \"-q\", \"--depth\", \"1\", \"--branch\", $sourceRef, $Repo, $sourceCache)")); assert!(ps1.contains("git @cloneArgs | Out-Null")); assert!( !ps1.contains("git clone --depth 1 $Repo $tmp"), @@ -218,8 +217,12 @@ fn windows_installer_reuses_persistent_source_update_cache() { fn windows_quiet_source_updates_silence_git_like_unix() { let install = include_str!("../install.sh"); let ps1 = include_str!("../install.ps1"); - assert!(install.contains("git -C \"$update_cache\" fetch -q --depth 1 origin main")); - assert!(install.contains("git clone -q --depth 1 --branch main \"$repo\" \"$update_cache\"")); + assert!(install.contains("git -C \"$update_cache\" fetch -q --depth 1 origin \"$source_ref\"")); + assert!( + install.contains( + "git clone -q --depth 1 --branch \"$source_ref\" \"$repo\" \"$update_cache\"" + ) + ); assert!(ps1.contains("if ($Quiet)")); assert!(ps1.contains("\"fetch\", \"-q\", \"--depth\"")); assert!(ps1.contains("\"clone\", \"-q\", \"--depth\""));