Private/Kinds/NetworkDependency.ps1

# The NetworkDependency Kind: which programs on this machine depend on which Servers.
#
# The Server Kind answers "can this Server be reached" for Servers somebody wrote into a
# Check Definition. This Kind finds the Servers out on the Target Machine, so an
# application nobody has described is not invisible: a line-of-business program started
# from a mapped drive dies with c0000006 the moment its share drops, and until now the
# Report showed the drive as "OK" and the crashes as a bare count, with nothing between
# the two (#27).
#
# The Gatherer is built from sources. A source is a small function that reads one place a
# dependency leaves a trace and returns traces - application, path, kind, source - made
# with New-NetworkDependencyTrace. It knows nothing about drive letters or Servers. The
# Gatherer hands every trace to ConvertTo-NetworkDependency, which is pure: it resolves
# drive letters through the mapping it is given, reduces the path to its Server and drops
# what is local. A source is one more function and one more line in
# Get-NetworkDependencyData. The sources are in NetworkDependencySource.ps1.
#
# The sources, with the token each row carries as its Source and the kind that follows:
# process the paths of the running processes in the user's session program
# module the modules those and the service processes loaded (#33) program
# service services whose executable is on a share program
# task scheduled tasks whose action is on a share program
# startup startup entries on a share program
# shortcut, apppath shortcuts and registered application paths on a share: program
# what is installed but not running (a shortcut to a folder
# or a document is data)
# folder-redirection the user's shell folders on a Server profile
# offline-files the shares Offline Files keeps a copy of profile
# printer printers connected through a print server service
# odbc ODBC data sources that name a Server database
# config a Server or share named in <exe>.config or an .ini beside data or
# an executable that is already in the inventory database
# The kind of dependency is the Gatherer's only interpretation, and it follows from where
# the trace was found and what the path is, never from what was measured (#34).
#
# The startup entries and the printers are taken from the Startup and the Printing Check
# when those ran before this one, and read here when they did not. The ODBC data sources
# are always read here: several Checks of the Odbc and the Service Kind run in one Run,
# and what earlier Checks gathered is keyed by Kind, so they overwrite each other.
#
# What this session may not open - a process of the system or of another account, and
# the modules of every process that hosts a service - the NetworkDependencyElevated Kind
# reads with admin rights. Its reading comes in as $Observed['NetworkDependencyElevated']
# and is filled in here, where the drives are known that place a path.
#
# It runs in the Main Part and needs no admin rights: the Elevated Part has its own logon
# and cannot see the Technician's drive mappings. The price is that the SMB connection's
# dialect, encryption and multichannel state are refused to a Run without admin rights
# (Get-SmbConnection: access denied, observed on Windows 11). They are then absent and the
# refusal is recorded as unexamined, rather than the Kind being moved to where it would
# not know what K: is.
#
# What the Gatherer returns:
# Dependencies { Application, Path, UncPath, Server, Kind, Source, Port }
# Kind is program, data, profile, database or service; Path is as found
# (K:\x\y.exe), UncPath is resolved (\\server\share\x\y.exe), Server is
# the host name only. Port is the port the dependency needs its Server
# on: the one its path names (445, or a WebDAV port), the one a data
# source names, or $null when nobody knows it. A dependency that names a
# Server and no path - a database - has an empty Path and no UncPath
# Servers { Server, Port, Resolved, Reachable, TcpAttempts, TcpSuccesses, TcpMs,
# PingMs, PingMaxMs, PingSent, LossPercent, Dialect, Encrypted,
# Multichannel, Route, Address, DnsMs, DnsCached, AddressRoute }
# one per distinct Server and port. Port $null is a Server that was
# resolved and pinged only: TcpAttempts is 0 and Reachable is $null.
# Data from before #106 has no PingMaxMs, PingSent, Address, DnsMs,
# DnsCached or AddressRoute
# Prefixes the machine's own addresses with their prefix lengths, for the Zone
# Unexamined { What, Reason } - Reason is PathNotReadable, DriveNotResolved,
# AccessDenied, NotReadable, ModulesAccessDenied, ModulesNotReadable,
# ModulesProtected, ServiceProtected (refused to the Elevated Part too),
# ServiceAccessDenied, TaskNotReadable, ConfigNotReadable or
# ConfigSkipped. A row that stands for many things it does not name
# carries their number as Count as well; most rows have no such property
# DriveMappings { LocalPath, RemotePath }, the mapping the paths were resolved with
# LocalDrives the drive letters that are the machine's own, each with its colon (C:,
# D:): every drive of this session that Windows does not call a network
# drive - the disks, and a USB stick or a DVD drive as well. It is the set
# the paths were placed with, so a later Check that meets a path on a
# letter that is neither here nor in DriveMappings knows what this one
# knew: nobody could say where that drive is. Data from before it was
# gathered has no such property
# Adapters { Name, Description, IsUsb, PowerSavingOn, LinkSpeed }, the adapters that
# are up, copied from what the Network Check gathered (#38)
# UsbSelectiveSuspend $true, $false, or $null when it could not be read; copied likewise
# AdaptersGathered whether the Network Check handed over adapters that say if they
# are USB devices. $false is "not assessable", never "not USB"
#
# The adapters are copied, not read again: the Network Check has read them, and it runs
# before this one. They are judged here and not there because the combination that matters
# - a USB adapter and a program on a share - needs the inventory, which only this Kind has.

# How often the Server's port is tried, how long one try may take and how many pings are
# sent. Not Check Definition parameters, for the reason Private/Sampling.ps1 gives. Fewer
# than the Server Kind uses, because nobody chose these Servers: a machine with six of
# them must not add a minute to every Run.
#
# A Server whose name resolves and that is not there costs about nine seconds, measured
# against 192.0.2.10 on Windows PowerShell 5.1 and PowerShell 7: three attempts that each
# wait out the timeout, and the two pings below. It was seventeen while all ten pings were
# sent into the same silence. One without a port is pinged ten times and costs ten.
$script:NetworkDependencyTcpAttempts  = 3

$script:NetworkDependencyTcpTimeoutMs = 2000

$script:NetworkDependencyPingCount    = 10

# How many pings a Server gets first whose port did not open. Unanswered, they are the
# measurement: eight more would say the same and take nine seconds to say it. Answered,
# the Server is there behind a closed port, and it is pinged in full.
$script:NetworkDependencyProbePingCount = 2

# How many names a Finding's Value lists before the rest are only counted.
$script:NetworkDependencyListShown = 6

$script:NetworkDependencySmbPort = 445

function ConvertTo-NetworkDependencyDriveTable {
    <#
    .SYNOPSIS
        A drive mapping as a table from upper-case drive letter to UNC path. Pure.
    .DESCRIPTION
        The mapping arrives as rows of LocalPath and RemotePath - what Get-SmbMapping
        returns and what this Kind's data carries - or as a table from letter to UNC,
        which is what a test and a later Check would rather write.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param([AllowNull()]$DriveMapping)

    $table = @{}
    if ($null -eq $DriveMapping) { return $table }

    if ($DriveMapping -is [System.Collections.IDictionary]) {
        foreach ($key in @($DriveMapping.Keys)) {
            $remote = "$($DriveMapping[$key])".Trim()
            if ("$key" -match '^\s*([A-Za-z]):?\\?\s*$' -and $remote) {
                $table[$Matches[1].ToUpperInvariant()] = $remote
            }
        }
        return $table
    }

    foreach ($row in @($DriveMapping)) {
        $local  = "$(Get-DataProperty $row 'LocalPath')".Trim()
        $remote = "$(Get-DataProperty $row 'RemotePath')".Trim()
        if ($local -match '^([A-Za-z]):?\\?$' -and $remote) {
            $table[$Matches[1].ToUpperInvariant()] = $remote
        }
    }
    $table
}

function ConvertTo-NetworkDependencyUncPath {
    <#
    .SYNOPSIS
        A path as the UNC path it really is, or nothing when it is not on the network.
        Pure: the drive mapping is an argument, and nothing is asked of the machine.
    .DESCRIPTION
        A UNC path stays what it is. A drive letter is replaced by what the mapping says
        is behind it; a letter the mapping does not know is taken to be local. The long
        path forms Windows hands out (\\?\UNC\server\share and \\?\K:\) are read as what
        they stand for, and a device path (\\.\) is not on the network.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()][AllowEmptyString()][string]$Path,
        [AllowNull()]$DriveMapping
    )

    $text = "$Path".Trim().Trim('"').Trim()
    if (-not $text) { return }

    $text = $text -replace '/', '\'
    if ($text -match '^\\\\\?\\UNC\\(.+)$') { $text = '\\' + $Matches[1] }
    elseif ($text -match '^\\\\\?\\(.+)$')  { $text = $Matches[1] }

    if ($text -match '^\\\\\.\\') { return }
    if ($text -match '^\\\\[^\\]') { return $text }

    if ($text -notmatch '^([A-Za-z]):(.*)$') { return }
    $letter = $Matches[1].ToUpperInvariant()
    $rest   = $Matches[2]

    $table = ConvertTo-NetworkDependencyDriveTable -DriveMapping $DriveMapping
    if (-not $table.ContainsKey($letter)) { return }

    $root = "$($table[$letter])".TrimEnd('\')
    if ($rest -and -not $rest.StartsWith('\')) { $rest = '\' + $rest }
    $root + $rest
}

function Split-NetworkDependencyUncPath {
    <#
    .SYNOPSIS
        The Server, the port and the share a UNC path names. Pure.
    .DESCRIPTION
        The Server is the host name only. A WebDAV path carries its transport in the host
        part - \\host@SSL\DavWWWRoot, \\host@8080\..., \\host@SSL@8443\... - and that is
        not part of the name: asking port 445 of a SharePoint library would report a
        Server as unreachable that answers perfectly well on 443.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyString()][string]$UncPath)

    if ("$UncPath" -notmatch '^\\\\([^\\]+)(?:\\([^\\]+))?') { return }
    $hostPart  = $Matches[1]
    $shareName = $Matches[2]

    $pieces = @($hostPart -split '@')
    $server = $pieces[0].Trim()
    if (-not $server -or $server -eq '.' -or $server -eq '?') { return }

    $port = $script:NetworkDependencySmbPort
    if ($pieces.Count -gt 1) {
        $port = 80
        foreach ($piece in ($pieces | Select-Object -Skip 1)) {
            if ($piece -eq 'SSL') { $port = 443 }
            elseif ($piece -match '^\d+$' -and [int]$piece -ge 1 -and [int]$piece -le 65535) { $port = [int]$piece }
        }
    }

    $share = '\\' + $hostPart
    if ($shareName) { $share = $share + '\' + $shareName }

    [pscustomobject]@{
        Server = $server
        Port   = $port
        Share  = $share
    }
}

function New-NetworkDependencyTrace {
    <#
    .SYNOPSIS
        What a source returns: one place something was found that may depend on a Server.
    .PARAMETER Server
        For a trace that names a Server and no path, such as a database source.
    .PARAMETER Port
        The port such a trace needs its Server on, when it is known. A trace with a path
        needs none: the path says it.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Application,
        [AllowNull()][AllowEmptyString()][string]$Path,
        [Parameter(Mandatory)][string]$Kind,
        [Parameter(Mandatory)][string]$Source,
        [AllowNull()][AllowEmptyString()][string]$Server,
        [AllowNull()]$Port
    )

    [pscustomobject]@{
        Application = $Application
        Path        = $Path
        Kind        = $Kind
        Source      = $Source
        Server      = $Server
        Port        = ConvertTo-NetworkDependencyPort -Port $Port
    }
}

