Private/ServerIdentity.ps1

# The identity of a Server.
#
# Which Server a name or an address is, decided once for every Check and view (#145): the
# Kinds when they name and resolve what they looked at, the Finding when it checks its
# Subjects, the Leads and References when they compare Subjects, the Report when it merges
# its nodes. A rule of its own in any of them is a Server drawn twice, or two drawn as one.
#
# Pure functions, all of them: nothing here resolves a name or asks Windows anything. What
# a name resolved to is handed in by the Gatherer that resolved it.

# The addresses that are never on the internet: RFC 1918, the shared space of RFC 6598,
# link-local, loopback, and for IPv6 unique local, link-local, loopback and the site-local
# addresses Windows puts in as DNS servers where nobody gave IPv6 any (fec0:0:0:ffff::1 to 3,
# of a range long withdrawn, and never routed on the internet). The IPv4 ones
# as their bytes: written as addresses, they are what the package check refuses to find in
# the code - an address out of somebody's own network.
#
# The one list: the Zone of a Measurement is decided from it (Get-MeasurementZone), and so
# is whether a Server is in the internet. The ranges kept for documentation and benchmarks
# are not in it, and count as public in both.
$script:PrivateAddressRanges = @(
    @([byte[]](10, 0, 0, 0), 8), @([byte[]](172, 16, 0, 0), 12), @([byte[]](192, 168, 0, 0), 16),
    @([byte[]](100, 64, 0, 0), 10), @([byte[]](169, 254, 0, 0), 16), @([byte[]](127, 0, 0, 0), 8),
    @('fc00::', 7), @('fe80::', 10), @('fec0::', 10), @('::1', 128)
)

function ConvertTo-ServerName {
    <#
    .SYNOPSIS
        A Server's name as every Check and view names it: trimmed, the host of a UNC path,
        without a port, without a trailing dot, in lower case. Pure.
    .DESCRIPTION
        An address stays the address it is, an IPv6 one with all its colons: only brackets
        around it and a port behind them go, and its hexadecimal digits are written in lower
        case. A port is only ever taken from a name or an IPv4 address, where one colon
        stands before digits. Nothing for nothing.
    .PARAMETER Name
        The name or address as a Check met it: typed, logged, mapped, resolved.
    #>

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

    $text = "$Name".Trim()
    # \\fs01\daten names the Server fs01.
    $unc = Split-UncPath -Path $text
    if ($unc) { $text = $unc.Host }
    # [2001:db8::10]:443 before anything else: .NET reads it as an address, port and all.
    if ($text -match '^\[(?<host>[^\]]+)\](:\d+)?$') { $text = $Matches.host.Trim() }
    if (Test-NetworkAddress -Text $text) { return $text.ToLowerInvariant() }

    if ($text -match '^(?<host>[^:]+):\d+$') { $text = $Matches.host }
    $text.Trim().TrimEnd('.').ToLowerInvariant()
}

