Files
management-scripts/Test-FileDownload.ps1
T

541 lines
33 KiB
PowerShell
Raw Normal View History

2026-09-30 10:34:09 -04:00
#Requires -Version 2.0
<#
.SYNOPSIS
Compares download paths without changing the production downloader or TLS settings.
.DESCRIPTION
Run from the failing RMM caller, after its normal Tools.ps1 bootstrap, using the
same account and PowerShell executable. A standalone run skips the helper probe
when Download-File is not loaded. Never dot-sources a downloaded payload.
Each probe runs in a bounded child process. Only that run's BITS jobs and scratch
files are removed. Reports remain in a new subdirectory of OutputDirectory.
The source deliberately uses PowerShell 2 syntax and .NET 2-era APIs. Actual
PowerShell 2, 3, and 4 runtime validation is still required on legacy machines.
.PARAMETER URL
HTTP or HTTPS file URL. Downloads are never executed.
.PARAMETER OutputDirectory
Report parent directory; defaults to the account's temporary directory.
.PARAMETER TimeoutSeconds
Wall-clock limit for each probe, including download and hashing. Defaults to 120.
.EXAMPLE
& .\Test-FileDownload.ps1 -URL 'https://example.com/tool.exe'
#>
[CmdletBinding()]
param(
[Parameter(Mandatory=$true, Position=0)][string]$URL,
[string]$OutputDirectory = [IO.Path]::GetTempPath(),
[ValidateRange(1,3600)][int]$TimeoutSeconds = 120
)
# All diagnostic state is local even when the caller dot-sources this script.
& {
param($FdUrlText, $FdOutputParent, $FdTimeout)
$ErrorActionPreference = 'Stop'
$FdUri = New-Object Uri $FdUrlText
if (!$FdUri.IsAbsoluteUri -or @('http','https') -notcontains $FdUri.Scheme -or $FdUri.UserInfo) {
throw 'URL must be an absolute HTTP(S) URL without embedded credentials.'
}
if ($PSVersionTable.PSVersion.Major -gt 5) { throw 'Run this diagnostic in Windows PowerShell 2-5.1, using the same shell as the RMM.' }
$FdRunId = [guid]::NewGuid().ToString('N')
$FdParent = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($FdOutputParent)
$FdRoot = Join-Path $FdParent ('file-download-diagnostic-' + $FdRunId)
$null = New-Item -Path $FdRoot -ItemType Directory -ErrorAction Stop
$FdWork = Join-Path $FdRoot 'work'
$null = New-Item -Path $FdWork -ItemType Directory -ErrorAction Stop
$FdExe = [Diagnostics.Process]::GetCurrentProcess().MainModule.FileName
if ([IO.Path]::GetFileName($FdExe) -ine 'powershell.exe') {
throw 'Run from powershell.exe rather than ISE or an embedded host so child probes can use the same executable safely.'
}
$FdExtension = [IO.Path]::GetExtension($FdUri.AbsolutePath)
if ($FdExtension -notmatch '^\.[A-Za-z0-9]{1,16}$') { $FdExtension = '.download' }
function Get-FdTextHash($Text) {
$FdHash = [Security.Cryptography.SHA256]::Create()
try { return [BitConverter]::ToString($FdHash.ComputeHash([Text.Encoding]::UTF8.GetBytes($Text))).Replace('-','') }
finally { $FdHash.Clear() }
}
function Export-FdXml($Value,$Path,$Depth) {
# PowerShell 2 lacks some LiteralPath parameter sets. A scoped drive keeps
# wildcard characters in the parent directory out of Export-Clixml's path.
$Drive = 'FdXml' + [guid]::NewGuid().ToString('N')
$null = New-PSDrive -Name $Drive -PSProvider FileSystem -Root ([IO.Path]::GetDirectoryName($Path)) -Scope Local
try { $Value | Export-Clixml -Path ($Drive + ':\' + [IO.Path]::GetFileName($Path)) -Depth $Depth }
finally { Remove-PSDrive -Name $Drive -Scope Local }
}
$FdCommands = @()
foreach ($FdName in @('Download-File','Get-AbsoluteURI','IsURLValid','Get-TempPath','Write-Error','Write-Output','Start-BitsTransfer')) {
$FdCommand = Get-Command -Name $FdName -ErrorAction SilentlyContinue | Select-Object -First 1
$FdDefinition = $null
if ($FdCommand -and @('Function','Filter') -contains [string]$FdCommand.CommandType) { $FdDefinition = $FdCommand.Definition }
$FdCommands += New-Object PSObject -Property @{
Name=$FdName; CommandType=[string]$FdCommand.CommandType; ModuleName=$FdCommand.ModuleName
ModuleVersion=[string]$FdCommand.Module.Version; Definition=$FdDefinition
DefinitionSHA256=$(if ($null -ne $FdDefinition) { Get-FdTextHash $FdDefinition } else { $null })
ResolvedDefinition=$FdCommand.Definition
}
}
$FdContext = New-Object PSObject -Property @{
RunId=$FdRunId; URL=$FdUri.AbsoluteUri; Commands=$FdCommands
SecurityProtocol=[int][Net.ServicePointManager]::SecurityProtocol
CertificateCallbackPresent=($null -ne [Net.ServicePointManager]::ServerCertificateValidationCallback)
ProxyType=$(if ([Net.WebRequest]::DefaultWebProxy) { [Net.WebRequest]::DefaultWebProxy.GetType().FullName } else { 'None' })
ProxyAddress=[string]([Net.WebRequest]::DefaultWebProxy.Address)
PowerShellVersion=[string]$PSVersionTable.PSVersion; CLRVersion=[string][Environment]::Version
ProcessBits=([IntPtr]::Size * 8); Executable=$FdExe
Identity=[Security.Principal.WindowsIdentity]::GetCurrent().Name
WorkingDirectory=(Get-Location).Path; TimeoutSeconds=$FdTimeout; DestinationExtension=$FdExtension
}
$FdContextPath = Join-Path $FdWork 'context.clixml'
Export-FdXml $FdContext $FdContextPath 12
# This is diagnostic code, not code fetched from the URL. It is copied into each
# fresh child so leaked connections and preference changes cannot cross probes.
$FdWorker = {
param($InputPath)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$InputData = Import-Clixml -Path ([Management.Automation.WildcardPattern]::Escape($InputPath))
$Context = Import-Clixml -Path ([Management.Automation.WildcardPattern]::Escape($InputData.ContextPath))
$Result = New-Object PSObject -Property @{
Name=$InputData.Name; Method=$InputData.Method; ExistingEmpty=$InputData.ExistingEmpty
Status='Running'; StartedUtc=[DateTime]::UtcNow.ToString('o'); ElapsedSeconds=0
Errors=@(); Notes=@(); Http=@(); Bits=@(); Snapshots=@(); HelperOutput=@(); HelperBitsSource=@(); Environment=$null
CleanupErrors=@(); ChildPowerShell=[string]$PSVersionTable.PSVersion
ChildCLR=[string][Environment]::Version; ChildIdentity=[Security.Principal.WindowsIdentity]::GetCurrent().Name
ChildProcessBits=([IntPtr]::Size * 8)
}
$Watch = [Diagnostics.Stopwatch]::StartNew()
function Export-FdXml($Value,$Path,$Depth) {
$Drive = 'FdXml' + [guid]::NewGuid().ToString('N')
$null = New-PSDrive -Name $Drive -PSProvider FileSystem -Root ([IO.Path]::GetDirectoryName($Path)) -Scope Local
try { $Value | Export-Clixml -Path ($Drive + ':\' + [IO.Path]::GetFileName($Path)) -Depth $Depth }
finally { Remove-PSDrive -Name $Drive -Scope Local }
}
function Save-Checkpoint {
$Result.ElapsedSeconds = [Math]::Round($Watch.Elapsed.TotalSeconds,3)
# Replace only this probe's checkpoint; the parent reads it after child exit.
Export-FdXml $Result $InputData.ResultPath 16
}
function Get-ErrorDetail($Record) {
return New-Object PSObject -Property @{
Message=$Record.Exception.Message; Exception=$Record.Exception.ToString()
FullyQualifiedErrorId=$Record.FullyQualifiedErrorId
Category=[string]$Record.CategoryInfo; Position=$Record.InvocationInfo.PositionMessage
Record=($Record | Format-List * -Force | Out-String -Width 240)
}
}
function Get-FileSnapshot($Label) {
$Snapshot = New-Object PSObject -Property @{Label=$Label; TimeUtc=[DateTime]::UtcNow.ToString('o'); Exists=$false; Length=$null; LengthAfterHash=$null; SHA256=$null; FirstBytes=$null; Error=$null}
$Stream = $null; $Hash = $null
try {
if (Test-Path -LiteralPath $InputData.Destination -PathType Leaf) {
$Snapshot.Exists = $true
$Snapshot.Length = (Get-Item -LiteralPath $InputData.Destination).Length
$Stream = [IO.File]::Open($InputData.Destination,[IO.FileMode]::Open,[IO.FileAccess]::Read,([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete))
$Prefix = New-Object byte[] 8
$Count = $Stream.Read($Prefix,0,$Prefix.Length)
if ($Count -gt 0) { $Snapshot.FirstBytes = [BitConverter]::ToString($Prefix,0,$Count) }
$Stream.Position = 0
$Hash = [Security.Cryptography.SHA256]::Create()
$Snapshot.SHA256 = [BitConverter]::ToString($Hash.ComputeHash($Stream)).Replace('-','')
$Snapshot.LengthAfterHash = (Get-Item -LiteralPath $InputData.Destination).Length
}
} catch { $Snapshot.Error = Get-ErrorDetail $_ }
finally { if ($Stream) { $Stream.Dispose() }; if ($Hash) { $Hash.Clear() } }
return $Snapshot
}
function Add-BitsSnapshot($Job, $Stage) {
$Result.Bits += New-Object PSObject -Property @{
Stage=$Stage; TimeUtc=[DateTime]::UtcNow.ToString('o'); JobId=[string]$Job.JobId
JobState=[string]$Job.JobState; BytesTotal=$Job.BytesTotal; BytesTransferred=$Job.BytesTransferred
FilesTotal=$Job.FilesTotal; FilesTransferred=$Job.FilesTransferred
ErrorCode=$Job.ErrorCode; ErrorContext=[string]$Job.ErrorContext; ErrorDescription=$Job.ErrorDescription
ProxyUsage=[string]$Job.ProxyUsage; OwnerAccount=$Job.OwnerAccount
}
Save-Checkpoint
}
function Remove-OwnedBitsJobs {
try {
Import-Module BitsTransfer -ErrorAction Stop
$Jobs = @(BitsTransfer\Get-BitsTransfer -ErrorAction Stop | Where-Object {
$_.DisplayName -eq $InputData.Tag -and $_.Description -eq $Context.RunId
})
foreach ($Job in $Jobs) {
Add-BitsSnapshot $Job 'Before cleanup'
BitsTransfer\Remove-BitsTransfer -BitsJob $Job -ErrorAction Stop
}
} catch { $Result.CleanupErrors += Get-ErrorDetail $_ }
}
function Add-HttpSnapshot($Response, $Method, $RequestUri) {
$Headers = @{}
foreach ($Header in @('Content-Length','Content-Range','Content-Type','Transfer-Encoding','Content-Encoding','Accept-Ranges','ETag','Last-Modified','Location','Via','Age','Server')) {
$Headers[$Header] = $Response.Headers[$Header]
}
$Result.Http += New-Object PSObject -Property @{
Method=$Method; RequestUri=$RequestUri; ResponseUri=$Response.ResponseUri.AbsoluteUri
Status=[int]$Response.StatusCode; ContentLength=$Response.ContentLength; Headers=$Headers
}
Save-Checkpoint
}
function Open-HttpResponse($Method, $UseRange) {
$CurrentUri = New-Object Uri $Context.URL
for ($Redirect = 0; $Redirect -le 10; $Redirect++) {
$Request = [Net.HttpWebRequest]::Create($CurrentUri)
$Request.Method = $Method
$Request.AllowAutoRedirect = $false
$Request.Timeout = $Context.TimeoutSeconds * 1000
$Request.ReadWriteTimeout = $Context.TimeoutSeconds * 1000
if ($UseRange) { $Request.AddRange(0,15) }
try { $Response = $Request.GetResponse() }
catch {
if ($_.Exception.Response) {
try { Add-HttpSnapshot $_.Exception.Response $Method $CurrentUri.AbsoluteUri }
finally { $_.Exception.Response.Close() }
}
throw
}
Add-HttpSnapshot $Response $Method $CurrentUri.AbsoluteUri
if (@(301,302,303,307,308) -contains [int]$Response.StatusCode) {
try { $NextUri = New-Object Uri $CurrentUri, $Response.Headers['Location'] }
finally { $Response.Close() }
if (@('http','https') -notcontains $NextUri.Scheme -or $NextUri.UserInfo) { throw 'Unsupported redirect target.' }
if ($CurrentUri.Scheme -eq 'https' -and $NextUri.Scheme -eq 'http') { throw 'HTTPS-to-HTTP redirect recorded but not followed.' }
$CurrentUri = $NextUri
} else { return $Response }
}
throw 'HTTP redirect limit exceeded.'
}
try {
Save-Checkpoint
# A child does not inherit this process-local .NET setting. Reproduce the
# caller's exact value, rather than selecting a new protocol or changing Windows.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]$Context.SecurityProtocol
if ($InputData.Method -eq 'Cleanup') {
Remove-OwnedBitsJobs
$Result.Status = 'Complete'
} elseif ($InputData.Method -eq 'Environment') {
$Info = @{}
$Result.Environment = $Info
try { $Info['OS'] = Get-WmiObject -Class Win32_OperatingSystem -ErrorAction Stop | Select-Object Caption,Version,BuildNumber,OSArchitecture,ServicePackMajorVersion }
catch { $Info['OSError'] = Get-ErrorDetail $_ }
Save-Checkpoint
$Info['BitsService'] = Get-Service -Name BITS -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType
$Info['BitsModules'] = @(Get-Module -ListAvailable -Name BitsTransfer | Select-Object Name,Version,Path)
$BitsBinary = Join-Path $Env:WINDIR 'System32\qmgr.dll'
$NativeBitsBinary = Join-Path $Env:WINDIR 'Sysnative\qmgr.dll'
if (Test-Path -LiteralPath $NativeBitsBinary) { $BitsBinary = $NativeBitsBinary }
$Info['BitsBinaryPath'] = $BitsBinary
try { $Info['BitsBinaryVersion'] = [Diagnostics.FileVersionInfo]::GetVersionInfo($BitsBinary).FileVersion }
catch { $Info['BitsBinaryVersionError'] = Get-ErrorDetail $_ }
$Info['DotNetProxyType'] = $Context.ProxyType
$Info['CallerProxyAddress'] = $Context.ProxyAddress
$Info['DotNetSecurityProtocol'] = [string][Net.ServicePointManager]::SecurityProtocol
$Info['CertificateCallbackPresent'] = $Context.CertificateCallbackPresent
Save-Checkpoint
$Info['WinHttpProxy'] = (& "$Env:WINDIR\System32\netsh.exe" winhttp show proxy 2>&1 | Out-String)
$Info['WinHttpRegistryViews'] = @{}
foreach ($View in @('32','64')) {
# reg.exe supports explicit views without requiring the .NET 4 RegistryView API.
try { $Info['WinHttpRegistryViews'][$View] = (& "$Env:WINDIR\System32\reg.exe" query 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\WinHttp' /v DefaultSecureProtocols ("/reg:" + $View) 2>&1 | Out-String) }
catch { $Info['WinHttpRegistryViews'][$View] = $_.Exception.Message }
}
$Info['Registry'] = @()
foreach ($Key in @(
'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\WinHttp',
'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Internet Settings\WinHttp',
'HKLM:\SOFTWARE\Microsoft\.NETFramework\v2.0.50727',
'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319',
'HKLM:\SOFTWARE\Wow6432Node\Microsoft\.NETFramework\v2.0.50727',
'HKLM:\SOFTWARE\Wow6432Node\Microsoft\.NETFramework\v4.0.30319',
'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.0\Client',
'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.1\Client',
'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client'
)) {
$Values = Get-ItemProperty -LiteralPath $Key -ErrorAction SilentlyContinue
$Info['Registry'] += New-Object PSObject -Property @{
Key=$Key; Present=($null -ne $Values); Enabled=$Values.Enabled; DisabledByDefault=$Values.DisabledByDefault
DefaultSecureProtocols=$Values.DefaultSecureProtocols; SchUseStrongCrypto=$Values.SchUseStrongCrypto
SystemDefaultTlsVersions=$Values.SystemDefaultTlsVersions
ProxyEnable=$Values.ProxyEnable; ProxyServer=$Values.ProxyServer; AutoConfigURL=$Values.AutoConfigURL
}
}
Save-Checkpoint
$Info['DotNetResolvedProxy'] = $(if ([Net.WebRequest]::DefaultWebProxy) { [Net.WebRequest]::DefaultWebProxy.GetProxy((New-Object Uri $Context.URL)).AbsoluteUri } else { 'None' })
$Result.Status = 'Complete'
} elseif ($InputData.Method -eq 'Headers') {
foreach ($HttpMethod in @('HEAD','Range')) {
$Response = $null
try {
if ($HttpMethod -eq 'HEAD') { $Response = Open-HttpResponse 'HEAD' $false }
else { $Response = Open-HttpResponse 'GET' $true }
} catch { $Result.Errors += Get-ErrorDetail $_ }
finally { if ($Response) { $Response.Close() } }
}
$Result.Status = $(if ($Result.Errors.Count) { 'Failed' } else { 'Complete' })
} else {
if ($InputData.ExistingEmpty) { [IO.File]::WriteAllBytes($InputData.Destination,(New-Object byte[] 0)) }
$Result.Snapshots += Get-FileSnapshot 'Before transfer'
Save-Checkpoint
if ($InputData.Method -eq 'Helper') {
$Helper = @($Context.Commands | Where-Object { $_.Name -eq 'Download-File' -and $_.Definition })
$BitsResolution = $Context.Commands | Where-Object { $_.Name -eq 'Start-BitsTransfer' }
if ($Helper.Count -eq 0) {
$Result.Status = 'Skipped'
$Result.Notes += 'Download-File was not a loaded function; no Tools.ps1 was fetched or sourced.'
} elseif ($BitsResolution.CommandType -ne 'Cmdlet' -or $BitsResolution.ModuleName -ne 'BitsTransfer') {
$Result.Status = 'Skipped'
$Result.Notes += 'Start-BitsTransfer is shadowed or unavailable. Definition recorded; helper not run because its jobs cannot be safely tagged.'
} else {
Import-Module BitsTransfer -ErrorAction Stop
foreach ($Command in $Context.Commands) {
if ($Command.Definition -and $Command.Name -ne 'Start-BitsTransfer') {
. ([scriptblock]::Create(('function global:{0} {{ {1} }}' -f $Command.Name,$Command.Definition)))
}
}
$Global:LogFile = $InputData.LogPath
# Preserve all native BITS parameters; add only ownership tags so
# a timed-out synchronous job can be identified without touching others.
$Global:FdNativeBits = Get-Command BitsTransfer\Start-BitsTransfer
$Global:FdBitsTag = $InputData.Tag
$Global:FdBitsRun = $Context.RunId
$Global:FdWorkerResult = $Result
function global:Start-BitsTransfer {
[CmdletBinding()] param()
dynamicparam {
$Dictionary = New-Object Management.Automation.RuntimeDefinedParameterDictionary
foreach ($Parameter in $Global:FdNativeBits.Parameters.Values) {
if (@('Verbose','Debug','ErrorAction','WarningAction','InformationAction','ErrorVariable','WarningVariable','InformationVariable','OutVariable','OutBuffer','PipelineVariable','ProgressAction') -notcontains $Parameter.Name) {
$Attributes = New-Object 'Collections.ObjectModel.Collection[Attribute]'
foreach ($Attribute in $Parameter.Attributes) { $Attributes.Add($Attribute) }
$Dynamic = New-Object Management.Automation.RuntimeDefinedParameter $Parameter.Name,$Parameter.ParameterType,$Attributes
$Dictionary.Add($Parameter.Name,$Dynamic)
}
}
return $Dictionary
}
process {
$Global:FdWorkerResult.HelperBitsSource += @($PSBoundParameters['Source'])
$PSBoundParameters['DisplayName'] = $Global:FdBitsTag
$PSBoundParameters['Description'] = $Global:FdBitsRun
& $Global:FdNativeBits @PSBoundParameters
}
}
$Result.Notes += 'Loaded helper functions copied into a fresh process; BITS display name/description tagged for cleanup; helper logs redirected to diagnostic scratch.'
$Result.HelperOutput = @(Download-File -URL $Context.URL -File $InputData.Destination -ErrorAction Stop 2>&1 | ForEach-Object { [string]$_ })
}
} elseif ($InputData.Method -eq 'BitsSync') {
Import-Module BitsTransfer -ErrorAction Stop
BitsTransfer\Start-BitsTransfer -Source $Context.URL -Destination $InputData.Destination -DisplayName $InputData.Tag -Description $Context.RunId -ErrorAction Stop
} elseif ($InputData.Method -eq 'BitsJob') {
Import-Module BitsTransfer -ErrorAction Stop
$Job = BitsTransfer\Start-BitsTransfer -Source $Context.URL -Destination $InputData.Destination -DisplayName $InputData.Tag -Description $Context.RunId -Asynchronous -ErrorAction Stop
$PreviousState = ''
while ($true) {
$Job = BitsTransfer\Get-BitsTransfer -JobId $Job.JobId -ErrorAction Stop
if ([string]$Job.JobState -ne $PreviousState) { Add-BitsSnapshot $Job 'State change'; $PreviousState = [string]$Job.JobState }
if (@('Transferred','Error','TransientError','Suspended','Cancelled','Acknowledged') -contains [string]$Job.JobState) { break }
Start-Sleep -Milliseconds 250
}
if ([string]$Job.JobState -ne 'Transferred') { throw ('BITS job ended probe in {0}: {1}' -f $Job.JobState,$Job.ErrorDescription) }
Add-BitsSnapshot $Job 'Before completion'
BitsTransfer\Complete-BitsTransfer -BitsJob $Job -ErrorAction Stop
} elseif ($InputData.Method -eq 'DotNetGet') {
$Response = $null; $InputStream = $null; $OutputStream = $null
try {
$Response = Open-HttpResponse 'GET' $false
$InputStream = $Response.GetResponseStream()
$OutputStream = [IO.File]::Open($InputData.Destination,[IO.FileMode]::Create,[IO.FileAccess]::Write,[IO.FileShare]::Read)
$Buffer = New-Object byte[] 65536
$Received = [long]0
while (($Count = $InputStream.Read($Buffer,0,$Buffer.Length)) -gt 0) { $OutputStream.Write($Buffer,0,$Count); $Received += $Count }
if ($Response.ContentLength -ge 0 -and $Received -ne $Response.ContentLength) { throw "HTTP body length mismatch: expected $($Response.ContentLength), received $Received." }
} finally {
if ($OutputStream) { $OutputStream.Dispose() }
if ($InputStream) { $InputStream.Dispose() }
if ($Response) { $Response.Close() }
}
}
if ($Result.Status -ne 'Skipped') {
$Result.Snapshots += Get-FileSnapshot 'After transfer'
Save-Checkpoint
Start-Sleep -Milliseconds 1000
$Result.Snapshots += Get-FileSnapshot 'One second later'
$After = $Result.Snapshots[$Result.Snapshots.Count-1]
$Result.Status = $(if ($After.Error) { 'Failed' } elseif (!$After.Exists) { 'MissingFile' } elseif ($After.Length -eq 0) { 'EmptyFile' } else { 'Complete' })
}
}
} catch {
$Result.Status = 'Failed'
$Result.Errors += Get-ErrorDetail $_
if ($InputData.Destination) { $Result.Snapshots += Get-FileSnapshot 'After error' }
} finally {
Save-Checkpoint
if (@('Helper','BitsSync','BitsJob') -contains $InputData.Method) { Remove-OwnedBitsJobs }
Save-Checkpoint
}
}
$FdWorkerPath = Join-Path $FdWork 'worker.ps1'
[IO.File]::WriteAllText($FdWorkerPath,$FdWorker.ToString(),[Text.Encoding]::UTF8)
# Capture both child pipes concurrently without PowerShell callbacks on foreign
# threads. .NET file APIs also avoid Start-Process's wildcard path expansion.
if (!('EmberkomDownloadDiagnostic.ChildRunner' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.Diagnostics;
using System.IO;
namespace EmberkomDownloadDiagnostic {
public class ChildResult { public bool TimedOut; public int ExitCode; }
public static class ChildRunner {
public static ChildResult Run(string exe,string args,string directory,string stdout,string stderr,int milliseconds) {
using (StreamWriter output=new StreamWriter(stdout))
using (StreamWriter error=new StreamWriter(stderr))
using (Process child=new Process()) {
child.StartInfo.FileName=exe; child.StartInfo.Arguments=args;
child.StartInfo.WorkingDirectory=directory;
child.StartInfo.UseShellExecute=false; child.StartInfo.CreateNoWindow=true;
child.StartInfo.WindowStyle=ProcessWindowStyle.Hidden;
child.StartInfo.RedirectStandardOutput=true; child.StartInfo.RedirectStandardError=true;
child.OutputDataReceived+=delegate(object sender,DataReceivedEventArgs e) { if(e.Data!=null) lock(output) { output.WriteLine(e.Data); } };
child.ErrorDataReceived+=delegate(object sender,DataReceivedEventArgs e) { if(e.Data!=null) lock(error) { error.WriteLine(e.Data); } };
child.Start(); child.BeginOutputReadLine(); child.BeginErrorReadLine();
ChildResult result=new ChildResult();
result.TimedOut=!child.WaitForExit(milliseconds);
if(result.TimedOut) {
try { child.Kill(); } catch(InvalidOperationException) { }
if(!child.WaitForExit(5000)) throw new TimeoutException("Diagnostic child did not exit after termination.");
}
child.WaitForExit(); // Process has exited; drain the asynchronous pipe callbacks.
result.ExitCode=child.ExitCode;
return result;
}
}
}
}
'@
}
function Invoke-FdWorker($Spec, $Limit) {
$InputFile = Join-Path $FdWork ($Spec.Name + '-input.clixml')
Export-FdXml $Spec $InputFile 8
$Code = '& ([scriptblock]::Create([IO.File]::ReadAllText(''' + $FdWorkerPath.Replace("'","''") + '''))) ''' + $InputFile.Replace("'","''") + ''''
$Encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Code))
$Arguments = '-NoProfile -NonInteractive '
if ($PSVersionTable.PSVersion.Major -eq 2) { $Arguments += '-Version 2.0 ' }
$Arguments += '-EncodedCommand ' + $Encoded
$Child = [EmberkomDownloadDiagnostic.ChildRunner]::Run($FdExe,$Arguments,$FdContext.WorkingDirectory,(Join-Path $FdWork ($Spec.Name + '-stdout.txt')),(Join-Path $FdWork ($Spec.Name + '-stderr.txt')),($Limit * 1000))
$TimedOut = $Child.TimedOut
$ExitCode = $Child.ExitCode
$Item = $null
if (Test-Path -LiteralPath $Spec.ResultPath) {
try { $Item = Import-Clixml -Path ([Management.Automation.WildcardPattern]::Escape($Spec.ResultPath)) } catch { $Item = $null }
}
if (!$Item) { $Item = New-Object PSObject -Property @{Name=$Spec.Name; Method=$Spec.Method; ExistingEmpty=$Spec.ExistingEmpty; Status='Failed'; Notes=@('Child did not produce a readable result.'); Errors=@(); Snapshots=@(); CleanupErrors=@(); ElapsedSeconds=$Limit} }
if ($TimedOut) { $Item.Status = 'TimedOut'; $Item.Notes += "Stopped after $Limit seconds; partial checkpoint retained." }
elseif (($null -ne $ExitCode -and $ExitCode -ne 0) -or $Item.Status -eq 'Running') { $Item.Status = 'Failed' }
foreach ($StreamName in @('stdout','stderr')) {
$StreamPath = Join-Path $FdWork ($Spec.Name + '-' + $StreamName + '.txt')
$StreamText = $(if (Test-Path -LiteralPath $StreamPath) { [IO.File]::ReadAllText($StreamPath) } else { '' })
Add-Member -InputObject $Item -MemberType NoteProperty -Name $StreamName -Value $StreamText
}
Add-Member -InputObject $Item -MemberType NoteProperty -Name ChildExitCode -Value $ExitCode
return $Item
}
$FdReport = New-Object PSObject -Property @{
SchemaVersion=1; RunId=$FdRunId; StartedUtc=[DateTime]::UtcNow.ToString('o'); Context=$FdContext
Probes=@(); Cleanup=@(); Findings=@(); ReportDirectory=$FdRoot
Limitations=@(
'Fresh child processes reproduce the caller TLS selection but not existing pooled connections, custom certificate callbacks, or custom in-memory proxy objects.',
'Registry paths reflect this process registry view; a 32-bit process may not expose the native 64-bit view.',
'Helper dependencies outside the captured functions/global LogFile are not reproduced.',
'Scratch files preserve the original URL extension when usable, but not the production path; path-specific security behavior may differ.',
'PowerShell 2-4 compatibility requires actual runtime validation; newer features elsewhere in Tools.ps1 are separate from this diagnostic.'
)
}
$FdReportXml = Join-Path $FdRoot 'report.clixml'
$FdReportText = Join-Path $FdRoot 'report.txt'
try {
$FdCases = @('Environment','Headers','Helper','BitsSync','BitsJob','DotNetGet')
foreach ($FdMethod in $FdCases) {
$FdVariants = @($false)
if (@('Helper','BitsSync','BitsJob','DotNetGet') -contains $FdMethod) { $FdVariants = @($false,$true) }
foreach ($FdExisting in $FdVariants) {
$FdName = $FdMethod + $(if ($FdExisting) { '-existing-empty' } else { '-new' })
$FdSpec = New-Object PSObject -Property @{
Name=$FdName; Method=$FdMethod; ExistingEmpty=$FdExisting; ContextPath=$FdContextPath
ResultPath=(Join-Path $FdWork ($FdName + '-result.clixml'))
Destination=(Join-Path $FdWork ($FdName + $FdExtension))
LogPath=(Join-Path $FdWork ($FdName + '-helper.log'))
Tag=('Emberkom-DownloadDiagnostic-' + $FdRunId + '-' + $FdName)
}
Write-Host ("Testing {0} (limit {1}s)..." -f $FdName,$FdTimeout)
$FdResult = Invoke-FdWorker $FdSpec $FdTimeout
if (Test-Path -LiteralPath $FdSpec.LogPath) { Add-Member -InputObject $FdResult -MemberType NoteProperty -Name HelperLog -Value ([IO.File]::ReadAllText($FdSpec.LogPath)) }
$FdReport.Probes += $FdResult
Export-FdXml $FdReport $FdReportXml 20
# A killed child cannot run finally. Always use a separate, bounded
# cleanup worker; filter by BOTH unpredictable run ID and exact tag.
if (@('Helper','BitsSync','BitsJob') -contains $FdMethod) {
$FdSpec.Method = 'Cleanup'
$FdSpec.Name += '-cleanup'
$FdSpec.ResultPath = Join-Path $FdWork ($FdSpec.Name + '-result.clixml')
$FdReport.Cleanup += Invoke-FdWorker $FdSpec 15
}
}
}
$FdDownloads = @($FdReport.Probes | Where-Object { @('Helper','BitsSync','BitsJob','DotNetGet') -contains $_.Method -and $_.Status -ne 'Skipped' })
$FdBad = @($FdDownloads | Where-Object { $_.Status -ne 'Complete' })
$FdHashes = @($FdDownloads | Where-Object { $_.Status -eq 'Complete' } | ForEach-Object { $_.Snapshots[$_.Snapshots.Count-1].SHA256 } | Sort-Object -Unique)
if ($FdBad.Count -eq 0 -and $FdHashes.Count -eq 1) {
$FdReport.Findings += 'All tested download paths produced the same nonempty file. Failure not reproduced; no transport replacement is justified by this run.'
} else {
$FdReport.Findings += 'A failure or content difference was observed. Compare per-probe errors, HTTP responses, BITS byte counts, and file snapshots before choosing a shared fix.'
}
foreach ($FdProbe in $FdDownloads) {
$FdAfter = @($FdProbe.Snapshots | Where-Object { $_.Label -eq 'After transfer' -or $_.Label -eq 'One second later' })
if ($FdAfter.Count -eq 2 -and ($FdAfter[0].SHA256 -ne $FdAfter[1].SHA256 -or $FdAfter[0].Length -ne $FdAfter[1].Length)) {
$FdReport.Findings += "File changed after transfer in $($FdProbe.Name); investigate post-download modification."
}
}
} finally {
# This directory is created exclusively by this invocation. Delete only its
# immediate scratch files; never recurse or touch the production destination.
$FdResolvedWork = (Resolve-Path -LiteralPath $FdWork).Path
$FdExpectedWork = [IO.Path]::GetFullPath((Join-Path $FdRoot 'work'))
if ($FdResolvedWork -ne $FdExpectedWork -or (Get-Item -LiteralPath $FdResolvedWork).Attributes -band [IO.FileAttributes]::ReparsePoint) {
$FdReport.Findings += 'Scratch path validation failed; cleanup skipped.'
} else {
foreach ($FdScratch in @(Get-ChildItem -LiteralPath $FdResolvedWork -Force)) {
if (!$FdScratch.PSIsContainer) {
try { Remove-Item -LiteralPath $FdScratch.FullName -Force -ErrorAction Stop }
catch { $FdReport.Findings += ('Could not remove scratch file: ' + $FdScratch.FullName + ': ' + $_.Exception.Message) }
}
}
if (@(Get-ChildItem -LiteralPath $FdResolvedWork -Force).Count -eq 0) { Remove-Item -LiteralPath $FdResolvedWork -Force }
}
Export-FdXml $FdReport $FdReportXml 20
# A readable recursive rendering retains nested exception and HTTP details.
function Format-FdReport($Value, $Indent) {
if ($null -eq $Value) { return ($Indent + '<null>') }
if ($Value -is [string] -or $Value -is [ValueType]) { return ($Indent + [string]$Value) }
if ($Value -is [Collections.IDictionary]) {
foreach ($Key in @($Value.Keys | Sort-Object)) { $Indent + [string]$Key + ':'; Format-FdReport $Value[$Key] ($Indent + ' ') }
} elseif ($Value -is [Collections.IEnumerable]) {
foreach ($Entry in $Value) { Format-FdReport $Entry ($Indent + ' ') }
} else {
foreach ($Property in $Value.PSObject.Properties) {
if (@('NoteProperty','Property') -contains [string]$Property.MemberType) { $Indent + $Property.Name + ':'; Format-FdReport $Property.Value ($Indent + ' ') }
}
}
}
$FdText = @(Format-FdReport $FdReport '') -join [Environment]::NewLine
[IO.File]::WriteAllText($FdReportText,$FdText,[Text.Encoding]::UTF8)
}
Write-Host ("Diagnostic reports: {0}" -f $FdRoot)
New-Object PSObject -Property @{TextReport=$FdReportText; XmlReport=$FdReportXml; Findings=$FdReport.Findings}
} $URL $OutputDirectory $TimeoutSeconds