function ConvertTo-NetworkDependencyPort {
    <#
    .SYNOPSIS
        A port as a number, or $null when it is not one. Pure.
    #>

    [CmdletBinding()]
    [OutputType([int])]
    param([AllowNull()]$Port)

    $number = ConvertTo-Number $Port
    if ($null -eq $number -or $number -lt 1 -or $number -gt 65535 -or $number -ne [math]::Floor($number)) { return $null }
    [int]$number
}

function New-NetworkDependencyUnexamined {
    <#
    .PARAMETER Count
        For a row that stands for many things it does not name, such as the service
        processes a Run without admin rights may not open. Absent on every other row.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][string]$What,
        [Parameter(Mandatory)][string]$Reason,
        [int]$Count
    )

    $row = [pscustomobject]@{
        What   = $What
        Reason = $Reason
    }
    if ($PSBoundParameters.ContainsKey('Count')) {
        $row | Add-Member -NotePropertyName 'Count' -NotePropertyValue $Count
    }
    $row
}

function ConvertTo-NetworkDependency {
    <#
    .SYNOPSIS
        Turns traces into dependencies: drive letters resolved, Server named, local paths
        dropped, repeats removed. Pure.
    .DESCRIPTION
        Returns Dependencies and Unexamined. A path on a drive letter that is neither
        mapped nor one of the machine's own drives cannot be placed: that is what a
        program started from K: looks like to a session that cannot see K:, which is
        every elevated one. It is reported as unexamined, because dropping it would call a
        program local that is very likely not.
    .PARAMETER LocalDrive
        The letters of the machine's own drives. Without it, a letter the mapping does not
        know is taken to be local.
    .PARAMETER ComputerName
        The machine's own name. What names this machine as its Server - a printer it
        shares itself, a data source on localhost - is no network dependency.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Trace,
        [AllowNull()]$DriveMapping,
        [AllowNull()][AllowEmptyCollection()][string[]]$LocalDrive,
        [AllowNull()][AllowEmptyString()][string]$ComputerName
    )

    $table = ConvertTo-NetworkDependencyDriveTable -DriveMapping $DriveMapping
    $local = ConvertTo-NetworkDependencyDriveSet -LocalDrive $LocalDrive

    $dependencies = [System.Collections.Generic.List[psobject]]::new()
    $unexamined   = [System.Collections.Generic.List[psobject]]::new()
    $seen         = @{}
    $unplaced     = @{}

    foreach ($item in @($Trace)) {
        if ($null -eq $item) { continue }
        $path   = "$(Get-DataProperty $item 'Path')".Trim()
        $server = "$(Get-DataProperty $item 'Server')".Trim()
        $unc    = ConvertTo-NetworkDependencyUncPath -Path $path -DriveMapping $table
        # The port the trace names counts only where no path says it.
        $port   = ConvertTo-NetworkDependencyPort -Port (Get-DataProperty $item 'Port')

        if ($unc) {
            $parts = Split-NetworkDependencyUncPath -UncPath $unc
            if ($parts) { $server = $parts.Server; $port = $parts.Port }
        }
        elseif (-not $server) {
            if ($null -ne $LocalDrive -and $path -match '^(?:\\\\\?\\)?([A-Za-z]):' -and
                -not $local.ContainsKey($Matches[1].ToUpperInvariant())) {
                $key = $path.ToUpperInvariant()
                if (-not $unplaced.ContainsKey($key)) {
                    $unplaced[$key] = $true
                    $unexamined.Add((New-NetworkDependencyUnexamined -What $path -Reason 'DriveNotResolved'))
                }
            }
            continue
        }
        if (-not $server) { continue }
        if (Test-NetworkDependencyLocalName -Server $server -ComputerName $ComputerName) { continue }

        $application = "$(Get-DataProperty $item 'Application')"
        $kind        = "$(Get-DataProperty $item 'Kind')"
        $key = ('{0}|{1}|{2}|{3}|{4}' -f $application, $kind, $server, $(if ($unc) { $unc } else { $path }), $port).ToUpperInvariant()
        if ($seen.ContainsKey($key)) { continue }
        $seen[$key] = $true

        $dependencies.Add([pscustomobject]@{
            Application = $application
            Path        = $path
            UncPath     = $unc
            Server      = $server
            Kind        = $kind
            Source      = "$(Get-DataProperty $item 'Source')"
            Port        = $port
        })
    }

    [pscustomobject]@{
        Dependencies = @($dependencies)
        Unexamined   = @($unexamined)
    }
}

function Get-NetworkDependencyDriveMapping {
    <#
    .SYNOPSIS
        Which drive letter stands for which share, in this session.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param()

    $table = @{}

    # Asked not to stop, for the reason Get-StabilityEvent gives: a machine without a
    # single mapping is an answer, and so is one whose SMB cmdlets are missing. The catch
    # is for the missing cmdlet, and leaves no TerminatingError line in a transcript:
    # looked at under Windows PowerShell 5.1, where only -ErrorAction Stop wrote one.
    $mappings = @()
    try { $mappings = @(Get-SmbMapping -ErrorAction SilentlyContinue) } catch { }
    foreach ($mapping in $mappings) {
        $local  = "$($mapping.LocalPath)".Trim()
        $remote = "$($mapping.RemotePath)".Trim()
        if ($local -match '^([A-Za-z]):' -and $remote) { $table[$Matches[1].ToUpperInvariant()] = $remote }
    }

    # A network drive SMB does not know is a WebDAV or another redirector's drive. Only
    # asked for when there is one, because the query waits on drives that do not answer.
    $unknown = @(Get-NetworkDependencyDriveLetter -Network | Where-Object { -not $table.ContainsKey($_) })
    if ($unknown.Count) {
        $disks = @()
        try { $disks = @(Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=4' -ErrorAction SilentlyContinue) } catch { }
        foreach ($disk in $disks) {
            $remote = "$($disk.ProviderName)".Trim()
            if ("$($disk.DeviceID)" -match '^([A-Za-z]):' -and $remote -and -not $table.ContainsKey($Matches[1].ToUpperInvariant())) {
                $table[$Matches[1].ToUpperInvariant()] = $remote
            }
        }
    }

    foreach ($letter in ($table.Keys | Sort-Object)) {
        [pscustomobject]@{
            LocalPath  = '{0}:' -f $letter
            RemotePath = $table[$letter]
        }
    }
}

function Get-NetworkDependencyDriveLetter {
    <#
    .SYNOPSIS
        The letters of this session's drives: the machine's own, or with -Network the ones
        Windows says are network drives.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([switch]$Network)

    $drives = @()
    try { $drives = @([IO.DriveInfo]::GetDrives()) } catch { }
    foreach ($drive in $drives) {
        $isNetwork = $drive.DriveType -eq [IO.DriveType]::Network
        if ($isNetwork -ne [bool]$Network) { continue }
        "$($drive.Name)".Substring(0, 1).ToUpperInvariant()
    }
}

function ConvertTo-NetworkDependencyDriveSet {
    <#
    .SYNOPSIS
        The letters of the machine's own drives as a table to look a letter up in. Pure.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$LocalDrive)

    $local = @{}
    foreach ($drive in @($LocalDrive)) {
        if ("$drive" -match '^\s*([A-Za-z])') { $local[$Matches[1].ToUpperInvariant()] = $true }
    }
    $local
}

function Merge-NetworkDependencySource {
    <#
    .SYNOPSIS
        Keeps one row for what several sources found. Pure.
    .DESCRIPTION
        A program that runs from a share usually has a shortcut and often an App Paths
        entry as well, and three rows for one executable are three warnings for one
        fact. The first source that found it keeps the row; the sources are read with
        what is running first. A module is not folded in here: its row is about the
        program that loaded it, and Merge-NetworkDependencyModule has dealt with it.
 
        A configuration file beside a program nearly always names the share the program
        itself is on. That is no second dependency of that program, and is dropped.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()]$Dependency)

    $rows = @($Dependency | Where-Object { $null -ne $_ })

    $programs = @{}
    $shares   = @{}
    $kept     = [System.Collections.Generic.List[psobject]]::new()

    foreach ($row in $rows) {
        $source = "$(Get-DataProperty $row 'Source')"
        $unc    = "$(Get-DataProperty $row 'UncPath')"
        if ("$(Get-DataProperty $row 'Kind')" -eq 'program' -and $source -notin 'module', 'config' -and $unc) {
            $key = $unc.ToUpperInvariant()
            if ($programs.ContainsKey($key)) { continue }
            $programs[$key] = $true

            $parts = Split-NetworkDependencyUncPath -UncPath $unc
            if ($parts) { $shares[('{0}|{1}' -f (Get-DataProperty $row 'Application'), $parts.Share).ToUpperInvariant()] = $true }
        }
        $kept.Add($row)
    }

    foreach ($row in $kept) {
        if ("$(Get-DataProperty $row 'Source')" -eq 'config') {
            $parts = Split-NetworkDependencyUncPath -UncPath "$(Get-DataProperty $row 'UncPath')"
            if ($parts -and $shares.ContainsKey(('{0}|{1}' -f (Get-DataProperty $row 'Application'), $parts.Share).ToUpperInvariant())) { continue }
        }
        $row
    }
}

