143 lines
10 KiB
PowerShell
143 lines
10 KiB
PowerShell
#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;i<Payload.Length;i++) Payload[i]=(byte)(i%251);
|
|
listener=new TcpListener(IPAddress.Loopback,0); listener.Start();
|
|
BaseUrl="http://127.0.0.1:"+((IPEndPoint)listener.LocalEndpoint).Port;
|
|
Thread t=new Thread(Accept); t.IsBackground=true; t.Start();
|
|
}
|
|
private void Accept() {
|
|
while(!stopping) {
|
|
try { TcpClient c=listener.AcceptTcpClient(); ThreadPool.QueueUserWorkItem(Serve,c); }
|
|
catch { if(stopping) return; }
|
|
}
|
|
}
|
|
private void Serve(object state) {
|
|
using(TcpClient client=(TcpClient)state) {
|
|
try {
|
|
client.ReceiveTimeout=5000; client.SendTimeout=5000;
|
|
NetworkStream stream=client.GetStream();
|
|
StreamReader reader=new StreamReader(stream,Encoding.ASCII);
|
|
string first=reader.ReadLine(); if(first==null) return;
|
|
string[] parts=first.Split(' '); string method=parts[0],path=parts[1];
|
|
string range=null,line;
|
|
while(!String.IsNullOrEmpty(line=reader.ReadLine())) if(line.StartsWith("Range:",StringComparison.OrdinalIgnoreCase)) range=line.Substring(6).Trim();
|
|
if(path.StartsWith("/redirect")) { Send(stream,"302 Found","Location: /file.bin\r\nContent-Length: 0\r\n",null); return; }
|
|
if(path.StartsWith("/missing")) { Send(stream,"404 Not Found","Content-Length: 0\r\n",null); return; }
|
|
if(path.StartsWith("/empty")) { Send(stream,"200 OK","Content-Length: 0\r\n",null); return; }
|
|
if(path.StartsWith("/truncated")) { Send(stream,"200 OK","Content-Length: 100\r\n",Encoding.ASCII.GetBytes("short")); return; }
|
|
if(path.StartsWith("/chunked")) { Send(stream,"200 OK","Transfer-Encoding: chunked\r\n",Encoding.ASCII.GetBytes("5\r\nhello\r\n0\r\n\r\n")); return; }
|
|
int start=0,end=Payload.Length-1; string status="200 OK",extra="";
|
|
if(range!=null) {
|
|
string[] limits=range.Substring(6).Split('-'); start=Int32.Parse(limits[0]);
|
|
if(limits.Length>1 && 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
|
|
}
|