Private/Kinds/NetworkDependencyReference.ps1

# What on this machine still points at a Server that does not answer: a part of the
# NetworkDependency Kind, and no Kind.
#
# Windows logs that something asked for a Server whose name does not resolve - 339 times
# in a month, on one machine, only in working hours and at no regular interval. That is
# somebody working: every time the Explorer shows its quick access, a program opens its
# list of recent files or a dialog shows a remembered place, Windows tries the Server
# again and waits for it to fail. The Finding about failed connections said "look for
# what still points at it", and a Technician had nowhere to start. Observed 2026-10-14:
# no drive, no printer, no autostart entry and no scheduled task named that Server; what
# did was in none of the places a Run looked in.
#
# So this looks, for the Servers Windows logged as failed and for no others, in the
# places of the User of the Run where such a pointer lives:
#
# quick-access the Explorer's quick access: its pinned and its frequent folders
# jump-list the lists of recent files that programs keep in the taskbar
# recent the shortcuts Windows writes for every file that was opened
# shortcut shortcuts on the desktop, in the links and in the start menu
# place network places, and the shares the Explorer remembers visiting
# office paths in the settings of Office: templates, default folders, recent files
# typed addresses typed into the Explorer and into the Run dialog
# drive drives that are to be reconnected at sign-in
# printer printer connections of the User
#
# Kept is the Server, the place and the share - "\\Server\Share" - and never the path
# below it or the name of a file: which documents somebody opened is what that person
# did, and a Run reads the state of a machine (ADR-0006). The network is the exception
# that ADR makes, and a share is the network.
#
# Only for the User of the Run: these places are in that User's profile and registry.

# How large a file may be to be searched, and how many files of one place.
$script:ReferenceMaxFileBytes = 4MB
$script:ReferenceMaxFiles     = 1500
# How many values of the registry are read under one key at most.
$script:ReferenceMaxValues    = 40000
# How many Servers are looked for: the ones most often asked for in vain.
$script:ReferenceMaxServers   = 8

function Get-ReferencePattern {
    <#
    .SYNOPSIS
        What a pointer at one of several Servers looks like in a text: "\\Server\Share",
        the Server with or without its domain, in any case. One pattern for all of them,
        so that a text is read once however many Servers are looked for. Pure.
    #>

    [CmdletBinding()]
    [OutputType([regex])]
    param([Parameter(Mandatory)][string[]]$Server)

    $names = @($Server | ForEach-Object { "$_".Trim() } | Where-Object { $_ } | ForEach-Object { [regex]::Escape($_) }) -join '|'
    # In a registry key and in a printer connection the separators are hashes or commas
    # instead of backslashes: "##Server#Share" and ",,Server,Printer".
    [regex]('(?i)(?:\\\\|,,|##)(?<server>' + $names + ')(?:\.[a-z0-9.-]+)?(?:\\|,|#)(?<share>[^\\,#\x00-\x1f"<>|?*:]{1,80})')
}

function Find-ReferenceShare {
    <#
    .SYNOPSIS
        The shares of the Servers looked for that a text points at: each as its Server,
        written as it was asked for, and "\\Server\Share", each once. Nothing where it
        points at none. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyString()][string]$Text, [Parameter(Mandatory)][string[]]$Server, [regex]$Pattern)

    if (-not $Text) { return }
    if (-not $Pattern) { $Pattern = Get-ReferencePattern -Server $Server }
    $names = @($Server | ForEach-Object { "$_".Trim() } | Where-Object { $_ })
    $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
    foreach ($match in $Pattern.Matches($Text)) {
        $share = $match.Groups['share'].Value.Trim()
        if (-not $share) { continue }
        $asked = @($names | Where-Object { $_ -eq $match.Groups['server'].Value })[0]
        if (-not $asked) { continue }
        $unc = '\\{0}\{1}' -f $asked, $share
        if ($seen.Add($unc)) { [pscustomobject]@{ Server = $asked; Share = $unc } }
    }
}
function Read-ReferenceFileText {
    <#
    .SYNOPSIS
        What a file holds as text, whatever kind of file it is: read as the two ways
        Windows writes a path into a shortcut or a list. Empty where it would not open.
    .DESCRIPTION
        A shortcut and a jump list are binary files that hold their paths as plain
        characters, one byte each or two. Read as both, and as two bytes from either
        start, because such a path begins wherever the file put it.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][string]$Path)

    try {
        $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, ([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete))
        try {
            $length = [int][math]::Min([long]$script:ReferenceMaxFileBytes, $stream.Length)
            $bytes  = New-Object byte[] $length
            $read   = $stream.Read($bytes, 0, $length)
        }
        finally { $stream.Dispose() }
        if ($read -lt 2) { return '' }
        $latin = [Text.Encoding]::GetEncoding(28591).GetString($bytes, 0, $read)
        $wide  = [Text.Encoding]::Unicode.GetString($bytes, 0, $read - ($read % 2))
        $odd   = [Text.Encoding]::Unicode.GetString($bytes, 1, ($read - 1) - (($read - 1) % 2))
        "$latin`n$wide`n$odd"
    }
    catch { '' }
}

