Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom mask for secret #970

Merged
merged 8 commits into from
Sep 26, 2022
17 changes: 17 additions & 0 deletions docs/input/prompts/text.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,23 @@ What's the secret number? _

```text
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
Expand Down
28 changes: 26 additions & 2 deletions examples/Console/Prompt/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@ public static void Main(string[] args)
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();
Expand All @@ -48,7 +54,9 @@ public static void Main(string[] args)
.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));
}

Expand Down Expand Up @@ -145,6 +153,22 @@ public static string AskPassword()
new TextPrompt<string>("Enter [green]password[/]?")
.PromptStyle("red")
.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()
Expand Down
7 changes: 4 additions & 3 deletions src/Spectre.Console/Extensions/AnsiConsoleExtensions.Input.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace Spectre.Console;
/// </summary>
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)
{
Expand Down Expand Up @@ -57,8 +57,9 @@ internal static async Task<string> ReadLine(this IAnsiConsole console, Style? st

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);
}
}
}
Expand Down
23 changes: 23 additions & 0 deletions src/Spectre.Console/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -185,5 +185,28 @@ internal static bool ContainsExact(this string text, string value)
#else
return text.Contains(value, StringComparison.Ordinal);
#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;
}
}
20 changes: 14 additions & 6 deletions src/Spectre.Console/Prompts/TextPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@ public sealed class TextPrompt<T> : IPrompt<T>
/// Gets or sets a value indicating whether input should
/// be hidden in the console.
/// </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>
/// Gets or sets the validation error message.
Expand Down Expand Up @@ -119,14 +125,15 @@ public async Task<T> ShowAsync(IAnsiConsole console, CancellationToken cancellat

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;
}
Expand Down Expand Up @@ -201,13 +208,14 @@ private void WritePrompt(IAnsiConsole console)
{
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();
Expand Down
19 changes: 19 additions & 0 deletions src/Spectre.Console/Prompts/TextPromptExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,25 @@ public static TextPrompt<T> Secret<T>(this TextPrompt<T> obj)

obj.IsSecret = true;
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>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Favorite fruit? (------): ------
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Favorite fruit? ():
38 changes: 37 additions & 1 deletion test/Spectre.Console.Tests/Unit/Prompts/TextPromptTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ public Task Should_Use_Custom_Converter()

[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();
Expand All @@ -223,6 +223,42 @@ public Task Should_Chose_Masked_Default_Value_If_Nothing_Is_Entered_And_Prompt_I

// 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<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]
Expand Down