File: Acquisition\IWindowsRegistryReader.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Runtime.Versioning;
using System.Security;
using Microsoft.Win32;
 
namespace Aspire.Cli.Acquisition;
 
/// <summary>
/// Probes Windows-specific facts that identify whether the currently running
/// CLI binary was placed by a winget portable install. Used by
/// <see cref="WingetFirstRunProbe"/>; abstracted for testability.
/// </summary>
internal interface IWindowsRegistryReader
{
    /// <summary>
    /// Returns <see langword="true"/> when an entry under
    /// <c>HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall</c> (or the
    /// 64-bit HKLM hive) carries
    /// <c>WinGetPackageIdentifier == "Microsoft.Aspire"</c> and a
    /// <c>PortableTargetFullPath</c> that matches the supplied
    /// <paramref name="processPath"/> (canonicalized and compared
    /// case-insensitively).
    /// </summary>
    bool HasWingetAspireUninstallEntry(string processPath);
}
 
/// <summary>
/// Production <see cref="IWindowsRegistryReader"/>.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class WindowsRegistryReader : IWindowsRegistryReader
{
    private const string UninstallSubKey = @"Software\Microsoft\Windows\CurrentVersion\Uninstall";
    private const string PackageIdentifierValueName = "WinGetPackageIdentifier";
    private const string AspirePackageIdentifier = "Microsoft.Aspire";
    private const string PortableTargetValueName = "PortableTargetFullPath";
 
    public bool HasWingetAspireUninstallEntry(string processPath)
    {
        if (string.IsNullOrEmpty(processPath))
        {
            return false;
        }
 
        string canonicalProcessPath;
        try
        {
            canonicalProcessPath = Path.GetFullPath(processPath);
        }
        catch (Exception ex) when (ex is ArgumentException or PathTooLongException or NotSupportedException)
        {
            return false;
        }
 
        // Winget portable packages register under HKCU by default for per-user installs
        // and under HKLM for machine-wide installs. Probe both so an admin install still
        // self-stamps.
        return MatchesAspireEntry(RegistryHive.CurrentUser, canonicalProcessPath)
            || MatchesAspireEntry(RegistryHive.LocalMachine, canonicalProcessPath);
    }
 
    private static bool MatchesAspireEntry(RegistryHive hive, string canonicalProcessPath)
    {
        try
        {
            using var baseKey = RegistryKey.OpenBaseKey(hive, RegistryView.Registry64);
            using var uninstall = baseKey.OpenSubKey(UninstallSubKey, writable: false);
            if (uninstall is null)
            {
                return false;
            }
 
            foreach (var subKeyName in uninstall.GetSubKeyNames())
            {
                using var entry = uninstall.OpenSubKey(subKeyName, writable: false);
                if (entry is null)
                {
                    continue;
                }
 
                if (entry.GetValue(PackageIdentifierValueName) is not string identifier
                    || !string.Equals(identifier, AspirePackageIdentifier, StringComparison.Ordinal))
                {
                    continue;
                }
 
                if (entry.GetValue(PortableTargetValueName) is not string portableTarget
                    || string.IsNullOrEmpty(portableTarget))
                {
                    continue;
                }
 
                string canonicalTarget;
                try
                {
                    canonicalTarget = Path.GetFullPath(portableTarget);
                }
                catch (Exception ex) when (ex is ArgumentException or PathTooLongException or NotSupportedException)
                {
                    continue;
                }
 
                if (string.Equals(canonicalTarget, canonicalProcessPath, StringComparison.OrdinalIgnoreCase))
                {
                    return true;
                }
            }
        }
        catch (Exception ex) when (ex is SecurityException or UnauthorizedAccessException or IOException)
        {
            // Registry probe is best-effort. A locked hive, a denied ACL, or
            // a transient IO failure must not crash startup — the caller will
            // simply treat the install as non-winget for this run.
        }
 
        return false;
    }
}
 
/// <summary>
/// Non-Windows <see cref="IWindowsRegistryReader"/>. Always returns <see langword="false"/>.
/// </summary>
internal sealed class NullWindowsRegistryReader : IWindowsRegistryReader
{
    public bool HasWingetAspireUninstallEntry(string processPath) => false;
}