function Get-NetworkDependencySmbConnection {
    <#
    .SYNOPSIS
        The SMB connections this session holds, and how many channels each Server has.
    .DESCRIPTION
        Both are refused to a Run without admin rights. That is recorded and nothing is
        returned, so the Judge can tell "not encrypted" from "not allowed to ask".
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][AllowEmptyCollection()]$Unexamined)

    $readable    = $true
    $connections = @()
    $channels    = @()

    # Asked not to stop, so that the refusal is collected and no TerminatingError line is
    # written; the catch is for a Windows without the cmdlet, which writes none either
    # (both looked at in a transcript under Windows PowerShell 5.1).
    foreach ($command in 'Get-SmbConnection', 'Get-SmbMultichannelConnection') {
        $problems = @()
        $rows     = @()
        try { $rows = @(& $command -ErrorAction SilentlyContinue -ErrorVariable problems) }
        catch { $problems = @($_) }

        if (@($problems).Count) {
            $reason = 'NotReadable'
            if (Test-AccessDenied -ErrorRecord @($problems)[0]) { $reason = 'AccessDenied' }
            $Unexamined.Add((New-NetworkDependencyUnexamined -What $command -Reason $reason))
            if ($command -eq 'Get-SmbConnection') { $readable = $false }
            continue
        }
        if ($command -eq 'Get-SmbConnection') { $connections = $rows } else { $channels = $rows }
    }

    [pscustomobject]@{
        Readable = $readable
        Connections = @($connections | ForEach-Object {
            [pscustomobject]@{
                ServerName = "$($_.ServerName)"
                Dialect    = "$($_.Dialect)"
                Encrypted  = Get-DataProperty $_ 'Encrypted'
            }
        })
        Channels = @($channels | ForEach-Object {
            [pscustomobject]@{
                ServerName      = "$($_.ServerName)"
                CurrentChannels = Get-DataProperty $_ 'CurrentChannels'
            }
        })
    }
}

function Measure-NetworkDependencyServer {
    <#
    .SYNOPSIS
        Measures one Server with the probes the Server Kind uses, and reads what its SMB
        connection negotiated. Judges none of it.
    .PARAMETER Port
        The port the dependencies need the Server on. $null when nobody knows it - a
        data source of a driver that has no default port, a named instance: the Server
        is then resolved and pinged and nothing is connected to. Connecting to 445
        instead would report a database server as unreachable that never spoke SMB.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][string]$Server,
        [AllowNull()]$Port = $script:NetworkDependencySmbPort,
        [AllowNull()]$SmbConnection
    )

    $Port = ConvertTo-NetworkDependencyPort -Port $Port

    $resolution = Resolve-HostAddress -HostName $Server -CheckCache
    $resolved   = [bool]$resolution.Resolved

    $times   = @()
    $latency = $null
    $route   = $null
    $addressRoute = $null
    # A name that does not resolve cannot be connected to or pinged, as in the Server Kind.
    if ($resolved) {
        # By cable or by radio, next door or through a gateway: what to do about a slow
        # Server depends on it, as in the Server Kind.
        $route = Get-ServerRoute -Address "$(Get-DataProperty $resolution 'Address')"
        # The adapter the way leaves through, which the Judge decides the Zone by.
        $addressRoute = Get-AddressRoute -Address "$(Get-DataProperty $resolution 'Address')"
        if ($null -ne $Port) {
            $times = @(1..$script:NetworkDependencyTcpAttempts | ForEach-Object {
                Write-CheckProgress -Status ((Get-Text 'Console.Progress.Connect') -f ('{0}:{1}' -f $Server, $Port)) `
                    -Step ($_ - 1) -Of $script:NetworkDependencyTcpAttempts
                $ms = Measure-TcpConnect -HostName $Server -Port $Port -TimeoutMs $script:NetworkDependencyTcpTimeoutMs
                Start-Sleep -Milliseconds 200
                $ms
            })
        }
        # A port that did not open and two pings nobody answered: the Server is not
        # there, and what that measured is what ten would have - no reply, all lost.
        # One that answers is measured in full, and so is one nobody connected to.
        $probe = $null
        if ($null -ne $Port -and -not @($times | Where-Object { $null -ne $_ }).Count) {
            $probe = Measure-Latency -Target $Server -Count $script:NetworkDependencyProbePingCount
        }
        if ($probe -and -not $probe.Received) { $latency = $probe }
        else { $latency = Measure-Latency -Target $Server -Count $script:NetworkDependencyPingCount }
    }
    $ok = @($times | Where-Object { $null -ne $_ })

    # What the connection negotiated. Absent when the connections could not be read, and
    # absent when this session holds none to the Server, which is not the same as "no".
    $dialect      = $null
    $encrypted    = $null
    $multichannel = $null
    if ($Port -eq $script:NetworkDependencySmbPort -and $SmbConnection -and $SmbConnection.Readable) {
        $mine = @($SmbConnection.Connections | Where-Object { $_.ServerName -eq $Server })
        if ($mine.Count) {
            $dialect = @($mine | ForEach-Object { $_.Dialect } | Where-Object { $_ } | Sort-Object -Unique) -join ', '
            if (-not $dialect) { $dialect = $null }

            $flags = @($mine | ForEach-Object { $_.Encrypted } | Where-Object { $null -ne $_ })
            if ($flags.Count) { $encrypted = @($flags | Where-Object { $_ }).Count -gt 0 }

            $count = 0
            foreach ($channel in @($SmbConnection.Channels | Where-Object { $_.ServerName -eq $Server })) {
                $current = ConvertTo-Number $channel.CurrentChannels
                if ($null -ne $current) { $count += $current }
            }
            $multichannel = $count -gt 1
        }
    }

    [pscustomobject]@{
        Server       = $Server
        Port         = $Port
        Resolved     = $resolved
        # Without a port nothing was connected to, and whether it answers is not known.
        Reachable    = $(if ($null -ne $Port) { $ok.Count -gt 0 } else { $null })
        TcpAttempts  = @($times).Count
        TcpSuccesses = $ok.Count
        TcpMs        = $(if ($ok.Count) { [math]::Round(($ok | Measure-Object -Average).Average, 1) } else { $null })
        PingMs       = $(if ($latency) { $latency.AverageMs } else { $null })
        PingMaxMs    = $(if ($latency) { $latency.MaximumMs } else { $null })
        PingSent     = $(if ($latency) { $latency.Sent } else { $null })
        LossPercent  = $(if ($latency) { $latency.LossPercent } else { $null })
        Dialect      = $dialect
        Encrypted    = $encrypted
        Multichannel = $multichannel
        Route        = $route
        # Where the name led and how long that took, and whether the DNS client answered it
        # from its cache: then the time is the cache's. $null where it could not be asked.
        Address      = "$(Get-DataProperty $resolution 'Address')"
        DnsMs        = Get-DataProperty $resolution 'Milliseconds'
        DnsCached    = Get-DataProperty $resolution 'Cached'
        AddressRoute = $addressRoute
    }
}

function ConvertTo-NetworkDependencyAdapterFact {
    <#
    .SYNOPSIS
        What this Kind keeps of the adapters the Network Check gathered. Pure.
    .DESCRIPTION
        Returns Adapters, UsbSelectiveSuspend and AdaptersGathered. The Network Check's
        data is read like a fixture: it may be absent because the Check was skipped, and
        it may come from a module that did not yet say whether an adapter is a USB device.
        An adapter that does not say is left out, and when none says, the adapters count
        as not gathered: a missing answer is not the answer "no".
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Network)

    $adapters = [System.Collections.Generic.List[psobject]]::new()
    foreach ($adapter in (Get-DataCollection $Network 'Adapters')) {
        $isUsb = Get-DataProperty $adapter 'IsUsb'
        if ($isUsb -isnot [bool]) { continue }
        $adapters.Add([pscustomobject]@{
            Name          = "$(Get-DataProperty $adapter 'Name')"
            Description   = "$(Get-DataProperty $adapter 'Description')"
            IsUsb         = $isUsb
            PowerSavingOn = Get-DataProperty $adapter 'PowerSavingOn'
            LinkSpeed     = "$(Get-DataProperty $adapter 'LinkSpeed')"
        })
    }

    [pscustomobject]@{
        Adapters            = @($adapters)
        UsbSelectiveSuspend = Get-DataProperty $Network 'UsbSelectiveSuspend'
        AdaptersGathered    = $adapters.Count -gt 0
    }
}

# What Get-NetworkDependencyData hands its Judge. See Private/DataShape.ps1.
$script:NetworkDependencyDataShape = @{
    Dependencies        = 'List'
    Servers             = 'List'
    Unexamined          = 'List'
    References          = 'Value'
    DriveMappings       = 'List'
    LocalDrives         = 'List'
    ServerLimit         = 'Value'
    Prefixes            = 'List'
    Adapters            = 'List'
    UsbSelectiveSuspend = 'Value'
    AdaptersGathered    = 'Value'
}

function Get-NetworkDependencyData {
    <#
    .PARAMETER Observed
        What the Checks before this one gathered, keyed by Kind. Read are the Network
        Check's adapters, and the Startup Check's entries and the Printing Check's
        printers, which are then not read a second time.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [hashtable]$Parameters = @{},
        [AllowNull()][hashtable]$Observed = @{}
    )

    $network = $null
    if ($Observed -and $Observed.ContainsKey('Network')) { $network = $Observed['Network'] }
    $adapterFact = ConvertTo-NetworkDependencyAdapterFact -Network $network

    $startup  = $null
    $printing = $null
    if ($Observed -and $Observed.ContainsKey('Startup'))  { $startup  = $Observed['Startup'] }
    if ($Observed -and $Observed.ContainsKey('Printing')) { $printing = $Observed['Printing'] }

    # What the Elevated Part read of the processes this session may not open.
    $elevated = $null
    if ($Observed -and $Observed.ContainsKey('NetworkDependencyElevated')) { $elevated = $Observed['NetworkDependencyElevated'] }

    $unexamined = [System.Collections.Generic.List[psobject]]::new()

    # Where this machine still points at a Server it asked for in vain: searched for the
    # Servers the Check of the network's disruptions found, and only for those.
    $disruption = $null
    if ($Observed -and $Observed.ContainsKey('NetworkDisruption')) { $disruption = $Observed['NetworkDisruption'] }
    $references = Get-NetworkDependencyReference -NetworkDisruption $disruption

    $mapping  = @(Get-NetworkDependencyDriveMapping)
    $local    = @(Get-NetworkDependencyDriveLetter)
    $computer = "$env:COMPUTERNAME"

    # The same set the paths below are placed with, as a later Check writes a drive.
    $localDrives = [string[]]@((ConvertTo-NetworkDependencyDriveSet -LocalDrive $local).Keys | Sort-Object |
        ForEach-Object { '{0}:' -f $_ })

    # The sources. Each returns traces and knows nothing of drive letters or Servers. What
    # is running comes first: where two sources find one program, the first keeps the row.
    $traces = @(
        Get-NetworkDependencyProcessTrace -Unexamined $unexamined -Elevated $elevated
        Get-NetworkDependencyModuleTrace -Unexamined $unexamined -LocalDrive $local -Elevated $elevated
        Get-NetworkDependencyServiceTrace -Unexamined $unexamined -LocalDrive $local
        Get-NetworkDependencyTaskTrace -Unexamined $unexamined -LocalDrive $local
        Get-NetworkDependencyStartupTrace -Unexamined $unexamined -LocalDrive $local -Startup $startup
        Get-NetworkDependencyShortcutTrace -LocalDrive $local
        Get-NetworkDependencyAppPathTrace -LocalDrive $local
        Get-NetworkDependencyShellFolderTrace -Unexamined $unexamined
        Get-NetworkDependencyOfflineFilesTrace -Unexamined $unexamined
        Get-NetworkDependencyPrinterTrace -Unexamined $unexamined -Printing $printing
        Get-NetworkDependencyOdbcTrace
    )

    # Twice, because the last source needs the first result: configuration files are
    # searched only beside the executables the inventory holds, and only on a Server that
    # was measured and answered. What they name is then reduced and measured like the rest.
    $servers  = [System.Collections.Generic.List[psobject]]::new()
    $measured = @{}
    $smb      = $null
    $reduced  = $null
    $dependencies = @()
    foreach ($pass in 1, 2) {
        if ($pass -eq 2) {
            $programs = @(Select-NetworkDependencyExecutable -Dependency $dependencies)
            if (-not $programs.Count) { break }
            $named = @(Get-NetworkDependencyConfigTrace -Unexamined $unexamined -Dependency $programs `
                -Server @($servers) -ComputerName $computer)
            if (-not $named.Count) { break }
            $traces = @($traces) + $named
        }

        $reduced = ConvertTo-NetworkDependency -Trace $traces -DriveMapping $mapping -LocalDrive $local -ComputerName $computer
        $dependencies = @(Merge-NetworkDependencySource -Dependency (Merge-NetworkDependencyModule -Dependency $reduced.Dependencies))

        # Each Server once on each port something needs it on, however many depend on it.
        foreach ($dependency in $dependencies) {
            $key = ('{0}|{1}' -f $dependency.Server, $dependency.Port).ToUpperInvariant()
            if ($measured.ContainsKey($key)) { continue }
            $measured[$key] = $true

            # Asked for once, and only when something speaks SMB.
            if ($dependency.Port -eq $script:NetworkDependencySmbPort -and $null -eq $smb) {
                $smb = Get-NetworkDependencySmbConnection -Unexamined $unexamined
            }
            $servers.Add((Measure-NetworkDependencyServer -Server $dependency.Server -Port $dependency.Port -SmbConnection $smb))
        }
    }
    foreach ($row in $reduced.Unexamined) { $unexamined.Add($row) }

    # Two sources that ask the same place and are both refused name it once.
    $told = @{}
    $gaps  = @(foreach ($row in $unexamined) {
        if ($null -eq (Get-DataProperty $row 'Count')) {
            $key = ('{0}|{1}' -f $row.What, $row.Reason).ToUpperInvariant()
            if ($told.ContainsKey($key)) { continue }
            $told[$key] = $true
        }
        $row
    })

    [pscustomobject]@{
        PSTypeName    = 'Gutcheck.Data.NetworkDependency'
        Dependencies  = $dependencies
        Servers       = @($servers)
        Unexamined    = $gaps
        References    = $references
        DriveMappings = $mapping
        LocalDrives   = $localDrives
        # The limits this Check was given, for a later Check that asks whether a Server
        # was measured slow: see Get-NetworkDependencyServerLimit.
        ServerLimit   = Get-NetworkDependencyServerLimit -Parameters $Parameters
        # The machine's own addresses with their prefix lengths, for the Zone of each
        # Server measured. See Get-OwnPrefix.
        Prefixes      = @(Get-OwnPrefix)
        Adapters            = $adapterFact.Adapters
        UsbSelectiveSuspend = $adapterFact.UsbSelectiveSuspend
        AdaptersGathered    = $adapterFact.AdaptersGathered
    }
}

