Private/ComRegistry.ps1

# COM registration inspector. Cross-references the CoClasses declared in
# the DLL's embedded TypeLib against what is actually registered under
# HKCR\CLSID, using Microsoft.Win32.RegistryKey directly (the PowerShell
# registry provider is too slow for a full HKCR\CLSID walk — ~30k keys).
#
# Strictly read-only: nothing is registered, no LoadLibrary is performed,
# the DLL is never loaded into the process. Works without admin rights.

# Microsoft.Win32.Registry* types exist on non-Windows .NET, but throw
# PlatformNotSupportedException on first use. Detect early and bail out.
function Test-IsWindowsPlatform {
    if ($PSVersionTable.PSEdition -eq 'Desktop') { return $true }
    try { return [bool](Get-Variable -Name IsWindows -ValueOnly -ErrorAction Stop) }
    catch { return $false }
}

# Registry InprocServer32 values may contain %SystemRoot%, surrounding
# quotes, or forward slashes. Normalize so OrdinalIgnoreCase comparison
# against the inspected DLL path actually works.
function ConvertTo-NormalizedPath {
    param([string]$RawPath)
    if ([string]::IsNullOrWhiteSpace($RawPath)) { return $null }
    $p = $RawPath.Trim()
    if ($p.StartsWith('"') -and $p.EndsWith('"') -and $p.Length -ge 2) {
        $p = $p.Substring(1, $p.Length - 2)
    }
    $p = [Environment]::ExpandEnvironmentVariables($p)
    try { $p = [IO.Path]::GetFullPath($p) } catch { }
    return $p
}

# Read (Default) of a subkey if it exists. Returns $null when the value
# is missing or empty. Caller closes the parent; we close the subkey.
function Get-RegDefaultValue {
    param(
        [Microsoft.Win32.RegistryKey]$Parent,
        [string]$SubKeyName
    )
    if (-not $Parent) { return $null }
    $sub = $null
    try {
        $sub = $Parent.OpenSubKey($SubKeyName)
        if (-not $sub) { return $null }
        $v = $sub.GetValue('')
        if ([string]::IsNullOrWhiteSpace($v)) { return $null }
        return [string]$v
    } catch { return $null }
    finally { if ($sub) { $sub.Close() } }
}

# Build the (Hive, View) tuples we want to scan. HKLM and HKCU each in
# Registry64 + Registry32 views — 4 places total. On a 32-bit OS the
# Registry32 view aliases the same hive; harmless duplicates are filtered
# downstream by the (Clsid, Hive, View) key.
function Get-ComRegistryRoots {
    @(
        @{ Hive = 'LocalMachine'; View = 'Registry64'; Label = '64-bit' }
        @{ Hive = 'LocalMachine'; View = 'Registry32'; Label = '32-bit' }
        @{ Hive = 'CurrentUser';  View = 'Registry64'; Label = '64-bit' }
        @{ Hive = 'CurrentUser';  View = 'Registry32'; Label = '32-bit' }
    )
}

# Read the metadata around a single CLSID key: friendly name, ProgID,
# ThreadingModel, InprocServer32 path and TypeLib GUID. Used both by the
# inverse scan (after a path match) and by the direct lookup of CLSIDs
# declared in the TypeLib.
function Read-ClsidEntry {
    param(
        [Microsoft.Win32.RegistryKey]$ClsidKey,
        [string]$Clsid,
        [string]$HiveLabel,
        [string]$ViewLabel
    )
    if (-not $ClsidKey) { return $null }

    $friendly = $null
    try { $friendly = [string]$ClsidKey.GetValue('') } catch { }
    if ([string]::IsNullOrWhiteSpace($friendly)) { $friendly = $null }

    $progId = Get-RegDefaultValue -Parent $ClsidKey -SubKeyName 'ProgID'
    if (-not $progId) {
        $progId = Get-RegDefaultValue -Parent $ClsidKey -SubKeyName 'VersionIndependentProgID'
    }

    $tlbGuid = Get-RegDefaultValue -Parent $ClsidKey -SubKeyName 'TypeLib'

    $inproc = $ClsidKey.OpenSubKey('InprocServer32')
    if (-not $inproc) { return $null }
    try {
        $rawPath = [string]$inproc.GetValue('')
        $threading = [string]$inproc.GetValue('ThreadingModel')
        if ([string]::IsNullOrWhiteSpace($rawPath)) { return $null }
        return [pscustomobject]@{
            Clsid          = $Clsid
            FriendlyName   = $friendly
            ProgId         = $progId
            ThreadingModel = if ($threading) { $threading } else { $null }
            InprocServer32 = $rawPath
            NormalizedPath = ConvertTo-NormalizedPath -RawPath $rawPath
            TypeLib        = $tlbGuid
            Hive           = $HiveLabel
            View           = $ViewLabel
        }
    } finally { $inproc.Close() }
}