function Split-UncPath {
    <#
    .SYNOPSIS
        The host, the share and the path below it that a UNC path names, each as it is
        written; nothing for what is no UNC path. Pure.
    .DESCRIPTION
        \\fs01\daten\Projekte, and //fs01/daten/Projekte as some logs write it: the host is
        the first part, the share the second, the rest the path below, joined by
        backslashes. A part that is not there is empty: \\fs01 is a host without a share.
        Which Server the host is, is ConvertTo-ServerName's to say; a WebDAV path keeps its
        transport in the host, \\fs01@SSL, for the caller that reads it.
    .PARAMETER Path
        The path as a Check met it.
    #>

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

    $text = "$Path".Trim()
    if ($text -notmatch '^[\\/]{2}') { return $null }
    $parts = @($text.TrimStart('\', '/') -split '[\\/]+')
    [pscustomobject]@{
        Host  = $parts[0]
        Share = $(if ($parts.Count -gt 1) { $parts[1] } else { '' })
        Path  = (@($parts | Select-Object -Skip 2 | Where-Object { $_ }) -join '\')
    }
}

function Test-SameServer {
    <#
    .SYNOPSIS
        Whether two names are one Server. Pure.
    .DESCRIPTION
        Each is first normalised by ConvertTo-ServerName. They are one Server where they are
        then the same, or where one is a short name and the other a FQDN whose first label
        it is: FS01 and fs01.contoso.example. Two different FQDNs are never one, though they
        share a first label: fs01.a.example and fs01.b.example are two machines in two
        domains. An address is only itself; which name it has is not for this to know.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()][AllowEmptyString()][string]$Name, [AllowNull()][AllowEmptyString()][string]$Other)

    $one = ConvertTo-ServerName -Name $Name
    $two = ConvertTo-ServerName -Name $Other
    if (-not $one -or -not $two) { return $false }
    if ($one -eq $two) { return $true }
    if ((Test-NetworkAddress -Text $one) -or (Test-NetworkAddress -Text $two)) { return $false }
    if ($one.Contains('.') -eq $two.Contains('.')) { return $false }

    $short = $(if ($one.Contains('.')) { $two } else { $one })
    $full  = $(if ($one.Contains('.')) { $one } else { $two })
    ($full -split '\.')[0] -eq $short
}

function Get-ServerKey {
    <#
    .SYNOPSIS
        The key a Kind groups a Server by, among the names it met: the name as
        ConvertTo-ServerName writes it, and for a short name the one FQDN among the others
        that it begins. Pure.
    .DESCRIPTION
        By the rule of Test-SameServer, so that a Kind never hands a Finding two Subjects
        that are one Server: FS01 among fs01.contoso.example is fs01.contoso.example. A
        short name that begins none of them, or two - fs01 among fs01.a.example and
        fs01.b.example - stays itself, and so do an FQDN and an address: the relation does
        not chain, and two FQDNs are never joined through a third name.
    .PARAMETER Name
        The name or address to key.
    .PARAMETER Among
        Every name the Kind met, in any spelling, this one among them or not.
    #>

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

    $key = ConvertTo-ServerName -Name $Name
    if (-not $key -or $key.Contains('.') -or (Test-NetworkAddress -Text $key)) { return $key }
    # Only what contains the name can be one Server with it; the rest is not asked. A Run
    # keys every Finding's Subject among all the others. Of a short name, the one Server
    # that is not the name itself is a FQDN it begins.
    $full = @(@($Among) | Where-Object { $_ -and $_.IndexOf($key, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 } |
        ForEach-Object { ConvertTo-ServerName -Name $_ } |
        Where-Object { $_ -and $_ -ne $key -and (Test-SameServer -Name $key -Other $_) } |
        Select-Object -Unique)
    if ($full.Count -eq 1) { return $full[0] }
    $key
}

# The Service a Finding's Subject may name beside its Server (#166, ADR-0015): written as the
# Network Map keys a Service (Get-NetworkMapServiceKey), "fs01.contoso.example:445" or an
# absolute URL. Any other Subject - a Server, a program, a drive - names no Service.

function Split-ServiceSubject {
    <#
    .SYNOPSIS
        The Server and the port, or the URL, a Subject names a Service by; nothing for a
        Subject that names no Service. Pure.
    .DESCRIPTION
        A URL is an absolute one with a host, "https://cloud.contoso.example/", as it is
        written: Url, and no Server or port. A port is the digits behind the one colon of a
        name or an IPv4 address, or behind the brackets of an IPv6 one, from 1 to 65535:
        Server as written, Port as a number, and no Url. "Z:", a UNC path and an address
        alone name none.
    #>

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

    $text = "$Name".Trim()
    if (-not $text) { return $null }
    if (Test-ServiceUrl -Text $text) { return [pscustomobject]@{ Server = ''; Port = $null; Url = $text } }
    if ($text -notmatch '^\[(?<host>[^\]]+)\]:(?<port>\d{1,5})$' -and $text -notmatch '^(?<host>[^:\\/\[\]]+):(?<port>\d{1,5})$') { return $null }
    $port = [int]$Matches.port
    if ($port -lt 1 -or $port -gt 65535) { return $null }
    [pscustomobject]@{ Server = $Matches.host.Trim(); Port = $port; Url = '' }
}

function Test-ServiceUrl {
    <#
    .SYNOPSIS
        Whether a text is a URL a Service is named by: absolute, written scheme://, with a
        host. Pure.
    .DESCRIPTION
        The one reading, for a Subject (Split-ServiceSubject) and for a record on the Map
        (Get-NetworkMapServiceKey): a share path would pass .NET for a file URL, and is a
        record of the SMB Service it names a port of.
    #>

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

    $uri = $null
    [bool]($Text -match '^[a-z][a-z0-9+.-]*://' -and [uri]::TryCreate($Text, [UriKind]::Absolute, [ref]$uri) -and $uri.Host)
}

function Format-ServiceSubject {
    <#
    .SYNOPSIS
        A Service on a port as a Finding's Subject names it: the Server as written and the
        port, an IPv6 address in brackets, so that Split-ServiceSubject reads it back. Nothing
        for no Server. Pure. ADR-0015.
    .DESCRIPTION
        The one way every Kind writes a Service Subject. Which Server it is, is for the key
        to say (Get-SubjectKey); the Network Map writes its own key without brackets.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Server, [Parameter(Mandatory)][int]$Port)

    $name = "$Server".Trim()
    if (-not $name) { return '' }
    if ($name.Contains(':') -and -not $name.StartsWith('[')) { $name = '[{0}]' -f $name }
    '{0}:{1}' -f $name, $Port
}

function ConvertTo-UrlService {
    <#
    .SYNOPSIS
        The Service on a port a URL is on: its host as every Check names a Server, its port -
        the scheme's own where the URL names none - and the Subject both make, with the URL
        as it was given. Nothing for what is no URL (Test-ServiceUrl), or one of a scheme
        without a port. Pure. #173.
    .DESCRIPTION
        A log names the full URL of a request that failed, https://mail.contoso.example/OAB/...
        What failed is the Service the Server Check and the Links already draw,
        mail.contoso.example:443, and not a URL Service of its own: every path on a host would
        be one. The URL itself belongs in the words of what is said of it.
    #>

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

    $text = "$Url".Trim()
    if (-not (Test-ServiceUrl -Text $text)) { return $null }
    $uri = [uri]$text
    if ($uri.Port -lt 1) { return $null }
    New-PortService -Server (ConvertTo-ServerName -Name $uri.Host) -Port $uri.Port -Url $text
}

function New-PortService {
    <#
    .SYNOPSIS
        A Service on a port as ConvertTo-UrlService gives one: Server, Port, the Subject
        both make, and the URL it was read from, if any. A Server without a port is the
        Server alone, its Subject its name. Nothing for no Server. Pure.
    .DESCRIPTION
        For what names a host and maybe a port without a URL, "Host cloud.contoso.example
        not found": it is about the same Service, and comes in the same shape.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyString()][string]$Server, [AllowNull()]$Port, [AllowNull()][AllowEmptyString()][string]$Url)

    $name = "$Server".Trim()
    if (-not $name) { return $null }
    $number = ConvertTo-Number $Port
    if ($null -eq $number -or $number -lt 1 -or $number -gt 65535 -or $number -ne [math]::Floor($number)) { $number = $null } else { $number = [int]$number }
    $subject = $name
    if ($null -ne $number) { $subject = Format-ServiceSubject -Server $name -Port $number }
    [pscustomobject]@{ Server = $name; Port = $number; Subject = $subject; Url = "$Url".Trim() }
}

# The Namespace a Finding's Subject may name (#172): written as the Network Map keys a
# Namespace (New-LinkEnd -Thing Namespace), by its root as a UNC path,
# "\\contoso.example\public". A part of its own, never the Server the root is on: no Kind
# names a Server by a UNC path in a Finding, and a Namespace is judged by its folders.

function Split-NamespaceSubject {
    <#
    .SYNOPSIS
        The Server, the share and the key a Subject names a Namespace by; nothing for a
        Subject that names none. Pure.
    .DESCRIPTION
        A UNC path of a host and a share and nothing below them, \\contoso.example\public,
        or //contoso.example/public: Server as ConvertTo-ServerName writes the host, Share
        in lower case, and Key as the Network Map keys the Namespace, \\server\share. A host
        alone, \\fs01, and a path below a share name none: the one is its Server, the
        other no root.
    #>

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

    $unc = Split-UncPath -Path $Name
    if (-not $unc -or $unc.Path) { return $null }
    $server = ConvertTo-ServerName -Name $unc.Host
    $share = $unc.Share.Trim().ToLowerInvariant()
    if (-not $server -or -not $share) { return $null }
    [pscustomobject]@{ Server = $server; Share = $share; Key = ('\\{0}\{1}' -f $server, $share) }
}

function Split-Subject {
    <#
    .SYNOPSIS
        What kind of thing a Finding's Subject names - a Namespace, a Service or a Server -
        and the pieces it names it by; nothing for no Subject. Pure.
    .DESCRIPTION
        The one place the question is asked, for every Check and view that tells the three
        apart: a rule of its own anywhere would take a Namespace for its host's Server again.
        A Namespace first, as Split-NamespaceSubject reads it: Server, Share and Key. Then a
        Service, as Split-ServiceSubject reads it: Server and Port, or Url. Anything else is
        Kind Server, its name as written: a Server, or a program or a drive, which is keyed
        as a Server would be (Get-ServerKey). Each object carries every piece, empty where
        its Kind has none.
    #>

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

    $text = "$Name".Trim()
    if (-not $text) { return $null }
    $namespace = Split-NamespaceSubject -Name $text
    if ($namespace) { return [pscustomobject]@{ Kind = 'Namespace'; Server = $namespace.Server; Share = $namespace.Share; Key = $namespace.Key; Port = $null; Url = '' } }
    $service = Split-ServiceSubject -Name $text
    if ($service) { return [pscustomobject]@{ Kind = 'Service'; Server = $service.Server; Share = ''; Key = ''; Port = $service.Port; Url = $service.Url } }
    [pscustomobject]@{ Kind = 'Server'; Server = $text; Share = ''; Key = ''; Port = $null; Url = '' }
}

function Get-SubjectKey {
    <#
    .SYNOPSIS
        The key a Finding's Subject is one with another by: a Server's as Get-ServerKey
        gives it, a Service's as the Network Map writes it. Pure.
    .DESCRIPTION
        A URL keys to itself. A Service on a port keys its Server by Get-ServerKey among
        -Among and keeps the port: FS01:445 among fs01.contoso.example is
        fs01.contoso.example:445. A Namespace keys as the Network Map keys it,
        \\contoso.example\public. Any other Subject is keyed exactly as Get-ServerKey keys
        it, so a Server Subject keys as it always has.
    .PARAMETER Name
        The Subject to key.
    .PARAMETER Among
        Every name the Kind or the Run met, Services among them: a Service's Server counts
        as the Server it is on.
    #>

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

    $subject = Split-Subject -Name $Name
    if (-not $subject -or $subject.Kind -eq 'Server') { return (Get-ServerKey -Name $Name -Among $Among) }
    if ($subject.Kind -eq 'Namespace') { return $subject.Key }
    if ($subject.Url) { return $subject.Url }
    Get-NetworkMapServiceKey -ServerKey (Get-ServerKey -Name $subject.Server -Among $Among) -Port $subject.Port
}

function Test-SameSubject {
    <#
    .SYNOPSIS
        Whether two Subjects of a Finding are one: one Server, one Service, or one
        Namespace. Pure.
    .DESCRIPTION
        Two Servers by Test-SameServer. Two Services on a port where the port is the same
        and their Servers are one by Test-SameServer; two URLs where they are the same,
        ignoring case. Two Namespaces where the share is the same and the Servers are one.
        A Server and a Service on it are two, and so are a URL and a port on its host, and
        a Namespace and the Server its root is on: each is a Subject of its own.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()][AllowEmptyString()][string]$Name, [AllowNull()][AllowEmptyString()][string]$Other)

    $one = Split-Subject -Name $Name
    $two = Split-Subject -Name $Other
    if (-not $one -or -not $two -or $one.Kind -ne $two.Kind) { return $false }
    switch ($one.Kind) {
        'Namespace' { return ($one.Share -eq $two.Share -and (Test-SameServer -Name $one.Server -Other $two.Server)) }
        'Server'    { return (Test-SameServer -Name $Name -Other $Other) }
    }
    if ($one.Url -or $two.Url) { return [bool]($one.Url -and $two.Url -and $one.Url -eq $two.Url) }
    $one.Port -eq $two.Port -and (Test-SameServer -Name $one.Server -Other $two.Server)
}

