From 1f8f567f970d6da4ee64e525d002fa63dfe8d326 Mon Sep 17 00:00:00 2001 From: David Pilar Date: Thu, 27 Aug 2026 23:47:21 +0200 Subject: [PATCH 1/3] Fix tokenization of escaped and single quoted input Replace the regex based whitespace splitting in DefaultCommandParser with a character-by-character state machine that understands backslash escaping and both double and single quote delimiters. The previous regex counted all quote characters equally, so an odd number of escaped quotes made the parser split inside a quoted value. Unbalanced quotes are now rejected with a clear error message instead of producing a mis-parsed command. Resolves #1374 Signed-off-by: David Pilar --- .../core/command/DefaultCommandParser.java | 72 +++++++++++-- .../command/DefaultCommandParserTests.java | 102 ++++++++++++++++++ 2 files changed, 168 insertions(+), 6 deletions(-) diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/DefaultCommandParser.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/DefaultCommandParser.java index dd1a63ce7..a837aa1e8 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/DefaultCommandParser.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/DefaultCommandParser.java @@ -18,6 +18,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -26,7 +27,10 @@ * Default implementation of {@link CommandParser}. Supports options in the long form of * --key=value or --key value as well in the short form of -k=value or -k value. Options * and arguments can be specified in any order. Arguments are 0-based indexed among other - * arguments.
+ * arguments. Option values and arguments can be quoted with double or single quotes to
+ * include whitespace, and quote characters can be escaped with a backslash inside quoted
+ * values (e.g. greet "she said \" and left"). Unbalanced quotes are rejected with an
+ * {@link IllegalArgumentException}. 
  * CommandSyntax  ::= CommandName [SubCommandName]* [Option | Argument]*
  * CommandName    ::= String
  * SubCommandName ::= String
