diff --git a/picocli-examples/src/main/java/picocli/examples/interactive/UserPasswordDemo.java b/picocli-examples/src/main/java/picocli/examples/interactive/UserPasswordDemo.java new file mode 100644 index 000000000..053d3b504 --- /dev/null +++ b/picocli-examples/src/main/java/picocli/examples/interactive/UserPasswordDemo.java @@ -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); + } +} diff --git a/src/main/java/picocli/CommandLine.java b/src/main/java/picocli/CommandLine.java index bbd294ecc..44ab5b3db 100644 --- a/src/main/java/picocli/CommandLine.java +++ b/src/main/java/picocli/CommandLine.java @@ -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. *

* Best security practice is to use type {@code char[]} instead of {@code String}, and to to null out the array after use. *

@@ -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() @@ -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. * @@ -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; @@ -8256,6 +8290,8 @@ private > ArgSpec(Builder 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; @@ -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. @@ -8840,6 +8882,8 @@ abstract static class Builder> { private String descriptionKey; private boolean required; private boolean interactive; + private boolean echo; + private String prompt; private String paramLabel; private boolean hideParamSyntax; private String splitRegex; @@ -8874,6 +8918,8 @@ abstract static class Builder> { descriptionKey = original.descriptionKey; required = original.required; interactive = original.interactive; + echo = original.echo; + prompt = original.prompt; paramLabel = original.paramLabel; hideParamSyntax = original.hideParamSyntax; splitRegex = original.splitRegex; @@ -8920,6 +8966,8 @@ abstract static class Builder> { hideParamSyntax = option.hideParamSyntax(); interactive = option.interactive(); + echo = option.echo(); + prompt = option.prompt(); description = option.description(); descriptionKey = option.descriptionKey(); splitRegex = option.split(); @@ -8953,6 +9001,8 @@ abstract static class Builder> { hideParamSyntax = parameters.hideParamSyntax(); interactive = parameters.interactive(); + echo = parameters.echo(); + prompt = parameters.prompt(); description = parameters.description(); descriptionKey = parameters.descriptionKey(); splitRegex = parameters.split(); @@ -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() */ @@ -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(); } @@ -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; } } @@ -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) { @@ -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; } } @@ -13636,7 +13703,7 @@ private List 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()) @@ -13648,7 +13715,7 @@ private List 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 typedValuesAtPosition = new ArrayList(); @@ -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 result, int consumed, String argDescription) { - char[] password = readPassword(argSpec); + private int addUserInputToList(ArgSpec argSpec, List 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, @@ -13941,13 +14021,24 @@ private boolean assertNoMissingParameters(ArgSpec argSpec, Range arity, Stack x; + + @Option(names = "-z") + int z; + } + + PrintStream out = System.out; + PrintStream err = System.err; + InputStream in = System.in; + System.setProperty("picocli.trace", "DEBUG"); + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + ByteArrayOutputStream errBaos = new ByteArrayOutputStream(); + System.setErr(new PrintStream(errBaos)); + System.setIn(inputStream("1234567890")); + App app = new App(); + CommandLine cmd = new CommandLine(app); + ParseResult result = cmd.parseArgs("-x", "-x"); + ArgSpec specX = result.matchedArgs().get(0); + assertThat(specX.toString(), containsString("App.x")); + + assertEquals("Enter value for -x (Pwd): Enter value for -x (Pwd): ", baos.toString()); + assertEquals(Arrays.asList(1234567890, 1234567890), app.x); + assertEquals(0, app.z); + + String trace = errBaos.toString(); + assertThat(trace, containsString("User entered 1234567890")); + assertThat(trace, containsString( + "Adding 1234567890 (interactive value) to " + + specX.toString() + " for option -x on " + app.getClass().getSimpleName())); + assertThat(trace, not(containsString("10 characters"))); + assertThat(trace, not(containsString("***"))); + } finally { + System.setOut(out); + System.setOut(err); + System.setIn(in); + } + } + + @Test + public void testInteractiveOptionAsListOfIntegersWithCustomPrompt() { + class App { + @Option(names = "-x", description = {"Pwd", "line2"}, interactive = true, prompt = "[Customized]Enter your x: ") + List x; + + @Option(names = "-z") + int z; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + System.setIn(inputStream("123")); + App app = new App(); + CommandLine cmd = new CommandLine(app); + cmd.parseArgs("-x", "-x"); + + assertEquals("[Customized]Enter your x: [Customized]Enter your x: ", baos.toString()); + assertEquals(Arrays.asList(123, 123), app.x); + assertEquals(0, app.z); } finally { System.setOut(out); System.setIn(in); @@ -388,6 +556,89 @@ class App { @Parameters(index = "1") int z; } + PrintStream out = System.out; + PrintStream err = System.err; + InputStream in = System.in; + System.setProperty("picocli.trace", "DEBUG"); + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + ByteArrayOutputStream errBaos = new ByteArrayOutputStream(); + System.setErr(new PrintStream(errBaos)); + System.setIn(new ByteArrayInputStream("1234567890".getBytes())); + + App app = new App(); + CommandLine cmd = new CommandLine(app); + ParseResult result = cmd.parseArgs("987"); + ArgSpec specX = result.matchedArgs().get(0); + assertThat(specX.toString(), containsString("App.x")); + + String expectedPrompt = format("Enter value for position 0 (Pwd%nline2): "); + assertEquals(expectedPrompt, baos.toString()); + assertEquals(1234567890, app.x); + assertEquals(987, app.z); + + String trace = errBaos.toString(); + assertThat(trace, containsString("User entered 10 characters")); + assertThat(trace, containsString( + "Setting " + specX.toString() + " to *** (masked interactive value)")); + assertThat(trace, not(containsString("1234567890"))); + } finally { + System.setOut(out); + System.setOut(err); + System.setIn(in); + } + } + + @Test + public void testInteractivePositionalReadsFromStdInWithEchoing() { + class App { + @Parameters(index = "0", description = {"Pwd%nline2", "ignored"}, interactive = true, echo = true) int x; + @Parameters(index = "1") int z; + } + + PrintStream out = System.out; + PrintStream err = System.err; + InputStream in = System.in; + System.setProperty("picocli.trace", "DEBUG"); + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + ByteArrayOutputStream errBaos = new ByteArrayOutputStream(); + System.setErr(new PrintStream(errBaos)); + System.setIn(new ByteArrayInputStream("1234567890".getBytes())); + + App app = new App(); + CommandLine cmd = new CommandLine(app); + ParseResult result = cmd.parseArgs("987"); + ArgSpec specX = result.matchedArgs().get(0); + assertThat(specX.toString(), containsString("App.x")); + + String expectedPrompt = format("Enter value for position 0 (Pwd%nline2): "); + assertEquals(expectedPrompt, baos.toString()); + assertEquals(1234567890, app.x); + assertEquals(987, app.z); + + String trace = errBaos.toString(); + assertThat(trace, containsString("User entered 1234567890")); + assertThat(trace, containsString( + "Setting " + specX.toString() + " to 123")); + assertThat(trace, not(containsString("10 characters"))); + assertThat(trace, not(containsString("***"))); + } finally { + System.setOut(out); + System.setOut(err); + System.setIn(in); + } + } + + @Test + public void testInteractivePositionalReadsFromStdInWithCustomPrompt() { + class App { + @Parameters(index = "0", description = {"Pwd%nline2", "ignored"}, interactive = true, prompt = "[Customized]Enter your value: ") int x; + @Parameters(index = "1") int z; + } + PrintStream out = System.out; InputStream in = System.in; try { @@ -399,7 +650,7 @@ class App { CommandLine cmd = new CommandLine(app); cmd.parseArgs("987"); - String expectedPrompt = format("Enter value for position 0 (Pwd%nline2): "); + String expectedPrompt = format("[Customized]Enter your value: "); assertEquals(expectedPrompt, baos.toString()); assertEquals(123, app.x); assertEquals(987, app.z);