diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/curl/BasicCurlParser.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/curl/BasicCurlParser.java
index d01bb6bcd82..d84325cc3c4 100644
--- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/curl/BasicCurlParser.java
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/curl/BasicCurlParser.java
@@ -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:
+ *
+ * - Tokens are delimited by unquoted spaces.
+ * - Single-quoted strings ({@code '...'}) preserve every character literally,
+ * including backslashes. Nothing can be escaped inside single quotes; the
+ * first {@code '} ends the quoted region.
+ * - Double-quoted strings ({@code "..."}) follow POSIX rules: a backslash
+ * is an escape character only before {@code "}, {@code \}, {@code $},
+ *
`, and a newline; before any other character the
+ * backslash is kept as a literal {@code \}.
+ * - 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 },
+ * which is treated as a line-continuation and discards both characters.
+ * A backslash followed by {@code } escapes the carriage-return
+ * character itself (appending it to the current token).
+ * - ANSI-C quoting ({@code $'...'}) is not supported; an
+ * {@link IllegalArgumentException} is thrown if it is encountered.
+ *
*
* @param toProcess the command line to process.
* @return the command line broken into strings.
@@ -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 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;
}
}
}
diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/curl/BasicCurlParserTest.java b/src/protocol/http/src/test/java/org/apache/jmeter/curl/BasicCurlParserTest.java
index 0efc161cd3a..1cdff6a87c0 100644
--- a/src/protocol/http/src/test/java/org/apache/jmeter/curl/BasicCurlParserTest.java
+++ b/src/protocol/http/src/test/java/org/apache/jmeter/curl/BasicCurlParserTest.java
@@ -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;
@@ -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;
@@ -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 {
@@ -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() {
+ // 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 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 \-d 'hey' → curl / -d / hey
+ of("backslash-LF line continuation",
+ "curl \\\n-d 'hey'",
+ new String[]{"curl", "-d", "hey"}),
+
+ // Backslash + CRLF outside quotes: only \ is a line continuation.
+ // \ escapes the CR (appending it to the current token); the following
+ // 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");
+ }
+
}
diff --git a/xdocs/changes.xml b/xdocs/changes.xml
index 56b5657563c..386f3524822 100644
--- a/xdocs/changes.xml
+++ b/xdocs/changes.xml
@@ -126,6 +126,7 @@ Summary
5937Remove 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)
6620Fix report generation paths so dashboard output files are created in the correct location after internal refactoring.
6456Handle malformed percent-encoded URLs gracefully when recording HTTP traffic, logging a warning instead of failing the recording.
+ 6773Handle escaped characters in data of curl commands.