Private/Kinds/NetworkDependencySource.ps1

# The sources of the NetworkDependency Gatherer: a part of that Kind, and no Kind.
#
# A source reads one place a dependency leaves a trace - the running processes and their
# modules, services, scheduled tasks, startup entries, shortcuts, registered application
# paths, shell folders, Offline Files, printers, ODBC data sources, configuration files -
# and returns traces made with New-NetworkDependencyTrace. It knows nothing about drive
# letters or Servers: Get-NetworkDependencyData hands what it returns to
# ConvertTo-NetworkDependency. The list of sources, and what each one's rows carry, opens
# NetworkDependency.ps1.

function Get-NetworkDependencyProcessTrace {
    <#
    .SYNOPSIS
        Source: the program path of every process in the logged-on user's session.
    .DESCRIPTION
        The session rather than the user name, because asking who owns a process needs
        admin rights and the session does not: it is the desktop the Technician is looking
        at, whoever's credentials the Run was started with.
 
        A process this Run may not open gives no path. It is recorded by name, so the
        Report can say what was not looked at instead of implying it runs locally.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()]$Unexamined,
        # What the Elevated Part read of the processes, or nothing where it did not.
        [AllowNull()]$Elevated
    )

    $sessionId = [Diagnostics.Process]::GetCurrentProcess().SessionId
    $seen      = @{}
    $unread    = @{}

    foreach ($process in @(Get-Process -ErrorAction SilentlyContinue)) {
        if ($process.SessionId -ne $sessionId) { continue }

        $name = "$($process.ProcessName)"
        $path = $null
        try { $path = $process.Path } catch { }

        if (-not $path) {
            # Read with admin rights where this session could not: every path that
            # reading has for a process of this name in this session.
            $filled = @(Select-NetworkDependencyElevatedPath -Elevated $Elevated -Name $name -SessionId $sessionId)
            if ($filled.Count) {
                foreach ($one in $filled) {
                    $key = ('{0}|{1}' -f $name, $one).ToUpperInvariant()
                    if ($seen.ContainsKey($key)) { continue }
                    $seen[$key] = $true
                    New-NetworkDependencyTrace -Application $name -Path $one -Kind 'program' -Source 'process'
                }
                continue
            }
            if (-not $unread.ContainsKey($name)) {
                $unread[$name] = $true
                $Unexamined.Add((New-NetworkDependencyUnexamined -What $name -Reason 'PathNotReadable'))
            }
            continue
        }

        $key = ('{0}|{1}' -f $name, $path).ToUpperInvariant()
        if ($seen.ContainsKey($key)) { continue }
        $seen[$key] = $true

        New-NetworkDependencyTrace -Application $name -Path $path -Kind 'program' -Source 'process'
    }
}

# The Win32 error codes the loaded modules source tells apart. Every other code is a
# module list that could not be read.
$script:NetworkDependencyErrorAccessDenied = 5

$script:NetworkDependencyErrorNoSuchProcess = 87

function Initialize-NetworkDependencyNativeMethod {
    <#
    .SYNOPSIS
        Defines the Win32 calls that list a process's modules, once per session.
    .DESCRIPTION
        Process.Modules is not used: under Windows PowerShell 5.1 it returns seven system
        DLLs for a 32-bit process, without an error, and misses a DLL loaded from a share.
        EnumProcessModulesEx with LIST_MODULES_ALL returns the whole list on both editions
        (docs/research/process-modules-on-windows-powershell.md).
 
        A type of its own, because Gutcheck.Native in App.ps1 is defined once per session
        and cannot gain members. Compiled on first use and not at import, for the reason
        Initialize-AppNativeMethod gives.
 
        The loop is in the type and not in the Gatherer for two reasons: the last Win32
        error has to be read before anything else runs on the thread, and a machine has
        some twelve thousand modules to name. A failure comes back as its error code;
        nothing here throws.
    #>

    [CmdletBinding()]
    param()

    if ('Gutcheck.ModuleNative' -as [type]) { return }
    Add-Type -Namespace Gutcheck -Name ModuleNative -MemberDefinition @'
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr OpenProcess(uint access, [MarshalAs(UnmanagedType.Bool)] bool inherit, int processId);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)] private static extern bool CloseHandle(IntPtr handle);
[DllImport("psapi.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)] private static extern bool EnumProcessModulesEx(IntPtr process, [Out] IntPtr[] modules, int cb, out int needed, uint filter);
[DllImport("psapi.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern uint GetModuleFileNameEx(IntPtr process, IntPtr module, System.Text.StringBuilder name, int size);
 
// PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ, and LIST_MODULES_ALL.
private const uint Access = 0x1010;
private const uint AllModules = 3;
 
// The paths of the modules a process has loaded. On failure the list is empty and error
// holds the Win32 error code; on success error is 0.
public static string[] GetModulePath(int processId, out int error)
{
    error = 0;
    System.Collections.Generic.List<string> paths = new System.Collections.Generic.List<string>();
 
    IntPtr process = OpenProcess(Access, false, processId);
    if (process == IntPtr.Zero)
    {
        error = Marshal.GetLastWin32Error();
        if (error == 0) { error = -1; }
        return paths.ToArray();
    }
 
    try
    {
        IntPtr[] modules = new IntPtr[1024];
        int count = 0;
        // The list can grow between two calls, so the buffer is grown more than once.
        for (int attempt = 0; attempt < 4; attempt++)
        {
            int cb = modules.Length * IntPtr.Size;
            int needed;
            if (!EnumProcessModulesEx(process, modules, cb, out needed, AllModules))
            {
                error = Marshal.GetLastWin32Error();
                if (error == 0) { error = -1; }
                return paths.ToArray();
            }
            count = Math.Min(needed, cb) / IntPtr.Size;
            if (needed <= cb) { break; }
            modules = new IntPtr[needed / IntPtr.Size + 64];
        }
 
        System.Text.StringBuilder name = new System.Text.StringBuilder(1024);
        for (int i = 0; i < count; i++)
        {
            name.Length = 0;
            uint length = GetModuleFileNameEx(process, modules[i], name, name.Capacity);
            if (length >= (uint)(name.Capacity - 1) && name.Capacity < 32768)
            {
                // Cut short: a long path. Asked again with room for the longest there is.
                name = new System.Text.StringBuilder(32768);
                length = GetModuleFileNameEx(process, modules[i], name, name.Capacity);
            }
            // A module unloaded since the list was read has no name any more.
            if (length > 0) { paths.Add(name.ToString()); }
        }
    }
    finally
    {
        CloseHandle(process);
    }
 
    return paths.ToArray();
}
'@

}

function Get-NetworkDependencyProcessModule {
    <#
    .SYNOPSIS
        The modules loaded by every process in the logged-on user's session and by every
        process that hosts a service. One row per process; interprets none of it.
    .DESCRIPTION
        Which processes host a service is an argument. Returns
        { ProcessName, ProcessId, IsService, Modules, Error }. Modules is every
        path, local ones included. Error is the Win32 error code that stopped the reading,
        or $null. IsService says the process was selected because it hosts a service and
        is not in the session: without admin rights none of those can be opened, which the
        caller reports as a number and not as a hundred names.
 
        A process that ended between being listed and being opened is not returned: there
        is nothing left that was not examined.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()][int[]]$ServiceProcessId)

    Initialize-NetworkDependencyNativeMethod

    $hosts = @{}
    foreach ($id in @($ServiceProcessId)) { $hosts[[int]$id] = $true }

    $sessionId = [Diagnostics.Process]::GetCurrentProcess().SessionId
    $selected = @(Get-Process -ErrorAction SilentlyContinue | Where-Object {
        # 0 and 4 are the idle process and the kernel: no module list to read.
        $_.Id -ne 0 -and $_.Id -ne 4 -and ($_.SessionId -eq $sessionId -or $hosts.ContainsKey([int]$_.Id))
    })

    $status = Get-Text 'Console.Progress.Modules'
    $index  = 0
    foreach ($process in $selected) {
        # In twenty steps and not one per process: a bar drawn three hundred times costs
        # Windows PowerShell 5.1 more than a second of the four this takes.
        Write-CheckProgress -Status $status -Step ([math]::Floor($index * 20 / $selected.Count)) -Of 20
        $index++

        $code    = 0
        $modules = @([Gutcheck.ModuleNative]::GetModulePath($process.Id, [ref]$code))
        if ($code -eq $script:NetworkDependencyErrorNoSuchProcess) { continue }

        [pscustomobject]@{
            ProcessName = "$($process.ProcessName)"
            ProcessId   = [int]$process.Id
            IsService   = $process.SessionId -ne $sessionId
            Modules     = [string[]]$modules
            Error       = $(if ($code) { $code } else { $null })
        }
    }
}

function Select-NetworkDependencyElevatedPath {
    <#
    .SYNOPSIS
        Where the Elevated Part found the program of a process this session could not
        open: the paths of the processes of that name in that session. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Elevated, [Parameter(Mandatory)][string]$Name, [int]$SessionId)

    (Get-DataCollection $Elevated 'Processes') | Where-Object {
        "$(Get-DataProperty $_ 'Name')" -eq $Name -and
        (ConvertTo-Number (Get-DataProperty $_ 'SessionId')) -eq $SessionId -and
        "$(Get-DataProperty $_ 'Path')"
    } | ForEach-Object { "$(Get-DataProperty $_ 'Path')" } | Select-Object -Unique
}

