File: Certificates\CertificateGeneration\UnixCertificateManager.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.
 
#nullable enable
 
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.RegularExpressions;
using Aspire.Cli;
using Aspire.Cli.Certificates;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
 
namespace Microsoft.AspNetCore.Certificates.Generation;
 
/// <remarks>
/// On Unix, we trust the certificate in the following locations:
///   1. dotnet (i.e. the CurrentUser/Root store)
///   2. OpenSSL (i.e. adding it to a directory in $SSL_CERT_DIR)
///   3. Firefox &amp; Chromium (i.e. adding it to an NSS DB for each browser)
/// All of these locations are per-user.
/// </remarks>
internal sealed partial class UnixCertificateManager : CertificateManager
{
    private const UnixFileMode DirectoryPermissions = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
 
    /// <summary>The name of an environment variable consumed by OpenSSL to locate certificates.</summary>
    private const string OpenSslCertificateDirectoryVariableName = "SSL_CERT_DIR";
    private const string XdgConfigHomeVariableName = "XDG_CONFIG_HOME";
 
    private const string OpenSslCertDirectoryOverrideVariableName = "DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY";
    private const string NssDbOverrideVariableName = "DOTNET_DEV_CERTS_NSSDB_PATHS";
 
    private const string PowerShellCommand = "powershell.exe";
    private const string WslInteropPath = "/proc/sys/fs/binfmt_misc/WSLInterop";
    private const string WslInteropLatePath = "/proc/sys/fs/binfmt_misc/WSLInterop-late";
    private const string WslFriendlyName = AspNetHttpsOidFriendlyName + " (WSL)";
 
    private const string OpenSslCommand = "openssl";
    private const int MaxHashCollisions = 10; // Something is going badly wrong if we have this many dev certs with the same hash
 
    private HashSet<string>? _availableCommands;
    private readonly IEnvironment _environment;
    private readonly CertificateConfiguration.NssDbOverride? _nssDbOverride;
    private readonly Action<ProcessStartInfo> _configureCertUtilStartInfo = static _ => { };
 
    public UnixCertificateManager(ILogger logger, IEnvironment environment, CertificateConfiguration.NssDbOverride? nssDbOverride) : base(logger)
    {
        _environment = environment;
        _nssDbOverride = nssDbOverride;
    }
 
    internal UnixCertificateManager(
        ILogger logger,
        IEnvironment environment,
        CertificateConfiguration.NssDbOverride? nssDbOverride,
        Action<ProcessStartInfo> configureCertUtilStartInfo)
        : this(logger, environment, nssDbOverride)
    {
        _configureCertUtilStartInfo = configureCertUtilStartInfo;
    }
 
    internal UnixCertificateManager(string subject, int version)
        : base(subject, version)
    {
        _environment = new Aspire.Cli.HostEnvironment();
        _nssDbOverride = null;
    }
 
    public override TrustLevel GetTrustLevel(X509Certificate2 certificate)
        => GetTrustLevel(certificate, CancellationToken.None);
 
    internal TrustLevel GetTrustLevel(X509Certificate2 certificate, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        var sawTrustSuccess = false;
        var sawTrustFailure = false;
 
        if (!string.IsNullOrEmpty(_environment.GetEnvironmentVariable(OpenSslCertDirectoryOverrideVariableName)))
        {
            // Warn but don't bail.
            Log.UnixOpenSslCertificateDirectoryOverrideIgnored(OpenSslCertDirectoryOverrideVariableName);
        }
 
        // Building the chain will check whether dotnet trusts the cert.  We could, instead,
        // enumerate the Root store and/or look for the file in the OpenSSL directory, but
        // this tests the real-world behavior.
        var chain = new X509Chain();
        try
        {
            // This is just a heuristic for whether or not we should prompt the user to re-run with `--trust`
            // so we don't need to check revocation (which doesn't really make sense for dev certs anyway)
            chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
            if (chain.Build(certificate))
            {
                sawTrustSuccess = true;
            }
            else
            {
                sawTrustFailure = true;
                Log.UnixNotTrustedByDotnet();
            }
        }
        finally
        {
            // Disposing the chain does not dispose the elements we potentially built.
            // Do the full walk manually to dispose.
            for (var i = 0; i < chain.ChainElements.Count; i++)
            {
                chain.ChainElements[i].Certificate.Dispose();
            }
 
            chain.Dispose();
        }
 
        // Will become the name of the file on disk and the nickname in the NSS DBs
        var certificateNickname = GetCertificateNickname(certificate);
 
        var sslCertDirString = _environment.GetEnvironmentVariable(OpenSslCertificateDirectoryVariableName);
        if (string.IsNullOrEmpty(sslCertDirString))
        {
            sawTrustFailure = true;
            Log.UnixNotTrustedByOpenSsl(OpenSslCertificateDirectoryVariableName);
        }
        else
        {
            var foundCert = false;
            var sslCertDirs = sslCertDirString.Split(Path.PathSeparator);
            foreach (var sslCertDir in sslCertDirs)
            {
                var certPath = Path.Combine(sslCertDir, certificateNickname + ".pem");
                if (File.Exists(certPath))
                {
                    try
                    {
                        using var candidate = X509CertificateLoader.LoadCertificateFromFile(certPath);
                        if (AreCertificatesEqual(certificate, candidate))
                        {
                            foundCert = true;
                            break;
                        }
                    }
                    catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException)
                    {
                        // Treat unreadable entries as a miss. A later SSL_CERT_DIR entry may still contain
                        // the expected certificate, so only report OpenSSL as untrusted after the full search.
                    }
                }
            }
 
            if (foundCert)
            {
                sawTrustSuccess = true;
            }
            else
            {
                sawTrustFailure = true;
                Log.UnixNotTrustedByOpenSsl(OpenSslCertificateDirectoryVariableName);
            }
        }
 