# Inverse scan: walk every CLSID in HKLM/HKCU x64+x86 and yield entries
# whose InprocServer32 normalizes to $TargetPath.
function Find-ComRegistrationsForFile {
    param(
        [string]$TargetPath,
        [hashtable]$DeclaredLookup   # CLSID (uppercase, braced) -> $true
    )

    $results = New-Object System.Collections.Generic.List[object]

    foreach ($root in Get-ComRegistryRoots) {
        $base = $null; $clsidRoot = $null
        try {
            $hive = [Microsoft.Win32.RegistryHive]($root.Hive)
            $view = [Microsoft.Win32.RegistryView]($root.View)
            $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, $view)
        } catch { continue }

        try {
            $clsidRoot = $base.OpenSubKey('Software\Classes\CLSID')
            if (-not $clsidRoot) { continue }

            foreach ($name in $clsidRoot.GetSubKeyNames()) {
                $ck = $null
                try {
                    $ck = $clsidRoot.OpenSubKey($name)
                    if (-not $ck) { continue }
                    $entry = Read-ClsidEntry -ClsidKey $ck -Clsid $name `
                                             -HiveLabel $root.Hive -ViewLabel $root.Label
                    if (-not $entry) { continue }
                    if (-not [string]::Equals($entry.NormalizedPath, $TargetPath,
                                              [System.StringComparison]::OrdinalIgnoreCase)) {
                        continue
                    }
                    $clsidUpper = $name.ToUpperInvariant()
                    $isDeclared = $DeclaredLookup.ContainsKey($clsidUpper)
                    $entryStatus = if ($isDeclared) { 'Registered' } else { 'RegisteredOnly' }
                    Add-Member -InputObject $entry -NotePropertyName DeclaredInTypeLib `
                               -NotePropertyValue $isDeclared -Force
                    Add-Member -InputObject $entry -NotePropertyName PathMatchesTarget `
                               -NotePropertyValue $true -Force
                    Add-Member -InputObject $entry -NotePropertyName Status `
                               -NotePropertyValue $entryStatus -Force
                    $results.Add($entry) | Out-Null
                } finally { if ($ck) { $ck.Close() } }
            }
        } finally {
            if ($clsidRoot) { $clsidRoot.Close() }
            if ($base)      { $base.Close() }
        }
    }

    # Comma operator: keep the List intact; otherwise PS unrolls it to Object[].
    , $results
}