function ConvertTo-NetworkDependencyFinding {
    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{},
        [AllowNull()]$Situation
    )

    $check = Get-Text 'Check.NetworkDependency.Dependencies'

    # Data without the list is a Gatherer that did not run, not a machine without
    # dependencies, and must not read like one.
    if (-not (Test-NetworkDependencyProperty -InputObject $Data -Name 'Dependencies')) {
        return New-UnavailableFinding -Category Network -Check $check -Hint (Get-Text 'Hint.NetworkDependency.NotGathered')
    }

    $dependencies = Get-DataCollection $Data 'Dependencies'
    $servers      = Get-DataCollection $Data 'Servers'
    $unexamined   = Get-DataCollection $Data 'Unexamined'

    if (-not $dependencies.Count) {
        # A path on a drive nobody could place is very likely a dependency that was not
        # recognised. Saying "nothing depends on the network" beside it would be the
        # all-clear this Check exists to stop giving.
        $unplaced = @($unexamined | Where-Object { (Get-DataProperty $_ 'Reason') -eq 'DriveNotResolved' })
        if (-not $unplaced.Count) {
            New-Finding -Category Network -Check $check -Severity OK -Value (Get-Text 'Value.NetworkDependency.None')
        }
    }

    # A program on a share is a Finding of its own, and so is whatever depends on a Server
    # that does not answer. The rest - a printer, a redirected folder, a data source whose
    # Server is there - is in order, and an office has dozens of them: they are summed up
    # in one Finding per kind, and the Section lists each.
    $fine  = [ordered]@{}
    $users = @{}
    # Which Finding judges each measurement, for the Measurements below: the one about the
    # Server where it answered, and where it did not, the first that says so - a FAIL
    # before an INFO about a name out of a configuration file.
    $judgedBy = @{}
    $downBy   = @{}
    foreach ($dependency in $dependencies) {
        $server = Select-NetworkDependencyServerRow -Server $servers -Dependency $dependency
        $kind   = "$(Get-DataProperty $dependency 'Kind')"

        $key = $null
        if ($null -ne $server) {
            $key = Get-NetworkDependencyServerKey -Server $server
            if (-not $users.ContainsKey($key)) { $users[$key] = [System.Collections.Generic.List[psobject]]::new() }
            $users[$key].Add($dependency)
        }

        if ($kind -eq 'program' -or (Test-NetworkDependencyServerDown -Server $server)) {
            $finding = New-NetworkDependencyFinding -Dependency $dependency -Server $server -Situation $Situation
            $finding
            if ($key -and (Test-NetworkDependencyServerDown -Server $server) -and
                (-not $downBy.ContainsKey($key) -or ("$($downBy[$key].Severity)" -ne 'FAIL' -and "$($finding.Severity)" -eq 'FAIL'))) {
                $downBy[$key] = $finding
            }
            continue
        }
        if (-not $fine.Contains($kind)) { $fine[$kind] = [System.Collections.Generic.List[psobject]]::new() }
        $fine[$kind].Add($dependency)
    }

    foreach ($kind in @($fine.Keys)) {
        New-NetworkDependencyKindFinding -Kind $kind -Dependency @($fine[$kind])
    }

    foreach ($server in $servers) {
        $mine = @()
        $key  = Get-NetworkDependencyServerKey -Server $server
        if ($users.ContainsKey($key)) { $mine = @($users[$key]) }
        $finding = New-NetworkDependencyServerFinding -Server $server -DependencyCount $mine.Count -Parameters $Parameters -Situation $Situation `
            -Kind @($mine | ForEach-Object { "$(Get-DataProperty $_ 'Kind')" } | Select-Object -Unique)
        $finding
        if ($finding) { $judgedBy[$key] = $finding }
    }

    New-NetworkDependencyAdapterFinding -Data $Data -Dependency $dependencies -Situation $Situation

    New-NetworkDependencyReferenceFinding -Data $Data

    New-NetworkDependencyUnexaminedFinding -Unexamined $unexamined

    # And the numbers those Findings judge, each naming the Finding it belongs to, for the
    # Report's picture of the network. ADR-0009.
    foreach ($server in $servers) {
        $key = Get-NetworkDependencyServerKey -Server $server
        $mine = @()
        if ($users.ContainsKey($key)) { $mine = @($users[$key]) }
        ConvertTo-NetworkDependencyMeasurement -Server $server -Data $Data -Parameters $Parameters -Dependency $mine `
            -JudgedBy $judgedBy[$key] -DownBy $downBy[$key]
    }
}

