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
This commit is contained in:
Gary McDougall 2022-09-26 11:34:41 -07:00 committed by GitHub
parent 088db165ed
commit eb02c3d534
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 142 additions and 12 deletions

View File

@ -65,6 +65,23 @@ What's the secret number? _
Enter password: ************_ Enter password: ************_
``` ```
## Masks
<?# Example symbol="M:Prompt.Program.AskPasswordWithCustomMask" project="Prompt" /?>
```text
Enter password: ------------_
```
You can utilize a null character to completely hide input.
<?# Example symbol="M:Prompt.Program.AskPasswordWithNullMask" project="Prompt" /?>
```text
Enter password: _
```
## Optional ## Optional
<?# Example symbol="M:Prompt.Program.AskColor" project="Prompt" /?> <?# Example symbol="M:Prompt.Program.AskColor" project="Prompt" /?>

View File

@ -35,6 +35,12 @@ namespace Prompt
WriteDivider("Secrets"); WriteDivider("Secrets");
var password = AskPassword(); var password = AskPassword();
WriteDivider("Mask");
var mask = AskPasswordWithCustomMask();
WriteDivider("Null Mask");
var nullMask = AskPasswordWithNullMask();
WriteDivider("Optional"); WriteDivider("Optional");
var color = AskColor(); var color = AskColor();
@ -49,6 +55,8 @@ namespace Prompt
.AddRow("[grey]Favorite sport[/]", sport) .AddRow("[grey]Favorite sport[/]", sport)
.AddRow("[grey]Age[/]", age.ToString()) .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)); .AddRow("[grey]Favorite color[/]", string.IsNullOrEmpty(color) ? "Unknown" : color));
} }
@ -147,6 +155,22 @@ namespace Prompt
.Secret()); .Secret());
} }
public static string AskPasswordWithCustomMask()
{
return AnsiConsole.Prompt(
new TextPrompt<string>("Enter [green]password[/]?")
.PromptStyle("red")
.Secret('-'));
}
public static string AskPasswordWithNullMask()
{
return AnsiConsole.Prompt(
new TextPrompt<string>("Enter [green]password[/]?")
.PromptStyle("red")
.Secret(null));
}
public static string AskColor() public static string AskColor()
{ {
return AnsiConsole.Prompt( return AnsiConsole.Prompt(

View File

@ -5,7 +5,7 @@ namespace Spectre.Console;
/// </summary> /// </summary>
public static partial class AnsiConsoleExtensions public static partial class AnsiConsoleExtensions
{ {
internal static async Task<string> ReadLine(this IAnsiConsole console, Style? style, bool secret, IEnumerable<string>? items = null, CancellationToken cancellationToken = default) internal static async Task<string> ReadLine(this IAnsiConsole console, Style? style, bool secret, char? mask, IEnumerable<string>? items = null, CancellationToken cancellationToken = default)
{ {
if (console is null) if (console is null)
{ {
@ -61,7 +61,8 @@ public static partial class AnsiConsoleExtensions
if (!char.IsControl(key.KeyChar)) if (!char.IsControl(key.KeyChar))
{ {
text += key.KeyChar.ToString(); text += key.KeyChar.ToString();
console.Write(secret ? "*" : key.KeyChar.ToString(), style); var output = key.KeyChar.ToString();
console.Write(secret ? output.Mask(mask) : output, style);
} }
} }
} }

View File

@ -186,4 +186,27 @@ public static class StringExtensions
return text.Contains(value, StringComparison.Ordinal); return text.Contains(value, StringComparison.Ordinal);
#endif #endif
} }
/// <summary>
/// "Masks" every character in a string.
/// </summary>
/// <param name="value">String value to mask.</param>
/// <param name="mask">Character to use for masking.</param>
/// <returns>Masked string.</returns>
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;
}
} }

View File

@ -30,6 +30,12 @@ public sealed class TextPrompt<T> : IPrompt<T>
/// </summary> /// </summary>
public bool IsSecret { get; set; } public bool IsSecret { get; set; }
/// <summary>
/// Gets or sets the character to use while masking
/// a secret prompt.
/// </summary>
public char? Mask { get; set; } = '*';
/// <summary> /// <summary>
/// Gets or sets the validation error message. /// Gets or sets the validation error message.
/// </summary> /// </summary>
@ -119,14 +125,15 @@ public sealed class TextPrompt<T> : IPrompt<T>
while (true) 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? // Nothing entered?
if (string.IsNullOrWhiteSpace(input)) if (string.IsNullOrWhiteSpace(input))
{ {
if (DefaultValue != null) 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(); console.WriteLine();
return DefaultValue.Value; return DefaultValue.Value;
} }
@ -202,12 +209,13 @@ public sealed class TextPrompt<T> : IPrompt<T>
appendSuffix = true; appendSuffix = true;
var converter = Converter ?? TypeConverterHelper.ConvertToString; var converter = Converter ?? TypeConverterHelper.ConvertToString;
var defaultValueStyle = DefaultValueStyle?.ToMarkup() ?? "green"; var defaultValueStyle = DefaultValueStyle?.ToMarkup() ?? "green";
var defaultValue = converter(DefaultValue.Value);
builder.AppendFormat( builder.AppendFormat(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
" [{0}]({1})[/]", " [{0}]({1})[/]",
defaultValueStyle, defaultValueStyle,
IsSecret ? "******" : converter(DefaultValue.Value)); IsSecret ? defaultValue.Mask(Mask) : defaultValue);
} }
var markup = builder.ToString().Trim(); var markup = builder.ToString().Trim();

View File

@ -288,6 +288,25 @@ public static class TextPromptExtensions
return obj; return obj;
} }
/// <summary>
/// Replaces prompt user input with mask in the console.
/// </summary>
/// <typeparam name="T">The prompt type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="mask">The masking character to use for the secret.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static TextPrompt<T> Secret<T>(this TextPrompt<T> obj, char? mask)
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}
obj.IsSecret = true;
obj.Mask = mask;
return obj;
}
/// <summary> /// <summary>
/// Sets the function to create a display string for a given choice. /// Sets the function to create a display string for a given choice.
/// </summary> /// </summary>

View File

@ -0,0 +1 @@
Favorite fruit? (------): ------

View File

@ -233,7 +233,7 @@ public sealed class TextPromptTests
[Fact] [Fact]
[Expectation("SecretDefaultValue")] [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 // Given
var console = new TestConsole(); var console = new TestConsole();
@ -249,6 +249,42 @@ public sealed class TextPromptTests
return Verifier.Verify(console.Output); 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<string>("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<string>("Favorite fruit?")
.Secret(null)
.DefaultValue("Banana"));
// Then
return Verifier.Verify(console.Output);
}
[Fact] [Fact]
[Expectation("NoSuffix")] [Expectation("NoSuffix")]
public Task Should_Not_Append_Questionmark_Or_Colon_If_No_Choices_Are_Set() public Task Should_Not_Append_Questionmark_Or_Colon_If_No_Choices_Are_Set()