#Requires -Version 5.0 # Local HTTP fixtures only. No external downloads, execution of payloads, or uploads. $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' $Repo = Split-Path $PSScriptRoot -Parent $Diagnostic = Join-Path $Repo 'Test-FileDownload.ps1' $Tokens=$null; $Errors=$null $Ast=[Management.Automation.Language.Parser]::ParseFile($Diagnostic,[ref]$Tokens,[ref]$Errors) if ($Errors.Count) { throw ($Errors | Out-String) } $WorkerAssignment=$Ast.Find({param($Node) $Node -is [Management.Automation.Language.AssignmentStatementAst] -and $Node.Left.Extent.Text -eq '$FdWorker'},$true) $WorkerExpression=$WorkerAssignment.Find({param($Node) $Node -is [Management.Automation.Language.ScriptBlockExpressionAst]},$true) $Worker=$WorkerExpression.ScriptBlock.GetScriptBlock() $ToolsAst=[Management.Automation.Language.Parser]::ParseFile((Join-Path $Repo 'Tools.ps1'),[ref]$Tokens,[ref]$Errors) foreach ($Name in @('Download-File','Get-AbsoluteURI','IsURLValid','Get-TempPath')) { $Definition=$ToolsAst.Find({param($Node) $Node -is [Management.Automation.Language.FunctionDefinitionAst] -and $Node.Name -eq $Name},$true) . ([scriptblock]::Create($Definition.Extent.Text)) } Import-Module BitsTransfer function Assert-Fd([bool]$Condition,[string]$Message) { if (!$Condition) { throw $Message } } Add-Type -TypeDefinition @' using System; using System.IO; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; public sealed class FdHttpFixture : IDisposable { private readonly TcpListener listener; private volatile bool stopping; public readonly byte[] Payload = new byte[131072]; public readonly string BaseUrl; public FdHttpFixture() { for (int i=0;i1 && limits[1].Length>0) end=Math.Min(end,Int32.Parse(limits[1])); status="206 Partial Content"; extra="Content-Range: bytes "+start+"-"+end+"/"+Payload.Length+"\r\n"; } byte[] body=new byte[end-start+1]; Buffer.BlockCopy(Payload,start,body,0,body.Length); Send(stream,status,extra+"Content-Length: "+body.Length+"\r\n",method=="HEAD"?null:body); } catch { } } } private static void Send(NetworkStream stream,string status,string headers,byte[] body) { byte[] header=Encoding.ASCII.GetBytes("HTTP/1.1 "+status+"\r\nConnection: close\r\nContent-Type: application/octet-stream\r\nAccept-Ranges: bytes\r\nLast-Modified: Mon, 01 Jan 2024 00:00:00 GMT\r\n"+headers+"\r\n"); stream.Write(header,0,header.Length); if(body!=null) stream.Write(body,0,body.Length); } public void Dispose() { stopping=true; listener.Stop(); } } '@ $Fixture=New-Object FdHttpFixture $Root=Join-Path ([IO.Path]::GetTempPath()) ('fd-diag-tests-' + [guid]::NewGuid().ToString('N')) $null=New-Item -Path $Root -ItemType Directory $SentinelJob=$null try { # Another application's BITS job must survive all diagnostic cleanups. $SentinelJob=BitsTransfer\Start-BitsTransfer -Source ($Fixture.BaseUrl+'/file.bin') -Destination (Join-Path $Root 'unrelated.bin') -Suspended -Asynchronous -DisplayName ('fd-test-sentinel-'+[guid]::NewGuid()) -Description 'Unrelated test job' $OriginalProtocol=[Net.ServicePointManager]::SecurityProtocol $OriginalHelper=(Get-Command Download-File).Definition $ReportPaths=& $Diagnostic -URL ($Fixture.BaseUrl+'/redirect.exe') -OutputDirectory (Join-Path $Root 'reports with spaces') -TimeoutSeconds 15 $Report=Import-Clixml -LiteralPath $ReportPaths.XmlReport Assert-Fd ($Report.Probes.Count -eq 10) 'Expected metadata plus both destination variants for four download methods.' Assert-Fd ($Report.Context.DestinationExtension -eq '.exe') 'Original file extension was not preserved.' $Failures=@($Report.Probes | Where-Object {$_.Status -ne 'Complete'}) Assert-Fd ($Failures.Count -eq 0) ('Unexpected failures: '+($Failures | Select-Object Name,Status,@{Name='Message';Expression={$_.Errors.Message -join '; '}} | Format-List | Out-String)) $Downloads=@($Report.Probes | Where-Object {$_.Snapshots.Count -gt 0}) $Hasher=[Security.Cryptography.SHA256]::Create() try { $ExpectedHash=[BitConverter]::ToString($Hasher.ComputeHash($Fixture.Payload)).Replace('-','') } finally {$Hasher.Clear()} foreach ($Probe in $Downloads) { $Snapshot=$Probe.Snapshots[-1] Assert-Fd ($Snapshot.Length -eq $Fixture.Payload.Length -and $Snapshot.SHA256 -eq $ExpectedHash) ('Incorrect bytes: '+$Probe.Name) Assert-Fd ($Probe.ChildProcessBits -eq [IntPtr]::Size*8) 'Child changed process architecture.' } Assert-Fd ((Get-Command Download-File).Definition -ceq $OriginalHelper) 'Diagnostic modified the caller helper.' Assert-Fd ([Net.ServicePointManager]::SecurityProtocol -eq $OriginalProtocol) 'Diagnostic modified caller TLS.' Assert-Fd (@($Report.Cleanup | Where-Object {$_.Status -ne 'Complete' -or $_.CleanupErrors.Count}).Count -eq 0) 'Cleanup reported an error.' Assert-Fd (!(Test-Path -LiteralPath (Join-Path $Report.ReportDirectory 'work'))) 'Scratch files were not removed.' Assert-Fd (Test-Path -LiteralPath $ReportPaths.TextReport) 'Readable report missing.' Assert-Fd (@(($Report.Probes | Where-Object Method -eq 'Headers').Http | Where-Object Status -eq 302).Count -gt 0) 'Redirect chain missing.' $null=BitsTransfer\Get-BitsTransfer -JobId $SentinelJob.JobId -ErrorAction Stop Write-Host 'PASS: helper, sync BITS, inspectable BITS, and GET; redirects; existing-empty overwrite; hashes; isolation; cleanup.' # Exercise response edge cases directly through the same diagnostic worker. foreach ($Case in @('empty','chunked','truncated','missing')) { $ContextPath=Join-Path $Root ($Case+'-context.xml') @{URL=$Fixture.BaseUrl+'/'+$Case; SecurityProtocol=[int]$OriginalProtocol; TimeoutSeconds=5; RunId='test'} | Export-Clixml -Path $ContextPath $SpecPath=Join-Path $Root ($Case+'-input.xml') $ResultPath=Join-Path $Root ($Case+'-result.xml') @{Name=$Case; Method='DotNetGet'; ExistingEmpty=$false; ContextPath=$ContextPath; ResultPath=$ResultPath; Destination=(Join-Path $Root ($Case+'.bin'))} | Export-Clixml -Path $SpecPath & $Worker $SpecPath $Edge=Import-Clixml -LiteralPath $ResultPath if ($Case -eq 'empty') { Assert-Fd ($Edge.Status -eq 'EmptyFile' -and $Edge.Snapshots[-1].Length -eq 0) 'Empty body misclassified.' } elseif ($Case -eq 'chunked') { Assert-Fd ($Edge.Status -eq 'Complete' -and $Edge.Snapshots[-1].Length -eq 5) 'Chunked response failed.' } else { Assert-Fd ($Edge.Status -eq 'Failed' -and $Edge.Errors.Count -gt 0) ('Failure details missing: '+$Case) } Write-Host "PASS: $Case response." } function Download-File { [CmdletBinding()] param($URL,$File) Start-Sleep -Seconds 90 } $Watch=[Diagnostics.Stopwatch]::StartNew() $TimeoutPaths=& $Diagnostic -URL ($Fixture.BaseUrl+'/file.bin') -OutputDirectory $Root -TimeoutSeconds 3 $TimeoutReport=Import-Clixml -LiteralPath $TimeoutPaths.XmlReport $HelperTimeouts=@($TimeoutReport.Probes | Where-Object {$_.Method -eq 'Helper' -and $_.Status -eq 'TimedOut'}) Assert-Fd ($HelperTimeouts.Count -eq 2) 'Stalled helper was not bounded in both destination variants.' Assert-Fd ($Watch.Elapsed.TotalSeconds -lt 100) 'Timeout run exceeded the expected total bound.' Assert-Fd (!(Test-Path -LiteralPath (Join-Path $TimeoutReport.ReportDirectory 'work'))) 'Timed-out scratch files remain.' $null=BitsTransfer\Get-BitsTransfer -JobId $SentinelJob.JobId -ErrorAction Stop Write-Host 'PASS: stalled helper is stopped; partial report survives; unrelated BITS job survives.' Write-Host "All Test-FileDownload tests passed on PowerShell $($PSVersionTable.PSVersion), $([IntPtr]::Size*8)-bit." } finally { $Fixture.Dispose() if ($SentinelJob) { BitsTransfer\Remove-BitsTransfer -BitsJob $SentinelJob -ErrorAction SilentlyContinue } $Resolved=(Resolve-Path -LiteralPath $Root).Path $Temp=[IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\')+'\' if (!$Resolved.StartsWith($Temp,[StringComparison]::OrdinalIgnoreCase) -or (Split-Path $Resolved -Leaf) -notlike 'fd-diag-tests-*') { throw 'Unexpected cleanup path.' } Remove-Item -LiteralPath $Resolved -Recurse -Force }