Private/Kinds/DotNet.ps1

# The DotNet Kind: which .NET is installed, and whether its patch level can explain a crash.
#
# A .NET program that crashes raises the same question every time: is the runtime out of
# date? Answering it by hand means reading a registry value against a table on Microsoft
# Learn and comparing a file version with the one in the crash event. This Kind does both,
# so that a second-level Technician can rule the patch level in or out (#27, user story 22)
# without opening either.
#
# Three things are read, because .NET is three things on a Windows PC:
#
# - .NET Framework 4.x, the one Windows ships and services. Its Version and Release are in
# HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full, and Release is the number
# Microsoft documents for telling versions apart:
# https://learn.microsoft.com/en-us/dotnet/framework/install/how-to-determine-which-versions-are-installed
# (read 2026-10-07). "If the Full subkey is missing, then .NET Framework 4.5 or above
# isn't installed."
# - The CLR, which that page says "is versioned separately". The file version of clr.dll
# is the version an Application Error 1000 names as the faulting module version when a
# crash happens inside the runtime, which is what makes the two comparable.
# - .NET (Core) shared frameworks, installed per application need and absent on many
# machines. The installer registers them under
# SOFTWARE\dotnet\Setup\InstalledVersions\<architecture>\sharedfx\<framework>, one value
# per version. Observed on this development machine (Windows 11 26300, .NET 8.0.31,
# 2026-10-07): the x64 frameworks are registered in the 32-bit view of the registry
# (SOFTWARE\WOW6432Node\dotnet\...), and the 64-bit view holds only sharedhost. So both
# views are read for every architecture, and a row found in both is listed once.
#
# The crash rows are not read here. They are the Stability Check's, handed over through
# -Observed when it ran before this one.

# Where the installer registers shared frameworks: both registry views, see above.
$script:DotNetSharedFrameworkRoots = @(
    'HKLM:\SOFTWARE\dotnet\Setup\InstalledVersions'
    'HKLM:\SOFTWARE\WOW6432Node\dotnet\Setup\InstalledVersions'
)
$script:DotNetArchitectures = @('x64', 'x86', 'arm64')

function Get-DotNetData {
    <#
    .SYNOPSIS
        The installed .NET versions, and the CLR versions crash events named. Decides nothing.
    .PARAMETER Observed
        What the Checks before this one gathered, keyed by Kind. The crash rows come from
        the Stability Check. When it did not run, CrashClrVersions is $null, which is a
        different fact from an empty list: no crash named the CLR.
    #>

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

    # A machine without .NET Framework 4.5+ has no such key. Asked first: that is an
    # answer, not an error, and must not leave a line in the transcript.
    $framework = $null
    $fullKey   = 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full'
    if (Test-Path -LiteralPath $fullKey) {
        $full = Get-ItemProperty -LiteralPath $fullKey -ErrorAction SilentlyContinue
        if ($full) {
            $framework = [pscustomobject]@{
                Version = Get-DataProperty $full 'Version'
                Release = Get-DataProperty $full 'Release'
            }
        }
    }

    $frameworks = New-Object 'System.Collections.Generic.List[psobject]'
    $seen       = @{}
    foreach ($root in $script:DotNetSharedFrameworkRoots) {
        foreach ($architecture in $script:DotNetArchitectures) {
            $sharedfx = Join-Path (Join-Path $root $architecture) 'sharedfx'
            if (-not (Test-Path -LiteralPath $sharedfx)) { continue }

            foreach ($key in @(Get-ChildItem -LiteralPath $sharedfx -ErrorAction SilentlyContinue)) {
                # One value per installed version, named for the version.
                foreach ($version in @($key.GetValueNames() | Where-Object { $_ })) {
                    $identity = '{0}|{1}|{2}' -f $key.PSChildName, $version, $architecture
                    if ($seen.ContainsKey($identity)) { continue }
                    $seen[$identity] = $true
                    $frameworks.Add([pscustomobject]@{
                        Name         = $key.PSChildName
                        Version      = $version
                        Architecture = $architecture
                    })
                }
            }
        }
    }

    # Which crashes happened inside the runtime is a fact about the event, not a verdict:
    # the faulting module is named clr.dll or it is not.
    $crashClrVersions = $null
    if ($Observed -and $Observed.ContainsKey('Stability') -and $null -ne $Observed['Stability']) {
        $crashClrVersions = @((Get-DataCollection $Observed['Stability'] 'Crashes') |
            Where-Object { "$(Get-DataProperty $_ 'Module')".Trim() -eq 'clr.dll' -and "$(Get-DataProperty $_ 'ModuleVer')".Trim() } |
            ForEach-Object {
                [pscustomobject]@{
                    Time    = Get-DataProperty $_ 'Time'
                    Version = "$(Get-DataProperty $_ 'ModuleVer')".Trim()
                }
            })
    }

    [pscustomobject]@{
        PSTypeName       = 'Gutcheck.Data.DotNet'
        Framework        = $framework
        ClrFileVersion   = Get-ClrFileVersion -Directory 'Framework64'
        ClrFileVersion32 = Get-ClrFileVersion -Directory 'Framework'
        SharedFrameworks = @($frameworks | Sort-Object Name, Architecture, Version)
        CrashClrVersions = $crashClrVersions
    }
}