function Get-NetworkDependencyRole {
    <#
    .SYNOPSIS
        What a Server is to the Target Machine, as what depends on it says: a print server
        where only printers do, a file server where a path on it does, an application
        server where something needs it by name and port alone - a database. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Server, [AllowNull()][AllowEmptyCollection()]$Dependency)

    $rows = @($Dependency | Where-Object { $null -ne $_ })
    if ($rows.Count -and -not @($rows | Where-Object { "$(Get-DataProperty $_ 'Source')" -ne 'printer' }).Count) { return 'PrintServer' }
    if (@($rows | Where-Object { "$(Get-DataProperty $_ 'UncPath')" }).Count) { return 'FileServer' }
    if (-not $rows.Count -and "$(Get-DataProperty $Server 'Port')" -eq "$script:NetworkDependencySmbPort") { return 'FileServer' }
    'AppServer'
}

function ConvertTo-NetworkDependencyMeasurement {
    <#
    .SYNOPSIS
        What this Check measured one Server with, on one port: the name resolution, the
        connect, the ping, its slowest reply and the loss. Pure.
    .DESCRIPTION
        The connect, the ping and the loss belong to the Finding about the Server where it
        answered, and where it did not, to the Finding about what depends on it. No
        Finding of this Check judges how long a name took, so the resolution names none -
        but one that failed belongs to the Finding that says the Server is not there.
 
        A name answered from the DNS client's cache is Cached and has no time. Where the
        Gatherer could not ask the cache, the time stands as measured. Data from before
        the address and the times were kept says nothing measured of them.
    .PARAMETER JudgedBy
        The Finding about this Server's measurement, where there is one.
    .PARAMETER DownBy
        The Finding that says what depends on this Server is not reached, where the
        Server did not answer.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)]$Server,
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{},
        [AllowNull()][AllowEmptyCollection()]$Dependency,
        [AllowNull()]$JudgedBy,
        [AllowNull()]$DownBy
    )

    $resolved = [bool](Get-DataProperty $Server 'Resolved')
    $address  = ''
    $zone     = ''
    if ($resolved) {
        $address = "$(Get-DataProperty $Server 'Address')"
        $vpnPattern = Get-Parameter $Parameters 'VpnAdapterPattern' $script:MeasurementVpnAdapterPattern
        $zone = Get-GatheredAddressZone -Data $Data -Address $address -Route (Get-DataProperty $Server 'AddressRoute') -VpnPattern $vpnPattern
    }
    $common = @{ Subject = "$(Get-DataProperty $Server 'Server')"; Address = $address; Zone = $zone
                 Role = (Get-NetworkDependencyRole -Server $Server -Dependency $Dependency) }

    # Measured without a port, nothing was connected to. Data from before the port could
    # be absent has none either, and means 445.
    $port = Get-DataProperty $Server 'Port'
    if (-not (Test-NetworkDependencyProperty -InputObject $Server -Name 'Port')) { $port = $script:NetworkDependencySmbPort }

    if (-not $resolved) { return New-UnresolvedMeasurement -Common $common -Port @($port) -Finding $DownBy }

    ConvertTo-DnsMeasurement -Common $common -Cached (Get-DataProperty $Server 'DnsCached') -Milliseconds (Get-DataProperty $Server 'DnsMs')

    $belongs = $JudgedBy
    if ($null -eq $belongs) { $belongs = $DownBy }

    if ($port) {
        $tcp = ConvertTo-Number (Get-DataProperty $Server 'TcpMs')
        $attempts = Get-DataProperty $Server 'TcpAttempts'
        if ((Get-DataProperty $Server 'Reachable') -and $null -ne $tcp) {
            New-Measurement @common -Metric TcpConnect -Port $port -Samples $attempts -Value $tcp -Finding $belongs
        }
        else {
            New-Measurement @common -Metric TcpConnect -Port $port -Samples $attempts -State Failed `
                -ErrorText (Get-Text 'Measurement.Error.NoConnection') -Finding $belongs
        }
    }

    # The pings, as the Network Kind says them. The slowest reply was not kept before #106,
    # and is then not measured rather than failed.
    $sent = Get-DataProperty $Server 'PingSent'
    foreach ($pair in @(@('Ping', 'PingMs'), @('PingMax', 'PingMaxMs'))) {
        $value = ConvertTo-Number (Get-DataProperty $Server $pair[1])
        if ($null -ne $value) { New-Measurement @common -Metric $pair[0] -Samples $sent -Value $value -Finding $belongs }
        elseif (-not (Test-NetworkDependencyProperty -InputObject $Server -Name $pair[1])) { New-Measurement @common -Metric $pair[0] -State NotMeasured -Finding $belongs }
        else { New-Measurement @common -Metric $pair[0] -Samples $sent -State Failed -ErrorText (Get-Text 'Measurement.Error.NoReply') -Finding $belongs }
    }
    $loss = ConvertTo-Number (Get-DataProperty $Server 'LossPercent')
    if ($null -eq $loss) { New-Measurement @common -Metric Loss -State NotMeasured -Finding $belongs }
    else { New-Measurement @common -Metric Loss -Samples $sent -Value $loss -Finding $belongs }
}

function ConvertTo-NetworkDependencySection {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    # What points at a Server that does not answer stands whether or not anything else
    # depends on the network.
    ConvertTo-NetworkDependencyReferenceSection -Data $Data

    $dependencies = Get-DataCollection $Data 'Dependencies'
    if (-not $dependencies.Count) { return }
    $servers = Get-DataCollection $Data 'Servers'

    # The headings are German like the rest of the Report, so the rows are built with the
    # names the table is to show.
    $application = Get-Text 'Column.NetworkDependency.Application'
    $kind        = Get-Text 'Column.NetworkDependency.Kind'
    $location    = Get-Text 'Column.NetworkDependency.Location'
    $serverName  = Get-Text 'Column.NetworkDependency.Server'
    $measurement = Get-Text 'Column.NetworkDependency.Measurement'
    $source      = Get-Text 'Column.NetworkDependency.Source'

    New-Section -Title (Get-Text 'Title.NetworkDependency.Dependencies') -Help (Get-Text 'Help.NetworkDependency.Dependencies') -Row @(
        foreach ($dependency in ($dependencies | Sort-Object Server, Application, Path)) {
            $server = Select-NetworkDependencyServerRow -Server $servers -Dependency $dependency
            $row = [ordered]@{}
            $row[$application] = Get-NetworkDependencyApplicationText -Dependency $dependency
            $row[$kind]        = Get-NetworkDependencyKindText -Kind (Get-DataProperty $dependency 'Kind')
            $row[$location]    = Get-NetworkDependencyLocationText -Dependency $dependency
            $row[$serverName]  = Get-DataProperty $dependency 'Server'
            $row[$measurement] = Get-NetworkDependencyMeasurementText -Server $server
            $row[$source]      = Get-NetworkDependencySourceText -Source (Get-DataProperty $dependency 'Source')
            [pscustomobject]$row
        }
    )
}

function Test-NetworkDependencyProperty {
    <#
    .SYNOPSIS
        Whether gathered data carries a property at all, which is not the same as the
        property being empty. Pure.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()]$InputObject, [Parameter(Mandatory)][string]$Name)

    if ($null -eq $InputObject) { return $false }
    if ($InputObject -is [hashtable]) { return $InputObject.ContainsKey($Name) }
    [bool]$InputObject.PSObject.Properties[$Name]
}

function Get-NetworkDependencyServerKey {
    <#
    .SYNOPSIS
        What tells one measurement from another: the Server and the port. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Server)

    ('{0}|{1}' -f (Get-DataProperty $Server 'Server'), (Get-DataProperty $Server 'Port')).ToUpperInvariant()
}

function Select-NetworkDependencyServerRow {
    <#
    .SYNOPSIS
        The measurement that belongs to a dependency: its Server, on its port. Pure.
    .DESCRIPTION
        One Server is measured once per port something needs it on - a file server that
        also runs the database is measured on 445 and on 1433 - and a dependency is held
        against the measurement on its own port. A dependency from before the port was
        gathered has none: its path says which, and without a path it is the Server's
        first measurement.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Server,
        [AllowNull()]$Dependency
    )

    $name = "$(Get-DataProperty $Dependency 'Server')"
    $candidates = @($Server | Where-Object { $null -ne $_ -and "$(Get-DataProperty $_ 'Server')" -eq $name })
    if (-not $candidates.Count) { return }

    $known = Test-NetworkDependencyProperty -InputObject $Dependency -Name 'Port'
    $port  = Get-DataProperty $Dependency 'Port'
    if (-not $known) {
        $parts = Split-NetworkDependencyUncPath -UncPath "$(Get-DataProperty $Dependency 'UncPath')"
        if (-not $parts) { return $candidates[0] }
        $port = $parts.Port
    }

    foreach ($candidate in $candidates) {
        if ("$(Get-DataProperty $candidate 'Port')" -eq "$port") { return $candidate }
    }
}

function Test-NetworkDependencyServerDown {
    <#
    .SYNOPSIS
        Whether a measurement says the Server is not there. Pure.
    .DESCRIPTION
        A Server that was not measured is not thereby unreachable. A name that does not
        resolve is. A Server that was measured without a port was never connected to: it
        is down only when its name does not resolve, because a ping that is not answered
        is a firewall as often as a dead Server.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()]$Server)

    if ($null -eq $Server) { return $false }
    if (-not (Get-DataProperty $Server 'Resolved')) { return $true }
    if ((Test-NetworkDependencyProperty -InputObject $Server -Name 'Port') -and -not (Get-DataProperty $Server 'Port')) { return $false }
    -not (Get-DataProperty $Server 'Reachable')
}

function Get-NetworkDependencySourceText {
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Source)

    switch ($Source) {
        'process'            { return (Get-Text 'Value.NetworkDependency.Source.Process') }
        'module'             { return (Get-Text 'Value.NetworkDependency.Source.Module') }
        'service'            { return (Get-Text 'Value.NetworkDependency.Source.Service') }
        'task'               { return (Get-Text 'Value.NetworkDependency.Source.Task') }
        'startup'            { return (Get-Text 'Value.NetworkDependency.Source.Startup') }
        'shortcut'           { return (Get-Text 'Value.NetworkDependency.Source.Shortcut') }
        'apppath'            { return (Get-Text 'Value.NetworkDependency.Source.AppPath') }
        'folder-redirection' { return (Get-Text 'Value.NetworkDependency.Source.FolderRedirection') }
        'offline-files'      { return (Get-Text 'Value.NetworkDependency.Source.OfflineFiles') }
        'printer'            { return (Get-Text 'Value.NetworkDependency.Source.Printer') }
        'odbc'               { return (Get-Text 'Value.NetworkDependency.Source.Odbc') }
        'config'             { return (Get-Text 'Value.NetworkDependency.Source.Config') }
    }
    # A source a newer Gatherer invented is shown as it came rather than hidden.
    "$Source"
}

function Get-NetworkDependencyApplicationText {
    <#
    .SYNOPSIS
        What a dependency is called in the Report.
    .DESCRIPTION
        A program, a printer and a data source are called what they are called on the
        machine. A redirected folder and Offline Files are not applications and have no
        name of their own: the Gatherer records the folder as the registry names it, and
        the German is said here.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Dependency)

    $name = "$(Get-DataProperty $Dependency 'Application')"
    switch ("$(Get-DataProperty $Dependency 'Source')") {
        'offline-files'      { return (Get-Text 'Value.NetworkDependency.OfflineFiles') }
        'folder-redirection' {
            $folder = $name
            switch ($name) {
                'Personal'      { $folder = Get-Text 'Value.NetworkDependency.Folder.Personal' }
                'Desktop'       { $folder = Get-Text 'Value.NetworkDependency.Folder.Desktop' }
                'Downloads'     { $folder = Get-Text 'Value.NetworkDependency.Folder.Downloads' }
                'My Pictures'   { $folder = Get-Text 'Value.NetworkDependency.Folder.Pictures' }
                'My Music'      { $folder = Get-Text 'Value.NetworkDependency.Folder.Music' }
                'My Video'      { $folder = Get-Text 'Value.NetworkDependency.Folder.Video' }
                'Favorites'     { $folder = Get-Text 'Value.NetworkDependency.Folder.Favorites' }
                'AppData'       { $folder = Get-Text 'Value.NetworkDependency.Folder.AppData' }
                'Local AppData' { $folder = Get-Text 'Value.NetworkDependency.Folder.LocalAppData' }
                'Start Menu'    { $folder = Get-Text 'Value.NetworkDependency.Folder.StartMenu' }
                'Programs'      { $folder = Get-Text 'Value.NetworkDependency.Folder.Programs' }
                'Startup'       { $folder = Get-Text 'Value.NetworkDependency.Folder.Startup' }
            }
            return ((Get-Text 'Value.NetworkDependency.RedirectedFolder') -f $folder)
        }
    }
    $name
}

function Get-NetworkDependencyConsequenceText {
    <#
    .SYNOPSIS
        What a bad connection does to a dependency of this kind, in a few words.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Kind)

    switch ($Kind) {
        'program'  { return (Get-Text 'Meaning.NetworkDependency.Consequence.Program') }
        'data'     { return (Get-Text 'Meaning.NetworkDependency.Consequence.Data') }
        'profile'  { return (Get-Text 'Meaning.NetworkDependency.Consequence.Profile') }
        'database' { return (Get-Text 'Meaning.NetworkDependency.Consequence.Database') }
        'service'  { return (Get-Text 'Meaning.NetworkDependency.Consequence.Service') }
    }
    ''
}

function Get-NetworkDependencyKindText {
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Kind)

    switch ($Kind) {
        'program'  { return (Get-Text 'Value.NetworkDependency.Kind.Program') }
        'data'     { return (Get-Text 'Value.NetworkDependency.Kind.Data') }
        'profile'  { return (Get-Text 'Value.NetworkDependency.Kind.Profile') }
        'database' { return (Get-Text 'Value.NetworkDependency.Kind.Database') }
        'service'  { return (Get-Text 'Value.NetworkDependency.Kind.Service') }
    }
    # A kind a newer Gatherer invented is shown as it came rather than hidden.
    "$Kind"
}

function Get-NetworkDependencyLocationText {
    <#
    .SYNOPSIS
        Where a dependency points: the share, and the path as the user knows it when that
        is a drive letter.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Dependency)

    $path = "$(Get-DataProperty $Dependency 'Path')"
    $unc  = "$(Get-DataProperty $Dependency 'UncPath')"
    if (-not $unc -and -not $path) {
        # A database: a Server and, where it is known, the port.
        $server = "$(Get-DataProperty $Dependency 'Server')"
        $port   = Get-DataProperty $Dependency 'Port'
        if ($port) { return ((Get-Text 'Value.NetworkDependency.ServerPort') -f $server, $port) }
        return $server
    }
    if (-not $unc)  { return $path }
    if (-not $path -or $path -eq $unc -or $path -match '^\\\\') { return $unc }

    $share = $unc
    $parts = Split-NetworkDependencyUncPath -UncPath $unc
    if ($parts) { $share = $parts.Share }
    (Get-Text 'Value.NetworkDependency.MappedPath') -f $path, $share
}

function Get-NetworkDependencyMeasurementText {
    <#
    .SYNOPSIS
        One Server's measurement as the single cell the Section and the Findings show.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Server)

    if ($null -eq $Server) { return (Get-Text 'Value.NetworkDependency.NotMeasured') }
    if (-not (Get-DataProperty $Server 'Resolved')) { return (Get-Text 'Value.Server.CannotBeResolved') }

    # A Server measured without a port was resolved and pinged, and there is no connection
    # to show. Data from before the port could be absent has none either, and means 445.
    $port      = Get-DataProperty $Server 'Port'
    $connected = $true
    if (-not $port) {
        if (Test-NetworkDependencyProperty -InputObject $Server -Name 'Port') { $connected = $false }
        else { $port = $script:NetworkDependencySmbPort }
    }

    $parts = @()
    $tcp = ConvertTo-Number (Get-DataProperty $Server 'TcpMs')
    if ($connected) {
        if ((Get-DataProperty $Server 'Reachable') -and $null -ne $tcp) {
            $parts += (Get-Text 'Value.NetworkDependency.Tcp') -f $port, $tcp
        }
        else { $parts += (Get-Text 'Value.NetworkDependency.TcpFailed') -f $port }
    }

    $ping = ConvertTo-Number (Get-DataProperty $Server 'PingMs')
    $loss = ConvertTo-Number (Get-DataProperty $Server 'LossPercent')
    if ($null -eq $ping)                    { $parts += (Get-Text 'Value.NetworkDependency.NoPing') }
    elseif ($null -ne $loss -and $loss -gt 0) { $parts += (Get-Text 'Value.NetworkDependency.PingLoss') -f $ping, $loss }
    else                                    { $parts += (Get-Text 'Value.NetworkDependency.Ping') -f $ping }

    $dialect = "$(Get-DataProperty $Server 'Dialect')"
    if ($dialect) { $parts += (Get-Text 'Value.NetworkDependency.Smb') -f $dialect }
    if ((Get-DataProperty $Server 'Encrypted') -eq $true)    { $parts += (Get-Text 'Value.NetworkDependency.Encrypted') }
    if ((Get-DataProperty $Server 'Multichannel') -eq $true) { $parts += (Get-Text 'Value.NetworkDependency.Multichannel') }

    $parts -join ', '
}

function New-NetworkDependencyFinding {
    <#
    .SYNOPSIS
        One dependency, judged by what kind it is and whether its Server answers.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$Dependency, [AllowNull()]$Server, [AllowNull()]$Situation)

    $kind = "$(Get-DataProperty $Dependency 'Kind')"

    # A program that lives on a share is a risk before anything has gone wrong: Windows
    # pages it in from the Server as it runs, and a page it cannot fetch ends the process.
    $severity  = 'OK'
    $meaning   = @()
    $hint      = @()
    $reference = @()
    if ($kind -eq 'program') {
        $severity = 'WARN'
        # How to keep the connection steady depends on what it is, and so does what
        # there is to say about it.
        $steady = Select-SituationText -Situation $Situation -Fact Connection -Text @{
            Wired    = Get-Text 'Hint.NetworkDependency.Stable.Wired'
            Wireless = Get-Text 'Hint.NetworkDependency.Stable.Wireless'
            Vpn      = Get-Text 'Hint.NetworkDependency.Stable.Vpn'
            Unknown  = Get-Text 'Hint.NetworkDependency.Stable.Unknown'
        }
        $connection = Select-SituationText -Situation $Situation -Fact Connection -Text @{
            Wireless = Get-Text 'Meaning.NetworkDependency.Connection.Wireless'
            Vpn      = Get-Text 'Meaning.NetworkDependency.Connection.Vpn'
            Unknown  = ''
        }
        $meaning  = @((Get-Text 'Meaning.NetworkDependency.ProgramOnShare'), $connection)
        $hint     = @((Get-Text 'Hint.NetworkDependency.ProgramOnShare') -f $steady)
        # Found as a loaded module: the program itself may well be installed locally, and
        # telling the Technician to install it locally would send them the wrong way.
        if ("$(Get-DataProperty $Dependency 'Source')" -eq 'module') {
            $meaning = @((Get-Text 'Meaning.NetworkDependency.ModuleOnShare'), $connection)
            $hint    = @((Get-Text 'Hint.NetworkDependency.ModuleOnShare') -f $steady)
        }
        # Started by a scheduled task: it is not a program somebody has open all day, and
        # what the Technician needs is the task - when it runs, and how the last run ended.
        if ("$(Get-DataProperty $Dependency 'Source')" -eq 'task') {
            $meaning   = @(Get-Text 'Meaning.NetworkDependency.TaskOnShare')
            $hint      = @(Get-Text 'Hint.NetworkDependency.TaskOnShare')
            $reference = @(New-Reference -Section 'Title.Startup.ScheduledTasks')
        }
    }

    # Found only as something that can start it - a shortcut, a registered program path -
    # the program was not running from the share when this was looked at. Worth knowing
    # and no warning: nothing is at risk until somebody starts it.
    if ($kind -eq 'program' -and "$(Get-DataProperty $Dependency 'Source')" -in 'shortcut', 'apppath') {
        $severity = 'INFO'
        $meaning  = @(Get-Text 'Meaning.NetworkDependency.NotRunning') + $meaning
    }

    # What follows from a Server that is not there depends on what was on it; what to
    # check is the same, but for a database.
    if (Test-NetworkDependencyServerDown -Server $Server) {
        $severity = 'FAIL'
        $meaning  = @(Get-Text 'Meaning.NetworkDependency.ServerUnreachable')
        $hint     = @(Get-Text 'Hint.NetworkDependency.ServerUnreachable')
        switch ($kind) {
            'data'     { $meaning = @(Get-Text 'Meaning.NetworkDependency.Unreachable.Data') }
            'profile'  { $meaning = @(Get-Text 'Meaning.NetworkDependency.Unreachable.Profile') }
            'database' { $meaning = @(Get-Text 'Meaning.NetworkDependency.Unreachable.Database'); $hint = @(Get-Text 'Hint.NetworkDependency.Unreachable.Database') }
            'service'  { $meaning = @(Get-Text 'Meaning.NetworkDependency.Unreachable.Service') }
        }
        # A name out of a configuration file may be one nobody uses any more, and the
        # Technician has to be told where to look before a Server is searched for.
        if ("$(Get-DataProperty $Dependency 'Source')" -eq 'config') {
            $meaning += Get-Text 'Meaning.NetworkDependency.FromConfig'
            $hint    += Get-Text 'Hint.NetworkDependency.FromConfig'
            # And a name that does not even resolve is more likely no Server at all - a
            # database alias, a host retired years ago - than one that is down. A FAIL on
            # the first page for a line in an .ini would be this Check crying wolf. A name
            # that resolves and does not answer is a Server, and stays a FAIL.
            if (-not (Get-DataProperty $Server 'Resolved')) { $severity = 'INFO' }
        }
    }

    # About its Server: that a program lives there, and that the Server does not answer.
    $signal = @()
    if ($kind -eq 'program') { $signal += 'dependency-on-server' }
    # Not where the Finding itself doubts that there is such a Server: a name out of a
    # configuration file that does not resolve.
    if ((Test-NetworkDependencyServerDown -Server $Server) -and $severity -ne 'INFO') { $signal += 'server-unreachable' }

    New-Finding -Category Network -Severity $severity -Reference $reference `
        -Meaning (@($meaning | Where-Object { $_ }) -join ' | ') -Hint (@($hint | Where-Object { $_ }) -join ' | ') `
        -Signal $signal -Subject "$(Get-DataProperty $Dependency 'Server')" `
        -Check ((Get-Text 'Check.NetworkDependency.Dependency') -f (Get-NetworkDependencyApplicationText -Dependency $Dependency)) `
        -Value ((Get-Text 'Value.NetworkDependency.Dependency') -f (Get-NetworkDependencyKindText -Kind $kind),
                (Get-NetworkDependencyLocationText -Dependency $Dependency), (Get-DataProperty $Dependency 'Server'),
                (Get-NetworkDependencyMeasurementText -Server $Server))
}

function Get-NetworkDependencyServerLimit {
    <#
    .SYNOPSIS
        The three limits a Server's measurement is judged by, as a Check Definition gives
        them or as Gutcheck ships them. Pure.
    .DESCRIPTION
        Named as the parameters are, so that what comes back reads as parameters again.
        The Gatherer hands them on with what it gathered: a later Check that says a
        Server is slow - the one about where Desktop and Documents lie - is then
        answered by the limits this Check was given, and a Customer who set its own is
        not judged by two. As Get-StorageFreeSpaceLimit does for free space.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][hashtable]$Parameters)

    [pscustomobject]@{
        ServerTcpWarnMs       = Get-Parameter $Parameters 'ServerTcpWarnMs'       150
        ServerLatencyWarnMs   = Get-Parameter $Parameters 'ServerLatencyWarnMs'    50
        ServerLossWarnPercent = Get-Parameter $Parameters 'ServerLossWarnPercent'   0
    }
}

function New-NetworkDependencyServerFinding {
    <#
    .SYNOPSIS
        One Server's measurement, held against the thresholds the Server Kind uses.
    .DESCRIPTION
        Only for a Server that answered. One that did not is a FAIL on every dependency
        that needs it, and a second FAIL here would count one fault twice.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Server,
        [int]$DependencyCount,
        [hashtable]$Parameters,
        [AllowNull()]$Situation,
        # The kinds of dependency this measurement stands for: the Meaning says what a
        # slow connection does to each.
        [AllowNull()][AllowEmptyCollection()][string[]]$Kind
    )

    # The Server Kind's names and defaults, so one sentence in a Check Definition means
    # the same thing in both. WARN only: a slow Server is evidence here, not a verdict.
    $limit    = Get-NetworkDependencyServerLimit -Parameters $Parameters
    $tcpWarn  = $limit.ServerTcpWarnMs
    $pingWarn = $limit.ServerLatencyWarnMs
    $lossWarn = $limit.ServerLossWarnPercent

    if (Test-NetworkDependencyServerDown -Server $Server) { return }

    $never = [double]::MaxValue
    $grades = @('OK')

    $tcp = ConvertTo-Number (Get-DataProperty $Server 'TcpMs')
    if ($null -ne $tcp) { $grades += Get-Severity $tcp $tcpWarn $never }

    # No ping reply at all is a blocked ping on a Server whose port just opened, and says
    # nothing about loss.
    $ping = ConvertTo-Number (Get-DataProperty $Server 'PingMs')
    $loss = ConvertTo-Number (Get-DataProperty $Server 'LossPercent')
    if ($null -ne $ping) {
        $grades += Get-Severity $ping $pingWarn $never
        if ($null -ne $loss) { $grades += Get-Severity $loss $lossWarn $never }
    }

    # A port that opened only some of the time is the firewall or the link that makes an
    # application feel unreliable, as in the Server Kind.
    $attempts  = ConvertTo-Number (Get-DataProperty $Server 'TcpAttempts')
    $successes = ConvertTo-Number (Get-DataProperty $Server 'TcpSuccesses')
    if ($null -ne $attempts -and $null -ne $successes -and $successes -lt $attempts) { $grades += 'WARN' }

    # What it means is said here; where the machine and the Server are, and what to do
    # about it, as the Server Kind says it.
    $meaning = @((Get-Text 'Meaning.NetworkDependency.ServerSlow'), (Get-ServerPlaceText -Server $Server -Situation $Situation))
    $consequences = @($Kind | ForEach-Object { Get-NetworkDependencyConsequenceText -Kind $_ } | Where-Object { $_ } | Select-Object -Unique)
    if ($consequences.Count) {
        $meaning += (Get-Text 'Meaning.NetworkDependency.ServerSlowConsequence') -f ($consequences -join '; ')
    }

    $severity = Get-WorstSeverity @grades
    $signal = @()
    if ($severity -in 'WARN', 'FAIL') { $signal = @('server-slow') }
    New-Finding -Category Network -Severity $severity `
        -Check ((Get-Text 'Check.NetworkDependency.Server') -f (Get-DataProperty $Server 'Server')) `
        -Value ((Get-Text 'Value.NetworkDependency.ServerMeasurement') -f (Get-NetworkDependencyMeasurementText -Server $Server), $DependencyCount) `
        -Meaning (@($meaning | Where-Object { $_ }) -join ' | ') `
        -Hint (Get-ServerPingHint -Server $Server -Situation $Situation) `
        -Signal $signal -Subject "$(Get-DataProperty $Server 'Server')"
}

function Get-NetworkDependencyListText {
    <#
    .SYNOPSIS
        Names as one line of a Finding's Value: the first few, and how many more. Pure.
    .DESCRIPTION
        An office has dozens of printers and a Run without admin rights dozens of
        processes it may not open. The Value names enough to recognise them by and counts
        the rest; the Section lists every one. Nothing for no names.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Name)

    $names = @($Name | Where-Object { $_ })
    $list  = @($names | Select-Object -First $script:NetworkDependencyListShown) -join ', '
    if ($names.Count -gt $script:NetworkDependencyListShown) {
        $list = (Get-Text 'Value.NetworkDependency.AndMore') -f $list, ($names.Count - $script:NetworkDependencyListShown)
    }
    $list
}

