diff --git a/src/Aspire.Cli/Backchannel/AppHostIncompatibleException.cs b/src/Aspire.Cli/Backchannel/AppHostIncompatibleException.cs index 1487a81efd9..74afd98b29f 100644 --- a/src/Aspire.Cli/Backchannel/AppHostIncompatibleException.cs +++ b/src/Aspire.Cli/Backchannel/AppHostIncompatibleException.cs @@ -3,7 +3,8 @@ namespace Aspire.Cli.Backchannel; -internal class AppHostIncompatibleException(string message, string requiredCapability) : Exception(message) +internal class AppHostIncompatibleException(string message, string requiredCapability, string? aspireHostingVersion = null) : Exception(message) { public string RequiredCapability { get; } = requiredCapability; + public string? AspireHostingVersion { get; } = aspireHostingVersion; } diff --git a/src/Aspire.Cli/Commands/LogsCommand.cs b/src/Aspire.Cli/Commands/LogsCommand.cs index c06426e13f9..e4dd45dca33 100644 --- a/src/Aspire.Cli/Commands/LogsCommand.cs +++ b/src/Aspire.Cli/Commands/LogsCommand.cs @@ -334,7 +334,7 @@ private void OutputLogLine(ResourceLogLine logLine, OutputFormat format) // Colorized output: assign a consistent color to each resource var color = GetResourceColor(logLine.ResourceName); var escapedContent = logLine.Content.EscapeMarkup(); - AnsiConsole.MarkupLine($"[{color}][[{logLine.ResourceName}]][/] {escapedContent}"); + AnsiConsole.MarkupLine($"[{color}][[{logLine.ResourceName.EscapeMarkup()}]][/] {escapedContent}"); } else { diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index b5170cc3b60..e09d37da8f9 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -310,7 +310,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell endpointsGrid.AddRow( firstEndpoint ? new Align(new Markup($"[bold green]{endpointsLocalizedString}[/]:"), HorizontalAlignment.Right) : Text.Empty, - new Markup($"[bold]{resource}[/] [grey]has endpoint[/] [link={endpoint}]{endpoint}[/]") + new Markup($"[bold]{resource.EscapeMarkup()}[/] [grey]has endpoint[/] [link={endpoint.EscapeMarkup()}]{endpoint.EscapeMarkup()}[/]") ); var endpointsPadder = new Padder(endpointsGrid, new Padding(3, 0)); @@ -348,31 +348,31 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell catch (AppHostIncompatibleException ex) { Telemetry.RecordError(ex.Message, ex); - return InteractionService.DisplayIncompatibleVersionError(ex, ex.RequiredCapability); + return InteractionService.DisplayIncompatibleVersionError(ex, ex.AspireHostingVersion ?? ex.RequiredCapability); } catch (CertificateServiceException ex) { - var errorMessage = string.Format(CultureInfo.CurrentCulture, TemplatingStrings.CertificateTrustError, ex.Message.EscapeMarkup()); + var errorMessage = string.Format(CultureInfo.CurrentCulture, TemplatingStrings.CertificateTrustError, ex.Message); Telemetry.RecordError(errorMessage, ex); InteractionService.DisplayError(errorMessage); return ExitCodeConstants.FailedToTrustCertificates; } catch (FailedToConnectBackchannelConnection ex) { - var errorMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ErrorConnectingToAppHost, ex.Message.EscapeMarkup()); + var errorMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ErrorConnectingToAppHost, ex.Message); Telemetry.RecordError(errorMessage, ex); InteractionService.DisplayError(errorMessage); // Don't display raw output - it's already in the log file - InteractionService.DisplayMessage("page_facing_up", string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, ExecutionContext.LogFilePath)); + InteractionService.DisplayMessage("page_facing_up", string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, ExecutionContext.LogFilePath.EscapeMarkup())); return ExitCodeConstants.FailedToDotnetRunAppHost; } catch (Exception ex) { - var errorMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message.EscapeMarkup()); + var errorMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message); Telemetry.RecordError(errorMessage, ex); InteractionService.DisplayError(errorMessage); // Don't display raw output - it's already in the log file - InteractionService.DisplayMessage("page_facing_up", string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, ExecutionContext.LogFilePath)); + InteractionService.DisplayMessage("page_facing_up", string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, ExecutionContext.LogFilePath.EscapeMarkup())); return ExitCodeConstants.FailedToDotnetRunAppHost; } } @@ -850,7 +850,7 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? _interactionService.DisplayMessage("magnifying_glass_tilted_right", string.Format( CultureInfo.CurrentCulture, RunCommandStrings.CheckLogsForDetails, - _fileLoggerProvider.LogFilePath)); + _fileLoggerProvider.LogFilePath.EscapeMarkup())); return ExitCodeConstants.FailedToDotnetRunAppHost; } diff --git a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs index c363581dbb7..5312b6be831 100644 --- a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs @@ -258,6 +258,6 @@ private static void DisplayLogEntry(string resourceName, OtlpLogRecordJson log) var severityColor = TelemetryCommandHelpers.GetSeverityColor(log.SeverityNumber); var escapedBody = body.EscapeMarkup(); - AnsiConsole.MarkupLine($"[grey]{timestamp}[/] [{severityColor}]{severity,-5}[/] [cyan]{resourceName}[/] {escapedBody}"); + AnsiConsole.MarkupLine($"[grey]{timestamp}[/] [{severityColor}]{severity,-5}[/] [cyan]{resourceName.EscapeMarkup()}[/] {escapedBody}"); } } diff --git a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs index a82e6163976..ba29aa1f7fd 100644 --- a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs @@ -264,6 +264,6 @@ private static void DisplaySpanEntry(string resourceName, OtlpSpanJson span) var durationStr = TelemetryCommandHelpers.FormatDuration(duration); var escapedName = name.EscapeMarkup(); - AnsiConsole.MarkupLine($"[grey]{shortSpanId}[/] [cyan]{resourceName,-15}[/] [{statusColor}]{statusText}[/] [white]{durationStr,8}[/] {escapedName}"); + AnsiConsole.MarkupLine($"[grey]{shortSpanId}[/] [cyan]{resourceName.EscapeMarkup(),-15}[/] [{statusColor}]{statusText}[/] [white]{durationStr,8}[/] {escapedName}"); } } diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index 0e1bc787647..4c7d8487ebd 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -145,7 +145,7 @@ public async Task PromptForSelectionAsync(string promptText, IEnumerable() .Title(promptText) - .UseConverter(choiceFormatter) + .UseConverter(item => choiceFormatter(item).EscapeMarkup()) .AddChoices(choices) .PageSize(10) .EnableSearch(); @@ -174,7 +174,7 @@ public async Task> PromptForSelectionsAsync(string promptTex var prompt = new MultiSelectionPrompt() .Title(promptText) - .UseConverter(choiceFormatter) + .UseConverter(item => choiceFormatter(item).EscapeMarkup()) .AddChoices(choices) .PageSize(10); @@ -189,9 +189,9 @@ public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, stri DisplayError(InteractionServiceStrings.AppHostNotCompatibleConsiderUpgrading); Console.WriteLine(); _outConsole.MarkupLine( - $"\t[bold]{InteractionServiceStrings.AspireHostingSDKVersion}[/]: {appHostHostingVersion}"); - _outConsole.MarkupLine($"\t[bold]{InteractionServiceStrings.AspireCLIVersion}[/]: {cliInformationalVersion}"); - _outConsole.MarkupLine($"\t[bold]{InteractionServiceStrings.RequiredCapability}[/]: {ex.RequiredCapability}"); + $"\t[bold]{InteractionServiceStrings.AspireHostingSDKVersion}[/]: {appHostHostingVersion.EscapeMarkup()}"); + _outConsole.MarkupLine($"\t[bold]{InteractionServiceStrings.AspireCLIVersion}[/]: {cliInformationalVersion.EscapeMarkup()}"); + _outConsole.MarkupLine($"\t[bold]{InteractionServiceStrings.RequiredCapability}[/]: {ex.RequiredCapability.EscapeMarkup()}"); Console.WriteLine(); return ExitCodeConstants.AppHostIncompatible; } @@ -303,11 +303,11 @@ public void DisplayVersionUpdateNotification(string newerVersion, string? update { // Write to stderr to avoid corrupting stdout when JSON output is used _errorConsole.WriteLine(); - _errorConsole.MarkupLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NewCliVersionAvailable, newerVersion)); + _errorConsole.MarkupLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NewCliVersionAvailable, newerVersion.EscapeMarkup())); if (!string.IsNullOrEmpty(updateCommand)) { - _errorConsole.MarkupLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ToUpdateRunCommand, updateCommand)); + _errorConsole.MarkupLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ToUpdateRunCommand, updateCommand.EscapeMarkup())); } _errorConsole.MarkupLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.MoreInfoNewCliVersion, UpdateUrl)); diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index c2f96d8a5cd..f47686fa1a3 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -379,7 +379,8 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca { var exception = new AppHostIncompatibleException( $"The app host is not compatible. Aspire.Hosting version: {compatibilityCheck.AspireHostingVersion}", - "Aspire.Hosting"); + "Aspire.Hosting", + compatibilityCheck.AspireHostingVersion); // Signal the backchannel completion source so the caller doesn't wait forever context.BackchannelCompletionSource?.TrySetException(exception); throw exception; diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index 02646fd80c4..0d07a8c2aa7 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -720,48 +720,6 @@ public async Task PublishCommand_SingleInputPrompt_WhenStatusTextEqualsLabel_Sho // Should show: [bold]Environment Name[/] Assert.Equal("[bold]Environment Name[/]", promptCall.PromptText); } - - [Fact] - public async Task PublishCommand_SingleInputPrompt_EscapesSpectreMarkupInLabels() - { - // Arrange - using var workspace = TemporaryWorkspace.Create(outputHelper); - var promptBackchannel = new TestPromptBackchannel(); - var consoleService = new TestConsoleInteractionServiceWithPromptTracking(); - - // Set up a single-input prompt with Spectre markup characters in both StatusText and Label - promptBackchannel.AddPrompt("markup-prompt", "Value [required]", InputTypes.Text, "Enter value [1-10]", isRequired: true); - - // Set up the expected user response - consoleService.SetupStringPromptResponse("5"); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.ProjectLocatorFactory = (sp) => new TestProjectLocator(); - options.DotNetCliRunnerFactory = (sp) => CreateTestRunnerWithPromptBackchannel(promptBackchannel); - }); - - services.AddSingleton(consoleService); - - var serviceProvider = services.BuildServiceProvider(); - var command = serviceProvider.GetRequiredService(); - - // Act - var result = command.Parse("publish"); - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - // Assert - Assert.Equal(0, exitCode); - - // Verify that square brackets are properly escaped - var promptCalls = consoleService.StringPromptCalls; - Assert.Single(promptCalls); - var promptCall = promptCalls[0]; - - // Square brackets should be escaped to [[bracket]] - Assert.Contains("[[1-10]]", promptCall.PromptText); - Assert.Contains("[[required]]", promptCall.PromptText); - } } // Test implementation of IAppHostCliBackchannel that simulates prompt interactions diff --git a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs index 3ee92c32353..090fe5f1f35 100644 --- a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.InternalTesting; +using Aspire.Cli.Backchannel; using Aspire.Cli.Interaction; using Aspire.Cli.Resources; using Aspire.Cli.Utils; @@ -354,4 +355,196 @@ public void ShowStatus_NestedCall_DoesNotThrowException() Assert.Contains(outerStatusText, outputString); Assert.Contains(innerStatusText, outputString); } + + [Fact] + public void DisplayIncompatibleVersionError_WithMarkupCharactersInVersion_DoesNotThrow() + { + // Arrange + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + var interactionService = CreateInteractionService(console, executionContext); + + var ex = new AppHostIncompatibleException("Incompatible [version]", "capability [Prod]"); + + // Act - should not throw due to unescaped markup characters + var exception = Record.Exception(() => interactionService.DisplayIncompatibleVersionError(ex, "9.0.0-preview.1 [rc]")); + + // Assert + Assert.Null(exception); + var outputString = output.ToString(); + Assert.Contains("capability [Prod]", outputString); + Assert.Contains("9.0.0-preview.1 [rc]", outputString); + } + + [Fact] + public void DisplayMessage_WithMarkupCharactersInMessage_DoesNotThrow() + { + // Arrange + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + var interactionService = CreateInteractionService(console, executionContext); + + // DisplayMessage passes its message directly to MarkupLine. + // Callers that embed external data must escape it first. + var message = "See logs at C:\\Users\\test [Dev]\\logs\\aspire.log"; + + // Act - should not throw due to unescaped markup characters + var exception = Record.Exception(() => interactionService.DisplayMessage("page_facing_up", message.EscapeMarkup())); + + // Assert + Assert.Null(exception); + var outputString = output.ToString(); + Assert.Contains("C:\\Users\\test [Dev]\\logs\\aspire.log", outputString); + } + + [Fact] + public void DisplayVersionUpdateNotification_WithMarkupCharactersInVersion_DoesNotThrow() + { + // Arrange + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + var interactionService = CreateInteractionService(console, executionContext); + + // Version strings are unlikely to have brackets, but the method should handle it + var version = "13.2.0-preview [beta]"; + var updateCommand = "aspire update --channel [stable]"; + + // Act - should not throw due to unescaped markup characters + var exception = Record.Exception(() => interactionService.DisplayVersionUpdateNotification(version, updateCommand)); + + // Assert + Assert.Null(exception); + var outputString = output.ToString(); + Assert.Contains("13.2.0-preview [beta]", outputString); + Assert.Contains("aspire update --channel [stable]", outputString); + } + + [Fact] + public void DisplayError_WithMarkupCharactersInMessage_DoesNotDoubleEscape() + { + // Arrange - verifies that DisplayError escapes once (callers should NOT pre-escape) + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + var interactionService = CreateInteractionService(console, executionContext); + + // Error message with brackets (e.g., from an exception) + var errorMessage = "Failed to connect to service [Prod]: Connection refused "; + + // Act - should not throw + var exception = Record.Exception(() => interactionService.DisplayError(errorMessage)); + + // Assert + Assert.Null(exception); + var outputString = output.ToString(); + // Should contain the original text (not double-escaped like [[Prod]]) + Assert.Contains("[Prod]", outputString); + Assert.DoesNotContain("[[Prod]]", outputString); + } + + [Fact] + public void DisplayMessage_WithUnescapedLogFilePath_Throws() + { + // Arrange - verifies that DisplayMessage requires callers to escape external data + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + var interactionService = CreateInteractionService(console, executionContext); + + // Path with brackets that looks like Spectre markup if not escaped + var path = @"C:\Users\[Dev Team]\logs\aspire.log"; + + // Act - unescaped path should cause a Spectre markup error + var exception = Record.Exception(() => interactionService.DisplayMessage("page_facing_up", $"See logs at {path}")); + + // Assert - this should throw because [Dev Team] is interpreted as markup + Assert.NotNull(exception); + } + + [Fact] + public void DisplayMessage_WithEscapedLogFilePath_DoesNotThrow() + { + // Arrange - verifies that properly escaped paths work in DisplayMessage + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + var interactionService = CreateInteractionService(console, executionContext); + + // Path with brackets - properly escaped + var path = @"C:\Users\[Dev Team]\logs\aspire.log".EscapeMarkup(); + + // Act + var exception = Record.Exception(() => interactionService.DisplayMessage("page_facing_up", $"See logs at {path}")); + + // Assert + Assert.Null(exception); + var outputString = output.ToString(); + Assert.Contains(@"C:\Users\[Dev Team]\logs\aspire.log", outputString); + } + + [Fact] + public void DisplaySubtleMessage_WithMarkupCharacters_EscapesByDefault() + { + // Arrange - verifies that DisplaySubtleMessage escapes by default + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + var interactionService = CreateInteractionService(console, executionContext); + + // Message with all kinds of markup characters + var message = "Error in [Module]: value $.items[0] invalid"; + + // Act + var exception = Record.Exception(() => interactionService.DisplaySubtleMessage(message)); + + // Assert + Assert.Null(exception); + var outputString = output.ToString(); + Assert.Contains("[Module]", outputString); + } }