From 36610f9aba757ee613de51e5ddb985a550012c89 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 21 Jul 2026 16:12:55 -0700 Subject: [PATCH 01/18] Improve Linux dev certificate trust handling Preserve Linux system certificate directories for local executable resources when AppHosts are launched without aspire run materializing SSL_CERT_DIR, and add doctor diagnostics for stale or corrupt OpenSSL dev certificate cache entries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677e0f4a-653a-4d97-b690-f86d67107226 --- .../UnixCertificateManager.cs | 16 +- .../DoctorCommandStrings.Designer.cs | 45 ++++ .../Resources/DoctorCommandStrings.resx | 18 ++ .../Resources/xlf/DoctorCommandStrings.cs.xlf | 25 ++ .../Resources/xlf/DoctorCommandStrings.de.xlf | 25 ++ .../Resources/xlf/DoctorCommandStrings.es.xlf | 25 ++ .../Resources/xlf/DoctorCommandStrings.fr.xlf | 25 ++ .../Resources/xlf/DoctorCommandStrings.it.xlf | 25 ++ .../Resources/xlf/DoctorCommandStrings.ja.xlf | 25 ++ .../Resources/xlf/DoctorCommandStrings.ko.xlf | 25 ++ .../Resources/xlf/DoctorCommandStrings.pl.xlf | 25 ++ .../xlf/DoctorCommandStrings.pt-BR.xlf | 25 ++ .../Resources/xlf/DoctorCommandStrings.ru.xlf | 25 ++ .../Resources/xlf/DoctorCommandStrings.tr.xlf | 25 ++ .../xlf/DoctorCommandStrings.zh-Hans.xlf | 25 ++ .../xlf/DoctorCommandStrings.zh-Hant.xlf | 25 ++ .../Utils/EnvironmentChecker/DevCertsCheck.cs | 122 ++++++++++ src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 46 +++- .../UnixCertificateManagerTests.cs | 32 +++ .../Utils/DevCertsCheckTests.cs | 222 +++++++++++++++++- .../Dcp/DcpExecutorTests.cs | 58 +++++ 21 files changed, 862 insertions(+), 22 deletions(-) diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs index 216daa18287..fa5033b47e5 100644 --- a/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text.RegularExpressions; using Aspire.Cli; @@ -116,11 +117,18 @@ public override TrustLevel GetTrustLevel(X509Certificate2 certificate) var certPath = Path.Combine(sslCertDir, certificateNickname + ".pem"); if (File.Exists(certPath)) { - using var candidate = X509CertificateLoader.LoadCertificateFromFile(certPath); - if (AreCertificatesEqual(certificate, candidate)) + try { - foundCert = true; - break; + using var candidate = X509CertificateLoader.LoadCertificateFromFile(certPath); + if (AreCertificatesEqual(certificate, candidate)) + { + foundCert = true; + break; + } + } + catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException) + { + Log.UnixNotTrustedByOpenSsl(OpenSslCertificateDirectoryVariableName); } } } diff --git a/src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs index 6b949aff43c..e454d825bef 100644 --- a/src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs @@ -455,6 +455,51 @@ public static string DevCertsMissingCertUtilFix { } } + /// + /// Looks up a localized string similar to OpenSSL HTTPS development certificate cache is missing the current certificate. + /// + public static string DevCertsOpenSslCacheMissingCurrentCertificateMessage { + get { + return ResourceManager.GetString("DevCertsOpenSslCacheMissingCurrentCertificateMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates.. + /// + public static string DevCertsOpenSslCacheMissingDetailsFormat { + get { + return ResourceManager.GetString("DevCertsOpenSslCacheMissingDetailsFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to OpenSSL HTTPS development certificate cache contains unreadable certificate files. + /// + public static string DevCertsOpenSslCacheUnreadableMessage { + get { + return ResourceManager.GetString("DevCertsOpenSslCacheUnreadableMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates.. + /// + public static string DevCertsOpenSslCacheUnreadableDetailsFormat { + get { + return ResourceManager.GetString("DevCertsOpenSslCacheUnreadableDetailsFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates.. + /// + public static string DevCertsOpenSslCacheUnreadableFilesDetailsFormat { + get { + return ResourceManager.GetString("DevCertsOpenSslCacheUnreadableFilesDetailsFormat", resourceCulture); + } + } + /// /// Looks up a localized string similar to Developer Control Plane (DCP) bundle not found; skipping connection health checks. /// diff --git a/src/Aspire.Cli/Resources/DoctorCommandStrings.resx b/src/Aspire.Cli/Resources/DoctorCommandStrings.resx index 54c75958aaf..24850489d48 100644 --- a/src/Aspire.Cli/Resources/DoctorCommandStrings.resx +++ b/src/Aspire.Cli/Resources/DoctorCommandStrings.resx @@ -200,6 +200,24 @@ Install certutil from your distribution's NSS tools package (for example, libnss3-tools). + + OpenSSL HTTPS development certificate cache is missing the current certificate + + + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + OpenSSL HTTPS development certificate cache contains unreadable certificate files + + + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the error message. + + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is a comma-separated list of certificate file names. + Developer Control Plane (DCP) bundle not found; skipping connection health checks diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf index 206e4afb801..eb171c7588c 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf @@ -287,6 +287,31 @@ HTTPS development certificate has an older version ({0}) + + OpenSSL HTTPS development certificate cache is missing the current certificate + OpenSSL HTTPS development certificate cache is missing the current certificate + + + + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the error message. + + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is a comma-separated list of certificate file names. + + + OpenSSL HTTPS development certificate cache contains unreadable certificate files + OpenSSL HTTPS development certificate cache contains unreadable certificate files + + The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf index bb3501ef481..4ebd0aa1c9f 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf @@ -287,6 +287,31 @@ HTTPS development certificate has an older version ({0}) + + OpenSSL HTTPS development certificate cache is missing the current certificate + OpenSSL HTTPS development certificate cache is missing the current certificate + + + + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the error message. + + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is a comma-separated list of certificate file names. + + + OpenSSL HTTPS development certificate cache contains unreadable certificate files + OpenSSL HTTPS development certificate cache contains unreadable certificate files + + The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf index 70df006cd1a..75fc9eb4aae 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf @@ -287,6 +287,31 @@ HTTPS development certificate has an older version ({0}) + + OpenSSL HTTPS development certificate cache is missing the current certificate + OpenSSL HTTPS development certificate cache is missing the current certificate + + + + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the error message. + + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is a comma-separated list of certificate file names. + + + OpenSSL HTTPS development certificate cache contains unreadable certificate files + OpenSSL HTTPS development certificate cache contains unreadable certificate files + + The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf index 65b407937f3..9bfcfe24a1c 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf @@ -287,6 +287,31 @@ HTTPS development certificate has an older version ({0}) + + OpenSSL HTTPS development certificate cache is missing the current certificate + OpenSSL HTTPS development certificate cache is missing the current certificate + + + + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the error message. + + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is a comma-separated list of certificate file names. + + + OpenSSL HTTPS development certificate cache contains unreadable certificate files + OpenSSL HTTPS development certificate cache contains unreadable certificate files + + The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf index 4d5dbe60a66..2a8bb9d477d 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf @@ -287,6 +287,31 @@ HTTPS development certificate has an older version ({0}) + + OpenSSL HTTPS development certificate cache is missing the current certificate + OpenSSL HTTPS development certificate cache is missing the current certificate + + + + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the error message. + + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is a comma-separated list of certificate file names. + + + OpenSSL HTTPS development certificate cache contains unreadable certificate files + OpenSSL HTTPS development certificate cache contains unreadable certificate files + + The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf index 4582625018b..30133b50529 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf @@ -287,6 +287,31 @@ HTTPS development certificate has an older version ({0}) + + OpenSSL HTTPS development certificate cache is missing the current certificate + OpenSSL HTTPS development certificate cache is missing the current certificate + + + + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the error message. + + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is a comma-separated list of certificate file names. + + + OpenSSL HTTPS development certificate cache contains unreadable certificate files + OpenSSL HTTPS development certificate cache contains unreadable certificate files + + The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf index 54099f53d49..914550bb50a 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf @@ -287,6 +287,31 @@ HTTPS development certificate has an older version ({0}) + + OpenSSL HTTPS development certificate cache is missing the current certificate + OpenSSL HTTPS development certificate cache is missing the current certificate + + + + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the error message. + + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is a comma-separated list of certificate file names. + + + OpenSSL HTTPS development certificate cache contains unreadable certificate files + OpenSSL HTTPS development certificate cache contains unreadable certificate files + + The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf index 75681f5b8c6..a45f5b500e7 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf @@ -287,6 +287,31 @@ HTTPS development certificate has an older version ({0}) + + OpenSSL HTTPS development certificate cache is missing the current certificate + OpenSSL HTTPS development certificate cache is missing the current certificate + + + + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the error message. + + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is a comma-separated list of certificate file names. + + + OpenSSL HTTPS development certificate cache contains unreadable certificate files + OpenSSL HTTPS development certificate cache contains unreadable certificate files + + The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf index 35ff2c800f5..04c0aee5ab8 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf @@ -287,6 +287,31 @@ HTTPS development certificate has an older version ({0}) + + OpenSSL HTTPS development certificate cache is missing the current certificate + OpenSSL HTTPS development certificate cache is missing the current certificate + + + + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the error message. + + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is a comma-separated list of certificate file names. + + + OpenSSL HTTPS development certificate cache contains unreadable certificate files + OpenSSL HTTPS development certificate cache contains unreadable certificate files + + The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf index 97943c9f61e..3b1813180f4 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf @@ -287,6 +287,31 @@ HTTPS development certificate has an older version ({0}) + + OpenSSL HTTPS development certificate cache is missing the current certificate + OpenSSL HTTPS development certificate cache is missing the current certificate + + + + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the error message. + + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is a comma-separated list of certificate file names. + + + OpenSSL HTTPS development certificate cache contains unreadable certificate files + OpenSSL HTTPS development certificate cache contains unreadable certificate files + + The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf index b792b1cf1f2..ba85ffb9bb8 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf @@ -287,6 +287,31 @@ HTTPS development certificate has an older version ({0}) + + OpenSSL HTTPS development certificate cache is missing the current certificate + OpenSSL HTTPS development certificate cache is missing the current certificate + + + + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the error message. + + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is a comma-separated list of certificate file names. + + + OpenSSL HTTPS development certificate cache contains unreadable certificate files + OpenSSL HTTPS development certificate cache contains unreadable certificate files + + The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf index 3a781342b2e..ac9e64ab235 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf @@ -287,6 +287,31 @@ HTTPS development certificate has an older version ({0}) + + OpenSSL HTTPS development certificate cache is missing the current certificate + OpenSSL HTTPS development certificate cache is missing the current certificate + + + + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the error message. + + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is a comma-separated list of certificate file names. + + + OpenSSL HTTPS development certificate cache contains unreadable certificate files + OpenSSL HTTPS development certificate cache contains unreadable certificate files + + The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf index 5cde62f77f6..5566ffa1055 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf @@ -287,6 +287,31 @@ HTTPS development certificate has an older version ({0}) + + OpenSSL HTTPS development certificate cache is missing the current certificate + OpenSSL HTTPS development certificate cache is missing the current certificate + + + + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is the error message. + + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. + {0} is the OpenSSL certificate cache directory. {1} is a comma-separated list of certificate file names. + + + OpenSSL HTTPS development certificate cache contains unreadable certificate files + OpenSSL HTTPS development certificate cache contains unreadable certificate files + + The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{0}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically. diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs index bd98a403ea4..befa562db08 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Text.Json.Nodes; using Aspire.Cli.Certificates; using Aspire.Cli.Resources; @@ -18,6 +20,7 @@ internal sealed class DevCertsCheck(ILogger logger, ICertificateT internal const string CheckName = "dev-certs"; internal const string VersionCheckName = "dev-certs-version"; internal const string CertUtilCheckName = "dev-certs-certutil"; + internal const string OpenSslCertificateCacheCheckName = "dev-certs-openssl-cache"; public int Order => 35; // After SDK check (30), before container checks (40+) @@ -30,6 +33,7 @@ public Task> CheckAsync(CancellationToken { var trustResult = certificateToolRunner.CheckHttpCertificate(); var results = EvaluateCertificateResults(trustResult.Certificates, environment); + AddLinuxOpenSslCertificateCacheWarnings(results, trustResult.Certificates, environment); AddLinuxCertificateToolWarnings(results, environment); return Task.FromResult>(results); @@ -203,6 +207,122 @@ internal static List EvaluateCertificateResults( return results; } + private static void AddLinuxOpenSslCertificateCacheWarnings(List results, IReadOnlyList certInfos, IEnvironment environment) + { + if (!environment.IsLinux()) + { + return; + } + + var currentCertificate = GetCurrentDevCertificate(certInfos); + if (currentCertificate?.Thumbprint is null) + { + return; + } + + var trustPath = CertificateHelpers.GetDevCertsTrustPath(environment); + var cacheStatus = EvaluateOpenSslCertificateCache(trustPath, currentCertificate.Thumbprint); + if (cacheStatus is null) + { + return; + } + + results.Add(new EnvironmentCheckResult + { + Category = EnvironmentCheckCategories.Environment, + Name = OpenSslCertificateCacheCheckName, + Status = EnvironmentCheckStatus.Warning, + Message = cacheStatus.Message, + Details = cacheStatus.Details, + Fix = s_cleanAndTrustFixCommand, + Link = "https://aka.ms/aspire-prerequisites#dev-certs" + }); + } + + private static DevCertInfo? GetCurrentDevCertificate(IReadOnlyList certInfos) + { + var now = DateTimeOffset.Now; + return certInfos + .Where(c => c.IsHttpsDevelopmentCertificate && + c.ValidityNotBefore <= now && + now <= c.ValidityNotAfter && + !string.IsNullOrEmpty(c.Thumbprint)) + .OrderByDescending(c => c.Version) + .ThenByDescending(c => c.ValidityNotAfter) + .FirstOrDefault(); + } + + private static OpenSslCertificateCacheStatus? EvaluateOpenSslCertificateCache(string trustPath, string currentCertificateThumbprint) + { + if (!Directory.Exists(trustPath)) + { + return new( + DoctorCommandStrings.DevCertsOpenSslCacheMissingCurrentCertificateMessage, + string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheMissingDetailsFormat, trustPath, currentCertificateThumbprint)); + } + + string[] certificateFiles; + try + { + // The Linux dev-certs OpenSSL cache stores certificate files such as: + // aspnetcore-localhost-.pem + // OpenSSL/c_rehash can also create hash symlinks like .0 in the same directory, + // but only .pem/.crt/.cer files are certificate inputs that should be parsed here. + certificateFiles = Directory.GetFiles(trustPath) + .Where(IsOpenSslCertificateFile) + .ToArray(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return new( + DoctorCommandStrings.DevCertsOpenSslCacheUnreadableMessage, + string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheUnreadableDetailsFormat, trustPath, ex.Message)); + } + + var cachedThumbprints = new HashSet(StringComparer.OrdinalIgnoreCase); + var unreadableFiles = new List(); + + foreach (var certificateFile in certificateFiles) + { + try + { + using var cachedCertificate = X509CertificateLoader.LoadCertificateFromFile(certificateFile); + if (cachedCertificate.Thumbprint is not null) + { + cachedThumbprints.Add(cachedCertificate.Thumbprint); + } + } + catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException) + { + unreadableFiles.Add(Path.GetFileName(certificateFile)); + } + } + + if (unreadableFiles.Count > 0) + { + return new( + DoctorCommandStrings.DevCertsOpenSslCacheUnreadableMessage, + string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheUnreadableFilesDetailsFormat, trustPath, string.Join(", ", unreadableFiles))); + } + + if (!cachedThumbprints.Contains(currentCertificateThumbprint)) + { + return new( + DoctorCommandStrings.DevCertsOpenSslCacheMissingCurrentCertificateMessage, + string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheMissingDetailsFormat, trustPath, currentCertificateThumbprint)); + } + + return null; + } + + private static bool IsOpenSslCertificateFile(string path) + { + var extension = Path.GetExtension(path); + return extension.Equals(".pem", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".crt", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".cer", StringComparison.OrdinalIgnoreCase); + } + private static void AddLinuxCertificateToolWarnings(List results, IEnvironment environment) { if (!environment.IsLinux()) @@ -288,4 +408,6 @@ private static string BuildSslCertDirFixCommand(string devCertsTrustPath, IEnvir // We still prepend $SSL_CERT_DIR to be safe in case the user makes later modifications to their environment return $"export SSL_CERT_DIR=\"$SSL_CERT_DIR:{string.Join(':', systemCertDirs)}\""; } + + private sealed record OpenSslCertificateCacheStatus(string Message, string Details); } diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index fa51a923884..e8dbe452e20 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -23,6 +23,8 @@ namespace Aspire.Hosting.Dcp; /// internal sealed class ExecutableCreator : IObjectCreator { + private const string SslCertDirEnvVar = "SSL_CERT_DIR"; + private readonly IConfiguration _configuration; private readonly DcpNameGenerator _nameGenerator; private readonly DistributedApplicationModel _model; @@ -498,21 +500,14 @@ private async Task BuildExecutableConfiguration(Rendere .WithEnvironmentVariablesConfig() .WithCertificateTrustConfig(scope => { - var dirs = new List { certificatesOutputPath }; - if (scope == CertificateTrustScope.Append) - { - var existing = Environment.GetEnvironmentVariable("SSL_CERT_DIR"); - if (!string.IsNullOrEmpty(existing)) - { - dirs.AddRange(existing.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)); - } - } - return new() { CertificateBundlePath = ReferenceExpression.Create($"{bundleOutputPath}"), - // Build the SSL_CERT_DIR value by combining the new certs directory with any existing directories. - CertificateDirectoriesPath = ReferenceExpression.Create($"{string.Join(Path.PathSeparator, dirs)}"), + CertificateDirectoriesPath = ReferenceExpression.Create($"{BuildCertificateDirectoriesPath( + certificatesOutputPath, + scope, + Environment.GetEnvironmentVariable(SslCertDirEnvVar), + includeWellKnownCertificateDirectories: OperatingSystem.IsLinux())}"), RootCertificatesPath = certificatesRootDir, }; }) @@ -600,6 +595,33 @@ private async Task BuildExecutableConfiguration(Rendere return (configuration, pemCertificates); } + internal static string BuildCertificateDirectoriesPath( + string certificatesOutputPath, + CertificateTrustScope scope, + string? existingSslCertDir, + bool includeWellKnownCertificateDirectories, + Func? directoryExists = null) + { + var dirs = new List { certificatesOutputPath }; + if (scope == CertificateTrustScope.Append) + { + if (existingSslCertDir is not null) + { + dirs.AddRange(existingSslCertDir.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)); + } + else if (includeWellKnownCertificateDirectories) + { + directoryExists ??= Directory.Exists; + // Do not invoke the openssl CLI here. This fallback is only for dotnet-run AppHosts + // where Aspire CLI did not already materialize OpenSSL's default directory into + // SSL_CERT_DIR, so reuse the same well-known certificate directories used for containers. + dirs.AddRange(ContainerCertificatePathsAnnotation.DefaultCertificateDirectoriesPaths.Where(directoryExists)); + } + } + + return string.Join(Path.PathSeparator, dirs); + } + private string GetCertificatesRootDirectory(RenderedModelResource er, Executable exe) { if (er.ModelResource.GetLifetimeType() == Lifetime.Persistent) diff --git a/tests/Aspire.Cli.Tests/Certificates/UnixCertificateManagerTests.cs b/tests/Aspire.Cli.Tests/Certificates/UnixCertificateManagerTests.cs index cdcd85d6efe..a1855109b97 100644 --- a/tests/Aspire.Cli.Tests/Certificates/UnixCertificateManagerTests.cs +++ b/tests/Aspire.Cli.Tests/Certificates/UnixCertificateManagerTests.cs @@ -10,6 +10,38 @@ namespace Aspire.Cli.Tests.Certificates; public class UnixCertificateManagerTests { + [Fact] + public void GetTrustLevel_WithCorruptOpenSslCertificate_DoesNotThrow() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "OpenSSL certificate directory trust is only exercised on Linux."); + + var openSslDirectory = Directory.CreateTempSubdirectory(); + + try + { + var environment = TestEnvironment.CreateLinux(new Dictionary + { + ["PATH"] = Path.Combine(openSslDirectory.FullName, "missing-tools"), + ["SSL_CERT_DIR"] = openSslDirectory.FullName, + ["DOTNET_DEV_CERTS_NSSDB_PATHS"] = Path.Combine(openSslDirectory.FullName, "missing-nss-db") + }); + var manager = new UnixCertificateManager(NullLogger.Instance, environment); + using var certificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + var certificatePath = Path.Combine(openSslDirectory.FullName, $"aspnetcore-localhost-{certificate.Thumbprint}.pem"); + File.WriteAllText(certificatePath, "not a certificate"); + + var exception = Record.Exception(() => manager.GetTrustLevel(certificate)); + + Assert.Null(exception); + } + finally + { + openSslDirectory.Delete(recursive: true); + } + } + [Fact] public void RemoveCertificate_WithMissingCertUtilAndNssDbs_SkipsNssCleanup() { diff --git a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs index 8c62d95132d..9c3bf9cfecc 100644 --- a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using Aspire.Cli.Certificates; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Utils.EnvironmentChecker; @@ -29,6 +31,41 @@ private static DevCertInfo CreateDevCertInfo(CertificateManager.TrustLevel trust }; } + private static DevCertInfo CreateDevCertInfo(CertificateManager.TrustLevel trustLevel, X509Certificate2 certificate, int version) + { + return new DevCertInfo + { + TrustLevel = trustLevel, + Thumbprint = certificate.Thumbprint, + Version = version, + ValidityNotBefore = certificate.NotBefore, + ValidityNotAfter = certificate.NotAfter, + Subject = certificate.Subject, + IsHttpsDevelopmentCertificate = true, + IsExportable = true + }; + } + + private static X509Certificate2 CreateCertificate() + { + using var key = RSA.Create(2048); + var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + return X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert)); + } + + private static string CreateCertUtil(DirectoryInfo directory) + { + var certUtilPath = Path.Combine(directory.FullName, CertificateHelpers.CertUtilCommand); + File.WriteAllText(certUtilPath, ""); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(certUtilPath, UnixFileMode.UserRead | UnixFileMode.UserExecute); + } + + return certUtilPath; + } + [Fact] public void EvaluateCertificateResults_NoCertificates_ReturnsWarning() { @@ -270,12 +307,7 @@ public async Task CheckAsync_LinuxWithCertUtil_DoesNotReturnCertUtilWarning() try { - var certUtilPath = Path.Combine(tempDirectory.FullName, CertificateHelpers.CertUtilCommand); - File.WriteAllText(certUtilPath, ""); - if (!OperatingSystem.IsWindows()) - { - File.SetUnixFileMode(certUtilPath, UnixFileMode.UserRead | UnixFileMode.UserExecute); - } + CreateCertUtil(tempDirectory); var certs = new List { @@ -306,6 +338,184 @@ public async Task CheckAsync_LinuxWithCertUtil_DoesNotReturnCertUtilWarning() } } + [Fact] + public async Task CheckAsync_LinuxWithMatchingOpenSslCertificateCache_DoesNotReturnOpenSslCertificateCacheWarning() + { + using var certificate = CreateCertificate(); + var tempDirectory = Directory.CreateTempSubdirectory(); + + try + { + CreateCertUtil(tempDirectory); + var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); + File.WriteAllText(Path.Combine(trustDirectory.FullName, "localhost.pem"), certificate.ExportCertificatePem()); + + var certs = new List + { + CreateDevCertInfo(CertificateManager.TrustLevel.Full, certificate, MinVersion), + }; + var toolRunner = new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.Full, + Certificates = certs + } + }; + var environment = TestEnvironment.CreateLinux(new Dictionary + { + ["PATH"] = tempDirectory.FullName, + [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName + }); + var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + + var results = await check.CheckAsync(); + + Assert.DoesNotContain(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + + [Fact] + public async Task CheckAsync_LinuxWithCorruptOpenSslCertificateCache_ReturnsOpenSslCertificateCacheWarning() + { + using var certificate = CreateCertificate(); + var tempDirectory = Directory.CreateTempSubdirectory(); + + try + { + CreateCertUtil(tempDirectory); + var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); + File.WriteAllText(Path.Combine(trustDirectory.FullName, "broken.pem"), "not a certificate"); + + var certs = new List + { + CreateDevCertInfo(CertificateManager.TrustLevel.Full, certificate, MinVersion), + }; + var toolRunner = new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.Full, + Certificates = certs + } + }; + var environment = TestEnvironment.CreateLinux(new Dictionary + { + ["PATH"] = tempDirectory.FullName, + [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName + }); + var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + + var results = await check.CheckAsync(); + + var cacheResult = Assert.Single(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); + Assert.Equal(EnvironmentCheckStatus.Warning, cacheResult.Status); + Assert.Contains("broken.pem", cacheResult.Details); + Assert.Contains("aspire certs clean", cacheResult.Fix); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + + [Fact] + public async Task CheckAsync_LinuxWithUntrustedCertificateAndCorruptOpenSslCertificateCache_ReturnsOpenSslCertificateCacheWarning() + { + using var certificate = CreateCertificate(); + var tempDirectory = Directory.CreateTempSubdirectory(); + + try + { + CreateCertUtil(tempDirectory); + var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); + File.WriteAllText(Path.Combine(trustDirectory.FullName, "broken.pem"), "not a certificate"); + + var certs = new List + { + CreateDevCertInfo(CertificateManager.TrustLevel.None, certificate, MinVersion), + }; + var toolRunner = new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.None, + Certificates = certs + } + }; + var environment = TestEnvironment.CreateLinux(new Dictionary + { + ["PATH"] = tempDirectory.FullName, + [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName + }); + var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + + var results = await check.CheckAsync(); + + var cacheResult = Assert.Single(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); + Assert.Equal(EnvironmentCheckStatus.Warning, cacheResult.Status); + Assert.Contains("broken.pem", cacheResult.Details); + Assert.Contains("aspire certs clean", cacheResult.Fix); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + + [Fact] + public async Task CheckAsync_LinuxWithStaleOpenSslCertificateCache_ReturnsOpenSslCertificateCacheWarning() + { + using var certificate = CreateCertificate(); + using var staleCertificate = CreateCertificate(); + var tempDirectory = Directory.CreateTempSubdirectory(); + + try + { + CreateCertUtil(tempDirectory); + var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); + File.WriteAllText(Path.Combine(trustDirectory.FullName, "stale.pem"), staleCertificate.ExportCertificatePem()); + + var certs = new List + { + CreateDevCertInfo(CertificateManager.TrustLevel.Full, certificate, MinVersion), + }; + var toolRunner = new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.Full, + Certificates = certs + } + }; + var environment = TestEnvironment.CreateLinux(new Dictionary + { + ["PATH"] = tempDirectory.FullName, + [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName + }); + var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + + var results = await check.CheckAsync(); + + var cacheResult = Assert.Single(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); + Assert.Equal(EnvironmentCheckStatus.Warning, cacheResult.Status); + Assert.Contains(certificate.Thumbprint, cacheResult.Details); + Assert.Contains("aspire certs clean", cacheResult.Fix); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + [Fact] public async Task CheckAsync_NonLinux_DoesNotReadEnvironmentVariablesForCertUtilWarning() { diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index a2b8b787161..63693ad7089 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -3541,6 +3541,64 @@ public async Task PersistentPlainExecutable_UsesStableCertificateOutputPath() Assert.Equal(Path.Join(expectedCertificatesRoot, "cert.pem"), sslCertFile); } + [Fact] + public void ExecutableCertificateDirectoriesPath_IncludesExistingWellKnownDirectoriesForAppendWhenSslCertDirIsUnset() + { + var result = ExecutableCreator.BuildCertificateDirectoriesPath( + "/tmp/aspire/certs", + CertificateTrustScope.Append, + existingSslCertDir: null, + includeWellKnownCertificateDirectories: true, + directoryExists: path => path is "/etc/ssl/certs" or "/etc/pki/tls/certs"); + + Assert.Equal( + string.Join(Path.PathSeparator, "/tmp/aspire/certs", "/etc/ssl/certs", "/etc/pki/tls/certs"), + result); + } + + [Fact] + public void ExecutableCertificateDirectoriesPath_PreservesExistingSslCertDirForAppendWithoutAddingWellKnownDirectories() + { + var existingSslCertDir = string.Join(Path.PathSeparator, "/custom/certs", "/home/me/.aspnet/dev-certs/trust"); + + var result = ExecutableCreator.BuildCertificateDirectoriesPath( + "/tmp/aspire/certs", + CertificateTrustScope.Append, + existingSslCertDir, + includeWellKnownCertificateDirectories: true, + directoryExists: _ => true); + + Assert.Equal( + string.Join(Path.PathSeparator, "/tmp/aspire/certs", "/custom/certs", "/home/me/.aspnet/dev-certs/trust"), + result); + } + + [Fact] + public void ExecutableCertificateDirectoriesPath_PreservesEmptySslCertDirForAppendWithoutAddingWellKnownDirectories() + { + var result = ExecutableCreator.BuildCertificateDirectoriesPath( + "/tmp/aspire/certs", + CertificateTrustScope.Append, + existingSslCertDir: string.Empty, + includeWellKnownCertificateDirectories: true, + directoryExists: _ => true); + + Assert.Equal("/tmp/aspire/certs", result); + } + + [Fact] + public void ExecutableCertificateDirectoriesPath_DoesNotIncludeExistingOrWellKnownDirectoriesForOverride() + { + var result = ExecutableCreator.BuildCertificateDirectoriesPath( + "/tmp/aspire/certs", + CertificateTrustScope.Override, + existingSslCertDir: "/custom/certs", + includeWellKnownCertificateDirectories: true, + directoryExists: _ => true); + + Assert.Equal("/tmp/aspire/certs", result); + } + [Fact] public async Task SessionScopedExplicitStartPlainExecutable_DefersDcpObjectCreationUntilManualStart() { From f3aaba2f3e17c99135c5f7d93c4729f43e234f1d Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 21 Jul 2026 16:25:57 -0700 Subject: [PATCH 02/18] Narrow Linux dev cert cache diagnostics Only inspect OpenSSL cache files derived from certificates found in the current user certificate store so doctor does not report unrelated corrupt files that aspire certs clean will not remove. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677e0f4a-653a-4d97-b690-f86d67107226 --- .../Utils/EnvironmentChecker/DevCertsCheck.cs | 85 +++++++++++-------- .../Utils/DevCertsCheckTests.cs | 60 +++++++++++-- 2 files changed, 102 insertions(+), 43 deletions(-) diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs index befa562db08..e89de5bfaf7 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs @@ -214,14 +214,14 @@ private static void AddLinuxOpenSslCertificateCacheWarnings(List certInfos) + private static IEnumerable GetCurrentDevCertificates(IReadOnlyList certInfos) { var now = DateTimeOffset.Now; return certInfos @@ -249,52 +249,53 @@ private static void AddLinuxOpenSslCertificateCacheWarnings(List c.Version) .ThenByDescending(c => c.ValidityNotAfter) - .FirstOrDefault(); + .ThenBy(c => c.Thumbprint, StringComparer.OrdinalIgnoreCase); } - private static OpenSslCertificateCacheStatus? EvaluateOpenSslCertificateCache(string trustPath, string currentCertificateThumbprint) + private static OpenSslCertificateCacheStatus? EvaluateOpenSslCertificateCache(string trustPath, IReadOnlyList currentCertificates) { if (!Directory.Exists(trustPath)) { - return new( - DoctorCommandStrings.DevCertsOpenSslCacheMissingCurrentCertificateMessage, - string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheMissingDetailsFormat, trustPath, currentCertificateThumbprint)); - } + var trustedThumbprints = GetTrustedThumbprints(currentCertificates); + if (trustedThumbprints.Count == 0) + { + return null; + } - string[] certificateFiles; - try - { - // The Linux dev-certs OpenSSL cache stores certificate files such as: - // aspnetcore-localhost-.pem - // OpenSSL/c_rehash can also create hash symlinks like .0 in the same directory, - // but only .pem/.crt/.cer files are certificate inputs that should be parsed here. - certificateFiles = Directory.GetFiles(trustPath) - .Where(IsOpenSslCertificateFile) - .ToArray(); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { return new( - DoctorCommandStrings.DevCertsOpenSslCacheUnreadableMessage, - string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheUnreadableDetailsFormat, trustPath, ex.Message)); + DoctorCommandStrings.DevCertsOpenSslCacheMissingCurrentCertificateMessage, + string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheMissingDetailsFormat, trustPath, string.Join(", ", trustedThumbprints))); } - var cachedThumbprints = new HashSet(StringComparer.OrdinalIgnoreCase); var unreadableFiles = new List(); + var mismatchedThumbprints = new List(); + var missingTrustedThumbprints = new List(); - foreach (var certificateFile in certificateFiles) + foreach (var certificate in currentCertificates) { + var certificateFileName = GetOpenSslCertificateFileName(certificate.Thumbprint!); + var certificateFile = Path.Combine(trustPath, certificateFileName); + if (!File.Exists(certificateFile)) + { + if (certificate.TrustLevel != CertificateManager.TrustLevel.None) + { + missingTrustedThumbprints.Add(certificate.Thumbprint!); + } + + continue; + } + try { using var cachedCertificate = X509CertificateLoader.LoadCertificateFromFile(certificateFile); - if (cachedCertificate.Thumbprint is not null) + if (!string.Equals(certificate.Thumbprint, cachedCertificate.Thumbprint, StringComparison.OrdinalIgnoreCase)) { - cachedThumbprints.Add(cachedCertificate.Thumbprint); + mismatchedThumbprints.Add(certificate.Thumbprint!); } } catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException) { - unreadableFiles.Add(Path.GetFileName(certificateFile)); + unreadableFiles.Add(certificateFileName); } } @@ -305,24 +306,34 @@ private static void AddLinuxOpenSslCertificateCacheWarnings(List 0) + { + return new( + DoctorCommandStrings.DevCertsOpenSslCacheMissingCurrentCertificateMessage, + string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheMissingDetailsFormat, trustPath, string.Join(", ", mismatchedThumbprints))); + } + + if (missingTrustedThumbprints.Count > 0) { return new( DoctorCommandStrings.DevCertsOpenSslCacheMissingCurrentCertificateMessage, - string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheMissingDetailsFormat, trustPath, currentCertificateThumbprint)); + string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheMissingDetailsFormat, trustPath, string.Join(", ", missingTrustedThumbprints))); } return null; } - private static bool IsOpenSslCertificateFile(string path) + private static List GetTrustedThumbprints(IReadOnlyList currentCertificates) { - var extension = Path.GetExtension(path); - return extension.Equals(".pem", StringComparison.OrdinalIgnoreCase) || - extension.Equals(".crt", StringComparison.OrdinalIgnoreCase) || - extension.Equals(".cer", StringComparison.OrdinalIgnoreCase); + return currentCertificates + .Where(c => c.TrustLevel != CertificateManager.TrustLevel.None) + .Select(c => c.Thumbprint!) + .ToList(); } + private static string GetOpenSslCertificateFileName(string certificateThumbprint) => + $"aspnetcore-localhost-{certificateThumbprint}.pem"; + private static void AddLinuxCertificateToolWarnings(List results, IEnvironment environment) { if (!environment.IsLinux()) diff --git a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs index 9c3bf9cfecc..66c61d80b08 100644 --- a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs @@ -66,6 +66,9 @@ private static string CreateCertUtil(DirectoryInfo directory) return certUtilPath; } + private static string GetOpenSslCertificateFileName(X509Certificate2 certificate) => + $"aspnetcore-localhost-{certificate.Thumbprint}.pem"; + [Fact] public void EvaluateCertificateResults_NoCertificates_ReturnsWarning() { @@ -348,7 +351,7 @@ public async Task CheckAsync_LinuxWithMatchingOpenSslCertificateCache_DoesNotRet { CreateCertUtil(tempDirectory); var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); - File.WriteAllText(Path.Combine(trustDirectory.FullName, "localhost.pem"), certificate.ExportCertificatePem()); + File.WriteAllText(Path.Combine(trustDirectory.FullName, GetOpenSslCertificateFileName(certificate)), certificate.ExportCertificatePem()); var certs = new List { @@ -390,7 +393,8 @@ public async Task CheckAsync_LinuxWithCorruptOpenSslCertificateCache_ReturnsOpen { CreateCertUtil(tempDirectory); var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); - File.WriteAllText(Path.Combine(trustDirectory.FullName, "broken.pem"), "not a certificate"); + var corruptCertificateFileName = GetOpenSslCertificateFileName(certificate); + File.WriteAllText(Path.Combine(trustDirectory.FullName, corruptCertificateFileName), "not a certificate"); var certs = new List { @@ -416,7 +420,7 @@ public async Task CheckAsync_LinuxWithCorruptOpenSslCertificateCache_ReturnsOpen var cacheResult = Assert.Single(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); Assert.Equal(EnvironmentCheckStatus.Warning, cacheResult.Status); - Assert.Contains("broken.pem", cacheResult.Details); + Assert.Contains(corruptCertificateFileName, cacheResult.Details); Assert.Contains("aspire certs clean", cacheResult.Fix); } finally @@ -435,7 +439,8 @@ public async Task CheckAsync_LinuxWithUntrustedCertificateAndCorruptOpenSslCerti { CreateCertUtil(tempDirectory); var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); - File.WriteAllText(Path.Combine(trustDirectory.FullName, "broken.pem"), "not a certificate"); + var corruptCertificateFileName = GetOpenSslCertificateFileName(certificate); + File.WriteAllText(Path.Combine(trustDirectory.FullName, corruptCertificateFileName), "not a certificate"); var certs = new List { @@ -461,7 +466,7 @@ public async Task CheckAsync_LinuxWithUntrustedCertificateAndCorruptOpenSslCerti var cacheResult = Assert.Single(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); Assert.Equal(EnvironmentCheckStatus.Warning, cacheResult.Status); - Assert.Contains("broken.pem", cacheResult.Details); + Assert.Contains(corruptCertificateFileName, cacheResult.Details); Assert.Contains("aspire certs clean", cacheResult.Fix); } finally @@ -470,6 +475,49 @@ public async Task CheckAsync_LinuxWithUntrustedCertificateAndCorruptOpenSslCerti } } + [Fact] + public async Task CheckAsync_LinuxWithUnrelatedCorruptOpenSslCertificateCache_DoesNotReturnOpenSslCertificateCacheWarning() + { + using var certificate = CreateCertificate(); + var tempDirectory = Directory.CreateTempSubdirectory(); + + try + { + CreateCertUtil(tempDirectory); + var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); + File.WriteAllText(Path.Combine(trustDirectory.FullName, GetOpenSslCertificateFileName(certificate)), certificate.ExportCertificatePem()); + File.WriteAllText(Path.Combine(trustDirectory.FullName, "unrelated.pem"), "not a certificate"); + + var certs = new List + { + CreateDevCertInfo(CertificateManager.TrustLevel.Full, certificate, MinVersion), + }; + var toolRunner = new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.Full, + Certificates = certs + } + }; + var environment = TestEnvironment.CreateLinux(new Dictionary + { + ["PATH"] = tempDirectory.FullName, + [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName + }); + var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + + var results = await check.CheckAsync(); + + Assert.DoesNotContain(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + [Fact] public async Task CheckAsync_LinuxWithStaleOpenSslCertificateCache_ReturnsOpenSslCertificateCacheWarning() { @@ -481,7 +529,7 @@ public async Task CheckAsync_LinuxWithStaleOpenSslCertificateCache_ReturnsOpenSs { CreateCertUtil(tempDirectory); var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); - File.WriteAllText(Path.Combine(trustDirectory.FullName, "stale.pem"), staleCertificate.ExportCertificatePem()); + File.WriteAllText(Path.Combine(trustDirectory.FullName, GetOpenSslCertificateFileName(certificate)), staleCertificate.ExportCertificatePem()); var certs = new List { From 37f6e03cfc2fa251c265742c942161c85c70eb27 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 21 Jul 2026 16:52:40 -0700 Subject: [PATCH 03/18] Address Linux dev cert diagnostic review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677e0f4a-653a-4d97-b690-f86d67107226 --- .../DoctorCommandStrings.Designer.cs | 9 -- .../Resources/DoctorCommandStrings.resx | 4 - .../Resources/xlf/DoctorCommandStrings.cs.xlf | 5 -- .../Resources/xlf/DoctorCommandStrings.de.xlf | 5 -- .../Resources/xlf/DoctorCommandStrings.es.xlf | 5 -- .../Resources/xlf/DoctorCommandStrings.fr.xlf | 5 -- .../Resources/xlf/DoctorCommandStrings.it.xlf | 5 -- .../Resources/xlf/DoctorCommandStrings.ja.xlf | 5 -- .../Resources/xlf/DoctorCommandStrings.ko.xlf | 5 -- .../Resources/xlf/DoctorCommandStrings.pl.xlf | 5 -- .../xlf/DoctorCommandStrings.pt-BR.xlf | 5 -- .../Resources/xlf/DoctorCommandStrings.ru.xlf | 5 -- .../Resources/xlf/DoctorCommandStrings.tr.xlf | 5 -- .../xlf/DoctorCommandStrings.zh-Hans.xlf | 5 -- .../xlf/DoctorCommandStrings.zh-Hant.xlf | 5 -- .../UnixCertificateManagerTests.cs | 4 +- .../Utils/DevCertsCheckTests.cs | 88 +++++++++++++++++++ 17 files changed, 90 insertions(+), 80 deletions(-) diff --git a/src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs index e454d825bef..9c356c82160 100644 --- a/src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs @@ -482,15 +482,6 @@ public static string DevCertsOpenSslCacheUnreadableMessage { } } - /// - /// Looks up a localized string similar to Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates.. - /// - public static string DevCertsOpenSslCacheUnreadableDetailsFormat { - get { - return ResourceManager.GetString("DevCertsOpenSslCacheUnreadableDetailsFormat", resourceCulture); - } - } - /// /// Looks up a localized string similar to Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates.. /// diff --git a/src/Aspire.Cli/Resources/DoctorCommandStrings.resx b/src/Aspire.Cli/Resources/DoctorCommandStrings.resx index 24850489d48..5a18a4fa323 100644 --- a/src/Aspire.Cli/Resources/DoctorCommandStrings.resx +++ b/src/Aspire.Cli/Resources/DoctorCommandStrings.resx @@ -210,10 +210,6 @@ OpenSSL HTTPS development certificate cache contains unreadable certificate files - - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - {0} is the OpenSSL certificate cache directory. {1} is the error message. - Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is a comma-separated list of certificate file names. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf index eb171c7588c..d053aa868f3 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf @@ -297,11 +297,6 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. - - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - {0} is the OpenSSL certificate cache directory. {1} is the error message. - Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf index 4ebd0aa1c9f..355e0ad4437 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf @@ -297,11 +297,6 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. - - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - {0} is the OpenSSL certificate cache directory. {1} is the error message. - Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf index 75fc9eb4aae..1a54a6617e5 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf @@ -297,11 +297,6 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. - - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - {0} is the OpenSSL certificate cache directory. {1} is the error message. - Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf index 9bfcfe24a1c..9831aae9d4c 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf @@ -297,11 +297,6 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. - - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - {0} is the OpenSSL certificate cache directory. {1} is the error message. - Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf index 2a8bb9d477d..4687a8d72f4 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf @@ -297,11 +297,6 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. - - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - {0} is the OpenSSL certificate cache directory. {1} is the error message. - Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf index 30133b50529..c2bed9bf74f 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf @@ -297,11 +297,6 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. - - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - {0} is the OpenSSL certificate cache directory. {1} is the error message. - Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf index 914550bb50a..a4ba123da33 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf @@ -297,11 +297,6 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. - - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - {0} is the OpenSSL certificate cache directory. {1} is the error message. - Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf index a45f5b500e7..eedff72fdbe 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf @@ -297,11 +297,6 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. - - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - {0} is the OpenSSL certificate cache directory. {1} is the error message. - Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf index 04c0aee5ab8..4a100a1116a 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf @@ -297,11 +297,6 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. - - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - {0} is the OpenSSL certificate cache directory. {1} is the error message. - Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf index 3b1813180f4..93803bff982 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf @@ -297,11 +297,6 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. - - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - {0} is the OpenSSL certificate cache directory. {1} is the error message. - Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf index ba85ffb9bb8..56e0006bd0c 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf @@ -297,11 +297,6 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. - - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - {0} is the OpenSSL certificate cache directory. {1} is the error message. - Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf index ac9e64ab235..eabcab16c44 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf @@ -297,11 +297,6 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. - - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - {0} is the OpenSSL certificate cache directory. {1} is the error message. - Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf index 5566ffa1055..96afdc97bde 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf @@ -297,11 +297,6 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. - - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - Could not read the OpenSSL certificate cache at '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. - {0} is the OpenSSL certificate cache directory. {1} is the error message. - Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/tests/Aspire.Cli.Tests/Certificates/UnixCertificateManagerTests.cs b/tests/Aspire.Cli.Tests/Certificates/UnixCertificateManagerTests.cs index a1855109b97..933767cf751 100644 --- a/tests/Aspire.Cli.Tests/Certificates/UnixCertificateManagerTests.cs +++ b/tests/Aspire.Cli.Tests/Certificates/UnixCertificateManagerTests.cs @@ -32,9 +32,9 @@ public void GetTrustLevel_WithCorruptOpenSslCertificate_DoesNotThrow() var certificatePath = Path.Combine(openSslDirectory.FullName, $"aspnetcore-localhost-{certificate.Thumbprint}.pem"); File.WriteAllText(certificatePath, "not a certificate"); - var exception = Record.Exception(() => manager.GetTrustLevel(certificate)); + var trustLevel = manager.GetTrustLevel(certificate); - Assert.Null(exception); + Assert.Equal(CertificateManager.TrustLevel.None, trustLevel); } finally { diff --git a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs index 66c61d80b08..6a73a75d41d 100644 --- a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs @@ -383,6 +383,94 @@ public async Task CheckAsync_LinuxWithMatchingOpenSslCertificateCache_DoesNotRet } } + [Fact] + public async Task CheckAsync_LinuxWithMissingOpenSslCertificateCacheDirectory_ReturnsOpenSslCertificateCacheWarning() + { + using var certificate = CreateCertificate(); + var tempDirectory = Directory.CreateTempSubdirectory(); + + try + { + CreateCertUtil(tempDirectory); + var trustDirectory = Path.Combine(tempDirectory.FullName, "missing-trust"); + + var certs = new List + { + CreateDevCertInfo(CertificateManager.TrustLevel.Full, certificate, MinVersion), + }; + var toolRunner = new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.Full, + Certificates = certs + } + }; + var environment = TestEnvironment.CreateLinux(new Dictionary + { + ["PATH"] = tempDirectory.FullName, + [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory + }); + var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + + var results = await check.CheckAsync(); + + var cacheResult = Assert.Single(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); + Assert.Equal(EnvironmentCheckStatus.Warning, cacheResult.Status); + Assert.Contains(certificate.Thumbprint, cacheResult.Details); + Assert.Contains("aspire certs clean", cacheResult.Fix); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + + [Fact] + public async Task CheckAsync_LinuxWithMissingOpenSslCertificateCacheEntry_ReturnsOpenSslCertificateCacheWarning() + { + using var certificate = CreateCertificate(); + var tempDirectory = Directory.CreateTempSubdirectory(); + + try + { + CreateCertUtil(tempDirectory); + var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); + + var certs = new List + { + CreateDevCertInfo(CertificateManager.TrustLevel.Full, certificate, MinVersion), + }; + var toolRunner = new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.Full, + Certificates = certs + } + }; + var environment = TestEnvironment.CreateLinux(new Dictionary + { + ["PATH"] = tempDirectory.FullName, + [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName + }); + var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + + var results = await check.CheckAsync(); + + var cacheResult = Assert.Single(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); + Assert.Equal(EnvironmentCheckStatus.Warning, cacheResult.Status); + Assert.Contains(certificate.Thumbprint, cacheResult.Details); + Assert.Contains("aspire certs clean", cacheResult.Fix); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + [Fact] public async Task CheckAsync_LinuxWithCorruptOpenSslCertificateCache_ReturnsOpenSslCertificateCacheWarning() { From fe5dac6c1928e51ffd3c281bfa746ee7e30719a8 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 21 Jul 2026 18:59:26 -0700 Subject: [PATCH 04/18] Allow dev cert cleanup without openssl Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677e0f4a-653a-4d97-b690-f86d67107226 --- .../UnixCertificateManager.cs | 14 ++++---- .../UnixCertificateManagerTests.cs | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs index fa5033b47e5..7a5b2d11ffc 100644 --- a/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs @@ -484,18 +484,20 @@ protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certi if (File.Exists(certPath)) { var openSslUntrustSucceeded = false; + var certificateFileDeleted = TryDeleteCertificateFile(certPath); - if (IsCommandAvailable(OpenSslCommand)) + if (certificateFileDeleted) { - if (TryDeleteCertificateFile(certPath) && TryRehashOpenSslCertificates(certDir)) + if (IsCommandAvailable(OpenSslCommand)) { + openSslUntrustSucceeded = TryRehashOpenSslCertificates(certDir); + } + else + { + Log.UnixMissingOpenSslCommand(OpenSslCommand); openSslUntrustSucceeded = true; } } - else - { - Log.UnixMissingOpenSslCommand(OpenSslCommand); - } if (openSslUntrustSucceeded) { diff --git a/tests/Aspire.Cli.Tests/Certificates/UnixCertificateManagerTests.cs b/tests/Aspire.Cli.Tests/Certificates/UnixCertificateManagerTests.cs index 933767cf751..90ba3e70ae5 100644 --- a/tests/Aspire.Cli.Tests/Certificates/UnixCertificateManagerTests.cs +++ b/tests/Aspire.Cli.Tests/Certificates/UnixCertificateManagerTests.cs @@ -42,6 +42,40 @@ public void GetTrustLevel_WithCorruptOpenSslCertificate_DoesNotThrow() } } + [Fact] + public void RemoveCertificate_WithMissingOpenSsl_DeletesOpenSslCertificate() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "OpenSSL certificate cleanup is only exercised on Linux."); + + var openSslDirectory = Directory.CreateTempSubdirectory(); + + try + { + var environment = TestEnvironment.CreateLinux(new Dictionary + { + ["PATH"] = Path.Combine(openSslDirectory.FullName, "missing-tools"), + ["DOTNET_DEV_CERTS_NSSDB_PATHS"] = Path.Combine(openSslDirectory.FullName, "missing-nss-db"), + [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = openSslDirectory.FullName + }); + var manager = new UnixCertificateManager(NullLogger.Instance, environment); + using var certificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + using var savedCertificate = manager.SaveCertificate(certificate); + var certificatePath = Path.Combine(openSslDirectory.FullName, $"aspnetcore-localhost-{savedCertificate.Thumbprint}.pem"); + File.WriteAllText(certificatePath, "not a certificate"); + + var exception = Record.Exception(() => manager.RemoveCertificate(savedCertificate, CertificateManager.RemoveLocations.All)); + + Assert.Null(exception); + Assert.False(File.Exists(certificatePath)); + } + finally + { + openSslDirectory.Delete(recursive: true); + } + } + [Fact] public void RemoveCertificate_WithMissingCertUtilAndNssDbs_SkipsNssCleanup() { From 626d3ef5fdcac3a73f495e1c9f7b6d10e9fa843d Mon Sep 17 00:00:00 2001 From: David Negstad Date: Wed, 22 Jul 2026 12:23:17 -0700 Subject: [PATCH 05/18] Defer OpenSSL trust warning until lookup completes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677e0f4a-653a-4d97-b690-f86d67107226 --- .../UnixCertificateManager.cs | 3 +- .../UnixCertificateManagerTests.cs | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs index 7a5b2d11ffc..737bdc35110 100644 --- a/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs @@ -128,7 +128,8 @@ public override TrustLevel GetTrustLevel(X509Certificate2 certificate) } catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException) { - Log.UnixNotTrustedByOpenSsl(OpenSslCertificateDirectoryVariableName); + // 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. } } } diff --git a/tests/Aspire.Cli.Tests/Certificates/UnixCertificateManagerTests.cs b/tests/Aspire.Cli.Tests/Certificates/UnixCertificateManagerTests.cs index 90ba3e70ae5..f3a01288f8c 100644 --- a/tests/Aspire.Cli.Tests/Certificates/UnixCertificateManagerTests.cs +++ b/tests/Aspire.Cli.Tests/Certificates/UnixCertificateManagerTests.cs @@ -5,6 +5,7 @@ using Aspire.Cli.Tests.Utils; using Microsoft.AspNetCore.Certificates.Generation; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; namespace Aspire.Cli.Tests.Certificates; @@ -42,6 +43,44 @@ public void GetTrustLevel_WithCorruptOpenSslCertificate_DoesNotThrow() } } + [Fact] + public void GetTrustLevel_WithCorruptOpenSslCertificateBeforeValidCertificate_DoesNotLogOpenSslWarning() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "OpenSSL certificate directory trust is only exercised on Linux."); + + var corruptOpenSslDirectory = Directory.CreateTempSubdirectory(); + var validOpenSslDirectory = Directory.CreateTempSubdirectory(); + + try + { + var sink = new TestSink(); + var logger = new TestLogger(nameof(UnixCertificateManager), sink, enabled: true); + var environment = TestEnvironment.CreateLinux(new Dictionary + { + ["PATH"] = Path.Combine(corruptOpenSslDirectory.FullName, "missing-tools"), + ["SSL_CERT_DIR"] = string.Join(Path.PathSeparator, corruptOpenSslDirectory.FullName, validOpenSslDirectory.FullName), + ["DOTNET_DEV_CERTS_NSSDB_PATHS"] = Path.Combine(corruptOpenSslDirectory.FullName, "missing-nss-db") + }); + var manager = new UnixCertificateManager(logger, environment); + using var certificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + var certificateFileName = $"aspnetcore-localhost-{certificate.Thumbprint}.pem"; + File.WriteAllText(Path.Combine(corruptOpenSslDirectory.FullName, certificateFileName), "not a certificate"); + File.WriteAllText(Path.Combine(validOpenSslDirectory.FullName, certificateFileName), certificate.ExportCertificatePem()); + + var trustLevel = manager.GetTrustLevel(certificate); + + Assert.NotEqual(CertificateManager.TrustLevel.None, trustLevel); + Assert.DoesNotContain(sink.Writes, w => w.Message?.Contains("not trusted by OpenSSL", StringComparison.Ordinal) == true); + } + finally + { + corruptOpenSslDirectory.Delete(recursive: true); + validOpenSslDirectory.Delete(recursive: true); + } + } + [Fact] public void RemoveCertificate_WithMissingOpenSsl_DeletesOpenSslCertificate() { From af7ec5923b2697c4da24c77eb7f65d3734052b49 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Wed, 22 Jul 2026 16:19:17 -0700 Subject: [PATCH 06/18] Validate OpenSSL dev cert hash links Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677e0f4a-653a-4d97-b690-f86d67107226 --- .../DoctorCommandStrings.Designer.cs | 27 ++++ .../Resources/DoctorCommandStrings.resx | 11 ++ .../Resources/xlf/DoctorCommandStrings.cs.xlf | 15 ++ .../Resources/xlf/DoctorCommandStrings.de.xlf | 15 ++ .../Resources/xlf/DoctorCommandStrings.es.xlf | 15 ++ .../Resources/xlf/DoctorCommandStrings.fr.xlf | 15 ++ .../Resources/xlf/DoctorCommandStrings.it.xlf | 15 ++ .../Resources/xlf/DoctorCommandStrings.ja.xlf | 15 ++ .../Resources/xlf/DoctorCommandStrings.ko.xlf | 15 ++ .../Resources/xlf/DoctorCommandStrings.pl.xlf | 15 ++ .../xlf/DoctorCommandStrings.pt-BR.xlf | 15 ++ .../Resources/xlf/DoctorCommandStrings.ru.xlf | 15 ++ .../Resources/xlf/DoctorCommandStrings.tr.xlf | 15 ++ .../xlf/DoctorCommandStrings.zh-Hans.xlf | 15 ++ .../xlf/DoctorCommandStrings.zh-Hant.xlf | 15 ++ .../Utils/EnvironmentChecker/DevCertsCheck.cs | 148 ++++++++++++++++-- .../Utils/DevCertsCheckTests.cs | 143 ++++++++++++++++- 17 files changed, 511 insertions(+), 13 deletions(-) diff --git a/src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs index 9c356c82160..ce63a976622 100644 --- a/src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs @@ -319,6 +319,15 @@ public static string DevCertsCleanAndTrustFixFormat { return ResourceManager.GetString("DevCertsCleanAndTrustFixFormat", resourceCulture); } } + + /// + /// Looks up a localized string similar to Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one.. + /// + public static string DevCertsInstallOpenSslCleanAndTrustFixFormat { + get { + return ResourceManager.GetString("DevCertsInstallOpenSslCleanAndTrustFixFormat", resourceCulture); + } + } /// /// Looks up a localized string similar to Multiple HTTPS development certificates found ({0} certificates), but none are trusted. @@ -491,6 +500,24 @@ public static string DevCertsOpenSslCacheUnreadableFilesDetailsFormat { } } + /// + /// Looks up a localized string similar to OpenSSL HTTPS development certificate cache is missing subject-hash links. + /// + public static string DevCertsOpenSslCacheMissingHashLinkMessage { + get { + return ResourceManager.GetString("DevCertsOpenSslCacheMissingHashLinkMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories.. + /// + public static string DevCertsOpenSslCacheMissingHashLinkDetailsFormat { + get { + return ResourceManager.GetString("DevCertsOpenSslCacheMissingHashLinkDetailsFormat", resourceCulture); + } + } + /// /// Looks up a localized string similar to Developer Control Plane (DCP) bundle not found; skipping connection health checks. /// diff --git a/src/Aspire.Cli/Resources/DoctorCommandStrings.resx b/src/Aspire.Cli/Resources/DoctorCommandStrings.resx index 5a18a4fa323..063a3a47e12 100644 --- a/src/Aspire.Cli/Resources/DoctorCommandStrings.resx +++ b/src/Aspire.Cli/Resources/DoctorCommandStrings.resx @@ -155,6 +155,10 @@ Run '{0}' to remove all certificates, then run '{1}' to create and trust a new one. {0} is the aspire certs clean command, {1} is the aspire certs trust command (not localizable) + + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + {0} is the openssl command/package name, {1} is the aspire certs clean command, {2} is the aspire certs trust command (not localizable) + Multiple HTTPS development certificates found ({0} certificates), but none are trusted @@ -214,6 +218,13 @@ Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is a comma-separated list of certificate file names. + + OpenSSL HTTPS development certificate cache is missing subject-hash links + + + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + Developer Control Plane (DCP) bundle not found; skipping connection health checks diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf index d053aa868f3..c0ff9a1963d 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf @@ -222,6 +222,11 @@ Run '{0}' to remove all certificates, then run '{1}' to create and trust a new one. {0} is the aspire certs clean command, {1} is the aspire certs trust command (not localizable) + + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + {0} is the openssl command/package name, {1} is the aspire certs clean command, {2} is the aspire certs trust command (not localizable) + Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. @@ -297,6 +302,16 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + OpenSSL HTTPS development certificate cache is missing subject-hash links + OpenSSL HTTPS development certificate cache is missing subject-hash links + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf index 355e0ad4437..9df825ab628 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf @@ -222,6 +222,11 @@ Run '{0}' to remove all certificates, then run '{1}' to create and trust a new one. {0} is the aspire certs clean command, {1} is the aspire certs trust command (not localizable) + + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + {0} is the openssl command/package name, {1} is the aspire certs clean command, {2} is the aspire certs trust command (not localizable) + Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. @@ -297,6 +302,16 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + OpenSSL HTTPS development certificate cache is missing subject-hash links + OpenSSL HTTPS development certificate cache is missing subject-hash links + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf index 1a54a6617e5..2a3432fc20a 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf @@ -222,6 +222,11 @@ Run '{0}' to remove all certificates, then run '{1}' to create and trust a new one. {0} is the aspire certs clean command, {1} is the aspire certs trust command (not localizable) + + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + {0} is the openssl command/package name, {1} is the aspire certs clean command, {2} is the aspire certs trust command (not localizable) + Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. @@ -297,6 +302,16 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + OpenSSL HTTPS development certificate cache is missing subject-hash links + OpenSSL HTTPS development certificate cache is missing subject-hash links + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf index 9831aae9d4c..cfd900619a7 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf @@ -222,6 +222,11 @@ Run '{0}' to remove all certificates, then run '{1}' to create and trust a new one. {0} is the aspire certs clean command, {1} is the aspire certs trust command (not localizable) + + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + {0} is the openssl command/package name, {1} is the aspire certs clean command, {2} is the aspire certs trust command (not localizable) + Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. @@ -297,6 +302,16 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + OpenSSL HTTPS development certificate cache is missing subject-hash links + OpenSSL HTTPS development certificate cache is missing subject-hash links + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf index 4687a8d72f4..cf6f9527adf 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf @@ -222,6 +222,11 @@ Run '{0}' to remove all certificates, then run '{1}' to create and trust a new one. {0} is the aspire certs clean command, {1} is the aspire certs trust command (not localizable) + + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + {0} is the openssl command/package name, {1} is the aspire certs clean command, {2} is the aspire certs trust command (not localizable) + Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. @@ -297,6 +302,16 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + OpenSSL HTTPS development certificate cache is missing subject-hash links + OpenSSL HTTPS development certificate cache is missing subject-hash links + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf index c2bed9bf74f..4b8a143bd69 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf @@ -222,6 +222,11 @@ Run '{0}' to remove all certificates, then run '{1}' to create and trust a new one. {0} is the aspire certs clean command, {1} is the aspire certs trust command (not localizable) + + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + {0} is the openssl command/package name, {1} is the aspire certs clean command, {2} is the aspire certs trust command (not localizable) + Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. @@ -297,6 +302,16 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + OpenSSL HTTPS development certificate cache is missing subject-hash links + OpenSSL HTTPS development certificate cache is missing subject-hash links + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf index a4ba123da33..c74f8bd57cd 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf @@ -222,6 +222,11 @@ Run '{0}' to remove all certificates, then run '{1}' to create and trust a new one. {0} is the aspire certs clean command, {1} is the aspire certs trust command (not localizable) + + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + {0} is the openssl command/package name, {1} is the aspire certs clean command, {2} is the aspire certs trust command (not localizable) + Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. @@ -297,6 +302,16 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + OpenSSL HTTPS development certificate cache is missing subject-hash links + OpenSSL HTTPS development certificate cache is missing subject-hash links + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf index eedff72fdbe..fd4bbc8db29 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf @@ -222,6 +222,11 @@ Run '{0}' to remove all certificates, then run '{1}' to create and trust a new one. {0} is the aspire certs clean command, {1} is the aspire certs trust command (not localizable) + + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + {0} is the openssl command/package name, {1} is the aspire certs clean command, {2} is the aspire certs trust command (not localizable) + Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. @@ -297,6 +302,16 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + OpenSSL HTTPS development certificate cache is missing subject-hash links + OpenSSL HTTPS development certificate cache is missing subject-hash links + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf index 4a100a1116a..459a32262d2 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf @@ -222,6 +222,11 @@ Run '{0}' to remove all certificates, then run '{1}' to create and trust a new one. {0} is the aspire certs clean command, {1} is the aspire certs trust command (not localizable) + + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + {0} is the openssl command/package name, {1} is the aspire certs clean command, {2} is the aspire certs trust command (not localizable) + Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. @@ -297,6 +302,16 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + OpenSSL HTTPS development certificate cache is missing subject-hash links + OpenSSL HTTPS development certificate cache is missing subject-hash links + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf index 93803bff982..bb05e691ceb 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf @@ -222,6 +222,11 @@ Run '{0}' to remove all certificates, then run '{1}' to create and trust a new one. {0} is the aspire certs clean command, {1} is the aspire certs trust command (not localizable) + + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + {0} is the openssl command/package name, {1} is the aspire certs clean command, {2} is the aspire certs trust command (not localizable) + Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. @@ -297,6 +302,16 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + OpenSSL HTTPS development certificate cache is missing subject-hash links + OpenSSL HTTPS development certificate cache is missing subject-hash links + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf index 56e0006bd0c..382a4e311b3 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf @@ -222,6 +222,11 @@ Run '{0}' to remove all certificates, then run '{1}' to create and trust a new one. {0} is the aspire certs clean command, {1} is the aspire certs trust command (not localizable) + + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + {0} is the openssl command/package name, {1} is the aspire certs clean command, {2} is the aspire certs trust command (not localizable) + Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. @@ -297,6 +302,16 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + OpenSSL HTTPS development certificate cache is missing subject-hash links + OpenSSL HTTPS development certificate cache is missing subject-hash links + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf index eabcab16c44..3440d39775e 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf @@ -222,6 +222,11 @@ Run '{0}' to remove all certificates, then run '{1}' to create and trust a new one. {0} is the aspire certs clean command, {1} is the aspire certs trust command (not localizable) + + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + {0} is the openssl command/package name, {1} is the aspire certs clean command, {2} is the aspire certs trust command (not localizable) + Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. @@ -297,6 +302,16 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + OpenSSL HTTPS development certificate cache is missing subject-hash links + OpenSSL HTTPS development certificate cache is missing subject-hash links + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf index 96afdc97bde..bc6c8d5e2bc 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf @@ -222,6 +222,11 @@ Run '{0}' to remove all certificates, then run '{1}' to create and trust a new one. {0} is the aspire certs clean command, {1} is the aspire certs trust command (not localizable) + + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + Install {0}, run '{1}' to remove all certificates, then run '{2}' to create and trust a new one. + {0} is the openssl command/package name, {1} is the aspire certs clean command, {2} is the aspire certs trust command (not localizable) + Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. Aspire uses certutil to query and update NSS certificate databases used by Firefox and Chromium browsers on Linux. @@ -297,6 +302,16 @@ The OpenSSL certificate cache at '{0}' does not contain certificate {1} from the .NET current user certificate store. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + The OpenSSL certificate cache at '{0}' contains certificate {1}, but no subject-hash entry points to it. OpenSSL workloads use subject-hash entries when loading CA directories. + {0} is the OpenSSL certificate cache directory. {1} is the certificate thumbprint. + + + OpenSSL HTTPS development certificate cache is missing subject-hash links + OpenSSL HTTPS development certificate cache is missing subject-hash links + + Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. Could not read certificate files under '{0}': {1}. Run 'aspire certs clean' and then 'aspire certs trust' to remove stale or corrupt certificates and regenerate trusted development certificates. diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs index e89de5bfaf7..cf2507b3ee5 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs @@ -1,10 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text.Json.Nodes; +using System.Text.RegularExpressions; using Aspire.Cli.Certificates; using Aspire.Cli.Resources; using Microsoft.AspNetCore.Certificates.Generation; @@ -21,11 +25,15 @@ internal sealed class DevCertsCheck(ILogger logger, ICertificateT internal const string VersionCheckName = "dev-certs-version"; internal const string CertUtilCheckName = "dev-certs-certutil"; internal const string OpenSslCertificateCacheCheckName = "dev-certs-openssl-cache"; + private const string OpenSslCommand = "openssl"; + private const int OpenSslHashCollisionSearchLimit = 10; public int Order => 35; // After SDK check (30), before container checks (40+) private static readonly string s_trustFixCommand = string.Format(CultureInfo.InvariantCulture, DoctorCommandStrings.DevCertsTrustFixFormat, "aspire certs trust"); private static readonly string s_cleanAndTrustFixCommand = string.Format(CultureInfo.InvariantCulture, DoctorCommandStrings.DevCertsCleanAndTrustFixFormat, "aspire certs clean", "aspire certs trust"); + private static readonly string s_installOpenSslCleanAndTrustFixCommand = string.Format(CultureInfo.InvariantCulture, DoctorCommandStrings.DevCertsInstallOpenSslCleanAndTrustFixFormat, "openssl", "aspire certs clean", "aspire certs trust"); + private static readonly Regex s_openSslHashFileNameRegex = new("^[0-9a-fA-F]{8}\\.\\d+$", RegexOptions.CultureInvariant); public Task> CheckAsync(CancellationToken cancellationToken = default) { @@ -221,7 +229,10 @@ private static void AddLinuxOpenSslCertificateCacheWarnings(List GetCurrentDevCertificates(IReadOnlyList< .ThenBy(c => c.Thumbprint, StringComparer.OrdinalIgnoreCase); } - private static OpenSslCertificateCacheStatus? EvaluateOpenSslCertificateCache(string trustPath, IReadOnlyList currentCertificates) + private static OpenSslCertificateCacheStatus? EvaluateOpenSslCertificateCache(string trustPath, IReadOnlyList currentCertificates, string? openSslPath) { + var fix = GetOpenSslCertificateCacheFix(openSslPath); + if (!Directory.Exists(trustPath)) { var trustedThumbprints = GetTrustedThumbprints(currentCertificates); @@ -264,12 +277,14 @@ private static IEnumerable GetCurrentDevCertificates(IReadOnlyList< return new( DoctorCommandStrings.DevCertsOpenSslCacheMissingCurrentCertificateMessage, - string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheMissingDetailsFormat, trustPath, string.Join(", ", trustedThumbprints))); + string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheMissingDetailsFormat, trustPath, string.Join(", ", trustedThumbprints)), + fix); } var unreadableFiles = new List(); var mismatchedThumbprints = new List(); var missingTrustedThumbprints = new List(); + var missingHashLinkThumbprints = new List(); foreach (var certificate in currentCertificates) { @@ -292,6 +307,11 @@ private static IEnumerable GetCurrentDevCertificates(IReadOnlyList< { mismatchedThumbprints.Add(certificate.Thumbprint!); } + else if (certificate.TrustLevel != CertificateManager.TrustLevel.None && + !HasOpenSslHashEntry(trustPath, certificateFile, cachedCertificate, openSslPath)) + { + missingHashLinkThumbprints.Add(certificate.Thumbprint!); + } } catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException) { @@ -303,26 +323,129 @@ private static IEnumerable GetCurrentDevCertificates(IReadOnlyList< { return new( DoctorCommandStrings.DevCertsOpenSslCacheUnreadableMessage, - string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheUnreadableFilesDetailsFormat, trustPath, string.Join(", ", unreadableFiles))); + string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheUnreadableFilesDetailsFormat, trustPath, string.Join(", ", unreadableFiles)), + fix); } if (mismatchedThumbprints.Count > 0) { return new( DoctorCommandStrings.DevCertsOpenSslCacheMissingCurrentCertificateMessage, - string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheMissingDetailsFormat, trustPath, string.Join(", ", mismatchedThumbprints))); + string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheMissingDetailsFormat, trustPath, string.Join(", ", mismatchedThumbprints)), + fix); } if (missingTrustedThumbprints.Count > 0) { return new( DoctorCommandStrings.DevCertsOpenSslCacheMissingCurrentCertificateMessage, - string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheMissingDetailsFormat, trustPath, string.Join(", ", missingTrustedThumbprints))); + string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheMissingDetailsFormat, trustPath, string.Join(", ", missingTrustedThumbprints)), + fix); + } + + if (missingHashLinkThumbprints.Count > 0) + { + return new( + DoctorCommandStrings.DevCertsOpenSslCacheMissingHashLinkMessage, + string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DevCertsOpenSslCacheMissingHashLinkDetailsFormat, trustPath, string.Join(", ", missingHashLinkThumbprints)), + fix); } return null; } + private static string GetOpenSslCertificateCacheFix(string? openSslPath) => + openSslPath is null ? s_installOpenSslCleanAndTrustFixCommand : s_cleanAndTrustFixCommand; + + private static bool HasOpenSslHashEntry(string trustPath, string certificateFile, X509Certificate2 certificate, string? openSslPath) + { + if (openSslPath is not null) + { + return TryGetOpenSslHash(openSslPath, certificateFile, out var hash) && + HasMatchingHashEntry(trustPath, hash, certificate); + } + + // Without openssl we cannot compute the subject hash that OpenSSL requires. The + // absence of any hash-style entry for the certificate is still definitely broken, + // but a matching entry can only be treated as sufficient evidence to avoid warning. + return Directory.EnumerateFiles(trustPath) + .Where(path => s_openSslHashFileNameRegex.IsMatch(Path.GetFileName(path))) + .Any(path => CertificateFileMatches(path, certificate)); + } + + private static bool HasMatchingHashEntry(string trustPath, string hash, X509Certificate2 certificate) + { + for (var i = 0; i < OpenSslHashCollisionSearchLimit; i++) + { + var hashEntryPath = Path.Combine(trustPath, $"{hash}.{i}"); + if (CertificateFileMatches(hashEntryPath, certificate)) + { + return true; + } + } + + return false; + } + + private static bool CertificateFileMatches(string certificateFile, X509Certificate2 certificate) + { + if (!File.Exists(certificateFile)) + { + return false; + } + + try + { + using var cachedCertificate = X509CertificateLoader.LoadCertificateFromFile(certificateFile); + + return string.Equals(certificate.Thumbprint, cachedCertificate.Thumbprint, StringComparison.OrdinalIgnoreCase); + } + catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException) + { + return false; + } + } + + private static bool TryGetOpenSslHash(string openSslPath, string certificateFile, [NotNullWhen(true)] out string? hash) + { + hash = null; + + var processInfo = new ProcessStartInfo(openSslPath) + { + RedirectStandardOutput = true, + RedirectStandardError = true + }; + processInfo.ArgumentList.Add("x509"); + processInfo.ArgumentList.Add("-hash"); + processInfo.ArgumentList.Add("-noout"); + processInfo.ArgumentList.Add("-in"); + processInfo.ArgumentList.Add(certificateFile); + + Process? process = null; + try + { + process = Process.Start(processInfo); + var stdout = process!.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + return false; + } + + hash = stdout.Trim(); + return hash.Length > 0; + } + catch (Exception ex) when (ex is Win32Exception or IOException or InvalidOperationException) + { + return false; + } + finally + { + process?.Dispose(); + } + } + private static List GetTrustedThumbprints(IReadOnlyList currentCertificates) { return currentCertificates @@ -341,9 +464,7 @@ private static void AddLinuxCertificateToolWarnings(List return; } - var environmentVariables = environment.GetEnvironmentVariables() - .Where(kv => kv.Value is not null) - .ToDictionary(kv => kv.Name, kv => kv.Value!); + var environmentVariables = GetEnvironmentVariables(environment); if (PathLookupHelper.TryResolveExecutablePath(CertificateHelpers.CertUtilCommand, out _, environmentVariables)) { @@ -362,6 +483,11 @@ private static void AddLinuxCertificateToolWarnings(List }); } + private static Dictionary GetEnvironmentVariables(IEnvironment environment) => + environment.GetEnvironmentVariables() + .Where(kv => kv.Value is not null) + .ToDictionary(kv => kv.Name, kv => kv.Value!); + /// /// Builds structured metadata from certificate information for JSON output. /// @@ -420,5 +546,5 @@ private static string BuildSslCertDirFixCommand(string devCertsTrustPath, IEnvir return $"export SSL_CERT_DIR=\"$SSL_CERT_DIR:{string.Join(':', systemCertDirs)}\""; } - private sealed record OpenSslCertificateCacheStatus(string Message, string Details); + private sealed record OpenSslCertificateCacheStatus(string Message, string Details, string Fix); } diff --git a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs index 6a73a75d41d..079bd2b0ab2 100644 --- a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs @@ -66,9 +66,48 @@ private static string CreateCertUtil(DirectoryInfo directory) return certUtilPath; } + private static string CreateOpenSsl(DirectoryInfo directory, string hash) + { + var openSslPath = OperatingSystem.IsWindows() + ? Path.Combine(directory.FullName, "openssl.cmd") + : Path.Combine(directory.FullName, "openssl"); + + var contents = OperatingSystem.IsWindows() + ? $""" + @echo off + if "%1"=="x509" ( + echo {hash} + exit /b 0 + ) + exit /b 1 + """ + : $""" + #!/bin/sh + if [ "$1" = "x509" ]; then + echo {hash} + exit 0 + fi + exit 1 + """; + File.WriteAllText(openSslPath, contents); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(openSslPath, UnixFileMode.UserRead | UnixFileMode.UserExecute); + } + + return openSslPath; + } + private static string GetOpenSslCertificateFileName(X509Certificate2 certificate) => $"aspnetcore-localhost-{certificate.Thumbprint}.pem"; + private static void WriteOpenSslCertificateCache(DirectoryInfo trustDirectory, X509Certificate2 certificate, string hashEntryName = "12345678.0") + { + var certificatePem = certificate.ExportCertificatePem(); + File.WriteAllText(Path.Combine(trustDirectory.FullName, GetOpenSslCertificateFileName(certificate)), certificatePem); + File.WriteAllText(Path.Combine(trustDirectory.FullName, hashEntryName), certificatePem); + } + [Fact] public void EvaluateCertificateResults_NoCertificates_ReturnsWarning() { @@ -350,8 +389,13 @@ public async Task CheckAsync_LinuxWithMatchingOpenSslCertificateCache_DoesNotRet try { CreateCertUtil(tempDirectory); + if (!OperatingSystem.IsWindows()) + { + CreateOpenSsl(tempDirectory, "12345678"); + } + var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); - File.WriteAllText(Path.Combine(trustDirectory.FullName, GetOpenSslCertificateFileName(certificate)), certificate.ExportCertificatePem()); + WriteOpenSslCertificateCache(trustDirectory, certificate); var certs = new List { @@ -383,6 +427,101 @@ public async Task CheckAsync_LinuxWithMatchingOpenSslCertificateCache_DoesNotRet } } + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "The synthetic openssl command uses a POSIX shell script.")] + public async Task CheckAsync_LinuxWithMissingOpenSslHashEntry_ReturnsOpenSslCertificateCacheWarning() + { + using var certificate = CreateCertificate(); + var tempDirectory = Directory.CreateTempSubdirectory(); + + try + { + CreateCertUtil(tempDirectory); + CreateOpenSsl(tempDirectory, "12345678"); + var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); + File.WriteAllText(Path.Combine(trustDirectory.FullName, GetOpenSslCertificateFileName(certificate)), certificate.ExportCertificatePem()); + + var certs = new List + { + CreateDevCertInfo(CertificateManager.TrustLevel.Full, certificate, MinVersion), + }; + var toolRunner = new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.Full, + Certificates = certs + } + }; + var environment = TestEnvironment.CreateLinux(new Dictionary + { + ["PATH"] = tempDirectory.FullName, + [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName + }); + var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + + var results = await check.CheckAsync(); + + var cacheResult = Assert.Single(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); + Assert.Equal(EnvironmentCheckStatus.Warning, cacheResult.Status); + Assert.Contains(certificate.Thumbprint, cacheResult.Details); + Assert.Contains("subject-hash", cacheResult.Details); + Assert.Contains("aspire certs clean", cacheResult.Fix); + Assert.DoesNotContain("Install openssl", cacheResult.Fix); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + + [Fact] + public async Task CheckAsync_LinuxWithMissingOpenSslHashEntryAndNoOpenSsl_ReturnsOpenSslCertificateCacheWarningWithOpenSslFix() + { + using var certificate = CreateCertificate(); + var tempDirectory = Directory.CreateTempSubdirectory(); + + try + { + CreateCertUtil(tempDirectory); + var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); + File.WriteAllText(Path.Combine(trustDirectory.FullName, GetOpenSslCertificateFileName(certificate)), certificate.ExportCertificatePem()); + + var certs = new List + { + CreateDevCertInfo(CertificateManager.TrustLevel.Full, certificate, MinVersion), + }; + var toolRunner = new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.Full, + Certificates = certs + } + }; + var environment = TestEnvironment.CreateLinux(new Dictionary + { + ["PATH"] = tempDirectory.FullName, + [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName + }); + var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + + var results = await check.CheckAsync(); + + var cacheResult = Assert.Single(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); + Assert.Equal(EnvironmentCheckStatus.Warning, cacheResult.Status); + Assert.Contains(certificate.Thumbprint, cacheResult.Details); + Assert.Contains("Install openssl", cacheResult.Fix); + Assert.Contains("aspire certs trust", cacheResult.Fix); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + [Fact] public async Task CheckAsync_LinuxWithMissingOpenSslCertificateCacheDirectory_ReturnsOpenSslCertificateCacheWarning() { @@ -573,7 +712,7 @@ public async Task CheckAsync_LinuxWithUnrelatedCorruptOpenSslCertificateCache_Do { CreateCertUtil(tempDirectory); var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); - File.WriteAllText(Path.Combine(trustDirectory.FullName, GetOpenSslCertificateFileName(certificate)), certificate.ExportCertificatePem()); + WriteOpenSslCertificateCache(trustDirectory, certificate); File.WriteAllText(Path.Combine(trustDirectory.FullName, "unrelated.pem"), "not a certificate"); var certs = new List From 9da6e0106816492b89f9c405565e0b002858a0f8 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Wed, 22 Jul 2026 16:41:23 -0700 Subject: [PATCH 07/18] Drain OpenSSL diagnostic output Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677e0f4a-653a-4d97-b690-f86d67107226 --- src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs index cf2507b3ee5..b849b581f5e 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs @@ -425,8 +425,13 @@ private static bool TryGetOpenSslHash(string openSslPath, string certificateFile try { process = Process.Start(processInfo); - var stdout = process!.StandardOutput.ReadToEnd(); + // Read both redirected streams concurrently to avoid deadlock if openssl fills a pipe + // while the process is still running. + var stdoutTask = process!.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); process.WaitForExit(); + var stdout = stdoutTask.GetAwaiter().GetResult(); + _ = stderrTask.GetAwaiter().GetResult(); if (process.ExitCode != 0) { From 5f12c5a6b10f2fb42307898e8998fdcd98297a22 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Wed, 22 Jul 2026 17:12:19 -0700 Subject: [PATCH 08/18] Bound OpenSSL hash probe duration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677e0f4a-653a-4d97-b690-f86d67107226 --- .../Utils/EnvironmentChecker/DevCertsCheck.cs | 47 +++++++++++--- .../Utils/DevCertsCheckTests.cs | 64 +++++++++++++++++++ 2 files changed, 102 insertions(+), 9 deletions(-) diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs index b849b581f5e..c58d6b84a51 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs @@ -27,6 +27,7 @@ internal sealed class DevCertsCheck(ILogger logger, ICertificateT internal const string OpenSslCertificateCacheCheckName = "dev-certs-openssl-cache"; private const string OpenSslCommand = "openssl"; private const int OpenSslHashCollisionSearchLimit = 10; + private static readonly TimeSpan s_openSslHashTimeout = TimeSpan.FromSeconds(5); public int Order => 35; // After SDK check (30), before container checks (40+) @@ -41,7 +42,7 @@ public Task> CheckAsync(CancellationToken { var trustResult = certificateToolRunner.CheckHttpCertificate(); var results = EvaluateCertificateResults(trustResult.Certificates, environment); - AddLinuxOpenSslCertificateCacheWarnings(results, trustResult.Certificates, environment); + AddLinuxOpenSslCertificateCacheWarnings(results, trustResult.Certificates, environment, cancellationToken); AddLinuxCertificateToolWarnings(results, environment); return Task.FromResult>(results); @@ -215,7 +216,7 @@ internal static List EvaluateCertificateResults( return results; } - private static void AddLinuxOpenSslCertificateCacheWarnings(List results, IReadOnlyList certInfos, IEnvironment environment) + private static void AddLinuxOpenSslCertificateCacheWarnings(List results, IReadOnlyList certInfos, IEnvironment environment, CancellationToken cancellationToken) { if (!environment.IsLinux()) { @@ -232,7 +233,7 @@ private static void AddLinuxOpenSslCertificateCacheWarnings(List GetCurrentDevCertificates(IReadOnlyList< .ThenBy(c => c.Thumbprint, StringComparer.OrdinalIgnoreCase); } - private static OpenSslCertificateCacheStatus? EvaluateOpenSslCertificateCache(string trustPath, IReadOnlyList currentCertificates, string? openSslPath) + private static OpenSslCertificateCacheStatus? EvaluateOpenSslCertificateCache(string trustPath, IReadOnlyList currentCertificates, string? openSslPath, CancellationToken cancellationToken) { var fix = GetOpenSslCertificateCacheFix(openSslPath); @@ -308,7 +309,7 @@ private static IEnumerable GetCurrentDevCertificates(IReadOnlyList< mismatchedThumbprints.Add(certificate.Thumbprint!); } else if (certificate.TrustLevel != CertificateManager.TrustLevel.None && - !HasOpenSslHashEntry(trustPath, certificateFile, cachedCertificate, openSslPath)) + !HasOpenSslHashEntry(trustPath, certificateFile, cachedCertificate, openSslPath, cancellationToken)) { missingHashLinkThumbprints.Add(certificate.Thumbprint!); } @@ -357,11 +358,11 @@ private static IEnumerable GetCurrentDevCertificates(IReadOnlyList< private static string GetOpenSslCertificateCacheFix(string? openSslPath) => openSslPath is null ? s_installOpenSslCleanAndTrustFixCommand : s_cleanAndTrustFixCommand; - private static bool HasOpenSslHashEntry(string trustPath, string certificateFile, X509Certificate2 certificate, string? openSslPath) + private static bool HasOpenSslHashEntry(string trustPath, string certificateFile, X509Certificate2 certificate, string? openSslPath, CancellationToken cancellationToken) { if (openSslPath is not null) { - return TryGetOpenSslHash(openSslPath, certificateFile, out var hash) && + return TryGetOpenSslHash(openSslPath, certificateFile, cancellationToken, out var hash) && HasMatchingHashEntry(trustPath, hash, certificate); } @@ -406,7 +407,7 @@ private static bool CertificateFileMatches(string certificateFile, X509Certifica } } - private static bool TryGetOpenSslHash(string openSslPath, string certificateFile, [NotNullWhen(true)] out string? hash) + private static bool TryGetOpenSslHash(string openSslPath, string certificateFile, CancellationToken cancellationToken, [NotNullWhen(true)] out string? hash) { hash = null; @@ -429,7 +430,20 @@ private static bool TryGetOpenSslHash(string openSslPath, string certificateFile // while the process is still running. var stdoutTask = process!.StandardOutput.ReadToEndAsync(); var stderrTask = process.StandardError.ReadToEndAsync(); - process.WaitForExit(); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(s_openSslHashTimeout); + + try + { + process.WaitForExitAsync(timeoutCts.Token).GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + TryKillProcess(process); + return false; + } + var stdout = stdoutTask.GetAwaiter().GetResult(); _ = stderrTask.GetAwaiter().GetResult(); @@ -451,6 +465,21 @@ private static bool TryGetOpenSslHash(string openSslPath, string certificateFile } } + private static void TryKillProcess(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(milliseconds: 1000); + } + } + catch (Exception ex) when (ex is InvalidOperationException or Win32Exception) + { + } + } + private static List GetTrustedThumbprints(IReadOnlyList currentCertificates) { return currentCertificates diff --git a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs index 079bd2b0ab2..97df6cc4399 100644 --- a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs @@ -98,6 +98,21 @@ exit 1 return openSslPath; } + private static string CreateOpenSslThatWaits(DirectoryInfo directory) + { + var openSslPath = Path.Combine(directory.FullName, "openssl"); + File.WriteAllText(openSslPath, """ + #!/bin/sh + sleep 30 + """); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(openSslPath, UnixFileMode.UserRead | UnixFileMode.UserExecute); + } + + return openSslPath; + } + private static string GetOpenSslCertificateFileName(X509Certificate2 certificate) => $"aspnetcore-localhost-{certificate.Thumbprint}.pem"; @@ -476,6 +491,55 @@ public async Task CheckAsync_LinuxWithMissingOpenSslHashEntry_ReturnsOpenSslCert } } + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "The synthetic openssl command uses a POSIX shell script.")] + public async Task CheckAsync_LinuxWithCanceledOpenSslHashProbe_ReturnsOpenSslCertificateCacheWarning() + { + using var certificate = CreateCertificate(); + var tempDirectory = Directory.CreateTempSubdirectory(); + using var cancellationTokenSource = new CancellationTokenSource(); + + try + { + CreateCertUtil(tempDirectory); + CreateOpenSslThatWaits(tempDirectory); + var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); + File.WriteAllText(Path.Combine(trustDirectory.FullName, GetOpenSslCertificateFileName(certificate)), certificate.ExportCertificatePem()); + + var certs = new List + { + CreateDevCertInfo(CertificateManager.TrustLevel.Full, certificate, MinVersion), + }; + var toolRunner = new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.Full, + Certificates = certs + } + }; + var environment = TestEnvironment.CreateLinux(new Dictionary + { + ["PATH"] = tempDirectory.FullName, + [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName + }); + var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + await cancellationTokenSource.CancelAsync(); + + var results = await check.CheckAsync(cancellationTokenSource.Token); + + var cacheResult = Assert.Single(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); + Assert.Equal(EnvironmentCheckStatus.Warning, cacheResult.Status); + Assert.Contains(certificate.Thumbprint, cacheResult.Details); + Assert.Contains("subject-hash", cacheResult.Details); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + [Fact] public async Task CheckAsync_LinuxWithMissingOpenSslHashEntryAndNoOpenSsl_ReturnsOpenSslCertificateCacheWarningWithOpenSslFix() { From abcfb29a4562a170fe301444f80d9810f74858ce Mon Sep 17 00:00:00 2001 From: David Negstad Date: Wed, 22 Jul 2026 17:16:47 -0700 Subject: [PATCH 09/18] Use process factory for OpenSSL hash probe Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677e0f4a-653a-4d97-b690-f86d67107226 --- .../Utils/EnvironmentChecker/DevCertsCheck.cs | 112 ++++++++---------- .../Utils/DevCertsCheckTests.cs | 64 +++++----- 2 files changed, 85 insertions(+), 91 deletions(-) diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs index c58d6b84a51..5a7daefab8e 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs @@ -1,15 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.ComponentModel; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Text; using System.Text.Json.Nodes; using System.Text.RegularExpressions; using Aspire.Cli.Certificates; +using Aspire.Cli.DotNet; using Aspire.Cli.Resources; using Microsoft.AspNetCore.Certificates.Generation; using Microsoft.Extensions.Logging; @@ -19,7 +18,7 @@ namespace Aspire.Cli.Utils.EnvironmentChecker; /// /// Checks if the HTTPS development certificate is trusted and detects multiple certificates. /// -internal sealed class DevCertsCheck(ILogger logger, ICertificateToolRunner certificateToolRunner, IEnvironment environment) : IEnvironmentCheck +internal sealed class DevCertsCheck(ILogger logger, ICertificateToolRunner certificateToolRunner, IEnvironment environment, IProcessExecutionFactory processExecutionFactory) : IEnvironmentCheck { internal const string CheckName = "dev-certs"; internal const string VersionCheckName = "dev-certs-version"; @@ -36,28 +35,28 @@ internal sealed class DevCertsCheck(ILogger logger, ICertificateT private static readonly string s_installOpenSslCleanAndTrustFixCommand = string.Format(CultureInfo.InvariantCulture, DoctorCommandStrings.DevCertsInstallOpenSslCleanAndTrustFixFormat, "openssl", "aspire certs clean", "aspire certs trust"); private static readonly Regex s_openSslHashFileNameRegex = new("^[0-9a-fA-F]{8}\\.\\d+$", RegexOptions.CultureInvariant); - public Task> CheckAsync(CancellationToken cancellationToken = default) + public async Task> CheckAsync(CancellationToken cancellationToken = default) { try { var trustResult = certificateToolRunner.CheckHttpCertificate(); var results = EvaluateCertificateResults(trustResult.Certificates, environment); - AddLinuxOpenSslCertificateCacheWarnings(results, trustResult.Certificates, environment, cancellationToken); + await AddLinuxOpenSslCertificateCacheWarningsAsync(results, trustResult.Certificates, environment, cancellationToken).ConfigureAwait(false); AddLinuxCertificateToolWarnings(results, environment); - return Task.FromResult>(results); + return results; } catch (Exception ex) { logger.LogDebug(ex, "Error checking dev-certs"); - return Task.FromResult>([new EnvironmentCheckResult + return [new EnvironmentCheckResult { Category = EnvironmentCheckCategories.Environment, Name = CheckName, Status = EnvironmentCheckStatus.Warning, Message = "Unable to check HTTPS development certificate", Details = ex.Message - }]); + }]; } } @@ -216,7 +215,7 @@ internal static List EvaluateCertificateResults( return results; } - private static void AddLinuxOpenSslCertificateCacheWarnings(List results, IReadOnlyList certInfos, IEnvironment environment, CancellationToken cancellationToken) + private async Task AddLinuxOpenSslCertificateCacheWarningsAsync(List results, IReadOnlyList certInfos, IEnvironment environment, CancellationToken cancellationToken) { if (!environment.IsLinux()) { @@ -233,7 +232,7 @@ private static void AddLinuxOpenSslCertificateCacheWarnings(List GetCurrentDevCertificates(IReadOnlyList< .ThenBy(c => c.Thumbprint, StringComparer.OrdinalIgnoreCase); } - private static OpenSslCertificateCacheStatus? EvaluateOpenSslCertificateCache(string trustPath, IReadOnlyList currentCertificates, string? openSslPath, CancellationToken cancellationToken) + private async Task EvaluateOpenSslCertificateCacheAsync(string trustPath, IReadOnlyList currentCertificates, string? openSslPath, CancellationToken cancellationToken) { var fix = GetOpenSslCertificateCacheFix(openSslPath); @@ -309,7 +308,7 @@ private static IEnumerable GetCurrentDevCertificates(IReadOnlyList< mismatchedThumbprints.Add(certificate.Thumbprint!); } else if (certificate.TrustLevel != CertificateManager.TrustLevel.None && - !HasOpenSslHashEntry(trustPath, certificateFile, cachedCertificate, openSslPath, cancellationToken)) + !await HasOpenSslHashEntryAsync(trustPath, certificateFile, cachedCertificate, openSslPath, cancellationToken).ConfigureAwait(false)) { missingHashLinkThumbprints.Add(certificate.Thumbprint!); } @@ -358,11 +357,12 @@ private static IEnumerable GetCurrentDevCertificates(IReadOnlyList< private static string GetOpenSslCertificateCacheFix(string? openSslPath) => openSslPath is null ? s_installOpenSslCleanAndTrustFixCommand : s_cleanAndTrustFixCommand; - private static bool HasOpenSslHashEntry(string trustPath, string certificateFile, X509Certificate2 certificate, string? openSslPath, CancellationToken cancellationToken) + private async Task HasOpenSslHashEntryAsync(string trustPath, string certificateFile, X509Certificate2 certificate, string? openSslPath, CancellationToken cancellationToken) { if (openSslPath is not null) { - return TryGetOpenSslHash(openSslPath, certificateFile, cancellationToken, out var hash) && + var (success, hash) = await TryGetOpenSslHashAsync(openSslPath, certificateFile, cancellationToken).ConfigureAwait(false); + return success && HasMatchingHashEntry(trustPath, hash, certificate); } @@ -407,75 +407,65 @@ private static bool CertificateFileMatches(string certificateFile, X509Certifica } } - private static bool TryGetOpenSslHash(string openSslPath, string certificateFile, CancellationToken cancellationToken, [NotNullWhen(true)] out string? hash) + private async Task<(bool Success, string Hash)> TryGetOpenSslHashAsync(string openSslPath, string certificateFile, CancellationToken cancellationToken) { - hash = null; - - var processInfo = new ProcessStartInfo(openSslPath) - { - RedirectStandardOutput = true, - RedirectStandardError = true - }; - processInfo.ArgumentList.Add("x509"); - processInfo.ArgumentList.Add("-hash"); - processInfo.ArgumentList.Add("-noout"); - processInfo.ArgumentList.Add("-in"); - processInfo.ArgumentList.Add(certificateFile); + var stdout = new StringBuilder(); + var workingDirectory = Path.GetDirectoryName(certificateFile) is { } directory + ? new DirectoryInfo(directory) + : new DirectoryInfo(Directory.GetCurrentDirectory()); + await using var process = processExecutionFactory.CreateExecution( + openSslPath, + ["x509", "-hash", "-noout", "-in", certificateFile], + env: null, + workingDirectory, + new ProcessInvocationOptions + { + SuppressLogging = true, + StandardOutputCallback = line => stdout.AppendLine(line), + StandardErrorCallback = _ => { }, + }); + var started = false; + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(s_openSslHashTimeout); - Process? process = null; try { - process = Process.Start(processInfo); - // Read both redirected streams concurrently to avoid deadlock if openssl fills a pipe - // while the process is still running. - var stdoutTask = process!.StandardOutput.ReadToEndAsync(); - var stderrTask = process.StandardError.ReadToEndAsync(); - - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(s_openSslHashTimeout); - - try + started = await process.StartAsync(timeoutCts.Token).ConfigureAwait(false); + if (!started) { - process.WaitForExitAsync(timeoutCts.Token).GetAwaiter().GetResult(); + return (false, ""); } - catch (OperationCanceledException) - { - TryKillProcess(process); - return false; - } - - var stdout = stdoutTask.GetAwaiter().GetResult(); - _ = stderrTask.GetAwaiter().GetResult(); - if (process.ExitCode != 0) + var exitCode = await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false); + if (exitCode != 0) { - return false; + return (false, ""); } - hash = stdout.Trim(); - return hash.Length > 0; - } - catch (Exception ex) when (ex is Win32Exception or IOException or InvalidOperationException) - { - return false; + var hash = stdout.ToString().Trim(); + return hash.Length > 0 ? (true, hash) : (false, ""); } - finally + catch (Exception ex) when (ex is OperationCanceledException or IOException or InvalidOperationException) { - process?.Dispose(); + if (started) + { + TryKillProcess(process); + } + + return (false, ""); } } - private static void TryKillProcess(Process process) + private static void TryKillProcess(IProcessExecution process) { try { if (!process.HasExited) { process.Kill(entireProcessTree: true); - process.WaitForExit(milliseconds: 1000); } } - catch (Exception ex) when (ex is InvalidOperationException or Win32Exception) + catch (InvalidOperationException) { } } diff --git a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs index 97df6cc4399..62317f77cbe 100644 --- a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs @@ -54,6 +54,15 @@ private static X509Certificate2 CreateCertificate() return X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert)); } + private static DevCertsCheck CreateCheck(TestCertificateToolRunner toolRunner, IEnvironment environment, TestProcessExecutionFactory? processExecutionFactory = null) => + new(NullLogger.Instance, toolRunner, environment, processExecutionFactory ?? CreateOpenSslProcessExecutionFactory()); + + private static TestProcessExecutionFactory CreateOpenSslProcessExecutionFactory(string hash = "12345678") => + new() + { + AsyncAttemptCallback = (_, _, _) => Task.FromResult((0, (string?)hash)) + }; + private static string CreateCertUtil(DirectoryInfo directory) { var certUtilPath = Path.Combine(directory.FullName, CertificateHelpers.CertUtilCommand); @@ -98,21 +107,6 @@ exit 1 return openSslPath; } - private static string CreateOpenSslThatWaits(DirectoryInfo directory) - { - var openSslPath = Path.Combine(directory.FullName, "openssl"); - File.WriteAllText(openSslPath, """ - #!/bin/sh - sleep 30 - """); - if (!OperatingSystem.IsWindows()) - { - File.SetUnixFileMode(openSslPath, UnixFileMode.UserRead | UnixFileMode.UserExecute); - } - - return openSslPath; - } - private static string GetOpenSslCertificateFileName(X509Certificate2 certificate) => $"aspnetcore-localhost-{certificate.Thumbprint}.pem"; @@ -348,7 +342,7 @@ public async Task CheckAsync_LinuxWithoutCertUtil_ReturnsCertUtilWarning() { ["PATH"] = Path.Combine(AppContext.BaseDirectory, Guid.NewGuid().ToString("N")) }); - var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + var check = CreateCheck(toolRunner, environment); var results = await check.CheckAsync(); @@ -383,7 +377,7 @@ public async Task CheckAsync_LinuxWithCertUtil_DoesNotReturnCertUtilWarning() { ["PATH"] = tempDirectory.FullName }); - var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + var check = CreateCheck(toolRunner, environment); var results = await check.CheckAsync(); @@ -430,7 +424,7 @@ public async Task CheckAsync_LinuxWithMatchingOpenSslCertificateCache_DoesNotRet ["PATH"] = tempDirectory.FullName, [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName }); - var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + var check = CreateCheck(toolRunner, environment); var results = await check.CheckAsync(); @@ -474,7 +468,7 @@ public async Task CheckAsync_LinuxWithMissingOpenSslHashEntry_ReturnsOpenSslCert ["PATH"] = tempDirectory.FullName, [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName }); - var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + var check = CreateCheck(toolRunner, environment); var results = await check.CheckAsync(); @@ -502,7 +496,7 @@ public async Task CheckAsync_LinuxWithCanceledOpenSslHashProbe_ReturnsOpenSslCer try { CreateCertUtil(tempDirectory); - CreateOpenSslThatWaits(tempDirectory); + CreateOpenSsl(tempDirectory, "12345678"); var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); File.WriteAllText(Path.Combine(trustDirectory.FullName, GetOpenSslCertificateFileName(certificate)), certificate.ExportCertificatePem()); @@ -524,8 +518,15 @@ public async Task CheckAsync_LinuxWithCanceledOpenSslHashProbe_ReturnsOpenSslCer ["PATH"] = tempDirectory.FullName, [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName }); - var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); - await cancellationTokenSource.CancelAsync(); + var processExecutionFactory = new TestProcessExecutionFactory + { + AsyncAttemptCallback = async (_, _, cancellationToken) => + { + await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken); + return (0, (string?)"12345678"); + } + }; + var check = CreateCheck(toolRunner, environment, processExecutionFactory); var results = await check.CheckAsync(cancellationTokenSource.Token); @@ -533,6 +534,9 @@ public async Task CheckAsync_LinuxWithCanceledOpenSslHashProbe_ReturnsOpenSslCer Assert.Equal(EnvironmentCheckStatus.Warning, cacheResult.Status); Assert.Contains(certificate.Thumbprint, cacheResult.Details); Assert.Contains("subject-hash", cacheResult.Details); + var processExecution = Assert.IsType(Assert.Single(processExecutionFactory.CreatedExecutions)); + Assert.Equal(1, processExecution.KillCount); + Assert.True(processExecution.KilledEntireProcessTree); } finally { @@ -570,7 +574,7 @@ public async Task CheckAsync_LinuxWithMissingOpenSslHashEntryAndNoOpenSsl_Return ["PATH"] = tempDirectory.FullName, [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName }); - var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + var check = CreateCheck(toolRunner, environment); var results = await check.CheckAsync(); @@ -615,7 +619,7 @@ public async Task CheckAsync_LinuxWithMissingOpenSslCertificateCacheDirectory_Re ["PATH"] = tempDirectory.FullName, [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory }); - var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + var check = CreateCheck(toolRunner, environment); var results = await check.CheckAsync(); @@ -659,7 +663,7 @@ public async Task CheckAsync_LinuxWithMissingOpenSslCertificateCacheEntry_Return ["PATH"] = tempDirectory.FullName, [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName }); - var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + var check = CreateCheck(toolRunner, environment); var results = await check.CheckAsync(); @@ -705,7 +709,7 @@ public async Task CheckAsync_LinuxWithCorruptOpenSslCertificateCache_ReturnsOpen ["PATH"] = tempDirectory.FullName, [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName }); - var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + var check = CreateCheck(toolRunner, environment); var results = await check.CheckAsync(); @@ -751,7 +755,7 @@ public async Task CheckAsync_LinuxWithUntrustedCertificateAndCorruptOpenSslCerti ["PATH"] = tempDirectory.FullName, [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName }); - var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + var check = CreateCheck(toolRunner, environment); var results = await check.CheckAsync(); @@ -797,7 +801,7 @@ public async Task CheckAsync_LinuxWithUnrelatedCorruptOpenSslCertificateCache_Do ["PATH"] = tempDirectory.FullName, [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName }); - var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + var check = CreateCheck(toolRunner, environment); var results = await check.CheckAsync(); @@ -840,7 +844,7 @@ public async Task CheckAsync_LinuxWithStaleOpenSslCertificateCache_ReturnsOpenSs ["PATH"] = tempDirectory.FullName, [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName }); - var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + var check = CreateCheck(toolRunner, environment); var results = await check.CheckAsync(); @@ -875,7 +879,7 @@ public async Task CheckAsync_NonLinux_DoesNotReadEnvironmentVariablesForCertUtil { ["PATH"] = Path.Combine(AppContext.BaseDirectory, Guid.NewGuid().ToString("N")) }); - var check = new DevCertsCheck(NullLogger.Instance, toolRunner, environment); + var check = CreateCheck(toolRunner, environment); var results = await check.CheckAsync(); From cd934546dd8d27ae794d46fc58b0b2b566f35f79 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Wed, 22 Jul 2026 17:28:40 -0700 Subject: [PATCH 10/18] Address certificate trust review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677e0f4a-653a-4d97-b690-f86d67107226 --- .../Utils/EnvironmentChecker/DevCertsCheck.cs | 23 +++- ...cateTrustExecutionConfigurationGatherer.cs | 32 +++++- .../Utils/DevCertsCheckTests.cs | 104 ++++++++++++++++++ .../Dcp/DcpExecutorTests.cs | 28 +++++ 4 files changed, 183 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs index 5a7daefab8e..95440492d3d 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs @@ -46,6 +46,10 @@ public async Task> CheckAsync(Cancellation return results; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception ex) { logger.LogDebug(ex, "Error checking dev-certs"); @@ -362,13 +366,19 @@ private async Task HasOpenSslHashEntryAsync(string trustPath, string certi if (openSslPath is not null) { var (success, hash) = await TryGetOpenSslHashAsync(openSslPath, certificateFile, cancellationToken).ConfigureAwait(false); - return success && - HasMatchingHashEntry(trustPath, hash, certificate); + return success + ? HasMatchingHashEntry(trustPath, hash, certificate) + : HasMatchingHashStyleEntry(trustPath, certificate); } // Without openssl we cannot compute the subject hash that OpenSSL requires. The // absence of any hash-style entry for the certificate is still definitely broken, // but a matching entry can only be treated as sufficient evidence to avoid warning. + return HasMatchingHashStyleEntry(trustPath, certificate); + } + + private static bool HasMatchingHashStyleEntry(string trustPath, X509Certificate2 certificate) + { return Directory.EnumerateFiles(trustPath) .Where(path => s_openSslHashFileNameRegex.IsMatch(Path.GetFileName(path))) .Any(path => CertificateFileMatches(path, certificate)); @@ -445,6 +455,15 @@ private static bool CertificateFileMatches(string certificateFile, X509Certifica var hash = stdout.ToString().Trim(); return hash.Length > 0 ? (true, hash) : (false, ""); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if (started) + { + TryKillProcess(process); + } + + throw; + } catch (Exception ex) when (ex is OperationCanceledException or IOException or InvalidOperationException) { if (started) diff --git a/src/Aspire.Hosting/ApplicationModel/CertificateTrustExecutionConfigurationGatherer.cs b/src/Aspire.Hosting/ApplicationModel/CertificateTrustExecutionConfigurationGatherer.cs index 2041a84c68e..afdb9c630fa 100644 --- a/src/Aspire.Hosting/ApplicationModel/CertificateTrustExecutionConfigurationGatherer.cs +++ b/src/Aspire.Hosting/ApplicationModel/CertificateTrustExecutionConfigurationGatherer.cs @@ -81,9 +81,15 @@ public async ValueTask GatherAsync(IExecutionConfigurationGathererContext contex } var configurationContext = _configContextFactory(additionalData.Scope); + var certificateDirectoriesPath = configurationContext.CertificateDirectoriesPath; + if (additionalData.Scope == CertificateTrustScope.Append && + context.EnvironmentVariables.TryGetValue("SSL_CERT_DIR", out var existingCertificateDirectoriesPath)) + { + certificateDirectoriesPath = AppendCertificateDirectoriesPath(certificateDirectoriesPath, existingCertificateDirectoriesPath, configurationContext.IsContainer ? ':' : Path.PathSeparator); + } // Apply default OpenSSL environment configuration for certificate trust - context.EnvironmentVariables["SSL_CERT_DIR"] = configurationContext.CertificateDirectoriesPath; + context.EnvironmentVariables["SSL_CERT_DIR"] = certificateDirectoriesPath; if (additionalData.Scope != CertificateTrustScope.Append) { @@ -96,7 +102,7 @@ public async ValueTask GatherAsync(IExecutionConfigurationGathererContext contex Resource = resource, Scope = additionalData.Scope, CertificateBundlePath = configurationContext.CertificateBundlePath, - CertificateDirectoriesPath = configurationContext.CertificateDirectoriesPath, + CertificateDirectoriesPath = certificateDirectoriesPath, // Must use the tracked reference to ensure proper tracking of usage RootCertificatesPath = configurationContext.RootCertificatesPath, Arguments = context.Arguments, @@ -124,6 +130,28 @@ public async ValueTask GatherAsync(IExecutionConfigurationGathererContext contex } } + + private static ReferenceExpression AppendCertificateDirectoriesPath(ReferenceExpression certificateDirectoriesPath, object existingCertificateDirectoriesPath, char pathSeparator) + { + if (existingCertificateDirectoriesPath is string { Length: 0 }) + { + return certificateDirectoriesPath; + } + + var builder = new ReferenceExpressionBuilder(); + builder.Append($"{certificateDirectoriesPath}"); + builder.AppendLiteral(pathSeparator.ToString()); + if (existingCertificateDirectoriesPath is string existingCertificateDirectoriesPathString) + { + builder.Append($"{existingCertificateDirectoriesPathString}"); + } + else + { + builder.AppendValueProvider(existingCertificateDirectoriesPath); + } + + return builder.Build(); + } } /// diff --git a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs index 62317f77cbe..6dc584e75c2 100644 --- a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs @@ -436,6 +436,54 @@ public async Task CheckAsync_LinuxWithMatchingOpenSslCertificateCache_DoesNotRet } } + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "The synthetic openssl command uses a POSIX shell script.")] + public async Task CheckAsync_LinuxWithFailedOpenSslHashProbeAndMatchingHashStyleEntry_DoesNotReturnOpenSslCertificateCacheWarning() + { + using var certificate = CreateCertificate(); + var tempDirectory = Directory.CreateTempSubdirectory(); + + try + { + CreateCertUtil(tempDirectory); + CreateOpenSsl(tempDirectory, "12345678"); + var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); + WriteOpenSslCertificateCache(trustDirectory, certificate, "87654321.0"); + + var certs = new List + { + CreateDevCertInfo(CertificateManager.TrustLevel.Full, certificate, MinVersion), + }; + var toolRunner = new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.Full, + Certificates = certs + } + }; + var environment = TestEnvironment.CreateLinux(new Dictionary + { + ["PATH"] = tempDirectory.FullName, + [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName + }); + var processExecutionFactory = new TestProcessExecutionFactory + { + AsyncAttemptCallback = (_, _, _) => Task.FromResult((1, (string?)null)) + }; + var check = CreateCheck(toolRunner, environment, processExecutionFactory); + + var results = await check.CheckAsync(); + + Assert.DoesNotContain(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + [Fact] [SkipOnPlatform(TestPlatforms.Windows, "The synthetic openssl command uses a POSIX shell script.")] public async Task CheckAsync_LinuxWithMissingOpenSslHashEntry_ReturnsOpenSslCertificateCacheWarning() @@ -485,6 +533,62 @@ public async Task CheckAsync_LinuxWithMissingOpenSslHashEntry_ReturnsOpenSslCert } } + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "The synthetic openssl command uses a POSIX shell script.")] + public async Task CheckAsync_LinuxWithCallerCanceledOpenSslHashProbe_ThrowsOperationCanceledException() + { + using var certificate = CreateCertificate(); + var tempDirectory = Directory.CreateTempSubdirectory(); + using var cancellationTokenSource = new CancellationTokenSource(); + + try + { + CreateCertUtil(tempDirectory); + CreateOpenSsl(tempDirectory, "12345678"); + var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); + File.WriteAllText(Path.Combine(trustDirectory.FullName, GetOpenSslCertificateFileName(certificate)), certificate.ExportCertificatePem()); + + var certs = new List + { + CreateDevCertInfo(CertificateManager.TrustLevel.Full, certificate, MinVersion), + }; + var toolRunner = new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.Full, + Certificates = certs + } + }; + var environment = TestEnvironment.CreateLinux(new Dictionary + { + ["PATH"] = tempDirectory.FullName, + [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName + }); + var processExecutionFactory = new TestProcessExecutionFactory + { + AsyncAttemptCallback = async (_, _, cancellationToken) => + { + await cancellationTokenSource.CancelAsync(); + await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken); + return (0, (string?)"12345678"); + } + }; + var check = CreateCheck(toolRunner, environment, processExecutionFactory); + + await Assert.ThrowsAnyAsync(() => check.CheckAsync(cancellationTokenSource.Token)); + + var processExecution = Assert.IsType(Assert.Single(processExecutionFactory.CreatedExecutions)); + Assert.Equal(1, processExecution.KillCount); + Assert.True(processExecution.KilledEntireProcessTree); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + [Fact] [SkipOnPlatform(TestPlatforms.Windows, "The synthetic openssl command uses a POSIX shell script.")] public async Task CheckAsync_LinuxWithCanceledOpenSslHashProbe_ReturnsOpenSslCertificateCacheWarning() diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 63693ad7089..8c73358246c 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -3541,6 +3541,34 @@ public async Task PersistentPlainExecutable_UsesStableCertificateOutputPath() Assert.Equal(Path.Join(expectedCertificatesRoot, "cert.pem"), sslCertFile); } + [Fact] + public async Task PlainExecutableCertificateDirectoriesPath_PreservesResourceSslCertDirForAppend() + { + var builder = DistributedApplication.CreateBuilder(); + var customSslCertDir = $"/resource-certs-{Guid.NewGuid():N}"; + using var certificate = CreateTestCertificate(); + var certificateAuthorities = builder.AddCertificateAuthorityCollection("certificates") + .WithCertificate(certificate); + + var executable = new TestExecutableResource("test-working-directory"); + builder.AddResource(executable) + .WithEnvironment("SSL_CERT_DIR", customSslCertDir) + .WithCertificateAuthorityCollection(certificateAuthorities); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); + + await appExecutor.RunApplicationAsync(); + + var exe = Assert.Single(kubernetesService.CreatedResources.OfType(), e => e.AppModelResourceName == "TestExecutable"); + var sslCertDir = Assert.Single(exe.Spec.Env!, e => e.Name == "SSL_CERT_DIR").Value; + + Assert.NotNull(sslCertDir); + Assert.Contains(customSslCertDir, sslCertDir.Split(Path.PathSeparator)); + } + [Fact] public void ExecutableCertificateDirectoriesPath_IncludesExistingWellKnownDirectoriesForAppendWhenSslCertDirIsUnset() { From 7dd3dabd905f0ed977b845798270299204ceca05 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Wed, 22 Jul 2026 17:55:43 -0700 Subject: [PATCH 11/18] Preserve resource SSL_CERT_DIR before fallback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677e0f4a-653a-4d97-b690-f86d67107226 --- ...cateTrustExecutionConfigurationGatherer.cs | 7 +++- src/Aspire.Hosting/Dcp/ContainerCreator.cs | 2 + src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 2 + .../Dcp/DcpExecutorTests.cs | 5 ++- .../ExecutionConfigurationGathererTests.cs | 42 +++++++++++++++++++ 5 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/CertificateTrustExecutionConfigurationGatherer.cs b/src/Aspire.Hosting/ApplicationModel/CertificateTrustExecutionConfigurationGatherer.cs index afdb9c630fa..3ab3eb67935 100644 --- a/src/Aspire.Hosting/ApplicationModel/CertificateTrustExecutionConfigurationGatherer.cs +++ b/src/Aspire.Hosting/ApplicationModel/CertificateTrustExecutionConfigurationGatherer.cs @@ -85,7 +85,10 @@ public async ValueTask GatherAsync(IExecutionConfigurationGathererContext contex if (additionalData.Scope == CertificateTrustScope.Append && context.EnvironmentVariables.TryGetValue("SSL_CERT_DIR", out var existingCertificateDirectoriesPath)) { - certificateDirectoriesPath = AppendCertificateDirectoriesPath(certificateDirectoriesPath, existingCertificateDirectoriesPath, configurationContext.IsContainer ? ':' : Path.PathSeparator); + certificateDirectoriesPath = AppendCertificateDirectoriesPath( + configurationContext.CertificateDirectoriesPathBeforeFallback ?? certificateDirectoriesPath, + existingCertificateDirectoriesPath, + configurationContext.IsContainer ? ':' : Path.PathSeparator); } // Apply default OpenSSL environment configuration for certificate trust @@ -193,6 +196,8 @@ public class CertificateTrustExecutionConfigurationContext /// public required ReferenceExpression CertificateDirectoriesPath { get; init; } + internal ReferenceExpression? CertificateDirectoriesPathBeforeFallback { get; init; } + /// /// The root path certificates will be written to in the resource context (e.g., container filesystem). /// diff --git a/src/Aspire.Hosting/Dcp/ContainerCreator.cs b/src/Aspire.Hosting/Dcp/ContainerCreator.cs index a9d658241ee..554e167b9df 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -700,6 +700,7 @@ private string GetTunnelProxyResourceName() } var serverAuthCertificatesBasePath = $"{certificatesDestination}/private"; + var resourceCertificateDirectoriesPath = ReferenceExpression.Create($"{certificatesDestination}/certs"); var configuration = await ExecutionConfigurationBuilder.Create(cr.ModelResource) .WithArgumentsConfig() @@ -716,6 +717,7 @@ private string GetTunnelProxyResourceName() { CertificateBundlePath = ReferenceExpression.Create($"{certificatesDestination}/cert.pem"), CertificateDirectoriesPath = ReferenceExpression.Create($"{string.Join(':', dirs)}"), + CertificateDirectoriesPathBeforeFallback = resourceCertificateDirectoriesPath, RootCertificatesPath = certificatesDestination, IsContainer = true, }; diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index e8dbe452e20..06b9873d314 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -494,6 +494,7 @@ private async Task BuildExecutableConfiguration(Rendere var customBundleOutputPath = Path.Join(certificatesRootDir, "bundles"); var certificatesOutputPath = Path.Join(certificatesRootDir, "certs"); var baseServerAuthOutputPath = Path.Join(certificatesRootDir, "private"); + var resourceCertificateDirectoriesPath = ReferenceExpression.Create($"{certificatesOutputPath}"); var configuration = await ExecutionConfigurationBuilder.Create(er.ModelResource) .WithArgumentsConfig() @@ -508,6 +509,7 @@ private async Task BuildExecutableConfiguration(Rendere scope, Environment.GetEnvironmentVariable(SslCertDirEnvVar), includeWellKnownCertificateDirectories: OperatingSystem.IsLinux())}"), + CertificateDirectoriesPathBeforeFallback = resourceCertificateDirectoriesPath, RootCertificatesPath = certificatesRootDir, }; }) diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 8c73358246c..d2842a0ff3d 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -3566,7 +3566,10 @@ public async Task PlainExecutableCertificateDirectoriesPath_PreservesResourceSsl var sslCertDir = Assert.Single(exe.Spec.Env!, e => e.Name == "SSL_CERT_DIR").Value; Assert.NotNull(sslCertDir); - Assert.Contains(customSslCertDir, sslCertDir.Split(Path.PathSeparator)); + var sslCertDirs = sslCertDir.Split(Path.PathSeparator); + Assert.Equal(2, sslCertDirs.Length); + Assert.EndsWith($"{Path.DirectorySeparatorChar}certs", sslCertDirs[0]); + Assert.Equal(customSslCertDir, sslCertDirs[1]); } [Fact] diff --git a/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs b/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs index 8ae14072e2e..b2264e3b233 100644 --- a/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs +++ b/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs @@ -407,6 +407,48 @@ public async Task CertificateTrustExecutionConfigurationGatherer_NoCertificateAn Assert.DoesNotContain("SSL_CERT_DIR", context.EnvironmentVariables.Keys); } + [Fact] + public async Task CertificateTrustExecutionConfigurationGatherer_WithAppendScopeAndExistingSslCertDir_UsesResourceValueBeforeFallback() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + var cert = CreateTestCertificate(); + var caCollection = builder.AddCertificateAuthorityCollection("test-ca").WithCertificate(cert); + + var resource = builder.AddContainer("test", "image") + .WithCertificateAuthorityCollection(caCollection) + .WithCertificateTrustScope(CertificateTrustScope.Append) + .Resource; + + await builder.BuildAsync(); + + var context = new ExecutionConfigurationGathererContext(); + context.EnvironmentVariables["SSL_CERT_DIR"] = "/private-roots"; + var gatherer = new CertificateTrustExecutionConfigurationGatherer(_ => new CertificateTrustExecutionConfigurationContext + { + CertificateBundlePath = ReferenceExpression.Create($"/aspire/cert.pem"), + CertificateDirectoriesPath = ReferenceExpression.Create($"/aspire/certs:/ambient-roots"), + CertificateDirectoriesPathBeforeFallback = ReferenceExpression.Create($"/aspire/certs"), + RootCertificatesPath = "/aspire", + IsContainer = true, + }); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + + // Assert + var sslCertDir = Assert.IsType(context.EnvironmentVariables["SSL_CERT_DIR"]); + Assert.Equal("/aspire/certs:/private-roots", await sslCertDir.GetValueAsync(CancellationToken.None)); + + var emptyContext = new ExecutionConfigurationGathererContext(); + emptyContext.EnvironmentVariables["SSL_CERT_DIR"] = string.Empty; + + await gatherer.GatherAsync(emptyContext, resource, NullLogger.Instance, builder.ExecutionContext); + + sslCertDir = Assert.IsType(emptyContext.EnvironmentVariables["SSL_CERT_DIR"]); + Assert.Equal("/aspire/certs", await sslCertDir.GetValueAsync(CancellationToken.None)); + } + [Fact] public async Task CertificateTrustExecutionConfigurationGatherer_WithAppendScope_DoesNotSetSSL_CERT_FILE() { From 60321a0439d021bc7a7756f2e0e6c7236f495886 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 23 Jul 2026 12:06:37 -0700 Subject: [PATCH 12/18] Tighten OpenSSL hash probe diagnostics Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677e0f4a-653a-4d97-b690-f86d67107226 --- .../Utils/EnvironmentChecker/DevCertsCheck.cs | 21 ++++----------- .../Utils/DevCertsCheckTests.cs | 15 +++++------ .../Dcp/DcpExecutorTests.cs | 27 +++++++++++++++++++ 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs index 95440492d3d..efa49a4c8c3 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs @@ -6,7 +6,6 @@ using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json.Nodes; -using System.Text.RegularExpressions; using Aspire.Cli.Certificates; using Aspire.Cli.DotNet; using Aspire.Cli.Resources; @@ -33,7 +32,6 @@ internal sealed class DevCertsCheck(ILogger logger, ICertificateT private static readonly string s_trustFixCommand = string.Format(CultureInfo.InvariantCulture, DoctorCommandStrings.DevCertsTrustFixFormat, "aspire certs trust"); private static readonly string s_cleanAndTrustFixCommand = string.Format(CultureInfo.InvariantCulture, DoctorCommandStrings.DevCertsCleanAndTrustFixFormat, "aspire certs clean", "aspire certs trust"); private static readonly string s_installOpenSslCleanAndTrustFixCommand = string.Format(CultureInfo.InvariantCulture, DoctorCommandStrings.DevCertsInstallOpenSslCleanAndTrustFixFormat, "openssl", "aspire certs clean", "aspire certs trust"); - private static readonly Regex s_openSslHashFileNameRegex = new("^[0-9a-fA-F]{8}\\.\\d+$", RegexOptions.CultureInvariant); public async Task> CheckAsync(CancellationToken cancellationToken = default) { @@ -366,22 +364,13 @@ private async Task HasOpenSslHashEntryAsync(string trustPath, string certi if (openSslPath is not null) { var (success, hash) = await TryGetOpenSslHashAsync(openSslPath, certificateFile, cancellationToken).ConfigureAwait(false); - return success - ? HasMatchingHashEntry(trustPath, hash, certificate) - : HasMatchingHashStyleEntry(trustPath, certificate); + return success && + HasMatchingHashEntry(trustPath, hash, certificate); } - // Without openssl we cannot compute the subject hash that OpenSSL requires. The - // absence of any hash-style entry for the certificate is still definitely broken, - // but a matching entry can only be treated as sufficient evidence to avoid warning. - return HasMatchingHashStyleEntry(trustPath, certificate); - } - - private static bool HasMatchingHashStyleEntry(string trustPath, X509Certificate2 certificate) - { - return Directory.EnumerateFiles(trustPath) - .Where(path => s_openSslHashFileNameRegex.IsMatch(Path.GetFileName(path))) - .Any(path => CertificateFileMatches(path, certificate)); + // Without openssl we cannot compute the subject hash that OpenSSL requires, so + // friendly-name PEM checks are the strongest validation we can perform. + return true; } private static bool HasMatchingHashEntry(string trustPath, string hash, X509Certificate2 certificate) diff --git a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs index 6dc584e75c2..b5904188f9d 100644 --- a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs @@ -438,7 +438,7 @@ public async Task CheckAsync_LinuxWithMatchingOpenSslCertificateCache_DoesNotRet [Fact] [SkipOnPlatform(TestPlatforms.Windows, "The synthetic openssl command uses a POSIX shell script.")] - public async Task CheckAsync_LinuxWithFailedOpenSslHashProbeAndMatchingHashStyleEntry_DoesNotReturnOpenSslCertificateCacheWarning() + public async Task CheckAsync_LinuxWithFailedOpenSslHashProbeAndMatchingHashStyleEntry_ReturnsOpenSslCertificateCacheWarning() { using var certificate = CreateCertificate(); var tempDirectory = Directory.CreateTempSubdirectory(); @@ -476,7 +476,10 @@ public async Task CheckAsync_LinuxWithFailedOpenSslHashProbeAndMatchingHashStyle var results = await check.CheckAsync(); - Assert.DoesNotContain(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); + var cacheResult = Assert.Single(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); + Assert.Equal(EnvironmentCheckStatus.Warning, cacheResult.Status); + Assert.Contains(certificate.Thumbprint, cacheResult.Details); + Assert.Contains("subject-hash", cacheResult.Details); } finally { @@ -649,7 +652,7 @@ public async Task CheckAsync_LinuxWithCanceledOpenSslHashProbe_ReturnsOpenSslCer } [Fact] - public async Task CheckAsync_LinuxWithMissingOpenSslHashEntryAndNoOpenSsl_ReturnsOpenSslCertificateCacheWarningWithOpenSslFix() + public async Task CheckAsync_LinuxWithMissingOpenSslHashEntryAndNoOpenSsl_DoesNotReturnOpenSslCertificateCacheWarning() { using var certificate = CreateCertificate(); var tempDirectory = Directory.CreateTempSubdirectory(); @@ -682,11 +685,7 @@ public async Task CheckAsync_LinuxWithMissingOpenSslHashEntryAndNoOpenSsl_Return var results = await check.CheckAsync(); - var cacheResult = Assert.Single(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); - Assert.Equal(EnvironmentCheckStatus.Warning, cacheResult.Status); - Assert.Contains(certificate.Thumbprint, cacheResult.Details); - Assert.Contains("Install openssl", cacheResult.Fix); - Assert.Contains("aspire certs trust", cacheResult.Fix); + Assert.DoesNotContain(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); } finally { diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index d2842a0ff3d..18a37571698 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -3572,6 +3572,33 @@ public async Task PlainExecutableCertificateDirectoriesPath_PreservesResourceSsl Assert.Equal(customSslCertDir, sslCertDirs[1]); } + [Fact] + public async Task ContainerCertificateDirectoriesPath_PreservesResourceSslCertDirForAppend() + { + var builder = DistributedApplication.CreateBuilder(); + var customSslCertDir = $"/resource-certs-{Guid.NewGuid():N}"; + using var certificate = CreateTestCertificate(); + var certificateAuthorities = builder.AddCertificateAuthorityCollection("certificates") + .WithCertificate(certificate); + + builder.AddContainer("database", "image") + .WithEnvironment("SSL_CERT_DIR", customSslCertDir) + .WithCertificateAuthorityCollection(certificateAuthorities); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); + + await appExecutor.RunApplicationAsync(); + + var container = Assert.Single(kubernetesService.CreatedResources.OfType()); + var sslCertDir = Assert.Single(container.Spec.Env!, e => e.Name == "SSL_CERT_DIR").Value; + + Assert.NotNull(sslCertDir); + Assert.Equal($"{ContainerCertificatePathsAnnotation.DefaultCustomCertificatesDestination}/certs:{customSslCertDir}", sslCertDir); + } + [Fact] public void ExecutableCertificateDirectoriesPath_IncludesExistingWellKnownDirectoriesForAppendWhenSslCertDirIsUnset() { From c39fe4e661731b327d51208130e51173bd3d535b Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 23 Jul 2026 13:35:05 -0700 Subject: [PATCH 13/18] Restore certificate trust SSL_CERT_DIR ownership Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677e0f4a-653a-4d97-b690-f86d67107226 --- ...cateTrustExecutionConfigurationGatherer.cs | 37 +--------------- src/Aspire.Hosting/Dcp/ContainerCreator.cs | 2 - src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 2 - .../Dcp/DcpExecutorTests.cs | 42 +++++-------------- .../ExecutionConfigurationGathererTests.cs | 42 ------------------- 5 files changed, 12 insertions(+), 113 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/CertificateTrustExecutionConfigurationGatherer.cs b/src/Aspire.Hosting/ApplicationModel/CertificateTrustExecutionConfigurationGatherer.cs index 3ab3eb67935..2041a84c68e 100644 --- a/src/Aspire.Hosting/ApplicationModel/CertificateTrustExecutionConfigurationGatherer.cs +++ b/src/Aspire.Hosting/ApplicationModel/CertificateTrustExecutionConfigurationGatherer.cs @@ -81,18 +81,9 @@ public async ValueTask GatherAsync(IExecutionConfigurationGathererContext contex } var configurationContext = _configContextFactory(additionalData.Scope); - var certificateDirectoriesPath = configurationContext.CertificateDirectoriesPath; - if (additionalData.Scope == CertificateTrustScope.Append && - context.EnvironmentVariables.TryGetValue("SSL_CERT_DIR", out var existingCertificateDirectoriesPath)) - { - certificateDirectoriesPath = AppendCertificateDirectoriesPath( - configurationContext.CertificateDirectoriesPathBeforeFallback ?? certificateDirectoriesPath, - existingCertificateDirectoriesPath, - configurationContext.IsContainer ? ':' : Path.PathSeparator); - } // Apply default OpenSSL environment configuration for certificate trust - context.EnvironmentVariables["SSL_CERT_DIR"] = certificateDirectoriesPath; + context.EnvironmentVariables["SSL_CERT_DIR"] = configurationContext.CertificateDirectoriesPath; if (additionalData.Scope != CertificateTrustScope.Append) { @@ -105,7 +96,7 @@ public async ValueTask GatherAsync(IExecutionConfigurationGathererContext contex Resource = resource, Scope = additionalData.Scope, CertificateBundlePath = configurationContext.CertificateBundlePath, - CertificateDirectoriesPath = certificateDirectoriesPath, + CertificateDirectoriesPath = configurationContext.CertificateDirectoriesPath, // Must use the tracked reference to ensure proper tracking of usage RootCertificatesPath = configurationContext.RootCertificatesPath, Arguments = context.Arguments, @@ -133,28 +124,6 @@ public async ValueTask GatherAsync(IExecutionConfigurationGathererContext contex } } - - private static ReferenceExpression AppendCertificateDirectoriesPath(ReferenceExpression certificateDirectoriesPath, object existingCertificateDirectoriesPath, char pathSeparator) - { - if (existingCertificateDirectoriesPath is string { Length: 0 }) - { - return certificateDirectoriesPath; - } - - var builder = new ReferenceExpressionBuilder(); - builder.Append($"{certificateDirectoriesPath}"); - builder.AppendLiteral(pathSeparator.ToString()); - if (existingCertificateDirectoriesPath is string existingCertificateDirectoriesPathString) - { - builder.Append($"{existingCertificateDirectoriesPathString}"); - } - else - { - builder.AppendValueProvider(existingCertificateDirectoriesPath); - } - - return builder.Build(); - } } /// @@ -196,8 +165,6 @@ public class CertificateTrustExecutionConfigurationContext /// public required ReferenceExpression CertificateDirectoriesPath { get; init; } - internal ReferenceExpression? CertificateDirectoriesPathBeforeFallback { get; init; } - /// /// The root path certificates will be written to in the resource context (e.g., container filesystem). /// diff --git a/src/Aspire.Hosting/Dcp/ContainerCreator.cs b/src/Aspire.Hosting/Dcp/ContainerCreator.cs index 554e167b9df..a9d658241ee 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -700,7 +700,6 @@ private string GetTunnelProxyResourceName() } var serverAuthCertificatesBasePath = $"{certificatesDestination}/private"; - var resourceCertificateDirectoriesPath = ReferenceExpression.Create($"{certificatesDestination}/certs"); var configuration = await ExecutionConfigurationBuilder.Create(cr.ModelResource) .WithArgumentsConfig() @@ -717,7 +716,6 @@ private string GetTunnelProxyResourceName() { CertificateBundlePath = ReferenceExpression.Create($"{certificatesDestination}/cert.pem"), CertificateDirectoriesPath = ReferenceExpression.Create($"{string.Join(':', dirs)}"), - CertificateDirectoriesPathBeforeFallback = resourceCertificateDirectoriesPath, RootCertificatesPath = certificatesDestination, IsContainer = true, }; diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index 06b9873d314..e8dbe452e20 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -494,7 +494,6 @@ private async Task BuildExecutableConfiguration(Rendere var customBundleOutputPath = Path.Join(certificatesRootDir, "bundles"); var certificatesOutputPath = Path.Join(certificatesRootDir, "certs"); var baseServerAuthOutputPath = Path.Join(certificatesRootDir, "private"); - var resourceCertificateDirectoriesPath = ReferenceExpression.Create($"{certificatesOutputPath}"); var configuration = await ExecutionConfigurationBuilder.Create(er.ModelResource) .WithArgumentsConfig() @@ -509,7 +508,6 @@ private async Task BuildExecutableConfiguration(Rendere scope, Environment.GetEnvironmentVariable(SslCertDirEnvVar), includeWellKnownCertificateDirectories: OperatingSystem.IsLinux())}"), - CertificateDirectoriesPathBeforeFallback = resourceCertificateDirectoriesPath, RootCertificatesPath = certificatesRootDir, }; }) diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 18a37571698..8083606d0c4 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -3542,7 +3542,7 @@ public async Task PersistentPlainExecutable_UsesStableCertificateOutputPath() } [Fact] - public async Task PlainExecutableCertificateDirectoriesPath_PreservesResourceSslCertDirForAppend() + public async Task PlainExecutableCertificateDirectoriesPath_IgnoresResourceSslCertDirForAppend() { var builder = DistributedApplication.CreateBuilder(); var customSslCertDir = $"/resource-certs-{Guid.NewGuid():N}"; @@ -3566,37 +3566,15 @@ public async Task PlainExecutableCertificateDirectoriesPath_PreservesResourceSsl var sslCertDir = Assert.Single(exe.Spec.Env!, e => e.Name == "SSL_CERT_DIR").Value; Assert.NotNull(sslCertDir); - var sslCertDirs = sslCertDir.Split(Path.PathSeparator); - Assert.Equal(2, sslCertDirs.Length); - Assert.EndsWith($"{Path.DirectorySeparatorChar}certs", sslCertDirs[0]); - Assert.Equal(customSslCertDir, sslCertDirs[1]); - } - - [Fact] - public async Task ContainerCertificateDirectoriesPath_PreservesResourceSslCertDirForAppend() - { - var builder = DistributedApplication.CreateBuilder(); - var customSslCertDir = $"/resource-certs-{Guid.NewGuid():N}"; - using var certificate = CreateTestCertificate(); - var certificateAuthorities = builder.AddCertificateAuthorityCollection("certificates") - .WithCertificate(certificate); - - builder.AddContainer("database", "image") - .WithEnvironment("SSL_CERT_DIR", customSslCertDir) - .WithCertificateAuthorityCollection(certificateAuthorities); - - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); - - await appExecutor.RunApplicationAsync(); - - var container = Assert.Single(kubernetesService.CreatedResources.OfType()); - var sslCertDir = Assert.Single(container.Spec.Env!, e => e.Name == "SSL_CERT_DIR").Value; - - Assert.NotNull(sslCertDir); - Assert.Equal($"{ContainerCertificatePathsAnnotation.DefaultCustomCertificatesDestination}/certs:{customSslCertDir}", sslCertDir); + var generatedCertificatesPath = sslCertDir.Split(Path.PathSeparator)[0]; + Assert.EndsWith($"{Path.DirectorySeparatorChar}certs", generatedCertificatesPath); + Assert.Equal( + ExecutableCreator.BuildCertificateDirectoriesPath( + generatedCertificatesPath, + CertificateTrustScope.Append, + Environment.GetEnvironmentVariable("SSL_CERT_DIR"), + includeWellKnownCertificateDirectories: OperatingSystem.IsLinux()), + sslCertDir); } [Fact] diff --git a/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs b/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs index b2264e3b233..8ae14072e2e 100644 --- a/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs +++ b/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs @@ -407,48 +407,6 @@ public async Task CertificateTrustExecutionConfigurationGatherer_NoCertificateAn Assert.DoesNotContain("SSL_CERT_DIR", context.EnvironmentVariables.Keys); } - [Fact] - public async Task CertificateTrustExecutionConfigurationGatherer_WithAppendScopeAndExistingSslCertDir_UsesResourceValueBeforeFallback() - { - // Arrange - using var builder = TestDistributedApplicationBuilder.Create(); - var cert = CreateTestCertificate(); - var caCollection = builder.AddCertificateAuthorityCollection("test-ca").WithCertificate(cert); - - var resource = builder.AddContainer("test", "image") - .WithCertificateAuthorityCollection(caCollection) - .WithCertificateTrustScope(CertificateTrustScope.Append) - .Resource; - - await builder.BuildAsync(); - - var context = new ExecutionConfigurationGathererContext(); - context.EnvironmentVariables["SSL_CERT_DIR"] = "/private-roots"; - var gatherer = new CertificateTrustExecutionConfigurationGatherer(_ => new CertificateTrustExecutionConfigurationContext - { - CertificateBundlePath = ReferenceExpression.Create($"/aspire/cert.pem"), - CertificateDirectoriesPath = ReferenceExpression.Create($"/aspire/certs:/ambient-roots"), - CertificateDirectoriesPathBeforeFallback = ReferenceExpression.Create($"/aspire/certs"), - RootCertificatesPath = "/aspire", - IsContainer = true, - }); - - // Act - await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); - - // Assert - var sslCertDir = Assert.IsType(context.EnvironmentVariables["SSL_CERT_DIR"]); - Assert.Equal("/aspire/certs:/private-roots", await sslCertDir.GetValueAsync(CancellationToken.None)); - - var emptyContext = new ExecutionConfigurationGathererContext(); - emptyContext.EnvironmentVariables["SSL_CERT_DIR"] = string.Empty; - - await gatherer.GatherAsync(emptyContext, resource, NullLogger.Instance, builder.ExecutionContext); - - sslCertDir = Assert.IsType(emptyContext.EnvironmentVariables["SSL_CERT_DIR"]); - Assert.Equal("/aspire/certs", await sslCertDir.GetValueAsync(CancellationToken.None)); - } - [Fact] public async Task CertificateTrustExecutionConfigurationGatherer_WithAppendScope_DoesNotSetSSL_CERT_FILE() { From a01c1808242d23d80083e24ef82ab8e8e8453220 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 23 Jul 2026 13:40:16 -0700 Subject: [PATCH 14/18] Simplify executable certificate directory setup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677e0f4a-653a-4d97-b690-f86d67107226 --- ...cateTrustExecutionConfigurationGatherer.cs | 7 +- src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 53 +++++--------- .../Dcp/DcpExecutorTests.cs | 70 +------------------ 3 files changed, 27 insertions(+), 103 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/CertificateTrustExecutionConfigurationGatherer.cs b/src/Aspire.Hosting/ApplicationModel/CertificateTrustExecutionConfigurationGatherer.cs index 2041a84c68e..9ea0bd78f78 100644 --- a/src/Aspire.Hosting/ApplicationModel/CertificateTrustExecutionConfigurationGatherer.cs +++ b/src/Aspire.Hosting/ApplicationModel/CertificateTrustExecutionConfigurationGatherer.cs @@ -16,6 +16,9 @@ namespace Aspire.Hosting.ApplicationModel; /// internal class CertificateTrustExecutionConfigurationGatherer : IExecutionConfigurationGatherer { + internal const string SslCertDirEnvironmentVariable = "SSL_CERT_DIR"; + internal const string SslCertFileEnvironmentVariable = "SSL_CERT_FILE"; + private readonly Func _configContextFactory; /// @@ -83,11 +86,11 @@ public async ValueTask GatherAsync(IExecutionConfigurationGathererContext contex var configurationContext = _configContextFactory(additionalData.Scope); // Apply default OpenSSL environment configuration for certificate trust - context.EnvironmentVariables["SSL_CERT_DIR"] = configurationContext.CertificateDirectoriesPath; + context.EnvironmentVariables[SslCertDirEnvironmentVariable] = configurationContext.CertificateDirectoriesPath; if (additionalData.Scope != CertificateTrustScope.Append) { - context.EnvironmentVariables["SSL_CERT_FILE"] = configurationContext.CertificateBundlePath; + context.EnvironmentVariables[SslCertFileEnvironmentVariable] = configurationContext.CertificateBundlePath; } var callbackContext = new CertificateTrustConfigurationCallbackAnnotationContext diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index e8dbe452e20..07f17270f17 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -23,8 +23,6 @@ namespace Aspire.Hosting.Dcp; /// internal sealed class ExecutableCreator : IObjectCreator { - private const string SslCertDirEnvVar = "SSL_CERT_DIR"; - private readonly IConfiguration _configuration; private readonly DcpNameGenerator _nameGenerator; private readonly DistributedApplicationModel _model; @@ -500,14 +498,28 @@ private async Task BuildExecutableConfiguration(Rendere .WithEnvironmentVariablesConfig() .WithCertificateTrustConfig(scope => { + var dirs = new List { certificatesOutputPath }; + if (scope == CertificateTrustScope.Append) + { + var existingSslCertDir = Environment.GetEnvironmentVariable(CertificateTrustExecutionConfigurationGatherer.SslCertDirEnvironmentVariable); + if (existingSslCertDir is not null) + { + dirs.AddRange(existingSslCertDir.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)); + } + else if (OperatingSystem.IsLinux()) + { + // Do not invoke the openssl CLI here. This fallback is only for dotnet-run AppHosts + // where Aspire CLI did not already materialize OpenSSL's default directory into + // SSL_CERT_DIR, so reuse the same well-known certificate directories used for containers. + dirs.AddRange(ContainerCertificatePathsAnnotation.DefaultCertificateDirectoriesPaths.Where(Directory.Exists)); + } + } + return new() { CertificateBundlePath = ReferenceExpression.Create($"{bundleOutputPath}"), - CertificateDirectoriesPath = ReferenceExpression.Create($"{BuildCertificateDirectoriesPath( - certificatesOutputPath, - scope, - Environment.GetEnvironmentVariable(SslCertDirEnvVar), - includeWellKnownCertificateDirectories: OperatingSystem.IsLinux())}"), + // Build the SSL_CERT_DIR value by combining the new certs directory with any existing directories. + CertificateDirectoriesPath = ReferenceExpression.Create($"{string.Join(Path.PathSeparator, dirs)}"), RootCertificatesPath = certificatesRootDir, }; }) @@ -595,33 +607,6 @@ private async Task BuildExecutableConfiguration(Rendere return (configuration, pemCertificates); } - internal static string BuildCertificateDirectoriesPath( - string certificatesOutputPath, - CertificateTrustScope scope, - string? existingSslCertDir, - bool includeWellKnownCertificateDirectories, - Func? directoryExists = null) - { - var dirs = new List { certificatesOutputPath }; - if (scope == CertificateTrustScope.Append) - { - if (existingSslCertDir is not null) - { - dirs.AddRange(existingSslCertDir.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)); - } - else if (includeWellKnownCertificateDirectories) - { - directoryExists ??= Directory.Exists; - // Do not invoke the openssl CLI here. This fallback is only for dotnet-run AppHosts - // where Aspire CLI did not already materialize OpenSSL's default directory into - // SSL_CERT_DIR, so reuse the same well-known certificate directories used for containers. - dirs.AddRange(ContainerCertificatePathsAnnotation.DefaultCertificateDirectoriesPaths.Where(directoryExists)); - } - } - - return string.Join(Path.PathSeparator, dirs); - } - private string GetCertificatesRootDirectory(RenderedModelResource er, Executable exe) { if (er.ModelResource.GetLifetimeType() == Lifetime.Persistent) diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 8083606d0c4..d838f62e178 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -3566,73 +3566,9 @@ public async Task PlainExecutableCertificateDirectoriesPath_IgnoresResourceSslCe var sslCertDir = Assert.Single(exe.Spec.Env!, e => e.Name == "SSL_CERT_DIR").Value; Assert.NotNull(sslCertDir); - var generatedCertificatesPath = sslCertDir.Split(Path.PathSeparator)[0]; - Assert.EndsWith($"{Path.DirectorySeparatorChar}certs", generatedCertificatesPath); - Assert.Equal( - ExecutableCreator.BuildCertificateDirectoriesPath( - generatedCertificatesPath, - CertificateTrustScope.Append, - Environment.GetEnvironmentVariable("SSL_CERT_DIR"), - includeWellKnownCertificateDirectories: OperatingSystem.IsLinux()), - sslCertDir); - } - - [Fact] - public void ExecutableCertificateDirectoriesPath_IncludesExistingWellKnownDirectoriesForAppendWhenSslCertDirIsUnset() - { - var result = ExecutableCreator.BuildCertificateDirectoriesPath( - "/tmp/aspire/certs", - CertificateTrustScope.Append, - existingSslCertDir: null, - includeWellKnownCertificateDirectories: true, - directoryExists: path => path is "/etc/ssl/certs" or "/etc/pki/tls/certs"); - - Assert.Equal( - string.Join(Path.PathSeparator, "/tmp/aspire/certs", "/etc/ssl/certs", "/etc/pki/tls/certs"), - result); - } - - [Fact] - public void ExecutableCertificateDirectoriesPath_PreservesExistingSslCertDirForAppendWithoutAddingWellKnownDirectories() - { - var existingSslCertDir = string.Join(Path.PathSeparator, "/custom/certs", "/home/me/.aspnet/dev-certs/trust"); - - var result = ExecutableCreator.BuildCertificateDirectoriesPath( - "/tmp/aspire/certs", - CertificateTrustScope.Append, - existingSslCertDir, - includeWellKnownCertificateDirectories: true, - directoryExists: _ => true); - - Assert.Equal( - string.Join(Path.PathSeparator, "/tmp/aspire/certs", "/custom/certs", "/home/me/.aspnet/dev-certs/trust"), - result); - } - - [Fact] - public void ExecutableCertificateDirectoriesPath_PreservesEmptySslCertDirForAppendWithoutAddingWellKnownDirectories() - { - var result = ExecutableCreator.BuildCertificateDirectoriesPath( - "/tmp/aspire/certs", - CertificateTrustScope.Append, - existingSslCertDir: string.Empty, - includeWellKnownCertificateDirectories: true, - directoryExists: _ => true); - - Assert.Equal("/tmp/aspire/certs", result); - } - - [Fact] - public void ExecutableCertificateDirectoriesPath_DoesNotIncludeExistingOrWellKnownDirectoriesForOverride() - { - var result = ExecutableCreator.BuildCertificateDirectoriesPath( - "/tmp/aspire/certs", - CertificateTrustScope.Override, - existingSslCertDir: "/custom/certs", - includeWellKnownCertificateDirectories: true, - directoryExists: _ => true); - - Assert.Equal("/tmp/aspire/certs", result); + var sslCertDirs = sslCertDir.Split(Path.PathSeparator); + Assert.EndsWith($"{Path.DirectorySeparatorChar}certs", sslCertDirs[0]); + Assert.All(sslCertDirs, dir => Assert.NotEqual(customSslCertDir, dir)); } [Fact] From 5b5f13d3eb3756b45b249ac278b993b1f1caeaf4 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 23 Jul 2026 13:53:37 -0700 Subject: [PATCH 15/18] Add certificate trust fallback regressions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677e0f4a-653a-4d97-b690-f86d67107226 --- .../Utils/EnvironmentChecker/DevCertsCheck.cs | 2 +- .../TestProcessExecutionFactory.cs | 7 +++ .../Utils/DevCertsCheckTests.cs | 55 +++++++++++++++++++ .../Dcp/DcpExecutorTests.cs | 44 +++++++++++++++ 4 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs index efa49a4c8c3..57569aaabfa 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs @@ -453,7 +453,7 @@ private static bool CertificateFileMatches(string certificateFile, X509Certifica throw; } - catch (Exception ex) when (ex is OperationCanceledException or IOException or InvalidOperationException) + catch { if (started) { diff --git a/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs index 46ec93256c9..bc0937c4d13 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs @@ -191,6 +191,8 @@ public bool HasExited public bool StartReturnValue { get; init; } = true; + public Exception? StartException { get; init; } + public bool ThrowOnHasExitedBeforeStart { get; init; } public Func>? WaitForExitAsyncCallback { get; init; } @@ -209,6 +211,11 @@ public Task StartAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); + if (StartException is not null) + { + throw StartException; + } + if (!StartReturnValue) { return Task.FromResult(false); diff --git a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs index b5904188f9d..abf6907b0bf 100644 --- a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs @@ -487,6 +487,61 @@ public async Task CheckAsync_LinuxWithFailedOpenSslHashProbeAndMatchingHashStyle } } + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "The synthetic openssl command uses a POSIX shell script.")] + public async Task CheckAsync_LinuxWithOpenSslHashProbeStartFailure_ReturnsOpenSslCertificateCacheWarning() + { + using var certificate = CreateCertificate(); + var tempDirectory = Directory.CreateTempSubdirectory(); + + try + { + CreateCertUtil(tempDirectory); + CreateOpenSsl(tempDirectory, "12345678"); + var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); + WriteOpenSslCertificateCache(trustDirectory, certificate, "12345678.0"); + + var certs = new List + { + CreateDevCertInfo(CertificateManager.TrustLevel.Full, certificate, MinVersion), + }; + var toolRunner = new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.Full, + Certificates = certs + } + }; + var environment = TestEnvironment.CreateLinux(new Dictionary + { + ["PATH"] = tempDirectory.FullName, + [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName + }); + var processExecutionFactory = new TestProcessExecutionFactory + { + CreateExecutionWithFileNameCallback = (fileName, args, env, workingDirectory, options) => + new TestProcessExecution(fileName, args, env, options, (_, _, _) => Task.FromResult((0, (string?)"12345678")), () => 1) + { + StartException = new InvalidOperationException("openssl failed to start") + } + }; + var check = CreateCheck(toolRunner, environment, processExecutionFactory); + + var results = await check.CheckAsync(); + + var cacheResult = Assert.Single(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); + Assert.Equal(EnvironmentCheckStatus.Warning, cacheResult.Status); + Assert.Contains(certificate.Thumbprint, cacheResult.Details); + Assert.Contains("subject-hash", cacheResult.Details); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + [Fact] [SkipOnPlatform(TestPlatforms.Windows, "The synthetic openssl command uses a POSIX shell script.")] public async Task CheckAsync_LinuxWithMissingOpenSslHashEntry_ReturnsOpenSslCertificateCacheWarning() diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index d838f62e178..9a344acf7e9 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -25,6 +25,7 @@ using Aspire.Hosting.UserSecrets; using k8s.Models; using Microsoft.AspNetCore.InternalTesting; +using Microsoft.DotNet.RemoteExecutor; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; @@ -3541,6 +3542,49 @@ public async Task PersistentPlainExecutable_UsesStableCertificateOutputPath() Assert.Equal(Path.Join(expectedCertificatesRoot, "cert.pem"), sslCertFile); } + [Fact] + public void PlainExecutableCertificateDirectoriesPath_IncludesExistingWellKnownDirectoriesForAppendWhenSslCertDirIsUnsetOnLinux() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "OpenSSL default certificate directories are only inferred on Linux."); + + var options = new RemoteInvokeOptions(); + options.StartInfo.Environment.Remove("SSL_CERT_DIR"); + + RemoteExecutor.Invoke(static async () => + { + Environment.SetEnvironmentVariable("SSL_CERT_DIR", null); + + var expectedWellKnownCertificateDirectories = ContainerCertificatePathsAnnotation.DefaultCertificateDirectoriesPaths + .Where(Directory.Exists) + .ToArray(); + Assert.NotEmpty(expectedWellKnownCertificateDirectories); + + var builder = DistributedApplication.CreateBuilder(); + using var certificate = CreateTestCertificate(); + var certificateAuthorities = builder.AddCertificateAuthorityCollection("certificates") + .WithCertificate(certificate); + + var executable = new TestExecutableResource("test-working-directory"); + builder.AddResource(executable) + .WithCertificateAuthorityCollection(certificateAuthorities); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); + + await appExecutor.RunApplicationAsync(); + + var exe = Assert.Single(kubernetesService.CreatedResources.OfType(), e => e.AppModelResourceName == "TestExecutable"); + var sslCertDir = Assert.Single(exe.Spec.Env!, e => e.Name == "SSL_CERT_DIR").Value; + + Assert.NotNull(sslCertDir); + var sslCertDirs = sslCertDir.Split(Path.PathSeparator); + Assert.EndsWith($"{Path.DirectorySeparatorChar}certs", sslCertDirs[0]); + Assert.Equal(expectedWellKnownCertificateDirectories, sslCertDirs.Skip(1)); + }, options).Dispose(); + } + [Fact] public async Task PlainExecutableCertificateDirectoriesPath_IgnoresResourceSslCertDirForAppend() { From 2e29241ddcf9aedb58eb9ee899e4330983d309ad Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 23 Jul 2026 13:57:41 -0700 Subject: [PATCH 16/18] Address certificate trust review comments Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677e0f4a-653a-4d97-b690-f86d67107226 --- .../Utils/EnvironmentChecker/DevCertsCheck.cs | 7 +- .../Utils/DevCertsCheckTests.cs | 59 ----------- .../Dcp/DcpExecutorTests.cs | 98 +++++++++++++++---- 3 files changed, 83 insertions(+), 81 deletions(-) diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs index 57569aaabfa..0537eea4c10 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs @@ -25,7 +25,6 @@ internal sealed class DevCertsCheck(ILogger logger, ICertificateT internal const string OpenSslCertificateCacheCheckName = "dev-certs-openssl-cache"; private const string OpenSslCommand = "openssl"; private const int OpenSslHashCollisionSearchLimit = 10; - private static readonly TimeSpan s_openSslHashTimeout = TimeSpan.FromSeconds(5); public int Order => 35; // After SDK check (30), before container checks (40+) @@ -424,18 +423,16 @@ private static bool CertificateFileMatches(string certificateFile, X509Certifica StandardErrorCallback = _ => { }, }); var started = false; - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(s_openSslHashTimeout); try { - started = await process.StartAsync(timeoutCts.Token).ConfigureAwait(false); + started = await process.StartAsync(cancellationToken).ConfigureAwait(false); if (!started) { return (false, ""); } - var exitCode = await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false); + var exitCode = await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); if (exitCode != 0) { return (false, ""); diff --git a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs index abf6907b0bf..f4b4fb14df8 100644 --- a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs @@ -647,65 +647,6 @@ public async Task CheckAsync_LinuxWithCallerCanceledOpenSslHashProbe_ThrowsOpera } } - [Fact] - [SkipOnPlatform(TestPlatforms.Windows, "The synthetic openssl command uses a POSIX shell script.")] - public async Task CheckAsync_LinuxWithCanceledOpenSslHashProbe_ReturnsOpenSslCertificateCacheWarning() - { - using var certificate = CreateCertificate(); - var tempDirectory = Directory.CreateTempSubdirectory(); - using var cancellationTokenSource = new CancellationTokenSource(); - - try - { - CreateCertUtil(tempDirectory); - CreateOpenSsl(tempDirectory, "12345678"); - var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); - File.WriteAllText(Path.Combine(trustDirectory.FullName, GetOpenSslCertificateFileName(certificate)), certificate.ExportCertificatePem()); - - var certs = new List - { - CreateDevCertInfo(CertificateManager.TrustLevel.Full, certificate, MinVersion), - }; - var toolRunner = new TestCertificateToolRunner - { - CheckHttpCertificateCallback = () => new CertificateTrustResult - { - HasCertificates = true, - TrustLevel = CertificateManager.TrustLevel.Full, - Certificates = certs - } - }; - var environment = TestEnvironment.CreateLinux(new Dictionary - { - ["PATH"] = tempDirectory.FullName, - [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName - }); - var processExecutionFactory = new TestProcessExecutionFactory - { - AsyncAttemptCallback = async (_, _, cancellationToken) => - { - await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken); - return (0, (string?)"12345678"); - } - }; - var check = CreateCheck(toolRunner, environment, processExecutionFactory); - - var results = await check.CheckAsync(cancellationTokenSource.Token); - - var cacheResult = Assert.Single(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); - Assert.Equal(EnvironmentCheckStatus.Warning, cacheResult.Status); - Assert.Contains(certificate.Thumbprint, cacheResult.Details); - Assert.Contains("subject-hash", cacheResult.Details); - var processExecution = Assert.IsType(Assert.Single(processExecutionFactory.CreatedExecutions)); - Assert.Equal(1, processExecution.KillCount); - Assert.True(processExecution.KilledEntireProcessTree); - } - finally - { - tempDirectory.Delete(recursive: true); - } - } - [Fact] public async Task CheckAsync_LinuxWithMissingOpenSslHashEntryAndNoOpenSsl_DoesNotReturnOpenSslCertificateCacheWarning() { diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 9a344acf7e9..49115d02a3e 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -3586,28 +3586,69 @@ public void PlainExecutableCertificateDirectoriesPath_IncludesExistingWellKnownD } [Fact] - public async Task PlainExecutableCertificateDirectoriesPath_IgnoresResourceSslCertDirForAppend() + public void PlainExecutableCertificateDirectoriesPath_PreservesAppHostSslCertDirForAppend() { - var builder = DistributedApplication.CreateBuilder(); - var customSslCertDir = $"/resource-certs-{Guid.NewGuid():N}"; - using var certificate = CreateTestCertificate(); - var certificateAuthorities = builder.AddCertificateAuthorityCollection("certificates") - .WithCertificate(certificate); + var expectedExistingSslCertDirs = new[] { "/custom/certs", "/home/me/.aspnet/dev-certs/trust" }; + var options = new RemoteInvokeOptions(); + options.StartInfo.Environment["SSL_CERT_DIR"] = string.Join(Path.PathSeparator, expectedExistingSslCertDirs); - var executable = new TestExecutableResource("test-working-directory"); - builder.AddResource(executable) - .WithEnvironment("SSL_CERT_DIR", customSslCertDir) - .WithCertificateAuthorityCollection(certificateAuthorities); + RemoteExecutor.Invoke(static expectedExistingSslCertDir => + { + Environment.SetEnvironmentVariable("SSL_CERT_DIR", expectedExistingSslCertDir); - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); + var sslCertDir = GetPlainExecutableSslCertDirAsync().GetAwaiter().GetResult(); - await appExecutor.RunApplicationAsync(); + Assert.NotNull(sslCertDir); + var sslCertDirs = sslCertDir.Split(Path.PathSeparator); + Assert.EndsWith($"{Path.DirectorySeparatorChar}certs", sslCertDirs[0]); + Assert.Equal(expectedExistingSslCertDir.Split(Path.PathSeparator), sslCertDirs.Skip(1)); + }, string.Join(Path.PathSeparator, expectedExistingSslCertDirs), options).Dispose(); + } - var exe = Assert.Single(kubernetesService.CreatedResources.OfType(), e => e.AppModelResourceName == "TestExecutable"); - var sslCertDir = Assert.Single(exe.Spec.Env!, e => e.Name == "SSL_CERT_DIR").Value; + [Fact] + public void PlainExecutableCertificateDirectoriesPath_PreservesEmptyAppHostSslCertDirForAppend() + { + var options = new RemoteInvokeOptions(); + options.StartInfo.Environment["SSL_CERT_DIR"] = string.Empty; + + RemoteExecutor.Invoke(static () => + { + Environment.SetEnvironmentVariable("SSL_CERT_DIR", string.Empty); + + var sslCertDir = GetPlainExecutableSslCertDirAsync().GetAwaiter().GetResult(); + + Assert.NotNull(sslCertDir); + var sslCertDirs = sslCertDir.Split(Path.PathSeparator); + Assert.Single(sslCertDirs); + Assert.EndsWith($"{Path.DirectorySeparatorChar}certs", sslCertDirs[0]); + }, options).Dispose(); + } + + [Fact] + public void PlainExecutableCertificateDirectoriesPath_DoesNotIncludeAppHostSslCertDirForOverride() + { + var options = new RemoteInvokeOptions(); + options.StartInfo.Environment["SSL_CERT_DIR"] = "/custom/certs"; + + RemoteExecutor.Invoke(static () => + { + Environment.SetEnvironmentVariable("SSL_CERT_DIR", "/custom/certs"); + + var sslCertDir = GetPlainExecutableSslCertDirAsync(builder => builder.WithCertificateTrustScope(CertificateTrustScope.Override)).GetAwaiter().GetResult(); + + Assert.NotNull(sslCertDir); + var sslCertDirs = sslCertDir.Split(Path.PathSeparator); + Assert.Single(sslCertDirs); + Assert.EndsWith($"{Path.DirectorySeparatorChar}certs", sslCertDirs[0]); + }, options).Dispose(); + } + + [Fact] + public async Task PlainExecutableCertificateDirectoriesPath_IgnoresResourceSslCertDirForAppend() + { + var customSslCertDir = $"/resource-certs-{Guid.NewGuid():N}"; + + var sslCertDir = await GetPlainExecutableSslCertDirAsync(builder => builder.WithEnvironment("SSL_CERT_DIR", customSslCertDir)); Assert.NotNull(sslCertDir); var sslCertDirs = sslCertDir.Split(Path.PathSeparator); @@ -6787,6 +6828,29 @@ private static DcpExecutor CreateAppExecutor( userSecretsManager ?? NoopUserSecretsManager.Instance); } + private static async Task GetPlainExecutableSslCertDirAsync(Action>? configure = null) + { + var builder = DistributedApplication.CreateBuilder(); + using var certificate = CreateTestCertificate(); + var certificateAuthorities = builder.AddCertificateAuthorityCollection("certificates") + .WithCertificate(certificate); + + var executable = new TestExecutableResource("test-working-directory"); + var executableBuilder = builder.AddResource(executable) + .WithCertificateAuthorityCollection(certificateAuthorities); + configure?.Invoke(executableBuilder); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); + + await appExecutor.RunApplicationAsync(); + + var exe = Assert.Single(kubernetesService.CreatedResources.OfType(), e => e.AppModelResourceName == "TestExecutable"); + return Assert.Single(exe.Spec.Env!, e => e.Name == "SSL_CERT_DIR").Value; + } + private static void AssertPortAllocatedFromProxylessEndpointAllocatorRange(int port) { var defaultOptions = new DcpOptions(); From 80bd41b1e0e876fe646bf0b0bf635b86309b2165 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 23 Jul 2026 14:47:52 -0700 Subject: [PATCH 17/18] Fix empty SSL_CERT_DIR regression test Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677e0f4a-653a-4d97-b690-f86d67107226 --- tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 49115d02a3e..384fe9fa7f9 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -3613,8 +3613,6 @@ public void PlainExecutableCertificateDirectoriesPath_PreservesEmptyAppHostSslCe RemoteExecutor.Invoke(static () => { - Environment.SetEnvironmentVariable("SSL_CERT_DIR", string.Empty); - var sslCertDir = GetPlainExecutableSslCertDirAsync().GetAwaiter().GetResult(); Assert.NotNull(sslCertDir); From a6559507ad32a1c5d5215e28e8b718b5879c5b2f Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 23 Jul 2026 15:09:45 -0700 Subject: [PATCH 18/18] Relax OpenSSL probe cleanup exception handling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677e0f4a-653a-4d97-b690-f86d67107226 --- .../Utils/EnvironmentChecker/DevCertsCheck.cs | 4 +- .../Utils/DevCertsCheckTests.cs | 75 +++++++++++++++++-- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs index 0537eea4c10..eddc0790f95 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs @@ -470,8 +470,10 @@ private static void TryKillProcess(IProcessExecution process) process.Kill(entireProcessTree: true); } } - catch (InvalidOperationException) + catch (Exception) { + // Process cleanup is best-effort; do not let a kill failure mask caller cancellation + // or turn a non-critical OpenSSL probe failure into a failed doctor check. } } diff --git a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs index f4b4fb14df8..0e2587b4bbd 100644 --- a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs @@ -593,7 +593,7 @@ public async Task CheckAsync_LinuxWithMissingOpenSslHashEntry_ReturnsOpenSslCert [Fact] [SkipOnPlatform(TestPlatforms.Windows, "The synthetic openssl command uses a POSIX shell script.")] - public async Task CheckAsync_LinuxWithCallerCanceledOpenSslHashProbe_ThrowsOperationCanceledException() + public async Task CheckAsync_LinuxWithCallerCanceledOpenSslHashProbeAndKillFailure_ThrowsOperationCanceledException() { using var certificate = CreateCertificate(); var tempDirectory = Directory.CreateTempSubdirectory(); @@ -626,16 +626,79 @@ public async Task CheckAsync_LinuxWithCallerCanceledOpenSslHashProbe_ThrowsOpera }); var processExecutionFactory = new TestProcessExecutionFactory { - AsyncAttemptCallback = async (_, _, cancellationToken) => + CreateExecutionWithFileNameCallback = (fileName, args, env, workingDirectory, options) => + new TestProcessExecution(fileName, args, env, options, async (_, _, cancellationToken) => + { + await cancellationTokenSource.CancelAsync(); + await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken); + return (0, (string?)"12345678"); + }, () => 1) + { + KillCallback = _ => throw new NotSupportedException("Process tree termination is unavailable.") + } + }; + var check = CreateCheck(toolRunner, environment, processExecutionFactory); + + await Assert.ThrowsAnyAsync(() => check.CheckAsync(cancellationTokenSource.Token)); + + var processExecution = Assert.IsType(Assert.Single(processExecutionFactory.CreatedExecutions)); + Assert.Equal(1, processExecution.KillCount); + Assert.True(processExecution.KilledEntireProcessTree); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "The synthetic openssl command uses a POSIX shell script.")] + public async Task CheckAsync_LinuxWithOpenSslHashProbeFailureAndKillFailure_ReturnsOpenSslCertificateCacheWarning() + { + using var certificate = CreateCertificate(); + var tempDirectory = Directory.CreateTempSubdirectory(); + + try + { + CreateCertUtil(tempDirectory); + CreateOpenSsl(tempDirectory, "12345678"); + var trustDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "trust")); + File.WriteAllText(Path.Combine(trustDirectory.FullName, GetOpenSslCertificateFileName(certificate)), certificate.ExportCertificatePem()); + + var certs = new List + { + CreateDevCertInfo(CertificateManager.TrustLevel.Full, certificate, MinVersion), + }; + var toolRunner = new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => new CertificateTrustResult { - await cancellationTokenSource.CancelAsync(); - await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken); - return (0, (string?)"12345678"); + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.Full, + Certificates = certs } }; + var environment = TestEnvironment.CreateLinux(new Dictionary + { + ["PATH"] = tempDirectory.FullName, + [CertificateHelpers.DevCertsOpenSslCertDirEnvVar] = trustDirectory.FullName + }); + var processExecutionFactory = new TestProcessExecutionFactory + { + CreateExecutionWithFileNameCallback = (fileName, args, env, workingDirectory, options) => + new TestProcessExecution(fileName, args, env, options, (_, _, _) => throw new IOException("openssl pipe failed"), () => 1) + { + KillCallback = _ => throw new NotSupportedException("Process tree termination is unavailable.") + } + }; var check = CreateCheck(toolRunner, environment, processExecutionFactory); - await Assert.ThrowsAnyAsync(() => check.CheckAsync(cancellationTokenSource.Token)); + var results = await check.CheckAsync(); + + var cacheResult = Assert.Single(results, r => r.Name == DevCertsCheck.OpenSslCertificateCacheCheckName); + Assert.Equal(EnvironmentCheckStatus.Warning, cacheResult.Status); + Assert.Contains(certificate.Thumbprint, cacheResult.Details); + Assert.Contains("subject-hash", cacheResult.Details); var processExecution = Assert.IsType(Assert.Single(processExecutionFactory.CreatedExecutions)); Assert.Equal(1, processExecution.KillCount);