function Remove-AddressScope {
    <#
    .SYNOPSIS
        An address without the zone of a link-local one: fe80::1%4 is fe80::1. Pure.
    .DESCRIPTION
        The zone is this machine's own name for one of its adapters: the same Server
        reached through the dock and through Wi-Fi would otherwise be two. A name stays
        as it is - a percent sign behind anything but an address is no zone.
    #>

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

    $value = "$Text".Trim()
    if ($value.Contains('%') -and (Test-NetworkAddress -Text $value)) { return ($value -split '%')[0] }
    $value
}

function Select-ServerAddress {
    <#
    .SYNOPSIS
        The one address a Server is shown at, from what its name resolved to. Pure.
    .DESCRIPTION
        IPv4 before IPv6, because it is the address the Customer's network uses and the one
        a Technician pings; within a family the first in the order resolution returned them,
        never the first as text, which would put 192.0.2.10 before 192.0.2.9. Nothing where
        nothing resolved. The other addresses are the caller's to keep, where it says them.
    .PARAMETER Address
        What the name resolved to, in the order it came: addresses as text or as .NET gives
        them, or records that carry theirs as IPAddress. What is no address is passed over.
    .PARAMETER All
        Every address of the family the one address is of, in the order they came, once
        each: a name several Servers answer to - a domain's - leads to each of them.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()]$Address, [switch]$All)

    $first = ''
    $read = [System.Collections.Generic.List[psobject]]::new()
    foreach ($answer in @($Address)) {
        if ($null -eq $answer) { continue }
        $text = $(if ($answer -is [string] -or $answer -is [System.Net.IPAddress]) { "$answer" } else { "$(Get-DataProperty $answer 'IPAddress')" })
        $text = $text.Trim()
        $parsed = ConvertTo-NetworkAddress -Address $text
        if ($null -eq $parsed) { continue }
        $isV4 = $parsed.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork
        if ($isV4 -and -not $All) { return $text }
        if (-not $first) { $first = $text }
        $read.Add([pscustomobject]@{ Text = $text; IsV4 = $isV4 })
    }
    if (-not $All) { return $first }

    $v4 = @($read | Where-Object IsV4)
    @($(if ($v4.Count) { $v4 } else { $read }) | ForEach-Object { $_.Text } | Select-Object -Unique)
}

