Skip to content
Open
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
Expand Up @@ -818,7 +818,25 @@ public Request parse(String commandLine) {
}

/**
* Crack a command line.
* Break a command line into an array of arguments, using shell-like quoting rules:
* <ul>
* <li>Tokens are delimited by unquoted spaces.</li>
* <li>Single-quoted strings ({@code '...'}) preserve every character literally,
* including backslashes. Nothing can be escaped inside single quotes; the
* first {@code '} ends the quoted region.</li>
* <li>Double-quoted strings ({@code "..."}) follow POSIX rules: a backslash
* is an escape character only before {@code "}, {@code \}, {@code $},
* <code>&#96;</code>, and a newline; before any other character the
* backslash is kept as a literal {@code \}.</li>
* <li>Outside of quotes, a backslash escapes the immediately following
* character: the backslash is dropped and the next character is appended
* literally. The one exception is a backslash followed by {@code <LF>},
* which is treated as a line-continuation and discards both characters.
* A backslash followed by {@code <CR>} escapes the carriage-return
* character itself (appending it to the current token).</li>
* <li>ANSI-C quoting ({@code $'...'}) is not supported; an
* {@link IllegalArgumentException} is thrown if it is encountered.</li>
* </ul>
*
* @param toProcess the command line to process.
* @return the command line broken into strings.
Expand All @@ -829,50 +847,89 @@ public static String[] translateCommandline(String toProcess) {
//no command? no string
return new String[0];
}
// parse with a simple finite state machine

final int normal = 0;
final int inQuote = 1;
final int inDoubleQuote = 2;
int state = normal;
final StringTokenizer tok = new StringTokenizer(toProcess, "\"\' ", true);
final ArrayList<String> result = new ArrayList<>();
final StringBuilder current = new StringBuilder();
boolean lastTokenHasBeenQuoted = false;

while (tok.hasMoreTokens()) {
String nextTok = tok.nextToken();
int i = 0;
final int len = toProcess.length();
while (i < len) {
char c = toProcess.charAt(i);
switch (state) {
case inQuote -> {
if ("'".equals(nextTok)) {
if (c == '\'') {
lastTokenHasBeenQuoted = true;
state = normal;
i++;
} else {
current.append(nextTok);
current.append(c);
i++;
}
}
case inDoubleQuote -> {
if ("\"".equals(nextTok)) {
if (c == '\\' && i + 1 < len) {
char next = toProcess.charAt(i + 1);
if (next == '"' || next == '\\' || next == '$' || next == '`' || next == '\n') {
current.append(next);
i += 2;
} else if (next == '\r') {
// backslash-newline line continuation inside double quotes
i += 2;
if (i < len && toProcess.charAt(i) == '\n') {
i++;
}
} else {
// backslash is literal before any other character
current.append(c);
i++;
}
} else if (c == '"') {
lastTokenHasBeenQuoted = true;
state = normal;
i++;
} else {
current.append(nextTok);
current.append(c);
i++;
}
}
default -> {
if ("'".equals(nextTok)) {
if (c == '$' && i + 1 < len && toProcess.charAt(i + 1) == '\'') {
throw new IllegalArgumentException(
"ANSI-C quoting ($'...') is not supported in: " + toProcess);
} else if (c == '\'') {
state = inQuote;
} else if ("\"".equals(nextTok)) {
i++;
} else if (c == '"') {
state = inDoubleQuote;
} else if (" ".equals(nextTok)) {
i++;
} else if (c == ' ') {
if (lastTokenHasBeenQuoted || !current.isEmpty()) {
result.add(current.toString());
current.setLength(0);
}
lastTokenHasBeenQuoted = false;
i++;
} else if (c == '\\' && i + 1 < len) {
char next = toProcess.charAt(i + 1);
if (next == '\n') {
// backslash-LF line continuation: discard both
i += 2;
} else {
// backslash escapes any other character literally (including \r)
current.append(next);
i += 2;
}
lastTokenHasBeenQuoted = false;
} else {
current.append(nextTok.replaceAll("^\\\\[\\r\\n]", ""));
current.append(c);
lastTokenHasBeenQuoted = false;
i++;
}
lastTokenHasBeenQuoted = false;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.params.provider.Arguments.of;

import java.io.File;
import java.io.IOException;
Expand All @@ -31,6 +32,7 @@
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;

import org.apache.jmeter.protocol.http.control.Cookie;
import org.apache.jmeter.protocol.http.curl.ArgumentHolder;
Expand All @@ -40,6 +42,9 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

public class BasicCurlParserTest {

Expand Down Expand Up @@ -770,4 +775,142 @@ public void testIsValidCookie() {
assertTrue(BasicCurlParser.isValidCookie("a=b;c=d"), "The string should be cookies");
assertFalse(BasicCurlParser.isValidCookie("test.txt"), "A filename is not a valid cookie");
}

/**
* A single quote inside a value can be written using the POSIX idiom
* {@code 'tes'\''t'}: close the single-quoted region, escape the single
* quote with a backslash outside quotes, then reopen single-quoting.
*/
@Test
public void testEscapedSingleQuoteInData() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test makes 'tes\'t' (an unterminated quote in bash) a supported input, so it pins behavior that a POSIX-compliant parser has to reject. The same applies to testTranslateCommandlineEscapedSingleQuoteInsideSingleQuotes and testTranslateCommandlineMultipleEscapedQuotes. Please replace them with 'tes'\''t' and add the regression cases listed in the review summary.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

