diff --git a/src/Aspire.Cli/Certificates/CertificateCacheWriter.cs b/src/Aspire.Cli/Certificates/CertificateCacheWriter.cs new file mode 100644 index 00000000000..9b47645eb10 --- /dev/null +++ b/src/Aspire.Cli/Certificates/CertificateCacheWriter.cs @@ -0,0 +1,81 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Certificates; + +internal static class CertificateCacheWriter +{ + private const UnixFileMode DirectoryPermissions = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + private const UnixFileMode FilePermissions = + UnixFileMode.UserRead | UnixFileMode.UserWrite; + + public static void WriteFile(string outputPath, ReadOnlySpan contents, ILogger logger) + { + EnsureDirectory(Path.GetDirectoryName(outputPath)!); + + // Publish a fully flushed file atomically so readers never observe a partial certificate bundle. + var temporaryPath = Path.Combine( + Path.GetDirectoryName(outputPath)!, + $".{Path.GetFileName(outputPath)}.{Guid.NewGuid():N}.tmp"); + + try + { + var options = new FileStreamOptions + { + Access = FileAccess.Write, + Mode = FileMode.CreateNew, + Share = FileShare.None + }; + + if (!OperatingSystem.IsWindows()) + { +#pragma warning disable CA1416 // Validate platform compatibility + options.UnixCreateMode = FilePermissions; +#pragma warning restore CA1416 // Validate platform compatibility + } + + using (var stream = new FileStream(temporaryPath, options)) + { + stream.Write(contents); + stream.Flush(flushToDisk: true); + } + + try + { + File.Move(temporaryPath, outputPath); + } + catch (IOException) when (File.Exists(outputPath)) + { + // Another process published the same content-addressed file first. + } + } + finally + { + try + { + File.Delete(temporaryPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.LogDebug(ex, "Failed to delete temporary certificate cache file {TemporaryPath}", temporaryPath); + } + } + } + + private static void EnsureDirectory(string directory) + { + if (OperatingSystem.IsWindows()) + { + Directory.CreateDirectory(directory); + return; + } + +#pragma warning disable CA1416 // Validate platform compatibility + Directory.CreateDirectory(directory, DirectoryPermissions); + File.SetUnixFileMode(directory, DirectoryPermissions); +#pragma warning restore CA1416 // Validate platform compatibility + } +} diff --git a/src/Aspire.Cli/Certificates/CertificateService.cs b/src/Aspire.Cli/Certificates/CertificateService.cs index 5eb6c0d5d4a..447a6e1135e 100644 --- a/src/Aspire.Cli/Certificates/CertificateService.cs +++ b/src/Aspire.Cli/Certificates/CertificateService.cs @@ -9,6 +9,7 @@ using Aspire.Cli.Utils; using Aspire.Hosting; using Microsoft.AspNetCore.Certificates.Generation; +using Microsoft.Extensions.Logging; namespace Aspire.Cli.Certificates; @@ -42,6 +43,8 @@ internal sealed class EnsureCertificatesTrustedResult internal interface ICertificateService { Task EnsureCertificatesTrustedAsync(CancellationToken cancellationToken); + + string? ExportDevCertificatePem(CancellationToken cancellationToken); } internal sealed class CertificateService( @@ -49,9 +52,13 @@ internal sealed class CertificateService( IInteractionService interactionService, AspireCliTelemetry telemetry, ICliHostEnvironment hostEnvironment, - IEnvironment environment) : ICertificateService + IEnvironment environment, + CliExecutionContext executionContext, + ILogger logger) : ICertificateService { private const string SslCertDirEnvVar = "SSL_CERT_DIR"; + internal string DevCertDirectory => Path.Combine( + executionContext.AspireHomeDirectory.FullName, "dev-certs"); public async Task EnsureCertificatesTrustedAsync(CancellationToken cancellationToken) { @@ -197,6 +204,33 @@ private void ConfigureSslCertDir(Dictionary environmentVariables environmentVariables[SslCertDirEnvVar] = string.Join(Path.PathSeparator, systemCertDirs); } } + + public string? ExportDevCertificatePem(CancellationToken cancellationToken) + { + try + { + var result = certificateToolRunner.ExportDevCertificatePublicPem(DevCertDirectory, cancellationToken); + if (result is not null) + { + logger.LogDebug("Exported dev certificate public PEM to {Path}", result); + } + else + { + logger.LogDebug("No valid dev certificate found to export as PEM"); + } + + return result; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to export dev certificate as PEM"); + return null; + } + } } internal sealed class CertificateServiceException(string message) : Exception(message) diff --git a/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs b/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs index 622214b9680..4424585c874 100644 --- a/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs +++ b/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs @@ -30,4 +30,13 @@ internal interface ICertificateToolRunner /// Removes all HTTPS development certificates. /// CertificateCleanResult CleanHttpCertificate(); + + /// + /// Exports the highest-versioned trusted ASP.NET Core HTTPS development certificate + /// as a content-addressed PEM file in the specified directory. + /// + /// The directory where the PEM certificate should be cached. + /// A token that can be used to cancel the operation. + /// The output path if a certificate was exported; if no valid certificate was found. + string? ExportDevCertificatePublicPem(string outputDirectory, CancellationToken cancellationToken = default); } diff --git a/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs index 2d633d15432..81c2404eb58 100644 --- a/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs +++ b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs @@ -1,18 +1,24 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.IO.Hashing; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Text; +using Aspire.Hosting.Utils; using Microsoft.AspNetCore.Certificates.Generation; +using Microsoft.Extensions.Logging; namespace Aspire.Cli.Certificates; /// /// Certificate tool runner that uses the native CertificateManager directly (no subprocess needed). /// -internal sealed class NativeCertificateToolRunner(CertificateManager certificateManager, IEnvironment environment) : ICertificateToolRunner +internal sealed class NativeCertificateToolRunner( + CertificateManager certificateManager, + IEnvironment environment, + ILogger logger) : ICertificateToolRunner { - public CertificateTrustResult CheckHttpCertificate(CancellationToken cancellationToken = default) { var availableCertificates = certificateManager.ListCertificates( @@ -30,13 +36,11 @@ public CertificateTrustResult CheckHttpCertificate(CancellationToken cancellatio { trustLevel = CertificateManager.TrustLevel.None; } - else if (certificateManager is UnixCertificateManager unixCertificateManager) - { - trustLevel = unixCertificateManager.GetTrustLevel(cert, cancellationToken); - } else { - trustLevel = certificateManager.GetTrustLevel(cert); + trustLevel = certificateManager is UnixCertificateManager unixCertificateManager + ? unixCertificateManager.GetTrustLevel(cert, cancellationToken) + : certificateManager.GetTrustLevel(cert); } return new DevCertInfo @@ -192,6 +196,67 @@ public CertificateCleanResult CleanHttpCertificate() } } + public string? ExportDevCertificatePublicPem(string outputDirectory, CancellationToken cancellationToken = default) + { + logger.LogDebug("Searching for a trusted ASP.NET Core development certificate to export"); + + var availableCertificates = certificateManager.ListCertificates( + StoreName.My, StoreLocation.CurrentUser, isValid: false, requireExportable: false); + + try + { + var now = DateTimeOffset.Now; + var validCertificates = availableCertificates + .Where(c => c.HasPrivateKey && c.NotBefore <= now && now <= c.NotAfter) + .ToList(); + + if (validCertificates.Any(c => c.HasSubjectKeyIdentifier())) + { + validCertificates = validCertificates.Where(c => c.HasSubjectKeyIdentifier()).ToList(); + } + + var certificate = validCertificates + .GroupBy(c => c.Extensions.OfType().FirstOrDefault()?.SubjectKeyIdentifier) + .SelectMany(group => group.OrderByVersion().Take(1)) + .OrderByVersion() + .GetTrustedCertificates(cancellationToken) + .FirstOrDefault(); + + if (certificate is null) + { + logger.LogDebug("No trusted ASP.NET Core development certificate was available to export"); + return null; + } + + logger.LogDebug( + "Selected ASP.NET Core development certificate {Thumbprint} for public PEM export", + certificate.Thumbprint); + + return GetOrCreateCertificateCacheFile(certificate, outputDirectory); + } + finally + { + CertificateManager.DisposeCertificates(availableCertificates); + } + } + + internal string GetOrCreateCertificateCacheFile(X509Certificate2 certificate, string outputDirectory) + { + var pemContents = Encoding.UTF8.GetBytes(certificate.ExportCertificatePem()); + var hash = Convert.ToHexString(XxHash128.Hash(pemContents)).ToLowerInvariant(); + var outputPath = Path.Combine(outputDirectory, $"aspire-dev-cert-{hash}.pem"); + + if (File.Exists(outputPath)) + { + logger.LogDebug("Reusing cached development certificate PEM at {Path}", outputPath); + return outputPath; + } + + logger.LogDebug("Writing development certificate PEM to cache at {Path}", outputPath); + CertificateCacheWriter.WriteFile(outputPath, pemContents, logger); + return outputPath; + } + private static string[]? GetSanExtension(X509Certificate2 cert) { var dnsNames = new List(); diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 0f469c07220..345bef1570f 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.IO.Hashing; using System.Net.Sockets; using System.Text.Json; using Aspire.Cli.Backchannel; @@ -30,6 +31,9 @@ namespace Aspire.Cli.Projects; /// internal sealed class GuestAppHostProject : IAppHostProject, IGuestAppHostSdkGenerator { + private const string DevCertificateCacheDirectoryName = "dev-certs"; + private const string CertificateBundleCacheDirectoryName = "bundles"; + private readonly IInteractionService _interactionService; private readonly IAppHostCliBackchannel _backchannel; private readonly IAppHostServerProjectFactory _appHostServerProjectFactory; @@ -595,6 +599,24 @@ await GenerateCodeViaRpcAsync( environmentVariables["ASPIRE_APPHOST_FILEPATH"] = appHostFile.FullName; environmentVariables[KnownConfigNames.RemoteAppHostToken] = authenticationToken; + if (_guestRuntime is null) + { + _interactionService.DisplayError("GuestRuntime not initialized."); + return CliExitCodes.FailedToDotnetRunAppHost; + } + + if (_guestRuntime.CertificateBundleEnvironmentVariable is { } certificateBundleEnvironmentVariable) + { + var devCertPemPath = _certificateService.ExportDevCertificatePem(cancellationToken); + await ConfigureCertificateBundleEnvironmentAsync( + environmentVariables, + directory, + devCertPemPath, + certificateBundleEnvironmentVariable, + _guestRuntime.Language.Replace('/', '-'), + cancellationToken); + } + // Pass debug flag to the guest process if (context.Debug) { @@ -605,12 +627,6 @@ await GenerateCodeViaRpcAsync( // This mirrors the pattern in DotNetCliRunner.ExecuteAsync for .NET app hosts. // The RuntimeSpec declares the required extension capability (e.g., "node" for TypeScript); // only use the extension launcher when the runtime requests it and the extension supports it. - if (_guestRuntime is null) - { - _interactionService.DisplayError("GuestRuntime not initialized."); - return CliExitCodes.FailedToDotnetRunAppHost; - } - if (_guestRuntime.ExtensionLaunchCapability is { } requiredCapability && ExtensionHelper.IsExtensionHost(_interactionService, out var extensionInteractionService, out var extensionBackchannel) && await extensionBackchannel.HasCapabilityAsync(requiredCapability, cancellationToken)) @@ -1981,4 +1997,109 @@ private async Task InstallDependenciesAsync( var id = UserSecretsPathHelper.ComputeSyntheticUserSecretsId(appHostFile.FullName); return Task.FromResult(id); } + + /// + /// Configures a language runtime's certificate bundle to trust the ASP.NET Core development certificate. + /// + internal async Task ConfigureCertificateBundleEnvironmentAsync( + IDictionary environmentVariables, + DirectoryInfo workingDirectory, + string? devCertPemPath, + string environmentVariableName, + string cacheFilePrefix, + CancellationToken cancellationToken) + { + if (devCertPemPath is null) + { + return; + } + + if (string.IsNullOrWhiteSpace(environmentVariableName)) + { + throw new InvalidOperationException("The certificate bundle environment variable name cannot be empty."); + } + + if (string.IsNullOrWhiteSpace(cacheFilePrefix) || + cacheFilePrefix.Any(character => !char.IsAsciiLetterOrDigit(character) && character is not '-' and not '_')) + { + throw new InvalidOperationException("The certificate bundle cache file prefix contains invalid characters."); + } + + // Explicit AppHost configuration takes precedence over the inherited environment. + // Environment variable names are case-insensitive on Windows. + var configuredKeys = _environment.IsWindows() + ? environmentVariables.Keys + .Where(key => string.Equals(key, environmentVariableName, StringComparison.OrdinalIgnoreCase)) + .ToArray() + : environmentVariables.ContainsKey(environmentVariableName) + ? [environmentVariableName] + : []; + var existingCertificateBundle = configuredKeys.LastOrDefault() is { } configuredKey + ? environmentVariables[configuredKey] + : _environment.GetEnvironmentVariable(environmentVariableName); + var certificateBundlePath = devCertPemPath; + + if (!string.IsNullOrWhiteSpace(existingCertificateBundle)) + { + try + { + var existingBundlePath = Path.GetFullPath(existingCertificateBundle, workingDirectory.FullName); + var pathComparison = _environment.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + if (!string.Equals(existingBundlePath, devCertPemPath, pathComparison)) + { + var devCertificateContents = await File.ReadAllBytesAsync(devCertPemPath, cancellationToken); + var existingBundleContents = await File.ReadAllBytesAsync(existingBundlePath, cancellationToken); + + // Place the Aspire certificate first because OpenSSL may select the first matching self-signed certificate. + byte[] bundleContents = [.. devCertificateContents, (byte)'\n', .. existingBundleContents]; + + // Cache by the final contents so unchanged inputs reuse the same immutable bundle. + var bundleHash = Convert.ToHexString(XxHash128.Hash(bundleContents)).ToLowerInvariant(); + var bundleDirectory = Path.Combine( + _executionContext.AspireHomeDirectory.FullName, + DevCertificateCacheDirectoryName, + CertificateBundleCacheDirectoryName); + var bundlePath = Path.Combine(bundleDirectory, $"{cacheFilePrefix}-{bundleHash}.pem"); + + if (!File.Exists(bundlePath)) + { + CertificateCacheWriter.WriteFile(bundlePath, bundleContents, _logger); + } + + certificateBundlePath = bundlePath; + } + } + catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException) + { + _logger.LogWarning(ex, "Failed to combine {EnvironmentVariableName} bundle {ExistingBundlePath} with the Aspire development certificate", environmentVariableName, existingCertificateBundle); + _interactionService.DisplayMessage( + KnownEmojis.Warning, + $"Unable to add the Aspire development certificate to {environmentVariableName} '{existingCertificateBundle}'. The existing certificate bundle will be used unchanged."); + certificateBundlePath = existingCertificateBundle; + } + } + + SetCertificateBundleEnvironmentVariable(environmentVariables, configuredKeys, environmentVariableName, certificateBundlePath); + } + + private static void SetCertificateBundleEnvironmentVariable( + IDictionary environmentVariables, + IEnumerable configuredKeys, + string environmentVariableName, + string value) + { + foreach (var configuredKey in configuredKeys) + { + if (!string.Equals(configuredKey, environmentVariableName, StringComparison.Ordinal)) + { + environmentVariables.Remove(configuredKey); + } + } + + environmentVariables[environmentVariableName] = value; + } + } diff --git a/src/Aspire.Cli/Projects/GuestRuntime.cs b/src/Aspire.Cli/Projects/GuestRuntime.cs index 1ff5f9fa7d3..b28c5a4c2cf 100644 --- a/src/Aspire.Cli/Projects/GuestRuntime.cs +++ b/src/Aspire.Cli/Projects/GuestRuntime.cs @@ -63,6 +63,11 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, Func comm /// public string? ExtensionLaunchCapability => _spec.ExtensionLaunchCapability; + /// + /// Gets the environment variable used by the runtime for an additional certificate bundle in run mode. + /// + public string? CertificateBundleEnvironmentVariable => _spec.CertificateBundleEnvironmentVariable; + /// /// Initializes the project environment (e.g., creates a virtual environment and installs dependencies). /// Runs each command in sequentially. diff --git a/src/Aspire.Cli/Projects/TypeScriptAppHostToolchainResolver.cs b/src/Aspire.Cli/Projects/TypeScriptAppHostToolchainResolver.cs index 407ce55fe9a..3422ad25cc6 100644 --- a/src/Aspire.Cli/Projects/TypeScriptAppHostToolchainResolver.cs +++ b/src/Aspire.Cli/Projects/TypeScriptAppHostToolchainResolver.cs @@ -156,6 +156,7 @@ public static RuntimeSpec ApplyToRuntimeSpec(RuntimeSpec baseRuntimeSpec, TypeSc WatchExecute = CreateWatchCommand(toolchain, tsConfigFileName), PublishExecute = baseRuntimeSpec.PublishExecute, ExtensionLaunchCapability = baseRuntimeSpec.ExtensionLaunchCapability, + CertificateBundleEnvironmentVariable = baseRuntimeSpec.CertificateBundleEnvironmentVariable, MigrationFiles = baseRuntimeSpec.MigrationFiles }; } diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs b/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs index 0db229837ee..2a68dcadf72 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs @@ -56,6 +56,9 @@ internal sealed class TypeScriptLanguageSupport : ILanguageSupport /// public string Language => LanguageId; + /// + public string CertificateBundleEnvironmentVariable => "NODE_EXTRA_CA_CERTS"; + /// public Dictionary Scaffold(ScaffoldRequest request) { @@ -252,6 +255,7 @@ public RuntimeSpec GetRuntimeSpec() CodeGenLanguage = CodeGenTarget, DetectionPatterns = s_detectionPatterns, ExtensionLaunchCapability = "node", + CertificateBundleEnvironmentVariable = CertificateBundleEnvironmentVariable, InstallDependencies = new CommandSpec { Command = "npm", diff --git a/src/Aspire.Hosting/DeveloperCertificateService.cs b/src/Aspire.Hosting/DeveloperCertificateService.cs index ae440155df8..ae2af602650 100644 --- a/src/Aspire.Hosting/DeveloperCertificateService.cs +++ b/src/Aspire.Hosting/DeveloperCertificateService.cs @@ -57,56 +57,7 @@ public DeveloperCertificateService(ILogger logger, .OrderByVersion() .ToList(); - // Partition into trusted and untrusted using a single X509Chain instance. - // RevocationMode is set to NoCheck since revocation doesn't apply to self-signed dev certs. - using var chain = new X509Chain(); - chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; - - // On Windows, chain.Build() can succeed even when the certificate isn't in the - // trusted root store. Open the CurrentUser Root store so we can verify membership. - X509Certificate2Collection? rootCerts = null; - if (OperatingSystem.IsWindows()) - { - using var rootStore = new X509Store(StoreName.Root, StoreLocation.CurrentUser); - rootStore.Open(OpenFlags.ReadOnly); - rootCerts = rootStore.Certificates; - } - - // Find the dev certs that are trusted - var trustedCerts = new List(); - foreach (var cert in bestCerts) - { - try - { - if (!chain.Build(cert)) - { - continue; - } - - // On Windows, also verify the certificate exists in the root store - if (rootCerts is not null && - !rootCerts.Any(rc => rc.RawDataMemory.Span.SequenceEqual(cert.RawDataMemory.Span))) - { - continue; - } - - trustedCerts.Add(cert); - } - finally - { - // Reset the chain for the next certificate regardless of branch taken. - chain.Reset(); - } - } - - // Dispose root store certificates after use - if (rootCerts is not null) - { - foreach (var rc in rootCerts) - { - rc.Dispose(); - } - } + var trustedCerts = bestCerts.GetTrustedCertificates(); // Flag if the newest/highest-version cert is not trusted if (bestCerts.Count > 0 && diff --git a/src/Aspire.TypeSystem/ILanguageSupport.cs b/src/Aspire.TypeSystem/ILanguageSupport.cs index b8e13dd6c2d..e5d59e22a03 100644 --- a/src/Aspire.TypeSystem/ILanguageSupport.cs +++ b/src/Aspire.TypeSystem/ILanguageSupport.cs @@ -14,6 +14,22 @@ public interface ILanguageSupport /// string Language { get; } + /// + /// Gets the environment variable that accepts an additional PEM certificate bundle when running an AppHost for this language. + /// + /// + /// The CLI sets this environment variable only when running the AppHost, not when publishing it. + /// Return the name of the runtime-specific environment variable, rather than a certificate path. + /// The runtime uses the certificate bundle as additional trusted roots for the entire AppHost process, + /// affecting all outbound TLS connections, including connections unrelated to Aspire-managed resources. + /// Implementations should opt in only when this process-wide trust scope is appropriate. + /// For example: + /// + /// public string? CertificateBundleEnvironmentVariable => "NODE_EXTRA_CA_CERTS"; + /// + /// + string? CertificateBundleEnvironmentVariable => null; + /// /// Generates scaffold files for a new project. /// diff --git a/src/Aspire.TypeSystem/RuntimeSpec.cs b/src/Aspire.TypeSystem/RuntimeSpec.cs index b0e589bab1b..d4339d16471 100644 --- a/src/Aspire.TypeSystem/RuntimeSpec.cs +++ b/src/Aspire.TypeSystem/RuntimeSpec.cs @@ -67,6 +67,21 @@ public sealed class RuntimeSpec /// public string? ExtensionLaunchCapability { get; init; } + /// + /// Gets the environment variable that accepts an additional PEM certificate bundle when running an AppHost for this language. + /// + /// + /// When set, the CLI assigns this environment variable a certificate bundle containing the + /// ASP.NET Core development certificate before launching the AppHost in run mode. The variable + /// is not set when publishing the AppHost. The runtime uses the bundle as additional trusted roots + /// for the entire AppHost process, affecting all outbound TLS connections, including connections + /// unrelated to Aspire-managed resources. For example: + /// + /// CertificateBundleEnvironmentVariable = "NODE_EXTRA_CA_CERTS"; + /// + /// + public string? CertificateBundleEnvironmentVariable { get; init; } + /// /// Gets files that must exist in the project directory before execution. /// If a file in this dictionary is missing, the CLI will create it with the provided content. diff --git a/src/Shared/X509Certificate2Extensions.cs b/src/Shared/X509Certificate2Extensions.cs index 91bd6bb5d58..254a7f55780 100644 --- a/src/Shared/X509Certificate2Extensions.cs +++ b/src/Shared/X509Certificate2Extensions.cs @@ -129,4 +129,69 @@ public static IOrderedEnumerable OrderByVersion(this IEnumerab .OrderByDescending(c => c.GetCertificateVersion()) .ThenByDescending(c => c.NotAfter); } + + /// + /// Gets the certificates trusted by the current user's platform trust store. + /// + /// The certificates to check. + /// A token that can be used to cancel the operation. + /// The trusted certificates in their original order. + public static List GetTrustedCertificates( + this IEnumerable certificates, + CancellationToken cancellationToken = default) + { + using var chain = new X509Chain(); + // Revocation does not apply to self-signed development certificates. + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + + X509Certificate2Collection? rootCertificates = null; + if (OperatingSystem.IsWindows()) + { + // On Windows, chain.Build() can succeed even when the certificate is not in the trusted root store. + using var rootStore = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + rootStore.Open(OpenFlags.ReadOnly); + rootCertificates = rootStore.Certificates; + } + + try + { + var trustedCertificates = new List(); + foreach (var certificate in certificates) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + if (!chain.Build(certificate)) + { + continue; + } + + if (rootCertificates is not null && + !rootCertificates.Any(rootCertificate => rootCertificate.RawDataMemory.Span.SequenceEqual(certificate.RawDataMemory.Span))) + { + continue; + } + + trustedCertificates.Add(certificate); + } + finally + { + chain.Reset(); + } + } + + return trustedCertificates; + } + finally + { + if (rootCertificates is not null) + { + foreach (var rootCertificate in rootCertificates) + { + rootCertificate.Dispose(); + } + } + } + } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs index eed5e173ea5..2795286836f 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs @@ -18,7 +18,7 @@ public sealed class TypeScriptEmptyAppHostTemplateTests(ITestOutputHelper output { [Fact] [CaptureWorkspaceOnFailure] - public async Task CreateAndRunTypeScriptEmptyAppHostProject() + public async Task CreateAndRunTypeScriptEmptyAppHostProjectWithDevelopmentCertificate() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); var strategy = CliInstallStrategy.Detect(output.WriteLine); @@ -34,9 +34,28 @@ public async Task CreateAndRunTypeScriptEmptyAppHostProject() await auto.AspireNewAsync("TsEmptyApp", counter, template: AspireTemplate.TypeScriptEmptyAppHost); - GitIgnoreAssertions.AssertContainsEntry( - Path.Combine(workspace.WorkspaceRoot.FullName, "TsEmptyApp"), - ".aspire/"); + var appDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "TsEmptyApp"); + GitIgnoreAssertions.AssertContainsEntry(appDirectory, ".aspire/"); + + var appHostPath = Path.Combine(appDirectory, "apphost.mts"); + var appHostContents = await File.ReadAllTextAsync(appHostPath, TestContext.Current.CancellationToken); + appHostContents = appHostContents + .Replace( + "import { createBuilder } from './.aspire/modules/aspire.mjs';", + """ + import { writeFileSync } from 'node:fs'; + import { createBuilder } from './.aspire/modules/aspire.mjs'; + """, + StringComparison.Ordinal) + .Replace( + "const builder = await createBuilder();", + """ + writeFileSync('node-extra-ca-certs.txt', process.env.NODE_EXTRA_CA_CERTS ?? ''); + + const builder = await createBuilder(); + """, + StringComparison.Ordinal); + await File.WriteAllTextAsync(appHostPath, appHostContents, TestContext.Current.CancellationToken); // Start the empty TypeScript AppHost to verify the scaffolded project works await auto.TypeAsync("cd TsEmptyApp"); @@ -46,6 +65,9 @@ public async Task CreateAndRunTypeScriptEmptyAppHostProject() await auto.RunCommandAsync("npm run build", counter, TimeSpan.FromMinutes(2)); await auto.AspireStartAsync(counter); + await auto.RunCommandAsync( + "CERT_PATH=$(cat node-extra-ca-certs.txt) && test -s \"$CERT_PATH\" && grep -q -- '-----BEGIN CERTIFICATE-----' \"$CERT_PATH\"", + counter); await auto.AspireStopAsync(counter); } diff --git a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs index 848714d986e..6abdd54d43c 100644 --- a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs @@ -13,6 +13,7 @@ using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Aspire.Cli.Tests.Certificates; @@ -215,6 +216,118 @@ public async Task EnsureCertificatesTrustedAsync_NonInteractive_ChecksButDoesNot Assert.Empty(result.EnvironmentVariables); } + [Fact] + public async Task CertificatePemExport_IsExplicitAndUsesAspireHomeDirectory() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var aspireHomeDirectory = workspace.CreateDirectory("custom-aspire-home"); + var exportCalled = false; + string? exportDirectory = null; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext( + workspace.WorkspaceRoot, + aspireHomeDirectory: aspireHomeDirectory); + options.CertificateToolRunnerFactory = sp => + { + return new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => + { + return new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.Full, + Certificates = [new DevCertInfo { Version = 5, TrustLevel = CertificateManager.TrustLevel.Full, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }] + }; + }, + ExportDevCertificatePublicPemCallback = directory => + { + exportCalled = true; + exportDirectory = directory; + return Path.Combine(directory, "aspire-dev-cert-test.pem"); + } + }; + }; + }); + + var sp = services.BuildServiceProvider(); + var cs = sp.GetRequiredService(); + + await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); + + Assert.False(exportCalled); + + var result = cs.ExportDevCertificatePem(TestContext.Current.CancellationToken); + + Assert.True(exportCalled); + Assert.Equal(Path.Combine(aspireHomeDirectory.FullName, "dev-certs"), exportDirectory); + Assert.NotNull(result); + } + + [Fact] + public void ExportDevCertificatePem_Failure_DoesNotThrow() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CertificateToolRunnerFactory = sp => + { + return new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => + { + return new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.Full, + Certificates = [new DevCertInfo { Version = 5, TrustLevel = CertificateManager.TrustLevel.Full, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }] + }; + }, + ExportDevCertificatePublicPemCallback = (_) => throw new IOException("Disk full") + }; + }; + }); + + var sp = services.BuildServiceProvider(); + var cs = sp.GetRequiredService(); + + var result = cs.ExportDevCertificatePem(TestContext.Current.CancellationToken); + + Assert.Null(result); + } + + [Fact] + public void ExportDevCertificatePem_Cancellation_Throws() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var cancellationTokenSource = new CancellationTokenSource(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CertificateToolRunnerFactory = sp => + { + return new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => CreateTrustResult(CertificateManager.TrustLevel.Full), + ExportDevCertificatePublicPemCallback = _ => + { + cancellationTokenSource.Cancel(); + throw new OperationCanceledException(cancellationTokenSource.Token); + } + }; + }; + }); + + var sp = services.BuildServiceProvider(); + var certificateService = sp.GetRequiredService(); + + Assert.ThrowsAny( + () => certificateService.ExportDevCertificatePem(cancellationTokenSource.Token)); + } + [Fact] public async Task EnsureCertificatesTrustedAsync_NonInteractive_WarnsWhenUntrustedOnNonLinux() { @@ -326,7 +439,9 @@ public async Task EnsureCertificatesTrustedAsync_NonInteractive_ProceedsOnLinux( var interactiveService = sp.GetRequiredService(); var telemetry = sp.GetRequiredService(); var hostEnvironment = sp.GetRequiredService(); - return new CertificateService(toolRunner, interactiveService, telemetry, hostEnvironment, TestEnvironment.CreateLinux()); + var executionContext = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + return new CertificateService(toolRunner, interactiveService, telemetry, hostEnvironment, TestEnvironment.CreateLinux(), executionContext, logger); }; }); @@ -519,7 +634,9 @@ private ServiceProvider CreateServiceProvider(TemporaryWorkspace workspace, Test var interactiveService = sp.GetRequiredService(); var telemetry = sp.GetRequiredService(); var hostEnvironment = sp.GetRequiredService(); - return new CertificateService(toolRunner, interactiveService, telemetry, hostEnvironment, environment ?? sp.GetRequiredService()); + var executionContext = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + return new CertificateService(toolRunner, interactiveService, telemetry, hostEnvironment, environment ?? sp.GetRequiredService(), executionContext, logger); }; }); diff --git a/tests/Aspire.Cli.Tests/Certificates/NativeCertificateToolRunnerTests.cs b/tests/Aspire.Cli.Tests/Certificates/NativeCertificateToolRunnerTests.cs index 1730b70d6f6..d58f7e070ac 100644 --- a/tests/Aspire.Cli.Tests/Certificates/NativeCertificateToolRunnerTests.cs +++ b/tests/Aspire.Cli.Tests/Certificates/NativeCertificateToolRunnerTests.cs @@ -1,21 +1,24 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.IO.Hashing; using System.Security.Cryptography.X509Certificates; +using System.Text; using Aspire.Cli.Certificates; 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; -public class NativeCertificateToolRunnerTests +public class NativeCertificateToolRunnerTests(ITestOutputHelper outputHelper) { [Fact] public void TrustHttpCertificateOnLinux_WithNoCurrentCertificate_CreatesAndTrustsCertificate() { var certificateManager = new TestCertificateManager(); - var runner = new NativeCertificateToolRunner(certificateManager, TestEnvironment.CreateLinux()); + var runner = CreateRunner(certificateManager); var result = runner.TrustHttpCertificateOnLinux([], DateTimeOffset.UtcNow); @@ -31,7 +34,7 @@ public void TrustHttpCertificateOnLinux_WithExistingCurrentCertificate_TrustsWit using var certificate = certificateManager.CreateAspNetCoreHttpsDevelopmentCertificate( DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365)); - var runner = new NativeCertificateToolRunner(certificateManager, TestEnvironment.CreateLinux()); + var runner = CreateRunner(certificateManager); var result = runner.TrustHttpCertificateOnLinux([certificate], DateTimeOffset.UtcNow); @@ -48,7 +51,7 @@ public void TrustHttpCertificateOnLinux_WithOnlyOlderCertificate_CreatesCurrentC using var olderCertificate = olderVersionManager.CreateAspNetCoreHttpsDevelopmentCertificate( DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365)); - var runner = new NativeCertificateToolRunner(currentVersionManager, TestEnvironment.CreateLinux()); + var runner = CreateRunner(currentVersionManager); var result = runner.TrustHttpCertificateOnLinux([olderCertificate], DateTimeOffset.UtcNow); @@ -57,12 +60,99 @@ public void TrustHttpCertificateOnLinux_WithOnlyOlderCertificate_CreatesCurrentC Assert.True(currentVersionManager.TrustCalled); } + [Fact] + public void ExportDevCertificatePublicPem_WithUntrustedCertificate_ReturnsNull() + { + var certificateManager = new TestCertificateManager(); + using var certificate = certificateManager.CreateAspNetCoreHttpsDevelopmentCertificate( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + certificateManager.Certificates.Add(certificate.Export(X509ContentType.Pfx)); + var logger = new FakeLogger(); + var runner = CreateRunner(certificateManager, logger); + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var result = runner.ExportDevCertificatePublicPem( + workspace.WorkspaceRoot.FullName, + TestContext.Current.CancellationToken); + + Assert.Null(result); + Assert.Contains( + logger.Collector.GetSnapshot(), + record => record.Message.Contains("No trusted ASP.NET Core development certificate", StringComparison.Ordinal)); + } + + [Fact] + public void GetOrCreateCertificateCacheFile_CachesPublicPemWithRestrictedPermissions() + { + var certificateManager = new TestCertificateManager(); + using var certificate = certificateManager.CreateAspNetCoreHttpsDevelopmentCertificate( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + var logger = new FakeLogger(); + var runner = CreateRunner(certificateManager, logger); + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "dev-certs"); + var pemContents = certificate.ExportCertificatePem(); + var pemBytes = Encoding.UTF8.GetBytes(pemContents); + var hash = Convert.ToHexString(XxHash128.Hash(pemBytes)).ToLowerInvariant(); + var expectedPath = Path.Combine(outputDirectory, $"aspire-dev-cert-{hash}.pem"); + + var firstResult = runner.GetOrCreateCertificateCacheFile(certificate, outputDirectory); + var lastWriteTime = DateTime.UtcNow.AddHours(-1); + File.SetLastWriteTimeUtc(firstResult, lastWriteTime); + lastWriteTime = File.GetLastWriteTimeUtc(firstResult); + var secondResult = runner.GetOrCreateCertificateCacheFile(certificate, outputDirectory); + + Assert.Equal(expectedPath, firstResult); + Assert.Equal(firstResult, secondResult); + Assert.Equal(pemContents, File.ReadAllText(firstResult)); + Assert.Equal(lastWriteTime, File.GetLastWriteTimeUtc(secondResult)); + Assert.Contains( + logger.Collector.GetSnapshot(), + record => record.Message.Contains("Writing development certificate PEM to cache", StringComparison.Ordinal)); + Assert.Contains( + logger.Collector.GetSnapshot(), + record => record.Message.Contains("Reusing cached development certificate PEM", StringComparison.Ordinal)); + + if (!OperatingSystem.IsWindows()) + { + Assert.Equal( + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute, + File.GetUnixFileMode(outputDirectory)); + Assert.Equal( + UnixFileMode.UserRead | UnixFileMode.UserWrite, + File.GetUnixFileMode(firstResult)); + } + } + + private static NativeCertificateToolRunner CreateRunner( + CertificateManager certificateManager, + FakeLogger? logger = null) => + new( + certificateManager, + TestEnvironment.CreateLinux(), + logger ?? new FakeLogger()); + private sealed class TestCertificateManager(int version = CertificateManager.CurrentAspNetCoreCertificateVersion) : CertificateManager(NullLogger.Instance, CertificateManager.LocalhostHttpsDistinguishedName, version, version) { + public List Certificates { get; } = []; public bool SaveCalled { get; private set; } public bool TrustCalled { get; private set; } + protected override void PopulateCertificatesFromStore( + X509Store store, + List certificates, + bool requireExportable) + { + certificates.AddRange( + Certificates.Select(certificate => X509CertificateLoader.LoadPkcs12( + certificate, + password: null, + X509KeyStorageFlags.Exportable))); + } + protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation) { SaveCalled = true; diff --git a/tests/Aspire.Cli.Tests/Commands/CertificatesCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/CertificatesCommandTests.cs index fb6a82f8bf4..268915975d3 100644 --- a/tests/Aspire.Cli.Tests/Commands/CertificatesCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/CertificatesCommandTests.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Aspire.Cli.Tests.Commands; @@ -99,7 +100,9 @@ public async Task CertificatesCommand_TrustSubcommand_ReturnsSuccessForNonIntera { var telemetry = sp.GetRequiredService(); var hostEnvironment = sp.GetRequiredService(); - return new CertificateService(toolRunner, interactionService, telemetry, hostEnvironment, TestEnvironment.CreateLinux()); + var executionContext = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + return new CertificateService(toolRunner, interactionService, telemetry, hostEnvironment, TestEnvironment.CreateLinux(), executionContext, logger); }; }); using var provider = services.BuildServiceProvider(); diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 5713f002233..d4e95cc299e 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -749,6 +749,8 @@ public Task EnsureCertificatesTrustedAsync(Canc { throw new CertificateServiceException("Failed to trust certificates"); } + + public string? ExportDevCertificatePem(CancellationToken cancellationToken) => null; } [Fact] diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index a7a2f1b25f4..4222dcf47ec 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -1479,6 +1479,8 @@ private sealed class ThrowingCertificateService : Aspire.Cli.Certificates.ICerti { throw new Aspire.Cli.Certificates.CertificateServiceException("Failed to trust certificates"); } + + public string? ExportDevCertificatePem(CancellationToken cancellationToken) => null; } private sealed class NoProjectFileProjectLocator : IProjectLocator diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index 798a8c5cec1..0bea6569583 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.IO.Hashing; using System.Net.Sockets; +using System.Text; using Aspire.Cli.Backchannel; using Aspire.Cli.Configuration; using Aspire.Cli.Diagnostics; @@ -1238,10 +1240,13 @@ private GuestAppHostProject CreateGuestAppHostProject( TestAppHostBackchannel? backchannel = null, TestAppHostServerProjectFactory? appHostServerProjectFactory = null, IAppHostServerSessionFactory? serverSessionFactory = null, - bool identityOverridden = false) + bool identityOverridden = false, + string languageId = "typescript/nodejs", + IEnvironment? environment = null, + DirectoryInfo? homeDirectory = null) { var language = new LanguageInfo( - LanguageId: "typescript/nodejs", + LanguageId: languageId, DisplayName: "TypeScript (Node.js)", PackageName: "Aspire.Hosting.CodeGeneration.TypeScript", DetectionPatterns: ["apphost.ts"], @@ -1253,7 +1258,8 @@ private GuestAppHostProject CreateGuestAppHostProject( new DirectoryInfo(AppContext.BaseDirectory), identityChannel: identityChannel, logFilePath: logFilePath, - identityOverridden: identityOverridden); + identityOverridden: identityOverridden, + homeDirectory: homeDirectory); // Construct a real graceful-shutdown window so the contract matches production: // GuestAppHostProject requires it even when a test exits the Run path early @@ -1274,7 +1280,7 @@ private GuestAppHostProject CreateGuestAppHostProject( features: new Features(_configuration, NullLogger.Instance), languageDiscovery: new TestLanguageDiscovery(), executionContext: executionContext, - environment: new TestEnvironment(), + environment: environment ?? new TestEnvironment(), logger: NullLogger.Instance, fileLoggerProvider: new FileLoggerProvider(logFilePath, new TestStartupErrorWriter()), profilingTelemetry: _profilingTelemetry, @@ -1284,6 +1290,225 @@ private GuestAppHostProject CreateGuestAppHostProject( timeProvider: TimeProvider.System); } + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task ConfigureCertificateBundleEnvironmentAsync_SetsEnvironmentVariable_WhenExistingValueIsNotUsable(string? existingValue) + { + var project = CreateGuestAppHostProject(); + var envVars = new Dictionary(); + if (existingValue is not null) + { + envVars["NODE_EXTRA_CA_CERTS"] = existingValue; + } + + await project.ConfigureCertificateBundleEnvironmentAsync( + envVars, + _workspace.WorkspaceRoot, + "/path/to/cert.pem", + "NODE_EXTRA_CA_CERTS", + "typescript-nodejs", + TestContext.Current.CancellationToken); + + Assert.Equal("/path/to/cert.pem", envVars["NODE_EXTRA_CA_CERTS"]); + } + + [Fact] + public async Task ConfigureCertificateBundleEnvironmentAsync_ReusesDevCertificate_WhenAlreadyConfigured() + { + var devCertificatePath = Path.Combine(_workspace.WorkspaceRoot.FullName, "aspire-dev-cert.pem"); + var project = CreateGuestAppHostProject(); + var envVars = new Dictionary + { + ["NODE_EXTRA_CA_CERTS"] = Path.GetFileName(devCertificatePath) + }; + + await project.ConfigureCertificateBundleEnvironmentAsync( + envVars, + _workspace.WorkspaceRoot, + devCertificatePath, + "NODE_EXTRA_CA_CERTS", + "typescript-nodejs", + TestContext.Current.CancellationToken); + + Assert.Equal(devCertificatePath, envVars["NODE_EXTRA_CA_CERTS"]); + Assert.False(Directory.Exists(Path.Combine(_workspace.WorkspaceRoot.FullName, "bundles"))); + } + + [Fact] + public async Task ConfigureCertificateBundleEnvironmentAsync_UsesCaseInsensitivePathComparisonOnWindows() + { + var devCertificatePath = Path.Combine(_workspace.WorkspaceRoot.FullName, "aspire-dev-cert.pem"); + var project = CreateGuestAppHostProject(environment: TestEnvironment.CreateWindows()); + var envVars = new Dictionary + { + ["NODE_EXTRA_CA_CERTS"] = devCertificatePath.ToUpperInvariant() + }; + + await project.ConfigureCertificateBundleEnvironmentAsync( + envVars, + _workspace.WorkspaceRoot, + devCertificatePath, + "NODE_EXTRA_CA_CERTS", + "typescript-nodejs", + TestContext.Current.CancellationToken); + + Assert.Equal(devCertificatePath, envVars["NODE_EXTRA_CA_CERTS"]); + } + + [Fact] + public async Task ConfigureCertificateBundleEnvironmentAsync_DoesNotAssumeMacOSPathsAreCaseInsensitive() + { + var lowerCaseDirectory = Path.Combine(_workspace.WorkspaceRoot.FullName, "certificates"); + var upperCaseDirectory = Path.Combine(_workspace.WorkspaceRoot.FullName, "CERTIFICATES"); + Directory.CreateDirectory(lowerCaseDirectory); + Directory.CreateDirectory(upperCaseDirectory); + var devCertificatePath = Path.Combine(lowerCaseDirectory, "aspire.pem"); + var existingBundlePath = Path.Combine(upperCaseDirectory, "ASPIRE.PEM"); + await File.WriteAllTextAsync(existingBundlePath, "existing certificate", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(devCertificatePath, "development certificate", TestContext.Current.CancellationToken); + var expectedDevCertificateContents = await File.ReadAllBytesAsync(devCertificatePath, TestContext.Current.CancellationToken); + var expectedExistingBundleContents = await File.ReadAllBytesAsync(existingBundlePath, TestContext.Current.CancellationToken); + byte[] expectedBundleContents = [.. expectedDevCertificateContents, (byte)'\n', .. expectedExistingBundleContents]; + var project = CreateGuestAppHostProject(environment: TestEnvironment.CreateMacOS()); + var envVars = new Dictionary + { + ["NODE_EXTRA_CA_CERTS"] = existingBundlePath + }; + + await project.ConfigureCertificateBundleEnvironmentAsync( + envVars, + _workspace.WorkspaceRoot, + devCertificatePath, + "NODE_EXTRA_CA_CERTS", + "typescript-nodejs", + TestContext.Current.CancellationToken); + + Assert.NotEqual(devCertificatePath, envVars["NODE_EXTRA_CA_CERTS"]); + Assert.Equal( + expectedBundleContents, + await File.ReadAllBytesAsync(envVars["NODE_EXTRA_CA_CERTS"], TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ConfigureCertificateBundleEnvironmentAsync_DoesNotSet_WhenPemPathIsNull() + { + var project = CreateGuestAppHostProject(); + var envVars = new Dictionary(); + + await project.ConfigureCertificateBundleEnvironmentAsync( + envVars, + _workspace.WorkspaceRoot, + devCertPemPath: null, + environmentVariableName: "NODE_EXTRA_CA_CERTS", + cacheFilePrefix: "typescript-nodejs", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.False(envVars.ContainsKey("NODE_EXTRA_CA_CERTS")); + } + + [Fact] + public async Task ConfigureCertificateBundleEnvironmentAsync_CreatesAndReusesCombinedBundle_WhenAlreadySet() + { + const string certificateBundleEnvironmentVariable = "NODE_EXTRA_CA_CERTS"; + const string configuredNodeExtraCaCertsKey = "Node_Extra_Ca_Certs"; + var homeDirectory = _workspace.CreateDirectory("bundle-home"); + var existingBundlePath = Path.Combine(_workspace.WorkspaceRoot.FullName, "existing-ca-certs.pem"); + var inheritedBundlePath = Path.Combine(_workspace.WorkspaceRoot.FullName, "inherited-ca-certs.pem"); + var devCertificateDirectory = Path.Combine(homeDirectory.FullName, ".aspire", "dev-certs"); + var devCertificatePath = Path.Combine(devCertificateDirectory, "aspire-dev-cert.pem"); + const string existingBundleContents = "-----BEGIN CERTIFICATE-----\nexisting\n-----END CERTIFICATE-----\n"; + const string inheritedBundleContents = "-----BEGIN CERTIFICATE-----\ninherited\n-----END CERTIFICATE-----\n"; + const string devCertificateContents = "-----BEGIN CERTIFICATE-----\ndev\n-----END CERTIFICATE-----"; + Directory.CreateDirectory(devCertificateDirectory); + await File.WriteAllTextAsync(existingBundlePath, existingBundleContents, TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(inheritedBundlePath, inheritedBundleContents, TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(devCertificatePath, devCertificateContents, TestContext.Current.CancellationToken); + + var environment = TestEnvironment.CreateWindows(new Dictionary + { + [certificateBundleEnvironmentVariable] = inheritedBundlePath + }); + var project = CreateGuestAppHostProject(environment: environment, homeDirectory: homeDirectory); + var envVars = new Dictionary + { + [certificateBundleEnvironmentVariable] = inheritedBundlePath, + [configuredNodeExtraCaCertsKey] = Path.GetFileName(existingBundlePath) + }; + var expectedBundleContents = Encoding.UTF8.GetBytes($"{devCertificateContents}\n{existingBundleContents}"); + var expectedHash = Convert.ToHexString(XxHash128.Hash(expectedBundleContents)).ToLowerInvariant(); + var expectedBundlePath = Path.Combine( + homeDirectory.FullName, + ".aspire", + "dev-certs", + "bundles", + $"typescript-nodejs-{expectedHash}.pem"); + + await project.ConfigureCertificateBundleEnvironmentAsync( + envVars, + _workspace.WorkspaceRoot, + devCertificatePath, + certificateBundleEnvironmentVariable, + "typescript-nodejs", + TestContext.Current.CancellationToken); + + Assert.Equal(expectedBundlePath, envVars[certificateBundleEnvironmentVariable]); + Assert.DoesNotContain(configuredNodeExtraCaCertsKey, envVars.Keys); + Assert.Equal(expectedBundleContents, await File.ReadAllBytesAsync(expectedBundlePath, TestContext.Current.CancellationToken)); + + var cachedWriteTime = DateTime.UtcNow.AddDays(-1); + File.SetLastWriteTimeUtc(expectedBundlePath, cachedWriteTime); + cachedWriteTime = File.GetLastWriteTimeUtc(expectedBundlePath); + envVars.Remove(certificateBundleEnvironmentVariable); + envVars[configuredNodeExtraCaCertsKey] = Path.GetFileName(existingBundlePath); + await project.ConfigureCertificateBundleEnvironmentAsync( + envVars, + _workspace.WorkspaceRoot, + devCertificatePath, + certificateBundleEnvironmentVariable, + "typescript-nodejs", + TestContext.Current.CancellationToken); + + Assert.Equal(cachedWriteTime, File.GetLastWriteTimeUtc(expectedBundlePath)); + Assert.DoesNotContain(configuredNodeExtraCaCertsKey, envVars.Keys); + + if (!OperatingSystem.IsWindows()) + { + Assert.Equal( + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute, + File.GetUnixFileMode(Path.GetDirectoryName(expectedBundlePath)!)); + Assert.Equal( + UnixFileMode.UserRead | UnixFileMode.UserWrite, + File.GetUnixFileMode(expectedBundlePath)); + } + } + + [Fact] + public async Task ConfigureCertificateBundleEnvironmentAsync_PreservesExistingBundle_WhenCombinationFails() + { + var interactionService = new TestInteractionService(); + var project = CreateGuestAppHostProject(interactionService: interactionService); + var devCertificatePath = Path.Combine(_workspace.WorkspaceRoot.FullName, "aspire-dev-cert.pem"); + await File.WriteAllTextAsync(devCertificatePath, "dev certificate", TestContext.Current.CancellationToken); + var envVars = new Dictionary + { + ["NODE_EXTRA_CA_CERTS"] = "missing-ca-certs.pem" + }; + + await project.ConfigureCertificateBundleEnvironmentAsync( + envVars, + _workspace.WorkspaceRoot, + devCertificatePath, + "NODE_EXTRA_CA_CERTS", + "typescript-nodejs", + TestContext.Current.CancellationToken); + + Assert.Equal("missing-ca-certs.pem", envVars["NODE_EXTRA_CA_CERTS"]); + Assert.Single(interactionService.DisplayedMessages); + Assert.Contains("existing certificate bundle will be used unchanged", interactionService.DisplayedMessages[0].Message); + } + private static async Task InvokeStartBackchannelConnectionAsync( GuestAppHostProject project, IAppHostServerSession serverSession, @@ -1310,5 +1535,4 @@ private sealed class NoOpGracefulSignaler : IProcessTreeGracefulShutdownSignaler public Task RequestProcessTreeGracefulShutdownAsync(int pid, DateTimeOffset? startTime, bool includeStartTimeForDcp, CancellationToken cancellationToken) => Task.FromResult(true); } - } diff --git a/tests/Aspire.Cli.Tests/Projects/TypeScriptAppHostToolchainResolverTests.cs b/tests/Aspire.Cli.Tests/Projects/TypeScriptAppHostToolchainResolverTests.cs index 0b869c47c19..3fd02146ce7 100644 --- a/tests/Aspire.Cli.Tests/Projects/TypeScriptAppHostToolchainResolverTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/TypeScriptAppHostToolchainResolverTests.cs @@ -309,6 +309,7 @@ public void ApplyToRuntimeSpec_WhenBunSelected_UsesBunCommandsAndPreservesExtens ], runtimeSpec.WatchExecute!.Args); Assert.Equal("node", runtimeSpec.ExtensionLaunchCapability); + Assert.Equal("NODE_EXTRA_CA_CERTS", runtimeSpec.CertificateBundleEnvironmentVariable); } [Fact] @@ -377,7 +378,8 @@ private static RuntimeSpec CreateBaseRuntimeSpec() Command = "npx", Args = ["--no-install", "nodemon", "--exec", "npx --no-install tsx --tsconfig tsconfig.apphost.json {appHostFile}"] }, - ExtensionLaunchCapability = "node" + ExtensionLaunchCapability = "node", + CertificateBundleEnvironmentVariable = "NODE_EXTRA_CA_CERTS" }; } diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index 5054df0ebb0..a48a5c890b4 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -474,6 +474,8 @@ public Task EnsureCertificatesTrustedAsync(Canc EnvironmentVariables = new Dictionary(), Success = true }); + + public string? ExportDevCertificatePem(CancellationToken cancellationToken) => null; } private sealed class TestNewCommandPrompter : INewCommandPrompter, ITemplateVersionPrompter diff --git a/tests/Aspire.Cli.Tests/TestServices/TestCertificateService.cs b/tests/Aspire.Cli.Tests/TestServices/TestCertificateService.cs index c1a913c37d3..60b461c3899 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestCertificateService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestCertificateService.cs @@ -15,4 +15,6 @@ public Task EnsureCertificatesTrustedAsync(Canc Success = true }); } + + public string? ExportDevCertificatePem(CancellationToken cancellationToken) => null; } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs index 6f3a55b8f01..40c9c87055b 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs @@ -16,6 +16,7 @@ internal sealed class TestCertificateToolRunner : ICertificateToolRunner public Func? EnsureHttpCertificateExistsCallback { get; set; } public Func? TrustHttpCertificateCallback { get; set; } public Func? CleanHttpCertificateCallback { get; set; } + public Func? ExportDevCertificatePublicPemCallback { get; set; } public CertificateTrustResult CheckHttpCertificate(CancellationToken cancellationToken = default) { @@ -54,4 +55,12 @@ public CertificateCleanResult CleanHttpCertificate() ? CleanHttpCertificateCallback() : new CertificateCleanResult { Success = true }; } + + public string? ExportDevCertificatePublicPem(string outputDirectory, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return ExportDevCertificatePublicPemCallback is not null + ? ExportDevCertificatePublicPemCallback(outputDirectory) + : null; + } } diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index aea5aa4edcb..f2dc5707b35 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -509,7 +509,10 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser var interactiveService = serviceProvider.GetRequiredService(); var telemetry = serviceProvider.GetRequiredService(); var hostEnvironment = serviceProvider.GetRequiredService(); - return new CertificateService(certificateToolRunner, interactiveService, telemetry, hostEnvironment, serviceProvider.GetRequiredService()); + var environment = serviceProvider.GetRequiredService(); + var executionContext = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + return new CertificateService(certificateToolRunner, interactiveService, telemetry, hostEnvironment, environment, executionContext, logger); }; public Func ScaffoldingServiceFactory { get; set; } = (IServiceProvider serviceProvider) => diff --git a/tests/Aspire.Cli.Tests/Utils/TestExecutionContextHelper.cs b/tests/Aspire.Cli.Tests/Utils/TestExecutionContextHelper.cs index 422f49f670a..cc45176db2e 100644 --- a/tests/Aspire.Cli.Tests/Utils/TestExecutionContextHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/TestExecutionContextHelper.cs @@ -22,7 +22,8 @@ public static CliExecutionContext CreateExecutionContext( string? logFilePath = null, string? identityVersion = null, string? identityCommit = null, - bool identityOverridden = false) + bool identityOverridden = false, + DirectoryInfo? aspireHomeDirectory = null) { return CreateExecutionContext( workspace.WorkspaceRoot, @@ -30,7 +31,8 @@ public static CliExecutionContext CreateExecutionContext( logFilePath: logFilePath, identityVersion: identityVersion, identityCommit: identityCommit, - identityOverridden: identityOverridden); + identityOverridden: identityOverridden, + aspireHomeDirectory: aspireHomeDirectory); } /// @@ -49,7 +51,8 @@ public static CliExecutionContext CreateExecutionContext( string? identityVersion = null, string? identityCommit = null, bool identityOverridden = false, - DirectoryInfo? identityPackagesDirectory = null) + DirectoryInfo? identityPackagesDirectory = null, + DirectoryInfo? aspireHomeDirectory = null) { var root = rootDirectory.FullName; hivesDirectory ??= new DirectoryInfo(Path.Combine(root, ".aspire", "hives")); @@ -74,6 +77,7 @@ public static CliExecutionContext CreateExecutionContext( identityPackagesDirectory: identityPackagesDirectory, debugMode: debugMode, homeDirectory: homeDirectory, - packagesDirectory: packagesDirectory); + packagesDirectory: packagesDirectory, + aspireHomeDirectory: aspireHomeDirectory); } } diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TypeScriptLanguageSupportTests.cs b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TypeScriptLanguageSupportTests.cs index 95a1fc3058f..df33898cf00 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TypeScriptLanguageSupportTests.cs +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TypeScriptLanguageSupportTests.cs @@ -256,6 +256,8 @@ public void GetRuntimeSpec_UsesAppHostSpecificTsConfig() var preExecute = Assert.Single(runtimeSpec.PreExecute!); var watchExecute = Assert.IsType(runtimeSpec.WatchExecute); + Assert.Equal("NODE_EXTRA_CA_CERTS", _languageSupport.CertificateBundleEnvironmentVariable); + Assert.Equal(_languageSupport.CertificateBundleEnvironmentVariable, runtimeSpec.CertificateBundleEnvironmentVariable); Assert.Equal("npx", preExecute.Command); Assert.Equal(new[] { "--no-install", "tsc", "--noEmit", "-p", "tsconfig.apphost.json" }, preExecute.Args); Assert.Equal(new[] { "--no-install", "tsx", "--tsconfig", "tsconfig.apphost.json", "{appHostFile}" }, runtimeSpec.Execute.Args);