diff --git a/DevProxy.Abstractions/Models/MockResponse.cs b/DevProxy.Abstractions/Models/MockResponse.cs index 5b11034b8..fd0c0e5e0 100644 --- a/DevProxy.Abstractions/Models/MockResponse.cs +++ b/DevProxy.Abstractions/Models/MockResponse.cs @@ -19,9 +19,17 @@ public object Clone() return JsonSerializer.Deserialize(json) ?? new MockResponse(); } - public static MockResponse FromHttpResponse(string httpResponse, ILogger logger) + public static MockResponse FromHttpResponse(string httpResponse, ILogger logger) => + FromHttpResponse(httpResponse, logger, out _); + + /// + /// Parses an HTTP response, reporting recoverable errors without changing the + /// generated mock's fallback headers or body. + /// + public static MockResponse FromHttpResponse(string httpResponse, ILogger logger, out bool hasErrors) { ArgumentNullException.ThrowIfNull(logger); + hasErrors = false; logger.LogTrace("{Method} called", nameof(FromHttpResponse)); @@ -77,6 +85,7 @@ public static MockResponse FromHttpResponse(string httpResponse, ILogger logger) catch (JsonException ex) { logger.LogError(ex, "Failed to deserialize JSON body from HTTP response"); + hasErrors = true; body = bodyContents; } } @@ -94,6 +103,7 @@ public static MockResponse FromHttpResponse(string httpResponse, ILogger logger) if (headerParts.Length < 2) { logger.LogError($"Invalid HTTP response header format"); + hasErrors = true; continue; } diff --git a/DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs b/DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs index 90c3dfd37..5bae4edbf 100644 --- a/DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs +++ b/DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs @@ -29,7 +29,8 @@ public interface IRootCertificateTrust /// /// Unconditionally installs into the OS trust /// store, bypassing the first-run/install policy. Used by the explicit - /// cert ensure command. Best-effort: failures are logged, not thrown. + /// cert ensure command. Installation failures are logged and thrown. + /// On platforms without automatic trust installation, logs manual instructions. /// void Trust(X509Certificate2 rootCertificate); diff --git a/DevProxy.Integration.Tests/CommandExitCodeTests.cs b/DevProxy.Integration.Tests/CommandExitCodeTests.cs new file mode 100644 index 000000000..fde028164 --- /dev/null +++ b/DevProxy.Integration.Tests/CommandExitCodeTests.cs @@ -0,0 +1,235 @@ +// 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 file in the project root for more information. + +using System.Diagnostics; +using System.Text.Json; +using Xunit; + +namespace DevProxy.Integration.Tests; + +/// +/// Spawns the real DevProxy host to verify that CLI commands return a non-zero +/// exit code when they fail, so scripts and agents can rely on it. +/// +[Collection("process-smoke")] +public sealed class CommandExitCodeTests +{ + private static readonly TimeSpan s_timeout = TimeSpan.FromSeconds(90); + + [Fact] + public async Task ConfigNew_Succeeds_ReturnsZero() + { + using var workDir = new TempDir(); + + var (exitCode, output) = await RunHostAsync(workDir.Path, "config", "new", "myconfig.json"); + + Assert.True(exitCode == 0, output); + Assert.True(File.Exists(Path.Combine(workDir.Path, "myconfig.json")), output); + } + + [Fact] + public async Task ConfigNew_Fails_ReturnsNonZero() + { + using var workDir = new TempDir(); + + var (exitCode, output) = await RunHostAsync(workDir.Path, "config", "new", Path.Combine("missing-folder", "myconfig.json")); + + Assert.True(exitCode != 0, output); + } + + [Fact] + public async Task ConfigGet_DownloadFails_ReturnsNonZero() + { + using var workDir = new TempDir(); + var configId = $"devproxy-exitcode-test-{Guid.NewGuid():N}"; + // Route the GitHub request through an unreachable proxy so the download fails without network access. + var env = new Dictionary + { + ["HTTPS_PROXY"] = "http://127.0.0.1:1", + ["HTTP_PROXY"] = "http://127.0.0.1:1", + ["NO_PROXY"] = "", + }; + + try + { + var (exitCode, output) = await RunHostAsync(workDir.Path, env, "config", "get", configId); + + Assert.True(exitCode != 0, output); + } + finally + { + var configsFolder = Path.Combine(DevProxy.Abstractions.Utils.ProxyUtils.DataFolder, "configs"); + if (Directory.Exists(configsFolder)) + { + foreach (var dir in Directory.GetDirectories(configsFolder, $"{configId}*")) + { + Directory.Delete(dir, recursive: true); + } + } + } + } + + [Fact] + public async Task MocksFromHttpResponses_Succeeds_ReturnsZero() + { + using var workDir = new TempDir(); + await File.WriteAllTextAsync(Path.Combine(workDir.Path, "devproxyrc.json"), ConfigFile); + await File.WriteAllTextAsync(Path.Combine(workDir.Path, "response.http"), "HTTP/1.1 200 OK\nContent-Type: text/plain\n\nhello"); + + var (exitCode, output) = await RunHostAsync(workDir.Path, "mocks", "from-http-responses", "*.http", "--mocks-file", "mocks.json"); + + Assert.True(exitCode == 0, output); + Assert.True(File.Exists(Path.Combine(workDir.Path, "mocks.json")), output); + } + + [Fact] + public async Task MocksFromHttpResponses_NoMatchingFiles_ReturnsNonZero() + { + using var workDir = new TempDir(); + await File.WriteAllTextAsync(Path.Combine(workDir.Path, "devproxyrc.json"), ConfigFile); + + var (exitCode, output) = await RunHostAsync(workDir.Path, "mocks", "from-http-responses", "*.http", "--mocks-file", "mocks.json"); + + Assert.True(exitCode != 0, output); + } + + [Fact] + public async Task MocksFromHttpResponses_InvalidFile_ReturnsNonZero() + { + using var workDir = new TempDir(); + await File.WriteAllTextAsync(Path.Combine(workDir.Path, "devproxyrc.json"), ConfigFile); + await File.WriteAllTextAsync(Path.Combine(workDir.Path, "response.http"), "not an http response"); + + var (exitCode, output) = await RunHostAsync(workDir.Path, "mocks", "from-http-responses", "*.http", "--mocks-file", "mocks.json"); + + Assert.True(exitCode != 0, output); + } + + [Theory] + [InlineData("HTTP/1.1 200 OK\nInvalidHeader\n\nhello", "hello")] + [InlineData("HTTP/1.1 200 OK\nContent-Type: application/json\n\n{invalid}", "{invalid}")] + public async Task MocksFromHttpResponses_LoggedParseError_ReturnsNonZeroAndPreservesMocks(string response, string body) + { + using var workDir = new TempDir(); + await File.WriteAllTextAsync(Path.Combine(workDir.Path, "devproxyrc.json"), ConfigFile); + await File.WriteAllTextAsync(Path.Combine(workDir.Path, "invalid.http"), response); + await File.WriteAllTextAsync(Path.Combine(workDir.Path, "valid.http"), "HTTP/1.1 201 Created\n\nvalid"); + + var (exitCode, output) = await RunHostAsync(workDir.Path, "mocks", "from-http-responses", "*.http", "--mocks-file", "mocks.json"); + + Assert.True(exitCode != 0, output); + using var mocks = JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(workDir.Path, "mocks.json"))); + var responses = mocks.RootElement.GetProperty("mocks").EnumerateArray() + .Select(mock => mock.GetProperty("response")).ToArray(); + Assert.Equal(2, responses.Length); + Assert.Contains(responses, response => response.GetProperty("statusCode").GetInt32() == 200 && + response.GetProperty("body").GetString() == body); + Assert.Contains(responses, response => response.GetProperty("statusCode").GetInt32() == 201 && + response.GetProperty("body").GetString() == "valid"); + } + + private static Task<(int ExitCode, string Output)> RunHostAsync(string workDir, params string[] args) => + RunHostAsync(workDir, new Dictionary(), args); + + private static async Task<(int ExitCode, string Output)> RunHostAsync(string workDir, Dictionary env, params string[] args) + { + var hostDll = LocateHostDll(); + Assert.True(File.Exists(hostDll), $"DevProxy host not built at {hostDll}. Run `dotnet build DevProxy` first."); + + var psi = new ProcessStartInfo + { + FileName = "dotnet", + WorkingDirectory = workDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + UseShellExecute = false, + }; + psi.ArgumentList.Add(hostDll); + foreach (var arg in args) + { + psi.ArgumentList.Add(arg); + } + foreach (var (key, value) in env) + { + psi.Environment[key] = value; + } + + using var process = new Process { StartInfo = psi }; + Assert.True(process.Start(), "Failed to start DevProxy host process."); + process.StandardInput.Close(); + + using var cts = new CancellationTokenSource(s_timeout); + var stdout = process.StandardOutput.ReadToEndAsync(cts.Token); + var stderr = process.StandardError.ReadToEndAsync(cts.Token); + try + { + await process.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) + { + process.Kill(entireProcessTree: true); + throw new TimeoutException($"DevProxy host did not exit within {s_timeout.TotalSeconds}s."); + } + + var output = $"STDOUT:\n{await stdout}\nSTDERR:\n{await stderr}"; + return (process.ExitCode, output); + } + + private static string LocateHostDll() + { + var config = +#if DEBUG + "Debug"; +#else + "Release"; +#endif + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + var candidate = Path.Combine(dir.FullName, "DevProxy", "bin", config, "net10.0", "DevProxy.dll"); + if (File.Exists(candidate)) + { + return candidate; + } + + dir = dir.Parent; + } + + return Path.Combine(AppContext.BaseDirectory, "DevProxy.dll"); + } + + private sealed class TempDir : IDisposable + { + private readonly DirectoryInfo _dir = Directory.CreateTempSubdirectory("devproxy-exitcode-"); + + public string Path => _dir.FullName; + + public void Dispose() + { + try + { + _dir.Delete(recursive: true); + } + catch (IOException) + { + } + } + } + + private const string ConfigFile = """ + { + "plugins": [ + { + "name": "MockResponsePlugin", + "enabled": true, + "pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll" + } + ], + "urlsToWatch": [ + "https://api.contoso.local/*" + ] + } + """; +} diff --git a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs index b0f1755fa..80f9fa853 100644 --- a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs +++ b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs @@ -425,7 +425,7 @@ private void ProcessMockResponseInternal(ProxyRequestArgs e, MockResponse matchi Logger.LogRequest($"{matchingResponse.Response?.StatusCode ?? 200} {matchingResponse.Request?.Url}", MessageType.Mocked, new LoggingContext(e.ProxySession)); } - private async Task GenerateMocksFromHttpResponsesAsync(ParseResult parseResult) + private async Task GenerateMocksFromHttpResponsesAsync(ParseResult parseResult) { Logger.LogTrace("{Method} called", nameof(GenerateMocksFromHttpResponsesAsync)); @@ -442,14 +442,14 @@ private async Task GenerateMocksFromHttpResponsesAsync(ParseResult parseResult) if (string.IsNullOrEmpty(outputFilePath)) { Logger.LogError("No output file path provided for mock responses."); - return; + return 1; } var httpResponseFiles = parseResult.GetValue(_httpResponseFilesArgument); if (httpResponseFiles is null || !httpResponseFiles.Any()) { Logger.LogError("No HTTP response files provided."); - return; + return 1; } var matcher = new Matcher(); @@ -459,7 +459,7 @@ private async Task GenerateMocksFromHttpResponsesAsync(ParseResult parseResult) if (!matchingFiles.Any()) { Logger.LogError("No matching HTTP response files found."); - return; + return 1; } if (Logger.IsEnabled(LogLevel.Information)) @@ -472,16 +472,19 @@ private async Task GenerateMocksFromHttpResponsesAsync(ParseResult parseResult) } var mockResponses = new List(); + var hasErrors = false; foreach (var file in matchingFiles) { Logger.LogInformation("Processing file: {File}", Path.GetRelativePath(".", file)); try { - mockResponses.Add(MockResponse.FromHttpResponse(await File.ReadAllTextAsync(file), Logger)); + mockResponses.Add(MockResponse.FromHttpResponse(await File.ReadAllTextAsync(file), Logger, out var hasParseErrors)); + hasErrors |= hasParseErrors; } catch (Exception ex) { Logger.LogError(ex, "Error processing file {File}", file); + hasErrors = true; continue; } } @@ -498,6 +501,7 @@ await File.WriteAllTextAsync( Logger.LogInformation("Generated mock responses saved to {OutputFile}", outputFilePath); Logger.LogTrace("Left {Method}", nameof(GenerateMocksFromHttpResponsesAsync)); + return hasErrors ? 1 : 0; } private static bool HasMatchingBody(MockResponse mockResponse, IHttpRequest request) diff --git a/DevProxy.Tests/CertCommandTests.cs b/DevProxy.Tests/CertCommandTests.cs new file mode 100644 index 000000000..06cea4a98 --- /dev/null +++ b/DevProxy.Tests/CertCommandTests.cs @@ -0,0 +1,111 @@ +// 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 file in the project root for more information. + +using System.CommandLine; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using DevProxy.Abstractions.Proxy; +using DevProxy.Commands; +using DevProxy.Proxy; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace DevProxy.Tests; + +public class CertCommandTests +{ + [Theory] + [InlineData(false, 0)] + [InlineData(true, 1)] + public void Ensure_ReportsTrustResult(bool fail, int expectedExitCode) + { + using var certificate = CreateCertificate(); + var trust = new TestCertificateTrust(fail); + var command = new CertCommand(NullLogger.Instance, certificate, trust); + + Assert.Equal(expectedExitCode, command.Parse("ensure").Invoke()); + Assert.True(trust.TrustCalled); + } + + [Fact] + public void Ensure_ExportFailure_ReturnsNonZeroOnSupportedPlatforms() + { + if (!OperatingSystem.IsMacOS() && !OperatingSystem.IsWindows()) + { + return; + } + + using var certificate = CreateCertificate(); + certificate.Dispose(); + var trust = new RootCertificateTrust(new FakeProxyConfiguration(), NullLogger.Instance); + var command = new CertCommand(NullLogger.Instance, certificate, trust); + + Assert.Equal(1, command.Parse("ensure").Invoke()); + } + + [Fact] + public void MacTrust_ExportFailure_ReturnsFalseWithoutThrowing() + { + using var certificate = CreateCertificate(); + certificate.Dispose(); + + Assert.False(MacCertificateHelper.TrustCertificate(certificate, NullLogger.Instance)); + } + + [Fact] + public void MacSecurityCommand_NonZeroExit_ReturnsFalseWithoutThrowing() + { + if (!OperatingSystem.IsMacOS()) + { + return; + } + + Assert.False(MacCertificateHelper.RunSecurityCommand( + "devproxy-invalid-command", "test", NullLogger.Instance)); + } + + [Fact] + public void EnsureTrusted_WindowsInstallFailure_DoesNotThrow() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var certificate = CreateCertificate(); + certificate.Dispose(); + var trust = new RootCertificateTrust( + new FakeProxyConfiguration { InstallCert = true }, + NullLogger.Instance); + + trust.EnsureTrusted(certificate); + } + + private static X509Certificate2 CreateCertificate() + { + using var key = RSA.Create(2048); + var request = new CertificateRequest("CN=DevProxy test", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddMinutes(1)); + } + + private sealed class TestCertificateTrust(bool fail) : IRootCertificateTrust + { + public bool TrustCalled { get; private set; } + + public void EnsureTrusted(X509Certificate2 rootCertificate) => + throw new NotSupportedException(); + + public void Trust(X509Certificate2 rootCertificate) + { + TrustCalled = true; + if (fail) + { + throw new InvalidOperationException("Certificate installation failed."); + } + } + + public void Untrust(X509Certificate2 rootCertificate) => + throw new NotSupportedException(); + } +} diff --git a/DevProxy/Commands/CertCommand.cs b/DevProxy/Commands/CertCommand.cs index 4c2a4e16d..a4b0f3335 100644 --- a/DevProxy/Commands/CertCommand.cs +++ b/DevProxy/Commands/CertCommand.cs @@ -36,7 +36,7 @@ public CertCommand( private void ConfigureCommand() { var certEnsureCommand = new Command("ensure", "Ensure certificates are setup (creates root if required). Also makes root certificate trusted."); - certEnsureCommand.SetAction(async _ => await EnsureCertAsync()); + certEnsureCommand.SetAction(_ => EnsureCert()); var certRemoveCommand = new Command("remove", "Remove the certificate from Root Store"); certRemoveCommand.SetAction(RemoveCert); @@ -54,9 +54,9 @@ private void ConfigureCommand() ]); } - private Task EnsureCertAsync() + private int EnsureCert() { - _logger.LogTrace("EnsureCertAsync() called"); + _logger.LogTrace("EnsureCert() called"); try { @@ -64,14 +64,17 @@ private Task EnsureCertAsync() _logger.LogInformation("Ensuring certificate exists and is trusted..."); _rootCertificateTrust.Trust(_rootCertificate); _logger.LogInformation("DONE"); + return 0; } catch (Exception ex) { _logger.LogError(ex, "Error ensuring certificate"); + return 1; + } + finally + { + _logger.LogTrace("EnsureCert() finished"); } - - _logger.LogTrace("EnsureCertAsync() finished"); - return Task.CompletedTask; } public int RemoveCert(ParseResult parseResult) diff --git a/DevProxy/Commands/ConfigCommand.cs b/DevProxy/Commands/ConfigCommand.cs index 69b0052fd..1ae18b030 100644 --- a/DevProxy/Commands/ConfigCommand.cs +++ b/DevProxy/Commands/ConfigCommand.cs @@ -137,10 +137,7 @@ private void ConfigureCommand() { var configId = parseResult.GetValue(configIdArgument); var outputFormat = parseResult.GetValueOrDefault(DevProxyCommand.OutputOptionName) ?? OutputFormat.Text; - if (configId != null) - { - await DownloadConfigAsync(configId, outputFormat); - } + return configId is null ? 1 : await DownloadConfigAsync(configId, outputFormat); }); var configNewCommand = new Command("new", "Create new Dev Proxy configuration file"); @@ -160,7 +157,7 @@ private void ConfigureCommand() var format = parseResult.GetValue(formatOption); var name = parseResult.GetValue(nameArgument) ?? GetDefaultConfigFileName(format); var outputFormat = parseResult.GetValueOrDefault(DevProxyCommand.OutputOptionName) ?? OutputFormat.Text; - await CreateConfigFileAsync(name, format, outputFormat); + return await CreateConfigFileAsync(name, format, outputFormat); }); var configOpenCommand = new Command("open", "Open devproxyrc.json"); @@ -205,7 +202,7 @@ private void ConfigureCommand() ]); } - private async Task DownloadConfigAsync(string configId, OutputFormat outputFormat) + private async Task DownloadConfigAsync(string configId, OutputFormat outputFormat) { try { @@ -237,7 +234,7 @@ private async Task DownloadConfigAsync(string configId, OutputFormat outputForma { _logger.LogError("Config {ConfigId} not found in the samples repo", configId); } - return; + return 1; } foreach (var sampleFile in sampleFiles) { @@ -256,14 +253,14 @@ private async Task DownloadConfigAsync(string configId, OutputFormat outputForma mockFiles = configInfo.MockFiles }, ProxyUtils.JsonSerializerOptions); _logger.LogStructuredOutput(json); - return; + return 0; } _logger.LogInformation("Config saved in {TargetFolderPath}\r\n", targetFolderPath); if (!configInfo.ConfigFiles.Any() && !configInfo.MockFiles.Any()) { - return; + return 0; } if (configInfo.ConfigFiles.Any()) @@ -288,6 +285,8 @@ private async Task DownloadConfigAsync(string configId, OutputFormat outputForma } } } + + return 0; } catch (Exception ex) { @@ -295,6 +294,7 @@ private async Task DownloadConfigAsync(string configId, OutputFormat outputForma { _logger.LogError(ex, "Error downloading config"); } + return 1; } } @@ -441,7 +441,7 @@ private async Task DownloadFileAsync(string filePath, string targetFolderPath, s } } - private async Task CreateConfigFileAsync(string name, ConfigFileFormat? format, OutputFormat outputFormat) + private async Task CreateConfigFileAsync(string name, ConfigFileFormat? format, OutputFormat outputFormat) { try { @@ -466,6 +466,7 @@ private async Task CreateConfigFileAsync(string name, ConfigFileFormat? format, { _logger.LogInformation("Config file created at {TargetFileName}", targetFileName); } + return 0; } catch (Exception ex) { @@ -473,6 +474,7 @@ private async Task CreateConfigFileAsync(string name, ConfigFileFormat? format, { _logger.LogError(ex, "Error creating config"); } + return 1; } } diff --git a/DevProxy/Proxy/MacCertificateHelper.cs b/DevProxy/Proxy/MacCertificateHelper.cs index 34e6b9b9c..39e28a5af 100644 --- a/DevProxy/Proxy/MacCertificateHelper.cs +++ b/DevProxy/Proxy/MacCertificateHelper.cs @@ -9,12 +9,12 @@ namespace DevProxy.Proxy; internal static class MacCertificateHelper { - internal static void TrustCertificate(X509Certificate2 certificate, ILogger logger) + internal static bool TrustCertificate(X509Certificate2 certificate, ILogger logger) { var pemFilePath = ExportCertificateToPem(certificate, logger); if (pemFilePath is null) { - return; + return false; } try @@ -22,7 +22,7 @@ internal static void TrustCertificate(X509Certificate2 certificate, ILogger logg var keychainPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Keychains", "login.keychain-db"); - RunSecurityCommand( + return RunSecurityCommand( $"add-trusted-cert -r trustRoot -k \"{keychainPath}\" \"{pemFilePath}\"", "trust", logger); @@ -77,7 +77,7 @@ internal static void RemoveTrustedCertificate(X509Certificate2 certificate, ILog } } - private static void RunSecurityCommand(string arguments, string action, ILogger logger) + internal static bool RunSecurityCommand(string arguments, string action, ILogger logger) { var startInfo = new ProcessStartInfo { @@ -96,10 +96,12 @@ private static void RunSecurityCommand(string arguments, string action, ILogger if (process.ExitCode != 0) { logger.LogError("Failed to {Action} certificate: {Error}", action, stderr); + return false; } else { logger.LogInformation("Successfully completed {Action} certificate operation.", action); + return true; } } diff --git a/DevProxy/Proxy/RootCertificateTrust.cs b/DevProxy/Proxy/RootCertificateTrust.cs index d3db001a1..5693bf8aa 100644 --- a/DevProxy/Proxy/RootCertificateTrust.cs +++ b/DevProxy/Proxy/RootCertificateTrust.cs @@ -50,8 +50,10 @@ public void EnsureTrusted(X509Certificate2 rootCertificate) switch (action) { case RootTrustAction.TrustMacKeychain: - MacCertificateHelper.TrustCertificate(rootCertificate, logger); - logger.LogInformation("Certificate trusted successfully."); + if (MacCertificateHelper.TrustCertificate(rootCertificate, logger)) + { + logger.LogInformation("Certificate trusted successfully."); + } break; case RootTrustAction.TrustWindowsStore: @@ -75,12 +77,18 @@ public void Trust(X509Certificate2 rootCertificate) if (OperatingSystem.IsMacOS()) { - MacCertificateHelper.TrustCertificate(rootCertificate, logger); + if (!MacCertificateHelper.TrustCertificate(rootCertificate, logger)) + { + throw new InvalidOperationException("Failed to trust the root certificate in the macOS keychain."); + } logger.LogInformation("Certificate trusted successfully."); } else if (OperatingSystem.IsWindows()) { - InstallIntoWindowsRootStore(rootCertificate); + if (!InstallIntoWindowsRootStore(rootCertificate)) + { + throw new InvalidOperationException("Failed to install the root certificate into the Windows root store."); + } } else { @@ -124,11 +132,11 @@ public void Untrust(X509Certificate2 rootCertificate) return Console.ReadLine()?.Trim(); } - private void InstallIntoWindowsRootStore(X509Certificate2 rootCertificate) + private bool InstallIntoWindowsRootStore(X509Certificate2 rootCertificate) { if (!OperatingSystem.IsWindows()) { - return; + return false; } try @@ -147,10 +155,12 @@ private void InstallIntoWindowsRootStore(X509Certificate2 rootCertificate) #pragma warning restore CA5380 logger.LogInformation("Certificate installed into the current user's root store."); } + return true; } catch (Exception ex) when (ex is System.Security.Cryptography.CryptographicException or IOException or UnauthorizedAccessException) { logger.LogError(ex, "Failed to install the root certificate into the Windows root store."); + return false; } }