// Shell representation: --data 'tes'\''t'
// In Java string: 'tes'\'t' (close quote, backslash+quote outside, reopen quote)
String curl = " curl -X POST \"localhost.com\" --data 'tes'\\''t'";
BasicCurlParser basicCurlParser = new BasicCurlParser();
BasicCurlParser.Request request = basicCurlParser.parse(curl);
assertEquals("tes't", request.getPostData(),
"POSIX single-quote idiom 'tes'\\''t' should produce tes't");
}

/**
* Escaped double-quote inside a double-quoted --data value must not cause
* "unbalanced quotes" and must be included literally in the post data.
* Reproduces https://github.com/apache/jmeter/issues/6374
*/
@Test
public void testEscapedDoubleQuoteInData() {
// Shell representation: --data "tes\"t"
String curl = " curl -X POST \"localhost.com\" --data \"tes\\\"t\"";
BasicCurlParser basicCurlParser = new BasicCurlParser();
BasicCurlParser.Request request = basicCurlParser.parse(curl);
assertEquals("tes\"t", request.getPostData(),
"Escaped double-quote inside double-quoted data should be preserved");
}

static Stream<Arguments> translateCommandlineCases() {
return Stream.of(
// Plain unquoted tokens split on spaces
// bash: printf "%s\n" curl -X POST http://example.com
// → curl / -X / POST / http://example.com
of("plain unquoted tokens",
"curl -X POST http://example.com",
new String[]{"curl", "-X", "POST", "http://example.com"}),

// Single-quoted token: quotes stripped, content verbatim
// bash: printf "%s\n" curl 'hello world' → curl / hello world
of("single-quoted token with space",
"curl 'hello world'",
new String[]{"curl", "hello world"}),

// Double-quoted token: quotes stripped, content verbatim
// bash: printf "%s\n" curl "hello world" → curl / hello world
of("double-quoted token with space",
"curl \"hello world\"",
new String[]{"curl", "hello world"}),

// POSIX idiom for single quote inside single-quoted string: 'tes'\''t'
// bash: printf "%s\n" 'tes'\''t' → tes't
of("single quote via POSIX idiom 'tes'\\''t'",
"'tes'\\''t'",
new String[]{"tes't"}),

// Escaped double-quote inside double-quoted string
// bash: printf "%s\n" "tes\"t" → tes"t
of("escaped double-quote inside double quotes",
"\"tes\\\"t\"",
new String[]{"tes\"t"}),

// Multiple POSIX single-quote idioms in one token
// bash: printf "%s\n" 'it'\''s a test'\''s value' → it's a test's value
of("multiple single quotes via POSIX idiom",
"'it'\\''s a test'\\''s value'",
new String[]{"it's a test's value"}),

// Backslash + LF line continuation outside quotes
// bash: printf "%s\n" curl \<LF>-d 'hey' → curl / -d / hey
of("backslash-LF line continuation",
"curl \\\n-d 'hey'",
new String[]{"curl", "-d", "hey"}),

// Backslash + CRLF outside quotes: only \<LF> is a line continuation.
// \<CR> escapes the CR (appending it to the current token); the following
// <LF> is not a token separator in this tokenizer, so it is also appended.
// Result: the second token is "\r\n-d" (CR + LF + "-d" run together).
of("backslash-CRLF: only LF continuation is supported; CR and LF are appended",
"curl \\\r\n-d 'hey'",
new String[]{"curl", "\r\n-d", "hey"}),

// Backslash inside single quotes is literal; 'C:\dir\' is a complete
// single-quoted string containing C:\dir\
// bash: set -- curl -d 'C:\dir\'; echo $# → 3 tokens: curl / -d / C:\dir\
of("backslash before closing single quote is literal inside single quotes",
"curl -d 'C:\\dir\\'",
new String[]{"curl", "-d", "C:\\dir\\"}),

// Inside double quotes, \\ → single backslash; closing " is unescaped
// bash: printf "%s\n" curl -d "C:\\dir\\" http://x → curl / -d / C:\dir\ / http://x
of("escaped backslashes inside double quotes",
"curl -d \"C:\\\\dir\\\\\" http://x",
new String[]{"curl", "-d", "C:\\dir\\", "http://x"}),

// Inside double quotes, \\ → single backslash
// bash: printf "%s\n" "a\\b" → a\b
of("double-quoted escaped backslash yields single backslash",
"\"a\\\\b\"",
new String[]{"a\\b"})
);
}

