Private/Kinds/Network.ps1

# The Network Kind: how this machine reaches everything that is not on it.
#
# Nearly every application complaint that turns out not to be the application turns out to
# be here, and the three things that cause it - a weak radio, a lossy link and slow name
# resolution - are all invisible to a Technician looking at the machine.

# How many pings each target gets. The gateway gets more because local loss is the reading
# most often dismissed as noise, and a wider window is what makes it undeniable.
$script:NetworkGatewayPingCount  = 30
$script:NetworkInternetPingCount = 20
$script:NetworkServerPingCount   = 20

# Where the Network Kind looks for the internet and for name resolution. Not Check
# Definition parameters: a Customer may disagree about a threshold, not about whether
# 1.1.1.1 is a reasonable thing to ping.
$script:NetworkInternetTarget = '1.1.1.1'
$script:NetworkDnsTarget      = 'www.microsoft.com'

$script:NetworkSmbPort = 445

# Where the active power plan keeps USB selective suspend: the USB settings subgroup and the
# setting in it. Verified with "powercfg /q" on Windows 11; the names powercfg prints beside
# them are localised, the GUIDs are not.
$script:NetworkUsbPowerSubgroup = '2a737441-1930-4402-8d77-b2bebba308a3'
$script:NetworkUsbSuspendSetting = '48e6b7a6-50f5-4782-a5d4-53bb8f07e226'

# Which of an adapter's advanced properties are gathered and shown, matched on the registry
# keyword: the display names are localised and every vendor words them differently, the
# keywords are what the driver's INF calls them. Wider than what counts as power saving,
# because wake and interrupt moderation are what a second-level Technician asks about next.
$script:NetworkEnergySettingPattern =
    'EEE|Energy|Green|PowerSav|PowerDown|LowPower|ULP|Sips|SelectiveSuspend|SSIdle|DeviceSleep|GigaLite|AutoDisableGigabit|Wake|Wol|PME|Moderation|^ITR$'

# Which of those save energy while the machine is running, and so can take the link down
# under a program that is using it. The default of the PowerSavingKeywordPattern parameter.
# A keyword that matches is counted only when it is a switch; see Test-AdapterSettingSwitch.
$script:NetworkPowerSavingPattern =
    'EEE|EnergyEfficient|Green|PowerSav|LowPower|ULPMode|SipsEnabled|SelectiveSuspend|DeviceSleep|GigaLite|AutoDisableGigabit'

# A keyword that names a list of modes and not a switch, where the driver did not say which
# values the setting can take.
$script:NetworkModeKeywordPattern = '(?:Mode|Level)$'

# What the SMB client configuration is asked for. The rest of it concerns signing and
# guests, which is another Check's question.
$script:NetworkSmbClientProperties = @(
    'SessionTimeout', 'FileInfoCacheLifetime', 'DirectoryCacheLifetime',
    'OplocksDisabled', 'EnableBandwidthThrottling', 'EnableMultiChannel'
)

# The power management answers that say Windows may take the adapter away to save energy.
$script:NetworkPowerManagementSavers = @('AllowComputerToTurnOffDevice', 'SelectiveSuspend', 'DeviceSleepOnDisconnect')
$script:NetworkPowerManagementProperties = $script:NetworkPowerManagementSavers +
    @('WakeOnMagicPacket', 'WakeOnPattern', 'ArpOffload', 'NSOffload', 'D0PacketCoalescing', 'RsnRekeyOffload')

# The user's proxy settings, as Internet Options writes them.
$script:NetworkUserProxyKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings'