function Join-NetworkDependencyElevatedModule {
    <#
    .SYNOPSIS
        The modules this session read, with those the Elevated Part read beside them, and
        what is then still unread. Pure.
    .DESCRIPTION
        Where the Elevated Part read, a process this session was refused is no longer
        unread: the service processes as a number, and a process of the session by name.
        What even an administrator could not open - a protected process of Windows - is
        said instead, from the Elevated Part's own list. A module both found is one trace.
 
        Where the Elevated Part read nothing, everything is as this session read it.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)]$Read, [AllowNull()]$Elevated)

    $traces     = @((Get-DataCollection $Read 'Traces') | ForEach-Object { $_ })
    $unexamined = @((Get-DataCollection $Read 'Unexamined') | ForEach-Object { $_ })
    # Whether the Elevated Part read the modules at all, and not whether it found any on
    # a share: a reading that found none is a reading, and most machines have none.
    $wasRead = $false
    if ($Elevated -is [System.Collections.IDictionary]) { $wasRead = $Elevated.Contains('ModuleTraces') }
    elseif ($null -ne $Elevated)                        { $wasRead = $null -ne $Elevated.PSObject.Properties['ModuleTraces'] }
    if (-not $wasRead) {
        return [pscustomobject]@{ Traces = $traces; Unexamined = $unexamined }
    }

    $seen = @{}
    foreach ($trace in $traces) { $seen[('{0}|{1}' -f (Get-DataProperty $trace 'Application'), (Get-DataProperty $trace 'Path')).ToUpperInvariant()] = $true }
    foreach ($trace in (Get-DataCollection $Elevated 'ModuleTraces')) {
        $key = ('{0}|{1}' -f (Get-DataProperty $trace 'Application'), (Get-DataProperty $trace 'Path')).ToUpperInvariant()
        if ($seen.ContainsKey($key)) { continue }
        $seen[$key] = $true
        $traces += New-NetworkDependencyTrace -Application "$(Get-DataProperty $trace 'Application')" -Path "$(Get-DataProperty $trace 'Path')" -Kind 'program' -Source 'module'
    }

    $refused = 'ServiceAccessDenied', 'ModulesAccessDenied'
    $left    = @($unexamined | Where-Object { $refused -notcontains "$(Get-DataProperty $_ 'Reason')" })
    # What the Elevated Part was refused as well is no question of rights any more:
    # Windows protects those processes from an administrator too, and it is said so.
    $left   += @((Get-DataCollection $Elevated 'ModuleUnexamined') | ForEach-Object {
        $reason = "$(Get-DataProperty $_ 'Reason')"
        if ($reason -eq 'ServiceAccessDenied') { $reason = 'ServiceProtected' }
        if ($reason -eq 'ModulesAccessDenied') { $reason = 'ModulesProtected' }
        $count = ConvertTo-Number (Get-DataProperty $_ 'Count')
        if ($null -ne $count) { New-NetworkDependencyUnexamined -What "$(Get-DataProperty $_ 'What')" -Reason $reason -Count ([int]$count) }
        else                  { New-NetworkDependencyUnexamined -What "$(Get-DataProperty $_ 'What')" -Reason $reason }
    })
    [pscustomobject]@{ Traces = $traces; Unexamined = $left }
}

