Skip to content

Commit 53635e7

Browse files
Copilotmitchdenny
andauthored
Fix CLI crash when user input contains Spectre markup characters (#12919)
* Initial plan * Fix CLI markup escaping in prompts to prevent crashes with special characters Co-authored-by: mitchdenny <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: mitchdenny <[email protected]>
1 parent 5d1ee9f commit 53635e7

File tree

5 files changed

+85
-10
lines changed

5 files changed

+85
-10
lines changed

src/Aspire.Cli/Commands/NewCommand.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,18 +243,22 @@ static string FormatPackageLabel((NuGetPackage Package, PackageChannel Channel)
243243

244244
public virtual async Task<string> PromptForOutputPath(string path, CancellationToken cancellationToken)
245245
{
246+
// Escape markup characters in the path to prevent Spectre.Console from trying to parse them as markup
247+
// when displaying it as the default value in the prompt
246248
return await interactionService.PromptForStringAsync(
247249
NewCommandStrings.EnterTheOutputPath,
248-
defaultValue: path,
250+
defaultValue: path.EscapeMarkup(),
249251
cancellationToken: cancellationToken
250252
);
251253
}
252254

253255
public virtual async Task<string> PromptForProjectNameAsync(string defaultName, CancellationToken cancellationToken)
254256
{
257+
// Escape markup characters in the default name to prevent Spectre.Console from trying to parse them as markup
258+
// when displaying it as the default value in the prompt
255259
return await interactionService.PromptForStringAsync(
256260
NewCommandStrings.EnterTheProjectName,
257-
defaultValue: defaultName,
261+
defaultValue: defaultName.EscapeMarkup(),
258262
validator: name => ProjectNameValidator.IsProjectNameValid(name)
259263
? ValidationResult.Success()
260264
: ValidationResult.Error(NewCommandStrings.InvalidProjectName),

src/Aspire.Cli/Commands/PipelineCommandBase.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -769,13 +769,13 @@ private async Task HandlePromptActivityAsync(PublishingActivity activity, IAppHo
769769
{
770770
InputType.Text => await InteractionService.PromptForStringAsync(
771771
promptText,
772-
defaultValue: input.Value,
772+
defaultValue: input.Value?.EscapeMarkup(),
773773
required: input.Required,
774774
cancellationToken: cancellationToken),
775775

776776
InputType.SecretText => await InteractionService.PromptForStringAsync(
777777
promptText,
778-
defaultValue: input.Value,
778+
defaultValue: input.Value?.EscapeMarkup(),
779779
isSecret: true,
780780
required: input.Required,
781781
cancellationToken: cancellationToken),
@@ -786,15 +786,15 @@ private async Task HandlePromptActivityAsync(PublishingActivity activity, IAppHo
786786

787787
InputType.Number => await HandleNumberInputAsync(input, promptText, cancellationToken),
788788

789-
_ => await InteractionService.PromptForStringAsync(promptText, defaultValue: input.Value, required: input.Required, cancellationToken: cancellationToken)
789+
_ => await InteractionService.PromptForStringAsync(promptText, defaultValue: input.Value?.EscapeMarkup(), required: input.Required, cancellationToken: cancellationToken)
790790
};
791791
}
792792

793793
private async Task<string?> HandleSelectInputAsync(PublishingPromptInput input, string promptText, CancellationToken cancellationToken)
794794
{
795795
if (input.Options is null || input.Options.Count == 0)
796796
{
797-
return await InteractionService.PromptForStringAsync(promptText, defaultValue: input.Value, required: input.Required, cancellationToken: cancellationToken);
797+
return await InteractionService.PromptForStringAsync(promptText, defaultValue: input.Value?.EscapeMarkup(), required: input.Required, cancellationToken: cancellationToken);
798798
}
799799

800800
// If AllowCustomChoice is enabled then add an "Other" option to the list.
@@ -816,7 +816,7 @@ private async Task HandlePromptActivityAsync(PublishingActivity activity, IAppHo
816816

817817
if (value == CustomChoiceValue)
818818
{
819-
return await InteractionService.PromptForStringAsync(promptText, defaultValue: input.Value, required: input.Required, cancellationToken: cancellationToken);
819+
return await InteractionService.PromptForStringAsync(promptText, defaultValue: input.Value?.EscapeMarkup(), required: input.Required, cancellationToken: cancellationToken);
820820
}
821821

822822
AnsiConsole.MarkupLine($"{promptText} {displayText.EscapeMarkup()}");
@@ -838,7 +838,7 @@ static ValidationResult Validator(string value)
838838

839839
return await InteractionService.PromptForStringAsync(
840840
promptText,
841-
defaultValue: input.Value,
841+
defaultValue: input.Value?.EscapeMarkup(),
842842
validator: Validator,
843843
required: input.Required,
844844
cancellationToken: cancellationToken);

src/Aspire.Cli/Projects/ProjectUpdater.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ public async Task<ProjectUpdateResult> UpdateProjectAsync(FileInfo projectFile,
108108

109109
var selectedPathForNewNuGetConfigFile = await interactionService.PromptForStringAsync(
110110
promptText: UpdateCommandStrings.WhichDirectoryNuGetConfigPrompt,
111-
defaultValue: recommendedNuGetConfigFileDirectory,
111+
defaultValue: recommendedNuGetConfigFileDirectory.EscapeMarkup(),
112112
validator: null,
113113
isSecret: false,
114114
required: true,

src/Aspire.Cli/Templating/DotNetTemplateFactory.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
using Aspire.Cli.Utils;
1414
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
1515
using Semver;
16+
using Spectre.Console;
1617

1718
namespace Aspire.Cli.Templating;
1819

@@ -448,7 +449,7 @@ private async Task<TemplateResult> ApplyTemplateAsync(CallbackTemplate template,
448449
// working directory, create one in the newly created project's output directory.
449450
await PromptToCreateOrUpdateNuGetConfigAsync(selectedTemplateDetails.Channel, outputPath, cancellationToken);
450451

451-
interactionService.DisplaySuccess(string.Format(CultureInfo.CurrentCulture, TemplatingStrings.ProjectCreatedSuccessfully, outputPath));
452+
interactionService.DisplaySuccess(string.Format(CultureInfo.CurrentCulture, TemplatingStrings.ProjectCreatedSuccessfully, outputPath.EscapeMarkup()));
452453

453454
return new TemplateResult(ExitCodeConstants.Success, outputPath);
454455
}

tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,76 @@ public async Task NewCommandPromptsForTemplateVersionBeforeTemplateOptions()
578578
$"Template version should be prompted before template options. Order: {string.Join(", ", operationOrder)}");
579579
}
580580
}
581+
582+
[Fact]
583+
public async Task NewCommandEscapesMarkupInProjectNameAndOutputPath()
584+
{
585+
// This test validates that project names containing Spectre markup characters
586+
// (like '[' and ']') are properly escaped when displayed as default values in prompts.
587+
// This prevents crashes when the markup parser encounters malformed markup.
588+
589+
var projectNameWithMarkup = "[27;5;13~"; // Example of input that could crash the markup parser
590+
var capturedProjectNameDefault = string.Empty;
591+
var capturedOutputPathDefault = string.Empty;
592+
593+
using var workspace = TemporaryWorkspace.Create(outputHelper);
594+
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
595+
{
596+
options.NewCommandPrompterFactory = (sp) =>
597+
{
598+
var interactionService = sp.GetRequiredService<IInteractionService>();
599+
var prompter = new TestNewCommandPrompter(interactionService);
600+
601+
// Simulate user entering a project name with markup characters
602+
prompter.PromptForProjectNameCallback = (defaultName) =>
603+
{
604+
capturedProjectNameDefault = defaultName;
605+
return projectNameWithMarkup;
606+
};
607+
608+
// Capture what default value is passed for the output path
609+
// The path passed to this callback is the unescaped version
610+
prompter.PromptForOutputPathCallback = (path) =>
611+
{
612+
capturedOutputPathDefault = path;
613+
// Return the path as-is - the escaping is handled internally by PromptForOutputPath
614+
return path;
615+
};
616+
617+
return prompter;
618+
};
619+
620+
options.DotNetCliRunnerFactory = (sp) =>
621+
{
622+
var runner = new TestDotNetCliRunner();
623+
runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
624+
{
625+
var package = new NuGetPackage()
626+
{
627+
Id = "Aspire.ProjectTemplates",
628+
Source = "nuget",
629+
Version = "9.2.0"
630+
};
631+
632+
return (0, new NuGetPackage[] { package });
633+
};
634+
635+
return runner;
636+
};
637+
});
638+
var provider = services.BuildServiceProvider();
639+
640+
var command = provider.GetRequiredService<RootCommand>();
641+
var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
642+
643+
var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout);
644+
Assert.Equal(0, exitCode);
645+
646+
// Verify that the default output path was derived from the project name with markup characters
647+
// The path parameter passed to the callback contains the unescaped markup characters
648+
var expectedPath = $"./[27;5;13~";
649+
Assert.Equal(expectedPath, capturedOutputPathDefault);
650+
}
581651
}
582652

583653
internal sealed class TestNewCommandPrompter(IInteractionService interactionService) : NewCommandPrompter(interactionService)

0 commit comments

Comments
 (0)