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

Support echo and prompt for an interactive option #1284

Merged
merged 1 commit into from
Dec 14, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package picocli.examples.interactive;

import picocli.CommandLine;
import picocli.CommandLine.Parameters;

public class UserPasswordDemo implements Runnable {

@Parameters(index = "0", description = {"User"}, interactive = true, echo = true, prompt = "Enter your user: ")
String user;

@Parameters(index = "1", description = {"Password"}, interactive = true, prompt = "Enter your password: ")
String password;

@Parameters(index = "2", description = {"Action"})
String action;

public void run() {
// See also PasswordDemo
login(user, password);
doAction(action);
}

private void login(String user, String pwd) {
System.out.printf("User: %s, Password: %s%n", user, pwd);
}

private void doAction(String action) {
System.out.printf("Action: %s%n", action);
}

public static void main(String[] args) {
new CommandLine(new UserPasswordDemo()).execute(args);
}
}
158 changes: 125 additions & 33 deletions src/main/java/picocli/CommandLine.java
Original file line number Diff line number Diff line change
Expand Up @@ -3870,7 +3870,7 @@ public enum ScopeType {
/**
* Set {@code interactive=true} if this option will prompt the end user for a value (like a password).
* Only supported for single-value options and {@code char[]} arrays (no collections, maps or other array types).
* When running on Java 6 or greater, this will use the {@link Console#readPassword()} API to get a value without echoing input to the console.
* When running on Java 6 or greater and echo attribute is false, this will use the {@link Console#readPassword()} API to get a value without echoing input to the console.
* <p>
* Best security practice is to use type {@code char[]} instead of {@code String}, and to to null out the array after use.
* </p><p>
Expand All @@ -3883,6 +3883,22 @@ public enum ScopeType {
*/
boolean interactive() default false;

/**
* Use this attribute to control a user input is echo-ed to the console or not. If {@code echo=true}, a input is echo-ed to the console.
* This attribute is ignored when interactive attribute is false.
* @return whether the user input is echo-ed to the console or not
* @since 4.X
*/
boolean echo() default false;

/**
* Use this attribute to customize the text displayed to the end user for an interactive option.
* This attribute is ignored when interactive attribute is false.
* @return text will be displayed to the end user
* @since 4.X
*/
String prompt() default "";

/** ResourceBundle key for this option. If not specified, (and a ResourceBundle {@linkplain Command#resourceBundle() exists for this command}) an attempt
* is made to find the option description using any of the option names (without leading hyphens) as key.
* @see OptionSpec#description()
Expand Down Expand Up @@ -4143,12 +4159,28 @@ public enum ScopeType {
/**
* Set {@code interactive=true} if this positional parameter will prompt the end user for a value (like a password).
* Only supported for single-value positional parameters (not arrays, collections or maps).
* When running on Java 6 or greater, this will use the {@link Console#readPassword()} API to get a value without echoing input to the console.
* When running on Java 6 or greater and echo attribute is false, this will use the {@link Console#readPassword()} API to get a value without echoing input to the console.
* @return whether this positional parameter prompts the end user for a value to be entered on the command line
* @since 3.5
*/
boolean interactive() default false;

/**
* Use this attribute to control a user input is echo-ed to the console or not. If {@code echo=true}, a input is echo-ed to the console.
* This attribute is ignored when interactive attribute is false.
* @return whether the user input is echo-ed to the console or not
* @since 4.X
*/
boolean echo() default false;

/**
* Use this attribute to customize the text displayed to the end user for an interactive option.
* This attribute is ignored when interactive attribute is false.
* @return text will be displayed to the end user
* @since 4.X
*/
String prompt() default "";

/** ResourceBundle key for this option. If not specified, (and a ResourceBundle {@linkplain Command#resourceBundle() exists for this command}) an attempt
* is made to find the positional parameter description using {@code paramLabel() + "[" + index() + "]"} as key.
*
Expand Down Expand Up @@ -8217,6 +8249,8 @@ public abstract static class ArgSpec {
// parser fields
private boolean required;
private final boolean interactive;
private final boolean echo;
private final String prompt;
private final String splitRegex;
private final String splitRegexSynopsisLabel;
protected final ITypeInfo typeInfo;
Expand Down Expand Up @@ -8256,6 +8290,8 @@ private <T extends Builder<T>> ArgSpec(Builder<T> builder) {
inherited = builder.inherited;
root = builder.root == null && ScopeType.INHERIT.equals(builder.scopeType) ? this : builder.root;
interactive = builder.interactive;
echo = builder.echo;
prompt = builder.prompt;
initialValue = builder.initialValue;
hasInitialValue = builder.hasInitialValue;
initialValueState = builder.initialValueState;
Expand Down Expand Up @@ -8328,6 +8364,12 @@ public boolean required() {
/** Returns whether this option will prompt the user to enter a value on the command line.
* @see Option#interactive() */
public boolean interactive() { return interactive; }
/** Returns whether the user input is echo-ed to the console or not.
* @see Option#echo() */
public boolean echo() { return echo; }
/** Returns the text displayed to the end user for an interactive option.
* @see Option#prompt() */
public String prompt() { return prompt; }

/** Returns the description of this option or positional parameter, after all variables have been rendered,
* including the {@code ${DEFAULT-VALUE}} and {@code ${COMPLETION-CANDIDATES}} variables.
Expand Down Expand Up @@ -8840,6 +8882,8 @@ abstract static class Builder<T extends Builder<T>> {
private String descriptionKey;
private boolean required;
private boolean interactive;
private boolean echo;
private String prompt;
private String paramLabel;
private boolean hideParamSyntax;
private String splitRegex;
Expand Down Expand Up @@ -8874,6 +8918,8 @@ abstract static class Builder<T extends Builder<T>> {
descriptionKey = original.descriptionKey;
required = original.required;
interactive = original.interactive;
echo = original.echo;
prompt = original.prompt;
paramLabel = original.paramLabel;
hideParamSyntax = original.hideParamSyntax;
splitRegex = original.splitRegex;
Expand Down Expand Up @@ -8920,6 +8966,8 @@ abstract static class Builder<T extends Builder<T>> {

hideParamSyntax = option.hideParamSyntax();
interactive = option.interactive();
echo = option.echo();
prompt = option.prompt();
description = option.description();
descriptionKey = option.descriptionKey();
splitRegex = option.split();
Expand Down Expand Up @@ -8953,6 +9001,8 @@ abstract static class Builder<T extends Builder<T>> {

hideParamSyntax = parameters.hideParamSyntax();
interactive = parameters.interactive();
echo = parameters.echo();
prompt = parameters.prompt();
description = parameters.description();
descriptionKey = parameters.descriptionKey();
splitRegex = parameters.split();
Expand Down Expand Up @@ -8994,6 +9044,12 @@ private static String inferLabel(String label, String fieldName, ITypeInfo typeI
/** Returns whether this option prompts the user to enter a value on the command line.
* @see Option#interactive() */
public boolean interactive() { return interactive; }
/** Returns whether the user input is echo-ed to the console or not.
* @see Option#echo() */
public boolean echo() { return echo; }
/** Returns the text displayed to the end user for an interactive option.
* @see Option#prompt() */
public String prompt() { return prompt; }

/** Returns the description of this option, used when generating the usage documentation.
* @see Option#description() */
Expand Down Expand Up @@ -9121,6 +9177,12 @@ private static String inferLabel(String label, String fieldName, ITypeInfo typeI
/** Sets whether this option prompts the user to enter a value on the command line, and returns this builder. */
public T interactive(boolean interactive) { this.interactive = interactive; return self(); }

/** Sets whether the user input is echo-ed to the console or not. */
public T echo(boolean echo) { this.echo = echo; return self(); }

/** Sets the text displayed to the end user for an interactive option. */
public T prompt(String prompt) { this.prompt = prompt; return self(); }

/** Sets the description of this option, used when generating the usage documentation, and returns this builder.
* @see Option#description() */
public T description(String... description) { this.description = Assert.notNull(description, "description").clone(); return self(); }
Expand Down Expand Up @@ -13302,11 +13364,11 @@ private int applyValueToSingleValuedField(ArgSpec argSpec,
consumed = 0;
}
}
// if argSpec is interactive, we may need to read the password from the console:
// if argSpec is interactive and echo is false, we may need to read the password from the console:
// - if arity = 0 : ALWAYS read from console
// - if arity = 0..1: ONLY read from console if user specified a non-option value
if (argSpec.interactive() && (arity.max == 0 || !optionalValueExists)) {
interactiveValue = readPassword(argSpec);
interactiveValue = readUserInput(argSpec);
consumed = 0;
}
}
Expand All @@ -13326,8 +13388,13 @@ private int applyValueToSingleValuedField(ArgSpec argSpec,
} else {
consumed = 1;
if (interactiveValue != null) {
initValueMessage = "Setting %s to *** (masked interactive value) for %4$s on %5$s%n";
overwriteValueMessage = "Overwriting %s value with *** (masked interactive value) for %s on %5$s%n";
if (argSpec.echo()) {
initValueMessage = "Setting %s to %3$s (interactive value) for %4$s on %5$s%n";
overwriteValueMessage = "Overwriting %s value with %3$s (interactive value) for %s on %5$s%n";
} else {
initValueMessage = "Setting %s to *** (masked interactive value) for %4$s on %5$s%n";
overwriteValueMessage = "Overwriting %s value with *** (masked interactive value) for %s on %5$s%n";
}
}
if (!char[].class.equals(cls) && !char[].class.equals(argSpec.type())) {
if (interactiveValue != null) {
Expand All @@ -13339,7 +13406,7 @@ private int applyValueToSingleValuedField(ArgSpec argSpec,
if (interactiveValue == null) { // setting command line arg to char[] field
newValue = actualValue.toCharArray();
} else {
actualValue = "***"; // mask interactive value
actualValue = getMaskedValue(argSpec, new String(interactiveValue)); // mask interactive value if echo is false
newValue = interactiveValue;
}
}
Expand Down Expand Up @@ -13636,7 +13703,7 @@ private List<Object> consumeArguments(ArgSpec argSpec,
alreadyUnquoted = false;
}
if (argSpec.interactive() && argSpec.arity().max == 0) {
consumed = addPasswordToList(argSpec, result, consumed, argDescription);
consumed = addUserInputToList(argSpec, result, consumed, argDescription);
}
// now process the varargs if any
String fallback = consumed == 0 && argSpec.isOption() && !OptionSpec.DEFAULT_FALLBACK_VALUE.equals(((OptionSpec) argSpec).fallbackValue())
Expand All @@ -13648,7 +13715,7 @@ private List<Object> consumeArguments(ArgSpec argSpec,
for (int i = consumed; consumed < arity.max && !args.isEmpty(); i++) {
if (argSpec.interactive() && argSpec.arity().max == 1 && !varargCanConsumeNextValue(argSpec, args.peek())) {
// if interactive and arity = 0..1, we consume from command line if possible (if next arg not an option or subcommand)
consumed = addPasswordToList(argSpec, result, consumed, argDescription);
consumed = addUserInputToList(argSpec, result, consumed, argDescription);
} else {
if (!varargCanConsumeNextValue(argSpec, args.peek())) { break; }
List<Object> typedValuesAtPosition = new ArrayList<Object>();
Expand Down Expand Up @@ -13693,22 +13760,35 @@ private int consumedCountMap(int i, int initialSize, ArgSpec arg) {
return commandSpec.parser().splitFirst() ? (arg.stringValues().size() - initialSize) / 2 : i;
}

private int addPasswordToList(ArgSpec argSpec, List<Object> result, int consumed, String argDescription) {
char[] password = readPassword(argSpec);
private int addUserInputToList(ArgSpec argSpec, List<Object> result, int consumed, String argDescription) {
char[] input = readUserInput(argSpec);
String inputString = new String(input);
if (tracer.isInfo()) {
tracer.info("Adding *** (masked interactive value) to %s for %s on %s%n", argSpec.toString(), argDescription, argSpec.scopeString());
tracer.info("Adding %s to %s for %s on %s%n",
getLoggableMaskedInteractiveValue(argSpec, inputString), argSpec.toString(), argDescription, argSpec.scopeString());
}
parseResultBuilder.addStringValue(argSpec, "***");
parseResultBuilder.addOriginalStringValue(argSpec, "***");
String maskedValue = getMaskedValue(argSpec, inputString);
parseResultBuilder.addStringValue(argSpec, maskedValue);
parseResultBuilder.addOriginalStringValue(argSpec, maskedValue);
if (!char[].class.equals(argSpec.auxiliaryTypes()[0]) && !char[].class.equals(argSpec.type())) {
Object value = tryConvert(argSpec, consumed, getTypeConverter(argSpec.auxiliaryTypes(), argSpec, 0), new String(password), 0);
Object value = tryConvert(argSpec, consumed, getTypeConverter(argSpec.auxiliaryTypes(), argSpec, 0), new String(input), 0);
result.add(value);
} else {
result.add(password);
result.add(input);
}
consumed++;
return consumed;
}
private String getLoggableMaskedInteractiveValue(ArgSpec argSpec, String input) {
if (argSpec.echo()) {
return input + " (interactive value)";
} else {
return "*** (masked interactive value)";
}
}
private String getMaskedValue(ArgSpec argSpec, String input) {
return argSpec.echo() ? input : "***";
}
private int consumeOneArgument(ArgSpec argSpec,
LookBehind lookBehind,
boolean alreadyUnquoted, Range arity,
Expand Down Expand Up @@ -13941,31 +14021,43 @@ private boolean assertNoMissingParameters(ArgSpec argSpec, Range arity, Stack<St
return true;
}

char[] readPassword(ArgSpec argSpec) {
char[] readUserInput(ArgSpec argSpec) {
String name = argSpec.isOption() ? ((OptionSpec) argSpec).longestName() : "position " + position;
String prompt = String.format("Enter value for %s (%s): ", name, str(argSpec.description(), 0));
if (tracer.isDebug()) {tracer.debug("Reading value for %s from console...%n", name);}
char[] result = readPassword(prompt);
if (tracer.isDebug()) {tracer.debug("User entered %d characters for %s.%n", result.length, name);}
return result;
String prompt = argSpec.prompt() != null && argSpec.prompt().length() != 0 ?
argSpec.prompt() :
String.format("Enter value for %s (%s): ", name, str(argSpec.description(), 0));
try {
if (tracer.isDebug()) {tracer.debug("Reading value for %s from console...%n", name);}
char[] result = argSpec.echo() ? readUserInputWithEchoing(prompt) : readPassword(prompt);
if (tracer.isDebug()) {tracer.debug(createUserInputDebugString(argSpec, result, name));}
return result;
} finally {
interactiveCount++;
}
}
private String createUserInputDebugString(ArgSpec argSpec, char[] result, String name) {
return argSpec.echo() ?
String.format("User entered %s for %s.%n", new String(result), name) :
String.format("User entered %d characters for %s.%n", result.length, name);
}
char[] readPassword(String prompt) {
try {
Object console = System.class.getDeclaredMethod("console").invoke(null);
Method method = Class.forName("java.io.Console").getDeclaredMethod("readPassword", String.class, Object[].class);
return (char[]) method.invoke(console, prompt, new Object[0]);
} catch (Exception e) {
System.out.print(prompt);
InputStreamReader isr = new InputStreamReader(System.in);
BufferedReader in = new BufferedReader(isr);
try {
String password = in.readLine();
return password == null ? new char[0] : password.toCharArray();
} catch (IOException ex2) {
throw new IllegalStateException(ex2);
}
} finally {
interactiveCount++;
return readUserInputWithEchoing(prompt);
}
}
char[] readUserInputWithEchoing(String prompt) {
System.out.print(prompt);
InputStreamReader isr = new InputStreamReader(System.in);
BufferedReader in = new BufferedReader(isr);
try {
String input = in.readLine();
return input == null ? new char[0] : input.toCharArray();
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
int getPosition(ArgSpec arg) {
Expand Down
Loading