function ConvertTo-NetworkDependencyModuleTrace {
    <#
    .SYNOPSIS
        Turns what Get-NetworkDependencyProcessModule read into traces and into rows for
        what it could not read. Pure.
    .DESCRIPTION
        Returns Traces and Unexamined. Only a module that is not on one of the machine's
        own drives becomes a trace: a UNC path, or a drive letter the mapping has to place.
        ConvertTo-NetworkDependency would drop the local ones as well, but a machine has
        some twelve thousand of them and that function is not built for the number.
 
        A process of the session that could not be read is named once, whatever number of
        it runs. The service processes that may not be opened are one row with their
        number: without admin rights that is every one of them.
    .PARAMETER LocalDrive
        The letters of the machine's own drives.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$ProcessModule,
        [AllowNull()][AllowEmptyCollection()][string[]]$LocalDrive
    )

    $local = ConvertTo-NetworkDependencyDriveSet -LocalDrive $LocalDrive

    $traces     = [System.Collections.Generic.List[psobject]]::new()
    $unexamined = [System.Collections.Generic.List[psobject]]::new()
    $seen       = @{}
    $unread     = @{}
    $services   = 0

    foreach ($row in @($ProcessModule)) {
        if ($null -eq $row) { continue }
        $name  = "$(Get-DataProperty $row 'ProcessName')"
        $code  = Get-DataProperty $row 'Error'

        if ($null -ne $code -and "$code" -ne '') {
            $denied = "$code" -eq "$script:NetworkDependencyErrorAccessDenied"
            if ($denied -and (Get-DataProperty $row 'IsService')) { $services++; continue }

            $reason = 'ModulesNotReadable'
            if ($denied) { $reason = 'ModulesAccessDenied' }
            $key = ('{0}|{1}' -f $reason, $name).ToUpperInvariant()
            if (-not $unread.ContainsKey($key)) {
                $unread[$key] = $true
                $unexamined.Add((New-NetworkDependencyUnexamined -What $name -Reason $reason))
            }
            continue
        }

        foreach ($module in @(Get-DataProperty $row 'Modules')) {
            $path = "$module"
            if (-not $path) { continue }

            # The long form of a drive path (\\?\C:\...) is the same drive.
            $start = 0
            if ($path.StartsWith('\\?\') -and -not $path.StartsWith('\\?\UNC\', [StringComparison]::OrdinalIgnoreCase)) { $start = 4 }
            if ($path.Length -ge $start + 2 -and $path[$start + 1] -eq ':' -and
                $local.ContainsKey("$($path[$start])".ToUpperInvariant())) { continue }

            $key = ('{0}|{1}' -f $name, $path).ToUpperInvariant()
            if ($seen.ContainsKey($key)) { continue }
            $seen[$key] = $true

            $traces.Add((New-NetworkDependencyTrace -Application $name -Path $path -Kind 'program' -Source 'module'))
        }
    }

    if ($services) {
        $unexamined.Add((New-NetworkDependencyUnexamined -What 'services' -Reason 'ServiceAccessDenied' -Count $services))
    }

    [pscustomobject]@{
        Traces     = @($traces)
        Unexamined = @($unexamined)
    }
}

function Get-NetworkDependencyModuleTrace {
    <#
    .SYNOPSIS
        Source: the modules loaded by the processes in the logged-on user's session and by
        the service processes, where they are not on one of the machine's own drives.
    .DESCRIPTION
        Finds the program that is installed locally and loads an add-in or a database DLL
        from a share. A module is code Windows pages in from its Server like the program
        itself, so its kind is program.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()]$Unexamined,
        [AllowNull()][AllowEmptyCollection()][string[]]$LocalDrive,
        # What the Elevated Part read of the modules, or nothing where it did not.
        [AllowNull()]$Elevated
    )

    # Which processes host a service. Asked not to stop: a machine whose WMI does not
    # answer still has a session to read, and the Report says the services were left out.
    $problems = @()
    $services = @(Get-CimInstance -ClassName Win32_Service -Filter 'ProcessId != 0' -Property ProcessId `
        -ErrorAction SilentlyContinue -ErrorVariable problems)
    if (@($problems).Count) {
        $reason = 'NotReadable'
        if (Test-AccessDenied -ErrorRecord @($problems)[0]) { $reason = 'AccessDenied' }
        $Unexamined.Add((New-NetworkDependencyUnexamined -What 'Win32_Service' -Reason $reason))
    }
    $ids = @($services | ForEach-Object { [int]$_.ProcessId } | Sort-Object -Unique)

    $read = ConvertTo-NetworkDependencyModuleTrace -LocalDrive $LocalDrive `
        -ProcessModule @(Get-NetworkDependencyProcessModule -ServiceProcessId $ids)
    $read = Join-NetworkDependencyElevatedModule -Read $read -Elevated $Elevated
    foreach ($row in $read.Unexamined) { $Unexamined.Add($row) }
    $read.Traces
}

