diff --git a/eng/Npm.Workspace.nodeproj b/eng/Npm.Workspace.nodeproj index 58722e78cc3a..bca7304dd870 100644 --- a/eng/Npm.Workspace.nodeproj +++ b/eng/Npm.Workspace.nodeproj @@ -36,7 +36,7 @@ - @@ -58,6 +58,8 @@ + + diff --git a/eng/tools/RepoTasks/GenerateTestDevCert.cs b/eng/tools/RepoTasks/GenerateTestDevCert.cs new file mode 100644 index 000000000000..37a4b33216ed --- /dev/null +++ b/eng/tools/RepoTasks/GenerateTestDevCert.cs @@ -0,0 +1,74 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Linq; +using System.Text.Json; +using System.Threading; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace RepoTasks; + +/// +/// Generates the test HTTPs certificate used by the template tests +/// +public class GenerateTestDevCert : Task +{ + [Required] + public string CertificatePath { get; private set; } + + public override bool Execute() + { + Mutex mutex = null; + try + { + // MSBuild will potentially invoke this task in parallel across different subprocesses/nodes. + // The build is configured to generate the certificate in a single location, but multiple projects + // import the same targets that will use this task, which will result in multiple calls. + // To avoid issues where we try to generate multiple certificates on the same location, we wrap the + // usage in a named mutex which guarantees that only one instance will run at a time. + mutex = new(initiallyOwned: true, "Global\\GenerateTestDevCert", out var createdNew); + if (!createdNew) + { + // The mutex already exists, wait for it to be released. + mutex.WaitOne(); + } + + if (File.Exists(CertificatePath)) + { + Log.LogMessage(MessageImportance.Normal, $"A test certificate already exists at {CertificatePath}"); + return true; + } + + var cert = DevelopmentCertificate.Create(CertificatePath); + + var devCertJsonFile = Path.ChangeExtension(CertificatePath, ".json"); + var devCertJson = new CertificateInfo + { + Password = cert.CertificatePassword, + Thumbprint = cert.CertificateThumbprint + }; + + using var file = File.OpenWrite(devCertJsonFile); + file.SetLength(0); + JsonSerializer.Serialize(file, devCertJson); + } + catch (Exception e) + { + Log.LogErrorFromException(e, showStackTrace: true); + } + finally + { + mutex.ReleaseMutex(); + } + + return !Log.HasLoggedErrors; + } + + private class CertificateInfo + { + public string Password { get; set; } + public string Thumbprint { get; set; } + } +} diff --git a/eng/tools/RepoTasks/RepoTasks.csproj b/eng/tools/RepoTasks/RepoTasks.csproj index c9fd42558d07..c462a7517ae0 100644 --- a/eng/tools/RepoTasks/RepoTasks.csproj +++ b/eng/tools/RepoTasks/RepoTasks.csproj @@ -22,6 +22,15 @@ + + + + + + + + + @@ -46,4 +55,5 @@ $(WiXSdkPath)\Microsoft.Deployment.WindowsInstaller.Package.dll + diff --git a/eng/tools/RepoTasks/RepoTasks.tasks b/eng/tools/RepoTasks/RepoTasks.tasks index c5722a9e266d..b6cd9a820d1e 100644 --- a/eng/tools/RepoTasks/RepoTasks.tasks +++ b/eng/tools/RepoTasks/RepoTasks.tasks @@ -10,5 +10,6 @@ + diff --git a/eng/tools/RepoTasks/shared/CertificateGeneration/DevelopmentCertificate.cs b/eng/tools/RepoTasks/shared/CertificateGeneration/DevelopmentCertificate.cs new file mode 100644 index 000000000000..a7c826021814 --- /dev/null +++ b/eng/tools/RepoTasks/shared/CertificateGeneration/DevelopmentCertificate.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Certificates.Generation; + +namespace RepoTasks; + +public readonly struct DevelopmentCertificate +{ + public DevelopmentCertificate(string certificatePath, string certificatePassword, string certificateThumbprint) + { + CertificatePath = certificatePath; + CertificatePassword = certificatePassword; + CertificateThumbprint = certificateThumbprint; + } + + public readonly string CertificatePath { get; } + public readonly string CertificatePassword { get; } + public readonly string CertificateThumbprint { get; } + + public static DevelopmentCertificate Create(string certificatePath) + { + var certificatePassword = ""; + var certificateThumbprint = EnsureDevelopmentCertificates(certificatePath, certificatePassword); + + return new DevelopmentCertificate(certificatePath, certificatePassword, certificateThumbprint); + } + + private static string EnsureDevelopmentCertificates(string certificatePath, string certificatePassword) + { + var now = DateTimeOffset.Now; + var manager = CertificateManager.Instance; + var certificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1)); + var certificateThumbprint = certificate.Thumbprint; + manager.ExportCertificate(certificate, path: certificatePath, includePrivateKey: true, certificatePassword, CertificateKeyExportFormat.Pfx); + + return certificateThumbprint; + } +} diff --git a/eng/tools/RepoTasks/shared/CertificateGeneration/Global.cs b/eng/tools/RepoTasks/shared/CertificateGeneration/Global.cs new file mode 100644 index 000000000000..b9ed6282209f --- /dev/null +++ b/eng/tools/RepoTasks/shared/CertificateGeneration/Global.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +global using System; +global using System.Collections.Generic; +global using System.IO; diff --git a/src/ProjectTemplates/Shared/DevelopmentCertificate.cs b/src/ProjectTemplates/Shared/DevelopmentCertificate.cs index 080f35c3b3f8..3ee2f8e6eab2 100644 --- a/src/ProjectTemplates/Shared/DevelopmentCertificate.cs +++ b/src/ProjectTemplates/Shared/DevelopmentCertificate.cs @@ -1,42 +1,61 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.IO; -using Microsoft.AspNetCore.Certificates.Generation; +using System.Reflection; +using System.Text.Json; namespace Templates.Test.Helpers; -public readonly struct DevelopmentCertificate +public readonly struct DevelopmentCertificate(string certificatePath, string certificatePassword, string certificateThumbprint) { - public DevelopmentCertificate(string certificatePath, string certificatePassword, string certificateThumbprint) + public readonly string CertificatePath { get; } = certificatePath; + public readonly string CertificatePassword { get; } = certificatePassword; + public readonly string CertificateThumbprint { get; } = certificateThumbprint; + + public static DevelopmentCertificate Get(Assembly assembly) { - CertificatePath = certificatePath; - CertificatePassword = certificatePassword; - CertificateThumbprint = certificateThumbprint; - } + string[] locations = [ + Path.Combine(AppContext.BaseDirectory, "aspnetcore-https.json"), + Path.Combine(Environment.CurrentDirectory, "aspnetcore-https.json"), + Path.Combine(AppContext.BaseDirectory, "aspnetcore-https.json"), + ]; - public readonly string CertificatePath { get; } - public readonly string CertificatePassword { get; } - public readonly string CertificateThumbprint { get; } + var json = TryGetExistingFile(locations) + ?? throw new InvalidOperationException($"The aspnetcore-https.json file does not exist. Searched locations: {Environment.NewLine}{string.Join(Environment.NewLine, locations)}"); - public static DevelopmentCertificate Create(string workingDirectory) - { - var certificatePath = Path.Combine(workingDirectory, $"{Guid.NewGuid()}.pfx"); - var certificatePassword = Guid.NewGuid().ToString(); - var certificateThumbprint = EnsureDevelopmentCertificates(certificatePath, certificatePassword); + using var file = File.OpenRead(json); + var certificateAttributes = JsonSerializer.Deserialize(file) ?? + throw new InvalidOperationException($"The aspnetcore-https.json file does not contain valid JSON."); + + var path = Path.ChangeExtension(json, ".pfx"); - return new DevelopmentCertificate(certificatePath, certificatePassword, certificateThumbprint); + if (!File.Exists(path)) + { + throw new InvalidOperationException($"The certificate file does not exist. Expected at: '{path}'."); + } + + var password = certificateAttributes.Password; + var thumbprint = certificateAttributes.Thumbprint; + + return new DevelopmentCertificate(path, password, thumbprint); } - private static string EnsureDevelopmentCertificates(string certificatePath, string certificatePassword) + private static string TryGetExistingFile(string[] locations) { - var now = DateTimeOffset.Now; - var manager = CertificateManager.Instance; - var certificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1)); - var certificateThumbprint = certificate.Thumbprint; - manager.ExportCertificate(certificate, path: certificatePath, includePrivateKey: true, certificatePassword, CertificateKeyExportFormat.Pfx); + foreach (var location in locations) + { + if (File.Exists(location)) + { + return location; + } + } + + return null; + } - return certificateThumbprint; + private sealed class CertificateAttributes + { + public string Password { get; set; } + public string Thumbprint { get; set; } } } diff --git a/src/ProjectTemplates/Shared/Project.cs b/src/ProjectTemplates/Shared/Project.cs index ed00a32f7f8e..771095b177de 100644 --- a/src/ProjectTemplates/Shared/Project.cs +++ b/src/ProjectTemplates/Shared/Project.cs @@ -51,7 +51,7 @@ public static string ArtifactsLogDir public string TemplateOutputDir { get; set; } public string TargetFramework { get; set; } = GetAssemblyMetadata("Test.DefaultTargetFramework"); public string RuntimeIdentifier { get; set; } = string.Empty; - public static DevelopmentCertificate DevCert { get; } = DevelopmentCertificate.Create(AppContext.BaseDirectory); + public static DevelopmentCertificate DevCert { get; } = DevelopmentCertificate.Get(typeof(Project).Assembly); public string TemplateBuildDir => Path.Combine(TemplateOutputDir, "bin", "Debug", TargetFramework, RuntimeIdentifier); public string TemplatePublishDir => Path.Combine(TemplateOutputDir, "bin", "Release", TargetFramework, RuntimeIdentifier, "publish"); diff --git a/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets b/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets index 4e1a98035f07..04f5ec7ede2c 100644 --- a/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets +++ b/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets @@ -22,6 +22,31 @@ + + + + <_DevCertFileName>aspnetcore-https.pfx + <_DevCertJsonFileName>aspnetcore-https.json + <_DevCertPath>$(ArtifactsTmpDir)$(_DevCertFileName) + <_DevCertJsonPath>$(ArtifactsTmpDir)$(_DevCertJsonFileName) + + + + + + + + + + + + + + + + $([MSBuild]::NormalizePath('$(OutputPath)$(TestTemplateCreationFolder)')) diff --git a/src/ProjectTemplates/test/Templates.Tests/Templates.Tests.csproj b/src/ProjectTemplates/test/Templates.Tests/Templates.Tests.csproj index fb297bfae7c5..a22f7fdea519 100644 --- a/src/ProjectTemplates/test/Templates.Tests/Templates.Tests.csproj +++ b/src/ProjectTemplates/test/Templates.Tests/Templates.Tests.csproj @@ -1,4 +1,4 @@ - +