function Get-ReferencePlace {
    <#
    .SYNOPSIS
        The places of the User of the Run in which a pointer at a Server lives: each as
        its token and either the files or the registry key to search. Asks only.
    #>

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

    $roaming = [Environment]::GetFolderPath('ApplicationData')
    $recent  = Join-Path $roaming 'Microsoft\Windows\Recent'
    $file = { param($Token, $Folder, $Filter, [switch]$Deep)
        if (-not (Test-Path -LiteralPath $Folder -PathType Container)) { return }
        $found = @(Get-ChildItem -LiteralPath $Folder -Filter $Filter -File -Force -Recurse:$Deep -ErrorAction SilentlyContinue |
            Sort-Object LastWriteTime -Descending | Select-Object -First $script:ReferenceMaxFiles | ForEach-Object { $_.FullName })
        if ($found.Count) { [pscustomobject]@{ Token = $Token; Files = $found; Key = '' } }
    }
    # The Explorer's own two lists are known by their names; every other is a program's.
    $own = 'f01b4d95cf55d32a.automaticDestinations-ms', '5f7b5f1e01b83767.automaticDestinations-ms'
    $lists = Join-Path $recent 'AutomaticDestinations'
    if (Test-Path -LiteralPath $lists -PathType Container) {
        $all = @(Get-ChildItem -LiteralPath $lists -File -Force -ErrorAction SilentlyContinue)
        $quick = @($all | Where-Object { $own -contains $_.Name } | ForEach-Object { $_.FullName })
        $other = @($all | Where-Object { $own -notcontains $_.Name } | Select-Object -First $script:ReferenceMaxFiles | ForEach-Object { $_.FullName })
        if ($quick.Count) { [pscustomobject]@{ Token = 'quick-access'; Files = $quick; Key = '' } }
        if ($other.Count) { [pscustomobject]@{ Token = 'jump-list'; Files = $other; Key = '' } }
    }
    & $file 'jump-list' (Join-Path $recent 'CustomDestinations') '*'
    & $file 'recent'    $recent '*.lnk'
    & $file 'shortcut'  ([Environment]::GetFolderPath('DesktopDirectory')) '*.lnk'
    & $file 'shortcut'  (Join-Path ([Environment]::GetFolderPath('UserProfile')) 'Links') '*.lnk'
    & $file 'shortcut'  ([Environment]::GetFolderPath('StartMenu')) '*.lnk' -Deep
    & $file 'place'     (Join-Path $roaming 'Microsoft\Windows\Network Shortcuts') '*' -Deep

    foreach ($pair in @(
            @('place',   'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\MountPoints2'),
            @('office',  'HKCU:\Software\Microsoft\Office'),
            @('typed',   'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\TypedPaths'),
            @('typed',   'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\RunMRU'),
            @('drive',   'HKCU:\Network'),
            @('printer', 'HKCU:\Printers\Connections'))) {
        if (Test-Path -LiteralPath $pair[1]) { [pscustomobject]@{ Token = $pair[0]; Files = @(); Key = $pair[1] } }
    }
}

function Read-ReferenceRegistryText {
    <#
    .SYNOPSIS
        Everything under one registry key that could hold a path, as one text: the names
        of its keys and the names and texts of its values. Asks only.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][string]$Key)

    $text = New-Object System.Text.StringBuilder
    $left = $script:ReferenceMaxValues
    $keys = @(Get-Item -LiteralPath $Key -ErrorAction SilentlyContinue) + @(Get-ChildItem -LiteralPath $Key -Recurse -ErrorAction SilentlyContinue)
    foreach ($one in $keys) {
        if ($left -le 0) { break }
        $null = $text.AppendLine($one.PSChildName)
        foreach ($name in $one.GetValueNames()) {
            if (--$left -le 0) { break }
            $value = $one.GetValue($name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
            if ($value -is [string])       { $null = $text.Append($name).Append('=').AppendLine($value) }
            elseif ($value -is [string[]]) { $null = $text.Append($name).Append('=').AppendLine(($value -join "`n")) }
            elseif ($value -is [byte[]] -and $value.Length -le 65536) {
                # A list of recent places is kept as bytes that hold the path two bytes a letter.
                $null = $text.Append($name).Append('=').AppendLine([Text.Encoding]::Unicode.GetString($value, 0, $value.Length - ($value.Length % 2)))
            }
            else { $null = $text.AppendLine($name) }
        }
    }
    $text.ToString()
}