function Merge-NetworkDependencyModule {
    <#
    .SYNOPSIS
        Drops the modules a program on a share loads from that same share. Pure.
    .DESCRIPTION
        A program that runs from \\server\share loads its own DLLs from there, and the row
        for the program already says that it depends on that share: thirty more rows would
        bury it. A module on another share, or on another Server, is a second dependency
        and stays, and so does every module of a program that is installed locally.
    #>

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

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

    $programs = @{}
    foreach ($row in $rows) {
        if ("$(Get-DataProperty $row 'Source')" -ne 'process') { continue }
        $parts = Split-NetworkDependencyUncPath -UncPath "$(Get-DataProperty $row 'UncPath')"
        if (-not $parts) { continue }
        $programs[('{0}|{1}' -f (Get-DataProperty $row 'Application'), $parts.Share).ToUpperInvariant()] = $true
    }

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

# What makes a path a program and not a document: Windows runs it, a script host does, or
# a program loads it as code - a library, a control, a control panel item, an installer
# package. The one list of them; everything that tells a program from data by its name
# reads it through the two below.
$script:NetworkDependencyProgramExtensions =
    'exe|com|bat|cmd|ps1|psm1|vbs|vbe|js|jse|wsf|wsh|msi|msp|msc|dll|ocx|cpl|scr|pif|jar|hta|lnk|py|xll'

# A path that names code, and what may stand behind it: rundll32 and control.exe are handed
# "library,entry point", and the entry point is no part of the file's name.
$script:NetworkDependencyProgramPathPattern =
    '^(.+\.(?:{0}))(?:,[^\\/]*)?$' -f $script:NetworkDependencyProgramExtensions

# A command line that begins with a path and is not quoted: the program ends where the
# first of these extensions does, which is how Windows reads C:\Program Files\App\app.exe.
$script:NetworkDependencyLauncherPattern =
    '^(.+?\.(?:{0}))(?=\s|$)(.*)$' -f $script:NetworkDependencyProgramExtensions

# A UNC path or a path on a drive letter among the arguments of a command line: quoted -
# in double quotes or in single ones - or up to the next blank. Not after a letter, a
# slash, a backslash or a quote, so that neither a switch (/c:fast), nor a URL, nor the
# inside of another path is taken for one. A program handed 'path','NAME=value' had its
# path read on to the next blank, and the Report named a share "oic','NLS_LANG=...".
# Read what it matched through Get-NetworkDependencyArgumentPath.
$script:NetworkDependencyArgumentPathPattern =
    '"(?<dq>(?:\\\\[^\\"\s]|[A-Za-z]:\\)[^"]*)"|''(?<sq>(?:\\\\[^\\''\s]|[A-Za-z]:\\)[^'']*)''|(?<![\w\\/"''])(?<bare>(?:\\\\[^\\"\s]|[A-Za-z]:\\)[^\s"]*)'

function Get-NetworkDependencyArgumentPath {
    <#
    .SYNOPSIS
        The path one match of the pattern above found, whichever way it was written. Pure.
    #>

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

    foreach ($name in 'dq', 'sq', 'bare') {
        if ($Match.Groups[$name].Success) { return $Match.Groups[$name].Value.Trim() }
    }
    ''
}

function Get-NetworkDependencyProgramPath {
    <#
    .SYNOPSIS
        The file a path names when that file is code, or nothing when it is a folder or a
        document. Pure.
    .DESCRIPTION
        Code that is loaded and run from a share is a program on a share, whatever started
        it: "rundll32 \\server\share\x.dll,Entry" pages x.dll in from its Server exactly as
        x.exe would be. The entry point or the resource number behind the comma is cut
        off, so the path is that of the file.
    #>

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

    if ("$Path".Trim() -match $script:NetworkDependencyProgramPathPattern) { $Matches[1] }
}

function Split-NetworkDependencyCommand {
    <#
    .SYNOPSIS
        A command line as the program it starts and the rest. Pure.
    .DESCRIPTION
        A service's PathName, a startup entry and an App Paths value are one string, quoted
        or not. Not quoted and beginning with a path, the program may contain blanks.
    #>

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

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

    $executable = $text
    $arguments  = ''
    if ($text.StartsWith('"')) {
        $end = $text.IndexOf('"', 1)
        if ($end -gt 0) {
            $executable = $text.Substring(1, $end - 1)
            $arguments  = $text.Substring($end + 1)
        }
        else { $executable = $text.Substring(1) }
    }
    elseif ($text -match '^(?:\\\\|[A-Za-z]:\\)' -and $text -match $script:NetworkDependencyLauncherPattern) {
        $executable = $Matches[1]
        $arguments  = $Matches[2]
    }
    elseif ($text -match '^(\S+)\s+(.*)$') {
        $executable = $Matches[1]
        $arguments  = $Matches[2]
    }

    [pscustomobject]@{
        Executable = $executable.Trim()
        Arguments  = $arguments.Trim()
    }
}

function ConvertTo-NetworkDependencyCommandTrace {
    <#
    .SYNOPSIS
        Turns things that start a program - services, scheduled tasks, startup entries,
        shortcuts, App Paths entries - into traces. Pure.
    .DESCRIPTION
        An entry is { Name, Command }, or { Name, Executable, Arguments } where Windows
        keeps the two apart. The program is a trace, and so is every path among the
        arguments: the script a local powershell.exe is handed lives on the share as much
        as a program would.
 
        What is on one of the machine's own drives is dropped here, as for the loaded
        modules: a machine has several hundred of these and nearly all are local.
 
        The kind follows from what the path is. A program, a script or a library is a
        program dependency, with or without an entry point behind it
        (Get-NetworkDependencyProgramPath); a folder or a document among the arguments
        is data.
    .PARAMETER BareTargetKind
        The kind of a program path without one of the extensions Windows runs. A service
        or a task starts a program whatever it is called; a shortcut may as well point at
        a folder or a document, and says 'data'.
    .PARAMETER LocalDrive
        The letters of the machine's own drives.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Entry,
        [Parameter(Mandatory)][string]$Source,
        [AllowNull()][AllowEmptyCollection()][string[]]$LocalDrive,
        [string]$BareTargetKind = 'program'
    )

    $local = ConvertTo-NetworkDependencyDriveSet -LocalDrive $LocalDrive
    $seen  = @{}

    foreach ($item in @($Entry)) {
        if ($null -eq $item) { continue }
        $name       = "$(Get-DataProperty $item 'Name')"
        $executable = "$(Get-DataProperty $item 'Executable')"
        $arguments  = "$(Get-DataProperty $item 'Arguments')"

        $command = Get-DataProperty $item 'Command'
        if ($null -ne $command) {
            $split = Split-NetworkDependencyCommand -Command "$command"
            if (-not $split) { continue }
            $executable = $split.Executable
            $arguments  = $split.Arguments
        }

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

        $target = $executable.Trim().Trim('"').Trim()
        if ($target -match '^(?:\\\\[^\\\s]|[A-Za-z]:\\)') {
            $program = Get-NetworkDependencyProgramPath -Path $target
            if ($program) { $found.Add([pscustomobject]@{ Path = $program; Kind = 'program' }) }
            else          { $found.Add([pscustomobject]@{ Path = $target; Kind = $BareTargetKind }) }
        }

        foreach ($match in [regex]::Matches($arguments, $script:NetworkDependencyArgumentPathPattern)) {
            $path = Get-NetworkDependencyArgumentPath -Match $match
            if (-not $path) { continue }
            $program = Get-NetworkDependencyProgramPath -Path $path
            if ($program) { $found.Add([pscustomobject]@{ Path = $program; Kind = 'program' }) }
            else          { $found.Add([pscustomobject]@{ Path = $path; Kind = 'data' }) }
        }

        foreach ($row in $found) {
            $path = $row.Path
            if ($path.Length -ge 2 -and $path[1] -eq ':' -and $local.ContainsKey("$($path[0])".ToUpperInvariant())) { continue }

            $key = ('{0}|{1}' -f $name, $path).ToUpperInvariant()
            if ($seen.ContainsKey($key)) { continue }
            $seen[$key] = $true

            New-NetworkDependencyTrace -Application $name -Path $path -Kind $row.Kind -Source $Source
        }
    }
}

function Add-NetworkDependencyUnread {
    <#
    .SYNOPSIS
        Records a source that did not answer, by the name a Technician can type.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()]$Unexamined,
        [Parameter(Mandatory)][string]$What,
        [AllowNull()][AllowEmptyCollection()]$Problem
    )

    $reason = 'NotReadable'
    if (@($Problem).Count -and (Test-AccessDenied -ErrorRecord @($Problem)[0])) { $reason = 'AccessDenied' }
    $Unexamined.Add((New-NetworkDependencyUnexamined -What $What -Reason $reason))
}