function Get-NetworkData {
    <#
    .PARAMETER Observed
        What earlier Checks gathered, the Elevated Part's among them. The adapters' power
        management is closed to a Run without admin rights; the NetworkDisruption Check
        reads it with them and it is taken from there. See Get-NetworkAdapterFact.
    #>

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

    $elevatedPower = $null
    if ($Observed -and $Observed.ContainsKey('NetworkDisruption')) {
        $elevatedPower = Get-DataProperty $Observed['NetworkDisruption'] 'AdapterPowerManagement'
    }
    $adapterFacts = Get-NetworkAdapterFact -Parameters $Parameters -ElevatedPowerManagement $elevatedPower
    $adapters     = $adapterFacts.Adapters

    # The signal is parsed out of an external tool's output, which is the one genuinely
    # localisation-exposed read in Gutcheck. An unparseable answer leaves the percentage
    # absent, which the Judge treats as nothing to say rather than as a bad signal.
    $wifiText   = ''
    $wifiSignal = $null
    try {
        $wifiText = (netsh wlan show interfaces 2>$null | Out-String)
        $match = [regex]::Match($wifiText, '(?m)^\s*Signal\s*:\s*(\d+)\s*%')
        if ($match.Success) { $wifiSignal = [int]$match.Groups[1].Value }
    }
    catch { }

    $gateway = $null
    try {
        $gateway = (Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop |
            Sort-Object RouteMetric | Select-Object -First 1).NextHop
    }
    catch { }
    if ($gateway -eq '0.0.0.0') { $gateway = $null }

    $gatewayLatency = $null
    if ($gateway) { $gatewayLatency = Measure-Latency -Target $gateway -Count $script:NetworkGatewayPingCount }

    $internetLatency = Measure-Latency -Target $script:NetworkInternetTarget -Count $script:NetworkInternetPingCount

    $resolution = Resolve-HostAddress -HostName $script:NetworkDnsTarget

    $mappings = @()
    try {
        $mappings = @(Get-SmbMapping -ErrorAction Stop | ForEach-Object {
            $server = ("$($_.RemotePath)" -split '\\')[2]
            [pscustomobject]@{
                LocalPath  = "$($_.LocalPath)"
                RemotePath = "$($_.RemotePath)"
                Status     = "$($_.Status)"
                Server     = $server
                ConnectMs  = $(if ($server) { Measure-TcpConnect -HostName $server -Port $script:NetworkSmbPort } else { $null })
            }
        })
    }
    catch { }

    # Latency to every mapped file server, for the detail table: a share that feels slow
    # and a share that is slow are told apart here.
    $mappedLatency = @(
        $mappings | Where-Object { $_.Server } | Select-Object -ExpandProperty Server -Unique |
            ForEach-Object { Measure-Latency -Target $_ -Count $script:NetworkServerPingCount }
    )

    # The user's proxy (WinINET), read from the hive of whoever this process runs as. That
    # is the Technician's session only in the Main Part and unelevated; Elevated below is
    # what lets the Judge say so rather than present another account's settings as the
    # user's. $null when the key could not be read, which is not the same as no proxy.
    $userProxy = $null
    try {
        $settings = Get-ItemProperty -Path $script:NetworkUserProxyKey -ErrorAction Stop
        $userProxy = [pscustomobject]@{
            ProxyEnable   = $settings.ProxyEnable
            ProxyServer   = $settings.ProxyServer
            ProxyOverride = $settings.ProxyOverride
            AutoConfigURL = $settings.AutoConfigURL
        }
    }
    catch { }

    # The system proxy (WinHTTP), which is what Office uses for its background requests.
    # Kept as netsh's own text and parsed by the Judge: the output is localised, and a
    # parse that goes wrong has to be something a fixture can catch. There are two views on
    # 64-bit Windows, and 32-bit Office reads the SysWOW64 one (KB 2847833). A 32-bit
    # PowerShell would be redirected to SysWOW64 by "System32", so it asks for Sysnative.
    $nativeNetsh = Join-Path $env:windir 'System32\netsh.exe'
    if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) {
        $nativeNetsh = Join-Path $env:windir 'Sysnative\netsh.exe'
    }
    $winHttp64 = ''
    try { $winHttp64 = (& $nativeNetsh winhttp show proxy 2>$null | Out-String) } catch { }

    # $null only where there is no 32-bit view at all; a view that would not answer is an
    # empty string, which the Judge reports as unreadable rather than as absent.
    $winHttp32 = $null
    $wowNetsh  = Join-Path $env:windir 'SysWOW64\netsh.exe'
    if (Test-Path -LiteralPath $wowNetsh) {
        $winHttp32 = ''
        try { $winHttp32 = (& $wowNetsh winhttp show proxy 2>$null | Out-String) } catch { }
    }

    [pscustomobject]@{
        PSTypeName        = 'Gutcheck.Data.Network'
        Adapters          = $adapters
        WifiSignalPercent = $wifiSignal
        WifiInterfaceText = $wifiText
        GatewayAddress    = $gateway
        GatewayLatency    = $gatewayLatency
        InternetTarget    = $script:NetworkInternetTarget
        InternetLatency   = $internetLatency
        DnsTarget         = $script:NetworkDnsTarget
        DnsResolved       = $resolution.Resolved
        DnsMilliseconds   = $resolution.Milliseconds
        SmbMappings       = $mappings
        MappedLatency     = $mappedLatency
        UserProxy          = $userProxy
        WinHttpProxy64Text = $winHttp64
        WinHttpProxy32Text = $winHttp32
        # USB selective suspend in the active power plan: $true, $false, or $null when
        # powercfg gave no answer. The text is what it printed, for the Section.
        UsbSelectiveSuspend     = $adapterFacts.UsbSelectiveSuspend
        UsbSelectiveSuspendText = $adapterFacts.UsbSelectiveSuspendText
        SmbClientConfiguration  = $adapterFacts.SmbClientConfiguration
        # The sources that would not answer, by the name a Technician can type. Not read
        # is not the same as absent, and only the Gatherer can tell the two apart.
        Unread                  = $adapterFacts.Unread
        # Which rights the readings were taken with. Not a judgement, and not the same
        # question as Finding.Privilege: the Judge needs it because an elevated session
        # cannot see the Technician's drive mappings, so an empty list means two things.
        Elevated          = (Get-CurrentPrivilege) -eq 'admin'
    }
}

function Get-NetworkAdapterPowerManagement {
    <#
    .SYNOPSIS
        Get-NetAdapterPowerManagement for every adapter, as rows of text, and whether it
        refused. Never throws, and writes no error into the transcript.
    .DESCRIPTION
        Asked for all adapters at once: a virtual adapter has no power management, and
        asked for by name it answers with an error that says nothing about this Run's
        rights. Nothing back at all, with an error, is the refusal - and without admin
        rights that is what comes back.
 
        Called twice in a Run that was given admin rights: by the Network Check, which
        runs with the user's rights and is refused, and by the NetworkDisruption Check in
        the Elevated Part, whose answer the Network Check then uses.
    #>

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

    $rows = @()
    $refused = $true
    if (Get-Command -Name Get-NetAdapterPowerManagement -ErrorAction SilentlyContinue) {
        $problems = $null
        $power = @(Get-NetAdapterPowerManagement -ErrorAction SilentlyContinue -ErrorVariable problems)
        $refused = [bool](-not $power.Count -and @($problems).Count)
        $rows = @(foreach ($row in $power) {
            $out = [pscustomobject]@{ Name = "$($row.Name)" }
            foreach ($property in $script:NetworkPowerManagementProperties) {
                $out | Add-Member -NotePropertyName $property -NotePropertyValue "$($row.$property)"
            }
            $out
        })
    }
    [pscustomobject]@{ Rows = $rows; Refused = $refused }
}

function Select-AdapterPowerManagement {
    <#
    .SYNOPSIS
        Which reading of the adapters' power management a Run uses: its own, or the
        Elevated Part's where its own was refused. Pure.
    .DESCRIPTION
        Seen on a real machine: a Run that was given admin rights still reported
        "nicht gelesen: Get-NetAdapterPowerManagement", because the Check that asks runs
        with the user's rights and nobody handed it what the Elevated Part could read.
 
        Unread only when neither has rows: refused here, and no Elevated Part, or one
        that was refused as well or came from a module that did not read it yet.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Own, [AllowNull()]$Elevated)

    if (-not [bool](Get-DataProperty $Own 'Refused')) {
        return [pscustomobject]@{ Rows = (Get-DataCollection $Own 'Rows'); Unread = $false; Source = 'Own' }
    }
    $rows = @((Get-DataCollection ([pscustomobject]@{ Rows = $Elevated }) 'Rows') | Where-Object { $_ })
    [pscustomobject]@{ Rows = $rows; Unread = [bool](-not $rows.Count); Source = $(if ($rows.Count) { 'Elevated' } else { 'None' }) }
}