@ParameterizedTest(name = "{0}")
@MethodSource("translateCommandlineCases")
public void testTranslateCommandline(String name, String input, String[] expected) {
String[] result = BasicCurlParser.translateCommandline(input);
assertEquals(expected.length, result.length, "token count for: " + input);
for (int i = 0; i < expected.length; i++) {
assertEquals(expected[i], result[i], "token[" + i + "] for: " + input);
}
}

/** Empty input returns an empty array. */
@Test
public void testTranslateCommandlineEmptyInput() {
assertEquals(0, BasicCurlParser.translateCommandline("").length);
}

/** Unbalanced double quotes throw IllegalArgumentException. */
@Test
public void testTranslateCommandlineUnbalancedDoubleQuotesThrows() {
assertThrows(IllegalArgumentException.class,
() -> BasicCurlParser.translateCommandline("curl \"unclosed"),
"Unbalanced double quotes must throw");
}

/** Unbalanced single quotes throw IllegalArgumentException. */
@Test
public void testTranslateCommandlineUnbalancedSingleQuotesThrows() {
assertThrows(IllegalArgumentException.class,
() -> BasicCurlParser.translateCommandline("curl 'unclosed"),
"Unbalanced single quotes must throw");
}

}
1 change: 1 addition & 0 deletions xdocs/changes.xml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ Summary
<li><issue>5937</issue>Remove deprecated Log4j package scanning and configure plugin metadata processing to improve startup time and avoid deprecation warnings. Contributed by Piotr P. Karwasz (github.com/piotrgithub)</li>
<li><pr>6620</pr>Fix report generation paths so dashboard output files are created in the correct location after internal refactoring.</li>
<li><bug>6456</bug>Handle malformed percent-encoded URLs gracefully when recording HTTP traffic, logging a warning instead of failing the recording.</li>
<li><bug>6773</bug>Handle escaped characters in data of curl commands.</li>
</ul>
<!-- =================== Thanks =================== -->

Expand Down