From eb02c3d534bb4651a4e691cf2a24b6fa1f087a85 Mon Sep 17 00:00:00 2001 From: Gary McDougall Date: Mon, 26 Sep 2022 11:34:41 -0700 Subject: [PATCH] Custom mask for secret (#970) * Masking Character added, not yet used. * Setting the masking character can be chained with other extensions. * Added string extension for masking, and replaced hardcoded asterisks. * Check if mask is null first. * Fixed Typo in previous test and added new test for custom masks. * Added tests for masking with null character * Added docs and example. * Adjusted extensions so that Mask is integrated into Secret extension. Updated Exampls and Tests accordingly --- docs/input/prompts/text.md | 17 +++++++++ examples/Console/Prompt/Program.cs | 28 +++++++++++++- .../Extensions/AnsiConsoleExtensions.Input.cs | 7 ++-- .../Extensions/StringExtensions.cs | 23 +++++++++++ src/Spectre.Console/Prompts/TextPrompt.cs | 20 +++++++--- .../Prompts/TextPromptExtensions.cs | 19 ++++++++++ ...DefaultValueCustomMask.Output.verified.txt | 1 + ...etDefaultValueNullMask.Output.verified.txt | 1 + .../Unit/Prompts/TextPromptTests.cs | 38 ++++++++++++++++++- 9 files changed, 142 insertions(+), 12 deletions(-) create mode 100644 test/Spectre.Console.Tests/Expectations/Prompts/Text/SecretDefaultValueCustomMask.Output.verified.txt create mode 100644 test/Spectre.Console.Tests/Expectations/Prompts/Text/SecretDefaultValueNullMask.Output.verified.txt diff --git a/docs/input/prompts/text.md b/docs/input/prompts/text.md index 91b202f..88ddd43 100644 --- a/docs/input/prompts/text.md +++ b/docs/input/prompts/text.md @@ -63,6 +63,23 @@ What's the secret number? _ ```text Enter password: ************_ +``` + +## Masks + + + + +```text +Enter password: ------------_ +``` + +You can utilize a null character to completely hide input. + + + +```text +Enter password: _ ``` ## Optional diff --git a/examples/Console/Prompt/Program.cs b/examples/Console/Prompt/Program.cs index 7428a20..07f01cc 100644 --- a/examples/Console/Prompt/Program.cs +++ b/examples/Console/Prompt/Program.cs @@ -33,7 +33,13 @@ namespace Prompt var age = AskAge(); WriteDivider("Secrets"); - var password = AskPassword(); + var password = AskPassword(); + + WriteDivider("Mask"); + var mask = AskPasswordWithCustomMask(); + + WriteDivider("Null Mask"); + var nullMask = AskPasswordWithNullMask(); WriteDivider("Optional"); var color = AskColor(); @@ -48,7 +54,9 @@ namespace Prompt .AddRow("[grey]Favorite fruit[/]", fruit) .AddRow("[grey]Favorite sport[/]", sport) .AddRow("[grey]Age[/]", age.ToString()) - .AddRow("[grey]Password[/]", password) + .AddRow("[grey]Password[/]", password) + .AddRow("[grey]Mask[/]", mask) + .AddRow("[grey]Null Mask[/]", nullMask) .AddRow("[grey]Favorite color[/]", string.IsNullOrEmpty(color) ? "Unknown" : color)); } @@ -145,6 +153,22 @@ namespace Prompt new TextPrompt("Enter [green]password[/]?") .PromptStyle("red") .Secret()); + } + + public static string AskPasswordWithCustomMask() + { + return AnsiConsole.Prompt( + new TextPrompt("Enter [green]password[/]?") + .PromptStyle("red") + .Secret('-')); + } + + public static string AskPasswordWithNullMask() + { + return AnsiConsole.Prompt( + new TextPrompt("Enter [green]password[/]?") + .PromptStyle("red") + .Secret(null)); } public static string AskColor() diff --git a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Input.cs b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Input.cs index c56e060..31f6f16 100644 --- a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Input.cs +++ b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Input.cs @@ -5,7 +5,7 @@ namespace Spectre.Console; /// public static partial class AnsiConsoleExtensions { - internal static async Task ReadLine(this IAnsiConsole console, Style? style, bool secret, IEnumerable? items = null, CancellationToken cancellationToken = default) + internal static async Task ReadLine(this IAnsiConsole console, Style? style, bool secret, char? mask, IEnumerable? items = null, CancellationToken cancellationToken = default) { if (console is null) { @@ -60,8 +60,9 @@ public static partial class AnsiConsoleExtensions if (!char.IsControl(key.KeyChar)) { - text += key.KeyChar.ToString(); - console.Write(secret ? "*" : key.KeyChar.ToString(), style); + text += key.KeyChar.ToString(); + var output = key.KeyChar.ToString(); + console.Write(secret ? output.Mask(mask) : output, style); } } } diff --git a/src/Spectre.Console/Extensions/StringExtensions.cs b/src/Spectre.Console/Extensions/StringExtensions.cs index a14dd00..e6485e9 100644 --- a/src/Spectre.Console/Extensions/StringExtensions.cs +++ b/src/Spectre.Console/Extensions/StringExtensions.cs @@ -185,5 +185,28 @@ public static class StringExtensions #else return text.Contains(value, StringComparison.Ordinal); #endif + } + + /// + /// "Masks" every character in a string. + /// + /// String value to mask. + /// Character to use for masking. + /// Masked string. + public static string Mask(this string value, char? mask) + { + var output = string.Empty; + + if (mask is null) + { + return output; + } + + foreach (var c in value) + { + output += mask; + } + + return output; } } \ No newline at end of file diff --git a/src/Spectre.Console/Prompts/TextPrompt.cs b/src/Spectre.Console/Prompts/TextPrompt.cs index 9f253bf..2bd4b64 100644 --- a/src/Spectre.Console/Prompts/TextPrompt.cs +++ b/src/Spectre.Console/Prompts/TextPrompt.cs @@ -28,7 +28,13 @@ public sealed class TextPrompt : IPrompt /// Gets or sets a value indicating whether input should /// be hidden in the console. /// - public bool IsSecret { get; set; } + public bool IsSecret { get; set; } + + /// + /// Gets or sets the character to use while masking + /// a secret prompt. + /// + public char? Mask { get; set; } = '*'; /// /// Gets or sets the validation error message. @@ -119,14 +125,15 @@ public sealed class TextPrompt : IPrompt while (true) { - var input = await console.ReadLine(promptStyle, IsSecret, choices, cancellationToken).ConfigureAwait(false); + var input = await console.ReadLine(promptStyle, IsSecret, Mask, choices, cancellationToken).ConfigureAwait(false); // Nothing entered? if (string.IsNullOrWhiteSpace(input)) { if (DefaultValue != null) - { - console.Write(IsSecret ? "******" : converter(DefaultValue.Value), promptStyle); + { + var defaultValue = converter(DefaultValue.Value); + console.Write(IsSecret ? defaultValue.Mask(Mask) : defaultValue, promptStyle); console.WriteLine(); return DefaultValue.Value; } @@ -201,13 +208,14 @@ public sealed class TextPrompt : IPrompt { appendSuffix = true; var converter = Converter ?? TypeConverterHelper.ConvertToString; - var defaultValueStyle = DefaultValueStyle?.ToMarkup() ?? "green"; + var defaultValueStyle = DefaultValueStyle?.ToMarkup() ?? "green"; + var defaultValue = converter(DefaultValue.Value); builder.AppendFormat( CultureInfo.InvariantCulture, " [{0}]({1})[/]", defaultValueStyle, - IsSecret ? "******" : converter(DefaultValue.Value)); + IsSecret ? defaultValue.Mask(Mask) : defaultValue); } var markup = builder.ToString().Trim(); diff --git a/src/Spectre.Console/Prompts/TextPromptExtensions.cs b/src/Spectre.Console/Prompts/TextPromptExtensions.cs index c44f427..ea023dd 100644 --- a/src/Spectre.Console/Prompts/TextPromptExtensions.cs +++ b/src/Spectre.Console/Prompts/TextPromptExtensions.cs @@ -286,6 +286,25 @@ public static class TextPromptExtensions obj.IsSecret = true; return obj; + } + + /// + /// Replaces prompt user input with mask in the console. + /// + /// The prompt type. + /// The prompt. + /// The masking character to use for the secret. + /// The same instance so that multiple calls can be chained. + public static TextPrompt Secret(this TextPrompt obj, char? mask) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.IsSecret = true; + obj.Mask = mask; + return obj; } /// diff --git a/test/Spectre.Console.Tests/Expectations/Prompts/Text/SecretDefaultValueCustomMask.Output.verified.txt b/test/Spectre.Console.Tests/Expectations/Prompts/Text/SecretDefaultValueCustomMask.Output.verified.txt new file mode 100644 index 0000000..f2f7a7a --- /dev/null +++ b/test/Spectre.Console.Tests/Expectations/Prompts/Text/SecretDefaultValueCustomMask.Output.verified.txt @@ -0,0 +1 @@ +Favorite fruit? (------): ------ diff --git a/test/Spectre.Console.Tests/Expectations/Prompts/Text/SecretDefaultValueNullMask.Output.verified.txt b/test/Spectre.Console.Tests/Expectations/Prompts/Text/SecretDefaultValueNullMask.Output.verified.txt new file mode 100644 index 0000000..f377b3b --- /dev/null +++ b/test/Spectre.Console.Tests/Expectations/Prompts/Text/SecretDefaultValueNullMask.Output.verified.txt @@ -0,0 +1 @@ +Favorite fruit? (): diff --git a/test/Spectre.Console.Tests/Unit/Prompts/TextPromptTests.cs b/test/Spectre.Console.Tests/Unit/Prompts/TextPromptTests.cs index 69d0770..734a4fd 100644 --- a/test/Spectre.Console.Tests/Unit/Prompts/TextPromptTests.cs +++ b/test/Spectre.Console.Tests/Unit/Prompts/TextPromptTests.cs @@ -233,7 +233,7 @@ public sealed class TextPromptTests [Fact] [Expectation("SecretDefaultValue")] - public Task Should_Chose_Masked_Default_Value_If_Nothing_Is_Entered_And_Prompt_Is_Secret() + public Task Should_Choose_Masked_Default_Value_If_Nothing_Is_Entered_And_Prompt_Is_Secret() { // Given var console = new TestConsole(); @@ -247,6 +247,42 @@ public sealed class TextPromptTests // Then return Verifier.Verify(console.Output); + } + + [Fact] + [Expectation("SecretDefaultValueCustomMask")] + public Task Should_Choose_Custom_Masked_Default_Value_If_Nothing_Is_Entered_And_Prompt_Is_Secret_And_Mask_Is_Custom() + { + // Given + var console = new TestConsole(); + console.Input.PushKey(ConsoleKey.Enter); + + // When + console.Prompt( + new TextPrompt("Favorite fruit?") + .Secret('-') + .DefaultValue("Banana")); + + // Then + return Verifier.Verify(console.Output); + } + + [Fact] + [Expectation("SecretDefaultValueNullMask")] + public Task Should_Choose_Empty_Masked_Default_Value_If_Nothing_Is_Entered_And_Prompt_Is_Secret_And_Mask_Is_Null() + { + // Given + var console = new TestConsole(); + console.Input.PushKey(ConsoleKey.Enter); + + // When + console.Prompt( + new TextPrompt("Favorite fruit?") + .Secret(null) + .DefaultValue("Banana")); + + // Then + return Verifier.Verify(console.Output); } [Fact]