function Get-NetworkAdapterFact {
    <#
    .SYNOPSIS
        The adapters that are up with their driver, whether they are USB devices and what
        their power saving is set to, USB selective suspend and the SMB client configuration.
    .DESCRIPTION
        Every source is asked once and may refuse: Get-NetAdapterPowerManagement does so
        without admin rights. A refusal is an answer here, so nothing is asked with
        -ErrorAction Stop - that would write a TerminatingError line into the Run's
        transcript for a setting this Run was never going to read. What refused is named
        in Unread.
    #>

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

        # What the Elevated Part read of the adapters' power management, when it ran. Used
        # where this Run's own rights were refused.
        [AllowNull()]$ElevatedPowerManagement
    )

    $unread = [System.Collections.Generic.List[string]]::new()

    $up = @()
    if (Get-Command -Name Get-NetAdapter -ErrorAction SilentlyContinue) {
        $problems = $null
        $up = @(Get-NetAdapter -ErrorAction SilentlyContinue -ErrorVariable problems | Where-Object { $_.Status -eq 'Up' })
        if (@($problems).Count) { $unread.Add('Get-NetAdapter') }
    }
    else { $unread.Add('Get-NetAdapter') }

    $power = @()
    $advanced = @()
    $sleep = @()
    if ($up.Count) {
        # Refused without admin rights. A Run that was given them read it in the Elevated
        # Part, and that answer is as good as one read here.
        $chosen = Select-AdapterPowerManagement -Own (Get-NetworkAdapterPowerManagement) -Elevated $ElevatedPowerManagement
        $power  = @($chosen.Rows)
        if ($chosen.Unread) { $unread.Add('Get-NetAdapterPowerManagement') }

        if (Get-Command -Name Get-NetAdapterAdvancedProperty -ErrorAction SilentlyContinue) {
            $problems = $null
            $advanced = @(Get-NetAdapterAdvancedProperty -ErrorAction SilentlyContinue -ErrorVariable problems)
            if (-not $advanced.Count -and @($problems).Count) { $unread.Add('Get-NetAdapterAdvancedProperty') }
        }
        else { $unread.Add('Get-NetAdapterAdvancedProperty') }

        # "Allow the computer to turn off this device to save power" as Device Manager
        # shows it, one instance per device that has the tick box. Readable without admin
        # rights where Get-NetAdapterPowerManagement is not, which is why both are asked.
        $problems = $null
        $sleep = @(Get-CimInstance -Namespace 'root\wmi' -ClassName 'MSPower_DeviceEnable' -ErrorAction SilentlyContinue -ErrorVariable problems)
        if (-not $sleep.Count -and @($problems).Count) { $unread.Add('MSPower_DeviceEnable') }
    }

    $energyPattern = Get-Parameter $Parameters 'EnergySettingPattern'      $script:NetworkEnergySettingPattern
    $savingPattern = Get-Parameter $Parameters 'PowerSavingKeywordPattern' $script:NetworkPowerSavingPattern

    $adapters = @($up | ForEach-Object {
        $name = "$($_.Name)"
        $pnp  = "$($_.PnPDeviceID)"

        $management = $null
        # Read with Get-DataProperty: a row from the Elevated Part came through CliXML.
        $row = $power | Where-Object { "$(Get-DataProperty $_ 'Name')" -eq $name } | Select-Object -First 1
        if ($row) {
            $management = [pscustomobject]@{}
            foreach ($property in $script:NetworkPowerManagementProperties) {
                $management | Add-Member -NotePropertyName $property -NotePropertyValue "$(Get-DataProperty $row $property)"
            }
        }

        # The instance is named after the device: its PnP device id and an index.
        $sleepAllowed = $null
        if ($pnp) {
            $instance = $sleep | Where-Object { "$($_.InstanceName)" -like ('{0}_*' -f [WildcardPattern]::Escape($pnp)) } | Select-Object -First 1
            if ($instance) { $sleepAllowed = [bool]$instance.Enable }
        }

        # By keyword where the driver gave one; by display name only for the property
        # that has none.
        $settings = @($advanced | Where-Object { "$($_.Name)" -eq $name } | Where-Object {
            if ("$($_.RegistryKeyword)") { "$($_.RegistryKeyword)" -match $energyPattern } else { "$($_.DisplayName)" -match $energyPattern }
        } | ForEach-Object {
            [pscustomobject]@{
                Keyword       = "$($_.RegistryKeyword)"
                DisplayName   = "$($_.DisplayName)"
                Value         = "$($_.DisplayValue)"
                RegistryValue = (@($_.RegistryValue) -join ',')
                # What the driver says the setting can be, in its order. Empty for a
                # setting that takes a number from a range. It is what tells a switch
                # from a list of modes.
                ValidRegistryValues = @($_.ValidRegistryValues | Where-Object { $null -ne $_ } | ForEach-Object { "$_" })
            }
        })

        [pscustomobject]@{
            Name               = $name
            Description        = "$($_.InterfaceDescription)"
            LinkSpeed          = "$($_.LinkSpeed)"
            DriverVersion      = "$($_.DriverVersion)"
            DriverDate         = "$($_.DriverDate)"
            # A USB network adapter - in a dock, in a dongle, on a stick - is enumerated by
            # the USB bus, and its PnP device id says so. Kept beside the answer.
            PnpDeviceId        = $pnp
            IsUsb              = $pnp -like 'USB\*'
            PowerManagement    = $management
            DeviceSleepAllowed = $sleepAllowed
            EnergySettings     = $settings
            # A reduction of the three above and nothing more, so the Report can state one
            # answer per adapter; Get-AdapterPowerSaving is pure and says how.
            PowerSavingOn      = Get-AdapterPowerSaving -PowerManagement $management -DeviceSleepAllowed $sleepAllowed `
                                     -EnergySetting $settings -KeywordPattern $savingPattern
        }
    })

    $suspendText = Get-PowercfgSettingText -Subgroup $script:NetworkUsbPowerSubgroup -Setting $script:NetworkUsbSuspendSetting
    $suspend     = ConvertFrom-PowercfgIndexText -Text $suspendText
    if (-not $suspend.Recognised) { $unread.Add('powercfg') }

    $smb = $null
    if (Get-Command -Name Get-SmbClientConfiguration -ErrorAction SilentlyContinue) {
        $problems = $null
        $configuration = Get-SmbClientConfiguration -ErrorAction SilentlyContinue -ErrorVariable problems
        if ($configuration) {
            $smb = [pscustomobject]@{}
            foreach ($property in $script:NetworkSmbClientProperties) {
                $smb | Add-Member -NotePropertyName $property -NotePropertyValue $configuration.$property
            }
        }
    }
    if ($null -eq $smb) { $unread.Add('Get-SmbClientConfiguration') }

    [pscustomobject]@{
        Adapters                = $adapters
        UsbSelectiveSuspend     = $suspend.On
        UsbSelectiveSuspendText = $suspendText
        SmbClientConfiguration  = $smb
        Unread                  = @($unread)
    }
}

function Get-PowercfgSettingText {
    <#
    .SYNOPSIS
        What "powercfg /q" prints for one setting of the active power plan, or an empty
        string when it would not say.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][string]$Subgroup,
        [Parameter(Mandatory)][string]$Setting
    )

    if (-not (Get-Command -Name 'powercfg.exe' -CommandType Application -ErrorAction SilentlyContinue)) { return '' }

    # A power plan without the setting makes powercfg complain on stderr. Under the
    # module's Stop that complaint would be a terminating error, and a machine without the
    # setting is not one.
    $ErrorActionPreference = 'SilentlyContinue'
    $output = & powercfg.exe /q SCHEME_CURRENT $Subgroup $Setting 2>$null
    if ($LASTEXITCODE -ne 0) { return '' }
    ($output | Out-String)
}

function ConvertFrom-PowercfgIndexText {
    <#
    .SYNOPSIS
        Parses what "powercfg /q" printed for one setting into the index in effect on mains
        and the one on battery. Pure, and reaches nothing.
    .DESCRIPTION
        powercfg prints its labels in the UI language, so this reads the two values written
        as 0x and eight hex digits and nothing else: the possible settings above them are
        written as three plain digits. Mains comes first, battery second. Anything that
        does not hold both is not recognised, because an error read as "off" would be a
        false all-clear.
 
        On says whether the setting is switched on for either: a notebook on a dock is on
        mains at the desk and on battery in the meeting room, and the adapter goes with it.
    #>

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

    $found = [regex]::Matches("$Text", '(?m):\s*0x([0-9a-fA-F]{8})\s*$')

    $ac = $null
    $dc = $null
    $on = $null
    if ($found.Count -ge 2) {
        $ac = [Convert]::ToInt32($found[0].Groups[1].Value, 16)
        $dc = [Convert]::ToInt32($found[1].Groups[1].Value, 16)
        $on = ($ac -ne 0) -or ($dc -ne 0)
    }

    [pscustomobject]@{
        PSTypeName = 'Gutcheck.PowercfgIndex'
        Recognised = $found.Count -ge 2
        AcIndex    = $ac
        DcIndex    = $dc
        On         = $on
    }
}

function Test-AdapterSettingSwitch {
    <#
    .SYNOPSIS
        Whether an advanced property is a plain switch: 0 is off, 1 is on, and it can be
        nothing else. Pure.
    .DESCRIPTION
        Only a switch can be reduced to on or off without knowing the driver. A setting
        with more positions is a list of modes, and which of them saves energy is the
        vendor's to say: an Intel wireless adapter's MIMOPowerSaveMode is 3 when the
        saving is switched off, and reading every value but 0 as "on" called that power
        saving.
 
        The driver's own list of valid values decides, where it gave one: Intel's ULPMode
        is called a mode and lists 1 and 0. Data without the list - gathered before it was,
        or a setting that takes a number from a range - is judged by its name and its
        value: a keyword that ends in Mode or Level is taken for a list of modes, and
        anything else for a switch only while its value is 0 or 1.
    #>

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

    $valid = (Get-DataCollection $Setting 'ValidRegistryValues')
    if ($valid.Count) {
        foreach ($value in $valid) {
            $position = ConvertTo-Number $value
            if ($null -eq $position -or ($position -ne 0 -and $position -ne 1)) { return $false }
        }
        return $true
    }

    if ("$(Get-DataProperty $Setting 'Keyword')" -match $script:NetworkModeKeywordPattern) { return $false }
    $number = ConvertTo-Number (Get-DataProperty $Setting 'RegistryValue')
    ($number -eq 0) -or ($number -eq 1)
}

function Get-AdapterPowerSaving {
    <#
    .SYNOPSIS
        Whether an adapter's settings let it save energy while the machine runs: $true,
        $false, or $null when none of them gave an answer. Pure.
    .DESCRIPTION
        Three sources, any of which may be missing. One that says "on" decides it; "off"
        needs at least one that answered and none that said on. "Unsupported", an empty
        value and a setting that is not a number are not answers.
 
        Wake-on-LAN and interrupt moderation are gathered and shown but not counted: they
        do not take a link down under a program that is using it. Neither is a setting that
        is not a switch, whatever its keyword: Test-AdapterSettingSwitch says why.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [AllowNull()]$PowerManagement,
        [AllowNull()]$DeviceSleepAllowed,
        [AllowNull()][AllowEmptyCollection()]$EnergySetting,
        [string]$KeywordPattern = $script:NetworkPowerSavingPattern
    )

    $on  = $false
    $off = $false

    if ($DeviceSleepAllowed -is [bool]) {
        if ($DeviceSleepAllowed) { $on = $true } else { $off = $true }
    }

    foreach ($name in $script:NetworkPowerManagementSavers) {
        $answer = "$(Get-DataProperty $PowerManagement $name)"
        if ($answer -eq 'Enabled')      { $on = $true }
        elseif ($answer -eq 'Disabled') { $off = $true }
    }

    foreach ($setting in @($EnergySetting)) {
        if ($null -eq $setting) { continue }
        if ("$(Get-DataProperty $setting 'Keyword')" -notmatch $KeywordPattern) { continue }
        if (-not (Test-AdapterSettingSwitch -Setting $setting)) { continue }
        $number = ConvertTo-Number (Get-DataProperty $setting 'RegistryValue')
        if ($number -eq 1)     { $on = $true }
        elseif ($number -eq 0) { $off = $true }
    }

    if ($on)  { return $true }
    if ($off) { return $false }
    $null
}

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

    New-AdapterFinding         -Data $Data -Parameters $Parameters
    New-AdapterSettingFinding  -Data $Data -Parameters $Parameters
    New-WifiSignalFinding      -Data $Data -Parameters $Parameters
    New-GatewayLatencyFinding  -Data $Data -Parameters $Parameters
    New-InternetLatencyFinding -Data $Data -Parameters $Parameters
    New-DnsResolutionFinding   -Data $Data -Parameters $Parameters
    New-MappedDriveFinding     -Data $Data -Parameters $Parameters
    New-ProxyFinding           -Data $Data -Parameters $Parameters
}

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

    $latency = @(
        Get-DataProperty $Data 'GatewayLatency'
        Get-DataProperty $Data 'InternetLatency'
        Get-DataProperty $Data 'MappedLatency'
    ) | Where-Object { $_ }

    New-Section -Title (Get-Text 'Title.Network.LatencyTestsNetwork') -Row (@($latency) | ForEach-Object {
        $_ | Select-Object Target, Sent, Lost, LossPercent, AverageMs, MaximumMs
    })

    $wifi = Get-DataProperty $Data 'WifiInterfaceText'
    if ($wifi) { New-Section -Title (Get-Text 'Title.Network.WiFiInterfaceNetsh') -Text $wifi }

    New-AdapterSection -Data $Data

    # The proxy readings exactly as they came, so second level can see what the Judge made
    # of netsh's localised text rather than trust it.
    $user = Get-DataProperty $Data 'UserProxy'
    $lines = @('HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings')
    if ($null -eq $user) { $lines += ' (not readable)' }
    else {
        foreach ($name in 'ProxyEnable', 'ProxyServer', 'ProxyOverride', 'AutoConfigURL') {
            $lines += ' {0} = {1}' -f $name, (Get-DataProperty $user $name)
        }
    }
    foreach ($view in @(@('64-bit', 'WinHttpProxy64Text'), @('32-bit (SysWOW64)', 'WinHttpProxy32Text'))) {
        $lines += ''
        $lines += 'netsh winhttp show proxy, {0}:' -f $view[0]
        $text = Get-DataProperty $Data $view[1]
        if ($null -eq $text) { $lines += ' (not gathered)' } else { $lines += "$text".Trim("`r", "`n") }
    }
    New-Section -Title (Get-Text 'Title.Network.ProxySettings') -Text ($lines -join "`r`n")
}

function Get-TriStateText {
    # Yes, no, or not read: $null is the third answer and must not be shown as either.
    [CmdletBinding()]
    param([AllowNull()]$Value)

    if ($null -eq $Value) { return (Get-Text 'Value.Network.NotRead') }
    if ($Value)           { return (Get-Text 'Value.Network.Yes') }
    Get-Text 'Value.Network.No'
}

function New-AdapterSection {
    <#
    .SYNOPSIS
        The adapters as a table, and under it the settings each answer was reduced from,
        USB selective suspend and the SMB client configuration, as they came.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data)

    $adapters = (Get-DataCollection $Data 'Adapters')
    $notRead  = Get-Text 'Value.Network.NotRead'

    # The headings are German like the rest of the Report, so the rows are built with the
    # names the table is to show.
    $name          = Get-Text 'Column.Network.Adapter.Name'
    $description   = Get-Text 'Column.Network.Adapter.Description'
    $linkSpeed     = Get-Text 'Column.Network.Adapter.LinkSpeed'
    $driverVersion = Get-Text 'Column.Network.Adapter.DriverVersion'
    $driverDate    = Get-Text 'Column.Network.Adapter.DriverDate'
    $usb           = Get-Text 'Column.Network.Adapter.Usb'
    $powerSaving   = Get-Text 'Column.Network.Adapter.PowerSaving'

    New-Section -Title (Get-Text 'Title.Network.Adapters') -Row ($adapters | ForEach-Object {
        $row = [ordered]@{}
        $row[$name]          = Get-DataProperty $_ 'Name'
        $row[$description]   = Get-DataProperty $_ 'Description'
        $row[$linkSpeed]     = Get-DataProperty $_ 'LinkSpeed'
        $row[$driverVersion] = Get-DataProperty $_ 'DriverVersion'
        $row[$driverDate]    = Get-DataProperty $_ 'DriverDate'
        $row[$usb]           = Get-TriStateText (Get-DataProperty $_ 'IsUsb')
        $row[$powerSaving]   = Get-TriStateText (Get-DataProperty $_ 'PowerSavingOn')
        [pscustomobject]$row
    })

    # What is a cmdlet, a WMI class or a registry keyword stays what a Technician types
    # and searches for; what is said about it is German.
    $lines = @()
    foreach ($adapter in $adapters) {
        $lines += (Get-Text 'Value.Network.Section.Adapter') -f (Get-DataProperty $adapter 'Name'), (Get-DataProperty $adapter 'PnpDeviceId')

        $management = Get-DataProperty $adapter 'PowerManagement'
        if ($null -eq $management) { $lines += ' Get-NetAdapterPowerManagement: {0}' -f $notRead }
        else {
            foreach ($name in $script:NetworkPowerManagementProperties) {
                $lines += ' {0} = {1}' -f $name, (Get-DataProperty $management $name)
            }
        }

        $lines += ' MSPower_DeviceEnable = {0}' -f (Get-TriStateText (Get-DataProperty $adapter 'DeviceSleepAllowed'))

        # Every setting, the ones that are not counted as power saving included, with the
        # values the driver says it can take: that list is why a mode is not counted.
        foreach ($setting in (Get-DataCollection $adapter 'EnergySettings')) {
            $line = ' {0} ({1}) = {2} [{3}]' -f (Get-DataProperty $setting 'Keyword'), (Get-DataProperty $setting 'DisplayName'),
                (Get-DataProperty $setting 'Value'), (Get-DataProperty $setting 'RegistryValue')
            $valid = (Get-DataCollection $setting 'ValidRegistryValues')
            if ($valid.Count) { $line += ' ({0})' -f ((Get-Text 'Value.Network.Section.ValidValues') -f ($valid -join ', ')) }
            $lines += $line
        }
        $lines += ''
    }

    $lines += (Get-Text 'Value.Network.Section.UsbSuspend') -f (Get-TriStateText (Get-DataProperty $Data 'UsbSelectiveSuspend'))
    $suspendText = "$(Get-DataProperty $Data 'UsbSelectiveSuspendText')".Trim("`r", "`n")
    if ($suspendText) { $lines += $suspendText }
    $lines += ''

    $smb = Get-DataProperty $Data 'SmbClientConfiguration'
    if ($null -eq $smb) { $lines += 'Get-SmbClientConfiguration: {0}' -f $notRead }
    else {
        $lines += 'Get-SmbClientConfiguration:'
        foreach ($name in $script:NetworkSmbClientProperties) {
            $lines += ' {0} = {1}' -f $name, (Get-DataProperty $smb $name)
        }
    }

    New-Section -Title (Get-Text 'Title.Network.PowerSavingAndSmb') -Text ($lines -join "`r`n")
}

function ConvertFrom-WinHttpProxyText {
    <#
    .SYNOPSIS
        Parses what "netsh winhttp show proxy" printed into direct, proxy server and bypass
        list. Pure, and reaches nothing.
    .DESCRIPTION
        netsh prints its labels in the UI language, so this reads by structure and never by
        label. The settings are the indented lines; the heading and every error message are
        not. A proxy is two "label : value" lines, server first and bypass list second.
        Direct is one indented line with no colon, which in German reads "DirectAccess (kein
        Proxyserver)." - not the "Direkter Zugriff" one might guess - and in English
        "Direct access (no proxy server).", so "direct" is the one token both share.
        Anything else is not recognised, because an error read as direct would be a false
        all-clear.
    #>

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

    $body = @("$Text" -split '\r?\n' |
        Where-Object { $_ -match '^\s{2,}\S' } |
        ForEach-Object { $_.Trim() })

    # " : " with the spaces, because a proxy value carries colons of its own (host:port).
    $values = @($body |
        Where-Object { $_ -match '^[^:]+?\s:\s' } |
        ForEach-Object { ($_ -split '\s:\s', 2)[1].Trim() })

    $server = $null
    $bypass = $null
    $direct = $false
    if ($values.Count -ge 1 -and $values[0]) {
        $server = $values[0]
        # "(keine)" / "(none)": the placeholder for an empty list, recognised by its shape.
        if ($values.Count -ge 2 -and $values[1] -notmatch '^\(.*\)$') { $bypass = $values[1] }
    }
    elseif (@($body | Where-Object { $_ -notmatch ':' -and $_ -match '(?i)direct' }).Count) {
        $direct = $true
    }

    [pscustomobject]@{
        PSTypeName  = 'Gutcheck.WinHttpProxy'
        Recognised  = $direct -or [bool]$server
        Direct      = $direct
        ProxyServer = $server
        BypassList  = $bypass
    }
}

function Get-UserProxyDescription {
    [CmdletBinding()]
    param([AllowNull()]$UserProxy)

    if ($null -eq $UserProxy) { return (Get-Text 'Value.Network.ProxyUnreadable') }

    $parts = @()
    $pac = "$(Get-DataProperty $UserProxy 'AutoConfigURL')".Trim()
    if ($pac) { $parts += (Get-Text 'Value.Network.ProxyScript') -f $pac }

    $server = Get-UserProxyServer $UserProxy
    if ($server) {
        $override = "$(Get-DataProperty $UserProxy 'ProxyOverride')".Trim()
        if ($override) { $parts += (Get-Text 'Value.Network.ProxyServerWithBypass') -f $server, $override }
        else           { $parts += (Get-Text 'Value.Network.ProxyServer') -f $server }
    }

    if (-not $parts.Count) { return (Get-Text 'Value.Network.ProxyDirect') }
    $parts -join '; '
}

function Get-UserProxyServer {
    # The server only while it is switched on: Internet Options keeps the last one typed in
    # after the box is unticked, and that one is used by nothing.
    [CmdletBinding()]
    param([AllowNull()]$UserProxy)

    if ((ConvertTo-Number (Get-DataProperty $UserProxy 'ProxyEnable')) -ne 1) { return $null }
    $server = "$(Get-DataProperty $UserProxy 'ProxyServer')".Trim()
    if ($server) { return $server }
    $null
}

function Get-WinHttpProxyDescription {
    [CmdletBinding()]
    param([AllowNull()]$Parsed)

    if ($null -eq $Parsed)       { return (Get-Text 'Value.Network.ProxyViewAbsent') }
    if (-not $Parsed.Recognised) { return (Get-Text 'Value.Network.ProxyUnreadable') }
    if ($Parsed.Direct)          { return (Get-Text 'Value.Network.ProxyDirect') }
    if ($Parsed.BypassList)      { return (Get-Text 'Value.Network.ProxyServerWithBypass') -f $Parsed.ProxyServer, $Parsed.BypassList }
    (Get-Text 'Value.Network.ProxyServer') -f $Parsed.ProxyServer
}

function New-ProxyFinding {
    <#
    .SYNOPSIS
        Judges the user's proxy (WinINET) against the system proxy (WinHTTP) Office uses
        for its background requests. See docs/research/outlook-connectivity-checks.md, 6.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $user = Get-DataProperty $Data 'UserProxy'

    # The 32-bit view is $null only on a Windows without SysWOW64; the 64-bit one always
    # exists, so its absence is data gathered before the proxy was, and reads unreadable.
    $views = @(
        [pscustomobject]@{ Check = (Get-Text 'Check.Network.WinHttpProxy64'); Text = (Get-DataProperty $Data 'WinHttpProxy64Text'); Optional = $false }
        [pscustomobject]@{ Check = (Get-Text 'Check.Network.WinHttpProxy32'); Text = (Get-DataProperty $Data 'WinHttpProxy32Text'); Optional = $true }
    )
    foreach ($view in $views) {
        $parsed = $null
        if ($null -ne $view.Text -or -not $view.Optional) { $parsed = ConvertFrom-WinHttpProxyText -Text $view.Text }
        $view | Add-Member -NotePropertyName Parsed -NotePropertyValue $parsed
    }

    # The effective configuration, always: a Technician asked about a proxy needs the
    # answer even when nothing is wrong with it.
    $hint = ''
    if ($null -eq $user) { $hint = (Get-Text 'Hint.Network.UserProxyUnreadable') }
    elseif (Get-DataProperty $Data 'Elevated') { $hint = (Get-Text 'Hint.Network.ProxyReadInElevatedSession') }
    New-Finding -Category Network -Check (Get-Text 'Check.Network.Proxy') -Severity INFO `
        -Value ((Get-Text 'Value.Network.ProxySummary') -f (Get-UserProxyDescription $user),
                (Get-WinHttpProxyDescription $views[0].Parsed), (Get-WinHttpProxyDescription $views[1].Parsed)) `
        -Hint $hint

    # KB 2332495: WinHTTP cannot load a PAC file from a file:// URL, so Outlook cannot either.
    $pac = "$(Get-DataProperty $user 'AutoConfigURL')".Trim()
    if ($pac -match '^(?i)file:') {
        New-Finding -Category Network -Check (Get-Text 'Check.Network.ProxyScript') -Severity FAIL `
            -Value $pac -Hint (Get-Text 'Hint.Network.ProxyScriptFromFile')
    }

    foreach ($view in $views) {
        if ($null -eq $view.Parsed) { continue }
        if (-not $view.Parsed.Recognised) {
            New-UnavailableFinding -Category Network -Check $view.Check -Hint (Get-Text 'Hint.Network.WinHttpProxyUnreadable')
            continue
        }

        # Nothing to compare against when the user's settings could not be read; the
        # summary above already says so.
        if ($null -eq $user -or $view.Parsed.Direct) { continue }

        # KB 2847833: a WinHTTP proxy that is not the user's breaks free/busy, out-of-office
        # and MailTips. Direct WinHTTP is not warned about, whatever the user has: the
        # research grades it OK beside a PAC, and it is exactly what that KB's own fix,
        # "netsh winhttp reset proxy", leaves behind beside a fixed proxy.
        $userServer = Get-UserProxyServer $user
        if ($userServer -and $userServer -eq $view.Parsed.ProxyServer.Trim()) { continue }

        New-Finding -Category Network -Check $view.Check -Severity WARN `
            -Value ((Get-Text 'Value.Network.WinHttpDiffers') -f $view.Parsed.ProxyServer, (Get-UserProxyDescription $user)) `
            -Hint (Get-Text 'Hint.Network.WinHttpProxyDiffers')
    }
}

function New-AdapterFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    # Which descriptions name a tunnel rather than a wire. A parameter because the estate
    # a Customer runs decides it, and because a VPN nobody knew was up explains a great
    # deal about an application that only feels slow at one site.
    $vpnPattern = Get-Parameter $Parameters 'VpnAdapterPattern' `
        'VPN|Fortinet|AnyConnect|GlobalProtect|Wintun|TAP-|WireGuard|Juniper|Check Point|Sophos'

    $adapters = (Get-DataCollection $Data 'Adapters')
    if (-not $adapters.Count) {
        # The script said nothing here, which reads in the Report exactly like a machine
        # that was never asked. A machine with every adapter down explains itself.
        return New-Finding -Category Network -Check (Get-Text 'Check.Network.NetworkAdapters') -Severity WARN `
            -Value (Get-Text 'Value.Network.NoneUp') `
            -Hint (Get-Text 'Hint.Network.NoNetworkAdapterIsUp')
    }

    foreach ($adapter in $adapters) {
        $hint = ''
        if ("$($adapter.Description)" -match $vpnPattern) {
            $hint = (Get-Text 'Hint.Network.VpnAdapterActive')
        }
        $value = '{0} @ {1}' -f $adapter.Description, $adapter.LinkSpeed
        if (Get-DataProperty $adapter 'IsUsb') {
            $value = (Get-Text 'Value.Network.AdapterUsb') -f $adapter.Description, $adapter.LinkSpeed
        }
        New-Finding -Category Network -Check ((Get-Text 'Check.Network.Adapter') -f $adapter.Name) -Severity INFO `
            -Value $value -Hint $hint
    }
}