function Get-ClrFileVersion {
    <#
    .SYNOPSIS
        The file version of the .NET Framework 4 clr.dll, or $null when it is not there.
    .DESCRIPTION
        Built from the numeric parts: the FileVersion text carries the build lab after the
        number ("4.8.9345.0 built by: NET481REL1LAST_C"), and a crash event names the
        number alone.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][ValidateSet('Framework64', 'Framework')][string]$Directory)

    if (-not $env:WINDIR) { return $null }
    $path = Join-Path $env:WINDIR ('Microsoft.NET\{0}\v4.0.30319\clr.dll' -f $Directory)

    # A 32-bit Windows has no Framework64, and a machine without .NET Framework 4 neither.
    if (-not (Test-Path -LiteralPath $path)) { return $null }

    $item = Get-Item -LiteralPath $path -ErrorAction SilentlyContinue
    if (-not $item -or -not $item.VersionInfo) { return $null }
    $info = $item.VersionInfo
    '{0}.{1}.{2}.{3}' -f $info.FileMajorPart, $info.FileMinorPart, $info.FileBuildPart, $info.FilePrivatePart
}

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

    New-DotNetFrameworkFinding       -Data $Data -Parameters $Parameters
    New-DotNetSharedFrameworkFinding -Data $Data -Parameters $Parameters
    New-DotNetCrashClrFinding        -Data $Data -Parameters $Parameters
}

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

    # The lowest Release that is .NET Framework 4.8, from the "Minimum version" table of
    # https://learn.microsoft.com/en-us/dotnet/framework/install/how-to-determine-which-versions-are-installed
    # (read 2026-10-07): "test for a Release REG_DWORD value that's greater than or equal
    # to 528040". 4.8.1 is 533320 by the same table.
    #
    # 4.8 and not 4.8.1 on purpose. 4.8 is what Windows 10 from 1903 on and Windows 11 bring
    # with them, so a machine below it has an operating system that old or a .NET nobody
    # updated - worth a WARN. 4.8.1 comes with Windows 11 22H2 and later only; on Windows 10
    # it is an optional update, and warning there would warn on machines that are patched
    # as far as Windows Update takes them. A Customer who wants 4.8.1 writes 533320 into
    # the Check Definition.
    $minimum = ConvertTo-Number (Get-Parameter $Parameters 'MinimumFrameworkRelease' 528040)

    $check     = Get-Text 'Check.DotNet.Framework'
    $framework = Get-DataProperty $Data 'Framework'
    $version   = "$(Get-DataProperty $framework 'Version')".Trim()
    $release   = ConvertTo-Number (Get-DataProperty $framework 'Release')
    $clr       = Get-DotNetInstalledClrText -Data $Data

    if (-not $version -and $null -eq $release) {
        # No key: .NET Framework 4.5 or later is not installed, by the page above. Said
        # rather than passed over, because a .NET program on this machine cannot start.
        return New-Finding -Category System -Check $check -Severity INFO `
            -Value (Get-Text 'Value.DotNet.FrameworkNotFound') -Hint (Get-Text 'Hint.DotNet.FrameworkNotFound')
    }

    if ($null -eq $release) {
        # A version without a release number cannot be weighed against the minimum, and a
        # gap is never a clean result.
        return New-Finding -Category System -Check $check -Severity INFO `
            -Value (Get-Text 'Value.DotNet.FrameworkWithoutRelease' $version $clr) `
            -Hint (Get-Text 'Hint.DotNet.ReleaseUnreadable')
    }

    if ($null -ne $minimum -and $release -lt $minimum) {
        return New-Finding -Category System -Check $check -Severity WARN `
            -Value (Get-Text 'Value.DotNet.FrameworkBelowMinimum' $version $release $clr $minimum) `
            -Hint (Get-Text 'Hint.DotNet.FrameworkBelowMinimum')
    }

    New-Finding -Category System -Check $check -Severity INFO `
        -Value (Get-Text 'Value.DotNet.Framework' $version $release $clr)
}