function Get-NetworkDependencyServiceTrace {
    <#
    .SYNOPSIS
        Source: the services whose executable is on a share.
    .DESCRIPTION
        Every installed service, running or not: one that is stopped today starts from the
        same share tomorrow. Named as the Technician sees it in the services console.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()]$Unexamined,
        [AllowNull()][AllowEmptyCollection()][string[]]$LocalDrive
    )

    $problems = @()
    $services = @(Get-CimInstance -ClassName Win32_Service -Property Name, DisplayName, PathName `
        -ErrorAction SilentlyContinue -ErrorVariable problems)
    if (@($problems).Count) { Add-NetworkDependencyUnread -Unexamined $Unexamined -What 'Win32_Service' -Problem $problems }

    ConvertTo-NetworkDependencyCommandTrace -Source 'service' -LocalDrive $LocalDrive -Entry @($services | ForEach-Object {
        $name = "$($_.DisplayName)"
        if (-not $name) { $name = "$($_.Name)" }
        [pscustomobject]@{ Name = $name; Command = "$($_.PathName)" }
    })
}

function Get-NetworkDependencyTaskTrace {
    <#
    .SYNOPSIS
        Source: the scheduled tasks whose action starts something from a share.
    .DESCRIPTION
        Only the program and the arguments of each action are read. An action that is a
        COM handler names no program and is not one.
 
        Without admin rights Windows hands out the tasks this user may see and says
        nothing about the rest: no error and no number (observed on Windows 11, 199 tasks,
        no error). Where it does name tasks it would not read, they are one row with their
        number, and not a hundred names.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()]$Unexamined,
        [AllowNull()][AllowEmptyCollection()][string[]]$LocalDrive
    )

    # Asked first: Windows Server Core and stripped-down images have no such cmdlet, and
    # calling a command that is not there is an error no -ErrorAction quietens.
    if (-not (Get-Command -Name Get-ScheduledTask -ErrorAction SilentlyContinue)) {
        $Unexamined.Add((New-NetworkDependencyUnexamined -What 'Get-ScheduledTask' -Reason 'NotReadable'))
        return
    }

    $problems = @()
    $tasks = @(Get-ScheduledTask -ErrorAction SilentlyContinue -ErrorVariable problems)
    if (@($problems).Count) {
        if ($tasks.Count) {
            $Unexamined.Add((New-NetworkDependencyUnexamined -What 'tasks' -Reason 'TaskNotReadable' -Count @($problems).Count))
        }
        else { Add-NetworkDependencyUnread -Unexamined $Unexamined -What 'Get-ScheduledTask' -Problem $problems }
    }

    $entries = foreach ($task in $tasks) {
        foreach ($action in @($task.Actions)) {
            $execute = "$(Get-DataProperty $action 'Execute')"
            if (-not $execute) { continue }
            [pscustomobject]@{
                Name       = "$($task.TaskName)"
                Executable = [Environment]::ExpandEnvironmentVariables($execute)
                Arguments  = [Environment]::ExpandEnvironmentVariables("$(Get-DataProperty $action 'Arguments')")
            }
        }
    }

    ConvertTo-NetworkDependencyCommandTrace -Source 'task' -LocalDrive $LocalDrive -Entry @($entries)
}

function Get-NetworkDependencyStartupTrace {
    <#
    .SYNOPSIS
        Source: the startup entries that start something from a share.
    .PARAMETER Startup
        What the Startup Check gathered, when it ran before this one. Its entries are then
        used and nothing is read a second time. Absent, or without entries because Windows
        would not enumerate them, they are read here.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()]$Unexamined,
        [AllowNull()][AllowEmptyCollection()][string[]]$LocalDrive,
        [AllowNull()]$Startup
    )

    if ($null -ne $Startup -and (Get-DataProperty $Startup 'Available')) {
        $entries = (Get-DataCollection $Startup 'Entries')
    }
    else {
        $problems = @()
        $entries = @(Get-CimInstance -ClassName Win32_StartupCommand -ErrorAction SilentlyContinue -ErrorVariable problems)
        if (@($problems).Count) { Add-NetworkDependencyUnread -Unexamined $Unexamined -What 'Win32_StartupCommand' -Problem $problems }
    }

    ConvertTo-NetworkDependencyCommandTrace -Source 'startup' -LocalDrive $LocalDrive -Entry @($entries | ForEach-Object {
        [pscustomobject]@{
            Name    = "$(Get-DataProperty $_ 'Name')"
            Command = [Environment]::ExpandEnvironmentVariables("$(Get-DataProperty $_ 'Command')")
        }
    })
}

function Get-NetworkDependencyShortcutTrace {
    <#
    .SYNOPSIS
        Source: the Desktop and Start menu shortcuts that point at a share.
    .DESCRIPTION
        The program that is not running right now. Read by the App Kind's reader, which
        reads them once per Run whoever asks. A shortcut to a folder or a document on a
        share is a data dependency: nothing is paged in from there.
    #>

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

    ConvertTo-NetworkDependencyCommandTrace -Source 'shortcut' -LocalDrive $LocalDrive -BareTargetKind 'data' -Entry @(
        Get-StartMenuShortcut | ForEach-Object {
            [pscustomobject]@{ Name = "$($_.Name)"; Executable = "$($_.Target)"; Arguments = "$($_.Arguments)" }
        })
}

# Where Windows looks a program up that is started by its name alone.
$script:NetworkDependencyAppPathKeys = @(
    'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths'
    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths'
    'HKCU:\Software\Microsoft\Windows\CurrentVersion\App Paths'
)

function Get-NetworkDependencyAppPathTrace {
    <#
    .SYNOPSIS
        Source: the registered application paths that point at a share.
    .DESCRIPTION
        A key that is not there is the usual case for the user's own hive and is no gap.
    #>

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

    $entries = foreach ($root in $script:NetworkDependencyAppPathKeys) {
        foreach ($key in @(Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {
            $value = "$($key.GetValue(''))"
            if (-not $value) { continue }
            [pscustomobject]@{ Name = ("$($key.PSChildName)" -replace '\.exe$', ''); Command = $value }
        }
    }

    ConvertTo-NetworkDependencyCommandTrace -Source 'apppath' -LocalDrive $LocalDrive -Entry @($entries)
}

# Where the user's folders are, as Explorer resolves them.
$script:NetworkDependencyShellFolderKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders'

# The folders Windows keeps a second time under a GUID, by the name they have without
# one, so that a redirected folder is one row. Downloads has no other name.
$script:NetworkDependencyShellFolderGuids = @{
    '{F42EE2D3-909F-4907-8871-4C22FC0BF756}' = 'Personal'
    '{754AC886-DF64-4CBA-86B5-F7FBF4FBCEF5}' = 'Desktop'
    '{0DDD015D-B06C-45D5-8C4C-F59713854639}' = 'My Pictures'
    '{A0C69A99-21C8-4671-8703-7934162FCF1D}' = 'My Music'
    '{35286A68-3C57-41A1-BBB1-0EAE73D76C95}' = 'My Video'
    '{374DE290-123F-4565-9164-39C4925E467B}' = 'Downloads'
    '{7D83EE9B-2244-4E70-B1F5-5393042AF1E4}' = 'Downloads'
}

function ConvertTo-NetworkDependencyShellFolderTrace {
    <#
    .SYNOPSIS
        Turns the user's shell folders, as { Name, Path }, into traces. Pure.
    .DESCRIPTION
        Every folder is a trace; the ones on the machine's own drives are dropped where
        every local path is. The application is the folder's name in the registry, which
        the Report translates.
    #>

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

    foreach ($item in @($Folder)) {
        if ($null -eq $item) { continue }
        $name = "$(Get-DataProperty $item 'Name')".Trim()
        $path = "$(Get-DataProperty $item 'Path')".Trim()
        if (-not $name -or -not $path) { continue }
        if ($script:NetworkDependencyShellFolderGuids.ContainsKey($name)) { $name = $script:NetworkDependencyShellFolderGuids[$name] }

        New-NetworkDependencyTrace -Application $name -Path $path -Kind 'profile' -Source 'folder-redirection'
    }
}

function Get-NetworkDependencyShellFolderTrace {
    <#
    .SYNOPSIS
        Source: folder redirection - Documents, Desktop, AppData and the like on a Server.
    #>

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

    $problems = @()
    $properties = Get-ItemProperty -Path $script:NetworkDependencyShellFolderKey -ErrorAction SilentlyContinue -ErrorVariable problems
    if (-not $properties) {
        Add-NetworkDependencyUnread -Unexamined $Unexamined -What 'User Shell Folders' -Problem $problems
        return
    }

    ConvertTo-NetworkDependencyShellFolderTrace -Folder @($properties.PSObject.Properties |
        Where-Object { $_.Name -notlike 'PS*' } |
        ForEach-Object { [pscustomobject]@{ Name = "$($_.Name)"; Path = "$($_.Value)" } })
}

function ConvertTo-NetworkDependencyOfflineFilesTrace {
    <#
    .SYNOPSIS
        Turns the shares Offline Files keeps a copy of, as { ItemPath }, into traces. Pure.
    .DESCRIPTION
        A share that is kept offline goes on working when its Server is gone, from the
        copy, and the user notices nothing until the two disagree. That is a dependency
        of the profile kind which hides its own failures, and the Report says so.
    #>

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

    foreach ($row in @($Item)) {
        if ($null -eq $row) { continue }
        $path = "$(Get-DataProperty $row 'ItemPath')".Trim()
        if (-not $path) { continue }
        New-NetworkDependencyTrace -Application 'offline-files' -Path $path -Kind 'profile' -Source 'offline-files'
    }
}

function Get-NetworkDependencyOfflineFilesTrace {
    <#
    .SYNOPSIS
        Source: Offline Files (CSC), when it is switched on - the shares it keeps a copy of.
    .DESCRIPTION
        Asked of WMI, which answers without admin rights (observed on Windows 11:
        Win32_OfflineFilesCache with Enabled and Active false). A Windows without the
        class has no Offline Files, which is an answer and not a gap. The shares are only
        asked for when the cache is active: with it off the provider refuses the query.
 
        Not verified on a machine with Offline Files switched on; the query for the
        shares is bounded so that a large cache cannot hold up the Run.
    #>

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

    $problems = @()
    $cache = Get-CimInstance -ClassName Win32_OfflineFilesCache -ErrorAction SilentlyContinue -ErrorVariable problems |
        Select-Object -First 1
    if (-not $cache) {
        $missing = @($problems | Where-Object { "$($_.Exception.NativeErrorCode)" -match 'InvalidClass|InvalidNamespace' })
        if (@($problems).Count -and -not $missing.Count) {
            Add-NetworkDependencyUnread -Unexamined $Unexamined -What 'Win32_OfflineFilesCache' -Problem $problems
        }
        return
    }
    if (-not ($cache.Enabled -and $cache.Active)) { return }

    # ItemType 2 is a share, as Win32_OfflineFilesItem numbers them.
    $problems = @()
    $shares = @(Get-CimInstance -Query 'SELECT ItemPath FROM Win32_OfflineFilesItem WHERE ItemType = 2' `
        -OperationTimeoutSec 20 -ErrorAction SilentlyContinue -ErrorVariable problems)
    if (@($problems).Count) { Add-NetworkDependencyUnread -Unexamined $Unexamined -What 'Win32_OfflineFilesItem' -Problem $problems }

    ConvertTo-NetworkDependencyOfflineFilesTrace -Item @($shares | ForEach-Object { [pscustomobject]@{ ItemPath = "$($_.ItemPath)" } })
}