function New-AdapterSettingFinding {
    <#
    .SYNOPSIS
        Names the settings this Run could not read, so that their absence from the Report
        is not taken for a machine without power saving.
    .DESCRIPTION
        Says nothing else. Whether a USB adapter with power saving matters depends on
        whether a program runs from a share, and this Check is performed before the one
        that finds that out; the NetworkDependency Kind weighs the two together.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $unread = (Get-DataCollection $Data 'Unread')
    if (-not $unread.Count) { return }

    New-Finding -Category Network -Check (Get-Text 'Check.Network.AdapterSettings') -Severity INFO `
        -Value ((Get-Text 'Value.Network.SettingsUnread') -f ($unread -join ', ')) `
        -Hint (Get-Text 'Hint.Network.SettingsUnread')
}

function New-WifiSignalFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warnBelow = Get-Parameter $Parameters 'WifiSignalWarnBelowPercent' 70
    $failBelow = Get-Parameter $Parameters 'WifiSignalFailBelowPercent' 50

    $signal = ConvertTo-Number (Get-DataProperty $Data 'WifiSignalPercent')

    # Nothing at all when there is no reading, which is the one place Gutcheck stays
    # silent on purpose. A machine with no wireless adapter and a machine whose wireless
    # output did not parse are indistinguishable here, and inventing a Finding for either
    # would be inventing a fact. See #1, Out of Scope.
    if ($null -eq $signal) { return }

    New-Finding -Category Network -Check (Get-Text 'Check.Network.WiFiSignal') `
        -Severity (Get-SeverityBelow $signal $warnBelow $failBelow) -Value ('{0:N0} %' -f $signal) `
        -Hint (Get-Text 'Hint.Network.WeakWiFiSlowSync')
}