function New-NetworkDependencyKindFinding {
    <#
    .SYNOPSIS
        The dependencies of one kind that are in order, as one Finding.
    .DESCRIPTION
        Not for programs, which are a risk each on its own, and not for a dependency
        whose Server does not answer, which is a FAIL by name. Names the first few and
        counts the rest; the Section lists every one.
    #>

    [CmdletBinding()]
    param(
        [AllowEmptyString()][string]$Kind,
        [AllowNull()][AllowEmptyCollection()]$Dependency
    )

    $rows = @($Dependency | Where-Object { $null -ne $_ })
    if (-not $rows.Count) { return }

    $list = Get-NetworkDependencyListText -Name @($rows | ForEach-Object {
        (Get-Text 'Value.NetworkDependency.KindSummaryEntry') -f (Get-NetworkDependencyApplicationText -Dependency $_), (Get-DataProperty $_ 'Server')
    } | Select-Object -Unique)

    New-Finding -Category Network -Severity OK `
        -Check ((Get-Text 'Check.NetworkDependency.Kind') -f (Get-NetworkDependencyKindText -Kind $Kind)) `
        -Value ((Get-Text 'Value.NetworkDependency.KindSummary') -f $rows.Count, $list)
}

function New-NetworkDependencyAdapterFinding {
    <#
    .SYNOPSIS
        A USB network adapter under a program that runs from a share (#38).
    .DESCRIPTION
        A USB adapter - in a dock, in a dongle - that Windows sends to sleep, or that drops
        off the bus for a moment, takes the connection with it, and that moment is enough
        to end a program Windows is paging in from a share. Neither fact is worth a WARN
        alone: the adapter is reported by the Network Check, the program by its own
        Finding. Without a program on a share there is nothing to say here.
 
        One Finding per USB adapter, because the Meaning names the adapter and the Hint
        what to switch off on it. A setting nobody could read is named as one to look at,
        not as off.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [AllowNull()][AllowEmptyCollection()]$Dependency, [AllowNull()]$Situation)

    $programs = @($Dependency | Where-Object { "$(Get-DataProperty $_ 'Kind')" -eq 'program' })
    if (-not $programs.Count) { return }

    # Absent as well as $false: data without the property comes from before the adapters
    # were copied, and says as little about them.
    if ((Get-DataProperty $Data 'AdaptersGathered') -ne $true) {
        return New-Finding -Category Network -Check (Get-Text 'Check.NetworkDependency.Adapter') -Severity INFO `
            -Value (Get-Text 'Value.NetworkDependency.AdapterNotAssessable') `
            -Hint (Get-Text 'Hint.NetworkDependency.AdapterNotAssessable')
    }

    $list = Get-NetworkDependencyListText -Name @($programs | ForEach-Object { "$(Get-DataProperty $_ 'Application')" } | Select-Object -Unique)

    $suspend = Get-DataProperty $Data 'UsbSelectiveSuspend'

    foreach ($adapter in (Get-DataCollection $Data 'Adapters')) {
        if ((Get-DataProperty $adapter 'IsUsb') -ne $true) { continue }

        $name   = "$(Get-DataProperty $adapter 'Name')"
        $saving = Get-DataProperty $adapter 'PowerSavingOn'

        $steps = @()
        if ($saving -eq $true)       { $steps += Get-Text 'Hint.NetworkDependency.UsbAdapter.PowerSavingOn' }
        elseif ($saving -ne $false)  { $steps += Get-Text 'Hint.NetworkDependency.UsbAdapter.PowerSavingUnknown' }
        if ($suspend -eq $true)      { $steps += Get-Text 'Hint.NetworkDependency.UsbAdapter.SuspendOn' }
        elseif ($suspend -ne $false) { $steps += Get-Text 'Hint.NetworkDependency.UsbAdapter.SuspendUnknown' }
        # Both off: said as what is, and what is left to do is to look elsewhere.
        $meaning = @((Get-Text 'Meaning.NetworkDependency.UsbAdapter') -f $name)
        if (-not $steps.Count) {
            $meaning += Get-Text 'Meaning.NetworkDependency.UsbAdapter.NothingOn'
            # With the docking station, unless the machine is known not to be in one.
            $steps   += Select-SituationText -Situation $Situation -Fact Docked -Text @{
                No      = Get-Text 'Hint.NetworkDependency.UsbAdapter.NothingOn.NoDock'
                Unknown = Get-Text 'Hint.NetworkDependency.UsbAdapter.NothingOn'
            }
        }

        New-Finding -Category Network -Severity WARN `
            -Check ((Get-Text 'Check.NetworkDependency.UsbAdapter') -f $name) `
            -Value ((Get-Text 'Value.NetworkDependency.UsbAdapter') -f (Get-DataProperty $adapter 'Description'),
                    (Get-DataProperty $adapter 'LinkSpeed'), $list) `
            -Meaning ($meaning -join ' | ') -Hint ($steps -join ' | ') `
            -Signal 'usb-network-adapter'
    }
}

