diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs index f1a455974b9..a9aa1f4b6b1 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs @@ -580,10 +580,7 @@ protected override void ProcessRecord() // Check if the Resume range was not satisfiable because the file already completed downloading. // This happens when the local file is the same size as the remote file. - if (Resume.IsPresent - && response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable - && response.Content.Headers.ContentRange.HasLength - && response.Content.Headers.ContentRange.Length == _resumeFileSize) + if (IsResumeRangeAlreadyComplete(response)) { _isSuccess = true; WriteVerbose(string.Format( @@ -1354,10 +1351,7 @@ internal virtual HttpResponseMessage GetResponse(HttpClient client, HttpRequestM // Request again without the Range header because the server indicated the range was not satisfiable. // This happens when the local file is larger than the remote file. // If the size of the remote file is the same as the local file, there is nothing to resume. - if (Resume.IsPresent - && response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable - && (response.Content.Headers.ContentRange.HasLength - && response.Content.Headers.ContentRange.Length != _resumeFileSize)) + if (ShouldRetryWithoutResumeRange(response)) { _cancelToken.Cancel(); @@ -1736,6 +1730,31 @@ private void ProcessAuthentication() private bool IsPersistentSession() => MyInvocation.BoundParameters.ContainsKey(nameof(WebSession)) || MyInvocation.BoundParameters.ContainsKey(nameof(SessionVariable)); + private bool IsResumeRangeAlreadyComplete(HttpResponseMessage response) + { + if (!Resume.IsPresent || response.StatusCode != HttpStatusCode.RequestedRangeNotSatisfiable) + { + return false; + } + + ContentRangeHeaderValue contentRange = response.Content.Headers.ContentRange; + + // RFC 9110 only says 416 responses SHOULD include Content-Range. Treat a missing + // header as an already-complete resume instead of failing with a null reference. + return contentRange is null || (contentRange.HasLength && contentRange.Length == _resumeFileSize); + } + + private bool ShouldRetryWithoutResumeRange(HttpResponseMessage response) + { + if (!Resume.IsPresent || response.StatusCode != HttpStatusCode.RequestedRangeNotSatisfiable) + { + return false; + } + + ContentRangeHeaderValue contentRange = response.Content.Headers.ContentRange; + return contentRange is not null && contentRange.HasLength && contentRange.Length != _resumeFileSize; + } + /// /// Sets the ContentLength property of the request and writes the specified content to the request's RequestStream. /// diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 index 7c0fffa5c4a..13d3b99ec9f 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 @@ -2213,6 +2213,23 @@ Describe "Invoke-WebRequest tests" -Tags "Feature", "RequireAdminOnWindows" { $response.StatusCode | Should -Be 416 $response.Headers.'Content-Range'[0] | Should -BeExactly "bytes */$referenceFileSize" } + + It "Invoke-WebRequest -Resume treats 416 without Content-Range as a completed download" { + # Download the entire file + $uri = Get-WebListenerUrl -Test 'Resume' -TestValue 'MissingContentRange' + $null = Invoke-WebRequest -Uri $uri -OutFile $outFile + $fileSize = Get-Item $outFile | Select-Object -ExpandProperty Length + + $response = Invoke-WebRequest -Uri $uri -OutFile $outFile -Resume -PassThru + + $outFileHash = Get-FileHash -Algorithm SHA256 -Path $outFile + $outFileHash.Hash | Should -BeExactly $referenceFileHash.Hash + Get-Item $outFile | Select-Object -ExpandProperty Length | Should -Be $referenceFileSize + $response.Headers.'X-WebListener-Has-Range'[0] | Should -BeExactly 'true' + $response.Headers.'X-WebListener-Request-Range'[0] | Should -BeExactly "bytes=$fileSize-" + $response.StatusCode | Should -Be 416 + $response.Headers.ContainsKey('Content-Range') | Should -BeFalse + } } Context "Invoke-WebRequest retry tests" { @@ -4244,6 +4261,22 @@ Describe "Invoke-RestMethod tests" -Tags "Feature", "RequireAdminOnWindows" { $Headers.'X-WebListener-Request-Range'[0] | Should -BeExactly "bytes=$fileSize-" $Headers.'Content-Range'[0] | Should -BeExactly "bytes */$referenceFileSize" } + + It "Invoke-RestMethod -Resume treats 416 without Content-Range as a completed download" { + # Download the entire file + $uri = Get-WebListenerUrl -Test 'Resume' -TestValue 'MissingContentRange' + $null = Invoke-RestMethod -Uri $uri -OutFile $outFile + $fileSize = Get-Item $outFile | Select-Object -ExpandProperty Length + + Invoke-RestMethod -Uri $uri -OutFile $outFile -ResponseHeadersVariable 'Headers' -Resume + + $outFileHash = Get-FileHash -Algorithm SHA256 -Path $outFile + $outFileHash.Hash | Should -BeExactly $referenceFileHash.Hash + Get-Item $outFile | Select-Object -ExpandProperty Length | Should -Be $referenceFileSize + $Headers.'X-WebListener-Has-Range'[0] | Should -BeExactly 'true' + $Headers.'X-WebListener-Request-Range'[0] | Should -BeExactly "bytes=$fileSize-" + $Headers.ContainsKey('Content-Range') | Should -BeFalse + } } Context "Invoke-RestMethod retry tests" { diff --git a/test/tools/WebListener/Controllers/ResumeController.cs b/test/tools/WebListener/Controllers/ResumeController.cs index 55341894e12..a6882f44cfd 100644 --- a/test/tools/WebListener/Controllers/ResumeController.cs +++ b/test/tools/WebListener/Controllers/ResumeController.cs @@ -74,6 +74,22 @@ public async void NoResume() await Response.Body.WriteAsync(FileBytes, 0, FileBytes.Length); } + public async void MissingContentRange() + { + SetResumeResponseHeaders(); + string rangeHeader; + if (TryGetRangeHeader(out rangeHeader)) + { + Response.StatusCode = StatusCodes.Status416RequestedRangeNotSatisfiable; + return; + } + + Response.ContentType = MediaTypeNames.Application.Octet; + Response.ContentLength = FileBytes.Length; + Response.StatusCode = StatusCodes.Status200OK; + await Response.Body.WriteAsync(FileBytes, 0, FileBytes.Length); + } + public async void Bytes(int NumberBytes) { if (NumberBytes > FileBytes.Length || NumberBytes < 0)