function Get-DotNetInstalledClrText {
    <#
    .SYNOPSIS
        The installed CLR file version as the Report shows it. Pure.
    .DESCRIPTION
        The 64-bit and the 32-bit clr.dll are serviced together and normally agree, so one
        number is shown. Where they differ both are, because a 32-bit program crashes with
        the 32-bit one.
    #>

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

    $clr64 = "$(Get-DataProperty $Data 'ClrFileVersion')".Trim()
    $clr32 = "$(Get-DataProperty $Data 'ClrFileVersion32')".Trim()

    if ($clr64 -and $clr32 -and -not (Test-DotNetVersionEqual $clr64 $clr32)) {
        return (Get-Text 'Value.DotNet.ClrBoth' $clr64 $clr32)
    }
    if ($clr64) { return $clr64 }
    if ($clr32) { return $clr32 }
    Get-Text 'Value.DotNet.ClrUnknown'
}

function Test-DotNetVersionEqual {
    <#
    .SYNOPSIS
        Whether two version texts name the same version. Pure.
    .DESCRIPTION
        As versions where both read as one, so that 4.8.9345.0 and 4.8.9345.00 agree;
        as text otherwise.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()][string]$Left, [AllowNull()][string]$Right)

    $a = "$Left".Trim()  -as [version]
    $b = "$Right".Trim() -as [version]
    if ($a -and $b) { return $a -eq $b }
    "$Left".Trim() -eq "$Right".Trim()
}

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

    # Never graded: which .NET (Core) an application needs is the application's business,
    # and none at all is how most machines are. Listed so that "the program needs .NET 8"
    # can be answered from the Report.
    $rows = Get-DataCollection $Data 'SharedFrameworks'
    if (-not $rows.Count) {
        return New-Finding -Category System -Check (Get-Text 'Check.DotNet.SharedFrameworks') -Severity INFO `
            -Value (Get-Text 'Value.DotNet.NoSharedFrameworks')
    }

    $described = @($rows | ForEach-Object {
        '{0} {1} ({2})' -f (Get-DataProperty $_ 'Name'), (Get-DataProperty $_ 'Version'), (Get-DataProperty $_ 'Architecture')
    })
    New-Finding -Category System -Check (Get-Text 'Check.DotNet.SharedFrameworks') -Severity INFO `
        -Value ($described -join ', ')
}

