Scripts/Find-OrphanDependencyPackages.ps1
<#
.SYNOPSIS Locates orphan dependency packages in the system package cache .DESCRIPTION Windows maintains a local package cache of installed software to simplify operations which require access to the original installer. The package cache is typically located at "C:\ProgramData\Package Cache". Not all installers use the package cache, but Windows Installer (MSI files) typically do, as without a cached copy of the installer it's not possible to modify or even remove the existing installation. Unfortunately, the package cache may in some cases maintain installers for removed applications, growing in size as old installers are accumulated. This is especially the case for dependency packages. Visual Studio is an particularly prominent offender, as it relies on many MSIs which are frequently updated, but not always cleanly removed. The various .NET Core packages are the most common examples. This function attempts to identify "orphaned" dependency packages, which after inspection can be removed using the Remove-OrphanDependencyPackages function. You use this function entirely at your own risk! .EXAMPLE Find-OrphanDependencyPackages Analyzes the registry and file system for orphan dependency packages. .NOTES There's no simple way to "clean" the package cache and associated registry data. The best that can be done is to try and determine if a package is unused and match registry data to a cached installer. The general process is to inspect the registry data for each package, and given an absence of any metadata and a "Dependents" key with no sub-keys, it's fairly safe to assume it is an orphaned dependency. Matching a package to a cached installer is non-trivial, as there's no general way to make the match. This function is able to do so for various .NET Core packages due to the predictable naming scheme. .LINK https://github.com/ralish/PSWinGlue #> #Requires -Version 3.0 [CmdletBinding()] [OutputType([PSCustomObject[]])] Param() $PowerShellCore = New-Object -TypeName Version -ArgumentList 6, 0 if ($PSVersionTable.PSVersion -ge $PowerShellCore -and $PSVersionTable.Platform -ne 'Win32NT') { throw '{0} is only compatible with Windows.' -f $MyInvocation.MyCommand.Name } # System package cache $Script:SysPackageCache = Join-Path -Path $env:ProgramData -ChildPath 'Package Cache' # Visual Studio package cache $Script:VsPackageCache = Join-Path -Path $env:ProgramData -ChildPath 'Microsoft\VisualStudio\Packages' # Registry MSI metadata $Script:InstallerRegPath = 'HKLM:\SOFTWARE\Classes\Installer' # Registry MSI dependencies $Script:DependenciesRegPath = Join-Path -Path $InstallerRegPath -ChildPath 'Dependencies' # Known packages $Script:KnownPackages = @{ 'dotnet_apphost_pack' = @{ Registry = 'dotnet_apphost_pack_(\d+\.\d+\.\d+)_([a-z0-9_]+)' Directory = 'v$1' File = '^dotnet-apphost-pack-.+-$2\.msi' } # The registry key name is insufficient to match to MSI files in the # package cache. Instead, we have to find MSI files matching the below # pattern, then extract a record from them which we can use to match # against the correct registry key. #'Dotnet_CLI' = @{ # Registry = 'Dotnet_CLI_(\d+\.\d+\.\d+)\.\d+_([a-z0-9]+)' # Directory = 'v\d+\.\d+\.\d+' # File = '^dotnet-sdk-internal-.+-$2\.msi' #} 'Dotnet_CLI_HostFxr' = @{ Registry = 'Dotnet_CLI_HostFxr_(\d+\.\d+\.\d+)_([a-z0-9]+)' Directory = 'v$1' File = '^dotnet-hostfxr-.+-$2\.msi' } 'Dotnet_CLI_SharedHost' = @{ Registry = 'Dotnet_CLI_SharedHost_(\d+\.\d+(\.\d+)?)_([a-z0-9]+)' Directory = 'v$1' File = '^dotnet-host-.+-$2\.msi' } 'dotnet_runtime' = @{ Registry = 'dotnet_runtime_(\d+\.\d+\.\d+)_([a-z0-9]+)' Directory = 'v$1' File = '^dotnet-runtime-.+-$2\.msi' } 'dotnet_targeting_pack' = @{ Registry = 'dotnet_targeting_pack_(\d+\.\d+\.\d+)_([a-z0-9]+)' Directory = 'v$1' File = '^dotnet-targeting-pack-.+-$2\.msi' } 'DotNet.CLI.SharedFramework.Microsoft.NETCore.App' = @{ Registry = 'DotNet\.CLI\.SharedFramework\.Microsoft\.NETCore\.App_(\d+\.\d+\.\d+)_([a-z0-9]+)' Directory = 'v\d+\.\d+\.\d+' File = '^dotnet-runtime-$1-.+-$2\.msi' } 'Microsoft.AspNetCore.SharedFramework' = @{ Registry = 'Microsoft\.AspNetCore\.SharedFramework_([a-z0-9]+)_.+,v(\d+\.\d+\.\d+)' Directory = 'v$2' File = '^AspNetCoreSharedFramework-$1\.msi' } 'Microsoft.AspNetCore.TargetingPack' = @{ Registry = 'Microsoft\.AspNetCore\.TargetingPack_([a-z0-9]+)_.+,v(\d+\.\d+\.\d+)' Directory = 'v$2' File = '^aspnetcore-targeting-pack-$2-.+-$1\.msi' } 'NetCore_Templates' = @{ Registry = 'NetCore_Templates_\d+\.\d+_(\d+\.\d+\.\d+).*_([a-z0-9]+)' Directory = 'v$1' File = '^dotnet-\d+templates-.+-$2\.msi' } 'windowsdesktop_runtime' = @{ Registry = 'windowsdesktop_runtime_(\d+\.\d+\.\d+)_([a-z0-9]+)' Directory = 'v$1' File = '^windowsdesktop-runtime-.+-$2\.msi' } 'windowsdesktop_targeting_pack' = @{ Registry = 'windowsdesktop_targeting_pack_(\d+\.\d+\.\d+)_([a-z0-9]+)' Directory = 'v$1' File = '^windowsdesktop-targeting-pack-.+-$2\.msi' } } Function Find-DotNetCliPackagesFromCache { [CmdletBinding()] [OutputType([Object[]])] Param() $Results = New-Object -TypeName 'Collections.Generic.List[PSCustomObject]' # Retrieve all Dotnet_CLI packages $DncFileRegex = '^dotnet-sdk-internal-.+\.msi' $DncInstallers = @(Get-ChildItem -Path $Script:PackageCaches -File -Recurse | Where-Object Name -Match $DncFileRegex) if ($DncInstallers.Count -eq 0) { Write-Verbose -Message 'No .NET CLI packages found in package caches.' return , $Results.ToArray() } # Windows Installer object for querying MSI databases $Msi = New-Object -ComObject 'WindowsInstaller.Installer' # Windows Installer method parameters $MsiOpenDatabaseModeReadOnly = 0 $MsiOpenViewQuery = @('SELECT `ProviderKey` FROM `WixDependencyProvider`') foreach ($DncInstaller in $DncInstallers) { $DotNetCli = [PSCustomObject]@{ Name = $DncInstaller.Name File = $DncInstaller Provider = [String]::Empty } $Results.Add($DotNetCli) try { # Open the MSI database $MsiOpenDatabaseParams = $DncInstaller.FullName, $MsiOpenDatabaseModeReadOnly $MsiDatabase = $Msi.GetType().InvokeMember('OpenDatabase', 'InvokeMethod', $null, $Msi, $MsiOpenDatabaseParams) # Retrieve all records from the WixDependencyProvider table. Only a # subset of SQL is supported and the "LIKE" clause is unfortunately # not included. $MsiView = $Msi.GetType().InvokeMember('OpenView', 'InvokeMethod', $null, $MsiDatabase, $MsiOpenViewQuery) $null = $MsiView.GetType().InvokeMember('Execute', 'InvokeMethod', $null, $MsiView, $null) # Iterate over the returned records (there should only be one) while ($MsiRecord = $MsiView.GetType().InvokeMember('Fetch', 'InvokeMethod', $null, $MsiView, $null)) { $MsiRecordValue = $MsiRecord.GetType().InvokeMember('StringData', 'GetProperty', $null, $MsiRecord, 1) if ($MsiRecordValue -match '^Dotnet_CLI_') { $DotNetCli.Provider = $MsiRecordValue break } } } finally { $null = [Runtime.InteropServices.Marshal]::ReleaseComObject($MsiRecord) $null = [Runtime.InteropServices.Marshal]::ReleaseComObject($MsiView) $null = [Runtime.InteropServices.Marshal]::ReleaseComObject($MsiDatabase) } if (!$DotNetCli.Provider) { Write-Warning -Message ('[{0}] Failed to find Provider value.' -f $DncInstaller.Name) } } $null = [Runtime.InteropServices.Marshal]::ReleaseComObject($Msi) return ($Results.ToArray() | Sort-Object -Property Name) } Function Find-OrphanDependenciesFromRegistry { [CmdletBinding()] [OutputType([Object[]])] Param() $Results = New-Object -TypeName 'Collections.Generic.List[PSCustomObject]' # Retrieve all package dependencies $Dependencies = @(Get-ChildItem -Path $Script:DependenciesRegPath) if ($Dependencies.Count -eq 0) { Write-Warning -Message 'No MSI dependency packages found in the registry.' return , $Results.ToArray() } # Filter out packages with dependencies foreach ($DependencyKey in $Dependencies) { $Dependency = [PSCustomObject]@{ Name = $DependencyKey.PSChildName Status = 'Active' RegKey = $DependencyKey -replace '^HKEY_LOCAL_MACHINE', 'HKLM:' CacheStatus = $null CacheFiles = (New-Object -TypeName 'Collections.Generic.List[IO.FileSystemInfo]') } $Results.Add($Dependency) # Any values (inc. default value) $BaseValues = Get-ItemProperty -Path $DependencyKey.PSPath if ($BaseValues) { continue } # No sub-keys $BaseKeys = @(Get-ChildItem -Path $DependencyKey.PSPath) if (!$BaseKeys) { continue } # Any sub-keys except "Dependents" if ($BaseKeys.Count -gt 1 -or $BaseKeys.PSChildName -ne 'Dependents') { continue } $DependentsKey = $BaseKeys[0] # Any values under "Dependents" (inc. default value) $DependentsValues = Get-ItemProperty -Path $DependentsKey.PSPath if ($DependentsValues) { continue } # Any sub-keys under "Dependents" $DependentsKeys = Get-ChildItem -Path $DependentsKey.PSPath if ($DependentsKeys) { continue } $Dependency.Status = 'Orphaned' } return ($Results.ToArray() | Sort-Object -Property Name) } Function Resolve-DotNetCliPackagesCacheToRegistry { [CmdletBinding()] [OutputType([Void])] Param( [Parameter(Mandatory)] [PSCustomObject[]]$DotNetCliPackages, [Parameter(Mandatory)] [PSCustomObject[]]$RegistryPackages ) foreach ($DncPackage in $DotNetCliPackages) { $RegistryPackage = $RegistryPackages | Where-Object Name -EQ $DncPackage.Provider if (!$RegistryPackage) { Write-Warning -Message ('Unable to associate {0} to a registry package.' -f $DncPackage.Name) continue } if ($RegistryPackage.CacheFiles.Count -ne 0) { Write-Error -Message ('Registry package "{0}" already associated with Dotnet_CLI package but matched: {1}' -f $RegistryPackage.Name, $DncPackage.Name) continue } $RegistryPackage.CacheFiles.Add($DncPackage.File) } } Function Resolve-OrphanDependenciesRegistryToCache { [CmdletBinding()] [OutputType([Void])] Param( [Parameter(Mandatory)] [PSCustomObject[]]$RegistryPackages ) # Retrieve all cached packages $Packages = Get-ChildItem -Path $Script:PackageCaches -File -Recurse foreach ($RegistryPackage in $RegistryPackages) { $RegistryPackage.CacheStatus = 'None found' # Locate the "known package" entry $KnownPackage = $null if ($RegistryPackage.Name -match '^([a-z_.]+)[_.]') { $KnownPackageName = $Matches[1] if ($Script:KnownPackages.Keys -contains $KnownPackageName) { $KnownPackage = $Script:KnownPackages[$KnownPackageName] } } if (!$KnownPackage) { $RegistryPackage.CacheStatus = 'Not searched' continue } # Retrieve the match on the package registry key if ($RegistryPackage.Name -match $KnownPackage.Registry) { $PackageRegVersion = $Matches[1] if ($Matches.Count -ge 3) { $PackageRegArch = $Matches[2] } else { $PackageRegArch = [String]::Empty } } else { $RegistryPackage.CacheStatus = 'Error' Write-Warning -Message ('[{0}] Known package registry key did not match.' -f $RegistryPackage.Name) continue } # Filter package cache directories on the regex $PackageDirs = New-Object -TypeName 'Collections.Generic.List[IO.FileSystemInfo]' $PackageDirRegex = $KnownPackage.Directory -replace '\$1', $PackageRegVersion -replace '\$2', $PackageRegArch foreach ($Package in $Packages) { if ($Package.Directory -match $PackageDirRegex) { $PackageDirs.Add($Package) } } if ($PackageDirs.Count -eq 0) { continue } # Filter package cache files on the regex $PackageFileRegex = $KnownPackage.File -replace '\$1', $PackageRegVersion -replace '\$2', $PackageRegArch foreach ($PackageFile in $PackageDirs) { if ($PackageFile.Name -match $PackageFileRegex) { $RegistryPackage.CacheFiles.Add($PackageFile) } } if ($RegistryPackage.CacheFiles.Count -gt 0) { $RegistryPackage.CacheStatus = '{0} files' -f $RegistryPackage.CacheFiles.Count if ($RegistryPackage.CacheFiles.Count -gt 1) { Write-Warning -Message ('[{0}] Found {1} files in package cache.' -f $RegistryPackage.Name, $RegistryPackage.CacheFiles.Count) } } } } # Determine which file system package caches to inspect $Script:PackageCaches = @($Script:SysPackageCache) if (Test-Path -Path $Script:VsPackageCache -PathType Container -ErrorAction Ignore) { $Script:PackageCaches += $Script:VsPackageCache } $Packages = Find-OrphanDependenciesFromRegistry if ($Packages) { Resolve-OrphanDependenciesRegistryToCache -RegistryPackages $Packages $DotNetCli = Find-DotNetCliPackagesFromCache if ($DotNetCli) { Resolve-DotNetCliPackagesCacheToRegistry -DotNetCliPackages $DotNetCli -RegistryPackages $Packages } } return $Packages |