# The processes of Windows itself that Windows opens to nobody, an administrator
# included. They run from the system drive and are no place to look for a dependency on
# the network: named apart, so that they are not read as something a Run with more rights
# would have opened. A constant and no parameter of a Check Definition: it is a fact
# about Windows.
$script:NetworkDependencyWindowsProcess = @(
    'System', 'Idle', 'Registry', 'Secure System', 'Memory Compression', 'MemCompression',
    'smss', 'csrss', 'wininit', 'services', 'lsass', 'LsaIso', 'NgcIso', 'vmmem', 'vmmemWSL',
    'MsMpEng', 'NisSrv', 'MpDefenderCoreService', 'SgrmBroker', 'SecurityHealthService'
)

function New-NetworkDependencyUnexaminedFinding {
    <#
    .SYNOPSIS
        What the Gatherer could not read, so a gap is not mistaken for no dependency.
    .DESCRIPTION
        Three kinds of gap, and a Technician does something different about each. What
        more rights would open: said first, with "repeat with admin rights". What Windows
        gives to the User of the Run only where that User is an administrator: how the
        connections to the file servers are made. And what Windows opens to nobody: said
        last, as nothing to do - on a Run with admin rights that is all that is left, and
        it read as fifteen things that had gone wrong.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyCollection()]$Unexamined)

    $rows = @($Unexamined | Where-Object { $null -ne $_ })
    if (-not $rows.Count) { return }

    $guarded  = @()
    $services = 0
    $smb      = $false
    $open     = @()
    foreach ($row in $rows) {
        $reason = "$(Get-DataProperty $row 'Reason')"
        $what   = "$(Get-DataProperty $row 'What')"
        $count  = ConvertTo-Number (Get-DataProperty $row 'Count')
        if (@('Get-SmbConnection', 'Get-SmbMultichannelConnection') -contains $what) { $smb = $true; continue }
        if (@('ModulesProtected', 'ServiceProtected') -contains $reason) {
            if ($null -ne $count) { $services += [int]$count } else { $guarded += $what }
            continue
        }
        if ($null -eq $count -and $script:NetworkDependencyWindowsProcess -contains $what -and
            @('PathNotReadable', 'ModulesNotReadable', 'ModulesAccessDenied') -contains $reason) { $guarded += $what; continue }
        $open += $row
    }

    # A process this Run may not open gives neither its path nor its modules, and both
    # sources say so. Named once, under the path: the same fifteen names twice would bury
    # the rest of the line.
    $pathless = @{}
    foreach ($row in $open) {
        if ("$(Get-DataProperty $row 'Reason')" -eq 'PathNotReadable') { $pathless["$(Get-DataProperty $row 'What')"] = $true }
    }
    $open = @($open | Where-Object {
        "$(Get-DataProperty $_ 'Reason')" -ne 'ModulesAccessDenied' -or -not $pathless.ContainsKey("$(Get-DataProperty $_ 'What')")
    })

    $reasons = @($open | ForEach-Object { "$(Get-DataProperty $_ 'Reason')" } | Select-Object -Unique)
    $parts = @(foreach ($reason in $reasons) {
        # A row that carries a Count stands for that many things and names none of them:
        # the service processes a Run without admin rights may not open are a number, and
        # "services" is not a name anybody should read.
        $what  = @()
        $total = 0
        foreach ($row in @($open | Where-Object { "$(Get-DataProperty $_ 'Reason')" -eq $reason })) {
            $counted = ConvertTo-Number (Get-DataProperty $row 'Count')
            if ($null -ne $counted) { $total += [int]$counted; continue }
            $total++
            $what += "$(Get-DataProperty $row 'What')"
        }
        $names = Get-NetworkDependencyListText -Name $what

        $text = $reason
        switch ($reason) {
            'PathNotReadable'     { $text = Get-Text 'Value.NetworkDependency.Reason.PathNotReadable' }
            'DriveNotResolved'    { $text = Get-Text 'Value.NetworkDependency.Reason.DriveNotResolved' }
            'AccessDenied'        { $text = Get-Text 'Value.NetworkDependency.Reason.AccessDenied' }
            'NotReadable'         { $text = Get-Text 'Value.NetworkDependency.Reason.NotReadable' }
            'ModulesAccessDenied' { $text = Get-Text 'Value.NetworkDependency.Reason.ModulesAccessDenied' }
            'ModulesNotReadable'  { $text = Get-Text 'Value.NetworkDependency.Reason.ModulesNotReadable' }
            'ServiceAccessDenied' { $text = Get-Text 'Value.NetworkDependency.Reason.ServiceAccessDenied' }
            'TaskNotReadable'     { $text = Get-Text 'Value.NetworkDependency.Reason.TaskNotReadable' }
            'ConfigNotReadable'   { $text = Get-Text 'Value.NetworkDependency.Reason.ConfigNotReadable' }
            'ConfigSkipped'       { $text = Get-Text 'Value.NetworkDependency.Reason.ConfigSkipped' }
        }
        if ($names) { (Get-Text 'Value.NetworkDependency.Unexamined') -f $total, $text, $names }
        else        { (Get-Text 'Value.NetworkDependency.UnexaminedCount') -f $total, $text }
    })
    if ($smb) { $parts += Get-Text 'Value.NetworkDependency.Unread.Smb' }
    $guarded = @($guarded | Where-Object { $_ } | Select-Object -Unique)
    if ($services -or $guarded.Count) {
        $said = @()
        if ($services)      { $said += (Get-Text 'Value.NetworkDependency.Unread.GuardedServices') -f $services }
        if ($guarded.Count) { $said += (Get-Text 'Value.NetworkDependency.Unread.GuardedProcesses') -f (Get-NetworkDependencyListText -Name $guarded) }
        $parts += (Get-Text 'Value.NetworkDependency.Unread.Guarded') -f ($said -join '; ')
    }

    # What to do, for what is there and for nothing else.
    $hint = @()
    if (@($reasons | Where-Object { @('PathNotReadable', 'AccessDenied', 'ModulesAccessDenied', 'ServiceAccessDenied') -contains $_ }).Count) {
        $hint += Get-Text 'Hint.NetworkDependency.Unread.Rights'
    }
    if ($reasons -contains 'DriveNotResolved') { $hint += Get-Text 'Hint.NetworkDependency.Unread.Drive' }
    if (@($reasons | Where-Object { @('PathNotReadable', 'AccessDenied', 'ModulesAccessDenied', 'ServiceAccessDenied', 'DriveNotResolved') -notcontains $_ }).Count) {
        $hint += Get-Text 'Hint.NetworkDependency.Unread.Other'
    }
    if ($smb) { $hint += Get-Text 'Hint.NetworkDependency.Unread.Smb' }
    if ($services -or $guarded.Count) { $hint += Get-Text 'Hint.NetworkDependency.Unread.Guarded' }

    New-Finding -Category Network -Check (Get-Text 'Check.NetworkDependency.Unexamined') -Severity INFO `
        -Value (@($parts) -join ' | ') -Meaning (Get-Text 'Meaning.NetworkDependency.Unread') -Hint ($hint -join ' | ')
}