function New-GatewayLatencyFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $lossWarn = Get-Parameter $Parameters 'GatewayLossWarnPercent'  0
    $lossFail = Get-Parameter $Parameters 'GatewayLossFailPercent'  5
    $msWarn   = Get-Parameter $Parameters 'GatewayLatencyWarnMs'   10
    $msFail   = Get-Parameter $Parameters 'GatewayLatencyFailMs'   50

    $address = Get-DataProperty $Data 'GatewayAddress'
    $latency = Get-DataProperty $Data 'GatewayLatency'

    if (-not $address -or -not $latency) {
        return New-Finding -Category Network -Check (Get-Text 'Check.Network.DefaultGateway') -Severity WARN -Value (Get-Text 'Value.Shared.None') `
            -Hint (Get-Text 'Hint.Network.TheMachineHasNoDefault')
    }

    $loss = ConvertTo-Number (Get-DataProperty $latency 'LossPercent')
    $avg  = ConvertTo-Number (Get-DataProperty $latency 'AverageMs')
    $max  = ConvertTo-Number (Get-DataProperty $latency 'MaximumMs')

    if ($null -eq $avg) {
        # Every ping lost. On a gateway that is not a blocked ping, it is a broken link.
        return New-Finding -Category Network -Check (Get-Text 'Check.Network.GatewayLatency') -Severity FAIL `
            -Value ((Get-Text 'Value.Network.NoReplyWithLoss') -f $address, $loss) `
            -Hint (Get-Text 'Hint.Network.TheDefaultGatewayDidNot')
    }

    # Loss and latency are independent: whichever reads worse decides.
    $severity = Get-WorstSeverity (Get-Severity $loss $lossWarn $lossFail) (Get-Severity $avg $msWarn $msFail)

    New-Finding -Category Network -Check (Get-Text 'Check.Network.GatewayLatency') -Severity $severity `
        -Value ((Get-Text 'Value.Network.PingResult') -f $address, $avg, $max, $loss) `
        -Hint (Get-Text 'Hint.Network.UnstableLocalNetworkWiFi')
}

function New-InternetLatencyFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $target  = Get-DataProperty $Data 'InternetTarget'
    $latency = Get-DataProperty $Data 'InternetLatency'
    if (-not $latency) { return }

    $loss = ConvertTo-Number (Get-DataProperty $latency 'LossPercent')
    $avg  = ConvertTo-Number (Get-DataProperty $latency 'AverageMs')

    # Never graded. Plenty of Customer networks block ICMP to the internet entirely, and a
    # FAIL there would be a Finding about a firewall policy rather than about the machine.
    $value = $(if ($null -eq $avg) { "no reply (loss $loss %)" } else { (Get-Text 'Value.Network.LatencyWithLoss') -f $avg, $loss })
    New-Finding -Category Network -Check ((Get-Text 'Check.Network.InternetLatency') -f $target) -Severity INFO -Value $value
}

function New-DnsResolutionFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warn = Get-Parameter $Parameters 'DnsResolutionWarnMs' 500
    $fail = Get-Parameter $Parameters 'DnsResolutionFailMs' ([double]::MaxValue)

    $target = Get-DataProperty $Data 'DnsTarget'
    $check  = Get-Text 'Check.Network.DnsResolution' $target

    if (-not (Get-DataProperty $Data 'DnsResolved')) {
        return New-Finding -Category Network -Check $check -Severity FAIL -Value (Get-Text 'Value.Network.Failed') `
            -Hint (Get-Text 'Hint.Network.NoInternetNameResolution')
    }

    $ms = ConvertTo-Number (Get-DataProperty $Data 'DnsMilliseconds')
    if ($null -eq $ms) {
        return New-UnavailableFinding -Category Network -Check $check `
            -Hint (Get-Text 'Hint.Shared.NameResolvedNotTimed')
    }

    New-Finding -Category Network -Check $check -Severity (Get-Severity $ms $warn $fail) `
        -Value ('{0:N0} ms' -f $ms) `
        -Hint (Get-Text 'Hint.Network.SlowDNSDelaysEveryConnection')
}

function New-MappedDriveFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $mappings = (Get-DataCollection $Data 'SmbMappings')

    if (-not $mappings.Count) {
        # In the Technician's own session no mappings means no mappings, which is not
        # worth a line. Elevated it means something else entirely, and staying quiet would
        # let a Technician read an empty list as a machine with no network drives.
        if (Get-DataProperty $Data 'Elevated') {
            return New-Finding -Category Network -Check (Get-Text 'Check.Network.MappedDrives') -Severity INFO -Value (Get-Text 'Value.Network.NoneVisible') `
                -Hint (Get-Text 'Hint.Network.ElevatedSessionsDoNotSee')
        }
        return
    }

    foreach ($mapping in $mappings) {
        $connect  = ConvertTo-Number $mapping.ConnectMs
        $statusOk = "$($mapping.Status)" -eq 'OK'

        $severity = 'OK'
        if (-not $statusOk -or $null -eq $connect) { $severity = 'WARN' }

        New-Finding -Category Network -Check ((Get-Text 'Check.Network.MappedDrive') -f $mapping.LocalPath) -Severity $severity `
            -Value ((Get-Text 'Value.Network.MappingStatus') -f $mapping.RemotePath, $mapping.Status,
                    $(if ($null -eq $connect) { (Get-Text 'Value.Network.Failed') } else { '{0} ms' -f $connect })) `
            -Hint (Get-Text 'Hint.Network.AppsStartedFromANetwork')
    }
}