function ConvertTo-NetworkDependencyPrinterTrace {
    <#
    .SYNOPSIS
        Turns printers, as { Name, ServerName, ShareName }, into traces. Pure.
    .DESCRIPTION
        Only a printer that is connected through a print server: its queue, its driver
        and its settings come from there, and every print dialog waits for it. A printer
        with its own port depends on no Server.
    #>

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

    foreach ($item in @($Printer)) {
        if ($null -eq $item) { continue }
        $server = "$(Get-DataProperty $item 'ServerName')".Trim().TrimStart('\').Trim()
        # A printer reached over IPP names a URL here, which is no Server to measure.
        if ($server -notmatch '^[\w.\-]+$') { continue }

        $name  = "$(Get-DataProperty $item 'Name')".Trim()
        $share = "$(Get-DataProperty $item 'ShareName')".Trim()

        $application = $name
        $path        = ''
        if ($name -match '^\\\\[^\\]+\\(.+)$') {
            $application = $Matches[1]
            $path        = $name
        }
        elseif ($share) { $path = '\\{0}\{1}' -f $server, $share }

        New-NetworkDependencyTrace -Application $application -Path $path -Kind 'service' -Source 'printer' `
            -Server $server -Port $script:NetworkDependencySmbPort
    }
}

function Get-NetworkDependencyPrinterTrace {
    <#
    .SYNOPSIS
        Source: the printers that are connected through a print server.
    .PARAMETER Printing
        What the Printing Check gathered, when it ran before this one. Its printers are
        then used and nothing is read a second time.
    #>

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

    if (Test-NetworkDependencyProperty -InputObject $Printing -Name 'Printers') {
        $printers = (Get-DataCollection $Printing 'Printers')
    }
    else {
        $problems = @()
        $printers = @(Get-CimInstance -ClassName Win32_Printer -Property Name, ServerName, ShareName `
            -ErrorAction SilentlyContinue -ErrorVariable problems)
        if (@($problems).Count) { Add-NetworkDependencyUnread -Unexamined $Unexamined -What 'Win32_Printer' -Problem $problems }
    }

    ConvertTo-NetworkDependencyPrinterTrace -Printer @($printers)
}

# The drivers that speak to a SQL Server, by the file or the name a data source records.
# Only for these is the default port known when the data source names none.
$script:NetworkDependencySqlServerDriverPattern = 'sqlsrv32|sqlncli|msodbcsql|SQL Server|SQL Native Client'