@@ -55,7 +59,10 @@ public DefaultCommandParser(CommandRegistry commandRegistry) {
 	@Override
 	public ParsedInput parse(String input) {
 		log.debug("Parsing input: " + input);
-		List words = List.of(input.split("\\s+(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)"));
+		List words = tokenize(input);
+		if (words.isEmpty()) {
+			words = List.of("");
+		}
 
 		// the first word is the (root) command name
 		String commandName = words.get(0);
@@ -143,6 +150,56 @@ else if (isBooleanOption(fullCommandName, currentWord) && !isBooleanValue(nextWo
 		return parsedInput;
 	}
 
+	/**
+	 * Split the input into words on whitespace, keeping quoted sections together. This is
+	 * a character-by-character state machine tracking the currently open quote (none,
+	 * double or single). A backslash makes the following character part of the current
+	 * word without any special meaning, so an escaped quote neither opens nor closes a
+	 * quoted section. Words are kept verbatim (quotes and escape sequences included),
+	 * quote removal and escape resolution happen later in
+	 * {@link #unquoteAndUnescapeQuoted(String)}.
+	 * @param input the raw input line
+	 * @return the list of words
+	 * @throws IllegalArgumentException if the input contains an unbalanced quote
+	 */
+	private List tokenize(String input) {
+		List words = new ArrayList<>();
+		StringBuilder currentWord = new StringBuilder();
+		char openingQuote = 0;
+		for (int i = 0; i < input.length(); i++) {
+			char currentChar = input.charAt(i);
+			if (currentChar == '\\' && i + 1 < input.length()) {
+				currentWord.append(currentChar).append(input.charAt(++i));
+			}
+			else if (openingQuote != 0) {
+				currentWord.append(currentChar);
+				if (currentChar == openingQuote) {
+					openingQuote = 0;
+				}
+			}
+			else if (currentChar == '"' || currentChar == '\'') {
+				openingQuote = currentChar;
+				currentWord.append(currentChar);
+			}
+			else if (Character.isWhitespace(currentChar)) {
+				if (!currentWord.isEmpty()) {
+					words.add(currentWord.toString());
+					currentWord.setLength(0);
+				}
+			}
+			else {
+				currentWord.append(currentChar);
+			}
+		}
+		if (openingQuote != 0) {
+			throw new IllegalArgumentException("Unbalanced quote (" + openingQuote + ") in input: " + input);
+		}
+		if (!currentWord.isEmpty()) {
+			words.add(currentWord.toString());
+		}
+		return words;
+	}
+
 	// Check if the word is the argument separator, ie empty "--" (POSIX style)
 	private boolean isArgumentSeparator(String word) {
 		return word.equals("--");
@@ -185,11 +242,14 @@ private CommandArgument parseArgument(int index, String word) {
 
 	private String unquoteAndUnescapeQuoted(String s) {
 		// only process quoted strings
-		if (s.length() >= 2 && s.startsWith("\"") && s.endsWith("\"")) {
-			s = s.substring(1, s.length() - 1);
+		if (s.length() >= 2) {
+			char quote = s.charAt(0);
+			if ((quote == '"' || quote == '\'') && s.charAt(s.length() - 1) == quote) {
+				s = s.substring(1, s.length() - 1);
 
-			// unescape only inside quoted strings
-			s = s.replace("\\\"", "\"").replace("\\\\", "\\");
+				// unescape only inside quoted strings
+				s = s.replace("\\" + quote, String.valueOf(quote)).replace("\\\\", "\\");
+			}
 		}
 		return s;
 	}
diff --git a/spring-shell-core/src/test/java/org/springframework/shell/core/command/DefaultCommandParserTests.java b/spring-shell-core/src/test/java/org/springframework/shell/core/command/DefaultCommandParserTests.java
index 4a64eba36..dcd74b535 100644
--- a/spring-shell-core/src/test/java/org/springframework/shell/core/command/DefaultCommandParserTests.java
+++ b/spring-shell-core/src/test/java/org/springframework/shell/core/command/DefaultCommandParserTests.java
@@ -270,6 +270,108 @@ static Stream parseWithQuotedArgumentData() {
 				Arguments.of("mycommand  --  value", "value"), Arguments.of("mycommand  --  \"value\"", "value"));
 	}
 
+	@ParameterizedTest
+	@MethodSource("parseWithEscapedQuotedArgumentData")
+	void testParseWithEscapedQuotedArgument(String input, String expectedValue) {
+		// when
+		ParsedInput parsedInput = parser.parse(input);
+
+		// then
+		assertEquals("mycommand", parsedInput.commandName());
+		assertEquals(1, parsedInput.arguments().size());
+		assertEquals(expectedValue, parsedInput.arguments().get(0).value());
+	}
+
+	static Stream parseWithEscapedQuotedArgumentData() {
+		return Stream.of(
+				// escaped quote inside a double quoted argument (odd number of escaped
+				// quotes)
+				Arguments.of("mycommand \"she said \\\" and left\"", "she said \" and left"),
+				Arguments.of("mycommand \"a \\\" b \\\" c \\\" d\"", "a \" b \" c \" d"),
+				Arguments.of("mycommand \"it's here\"", "it's here"),
+				// single quoted arguments group words
+				Arguments.of("mycommand 'value1 value2'", "value1 value2"),
+				Arguments.of("mycommand 'she said \" and left'", "she said \" and left"),
+				Arguments.of("mycommand 'don\\'t stop'", "don't stop"),
+				// escaped backslash inside a quoted argument
+				Arguments.of("mycommand \"a\\\\b\"", "a\\b"),
+				// empty quoted argument
+				Arguments.of("mycommand \"\"", ""), Arguments.of("mycommand ''", ""),
+				// quoted words are not treated as options
+				Arguments.of("mycommand \"--option=value\"", "--option=value"),
+				// trailing backslash is kept as-is
+				Arguments.of("mycommand arg\\", "arg\\"),
+				// any whitespace separates words
+				Arguments.of("mycommand\t\"value1 value2\"", "value1 value2"));
+	}
+
+	@Test
+	void testParseWithMultipleQuotedArguments() {
+		// when
+		ParsedInput parsedInput = parser.parse("mycommand \"value1 value2\" 'value3 value4'");
+
+		// then
+		assertEquals("mycommand", parsedInput.commandName());
+		assertEquals(2, parsedInput.arguments().size());
+		assertEquals("value1 value2", parsedInput.arguments().get(0).value());
+		assertEquals("value3 value4", parsedInput.arguments().get(1).value());
+	}
+
+	@Test
+	void testParseWithQuotedOptionValueFollowedByArgument() {
+		// when
+		ParsedInput parsedInput = parser.parse("mycommand --option \"value1 value2\" arg1");
+
+		// then
+		assertEquals("mycommand", parsedInput.commandName());
+		assertEquals(1, parsedInput.options().size());
+		assertEquals("value1 value2", parsedInput.options().get(0).value());
+		assertEquals(1, parsedInput.arguments().size());
+		assertEquals("arg1", parsedInput.arguments().get(0).value());
+	}
+
+	@ParameterizedTest
+	@ValueSource(strings = { "", "   " })
+	void testParseEmptyInput(String input) {
+		// when
+		ParsedInput parsedInput = parser.parse(input);
+
+		// then
+		assertEquals("", parsedInput.commandName());
+		assertEquals(0, parsedInput.options().size());
+		assertEquals(0, parsedInput.arguments().size());
+	}
+
+	@ParameterizedTest
+	@MethodSource("parseWithEscapedQuotedOptionData")
+	void testParseWithEscapedQuotedOption(String input, String expectedValue) {
+		// when
+		ParsedInput parsedInput = parser.parse(input);
+
+		// then
+		assertEquals("mycommand", parsedInput.commandName());
+		assertEquals(1, parsedInput.options().size());
+		assertEquals("option", parsedInput.options().get(0).longName());
+		assertEquals(expectedValue, parsedInput.options().get(0).value());
+	}
+
+	static Stream parseWithEscapedQuotedOptionData() {
+		return Stream.of(Arguments.of("mycommand --option=\"she said \\\" and left\"", "she said \" and left"),
+				Arguments.of("mycommand --option \"she said \\\" and left\"", "she said \" and left"),
+				Arguments.of("mycommand --option='value1 value2'", "value1 value2"),
+				Arguments.of("mycommand --option 'value1 value2'", "value1 value2"),
+				Arguments.of("mycommand --option=\"\"", ""), Arguments.of("mycommand --option=''", ""));
+	}
+
+	@ParameterizedTest
+	@ValueSource(strings = { "mycommand \"unbalanced value", "mycommand 'unbalanced value",
+			"mycommand --option=\"unbalanced value", "mycommand don't" })
+	void testParseWithUnbalancedQuotes(String input) {
+		IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class,
+				() -> parser.parse(input));
+		Assertions.assertTrue(exception.getMessage().contains("quote"));
+	}
+
 	@ParameterizedTest
 	@MethodSource("parseWithBooleanOptionData")
 	void testParseWithBooleanOption(String input, String commandName, String longName, char shortName, Class type,

From dd8e29fb06beba961be710e9d69eb761228b0fb2 Mon Sep 17 00:00:00 2001
From: David Pilar 
Date: Thu, 27 Aug 2026 23:47:21 +0200
Subject: [PATCH 2/3] Document quoting and escaping rules

The quoting behavior was documented in 2.x but the section was lost in
later documentation rewrites. Document the rules implemented by
DefaultCommandParser in the command syntax page.

See #1374

Signed-off-by: David Pilar 
---
 .../modules/ROOT/pages/commands/syntax.adoc   | 41 +++++++++++++++++++
 1 file changed, 41 insertions(+)

diff --git a/spring-shell-docs/modules/ROOT/pages/commands/syntax.adoc b/spring-shell-docs/modules/ROOT/pages/commands/syntax.adoc
index da8156dc5..de30163bd 100644
--- a/spring-shell-docs/modules/ROOT/pages/commands/syntax.adoc
+++ b/spring-shell-docs/modules/ROOT/pages/commands/syntax.adoc
@@ -61,6 +61,47 @@ IMPORTANT: When an option is specified, it is always expected to have a value fo
 
 TIP: To avoid ambiguity, named options should be preferred over positional arguments whenever possible, especially when subcommands are involved (see https://clig.dev/#arguments-and-flags[Command Line Interface Guidelines]).
 
+== Quoting and escaping
+
+The input is split into words on whitespace. To provide an option value or an argument that contains whitespace,
+the value needs to be quoted. Both single (`'`) and double (`"`) quotes are supported, and the enclosing quotes
+will not be part of the value:
+
+[source,shell]
+----
+$>mycommand --message='Hello World' <1>
+$>mycommand --message="Hello World" <2>
+$>mycommand "arg1 with spaces" <3>
+----
+<1> option value is `Hello World`
+<2> option value is `Hello World`
+<3> argument value is `arg1 with spaces`
+
+Supporting both types of quotes allows one type of quote to be embedded in a value quoted with the other type:
+
+[source,shell]
+----
+$>mycommand --message="I'm here!" <1>
+$>mycommand --message='He said "Hi!"' <2>
+----
+<1> option value is `I'm here!`
+<2> option value is `He said "Hi!"`
+
+To embed the same kind of quote that was used to quote the whole value, escape it with the backslash (`\`)
+character. A literal backslash inside a quoted value can be escaped as `\\`:
+
+[source,shell]
+----
+$>mycommand --message="He said \"Hi!\"" <1>
+$>mycommand --message='I\'m here!' <2>
+----
+<1> option value is `He said "Hi!"`
+<2> option value is `I'm here!`
+
+Quotes are only removed when the whole value is enclosed in them. Quote characters in the middle of a value
+are kept as-is (e.g., `value1"inside"value2` stays unchanged), and escape sequences are only resolved inside
+quoted values. An input with an unbalanced quote is rejected with an error.
+
 == Customizing parsing rules
 
 Spring Shell 4 provides a new API called `CommandParser` that allows you to customize the command parsing rules.

From 658b47d02a7a7d419b70dd29f14b57ccdc217da3 Mon Sep 17 00:00:00 2001
From: David Pilar 
Date: Fri, 28 Aug 2026 00:04:28 +0200
Subject: [PATCH 3/3] Preserve backslashes in interactive input

JLine's LineReader strips escape characters from the line returned by
readLine() when event expansion is enabled, so escaped quotes typed in
interactive mode never reached the command parser. In 3.x this did not
matter because the shell consumed the words of JLine's ParsedLine
instead of the returned raw line. Disable event expansion on the line
reader since escape resolution is the responsibility of the command
parser.

See #1374

Signed-off-by: David Pilar 
---
 .../core/autoconfigure/JLineShellAutoConfiguration.java  | 5 ++++-
 .../shell/jline/DefaultJLineShellConfiguration.java      | 9 ++++++++-
 2 files changed, 12 insertions(+), 2 deletions(-)

diff --git a/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/JLineShellAutoConfiguration.java b/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/JLineShellAutoConfiguration.java
index a4039fb72..ccd743c00 100644
--- a/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/JLineShellAutoConfiguration.java
+++ b/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/JLineShellAutoConfiguration.java
@@ -132,7 +132,10 @@ public LineReader lineReader(Terminal terminal, Parser parser, CommandCompleter
 			.completer(commandCompleter)
 			.history(jLineHistory)
 			.highlighter(commandHighlighter)
-			.parser(parser);
+			.parser(parser)
+			// keep backslashes in the returned line, escapes are resolved by the
+			// command parser
+			.option(LineReader.Option.DISABLE_EVENT_EXPANSION, true);
 
 		LineReader lineReader = lineReaderBuilder.build();
 		if (this.springShellProperties.getHistory().isEnabled()) {
diff --git a/spring-shell-jline/src/main/java/org/springframework/shell/jline/DefaultJLineShellConfiguration.java b/spring-shell-jline/src/main/java/org/springframework/shell/jline/DefaultJLineShellConfiguration.java
index 8159a471d..e6107016f 100644
--- a/spring-shell-jline/src/main/java/org/springframework/shell/jline/DefaultJLineShellConfiguration.java
+++ b/spring-shell-jline/src/main/java/org/springframework/shell/jline/DefaultJLineShellConfiguration.java
@@ -30,7 +30,14 @@ public JLineInputProvider inputProvider(LineReader lineReader) {
 
 	@Bean
 	public LineReader lineReader(Terminal terminal, Parser parser, CommandCompleter commandCompleter) {
-		return LineReaderBuilder.builder().terminal(terminal).completer(commandCompleter).parser(parser).build();
+		return LineReaderBuilder.builder()
+			.terminal(terminal)
+			.completer(commandCompleter)
+			.parser(parser)
+			// keep backslashes in the returned line, escapes are resolved by the
+			// command parser
+			.option(LineReader.Option.DISABLE_EVENT_EXPANSION, true)
+			.build();
 	}
 
 	@Bean