From 6d262800ecd425b486bcb00a14c078a0f18b07e7 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 6 Jul 2026 10:14:01 -0700 Subject: [PATCH 1/6] get-dotnetup: pin daily shortlink to concrete build to avoid checksum race The daily/quality shortlink is a mutable pointer, so downloading the binary and its .sha512 as two separate requests can straddle a new build publish, yielding a binary from one build and a checksum from another (spurious checksum mismatch). Resolve the shortlink to its concrete versioned URL once (curl --head, with a pwsh 5.1-safe .NET WebRequest backup) and derive both the binary and checksum URLs from that single resolved URL so they always match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/get-dotnetup.ps1 | 52 ++++++++++++++++++++++++++++++++++++++++ scripts/get-dotnetup.sh | 28 ++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/scripts/get-dotnetup.ps1 b/scripts/get-dotnetup.ps1 index 078d9b4938ef..45f0ebe86b62 100755 --- a/scripts/get-dotnetup.ps1 +++ b/scripts/get-dotnetup.ps1 @@ -140,6 +140,47 @@ function Get-RuntimeId { # --- Main --- +# The $BaseUrl points at a mutable 'quality' shortlink (e.g. .../daily/...), so the +# binary and its .sha512 are two independent requests against a moving target. If a +# new build publishes between them we download the binary from one build and the +# checksum from another, producing a spurious checksum mismatch. Resolve the +# shortlink to its concrete, versioned URL ONCE and derive both URLs from it so they +# always share the same build. +function Resolve-FinalUrl([string]$Url) { + # Prefer curl to stay consistent with get-dotnetup.sh. On Windows PowerShell 5.1 + # 'curl' is an ALIAS for Invoke-WebRequest, so require an actual executable via + # -CommandType Application (this also resolves 'curl' on Linux/macOS). + $curl = Get-Command curl.exe -CommandType Application -ErrorAction SilentlyContinue + if (-not $curl) { $curl = Get-Command curl -CommandType Application -ErrorAction SilentlyContinue } + if ($curl) { + $sink = [System.IO.Path]::GetTempFileName() + try { + # --head follows redirects issuing HEAD requests; --write-out reports the + # final resolved URL without downloading the (large) body. + $final = & $curl.Source --silent --show-error --location --head ` + --output $sink --write-out '%{url_effective}' $Url 2>$null + if ($LASTEXITCODE -eq 0 -and $final) { return "$final".Trim() } + } + catch { } + finally { Remove-Item $sink -Force -ErrorAction SilentlyContinue } + } + + # Safe backup for hosts without a curl executable (e.g. stock Windows + # PowerShell 5.1): resolve redirects with .NET WebRequest, which is available on + # both Windows PowerShell and PowerShell Core across all platforms. + try { + $req = [System.Net.WebRequest]::Create($Url) + $req.Method = "HEAD" + $req.AllowAutoRedirect = $true + $resp = $req.GetResponse() + try { return $resp.ResponseUri.AbsoluteUri } + finally { $resp.Dispose() } + } + catch { + return $null + } +} + $rid = Get-RuntimeId Write-Host "Detected runtime: $rid" -ForegroundColor Cyan @@ -148,6 +189,17 @@ $fileName = if ($rid -like "win-*") { "dotnetup-$rid.exe" } else { "dotnetup-$ri $downloadUrl = "$BaseUrl/$fileName" $checksumUrl = "$downloadUrl.sha512" +$resolvedUrl = Resolve-FinalUrl $downloadUrl +if ($resolvedUrl -and $resolvedUrl -like "*/public/*") { + Write-Host "Resolved '$Quality' to concrete build: $resolvedUrl" -ForegroundColor DarkGray + $downloadUrl = $resolvedUrl + # Checksums live under the sibling 'public-checksums' path with a .sha512 suffix. + $checksumUrl = ($resolvedUrl -replace '/public/', '/public-checksums/') + ".sha512" +} +else { + Write-Host "Could not resolve '$Quality' shortlink to a concrete build; using shortlink URLs directly." -ForegroundColor DarkGray +} + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "dotnetup-install-$([System.IO.Path]::GetRandomFileName())" New-Item -ItemType Directory -Path $tempDir -Force | Out-Null diff --git a/scripts/get-dotnetup.sh b/scripts/get-dotnetup.sh index a3556d0d56fe..2a8243c66b2b 100755 --- a/scripts/get-dotnetup.sh +++ b/scripts/get-dotnetup.sh @@ -169,6 +169,34 @@ FILE_NAME="dotnetup-${RID}" DOWNLOAD_URL="${BASE_URL}/${FILE_NAME}" CHECKSUM_URL="${DOWNLOAD_URL}.sha512" +# BASE_URL points at a mutable 'quality' shortlink (e.g. .../daily/...), so the +# binary and its .sha512 are two independent requests against a moving target. If a +# new build publishes between them we download the binary from one build and the +# checksum from another, producing a spurious checksum mismatch. Resolve the +# shortlink to its concrete, versioned URL ONCE and derive both the binary and +# checksum URLs from that single resolved URL so they always share the same build. +resolve_final_url() { + local url="$1" + if [ "$DOWNLOADER" = "curl" ]; then + # --head follows redirects issuing HEAD requests; --write-out reports the + # final resolved URL without downloading the (large) body. + curl --silent --show-error --location --head --output /dev/null \ + --write-out '%{url_effective}' "$url" 2>/dev/null + fi + # wget has no simple equivalent; leaving the result empty falls back to the + # shortlink URLs below (i.e. the previous, unpinned behavior). +} + +RESOLVED_URL="$(resolve_final_url "$DOWNLOAD_URL" || true)" +if [ -n "$RESOLVED_URL" ] && [[ "$RESOLVED_URL" == *"/public/"* ]]; then + info "Resolved '${QUALITY}' to concrete build: $RESOLVED_URL" + DOWNLOAD_URL="$RESOLVED_URL" + # Checksums live under the sibling 'public-checksums' path with a .sha512 suffix. + CHECKSUM_URL="${RESOLVED_URL/\/public\//\/public-checksums\/}.sha512" +else + gray "Could not resolve '${QUALITY}' shortlink to a concrete build; using shortlink URLs directly." +fi + INSTALLED_BINARY="${INSTALL_DIR}/dotnetup" TEMP_DIR=$(mktemp -d) From 962d9f75fafe385f004ee040948162a4f146291f Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 6 Jul 2026 10:19:46 -0700 Subject: [PATCH 2/6] get-dotnetup.sh: resolve concrete build URL via wget when curl is unavailable Minimal Linux images may ship only wget, so mirror the download() fallback in the shortlink resolver: parse the final Location header from 'wget --spider -S' (POSIX tolower(), portable across gawk/mawk/busybox). Resolution stays best-effort; if neither downloader can resolve it, the shortlink URLs are used. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/get-dotnetup.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/get-dotnetup.sh b/scripts/get-dotnetup.sh index 2a8243c66b2b..8e0646434cce 100755 --- a/scripts/get-dotnetup.sh +++ b/scripts/get-dotnetup.sh @@ -182,9 +182,15 @@ resolve_final_url() { # final resolved URL without downloading the (large) body. curl --silent --show-error --location --head --output /dev/null \ --write-out '%{url_effective}' "$url" 2>/dev/null + elif [ "$DOWNLOADER" = "wget" ]; then + # wget has no --write-out equivalent, but --spider -S prints the redirect + # chain's response headers; the final 'Location:' is the concrete build URL. + # tolower() (POSIX) keeps this portable across gawk/mawk/busybox awk. + wget --spider -S "$url" 2>&1 \ + | awk 'tolower($1) == "location:" { u = $2 } END { if (u != "") print u }' fi - # wget has no simple equivalent; leaving the result empty falls back to the - # shortlink URLs below (i.e. the previous, unpinned behavior). + # If neither downloader can resolve (or the request fails), the empty result + # falls back to the shortlink URLs below (i.e. the previous, unpinned behavior). } RESOLVED_URL="$(resolve_final_url "$DOWNLOAD_URL" || true)" From b9fd0376cd7302f59e70bb5ddbbb8911746a894f Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 6 Jul 2026 10:28:12 -0700 Subject: [PATCH 3/6] get-dotnetup.sh: derive checksum URL with sed to fix macOS bash 3.2 bash 3.2 (the default on macOS) preserves the backslashes from an escaped replacement in \, yielding a malformed 'https://ci.dot.net\/public-checksums\/...' URL and a failed checksum download. Use sed with a '#' delimiter, which is unambiguous and portable across BSD/macOS and GNU seds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/get-dotnetup.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/get-dotnetup.sh b/scripts/get-dotnetup.sh index 8e0646434cce..e8d7659125b2 100755 --- a/scripts/get-dotnetup.sh +++ b/scripts/get-dotnetup.sh @@ -198,7 +198,9 @@ if [ -n "$RESOLVED_URL" ] && [[ "$RESOLVED_URL" == *"/public/"* ]]; then info "Resolved '${QUALITY}' to concrete build: $RESOLVED_URL" DOWNLOAD_URL="$RESOLVED_URL" # Checksums live under the sibling 'public-checksums' path with a .sha512 suffix. - CHECKSUM_URL="${RESOLVED_URL/\/public\//\/public-checksums\/}.sha512" + # Use sed (not bash ${var/p/r}) because bash 3.2 (macOS) keeps the backslashes + # from an escaped replacement literally, producing a malformed URL. + CHECKSUM_URL="$(printf '%s' "$RESOLVED_URL" | sed 's#/public/#/public-checksums/#').sha512" else gray "Could not resolve '${QUALITY}' shortlink to a concrete build; using shortlink URLs directly." fi From e6e47377c2666fa8a0330b64309111743053b565 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 6 Jul 2026 11:29:34 -0700 Subject: [PATCH 4/6] get-dotnetup: shorten verbose comments in install scripts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/get-dotnetup.ps1 | 23 +++++++++-------------- scripts/get-dotnetup.sh | 26 ++++++++++---------------- 2 files changed, 19 insertions(+), 30 deletions(-) diff --git a/scripts/get-dotnetup.ps1 b/scripts/get-dotnetup.ps1 index 45f0ebe86b62..0600b6f7d66f 100755 --- a/scripts/get-dotnetup.ps1 +++ b/scripts/get-dotnetup.ps1 @@ -140,23 +140,19 @@ function Get-RuntimeId { # --- Main --- -# The $BaseUrl points at a mutable 'quality' shortlink (e.g. .../daily/...), so the -# binary and its .sha512 are two independent requests against a moving target. If a -# new build publishes between them we download the binary from one build and the -# checksum from another, producing a spurious checksum mismatch. Resolve the -# shortlink to its concrete, versioned URL ONCE and derive both URLs from it so they -# always share the same build. +# $BaseUrl is a mutable 'quality' shortlink, so fetching the binary and its .sha512 +# separately can straddle two builds and cause a spurious checksum mismatch. Resolve +# the shortlink to its concrete build URL once and derive both URLs from it. function Resolve-FinalUrl([string]$Url) { - # Prefer curl to stay consistent with get-dotnetup.sh. On Windows PowerShell 5.1 - # 'curl' is an ALIAS for Invoke-WebRequest, so require an actual executable via - # -CommandType Application (this also resolves 'curl' on Linux/macOS). + # Require an actual curl executable; on Windows PowerShell 5.1 'curl' is an alias + # for Invoke-WebRequest, so -CommandType Application excludes it. $curl = Get-Command curl.exe -CommandType Application -ErrorAction SilentlyContinue if (-not $curl) { $curl = Get-Command curl -CommandType Application -ErrorAction SilentlyContinue } if ($curl) { $sink = [System.IO.Path]::GetTempFileName() try { - # --head follows redirects issuing HEAD requests; --write-out reports the - # final resolved URL without downloading the (large) body. + # --head resolves redirects without downloading the body; --write-out + # reports the final URL. $final = & $curl.Source --silent --show-error --location --head ` --output $sink --write-out '%{url_effective}' $Url 2>$null if ($LASTEXITCODE -eq 0 -and $final) { return "$final".Trim() } @@ -165,9 +161,8 @@ function Resolve-FinalUrl([string]$Url) { finally { Remove-Item $sink -Force -ErrorAction SilentlyContinue } } - # Safe backup for hosts without a curl executable (e.g. stock Windows - # PowerShell 5.1): resolve redirects with .NET WebRequest, which is available on - # both Windows PowerShell and PowerShell Core across all platforms. + # Fallback for hosts without a curl executable (e.g. Windows PowerShell 5.1): + # resolve redirects with .NET WebRequest. try { $req = [System.Net.WebRequest]::Create($Url) $req.Method = "HEAD" diff --git a/scripts/get-dotnetup.sh b/scripts/get-dotnetup.sh index e8d7659125b2..6e3d16a7b730 100755 --- a/scripts/get-dotnetup.sh +++ b/scripts/get-dotnetup.sh @@ -169,37 +169,31 @@ FILE_NAME="dotnetup-${RID}" DOWNLOAD_URL="${BASE_URL}/${FILE_NAME}" CHECKSUM_URL="${DOWNLOAD_URL}.sha512" -# BASE_URL points at a mutable 'quality' shortlink (e.g. .../daily/...), so the -# binary and its .sha512 are two independent requests against a moving target. If a -# new build publishes between them we download the binary from one build and the -# checksum from another, producing a spurious checksum mismatch. Resolve the -# shortlink to its concrete, versioned URL ONCE and derive both the binary and -# checksum URLs from that single resolved URL so they always share the same build. +# BASE_URL is a mutable 'quality' shortlink, so fetching the binary and its .sha512 +# separately can straddle two builds and cause a spurious checksum mismatch. Resolve +# the shortlink to its concrete build URL once and derive both URLs from it. resolve_final_url() { local url="$1" if [ "$DOWNLOADER" = "curl" ]; then - # --head follows redirects issuing HEAD requests; --write-out reports the - # final resolved URL without downloading the (large) body. + # --head resolves redirects without downloading the body; --write-out reports + # the final URL. curl --silent --show-error --location --head --output /dev/null \ --write-out '%{url_effective}' "$url" 2>/dev/null elif [ "$DOWNLOADER" = "wget" ]; then - # wget has no --write-out equivalent, but --spider -S prints the redirect - # chain's response headers; the final 'Location:' is the concrete build URL. - # tolower() (POSIX) keeps this portable across gawk/mawk/busybox awk. + # wget lacks --write-out; --spider -S prints the redirect headers, whose final + # 'Location:' is the concrete build URL. tolower() keeps awk portable. wget --spider -S "$url" 2>&1 \ | awk 'tolower($1) == "location:" { u = $2 } END { if (u != "") print u }' fi - # If neither downloader can resolve (or the request fails), the empty result - # falls back to the shortlink URLs below (i.e. the previous, unpinned behavior). + # An empty result falls back to the shortlink URLs below (previous behavior). } RESOLVED_URL="$(resolve_final_url "$DOWNLOAD_URL" || true)" if [ -n "$RESOLVED_URL" ] && [[ "$RESOLVED_URL" == *"/public/"* ]]; then info "Resolved '${QUALITY}' to concrete build: $RESOLVED_URL" DOWNLOAD_URL="$RESOLVED_URL" - # Checksums live under the sibling 'public-checksums' path with a .sha512 suffix. - # Use sed (not bash ${var/p/r}) because bash 3.2 (macOS) keeps the backslashes - # from an escaped replacement literally, producing a malformed URL. + # Checksums live under the sibling 'public-checksums' path. Use sed, not bash + # ${var/p/r}, since bash 3.2 (macOS) keeps the escaped backslashes literally. CHECKSUM_URL="$(printf '%s' "$RESOLVED_URL" | sed 's#/public/#/public-checksums/#').sha512" else gray "Could not resolve '${QUALITY}' shortlink to a concrete build; using shortlink URLs directly." From a056320575ead674f4a7040559e64978026266b9 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 6 Jul 2026 11:57:06 -0700 Subject: [PATCH 5/6] clean wording in comments to explain scripts --- scripts/get-dotnetup.ps1 | 12 +++--------- scripts/get-dotnetup.sh | 12 ++++-------- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/scripts/get-dotnetup.ps1 b/scripts/get-dotnetup.ps1 index 0600b6f7d66f..4e58b1340f61 100755 --- a/scripts/get-dotnetup.ps1 +++ b/scripts/get-dotnetup.ps1 @@ -140,20 +140,15 @@ function Get-RuntimeId { # --- Main --- -# $BaseUrl is a mutable 'quality' shortlink, so fetching the binary and its .sha512 -# separately can straddle two builds and cause a spurious checksum mismatch. Resolve -# the shortlink to its concrete build URL once and derive both URLs from it. +# Map a 'channel' such as 'daily' to specific version url for the binary and its .sha512 to prevent release race condition mismatches function Resolve-FinalUrl([string]$Url) { - # Require an actual curl executable; on Windows PowerShell 5.1 'curl' is an alias - # for Invoke-WebRequest, so -CommandType Application excludes it. + # Require an actual curl executable; on Windows PowerShell 5.1 'curl' is an alias for Invoke-WebRequest, so -CommandType Application excludes it. $curl = Get-Command curl.exe -CommandType Application -ErrorAction SilentlyContinue if (-not $curl) { $curl = Get-Command curl -CommandType Application -ErrorAction SilentlyContinue } if ($curl) { $sink = [System.IO.Path]::GetTempFileName() try { - # --head resolves redirects without downloading the body; --write-out - # reports the final URL. - $final = & $curl.Source --silent --show-error --location --head ` + $final = & $curl.Source --silent --show-error --location --head ` # --head resolves redirects without downloading the body --output $sink --write-out '%{url_effective}' $Url 2>$null if ($LASTEXITCODE -eq 0 -and $final) { return "$final".Trim() } } @@ -162,7 +157,6 @@ function Resolve-FinalUrl([string]$Url) { } # Fallback for hosts without a curl executable (e.g. Windows PowerShell 5.1): - # resolve redirects with .NET WebRequest. try { $req = [System.Net.WebRequest]::Create($Url) $req.Method = "HEAD" diff --git a/scripts/get-dotnetup.sh b/scripts/get-dotnetup.sh index 6e3d16a7b730..76dde991b6f6 100755 --- a/scripts/get-dotnetup.sh +++ b/scripts/get-dotnetup.sh @@ -169,19 +169,15 @@ FILE_NAME="dotnetup-${RID}" DOWNLOAD_URL="${BASE_URL}/${FILE_NAME}" CHECKSUM_URL="${DOWNLOAD_URL}.sha512" -# BASE_URL is a mutable 'quality' shortlink, so fetching the binary and its .sha512 -# separately can straddle two builds and cause a spurious checksum mismatch. Resolve -# the shortlink to its concrete build URL once and derive both URLs from it. +# Map a 'channel' such as 'daily' to specific version url for the binary and its .sha512 to prevent release race condition mismatches resolve_final_url() { local url="$1" if [ "$DOWNLOADER" = "curl" ]; then - # --head resolves redirects without downloading the body; --write-out reports - # the final URL. + # --head resolves redirects without downloading the body. curl --silent --show-error --location --head --output /dev/null \ - --write-out '%{url_effective}' "$url" 2>/dev/null + --write-out '%{url_effective}' "$url" 2>/dev/null elif [ "$DOWNLOADER" = "wget" ]; then - # wget lacks --write-out; --spider -S prints the redirect headers, whose final - # 'Location:' is the concrete build URL. tolower() keeps awk portable. + # wget lacks --write-out; --spider -S prints the redirect headers, whose final 'Location:' is the concrete build URL. tolower() keeps awk portable. wget --spider -S "$url" 2>&1 \ | awk 'tolower($1) == "location:" { u = $2 } END { if (u != "") print u }' fi From eef74911b9fbbc4ec7be4582d09c4e008d8f7d8b Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 6 Jul 2026 13:06:03 -0700 Subject: [PATCH 6/6] move comment to next line Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- scripts/get-dotnetup.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/get-dotnetup.ps1 b/scripts/get-dotnetup.ps1 index 4e58b1340f61..c1dd1373b37c 100755 --- a/scripts/get-dotnetup.ps1 +++ b/scripts/get-dotnetup.ps1 @@ -148,7 +148,8 @@ function Resolve-FinalUrl([string]$Url) { if ($curl) { $sink = [System.IO.Path]::GetTempFileName() try { - $final = & $curl.Source --silent --show-error --location --head ` # --head resolves redirects without downloading the body + # --head resolves redirects without downloading the body. + $final = & $curl.Source --silent --show-error --location --head ` --output $sink --write-out '%{url_effective}' $Url 2>$null if ($LASTEXITCODE -eq 0 -and $final) { return "$final".Trim() } }