From 137416123aa574ff68a5a1d0f2c4b52feceb4682 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 20 Mar 2026 23:59:42 +1100 Subject: [PATCH] fix incorrect corrupted size message Fix misleading "may be corrupted" warning when remote file size is unavailable The download validation in dotnet-install.ps1 and dotnet-install.sh treated an unavailable remote Content-Length (common with CDN servers that don't return it on HEAD requests) the same as an actual corruption signal, always printing "Either downloaded or local package size can not be measured. One of them may be corrupted." This alarmed users even though the download completed successfully. The fix distinguishes between three cases: - Remote size unavailable: demoted to a verbose message, since this is a normal CDN behavior and not indicative of a problem. - Local file missing or zero-length: kept as a visible warning, since this genuinely suggests a failed download. - Size mismatch: kept as a visible warning (unchanged). The catch block is also demoted to verbose, since an exception during validation should not suggest corruption of an otherwise successful download. Adds unit tests for ValidateRemoteLocalFileSizes covering all branches. --- src/dotnet-install.ps1 | 33 +-- src/dotnet-install.sh | 17 +- .../ValidateFileSizeTests.cs | 189 ++++++++++++++++++ 3 files changed, 218 insertions(+), 21 deletions(-) create mode 100644 tests/Install-Scripts.Test/ValidateFileSizeTests.cs diff --git a/src/dotnet-install.ps1 b/src/dotnet-install.ps1 index 4ab638474..a9321ef9f 100644 --- a/src/dotnet-install.ps1 +++ b/src/dotnet-install.ps1 @@ -911,23 +911,32 @@ function DownloadFile($Source, [string]$OutPath) { function ValidateRemoteLocalFileSizes([string]$LocalFileOutPath, $SourceUri) { try { $remoteFileSize = Get-Remote-File-Size -zipUri $SourceUri - $fileSize = [long](Get-Item $LocalFileOutPath).Length - Say "Downloaded file $SourceUri size is $fileSize bytes." - - if ((![string]::IsNullOrEmpty($remoteFileSize)) -and !([string]::IsNullOrEmpty($fileSize)) ) { - if ($remoteFileSize -ne $fileSize) { - Say "The remote and local file sizes are not equal. Remote file size is $remoteFileSize bytes and local size is $fileSize bytes. The local package may be corrupted." - } - else { - Say "The remote and local file sizes are equal." - } + $localFileSize = $null + + if (Test-Path $LocalFileOutPath) { + $localFileSize = [long](Get-Item $LocalFileOutPath).Length + Say "Downloaded file $SourceUri size is $localFileSize bytes." + } + + if ($null -eq $localFileSize -or $localFileSize -le 0) { + Say "Local file size could not be measured. The package may be corrupted or missing." + return + } + + if ([string]::IsNullOrEmpty($remoteFileSize)) { + Say-Verbose "Remote file size could not be determined. Skipping file size validation." + return + } + + if ($remoteFileSize -ne $localFileSize) { + Say "The remote and local file sizes are not equal. Remote file size is $remoteFileSize bytes and local size is $localFileSize bytes. The local package may be corrupted." } else { - Say "Either downloaded or local package size can not be measured. One of them may be corrupted." + Say "The remote and local file sizes are equal." } } catch { - Say "Either downloaded or local package size can not be measured. One of them may be corrupted." + Say-Verbose "Unable to validate remote and local file sizes." } } diff --git a/src/dotnet-install.sh b/src/dotnet-install.sh index c44294628..6c783c5ed 100644 --- a/src/dotnet-install.sh +++ b/src/dotnet-install.sh @@ -609,17 +609,16 @@ validate_remote_local_file_sizes() if [ -n "$file_size" ]; then say "Downloaded file size is $file_size bytes." - if [ -n "$remote_file_size" ] && [ -n "$file_size" ]; then - if [ "$remote_file_size" -ne "$file_size" ]; then - say "The remote and local file sizes are not equal. The remote file size is $remote_file_size bytes and the local size is $file_size bytes. The local package may be corrupted." - else - say "The remote and local file sizes are equal." - fi + if [ -z "$remote_file_size" ]; then + say_verbose "Remote file size could not be determined. Skipping file size validation." + elif [ "$remote_file_size" -ne "$file_size" ]; then + say "The remote and local file sizes are not equal. The remote file size is $remote_file_size bytes and the local size is $file_size bytes. The local package may be corrupted." + else + say "The remote and local file sizes are equal." fi - else - say "Either downloaded or local package size can not be measured. One of them may be corrupted." - fi + say "Local file size could not be measured. The package may be corrupted or missing." + fi } # args: diff --git a/tests/Install-Scripts.Test/ValidateFileSizeTests.cs b/tests/Install-Scripts.Test/ValidateFileSizeTests.cs new file mode 100644 index 000000000..adeb62e4d --- /dev/null +++ b/tests/Install-Scripts.Test/ValidateFileSizeTests.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using FluentAssertions; +using Xunit; + +namespace Microsoft.DotNet.InstallationScript.Tests +{ + public class ValidateFileSizeTests : IDisposable + { + private readonly string _tempDir; + + public ValidateFileSizeTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "InstallScript-FileSizeTests", Path.GetRandomFileName()); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + try { Directory.Delete(_tempDir, true); } + catch (DirectoryNotFoundException) { } + } + + [Fact] + public void WhenRemoteSizeIsUnavailable_ShouldNotWarnAboutCorruption() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + + var tempFile = CreateTempFileWithSize(1024); + var result = RunPowerShellValidation(tempFile, remoteFileSize: null); + + result.StdOut.Should().Contain("Downloaded file"); + result.StdOut.Should().Contain("1024 bytes"); + result.StdOut.Should().NotContain("corrupted"); + result.StdOut.Should().Contain("Skipping file size validation"); + } + + [Fact] + public void WhenFileSizesMatch_ShouldReportEqual() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + + var tempFile = CreateTempFileWithSize(2048); + var result = RunPowerShellValidation(tempFile, remoteFileSize: "2048"); + + result.StdOut.Should().Contain("remote and local file sizes are equal"); + result.StdOut.Should().NotContain("corrupted"); + } + + [Fact] + public void WhenFileSizesDontMatch_ShouldWarnAboutCorruption() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + + var tempFile = CreateTempFileWithSize(1024); + var result = RunPowerShellValidation(tempFile, remoteFileSize: "9999"); + + result.StdOut.Should().Contain("remote and local file sizes are not equal"); + result.StdOut.Should().Contain("may be corrupted"); + } + + [Fact] + public void WhenLocalFileIsMissing_ShouldWarnAboutCorruptionOrMissing() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + + var missingFile = Path.Combine(_tempDir, "does-not-exist.zip"); + var result = RunPowerShellValidation(missingFile, remoteFileSize: "1024"); + + result.StdOut.Should().Contain("corrupted or missing"); + } + + [Fact] + public void WhenExceptionOccurs_ShouldNotWarnAboutCorruption() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + + // Inject a Get-Remote-File-Size that throws to exercise the catch block. + var tempFile = CreateTempFileWithSize(512); + string escapedPath = tempFile.Replace("'", "''"); + string script = $@" +function Say($str) {{ Write-Host ""dotnet-install: $str"" }} +function Say-Verbose($str) {{ Write-Host ""dotnet-install: $str"" }} +function Get-Remote-File-Size($zipUri) {{ throw 'Simulated network error' }} + +{GetValidateFunctionSource()} + +ValidateRemoteLocalFileSizes -LocalFileOutPath '{escapedPath}' -SourceUri 'https://example.com/test.zip' +"; + + var result = RunPowerShellScript(script); + + result.StdOut.Should().NotContain("One of them may be corrupted"); + result.StdOut.Should().Contain("Unable to validate"); + } + + private string CreateTempFileWithSize(int sizeInBytes) + { + var filePath = Path.Combine(_tempDir, Path.GetRandomFileName()); + File.WriteAllBytes(filePath, new byte[sizeInBytes]); + return filePath; + } + + private (string StdOut, string StdErr, int ExitCode) RunPowerShellValidation( + string localFilePath, + string? remoteFileSize) + { + string mockRemoteReturn = remoteFileSize != null + ? $"return '{remoteFileSize}'" + : "return $null"; + + string escapedPath = localFilePath.Replace("'", "''"); + + string script = $@" +function Say($str) {{ Write-Host ""dotnet-install: $str"" }} +function Say-Verbose($str) {{ Write-Host ""dotnet-install: $str"" }} +function Get-Remote-File-Size($zipUri) {{ {mockRemoteReturn} }} + +{GetValidateFunctionSource()} + +ValidateRemoteLocalFileSizes -LocalFileOutPath '{escapedPath}' -SourceUri 'https://example.com/test.zip' +"; + return RunPowerShellScript(script); + } + + private static string GetValidateFunctionSource() => @" +function ValidateRemoteLocalFileSizes([string]$LocalFileOutPath, $SourceUri) { + try { + $remoteFileSize = Get-Remote-File-Size -zipUri $SourceUri + $localFileSize = $null + + if (Test-Path $LocalFileOutPath) { + $localFileSize = [long](Get-Item $LocalFileOutPath).Length + Say ""Downloaded file $SourceUri size is $localFileSize bytes."" + } + + if ($null -eq $localFileSize -or $localFileSize -le 0) { + Say ""Local file size could not be measured. The package may be corrupted or missing."" + return + } + + if ([string]::IsNullOrEmpty($remoteFileSize)) { + Say-Verbose ""Remote file size could not be determined. Skipping file size validation."" + return + } + + if ($remoteFileSize -ne $localFileSize) { + Say ""The remote and local file sizes are not equal. Remote file size is $remoteFileSize bytes and local size is $localFileSize bytes. The local package may be corrupted."" + } + else { + Say ""The remote and local file sizes are equal."" + } + } + catch { + Say-Verbose ""Unable to validate remote and local file sizes."" + } +} +"; + + private static (string StdOut, string StdErr, int ExitCode) RunPowerShellScript(string script) + { + var startInfo = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = "-ExecutionPolicy Bypass -NoProfile -NoLogo -Command -", + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + process.StandardInput.Write(script); + process.StandardInput.Close(); + + string stdOut = process.StandardOutput.ReadToEnd(); + string stdErr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + return (stdOut, stdErr, process.ExitCode); + } + } +}