function ConvertTo-NetworkDependencyOdbcTrace {
    <#
    .SYNOPSIS
        Turns ODBC data sources, as { Dsn, Server, Port, Driver }, into traces. Pure.
    .DESCRIPTION
        A data source names a Server and no path. The Server value is read by the Odbc
        Kind's parser, which knows "tcp:host,1433" and "host\INSTANCE" and drops a data
        source that points at this machine.
 
        The port is the one the Server value names, else the one the data source names,
        else 1433 for a SQL Server driver and a default instance. A named instance
        negotiates its port, and a driver nobody knows has none to guess: the trace then
        carries no port, and the Server is resolved and pinged but not connected to.
    #>

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

    foreach ($item in @($DataSource)) {
        if ($null -eq $item) { continue }
        $target = ConvertTo-OdbcServerTarget -Server "$(Get-DataProperty $item 'Server')"
        if (-not $target) { continue }

        $port   = $null
        $stated = ConvertTo-Number (Get-DataProperty $item 'Port')
        if ($target.Port) { $port = [int]$target.Port }
        elseif ($null -ne $stated -and $stated -ge 1) { $port = [int]$stated }
        elseif (-not $target.DynamicPort -and "$(Get-DataProperty $item 'Driver')" -match $script:NetworkDependencySqlServerDriverPattern) {
            $port = $script:OdbcDefaultSqlPort
        }

        # "LxPostgreSQL" names no program a Technician knows. Whose driver it is does,
        # where the driver came with a program and not with Windows.
        $name  = "$(Get-DataProperty $item 'Dsn')"
        $owner = "$(Get-DataProperty $item 'Owner')"
        if ($owner) { $name = (Get-Text 'Value.NetworkDependency.OdbcOwned') -f $owner, $name }

        New-NetworkDependencyTrace -Application $name -Kind 'database' -Source 'odbc' `
            -Server $target.HostName -Port $port
    }
}

function ConvertTo-NetworkDependencyOdbcOwner {
    <#
    .SYNOPSIS
        Whose ODBC driver a file is, from where it is installed and who made it. Pure.
    .DESCRIPTION
        A driver a program brought along is installed in that program's folder, and the
        folder under Program Files names the product or its maker. A driver in the
        Windows folder came with Windows or was installed for everybody: any program may
        use it, and it says nothing about which one does. Then there is no owner.
    #>

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

    if (-not $Path -or $Path -match '(?i)^[a-z]:\\Windows\\') { return '' }

    $folder = ''
    if ($Path -match '(?i)\\Program Files(?: \(x86\))?\\(?:Common Files\\)?(?<folder>[^\\]+)\\') { $folder = $Matches['folder'].Trim() }

    $maker = "$Company".Trim()
    # A driver Microsoft installs for everybody, outside the Windows folder.
    if ($maker -match '(?i)^Microsoft' -and (-not $folder -or $folder -match '(?i)^Microsoft')) { return '' }

    if ($folder) { return $folder }
    $maker
}

function Get-NetworkDependencyOdbcOwner {
    <#
    .SYNOPSIS
        Finds a data source's driver file and says whose it is. Nothing where the file is
        not found.
    .PARAMETER Driver
        What the data source names as its driver: the file, or the driver's name.
    .PARAMETER Root
        The registry key the data source was read under. A driver named by its name is
        looked up beside it, on the same side of the 32-bit registry.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Driver, [AllowNull()][AllowEmptyString()][string]$Root)

    if (-not $Driver) { return '' }
    try {
        $file = [Environment]::ExpandEnvironmentVariables($Driver)
        if ($file -notmatch '\\') {
            $file = ''
            $installed = @(($Root -replace '(?i)ODBC\.INI$', 'ODBCINST.INI'), 'HKLM:\SOFTWARE\ODBC\ODBCINST.INI', 'HKLM:\SOFTWARE\WOW6432Node\ODBC\ODBCINST.INI') |
                Where-Object { $_ -and $_ -notmatch '^HKCU' } | Select-Object -Unique
            foreach ($key in $installed) {
                $entry = Get-ItemProperty -LiteralPath (Join-Path $key $Driver) -Name Driver -ErrorAction SilentlyContinue
                if ($entry -and $entry.Driver) { $file = [Environment]::ExpandEnvironmentVariables("$($entry.Driver)"); break }
            }
        }
        if (-not $file) { return '' }

        $company = ''
        if (Test-Path -LiteralPath $file) { $company = "$((Get-Item -LiteralPath $file -ErrorAction Stop).VersionInfo.CompanyName)" }
        ConvertTo-NetworkDependencyOdbcOwner -Path $file -Company $company
    }
    catch { '' }
}

function Get-NetworkDependencyOdbcTrace {
    <#
    .SYNOPSIS
        Source: the ODBC data sources that name a Server.
    .DESCRIPTION
        Read from the registry here and not taken from the Odbc Check: several Checks of
        that Kind may run, each for another application, and what an earlier Check
        gathered is keyed by Kind, so they overwrite each other. The keys are the Odbc
        Kind's. A machine without data sources has no such keys, which is no gap.
 
        psqlODBC writes Servername where the others write Server.
    #>

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

    $sources = foreach ($root in $script:OdbcDataSourceKeys) {
        foreach ($key in @(Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {
            if ($key.PSChildName -eq 'ODBC Data Sources') { continue }
            $driver = "$($key.GetValue('Driver'))"
            $server = "$($key.GetValue('Server'))"
            if (-not $server -and $driver -match 'psql') { $server = "$($key.GetValue('Servername'))" }
            if (-not $server) { continue }
            [pscustomobject]@{
                Dsn    = "$($key.PSChildName)"
                Server = $server
                Port   = $key.GetValue('Port')
                Driver = $driver
                Owner  = Get-NetworkDependencyOdbcOwner -Driver $driver -Root $root
            }
        }
    }

    ConvertTo-NetworkDependencyOdbcTrace -DataSource @($sources)
}

# How much of the configuration files beside the executables is read: no file above this
# size, no more .ini files per directory and no more files in a Run than these. Not Check
# Definition parameters, for the reason Private/Sampling.ps1 gives: they bound what a Run
# costs, and decide nothing about what it finds.
$script:NetworkDependencyConfigMaxBytes        = 1MB

$script:NetworkDependencyConfigMaxPerDirectory = 20

$script:NetworkDependencyConfigMaxFiles        = 100

# \\server\share in a file. The Server begins with a letter or a digit and the pattern
# does not begin inside a run of backslashes, so that neither a device path (\\.\pipe) nor
# a doubled backslash from an escaped string or a regular expression is taken for one.
$script:NetworkDependencyConfigUncPattern =
    '(?<![\\\w])\\\\(?<host>[A-Za-z0-9][\w.\-]*(?:@[A-Za-z0-9]+)*)\\(?<share>[^\\/:*?"<>|\r\n\t;=,'']+)'

# The Server of a connection string, and the Server= line of an .ini. The key stands at
# the beginning of a line or after a separator: SmtpServer= and ProxyServer= are not it.
$script:NetworkDependencyConfigServerPattern =
    '(?im)(?:^|[;"''\s])[ \t]*(?:Data Source|Server|Addr|Address|Network Address)[ \t]*=[ \t]*(?<value>[^;"''<>\r\n]*)'

# Values of Server= that are a switch and no name, and the endings of a Data Source that
# is a file.
$script:NetworkDependencyConfigNoHostPattern = '^(?:true|false|yes|no|on|off|none|null|default|auto|local|ja|nein)$'

$script:NetworkDependencyConfigFilePattern   = '\.(?:db|db3|sqlite|sqlite3|mdb|accdb|mdf|sdf|fdb|gdb|dbf|xml|ini|txt|dat|udl|dsn|csv|xls|xlsx)$'

function Test-NetworkDependencyLocalName {
    <#
    .SYNOPSIS
        Whether a Server name stands for this machine. Pure: the machine's name is an argument.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [AllowNull()][AllowEmptyString()][string]$Server,
        [AllowNull()][AllowEmptyString()][string]$ComputerName
    )

    $name = "$Server".Trim().TrimEnd('.')
    if (-not $name) { return $false }
    if ($name -in '.', 'localhost', '127.0.0.1', '::1', '(local)') { return $true }

    $own = "$ComputerName".Trim()
    if (-not $own) { return $false }
    ($name -eq $own) -or $name.StartsWith($own + '.', [StringComparison]::OrdinalIgnoreCase)
}

function Get-NetworkDependencyConfigReference {
    <#
    .SYNOPSIS
        The Servers and shares the text of a configuration file names. Pure.
    .DESCRIPTION
        Returns { Kind, Path, Server, Port } and nothing else of the text: a configuration
        file holds user names, passwords and licence keys, and none of that belongs in a
        Report. A share is cut down to \\server\share and is a data dependency. The Server
        of a connection string is a database dependency without a path; its port is the
        one the string names, else none.
 
        A name is only taken when it can be one: what points at this machine, a file, a
        switch (Server=true) or a URL is left out. A share name with a blank in it is
        only kept whole when the path goes on behind it; at the end of a value nobody
        can tell where the name ends, and the first word is taken.
    #>

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

    if (-not $Text) { return }
    $seen = @{}

    foreach ($match in [regex]::Matches($Text, $script:NetworkDependencyConfigUncPattern)) {
        $hostPart = $match.Groups['host'].Value
        $share    = $match.Groups['share'].Value

        $after = $match.Index + $match.Length
        if (-not ($after -lt $Text.Length -and $Text[$after] -eq '\')) { $share = @($share.Trim() -split '\s')[0] }
        $share = $share.Trim()

        $server = @($hostPart -split '@')[0]
        if (-not $share -or $server.Length -lt 2) { continue }
        if (Test-NetworkDependencyLocalName -Server $server -ComputerName $ComputerName) { continue }

        $path = '\\{0}\{1}' -f $hostPart, $share
        $key  = ('data|{0}' -f $path).ToUpperInvariant()
        if ($seen.ContainsKey($key)) { continue }
        $seen[$key] = $true

        [pscustomobject]@{ Kind = 'data'; Path = $path; Server = $server; Port = $null }
    }

    foreach ($match in [regex]::Matches($Text, $script:NetworkDependencyConfigServerPattern)) {
        $target = ConvertTo-OdbcServerTarget -Server $match.Groups['value'].Value
        if (-not $target) { continue }

        $server = "$($target.HostName)"
        if ($server.Length -lt 2 -or $server -notmatch '^[A-Za-z0-9][A-Za-z0-9._\-]*$') { continue }
        if ($server -notmatch '[A-Za-z]' -and $server -notmatch '^\d{1,3}(\.\d{1,3}){3}$') { continue }
        if ($server -match $script:NetworkDependencyConfigNoHostPattern -or $server -match $script:NetworkDependencyConfigFilePattern) { continue }
        if (Test-NetworkDependencyLocalName -Server $server -ComputerName $ComputerName) { continue }

        $port = $null
        if ($target.Port) { $port = [int]$target.Port }

        $key = ('database|{0}|{1}' -f $server, $port).ToUpperInvariant()
        if ($seen.ContainsKey($key)) { continue }
        $seen[$key] = $true

        [pscustomobject]@{ Kind = 'database'; Path = ''; Server = $server; Port = $port }
    }
}

function Select-NetworkDependencyExecutable {
    <#
    .SYNOPSIS
        The dependencies that are an executable on a share: where configuration files are
        searched. Pure.
    .DESCRIPTION
        A program, found as itself and not as a module some other program loaded or as a
        name out of a configuration file, whose path ends in .exe.
    #>

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

    foreach ($row in @($Dependency)) {
        if ($null -eq $row) { continue }
        if ("$(Get-DataProperty $row 'Kind')" -ne 'program') { continue }
        if ("$(Get-DataProperty $row 'Source')" -in 'module', 'config') { continue }
        if ("$(Get-DataProperty $row 'UncPath')" -notmatch '[\\/][^\\/]+\.exe$') { continue }
        $row
    }
}

function Get-NetworkDependencyConfigTrace {
    <#
    .SYNOPSIS
        Source: the Servers and shares named in <exe>.config and in the .ini files beside
        an executable that is already in the inventory.
    .DESCRIPTION
        Only there, and only those files: a search for configuration files across a share
        is not something a Run may do on somebody else's file server. Bounded in size and
        number; what the bounds left out is counted.
 
        The share of a Server that did not answer is not touched - opening a path there
        waits for a timeout per file - and is named as not read.
    .PARAMETER Server
        The measurements of the Servers the executables are on.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()]$Unexamined,
        [AllowNull()][AllowEmptyCollection()]$Dependency,
        [AllowNull()][AllowEmptyCollection()]$Server,
        [AllowNull()][AllowEmptyString()][string]$ComputerName
    )

    $done    = @{}
    $listed  = @{}
    $unread  = @{}
    $read    = 0
    $skipped = 0

    foreach ($program in @(Select-NetworkDependencyExecutable -Dependency $Dependency)) {
        $unc = "$(Get-DataProperty $program 'UncPath')"
        if ($done.ContainsKey($unc.ToUpperInvariant())) { continue }
        $done[$unc.ToUpperInvariant()] = $true

        $directory = $unc.Substring(0, $unc.LastIndexOfAny([char[]]'\/'))
        $place     = $directory.ToUpperInvariant()

        $row = Select-NetworkDependencyServerRow -Server $Server -Dependency $program
        if ($null -eq $row -or (Test-NetworkDependencyServerDown -Server $row)) {
            if (-not $unread.ContainsKey($place)) {
                $unread[$place] = $true
                $Unexamined.Add((New-NetworkDependencyUnexamined -What $directory -Reason 'ConfigNotReadable'))
            }
            continue
        }

        # Not List[object]: @($x) throws on one of those. See Public/Invoke-Gutcheck.ps1.
        $files = [System.Collections.Generic.List[psobject]]::new()

        # Most programs have none, which is no gap.
        $own = Get-Item -LiteralPath ($unc + '.config') -ErrorAction SilentlyContinue
        if ($own) { $files.Add($own) }

        if (-not $listed.ContainsKey($place)) {
            $listed[$place] = $true
            $problems = @()
            $ini = @(Get-ChildItem -LiteralPath $directory -Filter '*.ini' -File -ErrorAction SilentlyContinue -ErrorVariable problems |
                Where-Object { $_.Extension -eq '.ini' } | Sort-Object Name)
            if (@($problems).Count -and -not $unread.ContainsKey($place)) {
                $unread[$place] = $true
                $Unexamined.Add((New-NetworkDependencyUnexamined -What $directory -Reason 'ConfigNotReadable'))
            }
            if ($ini.Count -gt $script:NetworkDependencyConfigMaxPerDirectory) {
                $skipped += $ini.Count - $script:NetworkDependencyConfigMaxPerDirectory
            }
            foreach ($file in ($ini | Select-Object -First $script:NetworkDependencyConfigMaxPerDirectory)) { $files.Add($file) }
        }

        foreach ($file in $files) {
            if ($file.Length -gt $script:NetworkDependencyConfigMaxBytes -or $read -ge $script:NetworkDependencyConfigMaxFiles) {
                $skipped++
                continue
            }
            $read++

            $problems = @()
            $text = Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue -ErrorVariable problems
            if (@($problems).Count) {
                if (-not $unread.ContainsKey($place)) {
                    $unread[$place] = $true
                    $Unexamined.Add((New-NetworkDependencyUnexamined -What $directory -Reason 'ConfigNotReadable'))
                }
                continue
            }

            # Only what the reference holds leaves this function: nothing of $text does.
            foreach ($reference in @(Get-NetworkDependencyConfigReference -Text "$text" -ComputerName $ComputerName)) {
                New-NetworkDependencyTrace -Application "$(Get-DataProperty $program 'Application')" -Path $reference.Path `
                    -Kind $reference.Kind -Source 'config' -Server $reference.Server -Port $reference.Port
            }
        }
    }

    if ($skipped) {
        $Unexamined.Add((New-NetworkDependencyUnexamined -What 'configuration files' -Reason 'ConfigSkipped' -Count $skipped))
    }
}