function Get-ReferenceFailedServer {
    <#
    .SYNOPSIS
        The Servers Windows logged as asked for in vain, the most often asked for first:
        from what the Check of the network's disruptions gathered. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param([AllowNull()]$NetworkDisruption, [string[]]$Kind = @('smb-connect-failed'), [int]$First = $script:ReferenceMaxServers)

    @((Get-DataCollection $NetworkDisruption 'Disruptions') | Where-Object { $_ -and $Kind -contains "$(Get-DataProperty $_ 'Kind')" } |
        ForEach-Object { "$(Get-DataProperty $_ 'Server')".Trim() } | Where-Object { $_ } | Group-Object |
        Sort-Object @{ Expression = 'Count'; Descending = $true }, Name | Select-Object -First $First | ForEach-Object { $_.Name })
}

function Get-NetworkDependencyReference {
    <#
    .SYNOPSIS
        Where on this machine the Servers that were asked for in vain are still pointed
        at. Returns what was searched for, where, and what was found. Asks only.
    .DESCRIPTION
        Nothing is searched where no Server failed, or nobody read whether one did: the
        places are then not opened at all.
    #>

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

    $servers = @(Get-ReferenceFailedServer -NetworkDisruption $NetworkDisruption)
    if (-not $servers.Count) {
        return [pscustomobject]@{ Searched = $false; Servers = @(); Places = @(); Found = @() }
    }

    # A place may be several folders - the shortcuts are the desktop's and the start
    # menu's - and is one place in what is said: counted and named together.
    $pattern = Get-ReferencePattern -Server $servers
    $places  = New-Object 'System.Collections.Generic.List[string]'
    $rows    = [ordered]@{}
    foreach ($place in @(Get-ReferencePlace)) {
        if (-not $places.Contains($place.Token)) { $places.Add($place.Token) }
        $texts = @()
        if ($place.Key) { $texts = @(Read-ReferenceRegistryText -Key $place.Key) }
        else            { $texts = @($place.Files | ForEach-Object { Read-ReferenceFileText -Path $_ }) }
        foreach ($text in $texts) {
            $in = @(Find-ReferenceShare -Text $text -Server $servers -Pattern $pattern)
            foreach ($server in @($in | ForEach-Object { $_.Server } | Select-Object -Unique)) {
                $key = '{0}|{1}' -f $server, $place.Token
                if (-not $rows.Contains($key)) {
                    $rows[$key] = [pscustomobject]@{ Server = $server; Place = $place.Token; Count = 0
                        Shares = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) }
                }
                $rows[$key].Count++
                foreach ($one in @($in | Where-Object { $_.Server -eq $server })) { $null = $rows[$key].Shares.Add($one.Share) }
            }
        }
    }
    # In the order the Servers were asked for, and within one in the order of the places.
    $found = @(foreach ($server in $servers) {
        foreach ($row in @($rows.Values | Where-Object { $_.Server -eq $server })) {
            [pscustomobject]@{ Server = $row.Server; Place = $row.Place; Count = $row.Count; Shares = @($row.Shares | Sort-Object) }
        }
    })
    [pscustomobject]@{ Searched = $true; Servers = $servers; Places = @($places); Found = @($found) }
}

function Get-ReferencePlaceText {
    # A place as the Report names it. Pure.
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][string]$Place)

    switch ("$Place") {
        'quick-access' { return (Get-Text 'Value.Reference.Place.QuickAccess') }
        'jump-list'    { return (Get-Text 'Value.Reference.Place.JumpList') }
        'recent'       { return (Get-Text 'Value.Reference.Place.Recent') }
        'shortcut'     { return (Get-Text 'Value.Reference.Place.Shortcut') }
        'place'        { return (Get-Text 'Value.Reference.Place.Place') }
        'office'       { return (Get-Text 'Value.Reference.Place.Office') }
        'typed'        { return (Get-Text 'Value.Reference.Place.Typed') }
        'drive'        { return (Get-Text 'Value.Reference.Place.Drive') }
        'printer'      { return (Get-Text 'Value.Reference.Place.Printer') }
    }
    "$Place"
}

function Get-ReferencePlaceHint {
    # What to do about a pointer in a place. Pure.
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][string]$Place)

    switch ("$Place") {
        'quick-access' { return (Get-Text 'Hint.Reference.QuickAccess') }
        'jump-list'    { return (Get-Text 'Hint.Reference.Recent') }
        'recent'       { return (Get-Text 'Hint.Reference.Recent') }
        'shortcut'     { return (Get-Text 'Hint.Reference.Shortcut') }
        'place'        { return (Get-Text 'Hint.Reference.Place') }
        'office'       { return (Get-Text 'Hint.Reference.Office') }
        'typed'        { return (Get-Text 'Hint.Reference.Typed') }
        'drive'        { return (Get-Text 'Hint.Reference.Drive') }
        'printer'      { return (Get-Text 'Hint.Reference.Printer') }
    }
    ''
}

