From b7ca3890c679858f7d5c0c6c8a569c78077c98c3 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 9 Feb 2026 00:50:10 -0800 Subject: [PATCH 1/5] Add failing tests for unescaped Spectre markup in CLI output --- .../Interaction/ConsoleInteractionService.cs | 4 +- .../ConsoleInteractionServiceTests.cs | 85 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index 0e1bc787647..ceb6b022570 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); diff --git a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs index 3ee92c32353..af2ba1ab78e 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,88 @@ 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); + } } From a3131be90fd6cd6948a0c2c515b58f1284f45c8d Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 9 Feb 2026 08:59:06 -0800 Subject: [PATCH 2/5] Fix Spectre markup escaping bugs across CLI commands - ConsoleInteractionService: Escape appHostHostingVersion, RequiredCapability in DisplayIncompatibleVersionError, and newerVersion/updateCommand in DisplayVersionUpdateNotification - RunCommand: Remove double-escaping of ex.Message before DisplayError (lines 355,362,371), escape resource/endpoint names in Markup (line 313), escape log file paths in DisplayMessage (lines 366,375,850) - LogsCommand: Escape logLine.ResourceName in MarkupLine (line 337) - TelemetryLogsCommand/TelemetrySpansCommand: Escape resourceName in MarkupLine (lines 261,267) - Add tests: DisplayError double-escape verification, DisplayMessage with escaped/unescaped paths, DisplaySubtleMessage default escaping, choice prompt with bracket-containing Azure subscription names --- src/Aspire.Cli/Commands/LogsCommand.cs | 2 +- src/Aspire.Cli/Commands/RunCommand.cs | 14 +-- .../Commands/TelemetryLogsCommand.cs | 2 +- .../Commands/TelemetrySpansCommand.cs | 2 +- .../Interaction/ConsoleInteractionService.cs | 8 +- ...PublishCommandPromptingIntegrationTests.cs | 44 +++++++ .../ConsoleInteractionServiceTests.cs | 108 ++++++++++++++++++ 7 files changed, 166 insertions(+), 14 deletions(-) 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..40e3f10015c 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}]{endpoint.EscapeMarkup()}[/]") ); var endpointsPadder = new Padder(endpointsGrid, new Padding(3, 0)); @@ -352,27 +352,27 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } 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 ceb6b022570..62b14b3b91a 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -189,9 +189,9 @@ public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, stri DisplayError(InteractionServiceStrings.AppHostNotCompatibleConsiderUpgrading); Console.WriteLine(); _outConsole.MarkupLine( - $"\t[bold]{InteractionServiceStrings.AspireHostingSDKVersion}[/]: {appHostHostingVersion}"); + $"\t[bold]{InteractionServiceStrings.AspireHostingSDKVersion}[/]: {appHostHostingVersion.EscapeMarkup()}"); _outConsole.MarkupLine($"\t[bold]{InteractionServiceStrings.AspireCLIVersion}[/]: {cliInformationalVersion}"); - _outConsole.MarkupLine($"\t[bold]{InteractionServiceStrings.RequiredCapability}[/]: {ex.RequiredCapability}"); + _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/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index 02646fd80c4..5973b355cd4 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -762,6 +762,50 @@ public async Task PublishCommand_SingleInputPrompt_EscapesSpectreMarkupInLabels( Assert.Contains("[[1-10]]", promptCall.PromptText); Assert.Contains("[[required]]", promptCall.PromptText); } + + [Fact] + public async Task PublishCommand_ChoicePrompt_WithSquareBracketsInOptions_DoesNotThrow() + { + // Arrange - simulates Azure subscription names containing brackets like "[Prod]" + // This is the root cause of https://github.com/dotnet/aspire/issues/13955 + using var workspace = TemporaryWorkspace.Create(outputHelper); + var promptBackchannel = new TestPromptBackchannel(); + var consoleService = new TestConsoleInteractionServiceWithPromptTracking(); + + var options = new List> + { + new("sub-1", "DevDiv Test labs V2 [Prod] (00000000-0000-0000-0000-000000000001)"), + new("sub-2", "Azure SDK [Dev/Test] (00000000-0000-0000-0000-000000000002)"), + new("sub-3", "My Subscription (00000000-0000-0000-0000-000000000003)") + }; + promptBackchannel.AddPrompt("subscription-prompt", "Azure Subscription", InputTypes.Choice, "Select subscription:", isRequired: true, options: options); + + // Select the option with [Prod] in the name + consoleService.SetupSelectionResponse("DevDiv Test labs V2 [Prod] (00000000-0000-0000-0000-000000000001)"); + + 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 the correct subscription was selected and sent back + Assert.Single(promptBackchannel.CompletedPrompts); + var completedPrompt = promptBackchannel.CompletedPrompts[0]; + Assert.Equal("sub-1", completedPrompt.Answers[0].Value); + } } // 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 af2ba1ab78e..090fe5f1f35 100644 --- a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs @@ -439,4 +439,112 @@ public void DisplayVersionUpdateNotification_WithMarkupCharactersInVersion_DoesN 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); + } } From a3130b925cd67bfd0ed727482dfe4bcd28ea8288 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 9 Feb 2026 22:52:57 -0800 Subject: [PATCH 3/5] Fix Spectre markup escaping: escape link targets and CLI version string - RunCommand: escape endpoint URL in link= attribute (IPv6 [::1] URLs crash Spectre) - ConsoleInteractionService: escape cliInformationalVersion (brackets in build metadata crash Spectre) - Verified: LogsCommand double-escaping is NOT a bug (both approaches produce identical markup) --- src/Aspire.Cli/Commands/RunCommand.cs | 2 +- src/Aspire.Cli/Interaction/ConsoleInteractionService.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 40e3f10015c..cd524a05ba0 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.EscapeMarkup()}[/] [grey]has endpoint[/] [link={endpoint}]{endpoint.EscapeMarkup()}[/]") + new Markup($"[bold]{resource.EscapeMarkup()}[/] [grey]has endpoint[/] [link={endpoint.EscapeMarkup()}]{endpoint.EscapeMarkup()}[/]") ); var endpointsPadder = new Padder(endpointsGrid, new Padding(3, 0)); diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index 62b14b3b91a..4c7d8487ebd 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -190,7 +190,7 @@ public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, stri Console.WriteLine(); _outConsole.MarkupLine( $"\t[bold]{InteractionServiceStrings.AspireHostingSDKVersion}[/]: {appHostHostingVersion.EscapeMarkup()}"); - _outConsole.MarkupLine($"\t[bold]{InteractionServiceStrings.AspireCLIVersion}[/]: {cliInformationalVersion}"); + _outConsole.MarkupLine($"\t[bold]{InteractionServiceStrings.AspireCLIVersion}[/]: {cliInformationalVersion.EscapeMarkup()}"); _outConsole.MarkupLine($"\t[bold]{InteractionServiceStrings.RequiredCapability}[/]: {ex.RequiredCapability.EscapeMarkup()}"); Console.WriteLine(); return ExitCodeConstants.AppHostIncompatible; From 2d6ec8382839e5b0a9c8dda93087c17319fc4748 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 9 Feb 2026 22:57:47 -0800 Subject: [PATCH 4/5] Remove tests that don't exercise actual Spectre escaping --- ...PublishCommandPromptingIntegrationTests.cs | 86 ------------------- 1 file changed, 86 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index 5973b355cd4..0d07a8c2aa7 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -720,92 +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); - } - - [Fact] - public async Task PublishCommand_ChoicePrompt_WithSquareBracketsInOptions_DoesNotThrow() - { - // Arrange - simulates Azure subscription names containing brackets like "[Prod]" - // This is the root cause of https://github.com/dotnet/aspire/issues/13955 - using var workspace = TemporaryWorkspace.Create(outputHelper); - var promptBackchannel = new TestPromptBackchannel(); - var consoleService = new TestConsoleInteractionServiceWithPromptTracking(); - - var options = new List> - { - new("sub-1", "DevDiv Test labs V2 [Prod] (00000000-0000-0000-0000-000000000001)"), - new("sub-2", "Azure SDK [Dev/Test] (00000000-0000-0000-0000-000000000002)"), - new("sub-3", "My Subscription (00000000-0000-0000-0000-000000000003)") - }; - promptBackchannel.AddPrompt("subscription-prompt", "Azure Subscription", InputTypes.Choice, "Select subscription:", isRequired: true, options: options); - - // Select the option with [Prod] in the name - consoleService.SetupSelectionResponse("DevDiv Test labs V2 [Prod] (00000000-0000-0000-0000-000000000001)"); - - 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 the correct subscription was selected and sent back - Assert.Single(promptBackchannel.CompletedPrompts); - var completedPrompt = promptBackchannel.CompletedPrompts[0]; - Assert.Equal("sub-1", completedPrompt.Answers[0].Value); - } } // Test implementation of IAppHostCliBackchannel that simulates prompt interactions From 8a56cbc21bf16dc68c3863e8c58e93dcd043bfaa Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 9 Feb 2026 23:11:10 -0800 Subject: [PATCH 5/5] Fix DisplayIncompatibleVersionError showing capability name instead of hosting version --- src/Aspire.Cli/Backchannel/AppHostIncompatibleException.cs | 3 ++- src/Aspire.Cli/Commands/RunCommand.cs | 2 +- src/Aspire.Cli/Projects/DotNetAppHostProject.cs | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) 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/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index cd524a05ba0..e09d37da8f9 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -348,7 +348,7 @@ 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) { 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;