# Direct lookup of one CLSID across the 4 views. Used for declared
# CoClasses that the inverse scan did not match: distinguishes "not
# registered at all" (DeclaredOnly) from "registered but pointing
# elsewhere" (PathMismatch).
function Resolve-ClsidEverywhere {
    param([string]$Clsid)

    $found = New-Object System.Collections.Generic.List[object]
    foreach ($root in Get-ComRegistryRoots) {
        $base = $null; $ck = $null
        try {
            $hive = [Microsoft.Win32.RegistryHive]($root.Hive)
            $view = [Microsoft.Win32.RegistryView]($root.View)
            $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, $view)
            $ck = $base.OpenSubKey("Software\Classes\CLSID\$Clsid")
            if (-not $ck) { continue }
            $entry = Read-ClsidEntry -ClsidKey $ck -Clsid $Clsid `
                                     -HiveLabel $root.Hive -ViewLabel $root.Label
            if ($entry) { $found.Add($entry) | Out-Null }
        } catch { }
        finally {
            if ($ck)   { $ck.Close() }
            if ($base) { $base.Close() }
        }
    }
    , $found
}

function Get-ComRegistrationInfo {
    param(
        [Parameter(Mandatory)][string]$FilePath,
        [object]$TypeLibInfo  # output of Get-TypeLibInfoSafe; may be $null
    )

    if (-not (Test-IsWindowsPlatform)) {
        return [pscustomobject]@{
            Scanned         = $false
            Status          = 'NotApplicable'
            Issues          = @('Registry inspection requires Windows.')
            DeclaredCount   = 0
            RegisteredCount = 0
            Clsids          = @()
        }
    }

    $target = ConvertTo-NormalizedPath -RawPath $FilePath

    # Build the set of CLSIDs that the TypeLib declares as creatable
    # (CoClass with CanCreate flag — TypeFlag bit 0x0002). Without
    # CanCreate the class is not meant to be CoCreateInstance'd, so it
    # need not appear in the registry.
    $declared = New-Object System.Collections.Generic.List[object]
    $declaredLookup = @{}
    if ($TypeLibInfo -and $TypeLibInfo.TypeInfos) {
        foreach ($ti in $TypeLibInfo.TypeInfos) {
            if ($ti.Kind -ne 'TKIND_COCLASS') { continue }
            $canCreate = (($ti.TypeFlags -band 0x0002) -ne 0)
            if (-not $canCreate) { continue }
            $clsidStr = '{' + $ti.Guid.ToString().ToUpperInvariant() + '}'
            $declaredLookup[$clsidStr] = $true
            $declared.Add([pscustomobject]@{
                Clsid = $clsidStr
                Name  = $ti.Name
            }) | Out-Null
        }
    }

    # Pass 1 — inverse scan.
    $entries = Find-ComRegistrationsForFile -TargetPath $target -DeclaredLookup $declaredLookup

    # Pass 2 — for each declared CLSID we did NOT match by path, look it
    # up directly. If found anywhere, it is registered but pointing
    # elsewhere → PathMismatch. If not found at all → DeclaredOnly.
    foreach ($d in $declared) {
        $matched = $entries | Where-Object {
            [string]::Equals($_.Clsid, $d.Clsid, [System.StringComparison]::OrdinalIgnoreCase) `
                -and $_.PathMatchesTarget
        }
        if ($matched) { continue }

        $hits = Resolve-ClsidEverywhere -Clsid $d.Clsid
        if ($hits.Count -eq 0) {
            $entries.Add([pscustomobject]@{
                Clsid             = $d.Clsid
                FriendlyName      = $d.Name
                ProgId            = $null
                ThreadingModel    = $null
                InprocServer32    = $null
                NormalizedPath    = $null
                TypeLib           = $null
                Hive              = $null
                View              = $null
                DeclaredInTypeLib = $true
                PathMatchesTarget = $false
                Status            = 'DeclaredOnly'
            }) | Out-Null
        } else {
            foreach ($h in $hits) {
                Add-Member -InputObject $h -NotePropertyName DeclaredInTypeLib `
                           -NotePropertyValue $true -Force
                Add-Member -InputObject $h -NotePropertyName PathMatchesTarget `
                           -NotePropertyValue $false -Force
                Add-Member -InputObject $h -NotePropertyName Status `
                           -NotePropertyValue 'PathMismatch' -Force
                $entries.Add($h) | Out-Null
            }
        }
    }

    # Verdict + issue list.
    $registeredCount   = @($entries | Where-Object { $_.Status -eq 'Registered' }).Count
    $declaredOnly      = @($entries | Where-Object { $_.Status -eq 'DeclaredOnly' })
    $pathMismatches    = @($entries | Where-Object { $_.Status -eq 'PathMismatch' })
    $registeredOnly    = @($entries | Where-Object { $_.Status -eq 'RegisteredOnly' })

    $issues = New-Object System.Collections.Generic.List[string]
    foreach ($d in $declaredOnly) {
        $issues.Add("CLSID $($d.Clsid) ($($d.FriendlyName)) declared in TypeLib but not registered.") | Out-Null
    }
    foreach ($m in $pathMismatches) {
        $issues.Add("CLSID $($m.Clsid) registered but InprocServer32 points to '$($m.InprocServer32)' instead of the inspected DLL.") | Out-Null
    }

    $status = if ($declared.Count -eq 0 -and $entries.Count -eq 0) {
        'NotApplicable'
    } elseif ($declared.Count -gt 0 -and $declaredOnly.Count -eq 0 -and $pathMismatches.Count -eq 0) {
        'OK'
    } elseif ($declared.Count -gt 0 -and $registeredCount -eq 0) {
        'Unregistered'
    } else {
        'Partial'
    }

    return [pscustomobject]@{
        Scanned         = $true
        Status          = $status
        DeclaredCount   = $declared.Count
        RegisteredCount = $registeredCount
        RegisteredOnlyCount = $registeredOnly.Count
        MismatchCount   = $pathMismatches.Count
        Issues          = $issues.ToArray()
        Clsids          = $entries.ToArray()
    }
}