function New-NetworkDependencyReferenceFinding {
    <#
    .SYNOPSIS
        Where this machine still points at the Servers it asked for in vain, and what to
        do about each place. Nothing where nothing was searched.
    .DESCRIPTION
        A WARN where a pointer was found: it is the thing to remove, and the answer to
        the Finding about failed connections. Where none was found that is said too,
        with where the search went - so that the Technician knows what is left to look
        at, and does not look there again.
    #>

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

    $search = Get-DataProperty $Data 'References'
    if (-not [bool](Get-DataProperty $search 'Searched')) { return }
    $servers = @((Get-DataCollection $search 'Servers') | Where-Object { $_ })
    if (-not $servers.Count) { return }

    $check  = Get-Text 'Check.Reference.Pointers'
    $found  = @((Get-DataCollection $search 'Found') | Where-Object { $_ })
    $places = @((Get-DataCollection $search 'Places') | Where-Object { $_ })
    $looked = (Get-Text 'Value.Reference.Looked') -f (@($places | ForEach-Object { Get-ReferencePlaceText -Place $_ }) -join '; ')
    $reference = @(New-Reference -Section 'Title.Reference.Pointers')

    if (-not $found.Count) {
        return New-Finding -Category Network -Check $check -Severity INFO `
            -Value ('{0} | {1}' -f ((Get-Text 'Value.Reference.None') -f ($servers -join ', ')), $looked) `
            -Meaning (Get-Text 'Meaning.Reference.None') -Hint (Get-Text 'Hint.Reference.None')
    }

    $value = @()
    foreach ($server in $servers) {
        $mine = @($found | Where-Object { "$(Get-DataProperty $_ 'Server')" -eq $server })
        if (-not $mine.Count) { continue }
        $items = @($mine | ForEach-Object {
            $shares = @((Get-DataCollection $_ 'Shares') | Where-Object { $_ })
            $shown  = @($shares | Select-Object -First 4) -join ', '
            if ($shares.Count -gt 4) { $shown = (Get-Text 'Value.Reference.MoreShares') -f $shown, ($shares.Count - 4) }
            (Get-Text 'Value.Reference.Item') -f (Get-ReferencePlaceText -Place "$(Get-DataProperty $_ 'Place')"), $shown
        })
        $value += '{0}: {1}' -f $server, ($items -join '; ')
    }
    $without = @($servers | Where-Object { $name = $_; -not @($found | Where-Object { "$(Get-DataProperty $_ 'Server')" -eq $name }).Count })
    if ($without.Count) { $value += (Get-Text 'Value.Reference.NoneFor') -f ($without -join ', ') }

    # A hint for each place that holds one, each once, in the order of the places.
    $hints = @($found | ForEach-Object { "$(Get-DataProperty $_ 'Place')" } | Select-Object -Unique |
        ForEach-Object { Get-ReferencePlaceHint -Place $_ } | Where-Object { $_ } | Select-Object -Unique)
    $hints += Get-Text 'Hint.Reference.After'

    New-Finding -Category Network -Check $check -Severity WARN -Value ($value -join ' | ') `
        -Meaning (Get-Text 'Meaning.Reference.Found') -Hint ($hints -join ' | ') -Reference $reference
}

function ConvertTo-NetworkDependencyReferenceSection {
    <#
    .SYNOPSIS
        Every place that points at a Server that was asked for in vain: the Server, the
        place, in how many files or keys, and the shares. Nothing where none does. Pure.
    #>

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

    $found = @((Get-DataCollection (Get-DataProperty $Data 'References') 'Found') | Where-Object { $_ })
    if (-not $found.Count) { return }
    New-Section -Title (Get-Text 'Title.Reference.Pointers') -Help (Get-Text 'Help.Reference.Pointers') -Row @(
        $found | ForEach-Object {
            $row = [ordered]@{}
            $row[(Get-Text 'Column.Reference.Server')] = "$(Get-DataProperty $_ 'Server')"
            $row[(Get-Text 'Column.Reference.Place')]  = Get-ReferencePlaceText -Place "$(Get-DataProperty $_ 'Place')"
            $row[(Get-Text 'Column.Reference.Count')]  = [int](ConvertTo-Number (Get-DataProperty $_ 'Count'))
            $row[(Get-Text 'Column.Reference.Shares')] = @((Get-DataCollection $_ 'Shares') | Where-Object { $_ }) -join ', '
            [pscustomobject]$row
        }
    )
}