function ConvertTo-DotNetTime {
    <#
    .SYNOPSIS
        A crash row's time as a date, or the earliest date there is when it is none. Pure.
    .DESCRIPTION
        A time arrives as a date from the Gatherer and as text from a fixture or a CliXML
        round trip. A row whose time cannot be read still names a CLR version, so it is
        kept and sorts first - it must not become the "last" crash by accident.
    #>

    [CmdletBinding()]
    [OutputType([datetime])]
    param([AllowNull()]$Value)

    if ($Value -is [datetime]) { return $Value }
    $parsed = [datetime]::MinValue
    if ($null -ne $Value -and [datetime]::TryParse(
            "$Value", [Globalization.CultureInfo]::InvariantCulture,
            [Globalization.DateTimeStyles]::None, [ref]$parsed)) {
        return $parsed
    }
    [datetime]::MinValue
}

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

    # Only crashes that name a CLR version say anything here. None - because nothing
    # crashed inside the runtime, or because the Stability Check did not run - is no
    # sentence at all: there is nothing to compare.
    $rows = @((Get-DataCollection $Data 'CrashClrVersions') |
        Where-Object { "$(Get-DataProperty $_ 'Version')".Trim() })
    if (-not $rows.Count) { return }

    $last = $null
    $lastTime = [datetime]::MinValue
    foreach ($row in $rows) {
        $time = ConvertTo-DotNetTime (Get-DataProperty $row 'Time')
        if ($null -eq $last -or $time -gt $lastTime) { $last = $row; $lastTime = $time }
    }
    $crashed = "$(Get-DataProperty $last 'Version')".Trim()

    $check = Get-Text 'Check.DotNet.CrashClr'
    $clr64 = "$(Get-DataProperty $Data 'ClrFileVersion')".Trim()
    $clr32 = "$(Get-DataProperty $Data 'ClrFileVersion32')".Trim()
    $installed = @($clr64, $clr32 | Where-Object { $_ })

    if (-not $installed.Count) {
        return New-Finding -Category System -Check $check -Severity INFO `
            -Value (Get-Text 'Value.DotNet.CrashClrInstalledUnknown' $crashed) `
            -Hint (Get-Text 'Hint.DotNet.CrashClrInstalledUnknown')
    }

    # The event does not say whether the crashed process was 32-bit, so a match with
    # either installed clr.dll is a match.
    $same = @($installed | Where-Object { Test-DotNetVersionEqual $_ $crashed })
    if ($same.Count) {
        return New-Finding -Category System -Check $check -Severity INFO `
            -Value (Get-Text 'Value.DotNet.CrashClrSame' $crashed $same[0]) `
            -Hint (Get-Text 'Hint.DotNet.CrashClrSame')
    }

    # Worded neutrally on purpose: a different version now says the runtime changed since
    # the crash, not that the old one caused it or that the new one cured it.
    New-Finding -Category System -Check $check -Severity INFO `
        -Value (Get-Text 'Value.DotNet.CrashClrDifferent' $crashed (Get-DotNetInstalledClrText -Data $Data)) `
        -Hint (Get-Text 'Hint.DotNet.CrashClrDifferent')
}

function ConvertTo-DotNetSection {
    <#
    .SYNOPSIS
        The CLR versions crash events named, beside the installed one. Evidence, no judgement.
    #>

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

    $rows = @((Get-DataCollection $Data 'CrashClrVersions') |
        Where-Object { "$(Get-DataProperty $_ 'Version')".Trim() })
    if (-not $rows.Count) { return }

    # The headings are German like the rest of the Report, so the rows are built with the
    # names the table is to show.
    $crashClr     = Get-Text 'Column.DotNet.CrashClr'
    $count        = Get-Text 'Column.DotNet.Count'
    $first        = Get-Text 'Column.DotNet.First'
    $last         = Get-Text 'Column.DotNet.Last'
    $installedClr = Get-Text 'Column.DotNet.InstalledClr'

    $installed = Get-DotNetInstalledClrText -Data $Data
    New-Section -Title (Get-Text 'Title.DotNet.CrashClrVersions') -Row @(
        # Sorted on the times and named afterwards: the version that crashed last first.
        $rows | Group-Object { "$(Get-DataProperty $_ 'Version')".Trim() } | ForEach-Object {
            $times = @($_.Group | ForEach-Object { ConvertTo-DotNetTime (Get-DataProperty $_ 'Time') } | Sort-Object)
            [pscustomobject]@{ Version = $_.Name; Count = $_.Count; First = $times[0]; Last = $times[-1] }
        } | Sort-Object Last -Descending | ForEach-Object {
            $row = [ordered]@{}
            $row[$crashClr]     = $_.Version
            $row[$count]        = $_.Count
            $row[$first]        = $_.First
            $row[$last]         = $_.Last
            $row[$installedClr] = $installed
            [pscustomobject]$row
        }
    )
}