From 237973f3d331cfe239db8faaebf70ce91c1cbd30 Mon Sep 17 00:00:00 2001 From: Jozef Izso Date: Wed, 1 Jul 2026 17:22:55 +0200 Subject: [PATCH 1/2] Implement new `--certificate-output` argument to save the certificate information The certificate information is useful when signing code with Azure Artifacts Signing which rotates certificates each day. Scripts and automation can use correct certificate used to sign content. This helps with NuGet Gallery where we must manually upload certificates. --- docs/gh-build-and-sign.yml | 11 +++++ src/Sign.Cli/ArtifactSigningCommand.cs | 2 +- src/Sign.Cli/AzureKeyVaultCommand.cs | 2 +- src/Sign.Cli/CertificateStoreCommand.cs | 2 +- src/Sign.Cli/CodeCommand.cs | 42 ++++++++++++++++- src/Sign.Cli/Resources.Designer.cs | 9 ++++ src/Sign.Cli/Resources.resx | 3 ++ src/Sign.Cli/TrustedSigningCommand.cs | 2 +- src/Sign.Cli/xlf/Resources.cs.xlf | 5 +++ src/Sign.Cli/xlf/Resources.de.xlf | 5 +++ src/Sign.Cli/xlf/Resources.es.xlf | 5 +++ src/Sign.Cli/xlf/Resources.fr.xlf | 5 +++ src/Sign.Cli/xlf/Resources.it.xlf | 5 +++ src/Sign.Cli/xlf/Resources.ja.xlf | 5 +++ src/Sign.Cli/xlf/Resources.ko.xlf | 5 +++ src/Sign.Cli/xlf/Resources.pl.xlf | 5 +++ src/Sign.Cli/xlf/Resources.pt-BR.xlf | 5 +++ src/Sign.Cli/xlf/Resources.ru.xlf | 5 +++ src/Sign.Cli/xlf/Resources.tr.xlf | 5 +++ src/Sign.Cli/xlf/Resources.zh-Hans.xlf | 5 +++ src/Sign.Cli/xlf/Resources.zh-Hant.xlf | 5 +++ test/Sign.Cli.Test/CodeCommandTests.cs | 45 +++++++++++++++++++ .../Options/CertificateOutputOptionTests.cs | 16 +++++++ test/Sign.Cli.Test/SignCommandTests.cs | 1 + 24 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 test/Sign.Cli.Test/Options/CertificateOutputOptionTests.cs diff --git a/docs/gh-build-and-sign.yml b/docs/gh-build-and-sign.yml index b013eeef..ec92e605 100644 --- a/docs/gh-build-and-sign.yml +++ b/docs/gh-build-and-sign.yml @@ -88,6 +88,17 @@ jobs: --azure-credential-type "azure-cli" --azure-key-vault-url "${{ secrets.KEY_VAULT_URL }}" # This does not need to be a secret and is just a placeholder --azure-key-vault-certificate "${{ secrets.KEY_VAULT_CERTIFICATE_ID }}" # This does not need to be a secret and is just a placeholder + --certificate-output "${{ runner.temp }}\signing-certificate.cer" + + # Make the public certificate path available to later steps in this job. + - name: Publish signing certificate path + id: signing-certificate + shell: pwsh + run: '"certificate_file=${{ runner.temp }}\signing-certificate.cer" >> $env:GITHUB_OUTPUT' + + - name: Use signing certificate + shell: pwsh + run: Get-PfxCertificate -FilePath "${{ steps.signing-certificate.outputs.certificate_file }}" # Publish the signed packages - name: Upload build artifacts diff --git a/src/Sign.Cli/ArtifactSigningCommand.cs b/src/Sign.Cli/ArtifactSigningCommand.cs index c0c3f480..188b3567 100644 --- a/src/Sign.Cli/ArtifactSigningCommand.cs +++ b/src/Sign.Cli/ArtifactSigningCommand.cs @@ -103,7 +103,7 @@ internal ArtifactSigningCommand(CodeCommand codeCommand, IServiceProviderFactory ArtifactSigningServiceProvider trustedSigningServiceProvider = new(); - return codeCommand.HandleAsync(parseResult, serviceProviderFactory, trustedSigningServiceProvider, filesArgument); + return codeCommand.HandleAsync(parseResult, serviceProviderFactory, trustedSigningServiceProvider, filesArgument, cancellationToken); }); } } diff --git a/src/Sign.Cli/AzureKeyVaultCommand.cs b/src/Sign.Cli/AzureKeyVaultCommand.cs index 25e9ee82..dd4c0a73 100644 --- a/src/Sign.Cli/AzureKeyVaultCommand.cs +++ b/src/Sign.Cli/AzureKeyVaultCommand.cs @@ -118,7 +118,7 @@ internal AzureKeyVaultCommand(CodeCommand codeCommand, IServiceProviderFactory s KeyVaultServiceProvider keyVaultServiceProvider = new(); - return codeCommand.HandleAsync(parseResult, serviceProviderFactory, keyVaultServiceProvider, filesArgument); + return codeCommand.HandleAsync(parseResult, serviceProviderFactory, keyVaultServiceProvider, filesArgument, cancellationToken); }); } diff --git a/src/Sign.Cli/CertificateStoreCommand.cs b/src/Sign.Cli/CertificateStoreCommand.cs index b6d63dcb..a0604ed9 100644 --- a/src/Sign.Cli/CertificateStoreCommand.cs +++ b/src/Sign.Cli/CertificateStoreCommand.cs @@ -143,7 +143,7 @@ internal CertificateStoreCommand(CodeCommand codeCommand, IServiceProviderFactor useMachineKeyContainer, isInteractive); - return codeCommand.HandleAsync(parseResult, serviceProviderFactory, certificateStoreServiceProvider, filesArgument); + return codeCommand.HandleAsync(parseResult, serviceProviderFactory, certificateStoreServiceProvider, filesArgument, cancellationToken); }); } diff --git a/src/Sign.Cli/CodeCommand.cs b/src/Sign.Cli/CodeCommand.cs index c61367a5..b64851c9 100644 --- a/src/Sign.Cli/CodeCommand.cs +++ b/src/Sign.Cli/CodeCommand.cs @@ -6,6 +6,7 @@ using System.CommandLine.Parsing; using System.Globalization; using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Text; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileSystemGlobbing; @@ -19,6 +20,7 @@ internal sealed class CodeCommand : Command { internal Option ApplicationNameOption { get; } internal Option BaseDirectoryOption { get; } + internal Option CertificateOutputOption { get; } internal Option DescriptionOption { get; } internal Option DescriptionUrlOption { get; } internal Option FileDigestOption { get; } @@ -46,6 +48,11 @@ internal CodeCommand() Description = Resources.BaseDirectoryOptionDescription, Recursive = true }; + CertificateOutputOption = new Option("--certificate-output", "-co") + { + Description = Resources.CertificateOutputOptionDescription, + Recursive = true + }; DescriptionOption = new Option("--description", "-d") { Description = Resources.DescriptionOptionDescription, @@ -120,6 +127,7 @@ internal CodeCommand() Options.Add(DescriptionUrlOption); Options.Add(BaseDirectoryOption); Options.Add(OutputOption); + Options.Add(CertificateOutputOption); Options.Add(PublisherNameOption); Options.Add(FileListOption); Options.Add(RecurseContainersOption); @@ -130,7 +138,12 @@ internal CodeCommand() Options.Add(VerbosityOption); } - internal async Task HandleAsync(ParseResult parseResult, IServiceProviderFactory serviceProviderFactory, ISignatureProvider signatureProvider, IEnumerable filesArgument) + internal async Task HandleAsync( + ParseResult parseResult, + IServiceProviderFactory serviceProviderFactory, + ISignatureProvider signatureProvider, + IEnumerable filesArgument, + CancellationToken cancellationToken) { // Some of the options have a default value and that is why we can safely use // the null-forgiving operator (!) to simplify the code. @@ -146,6 +159,7 @@ internal async Task HandleAsync(ParseResult parseResult, IServiceProviderFa Uri timestampUrl = parseResult.GetValue(TimestampUrlOption)!; LogLevel verbosity = parseResult.GetValue(VerbosityOption); string? output = parseResult.GetValue(OutputOption); + string? certificateOutput = parseResult.GetValue(CertificateOutputOption); int maxConcurrency = parseResult.GetValue(MaxConcurrencyOption); // Make sure this is rooted @@ -246,6 +260,8 @@ internal async Task HandleAsync(ParseResult parseResult, IServiceProviderFa ISigner signer = serviceProvider.GetRequiredService(); + cancellationToken.ThrowIfCancellationRequested(); + int exitCode = await signer.SignAsync( inputFiles, output, @@ -261,9 +277,33 @@ internal async Task HandleAsync(ParseResult parseResult, IServiceProviderFa fileHashAlgorithmName, timestampHashAlgorithmName); + if (exitCode == ExitCode.Success && !string.IsNullOrEmpty(certificateOutput)) + { + FileInfo certificateOutputFile = new(ExpandFilePath(baseDirectory, certificateOutput)); + ICertificateProvider certificateProvider = serviceProvider.GetRequiredService(); + + await ExportCertificateAsync(certificateProvider, certificateOutputFile, cancellationToken); + } + return exitCode; } + internal static async Task ExportCertificateAsync( + ICertificateProvider certificateProvider, + FileInfo certificateOutputFile, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(certificateProvider, nameof(certificateProvider)); + ArgumentNullException.ThrowIfNull(certificateOutputFile, nameof(certificateOutputFile)); + + certificateOutputFile.Directory!.Create(); + + using X509Certificate2 certificate = await certificateProvider.GetCertificateAsync(cancellationToken); + byte[] certificateBytes = certificate.Export(X509ContentType.Cert); + + await File.WriteAllBytesAsync(certificateOutputFile.FullName, certificateBytes, cancellationToken); + } + private static string ExpandFilePath(DirectoryInfo baseDirectory, string file) { if (Path.IsPathRooted(file)) diff --git a/src/Sign.Cli/Resources.Designer.cs b/src/Sign.Cli/Resources.Designer.cs index 857aed23..c493c2e9 100644 --- a/src/Sign.Cli/Resources.Designer.cs +++ b/src/Sign.Cli/Resources.Designer.cs @@ -77,6 +77,15 @@ internal static string BaseDirectoryOptionDescription { return ResourceManager.GetString("BaseDirectoryOptionDescription", resourceCulture); } } + + /// + /// Looks up a localized string similar to Write the public signing certificate as a DER-encoded CER file.. + /// + internal static string CertificateOutputOptionDescription { + get { + return ResourceManager.GetString("CertificateOutputOptionDescription", resourceCulture); + } + } /// /// Looks up a localized string similar to Use Windows Certificate Store or a local certificate file.. diff --git a/src/Sign.Cli/Resources.resx b/src/Sign.Cli/Resources.resx index 3f730167..681d1bf8 100644 --- a/src/Sign.Cli/Resources.resx +++ b/src/Sign.Cli/Resources.resx @@ -123,6 +123,9 @@ Base directory for files. Overrides the current working directory. + + Write the public signing certificate as a DER-encoded CER file. + Use Windows Certificate Store or a local certificate file. diff --git a/src/Sign.Cli/TrustedSigningCommand.cs b/src/Sign.Cli/TrustedSigningCommand.cs index cc9ca491..ae901d33 100644 --- a/src/Sign.Cli/TrustedSigningCommand.cs +++ b/src/Sign.Cli/TrustedSigningCommand.cs @@ -105,7 +105,7 @@ internal TrustedSigningCommand(CodeCommand codeCommand, IServiceProviderFactory ArtifactSigningServiceProvider trustedSigningServiceProvider = new(); - return codeCommand.HandleAsync(parseResult, serviceProviderFactory, trustedSigningServiceProvider, filesArgument); + return codeCommand.HandleAsync(parseResult, serviceProviderFactory, trustedSigningServiceProvider, filesArgument, cancellationToken); }); } } diff --git a/src/Sign.Cli/xlf/Resources.cs.xlf b/src/Sign.Cli/xlf/Resources.cs.xlf index 6b141693..c23fdadc 100644 --- a/src/Sign.Cli/xlf/Resources.cs.xlf +++ b/src/Sign.Cli/xlf/Resources.cs.xlf @@ -12,6 +12,11 @@ Základní adresář pro soubory Přepíše aktuální pracovní adresář. + + Write the public signing certificate as a DER-encoded CER file. + Write the public signing certificate as a DER-encoded CER file. + + Use Windows Certificate Store or a local certificate file. Použijte úložiště certifikátů systému Windows nebo místní soubor certifikátu. diff --git a/src/Sign.Cli/xlf/Resources.de.xlf b/src/Sign.Cli/xlf/Resources.de.xlf index da4c7469..33f9dde3 100644 --- a/src/Sign.Cli/xlf/Resources.de.xlf +++ b/src/Sign.Cli/xlf/Resources.de.xlf @@ -12,6 +12,11 @@ Basisverzeichnis für Dateien. Überschreibt das aktuelle Arbeitsverzeichnis. + + Write the public signing certificate as a DER-encoded CER file. + Write the public signing certificate as a DER-encoded CER file. + + Use Windows Certificate Store or a local certificate file. Verwenden Sie den Windows-Zertifikatspeicher oder eine lokale Zertifikatdatei. diff --git a/src/Sign.Cli/xlf/Resources.es.xlf b/src/Sign.Cli/xlf/Resources.es.xlf index 16b74a1e..c2b86566 100644 --- a/src/Sign.Cli/xlf/Resources.es.xlf +++ b/src/Sign.Cli/xlf/Resources.es.xlf @@ -12,6 +12,11 @@ Directorio base para los archivos. Invalida el directorio de trabajo actual. + + Write the public signing certificate as a DER-encoded CER file. + Write the public signing certificate as a DER-encoded CER file. + + Use Windows Certificate Store or a local certificate file. Use el Almacén de certificados de Windows o un archivo de certificados local. diff --git a/src/Sign.Cli/xlf/Resources.fr.xlf b/src/Sign.Cli/xlf/Resources.fr.xlf index 5dc6faf2..ef6ecbb6 100644 --- a/src/Sign.Cli/xlf/Resources.fr.xlf +++ b/src/Sign.Cli/xlf/Resources.fr.xlf @@ -12,6 +12,11 @@ Répertoire de base pour les fichiers. Remplace le répertoire de travail actuel. + + Write the public signing certificate as a DER-encoded CER file. + Write the public signing certificate as a DER-encoded CER file. + + Use Windows Certificate Store or a local certificate file. Utilisez le magasin de certificats Windows ou un fichier local du certificat. diff --git a/src/Sign.Cli/xlf/Resources.it.xlf b/src/Sign.Cli/xlf/Resources.it.xlf index 3fe69af9..3e6d184b 100644 --- a/src/Sign.Cli/xlf/Resources.it.xlf +++ b/src/Sign.Cli/xlf/Resources.it.xlf @@ -12,6 +12,11 @@ Directory di base per i file. Esegue l'override della directory di lavoro corrente. + + Write the public signing certificate as a DER-encoded CER file. + Write the public signing certificate as a DER-encoded CER file. + + Use Windows Certificate Store or a local certificate file. Utilizzare l'archivio certificati di Windows o un file di certificato locale. diff --git a/src/Sign.Cli/xlf/Resources.ja.xlf b/src/Sign.Cli/xlf/Resources.ja.xlf index 8a638867..9419f8e4 100644 --- a/src/Sign.Cli/xlf/Resources.ja.xlf +++ b/src/Sign.Cli/xlf/Resources.ja.xlf @@ -12,6 +12,11 @@ ファイルのベース ディレクトリ。 現在の作業ディレクトリをオーバーライドします。 + + Write the public signing certificate as a DER-encoded CER file. + Write the public signing certificate as a DER-encoded CER file. + + Use Windows Certificate Store or a local certificate file. Windows 証明書ストアまたはローカル証明書ファイルを使用します。 diff --git a/src/Sign.Cli/xlf/Resources.ko.xlf b/src/Sign.Cli/xlf/Resources.ko.xlf index 36e0896c..b29ddf64 100644 --- a/src/Sign.Cli/xlf/Resources.ko.xlf +++ b/src/Sign.Cli/xlf/Resources.ko.xlf @@ -12,6 +12,11 @@ 파일의 기본 디렉터리입니다. 현재 작업 디렉터리를 재정의합니다. + + Write the public signing certificate as a DER-encoded CER file. + Write the public signing certificate as a DER-encoded CER file. + + Use Windows Certificate Store or a local certificate file. Windows 인증서 저장소 또는 로컬 인증서 파일을 사용합니다. diff --git a/src/Sign.Cli/xlf/Resources.pl.xlf b/src/Sign.Cli/xlf/Resources.pl.xlf index 3e31c0b9..b5bd1af4 100644 --- a/src/Sign.Cli/xlf/Resources.pl.xlf +++ b/src/Sign.Cli/xlf/Resources.pl.xlf @@ -12,6 +12,11 @@ Katalog podstawowy dla plików. Zastępuje bieżący katalog roboczy. + + Write the public signing certificate as a DER-encoded CER file. + Write the public signing certificate as a DER-encoded CER file. + + Use Windows Certificate Store or a local certificate file. Użyj magazynu certyfikatów systemu Windows lub lokalnego pliku certyfikatów. diff --git a/src/Sign.Cli/xlf/Resources.pt-BR.xlf b/src/Sign.Cli/xlf/Resources.pt-BR.xlf index c9ca5376..cbe09067 100644 --- a/src/Sign.Cli/xlf/Resources.pt-BR.xlf +++ b/src/Sign.Cli/xlf/Resources.pt-BR.xlf @@ -12,6 +12,11 @@ Diretório base para arquivos. Substitui o diretório de trabalho atual. + + Write the public signing certificate as a DER-encoded CER file. + Write the public signing certificate as a DER-encoded CER file. + + Use Windows Certificate Store or a local certificate file. Use o Repositório de Certificados do Windows ou um arquivo de certificado local. diff --git a/src/Sign.Cli/xlf/Resources.ru.xlf b/src/Sign.Cli/xlf/Resources.ru.xlf index 9a24103c..4a45ec61 100644 --- a/src/Sign.Cli/xlf/Resources.ru.xlf +++ b/src/Sign.Cli/xlf/Resources.ru.xlf @@ -12,6 +12,11 @@ Базовый каталог для файлов. Переопределяет текущий рабочий каталог. + + Write the public signing certificate as a DER-encoded CER file. + Write the public signing certificate as a DER-encoded CER file. + + Use Windows Certificate Store or a local certificate file. Используйте хранилище сертификатов Windows или локальный файл сертификата. diff --git a/src/Sign.Cli/xlf/Resources.tr.xlf b/src/Sign.Cli/xlf/Resources.tr.xlf index 5689874b..764881d7 100644 --- a/src/Sign.Cli/xlf/Resources.tr.xlf +++ b/src/Sign.Cli/xlf/Resources.tr.xlf @@ -12,6 +12,11 @@ Dosyalar için temel dizin. Geçerli çalışma dizinini geçersiz kılar. + + Write the public signing certificate as a DER-encoded CER file. + Write the public signing certificate as a DER-encoded CER file. + + Use Windows Certificate Store or a local certificate file. Windows Sertifika Deposu veya yerel bir sertifika dosyası kullanın. diff --git a/src/Sign.Cli/xlf/Resources.zh-Hans.xlf b/src/Sign.Cli/xlf/Resources.zh-Hans.xlf index 47deaec0..48ad2e76 100644 --- a/src/Sign.Cli/xlf/Resources.zh-Hans.xlf +++ b/src/Sign.Cli/xlf/Resources.zh-Hans.xlf @@ -12,6 +12,11 @@ 文件的基目录。替代当前工作目录。 + + Write the public signing certificate as a DER-encoded CER file. + Write the public signing certificate as a DER-encoded CER file. + + Use Windows Certificate Store or a local certificate file. 使用 Windows 证书存储或本地证书文件。 diff --git a/src/Sign.Cli/xlf/Resources.zh-Hant.xlf b/src/Sign.Cli/xlf/Resources.zh-Hant.xlf index 0427c8c6..3394389e 100644 --- a/src/Sign.Cli/xlf/Resources.zh-Hant.xlf +++ b/src/Sign.Cli/xlf/Resources.zh-Hant.xlf @@ -12,6 +12,11 @@ 檔案的基底目錄。覆寫目前的工作目錄。 + + Write the public signing certificate as a DER-encoded CER file. + Write the public signing certificate as a DER-encoded CER file. + + Use Windows Certificate Store or a local certificate file. 使用 Windows 憑證存放區或本機憑證檔案。 diff --git a/test/Sign.Cli.Test/CodeCommandTests.cs b/test/Sign.Cli.Test/CodeCommandTests.cs index e7bf8d2b..3e30e087 100644 --- a/test/Sign.Cli.Test/CodeCommandTests.cs +++ b/test/Sign.Cli.Test/CodeCommandTests.cs @@ -3,6 +3,10 @@ // See the LICENSE.txt file in the project root for more information. using System.CommandLine; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Moq; +using Sign.Core; namespace Sign.Cli.Test { @@ -22,6 +26,47 @@ public void BaseDirectoryOption_Always_IsNotRequired() Assert.False(_command.BaseDirectoryOption.Required); } + [Fact] + public void CertificateOutputOption_Always_HasArityOfExactlyOne() + { + Assert.Equal(ArgumentArity.ExactlyOne, _command.CertificateOutputOption.Arity); + } + + [Fact] + public void CertificateOutputOption_Always_IsNotRequired() + { + Assert.False(_command.CertificateOutputOption.Required); + } + + [Fact] + public async Task ExportCertificateAsync_WritesThePublicCertificateAsCer() + { + using RSA key = RSA.Create(2048); + CertificateRequest request = new("CN=test", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using X509Certificate2 expectedCertificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddMinutes(1)); + Mock certificateProvider = new(); + certificateProvider.Setup(provider => provider.GetCertificateAsync(It.IsAny())) + .ReturnsAsync(new X509Certificate2(expectedCertificate.Export(X509ContentType.Cert))); + + string outputDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + FileInfo outputFile = new(Path.Combine(outputDirectory, "certificate.cer")); + + try + { + await CodeCommand.ExportCertificateAsync(certificateProvider.Object, outputFile, CancellationToken.None); + + using X509Certificate2 actualCertificate = new(outputFile.FullName); + Assert.Equal(expectedCertificate.Thumbprint, actualCertificate.Thumbprint); + } + finally + { + if (Directory.Exists(outputDirectory)) + { + Directory.Delete(outputDirectory, recursive: true); + } + } + } + [Fact] public void DescriptionOption_Always_HasArityOfExactlyOne() { diff --git a/test/Sign.Cli.Test/Options/CertificateOutputOptionTests.cs b/test/Sign.Cli.Test/Options/CertificateOutputOptionTests.cs new file mode 100644 index 00000000..60035e51 --- /dev/null +++ b/test/Sign.Cli.Test/Options/CertificateOutputOptionTests.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE.txt file in the project root for more information. + +namespace Sign.Cli.Test +{ + public class CertificateOutputOptionTests : OptionTests + { + private const string ExpectedValue = "certificate.cer"; + + public CertificateOutputOptionTests() + : base(new CodeCommand().CertificateOutputOption, "-co", "--certificate-output", ExpectedValue) + { + } + } +} diff --git a/test/Sign.Cli.Test/SignCommandTests.cs b/test/Sign.Cli.Test/SignCommandTests.cs index 502c2622..3b56cbb9 100644 --- a/test/Sign.Cli.Test/SignCommandTests.cs +++ b/test/Sign.Cli.Test/SignCommandTests.cs @@ -90,6 +90,7 @@ public void Command_WhenAllOptionsAndArgumentAreValid_HasNoError() Assert.Equal(DescriptionUrl, result.GetValue(_codeCommand.DescriptionUrlOption)!.OriginalString); Assert.Equal(KeyVaultUrl, result.GetValue(_azureKeyVaultCommand.UrlOption)!.OriginalString); Assert.Equal(CertificateName, result.GetValue(_azureKeyVaultCommand.CertificateOption)); + Assert.Null(result.GetValue(_codeCommand.CertificateOutputOption)); Assert.Equal(TimestampUrl, result.GetValue(_codeCommand.TimestampUrlOption)!.OriginalString); Assert.Equal([File], result.GetValue(_azureKeyVaultCommand.FilesArgument)); } From 21962561026f3fc8377deb422a1edc09827c0dc5 Mon Sep 17 00:00:00 2001 From: Jozef Izso Date: Sun, 5 Jul 2026 15:49:23 +0200 Subject: [PATCH 2/2] Write tests for `--certificate-output` argument --- test/Sign.Cli.Test/CodeCommandTests.cs | 128 +++++++++++++++++- .../TestInfrastructure/SignerSpy.cs | 4 +- 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/test/Sign.Cli.Test/CodeCommandTests.cs b/test/Sign.Cli.Test/CodeCommandTests.cs index 3e30e087..c966ad6c 100644 --- a/test/Sign.Cli.Test/CodeCommandTests.cs +++ b/test/Sign.Cli.Test/CodeCommandTests.cs @@ -5,6 +5,7 @@ using System.CommandLine; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.DependencyInjection; using Moq; using Sign.Core; @@ -46,7 +47,7 @@ public async Task ExportCertificateAsync_WritesThePublicCertificateAsCer() using X509Certificate2 expectedCertificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddMinutes(1)); Mock certificateProvider = new(); certificateProvider.Setup(provider => provider.GetCertificateAsync(It.IsAny())) - .ReturnsAsync(new X509Certificate2(expectedCertificate.Export(X509ContentType.Cert))); + .ReturnsAsync(new X509Certificate2(expectedCertificate.Export(X509ContentType.Pfx))); string outputDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); FileInfo outputFile = new(Path.Combine(outputDirectory, "certificate.cer")); @@ -57,6 +58,7 @@ public async Task ExportCertificateAsync_WritesThePublicCertificateAsCer() using X509Certificate2 actualCertificate = new(outputFile.FullName); Assert.Equal(expectedCertificate.Thumbprint, actualCertificate.Thumbprint); + Assert.False(actualCertificate.HasPrivateKey); } finally { @@ -67,6 +69,84 @@ public async Task ExportCertificateAsync_WritesThePublicCertificateAsCer() } } + [Fact] + public async Task HandleAsync_WhenSigningSucceeds_WritesCertificateToRelativeOutputPath() + { + using RSA key = RSA.Create(2048); + CertificateRequest request = new("CN=test", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using X509Certificate2 expectedCertificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddMinutes(1)); + Mock certificateProvider = new(); + certificateProvider.Setup(provider => provider.GetCertificateAsync(It.IsAny())) + .ReturnsAsync(new X509Certificate2(expectedCertificate.Export(X509ContentType.Pfx))); + SignerSpy signer = new(); + string outputRelativePath = Path.Combine("certificates", "signing.cer"); + + await WithTemporaryInputAsync(async (baseDirectory, inputFile) => + { + int exitCode = await HandleAsync(baseDirectory, inputFile, outputRelativePath, signer, certificateProvider.Object); + FileInfo outputFile = new(Path.Combine(baseDirectory.FullName, outputRelativePath)); + + Assert.Equal(ExitCode.Success, exitCode); + Assert.NotNull(signer.InputFiles); + Assert.True(outputFile.Exists); + + using X509Certificate2 actualCertificate = new(outputFile.FullName); + Assert.Equal(expectedCertificate.Thumbprint, actualCertificate.Thumbprint); + Assert.False(actualCertificate.HasPrivateKey); + }); + } + + [Fact] + public async Task HandleAsync_WhenSigningFails_DoesNotWriteCertificate() + { + Mock certificateProvider = new(); + SignerSpy signer = new(ExitCode.Failed); + string outputRelativePath = Path.Combine("certificates", "signing.cer"); + + await WithTemporaryInputAsync(async (baseDirectory, inputFile) => + { + int exitCode = await HandleAsync(baseDirectory, inputFile, outputRelativePath, signer, certificateProvider.Object); + FileInfo outputFile = new(Path.Combine(baseDirectory.FullName, outputRelativePath)); + + Assert.Equal(ExitCode.Failed, exitCode); + Assert.NotNull(signer.InputFiles); + Assert.False(outputFile.Exists); + certificateProvider.Verify( + provider => provider.GetCertificateAsync(It.IsAny()), + Times.Never); + }); + } + + [Fact] + public async Task HandleAsync_WhenCancelled_DoesNotSignOrWriteCertificate() + { + Mock certificateProvider = new(); + SignerSpy signer = new(); + string outputRelativePath = Path.Combine("certificates", "signing.cer"); + using CancellationTokenSource cancellationTokenSource = new(); + cancellationTokenSource.Cancel(); + + await WithTemporaryInputAsync(async (baseDirectory, inputFile) => + { + await Assert.ThrowsAnyAsync( + () => HandleAsync( + baseDirectory, + inputFile, + outputRelativePath, + signer, + certificateProvider.Object, + cancellationTokenSource.Token)); + + FileInfo outputFile = new(Path.Combine(baseDirectory.FullName, outputRelativePath)); + + Assert.Null(signer.InputFiles); + Assert.False(outputFile.Exists); + certificateProvider.Verify( + provider => provider.GetCertificateAsync(It.IsAny()), + Times.Never); + }); + } + [Fact] public void DescriptionOption_Always_HasArityOfExactlyOne() { @@ -186,5 +266,51 @@ public void VerbosityOption_Always_IsNotRequired() { Assert.False(_command.VerbosityOption.Required); } + + private async Task HandleAsync( + DirectoryInfo baseDirectory, + FileInfo inputFile, + string certificateOutput, + SignerSpy signer, + ICertificateProvider certificateProvider, + CancellationToken cancellationToken = default) + { + ServiceCollection services = new(); + services.AddSingleton(signer); + services.AddSingleton(certificateProvider); + + using var serviceProvider = services.BuildServiceProvider(); + TestServiceProviderFactory serviceProviderFactory = new(serviceProvider); + RootCommand rootCommand = new(); + rootCommand.Subcommands.Add(_command); + var parseResult = rootCommand.Parse( + $"code --base-directory \"{baseDirectory.FullName}\" --certificate-output \"{certificateOutput}\""); + + Assert.Empty(parseResult.Errors); + + return await _command.HandleAsync( + parseResult, + serviceProviderFactory, + Mock.Of(), + [inputFile.FullName], + cancellationToken); + } + + private static async Task WithTemporaryInputAsync(Func test) + { + DirectoryInfo baseDirectory = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); + FileInfo inputFile = new(Path.Combine(baseDirectory.FullName, "input.bin")); + + try + { + await File.WriteAllTextAsync(inputFile.FullName, "content"); + await test(baseDirectory, inputFile); + } + finally + { + baseDirectory.Delete(recursive: true); + } + } } } diff --git a/test/Sign.Cli.Test/TestInfrastructure/SignerSpy.cs b/test/Sign.Cli.Test/TestInfrastructure/SignerSpy.cs index ec234bf5..f000cd61 100644 --- a/test/Sign.Cli.Test/TestInfrastructure/SignerSpy.cs +++ b/test/Sign.Cli.Test/TestInfrastructure/SignerSpy.cs @@ -24,9 +24,9 @@ internal sealed class SignerSpy : ISigner internal HashAlgorithmName? TimestampHashAlgorithm { get; private set; } internal int ExitCode { get; } - internal SignerSpy() + internal SignerSpy(int exitCode = Core.ExitCode.Success) { - ExitCode = Core.ExitCode.Success; + ExitCode = exitCode; } public Task SignAsync(