function Test-NetworkAddress {
    <#
    .SYNOPSIS
        Whether a text is an address and not a name: IPv4 as four numbers, or IPv6, with or
        without the zone of a link-local one. Pure.
    .DESCRIPTION
        By the one reading of an address (ConvertTo-NetworkAddress): a lone number is
        none, and nor is a name with a port.
    #>

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

    $null -ne (ConvertTo-NetworkAddress -Address $Text)
}

function Test-PrivateAddress {
    <#
    .SYNOPSIS
        Whether an address is one that is never on the internet, by the one list of
        private ranges the Zone of a Measurement is decided from. Pure.
    .DESCRIPTION
        False for a name and for nothing: only an address can be private.
    .PARAMETER Address
        The address as text, or as .NET gives it.
    #>

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

    $parsed = $(if ($Address -is [System.Net.IPAddress]) { $Address } else { ConvertTo-NetworkAddress -Address "$Address" })
    if ($null -eq $parsed) { return $false }
    foreach ($range in $script:PrivateAddressRanges) {
        if (Test-AddressInPrefix -Address $parsed -Network $range[0] -PrefixLength $range[1]) { return $true }
    }
    $false
}

# The one reading of an address, for every Check and view: the Zone of a Measurement, the
# ends of a Link and the Network Map read an address here, and nowhere else.