        var nssDbs = GetNssDbs(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
        if (nssDbs.Count > 0)
        {
            if (!IsCommandAvailable(CertificateHelpers.CertUtilCommand))
            {
                // If there are browsers but we don't have certutil, we can't check trust and,
                // in all probability, we can't have previously established it.
                Log.UnixMissingCertUtilCommand(CertificateHelpers.CertUtilCommand);
                sawTrustFailure = true;
            }
            else
            {
                foreach (var nssDb in nssDbs)
                {
                    if (IsCertificateInNssDb(certificateNickname, nssDb, cancellationToken))
                    {
                        sawTrustSuccess = true;
                    }
                    else
                    {
                        sawTrustFailure = true;
                        Log.UnixNotTrustedByNss(nssDb.Path, nssDb.BrowserFamily);
                    }
                }
            }
        }
 
        // Success & Failure => Partial; Success => Full; Failure => None
        return sawTrustSuccess
            ? sawTrustFailure
                ? TrustLevel.Partial
                : TrustLevel.Full
            : TrustLevel.None;
    }
 
    protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation)
    {
        var export = certificate.Export(X509ContentType.Pkcs12, "");
        certificate.Dispose();
        certificate = X509CertificateLoader.LoadPkcs12(export, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable);
        Array.Clear(export, 0, export.Length);
 
        using (var store = new X509Store(storeName, storeLocation))
        {
            store.Open(OpenFlags.ReadWrite);
            store.Add(certificate);
            store.Close();
        }
        ;
 
        return certificate;
    }
 
    internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate)
    {
        // Return true as we don't perform any check.
        // This is about checking storage, not trust.
        return new CheckCertificateStateResult(true, null);
    }
 
    internal override void CorrectCertificateState(X509Certificate2 candidate)
    {
        // Do nothing since we don't have anything to check here.
        // This is about correcting storage, not trust.
    }
 
    internal override bool IsExportable(X509Certificate2 c) => true;
 
    protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate)
    {
        var sawTrustFailure = false;
        var sawTrustSuccess = false;
 
        using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser);
        store.Open(OpenFlags.ReadWrite);
 
        if (TryFindCertificateInStore(store, certificate, out _))
        {
            sawTrustSuccess = true;
        }
        else
        {
            try
            {
                using var publicCertificate = X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert));
                // FriendlyName is Windows-only, so we don't set it here.
                store.Add(publicCertificate);
                Log.UnixDotnetTrustSucceeded();
                sawTrustSuccess = true;
            }
            catch (Exception ex)
            {
                sawTrustFailure = true;
                Log.UnixDotnetTrustException(ex.Message);
            }
        }
 
        var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
 
        // Rather than create a temporary file we'll have to clean up, we prefer to export the dev cert
        // to its final location in the OpenSSL directory.  As a result, any failure up until that point
        // is fatal (i.e. we can't trust the cert in other locations).
 
        var certDir = GetOpenSslCertificateDirectory(homeDirectory)!; // May not exist
 
        var nickname = GetCertificateNickname(certificate);
        var certPath = Path.Combine(certDir, nickname) + ".pem";
 
        var needToExport = true;
 
        // We do our own check for file collisions since ExportCertificate silently overwrites.
        if (File.Exists(certPath))
        {
            try
            {
                using var existingCert = X509CertificateLoader.LoadCertificateFromFile(certPath);
                if (!AreCertificatesEqual(existingCert, certificate))
                {
                    Log.UnixNotOverwritingCertificate(certPath);
                    return TrustLevel.None;
                }
 
                needToExport = false; // If the bits are on disk, we don't need to re-export
            }
            catch
            {
                // If we couldn't load the file, then we also can't safely overwrite it.
                Log.UnixNotOverwritingCertificate(certPath);
                return TrustLevel.None;
            }
        }
 
        if (needToExport)
        {
            // Security: we don't need the private key for trust, so we don't export it.
            // Note that this will create directories as needed.  We control `certPath`, so the permissions should be fine.
            ExportCertificate(certificate, certPath, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pem);
        }
 
        // Once the certificate is on disk, we prefer not to throw - some subsequent trust step might succeed.
 
        var openSslTrustSucceeded = false;
 
        var isOpenSslAvailable = IsCommandAvailable(OpenSslCommand);
        if (isOpenSslAvailable)
        {
            if (TryRehashOpenSslCertificates(certDir))
            {
                openSslTrustSucceeded = true;
            }
        }
        else
        {
            Log.UnixMissingOpenSslCommand(OpenSslCommand);
        }
 
        if (openSslTrustSucceeded)
        {
            Log.UnixOpenSslTrustSucceeded();
            sawTrustSuccess = true;
        }
        else
        {
            // The helpers log their own failure reasons - we just describe the consequences
            Log.UnixOpenSslTrustFailed();
            sawTrustFailure = true;
        }
 
        var nssDbs = GetNssDbs(homeDirectory);
        if (nssDbs.Count > 0)
        {
            var isCertUtilAvailable = IsCommandAvailable(CertificateHelpers.CertUtilCommand);
            if (!isCertUtilAvailable)
            {
                Log.UnixMissingCertUtilCommand(CertificateHelpers.CertUtilCommand);
                // We'll loop over the nssdbs anyway so they'll be listed
            }
 
            foreach (var nssDb in nssDbs)
            {
                if (isCertUtilAvailable && TryAddCertificateToNssDb(certPath, nickname, nssDb))
                {
                    if (IsCertificateInNssDb(nickname, nssDb))
                    {
                        Log.UnixNssDbTrustSucceeded(nssDb.Path);
                        sawTrustSuccess = true;
                    }
                    else
                    {
                        // If the dev cert is in the db under a different nickname, adding it will succeed (and probably even cause it to be trusted)
                        // but IsTrusted won't find it.  This is unlikely to happen in practice, so we warn here, rather than hardening IsTrusted.
                        Log.UnixNssDbTrustFailedWithProbableConflict(nssDb.Path, nssDb.BrowserFamily);
                        sawTrustFailure = true;
                    }
                }
                else
                {
                    Log.UnixNssDbTrustFailed(nssDb.Path, nssDb.BrowserFamily);
                    sawTrustFailure = true;
                }
            }
        }
 
        if (sawTrustFailure)
        {
            if (sawTrustSuccess)
            {
                // Untrust throws in this case, but we're more lenient since a partially trusted state may be useful in practice.
                Log.UnixTrustPartiallySucceeded();
            }
            else
            {
                return TrustLevel.None;
            }
        }
 
        if (openSslTrustSucceeded)
        {
            Debug.Assert(IsCommandAvailable(OpenSslCommand), "How did we trust without the openssl command?");
 
            var homeDirectoryWithSlash = homeDirectory[^1] == Path.DirectorySeparatorChar
                ? homeDirectory
                : homeDirectory + Path.DirectorySeparatorChar;
 
            var prettyCertDir = certDir.StartsWith(homeDirectoryWithSlash, StringComparison.Ordinal)
                ? Path.Combine("$HOME", certDir[homeDirectoryWithSlash.Length..])
                : certDir;
 
            var hasValidSslCertDir = false;
 
            // Check if SSL_CERT_DIR is already set and if certDir is already included
            var existingSslCertDir = _environment.GetEnvironmentVariable(OpenSslCertificateDirectoryVariableName);
            if (!string.IsNullOrEmpty(existingSslCertDir))
            {
                var existingDirs = existingSslCertDir.Split(Path.PathSeparator);
                var certDirFullPath = Path.GetFullPath(certDir);
                var isCertDirIncluded = existingDirs.Any(dir =>
                {
                    if (string.IsNullOrWhiteSpace(dir))
                    {
                        return false;
                    }
 
                    try
                    {
                        return string.Equals(Path.GetFullPath(dir), certDirFullPath, StringComparison.Ordinal);
                    }
                    catch
                    {
                        // Ignore invalid directory entries in SSL_CERT_DIR
                        return false;
                    }
                });
 
                if (isCertDirIncluded)
                {
                    // The certificate directory is already in SSL_CERT_DIR, no action needed
                    Log.UnixOpenSslCertificateDirectoryAlreadyConfigured(prettyCertDir, OpenSslCertificateDirectoryVariableName);
                    hasValidSslCertDir = true;
                }
                else
                {
                    // SSL_CERT_DIR is set but doesn't include our directory - suggest appending
                    Log.UnixSuggestAppendingToEnvironmentVariable(prettyCertDir, OpenSslCertificateDirectoryVariableName);
                    hasValidSslCertDir = false;
                }
            }
            else if (TryGetOpenSslDirectory(out var openSslDir))
            {
                Log.UnixSuggestSettingEnvironmentVariable(prettyCertDir, Path.Combine(openSslDir, "certs"), OpenSslCertificateDirectoryVariableName);
                hasValidSslCertDir = false;
            }
            else
            {
                Log.UnixSuggestSettingEnvironmentVariableWithoutExample(prettyCertDir, OpenSslCertificateDirectoryVariableName);
                hasValidSslCertDir = false;
            }
 
            sawTrustFailure = !hasValidSslCertDir;
        }
 
        // Check to see if we're running in WSL; if so, use powershell.exe to add the certificate to the Windows trust store as well
        if (IsRunningOnWslWithInterop())
        {
            try
            {
                if (TrustCertificateInWindowsStore(certificate))
                {
                    Log.WslWindowsTrustSucceeded();
                }
                else
                {
                    Log.WslWindowsTrustFailed();
                    sawTrustFailure = true;
                }
            }
            catch (Exception ex)
            {
                Log.WslWindowsTrustException(ex.Message);
                sawTrustFailure = true;
            }
        }
 
        return sawTrustFailure
            ? TrustLevel.Partial
            : TrustLevel.Full;
    }
 
    protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate)
    {
        var sawUntrustFailure = false;
 
        using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser);
        store.Open(OpenFlags.ReadWrite);
 
        if (TryFindCertificateInStore(store, certificate, out var matching))
        {
            try
            {
                store.Remove(matching);
            }
            catch (Exception ex)
            {
                Log.UnixDotnetUntrustException(ex.Message);
                sawUntrustFailure = true;
            }
        }
 
        var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)!;
 
        // We don't attempt to remove the directory when it's empty - it's a standard location
        // and will almost certainly be used in the future.
        var certDir = GetOpenSslCertificateDirectory(homeDirectory); // May not exist
 
        var nickname = GetCertificateNickname(certificate);
        var certPath = Path.Combine(certDir, nickname) + ".pem";
 
        if (File.Exists(certPath))
        {
            var openSslUntrustSucceeded = false;
            var certificateFileDeleted = TryDeleteCertificateFile(certPath);
 
            if (certificateFileDeleted)
            {
                if (IsCommandAvailable(OpenSslCommand))
                {
                    openSslUntrustSucceeded = TryRehashOpenSslCertificates(certDir);
                }
                else
                {
                    Log.UnixMissingOpenSslCommand(OpenSslCommand);
                    openSslUntrustSucceeded = true;
                }
            }
 
            if (openSslUntrustSucceeded)
            {
                Log.UnixOpenSslUntrustSucceeded();
            }
            else
            {
                // The helpers log their own failure reasons - we just describe the consequences
                Log.UnixOpenSslUntrustFailed();
                sawUntrustFailure = true;
            }
        }
        else
        {
            Log.UnixOpenSslUntrustSkipped(certPath);
        }
 
        var nssDbs = GetNssDbs(homeDirectory);
        if (nssDbs.Count > 0)
        {
            var isCertUtilAvailable = IsCommandAvailable(CertificateHelpers.CertUtilCommand);
            if (!isCertUtilAvailable)
            {
                Log.UnixMissingCertUtilCommand(CertificateHelpers.CertUtilCommand);
            }
            else
            {
                foreach (var nssDb in nssDbs)
                {
                    if (TryRemoveCertificateFromNssDb(nickname, nssDb))
                    {
                        Log.UnixNssDbUntrustSucceeded(nssDb.Path);
                    }
                    else
                    {
                        Log.UnixNssDbUntrustFailed(nssDb.Path);
                        sawUntrustFailure = true;
                    }
                }
            }
        }
 
        if (sawUntrustFailure)
        {
            // It might be nice to include more specific error information in the exception message, but we've logged it anyway.
            throw new InvalidOperationException($@"There was an error removing the certificate with thumbprint '{certificate.Thumbprint}'.");
        }
    }
 
    protected override IList<X509Certificate2> GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation)
    {
        return ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false, requireExportable: false);
    }
 
    protected override void CreateDirectoryWithPermissions(string directoryPath)
    {
#pragma warning disable CA1416 // Validate platform compatibility (not supported on Windows)
        var dirInfo = new DirectoryInfo(directoryPath);
        if (dirInfo.Exists)
        {
            if ((dirInfo.UnixFileMode & ~DirectoryPermissions) != 0)
            {
                Log.DirectoryPermissionsNotSecure(dirInfo.FullName);
            }
        }
        else
        {
            DirectoryHelper.CreateWithOwnerOnlyPermissions(directoryPath);
        }
#pragma warning restore CA1416 // Validate platform compatibility
    }
 
    private bool IsCommandAvailable(string command)
    {
        _availableCommands ??= FindAvailableCommands();
        return _availableCommands.Contains(command);
    }
 
    private HashSet<string> FindAvailableCommands()
    {
        var availableCommands = new HashSet<string>();
 
        // We need OpenSSL 1.1.1h or newer (to pick up https://github.com/openssl/openssl/pull/12357),
        // but, given that all of v1 is EOL, it doesn't seem worthwhile to check the version.
        var commands = new[] { OpenSslCommand, CertificateHelpers.CertUtilCommand };
 
        var environmentVariables = _environment.GetEnvironmentVariables()
            .Where(kv => kv.Value is not null)
            .ToDictionary(kv => kv.Name, kv => kv.Value!);
 
        foreach (var command in commands)
        {
            if (PathLookupHelper.TryResolveExecutablePath(command, out _, environmentVariables))
            {
                availableCommands.Add(command);
            }
        }
 
        return availableCommands;
    }
 
    private static string GetCertificateNickname(X509Certificate2 certificate)
    {
        return $"aspnetcore-localhost-{certificate.Thumbprint}";
    }
 
    /// <summary>
    /// Detects if the current environment is Windows Subsystem for Linux (WSL) with interop enabled.
    /// </summary>
    /// <returns>True if running on WSL with interop; otherwise, false.</returns>
    private bool IsRunningOnWslWithInterop()
    {
        // WSL exposes special files that indicate WSL interop is enabled.
        // Either WSLInterop or WSLInterop-late may be present depending on the WSL version and configuration.
        if (File.Exists(WslInteropPath) || File.Exists(WslInteropLatePath))
        {
            return true;
        }
 
        // Additionally check for standard WSL environment variables as a fallback.
        // WSL_INTEROP is set to the path of the interop socket.
        if (!string.IsNullOrEmpty(_environment.GetEnvironmentVariable("WSL_INTEROP")))
        {
            return true;
        }
 
        return false;
    }
 
    /// <summary>
    /// Attempts to trust the certificate in the Windows certificate store via PowerShell when running on WSL.
    /// If the certificate already exists in the store, this is a no-op.
    /// </summary>
    /// <param name="certificate">The certificate to trust.</param>
    /// <returns>True if the certificate was successfully added to the Windows store; otherwise, false.</returns>
    private static bool TrustCertificateInWindowsStore(X509Certificate2 certificate)
    {
        // Export the certificate as DER-encoded bytes (no private key needed for trust)
        // and embed it directly in the PowerShell script as Base64 to avoid file path
        // translation issues between WSL and Windows.
        var certBytes = certificate.Export(X509ContentType.Cert);
        var certBase64 = Convert.ToBase64String(certBytes);
 
        var escapedFriendlyName = WslFriendlyName.Replace("'", "''");
        var powershellScript = $@"
            $certBytes = [Convert]::FromBase64String('{certBase64}')
            $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(,$certBytes)
            $cert.FriendlyName = '{escapedFriendlyName}'
            $store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser')
            $store.Open('ReadWrite')
            $store.Add($cert)
            $store.Close()
        ";
 
        // Encode the PowerShell script to Base64 (UTF-16LE as required by PowerShell)
        var encodedCommand = Convert.ToBase64String(System.Text.Encoding.Unicode.GetBytes(powershellScript));
 
        return Process.Run(
            PowerShellCommand,
            ["-NoProfile", "-NonInteractive", "-EncodedCommand", encodedCommand],
            silent: true).ExitCode == 0;
    }
 
    /// <remarks>
    /// It is the caller's responsibility to ensure that <see cref="CertificateHelpers.CertUtilCommand"/> is available.
    /// </remarks>
    private bool IsCertificateInNssDb(string nickname, NssDb nssDb, CancellationToken cancellationToken = default)
    {
        // -V will validate that a cert can be used for a given purpose, in this case, server verification.
        // There is no corresponding -V check for the "Trusted CA" status required by Firefox, so we just check for existence.
        // (The docs suggest that "-V -u A" should do this, but it seems to accept all certs.)
        try
        {
            return RunCertUtil(nssDb.CreateCheckProcessStartInfo(nickname), cancellationToken);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            throw;
        }
        catch (Exception ex)
        {
            Log.UnixNssDbCheckException(nssDb.Path, ex.Message);
            // This method is used to determine whether more trust is needed, so it's better to underestimate the amount of trust.
            return false;
        }
    }
 
    /// <remarks>
    /// It is the caller's responsibility to ensure that <see cref="CertificateHelpers.CertUtilCommand"/> is available.
    /// </remarks>
    private bool TryAddCertificateToNssDb(string certificatePath, string nickname, NssDb nssDb)
    {
        // This silently clobbers an existing entry, so there's no need to check for existence first.
        try
        {
            return RunCertUtil(nssDb.CreateAddProcessStartInfo(certificatePath, nickname));
        }
        catch (Exception ex)
        {
            Log.UnixNssDbAdditionException(nssDb.Path, ex.Message);
            return false;
        }
    }
 
    /// <remarks>
    /// It is the caller's responsibility to ensure that <see cref="CertificateHelpers.CertUtilCommand"/> is available.
    /// </remarks>
    private bool TryRemoveCertificateFromNssDb(string nickname, NssDb nssDb)
    {
        try
        {
            if (RunCertUtil(nssDb.CreateRemoveProcessStartInfo(nickname)))
            {
                return true;
            }
 
            // Maybe it wasn't in there because the overrides have change or trust only partially succeeded.
            return !IsCertificateInNssDb(nickname, nssDb);
        }
        catch (Exception ex)
        {
            Log.UnixNssDbRemovalException(nssDb.Path, ex.Message);
            return false;
        }
    }
 
    private bool RunCertUtil(ProcessStartInfo startInfo, CancellationToken cancellationToken = default)
    {
        using var nullHandle = File.OpenNullHandle();
        startInfo.StandardInputHandle = nullHandle;
        startInfo.StandardOutputHandle = nullHandle;
        startInfo.StandardErrorHandle = nullHandle;
        _configureCertUtilStartInfo(startInfo);
 
        // Not Process.Run/RunAsync: they only kill the root process when canceled. Trust checks are canceled
        // on Ctrl+C, and certutil may be a wrapper script, so kill the whole tree to avoid leaking descendants.
        using var process = Process.Start(startInfo)!;
        try
        {
            process.WaitForExitAsync(cancellationToken).GetAwaiter().GetResult();
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            try
            {
                process.Kill(entireProcessTree: true);
                process.WaitForExit();
            }
            catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException or System.ComponentModel.Win32Exception)
            {
                // The process either exited concurrently or could not be killed by this platform. Don't
                // wait for it in the latter case: that would hang Ctrl+C on a process we can't stop.
            }
 
            throw;
        }
 
        return process.ExitCode == 0;
    }
 
    private string GetOpenSslCertificateDirectory(string homeDirectory)
    {
        var @override = _environment.GetEnvironmentVariable(OpenSslCertDirectoryOverrideVariableName);
        if (!string.IsNullOrEmpty(@override))
        {
            Log.UnixOpenSslCertificateDirectoryOverridePresent(OpenSslCertDirectoryOverrideVariableName);
            return @override;
        }
 
        return Path.Combine(homeDirectory, ".aspnet", "dev-certs", "trust");
    }
 
    private bool TryDeleteCertificateFile(string certPath)
    {
        try
        {
            File.Delete(certPath);
            return true;
        }
        catch (Exception ex)
        {
            Log.UnixCertificateFileDeletionException(certPath, ex.Message);
            return false;
        }
    }
 
    internal List<NssDb> GetNssDbs(string homeDirectory)
    {
        string nssDbOverrideSource;
        string? nssDbOverride;
        if (_nssDbOverride is { Value.Length: > 0 } configuredOverride)
        {
            nssDbOverrideSource = configuredOverride.Source;
            nssDbOverride = configuredOverride.Value;
        }
        else
        {
            nssDbOverrideSource = NssDbOverrideVariableName;
            nssDbOverride = _environment.GetEnvironmentVariable(NssDbOverrideVariableName);
        }
 
        return NssDb.Resolve(
            homeDirectory,
            _environment.GetEnvironmentVariable(XdgConfigHomeVariableName),
            nssDbOverride,
            nssDbOverrideSource,
            this);
    }
 
    [GeneratedRegex("OPENSSLDIR:\\s*\"([^\"]+)\"")]
    private static partial Regex OpenSslVersionRegex { get; }
 
    /// <remarks>
    /// It is the caller's responsibility to ensure that <see cref="OpenSslCommand"/> is available.
    /// </remarks>
    private bool TryGetOpenSslDirectory([NotNullWhen(true)] out string? openSslDir)
    {
        openSslDir = null;
 
        try
        {
            var processInfo = new ProcessStartInfo(OpenSslCommand, $"version -d")
            {
                RedirectStandardOutput = true,
                RedirectStandardError = true
            };
 
            var processOutput = Process.RunAndCaptureText(processInfo);
            if (processOutput.ExitStatus.ExitCode != 0)
            {
                Log.UnixOpenSslVersionFailed();
                return false;
            }
 
            var match = OpenSslVersionRegex.Match(processOutput.StandardOutput);
            if (!match.Success)
            {
                Log.UnixOpenSslVersionParsingFailed();
                return false;
            }
 
            openSslDir = match.Groups[1].Value;
            return true;
        }
        catch (Exception ex)
        {
            Log.UnixOpenSslVersionException(ex.Message);
            return false;
        }
    }
 
    /// <remarks>
    /// It is the caller's responsibility to ensure that <see cref="OpenSslCommand"/> is available.
    /// </remarks>
    private bool TryGetOpenSslHash(string certificatePath, [NotNullWhen(true)] out string? hash)
    {
        hash = null;
 
        try
        {
            // c_rehash actually does this twice: once with -subject_hash (equivalent to -hash) and again
            // with -subject_hash_old.  Old hashes are only  needed for pre-1.0.0, so we skip that.
            var processInfo = new ProcessStartInfo(OpenSslCommand, $"x509 -hash -noout -in {certificatePath}")
            {
                RedirectStandardOutput = true,
                RedirectStandardError = true
            };
 
            var processOutput = Process.RunAndCaptureText(processInfo);
            if (processOutput.ExitStatus.ExitCode != 0)
            {
                Log.UnixOpenSslHashFailed(certificatePath);
                return false;
            }
 
            hash = processOutput.StandardOutput.Trim();
            return true;
        }
        catch (Exception ex)
        {
            Log.UnixOpenSslHashException(certificatePath, ex.Message);
            return false;
        }
    }
 
    [GeneratedRegex("^[0-9a-f]+\\.[0-9]+$")]
    private static partial Regex OpenSslHashFilenameRegex { get; }
 
    /// <remarks>
    /// We only ever use .pem, but someone will eventually put their own cert in this directory,
    /// so we should handle the same extensions as c_rehash (other than .crl).
    /// </remarks>
    [GeneratedRegex("\\.(pem|crt|cer)$")]
    private static partial Regex OpenSslCertificateExtensionRegex { get; }
 
    /// <remarks>
    /// This is a simplified version of c_rehash from OpenSSL.  Using the real one would require
    /// installing the OpenSSL perl tools and perl itself, which might be annoying in a container.
    /// </remarks>
    private bool TryRehashOpenSslCertificates(string certificateDirectory)
    {
        try
        {
            // First, delete all the existing symlinks, so we don't have to worry about fragmentation or leaks.
            var certs = new List<FileInfo>();
 
            var dirInfo = new DirectoryInfo(certificateDirectory);
            foreach (var file in dirInfo.EnumerateFiles())
            {
                var isSymlink = (file.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint;
                if (isSymlink && OpenSslHashFilenameRegex.IsMatch(file.Name))
                {
                    file.Delete();
                }
                else if (OpenSslCertificateExtensionRegex.IsMatch(file.Name))
                {
                    certs.Add(file);
                }
            }
 
            // Then, enumerate all certificates - there will usually be zero or one.
 
            // c_rehash doesn't create additional symlinks for certs with the same fingerprint,
            // but we don't expect this to happen, so we favor slightly slower look-ups when it
            // does, rather than slightly slower rehashing when it doesn't.
 
            foreach (var cert in certs)
            {
                if (!TryGetOpenSslHash(cert.FullName, out var hash))
                {
                    return false;
                }
 
                var linkCreated = false;
                for (var i = 0; i < MaxHashCollisions; i++)
                {
                    var linkPath = Path.Combine(certificateDirectory, $"{hash}.{i}");
                    if (!File.Exists(linkPath))
                    {
                        // As in c_rehash, we link using a relative path.
                        File.CreateSymbolicLink(linkPath, cert.Name);
                        linkCreated = true;
                        break;
                    }
                }
 
                if (!linkCreated)
                {
                    Log.UnixOpenSslRehashTooManyHashes(cert.FullName, hash, MaxHashCollisions);
                    return false;
                }
            }
        }
        catch (Exception ex)
        {
            Log.UnixOpenSslRehashException(ex.Message);
            return false;
        }
 
        return true;
    }
 
    internal abstract class NssDb(string path)
    {
        public string Path => path;
 
        public abstract string BrowserFamily { get; }
 
        public abstract IReadOnlyList<string> CheckArguments { get; }
 
        public abstract string TrustUsage { get; }
 
        internal ProcessStartInfo CreateCheckProcessStartInfo(string nickname)
        {
            var startInfo = CreateProcessStartInfo(nickname);
            foreach (var argument in CheckArguments)
            {
                startInfo.ArgumentList.Add(argument);
            }
 
            return startInfo;
        }
 
        internal ProcessStartInfo CreateAddProcessStartInfo(string certificatePath, string nickname)
        {
            var startInfo = CreateProcessStartInfo(nickname);
            startInfo.ArgumentList.Add("-A");
            startInfo.ArgumentList.Add("-i");
            startInfo.ArgumentList.Add(certificatePath);
            startInfo.ArgumentList.Add("-t");
            startInfo.ArgumentList.Add($"{TrustUsage},,");
 
            return startInfo;
        }
 
        internal ProcessStartInfo CreateRemoveProcessStartInfo(string nickname)
        {
            var startInfo = CreateProcessStartInfo(nickname);
            startInfo.ArgumentList.Add("-D");
 
            return startInfo;
        }
 
        internal static List<NssDb> Resolve(
            string homeDirectory,
            string? xdgConfigHome,
            string? nssDbOverride,
            string nssDbOverrideVariableName,
            UnixCertificateManager manager)
        {
            var nssDbs = new List<NssDb>();
 
            if (TryGetOverrides(homeDirectory, xdgConfigHome, nssDbOverride, nssDbOverrideVariableName, nssDbs, manager))
            {
                // Overrides replace discovery so trust and cleanup use the caller's complete set of NSS databases.
                return nssDbs;
            }
 
            if (!Directory.Exists(homeDirectory))
            {
                manager.Log.UnixHomeDirectoryDoesNotExist(homeDirectory, Environment.UserName);
                return nssDbs;
            }
 
            ChromiumNssDb.AddDiscoveredNssDbs(homeDirectory, nssDbs);
            FirefoxNssDb.AddDiscoveredNssDbs(homeDirectory, xdgConfigHome, nssDbs, manager);
 
            return nssDbs;
        }
 
        protected static bool TryRemovePrefix(string path, string prefix, out string unprefixedPath)
        {
            if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
            {
                unprefixedPath = path[prefix.Length..];
                return true;
            }
 
            unprefixedPath = path;
            return false;
        }
 
        private ProcessStartInfo CreateProcessStartInfo(string nickname)
        {
            var startInfo = new ProcessStartInfo(CertificateHelpers.CertUtilCommand);
            startInfo.ArgumentList.Add("-d");
            startInfo.ArgumentList.Add($"sql:{Path}");
            startInfo.ArgumentList.Add("-n");
            startInfo.ArgumentList.Add(nickname);
 
            return startInfo;
        }
 
        private static bool TryGetOverrides(
            string homeDirectory,
            string? xdgConfigHome,
            string? nssDbOverride,
            string nssDbOverrideVariableName,
            List<NssDb> nssDbs,
            UnixCertificateManager manager)
        {
            if (string.IsNullOrEmpty(nssDbOverride))
            {
                return false;
            }
 
            manager.Log.UnixNssDbOverridePresent(nssDbOverrideVariableName);
 
            var paths = nssDbOverride.Split(System.IO.Path.PathSeparator);
            foreach (var path in paths)
            {
                if (FirefoxNssDb.TryRemoveOverridePrefix(path, out var unprefixedPath))
                {
                    AddOverride(unprefixedPath, static nssDbPath => new FirefoxNssDb(nssDbPath), nssDbOverrideVariableName, nssDbs, manager);
                    continue;
                }
 
                if (ChromiumNssDb.TryRemoveOverridePrefix(path, out unprefixedPath))
                {
                    AddOverride(unprefixedPath, static nssDbPath => new ChromiumNssDb(nssDbPath), nssDbOverrideVariableName, nssDbs, manager);
                    continue;
                }
 
                AddOverride(
                    path,
                    nssDbPath => FirefoxNssDb.IsProfilePath(homeDirectory, xdgConfigHome, nssDbPath)
                        ? new FirefoxNssDb(nssDbPath)
                        : new ChromiumNssDb(nssDbPath),
                    nssDbOverrideVariableName,
                    nssDbs,
                    manager);
            }
 
            return true;
        }
 
        private static void AddOverride(
            string path,
            Func<string, NssDb> createNssDb,
            string nssDbOverrideVariableName,
            List<NssDb> nssDbs,
            UnixCertificateManager manager)
        {
            if (string.IsNullOrEmpty(path))
            {
                return;
            }
 
            var nssDbPath = System.IO.Path.GetFullPath(path);
            if (!Directory.Exists(nssDbPath))
            {
                manager.Log.UnixNssDbDoesNotExist(nssDbPath, nssDbOverrideVariableName);
                return;
            }
 
            nssDbs.Add(createNssDb(nssDbPath));
        }
    }
 
    private sealed class ChromiumNssDb(string path) : NssDb(path)
    {
        private const string OverridePrefixValue = "chromium=";
        private static readonly IReadOnlyList<string> s_checkArgumentValues = ["-V", "-u", "V"];
 
        public override string BrowserFamily => "Chromium";
 
        public override IReadOnlyList<string> CheckArguments => s_checkArgumentValues;
 
        public override string TrustUsage => "P";
 
        public static bool TryRemoveOverridePrefix(string path, out string unprefixedPath)
            => TryRemovePrefix(path, OverridePrefixValue, out unprefixedPath);
 
        public static void AddDiscoveredNssDbs(string homeDirectory, List<NssDb> nssDbs)
        {
            // Chrome, Chromium, and Edge all use this directory.
            var chromiumNssDb = System.IO.Path.Combine(homeDirectory, ".pki", "nssdb");
            if (Directory.Exists(chromiumNssDb))
            {
                nssDbs.Add(new ChromiumNssDb(chromiumNssDb));
            }
 
            // Chromium Snap uses this directory when launched under snap confinement.
            var chromiumSnapNssDb = System.IO.Path.Combine(homeDirectory, "snap", "chromium", "current", ".pki", "nssdb");
            if (Directory.Exists(chromiumSnapNssDb))
            {
                nssDbs.Add(new ChromiumNssDb(chromiumSnapNssDb));
            }
        }
    }
 
    private sealed class FirefoxNssDb(string path) : NssDb(path)
    {
        private const string OverridePrefixValue = "firefox=";
        private static readonly IReadOnlyList<string> s_checkArgumentValues = ["-L"];
 
        public override string BrowserFamily => "Firefox";
 
        public override IReadOnlyList<string> CheckArguments => s_checkArgumentValues;
 
        // Firefox doesn't seem to respect the more correct "trusted peer" (P) usage.
        public override string TrustUsage => "C";
 
        public static bool TryRemoveOverridePrefix(string path, out string unprefixedPath)
            => TryRemovePrefix(path, OverridePrefixValue, out unprefixedPath);
 
        public static void AddDiscoveredNssDbs(
            string homeDirectory,
            string? xdgConfigHome,
            List<NssDb> nssDbs,
            UnixCertificateManager manager)
        {
            // Firefox continues using its legacy profile root when present and only uses XDG for new profiles.
            var firefoxDirectory = GetLegacyDirectory(homeDirectory);
            if (!Directory.Exists(firefoxDirectory))
            {
                firefoxDirectory = GetXdgDirectory(homeDirectory, xdgConfigHome);
            }
 
            AddProfiles(firefoxDirectory, nssDbs, manager);
            AddProfiles(GetSnapDirectory(homeDirectory), nssDbs, manager);
        }
 
        public static bool IsProfilePath(string homeDirectory, string? xdgConfigHome, string nssDbPath)
        {
            if (IsPathInDirectory(nssDbPath, GetLegacyDirectory(homeDirectory)) ||
                IsPathInDirectory(nssDbPath, GetXdgDirectory(homeDirectory, xdgConfigHome)) ||
                IsPathInDirectory(nssDbPath, GetSnapDirectory(homeDirectory)))
            {
                return true;
            }
 
            var separator = System.IO.Path.DirectorySeparatorChar;
            return nssDbPath.Contains($"{separator}.mozilla{separator}firefox{separator}", StringComparison.Ordinal) ||
                nssDbPath.Contains($"{separator}.config{separator}mozilla{separator}firefox{separator}", StringComparison.Ordinal);
        }
 
        private static string GetLegacyDirectory(string homeDirectory)
            => System.IO.Path.Combine(homeDirectory, ".mozilla", "firefox");
 
        private static string GetXdgDirectory(string homeDirectory, string? xdgConfigHome)
        {
            var configDirectory = !string.IsNullOrEmpty(xdgConfigHome) && System.IO.Path.IsPathFullyQualified(xdgConfigHome)
                ? xdgConfigHome
                : System.IO.Path.Combine(homeDirectory, ".config");
 
            return System.IO.Path.Combine(configDirectory, "mozilla", "firefox");
        }
 
        private static string GetSnapDirectory(string homeDirectory)
            => System.IO.Path.Combine(homeDirectory, "snap", "firefox", "common", ".mozilla", "firefox");
 
        private static void AddProfiles(
            string firefoxDirectory,
            List<NssDb> nssDbs,
            UnixCertificateManager manager)
        {
            if (!Directory.Exists(firefoxDirectory))
            {
                return;
            }
 
            foreach (var profileDirectory in GetProfiles(firefoxDirectory, manager))
            {
                nssDbs.Add(new FirefoxNssDb(profileDirectory));
            }
        }
 
        private static IEnumerable<string> GetProfiles(string firefoxDirectory, UnixCertificateManager manager)
        {
            try
            {
                var profiles = Directory.GetDirectories(firefoxDirectory, "*.default", SearchOption.TopDirectoryOnly).Concat(
                    Directory.GetDirectories(firefoxDirectory, "*.default-*", SearchOption.TopDirectoryOnly));
                if (!profiles.Any())
                {
                    manager.Log.UnixNoFirefoxProfilesFound(firefoxDirectory);
                }
 
                return profiles;
            }
            catch (Exception ex)
            {
                manager.Log.UnixFirefoxProfileEnumerationException(firefoxDirectory, ex.Message);
                return [];
            }
        }
 
        private static bool IsPathInDirectory(string path, string directory)
        {
            var directoryPrefix = System.IO.Path.TrimEndingDirectorySeparator(System.IO.Path.GetFullPath(directory)) + System.IO.Path.DirectorySeparatorChar;
            var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
            return path.StartsWith(directoryPrefix, comparison);
        }
    }
}