541 lines
33 KiB
PowerShell
541 lines
33 KiB
PowerShell
#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
|