function ConvertTo-NetworkAddress {
    # An address as .NET reads it, without the zone of a link-local one; $null for a name,
    # for nothing, and for a number that is no dotted address. Pure.
    [CmdletBinding()]
    [OutputType([System.Net.IPAddress])]
    param([AllowNull()][AllowEmptyString()][string]$Address)

    $text = ("$Address".Trim() -split '%')[0]
    if (-not $text) { return $null }
    # "1" parses as 0.0.0.1, which nobody measured.
    if ($text -notmatch ':' -and $text -notmatch '^\d{1,3}(\.\d{1,3}){3}$') { return $null }
    $parsed = $null
    if ([System.Net.IPAddress]::TryParse($text, [ref]$parsed)) { return $parsed }
    $null
}

function ConvertTo-ServerAddress {
    # An address as the Map writes it - as .NET writes it, in lower case, without the zone
    # of a link-local one - or nothing for what is no address. Pure.
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Address)

    $parsed = ConvertTo-NetworkAddress -Address $Address
    if ($null -eq $parsed) { return '' }
    $parsed.ToString().ToLowerInvariant()
}

function Test-AddressInPrefix {
    # Whether an address lies in a network given by an address - as text, as .NET gives it,
    # or as its bytes - and a prefix length. Pure.
    [CmdletBinding()]
    [OutputType([bool])]
    param([Parameter(Mandatory)][System.Net.IPAddress]$Address, [AllowNull()]$Network, [AllowNull()]$PrefixLength)

    $net = $Network
    if ($net -is [byte[]]) { $net = [System.Net.IPAddress]::new($net) }
    elseif ($net -isnot [System.Net.IPAddress]) { $net = ConvertTo-NetworkAddress -Address "$Network" }
    $length = ConvertTo-Number $PrefixLength
    if ($null -eq $net -or $null -eq $length -or $net.AddressFamily -ne $Address.AddressFamily) { return $false }

    $a = $Address.GetAddressBytes()
    $b = $net.GetAddressBytes()
    $bits = [int][math]::Min($length, $a.Length * 8)
    for ($i = 0; $i -lt $bits; $i++) {
        $mask = 0x80 -shr ($i % 8)
        if (($a[[int][math]::Floor($i / 8)] -band $mask) -ne ($b[[int][math]::Floor($i / 8)] -band $mask)) { return $false }
    }
    $true
}

function Get-SubnetAddress {
    # The network an address lies in under a prefix length: the address with every bit past
    # the prefix cleared. 192.0.2.17 and 24 are 192.0.2.0. Pure.
    [CmdletBinding()]
    [OutputType([System.Net.IPAddress])]
    param([Parameter(Mandatory)][System.Net.IPAddress]$Address, [Parameter(Mandatory)][int]$PrefixLength)

    $bytes = $Address.GetAddressBytes()
    for ($i = 0; $i -lt $bytes.Length; $i++) {
        $keep = [math]::Max(0, [math]::Min(8, $PrefixLength - $i * 8))
        $bytes[$i] = $bytes[$i] -band ((0xFF -shl (8 - $keep)) -band 0xFF)
    }
    [System.Net.IPAddress]::new($bytes)
}