categories/RegistryOrphans.ps1

# Registry-orphans category (009/Kudu parity): DETECTS (never auto-deletes without Octa's own
# confirmation) Uninstall registry entries whose UninstallString points at an executable that no
# longer exists. Deliberately narrower than Kudu's own registry cleaner - "App Paths" scanning
# was researched and dropped (research.md) for lacking a reliably false-positive-free pattern.

function Get-OctaRegistryOrphansCategory {
    [CmdletBinding()]
    param()
    return [pscustomobject]@{
        Id                          = 'registry-orphans'
        DisplayName                 = 'Registry Orphans'
        Description                 = 'Detect broken Uninstall registry entries'
        RequiresElevation           = $true
        ContainsIrreversibleActions = $false
        GetActionsFunction          = 'Get-OctaRegistryOrphansActions'
        ApplyActionFunction         = 'Set-OctaRegistryOrphansAction'
    }
}

function Test-OctaUninstallTargetBroken {
    <#
        research.md's original assumption (Test-Path alone is enough, since MsiExec.exe "always
        exists") was wrong for bare executable names - Test-Path does not search PATH, so
        "MsiExec.exe" as a relative path would almost always report false/missing. Fixed here:
        an absolute path is Test-Path'd directly; a bare name is resolved via Get-Command, the
        same way Windows itself would resolve it when actually invoking the uninstaller.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$UninstallString)

    if ([string]::IsNullOrWhiteSpace($UninstallString)) { return $false }

    $token = if ($UninstallString -match '^"([^"]+)"') { $Matches[1] } else { ($UninstallString -split '\s+')[0] }
    if ([string]::IsNullOrWhiteSpace($token)) { return $false }

    if ([System.IO.Path]::IsPathRooted($token)) {
        return -not (Test-Path -LiteralPath $token)
    }
    return -not [bool](Get-Command $token -ErrorAction SilentlyContinue)
}

function Get-OctaRegistryOrphansActions {
    [CmdletBinding()]
    param()
    $actions = @()

    $uninstallRoots = @(
        'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
        'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
    )

    foreach ($root in $uninstallRoots) {
        if (-not (Test-Path $root)) { continue }
        Get-ChildItem -Path $root -ErrorAction SilentlyContinue | ForEach-Object {
            $keyPath = Join-Path $root $_.PSChildName
            $entry = Get-ItemProperty -Path $keyPath -ErrorAction SilentlyContinue
            if (-not $entry.DisplayName) { return }
            if (-not $entry.UninstallString) { return }
            if (-not (Test-OctaUninstallTargetBroken -UninstallString $entry.UninstallString)) { return }

            $actions += New-OctaAction -TargetType Registry -TargetIdentifier "$keyPath|(delete)" `
                -CurrentValue $entry.UninstallString -PlannedValue '(removed)' -Reversible $true -RiskLevel Risky
        }
    }

    return $actions
}

function Set-OctaRegistryOrphansAction {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Action)
    $keyPath, $valueName = $Action.TargetIdentifier -split '\|', 2
    if ($valueName -eq '(delete)') {
        Remove-Item -LiteralPath $keyPath -Recurse -Force -ErrorAction SilentlyContinue
    }
}