From 33bac7137a97d7ba74f6c6307fa3eea191dfc3a6 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 1 Jul 2026 14:21:55 -0300 Subject: [PATCH 01/35] Improvements and alignment to JMeter for SSL KeyStore and TrustStore support. (#112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Relative path support for SSL KeyStore/TrustStore * Improve SSL Keystore and Truststore support * Protection against failure when configuring TrustStore or KeyStore * Alias ​​in the same JMeter thread --- .../core/HTTP2FutureResponseListener.java | 1 + .../jmeter/http2/core/HTTP2JettyClient.java | 13 +- .../core/JMeterJettySslContextFactory.java | 116 +++++++++- .../http2/core/JMeterSslAliasResolver.java | 39 ++++ .../http2/core/SslClientCertAliasContext.java | 48 ++++ .../http2/core/SslClientCertAliasSupport.java | 35 +++ .../http2/core/SslClientCertAliasTag.java | 58 +++++ .../http2/core/SslStorePathResolver.java | 77 +++++++ .../http2/core/HTTP2JettyClientTest.java | 2 + .../JMeterJettySslContextFactoryTest.java | 170 ++++++++++++++ .../jmeter/http2/core/ServerBuilder.java | 19 +- .../core/SslClientCertAliasSupportTest.java | 149 ++++++++++++ .../http2/core/SslPkixSimulationTest.java | 216 ++++++++++++++++++ .../http2/core/SslStorePathResolverTest.java | 124 ++++++++++ .../jmeter/http2/core/TestSslKeyStores.java | 61 +++++ 15 files changed, 1121 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/JMeterSslAliasResolver.java create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasContext.java create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasSupport.java create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasTag.java create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/SslStorePathResolver.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactoryTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasSupportTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/SslPkixSimulationTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/SslStorePathResolverTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/TestSslKeyStores.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java index e9b366f9..8707111b 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java @@ -498,6 +498,7 @@ private ContentResponse tryHttp11Fallback() { if (request.getBody() != null) { http11Request.body(request.getBody()); } + SslClientCertAliasSupport.copyFromRequest(request, http11Request); lowLevelDebug("Retrying request with HTTP/1.1 in listener fallback: {}", request.getURI()); return http11Request.send(); } catch (Exception e) { diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index 2648ffca..d8bb4578 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -1206,6 +1206,7 @@ private ContentResponse sendWithHTTP11Only(Request originalRequest, if (originalRequest.getBody() != null) { http11Request.body(originalRequest.getBody()); } + SslClientCertAliasSupport.copyFromRequest(originalRequest, http11Request); // Send request and wait for response lowLevelDebug("Sending HTTP/1.1 fallback request"); @@ -2511,6 +2512,7 @@ private Request cloneRequest(Request originalRequest, HttpClient client) if (originalRequest.getBody() != null) { request.body(originalRequest.getBody()); } + SslClientCertAliasSupport.copyFromRequest(originalRequest, request); configureContentDecoders(client, request); return request; } @@ -2541,7 +2543,9 @@ private void configureHttpClient(HttpClient client, ClientConnector connector) { client.setDestinationIdleTimeout(idleTimeout); } client.setIdleTimeout(idleTimeout); - addConnectionLogging(client); + if (LowLevelDebugLog.isEnabled()) { + addConnectionLogging(client); + } } private static void addConnectionLogging(HttpClient client) { @@ -2589,6 +2593,9 @@ public void handshakeFailed(Event event, Throwable failure) { } private static void logAlpnLine(String message) { + if (!LowLevelDebugLog.isEnabled()) { + return; + } try { Path parent = ALPN_DEBUG_LOG_PATH.getParent(); if (parent != null) { @@ -2888,6 +2895,10 @@ private RequestContext buildRequestContext(HTTPSampleResult result, HttpClient c boolean http3Attempted = enableHttp3 && client == httpClient && shouldAttemptHttp3(uri); request.attribute(ATTR_HTTP3_ATTEMPTED, http3Attempted); request.attribute(ATTR_ORIGIN_KEY, originKey(uri)); + if ("https".equalsIgnoreCase(uri.getScheme())) { + String clientCertAlias = JMeterSslAliasResolver.resolveForRequest(); + SslClientCertAliasSupport.bindToRequest(request, clientCertAlias); + } request.onRequestBegin(r -> result.connectEnd()); request.onRequestContent( (r, c) -> result.setSentBytes(result.getSentBytes() + c.limit())); diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java b/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java index 10aa0050..d6d02da4 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java @@ -1,30 +1,55 @@ package com.blazemeter.jmeter.http2.core; +import static com.blazemeter.jmeter.http2.core.LowLevelDebugLog.lowLevelDebug; + import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.Socket; import java.security.KeyStore; import java.security.Principal; import java.security.PrivateKey; +import java.security.cert.CRL; import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.Map; import javax.net.ssl.KeyManager; import javax.net.ssl.SSLEngine; +import javax.net.ssl.TrustManager; import javax.net.ssl.X509ExtendedKeyManager; import javax.net.ssl.X509KeyManager; +import org.apache.jmeter.threads.JMeterContextService; +import org.apache.jmeter.threads.JMeterVariables; import org.apache.jmeter.util.JsseSSLManager; import org.apache.jmeter.util.SSLManager; import org.apache.jmeter.util.keystore.JmeterKeyStore; +import org.eclipse.jetty.io.ssl.SslClientConnectionFactory; import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class JMeterJettySslContextFactory extends SslContextFactory.Client + implements SslClientConnectionFactory.SslEngineFactory { -public class JMeterJettySslContextFactory extends SslContextFactory.Client { + private static final Logger LOG = LoggerFactory.getLogger(JMeterJettySslContextFactory.class); private final JmeterKeyStore keys; public JMeterJettySslContextFactory() { setTrustAll(true); + setValidatePeerCerts(false); String keyStorePath = System.getProperty("javax.net.ssl.keyStore"); if (keyStorePath != null && !keyStorePath.isEmpty()) { - setKeyStorePath("file://" + keyStorePath); + if (SslStorePathResolver.isFileBasedStoreLocation(keyStorePath)) { + String jettyKeyStoreUri = SslStorePathResolver.toJettyFileUri(keyStorePath); + String keyStoreType = SslStorePathResolver.resolveKeyStoreType(keyStorePath); + lowLevelDebug( + "SSL keyStore path resolved: javax.net.ssl.keyStore='{}' -> jettyUri='{}'", + keyStorePath, jettyKeyStoreUri); + lowLevelDebug( + "SSL keyStore type resolved: javax.net.ssl.keyStoreType='{}' -> jettyType='{}'", + System.getProperty("javax.net.ssl.keyStoreType"), keyStoreType); + configureKeyStorePathForJetty(keyStorePath, jettyKeyStoreUri, keyStoreType); + } keys = getKeyStore((JsseSSLManager) SSLManager.getInstance()); /* we need to set password after getting keystore since getKeystore may ask the user for the @@ -37,7 +62,17 @@ public JMeterJettySslContextFactory() { String truststore = System.getProperty("javax.net.ssl.trustStore"); if (truststore != null && !truststore.isEmpty()) { - setTrustStorePath("file://" + truststore); + if (SslStorePathResolver.isFileBasedStoreLocation(truststore)) { + String jettyTrustStoreUri = SslStorePathResolver.toJettyFileUri(truststore); + String trustStoreType = SslStorePathResolver.resolveTrustStoreType(truststore); + lowLevelDebug( + "SSL trustStore path resolved: javax.net.ssl.trustStore='{}' -> jettyUri='{}'", + truststore, jettyTrustStoreUri); + lowLevelDebug( + "SSL trustStore type resolved: javax.net.ssl.trustStoreType='{}' -> jettyType='{}'", + System.getProperty("javax.net.ssl.trustStoreType"), trustStoreType); + configureTrustStorePathForJetty(truststore, jettyTrustStoreUri, trustStoreType); + } getTrustStore((JsseSSLManager) SSLManager.getInstance()); /* we need to set password after getting truststore since getTrustStore may ask the user for the @@ -47,6 +82,30 @@ public JMeterJettySslContextFactory() { } } + void configureKeyStorePathForJetty(String originalPath, String jettyUri, String storeType) { + try { + setKeyStorePath(jettyUri); + setKeyStoreType(storeType); + } catch (RuntimeException e) { + LOG.warn("Could not set Jetty keyStore path for '{}': {}. " + + "Client certificate authentication may not work.", + originalPath, e.getMessage()); + lowLevelDebug("Could not set Jetty keyStore path for '{}'", originalPath, e); + } + } + + void configureTrustStorePathForJetty(String originalPath, String jettyUri, String storeType) { + try { + setTrustStorePath(jettyUri); + setTrustStoreType(storeType); + } catch (RuntimeException e) { + LOG.warn("Could not set Jetty trustStore path for '{}': {}. " + + "Trust-all SSL configuration will still be used.", + originalPath, e.getMessage()); + lowLevelDebug("Could not set Jetty trustStore path for '{}'", originalPath, e); + } + } + private JmeterKeyStore getKeyStore(JsseSSLManager sslManager) { try { Method keystoreMethod = SSLManager.class.getDeclaredMethod("getKeyStore"); @@ -77,6 +136,32 @@ protected void checkTrustAll() { protected void checkEndPointIdentificationAlgorithm() { } + // JMeter HTTP uses CustomX509TrustManager which does not validate server certificates. + // Jetty still runs PKIX when a keyStore is configured unless trust managers are overridden. + @Override + protected TrustManager[] getTrustManagers(KeyStore trustStore, + Collection crls) throws Exception { + if (isTrustAll()) { + lowLevelDebug("SSL trust managers: using TRUST_ALL_CERTS (JMeter HTTP parity)"); + return TRUST_ALL_CERTS; + } + return super.getTrustManagers(trustStore, crls); + } + + @Override + public SSLEngine newSslEngine(String host, int port, Map context) { + SSLEngine engine = super.newSSLEngine(host, port); + bindAliasFromContext(engine, context); + return engine; + } + + private static void bindAliasFromContext(SSLEngine engine, Map context) { + String alias = SslClientCertAliasContext.readAlias(context); + if (alias != null) { + SslClientCertAliasContext.bindEngine(engine, alias); + } + } + // Overwritten to provide jmeter SSLManager configured keyManagers @Override protected KeyManager[] getKeyManagers(KeyStore keyStore) throws Exception { @@ -126,12 +211,33 @@ public PrivateKey getPrivateKey(String alias) { @Override public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { - return store.getAlias(); + return resolveClientAlias(null); } + @Override public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { - return store.getAlias(); + return resolveClientAlias(engine); + } + + private String resolveClientAlias(SSLEngine engine) { + String boundAlias = SslClientCertAliasContext.resolveFromEngine(engine); + if (boundAlias != null) { + return boundAlias; + } + JMeterVariables variables = JMeterContextService.getContext().getVariables(); + if (variables != null) { + return store.getAlias(); + } + return firstConfiguredAlias(); + } + + private String firstConfiguredAlias() { + String[] aliases = store.getClientAliases("RSA", null); + if (aliases == null || aliases.length == 0) { + aliases = store.getClientAliases("EC", null); + } + return aliases != null && aliases.length > 0 ? aliases[0] : null; } @Override diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JMeterSslAliasResolver.java b/src/main/java/com/blazemeter/jmeter/http2/core/JMeterSslAliasResolver.java new file mode 100644 index 00000000..93a868c0 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/JMeterSslAliasResolver.java @@ -0,0 +1,39 @@ +package com.blazemeter.jmeter.http2.core; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import org.apache.jmeter.util.SSLManager; +import org.apache.jmeter.util.keystore.JmeterKeyStore; + +/** + * Resolves the client certificate alias on the current JMeter thread, where + * {@link JMeterContextService} variables are available. + */ +public final class JMeterSslAliasResolver { + + private JMeterSslAliasResolver() { + } + + /** + * Returns the alias to use for the next HTTPS client authentication, or {@code null} when no + * client keystore is configured. + */ + public static String resolveForRequest() { + String keyStorePath = System.getProperty(SSLManager.JAVAX_NET_SSL_KEY_STORE); + if (keyStorePath == null || keyStorePath.isEmpty()) { + return null; + } + return getKeyStore().getAlias(); + } + + private static JmeterKeyStore getKeyStore() { + try { + Method keystoreMethod = SSLManager.class.getDeclaredMethod("getKeyStore"); + keystoreMethod.setAccessible(true); + return (JmeterKeyStore) keystoreMethod.invoke(SSLManager.getInstance()); + } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + throw new IllegalStateException("Could not access JMeter keystore", e); + } + } + +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasContext.java b/src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasContext.java new file mode 100644 index 00000000..e8bd01ec --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasContext.java @@ -0,0 +1,48 @@ +package com.blazemeter.jmeter.http2.core; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import javax.net.ssl.SSLEngine; + +/** + * Holds the client certificate alias resolved on the JMeter thread and bound to the + * {@link SSLEngine} used during the async Jetty TLS handshake. + */ +final class SslClientCertAliasContext { + + static final String CONNECTION_CONTEXT_KEY = "bzm.jmeter.ssl.clientCertAlias"; + static final String REQUEST_ATTRIBUTE = CONNECTION_CONTEXT_KEY; + + private static final Map ENGINE_ALIASES = new ConcurrentHashMap<>(); + + private SslClientCertAliasContext() { + } + + static void bindEngine(SSLEngine engine, String alias) { + if (engine != null && alias != null && !alias.isEmpty()) { + ENGINE_ALIASES.put(engine, alias); + } + } + + static String resolveFromEngine(SSLEngine engine) { + if (engine == null) { + return null; + } + return ENGINE_ALIASES.get(engine); + } + + static void clearEngine(SSLEngine engine) { + if (engine != null) { + ENGINE_ALIASES.remove(engine); + } + } + + static String readAlias(Map context) { + if (context == null) { + return null; + } + Object alias = context.get(CONNECTION_CONTEXT_KEY); + return alias instanceof String ? (String) alias : null; + } + +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasSupport.java b/src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasSupport.java new file mode 100644 index 00000000..462ab2f1 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasSupport.java @@ -0,0 +1,35 @@ +package com.blazemeter.jmeter.http2.core; + +import org.eclipse.jetty.client.Request; + +/** + * Binds a client certificate alias resolved on the JMeter thread to a Jetty {@link Request}. + */ +public final class SslClientCertAliasSupport { + + private SslClientCertAliasSupport() { + } + + public static void bindToRequest(Request request, String alias) { + if (request == null || alias == null || alias.isEmpty()) { + return; + } + request.attribute(SslClientCertAliasContext.REQUEST_ATTRIBUTE, alias); + request.tag(new SslClientCertAliasTag(alias)); + } + + public static void copyFromRequest(Request source, Request target) { + if (source == null || target == null) { + return; + } + Object tag = source.getTag(); + if (tag != null) { + target.tag(tag); + } + Object alias = source.getAttributes().get(SslClientCertAliasContext.REQUEST_ATTRIBUTE); + if (alias != null) { + target.attribute(SslClientCertAliasContext.REQUEST_ATTRIBUTE, alias); + } + } + +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasTag.java b/src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasTag.java new file mode 100644 index 00000000..ac93f721 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasTag.java @@ -0,0 +1,58 @@ +package com.blazemeter.jmeter.http2.core; + +import java.io.IOException; +import java.util.Objects; +import org.eclipse.jetty.io.ClientConnectionFactory; +import org.eclipse.jetty.io.Connection; +import org.eclipse.jetty.io.EndPoint; + +/** + * Request tag that routes HTTPS connections through a per-alias destination and injects the + * resolved client certificate alias into the Jetty connection context for TLS handshakes. + */ +final class SslClientCertAliasTag implements ClientConnectionFactory.Decorator { + + private final String alias; + + SslClientCertAliasTag(String alias) { + this.alias = Objects.requireNonNull(alias, "alias"); + } + + String getAlias() { + return alias; + } + + @Override + public ClientConnectionFactory apply(ClientConnectionFactory factory) { + return new ClientConnectionFactory() { + @Override + public Connection newConnection(EndPoint endPoint, java.util.Map context) + throws IOException { + context.put(SslClientCertAliasContext.CONNECTION_CONTEXT_KEY, alias); + return factory.newConnection(endPoint, context); + } + }; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof SslClientCertAliasTag)) { + return false; + } + return alias.equals(((SslClientCertAliasTag) other).alias); + } + + @Override + public int hashCode() { + return alias.hashCode(); + } + + @Override + public String toString() { + return "SslClientCertAliasTag[" + alias + "]"; + } + +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/SslStorePathResolver.java b/src/main/java/com/blazemeter/jmeter/http2/core/SslStorePathResolver.java new file mode 100644 index 00000000..f298b449 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/SslStorePathResolver.java @@ -0,0 +1,77 @@ +package com.blazemeter.jmeter.http2.core; + +import java.io.File; +import java.util.Locale; + +/** + * Converts {@code javax.net.ssl.*} store paths to Jetty {@code file:} URIs. + */ +public final class SslStorePathResolver { + + /** JSSE / JMeter sentinel for non-file keystores (e.g. PKCS#11). */ + public static final String NON_FILE_KEYSTORE_LOCATION = "NONE"; + + private static final String KEY_STORE_TYPE_PROPERTY = "javax.net.ssl.keyStoreType"; + private static final String TRUST_STORE_TYPE_PROPERTY = "javax.net.ssl.trustStoreType"; + private static final String PKCS12 = "pkcs12"; + private static final String JKS = "JKS"; + + private SslStorePathResolver() { + // Utility class. + } + + /** + * Whether the property points at a filesystem keystore (not PKCS#11 {@code NONE}). + */ + public static boolean isFileBasedStoreLocation(final String storePath) { + return storePath != null + && !storePath.isEmpty() + && !NON_FILE_KEYSTORE_LOCATION.equalsIgnoreCase(storePath); + } + + /** + * Same as JMeter ({@code new File(path)}), then {@link File#toURI()} for Jetty. + * + * @param storePath filesystem path from {@code javax.net.ssl.keyStore} or trustStore + * @return Jetty-compatible {@code file:} URI + */ + public static String toJettyFileUri(final String storePath) { + return new File(storePath).getAbsoluteFile().toURI().toString(); + } + + /** + * Resolves the Jetty key store type from {@link #KEY_STORE_TYPE_PROPERTY}, matching + * {@link org.apache.jmeter.util.SSLManager} when unset (.p12 β†’ PKCS12, else JKS). + * + * @param keyStorePath filesystem path from {@code javax.net.ssl.keyStore} + * @return type string for {@link org.eclipse.jetty.util.ssl.SslContextFactory#setKeyStoreType} + */ + public static String resolveKeyStoreType(final String keyStorePath) { + return resolveStoreType(KEY_STORE_TYPE_PROPERTY, keyStorePath, false); + } + + /** + * Resolves the Jetty trust store type from {@link #TRUST_STORE_TYPE_PROPERTY}, or by file + * extension when unset (.p12 / .pfx β†’ PKCS12, else JKS). + * + * @param trustStorePath filesystem path from {@code javax.net.ssl.trustStore} + * @return type string for {@link org.eclipse.jetty.util.ssl.SslContextFactory#setTrustStoreType} + */ + public static String resolveTrustStoreType(final String trustStorePath) { + return resolveStoreType(TRUST_STORE_TYPE_PROPERTY, trustStorePath, true); + } + + private static String resolveStoreType(final String typeProperty, final String storePath, + final boolean inferPfxAsPkcs12) { + String explicit = System.getProperty(typeProperty); + if (explicit != null && !explicit.isEmpty()) { + return explicit; + } + String lower = storePath.toLowerCase(Locale.ENGLISH); + if (lower.endsWith(".p12") || (inferPfxAsPkcs12 && lower.endsWith(".pfx"))) { + return PKCS12; + } + return JKS; + } + +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java index 2233d216..1aa3ed02 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java @@ -73,6 +73,7 @@ import org.apache.jmeter.protocol.http.util.HTTPFileArg; import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.util.JMeterUtils; +import org.apache.jmeter.util.SSLManager; import org.assertj.core.api.JUnitSoftAssertions; import org.eclipse.jetty.client.Request; import org.eclipse.jetty.client.ContentResponse; @@ -1391,6 +1392,7 @@ public void shouldGetSuccessResponseWhenServerRequiresClientCertAndOneIsConfigur } finally { System.setProperty(keyStorePropertyName, ""); System.setProperty(keyStorePasswordPropertyName, ""); + SSLManager.reset(); } } diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactoryTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactoryTest.java new file mode 100644 index 00000000..595798cb --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactoryTest.java @@ -0,0 +1,170 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.cert.CRL; +import java.util.Collection; +import javax.net.ssl.TrustManager; +import org.apache.jmeter.util.SSLManager; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.junit.After; +import org.junit.Test; + +public class JMeterJettySslContextFactoryTest extends HTTP2TestBase { + + private String previousKeyStore; + private String previousKeyStorePassword; + private String previousTrustStore; + private String previousTrustStorePassword; + private Path tempKeystore; + + @After + public void restoreSystemProperties() throws IOException { + restoreProperty("javax.net.ssl.keyStore", previousKeyStore); + restoreProperty("javax.net.ssl.keyStorePassword", previousKeyStorePassword); + restoreProperty("javax.net.ssl.trustStore", previousTrustStore); + restoreProperty("javax.net.ssl.trustStorePassword", previousTrustStorePassword); + SSLManager.reset(); + if (tempKeystore != null) { + Files.deleteIfExists(tempKeystore); + tempKeystore = null; + } + } + + @Test + public void shouldConstructWithFileBasedKeyStorePath() throws IOException { + tempKeystore = Files.createTempFile("jmeter-jetty-ssl", ".p12"); + try (InputStream in = getClass().getResourceAsStream("keystore.p12")) { + if (in == null) { + throw new IllegalStateException("classpath resource keystore.p12 not found"); + } + in.transferTo(Files.newOutputStream(tempKeystore)); + } + + previousKeyStore = System.getProperty("javax.net.ssl.keyStore"); + previousKeyStorePassword = System.getProperty("javax.net.ssl.keyStorePassword"); + + SSLManager.reset(); + System.setProperty("javax.net.ssl.keyStore", tempKeystore.toString()); + System.setProperty("javax.net.ssl.keyStorePassword", ServerBuilder.KEYSTORE_PASSWORD); + + assertThatCode(JMeterJettySslContextFactory::new).doesNotThrowAnyException(); + } + + @Test + public void shouldUseTrustAllManagersWhenKeyStoreIsConfigured() throws Exception { + tempKeystore = Files.createTempFile("jmeter-jetty-ssl-trust", ".p12"); + try (InputStream in = getClass().getResourceAsStream("keystore.p12")) { + if (in == null) { + throw new IllegalStateException("classpath resource keystore.p12 not found"); + } + in.transferTo(Files.newOutputStream(tempKeystore)); + } + + previousKeyStore = System.getProperty("javax.net.ssl.keyStore"); + previousKeyStorePassword = System.getProperty("javax.net.ssl.keyStorePassword"); + + SSLManager.reset(); + System.setProperty("javax.net.ssl.keyStore", tempKeystore.toString()); + System.setProperty("javax.net.ssl.keyStorePassword", ServerBuilder.KEYSTORE_PASSWORD); + + TestableJMeterJettySslContextFactory factory = new TestableJMeterJettySslContextFactory(); + TrustManager[] trustManagers = factory.getTrustManagersForTest(null, null); + + assertThat(trustManagers).isSameAs(SslContextFactory.TRUST_ALL_CERTS); + } + + @Test + public void shouldConstructWhenKeyStoreIsPkcs11None() { + previousKeyStore = System.getProperty("javax.net.ssl.keyStore"); + previousKeyStorePassword = System.getProperty("javax.net.ssl.keyStorePassword"); + + SSLManager.reset(); + System.setProperty("javax.net.ssl.keyStore", + SslStorePathResolver.NON_FILE_KEYSTORE_LOCATION); + + assertThatCode(JMeterJettySslContextFactory::new).doesNotThrowAnyException(); + } + + @Test + public void shouldNotThrowWhenJettyKeyStorePathDoesNotExist() { + String missingPath = "certs/nonexistent-jmeter-jetty-keystore.p12"; + TestableJMeterJettySslContextFactory factory = new TestableJMeterJettySslContextFactory(); + + assertThatCode(() -> factory.configureKeyStorePathForJetty( + missingPath, + SslStorePathResolver.toJettyFileUri(missingPath), + "pkcs12")) + .doesNotThrowAnyException(); + } + + @Test + public void shouldNotThrowWhenJettyTrustStorePathDoesNotExist() { + String missingPath = "certs/nonexistent-jmeter-jetty-truststore.jks"; + TestableJMeterJettySslContextFactory factory = new TestableJMeterJettySslContextFactory(); + + assertThatCode(() -> factory.configureTrustStorePathForJetty( + missingPath, + SslStorePathResolver.toJettyFileUri(missingPath), + "JKS")) + .doesNotThrowAnyException(); + } + + @Test + public void shouldNotThrowWhenSetKeyStorePathFails() { + FailingKeyStorePathFactory factory = new FailingKeyStorePathFactory(); + + assertThatCode(() -> factory.configureKeyStorePathForJetty( + "certs/keystore.p12", "file:///certs/keystore.p12", "pkcs12")) + .doesNotThrowAnyException(); + } + + @Test + public void shouldNotThrowWhenSetTrustStorePathFails() { + FailingTrustStorePathFactory factory = new FailingTrustStorePathFactory(); + + assertThatCode(() -> factory.configureTrustStorePathForJetty( + "certs/truststore.jks", "file:///certs/truststore.jks", "JKS")) + .doesNotThrowAnyException(); + } + + private static final class TestableJMeterJettySslContextFactory + extends JMeterJettySslContextFactory { + + TrustManager[] getTrustManagersForTest(java.security.KeyStore trustStore, + Collection crls) throws Exception { + return getTrustManagers(trustStore, crls); + } + } + + private static final class FailingKeyStorePathFactory extends JMeterJettySslContextFactory { + + @Override + public void setKeyStorePath(String path) { + throw new IllegalArgumentException("Could not find keyStore at " + path); + } + } + + private static final class FailingTrustStorePathFactory extends JMeterJettySslContextFactory { + + @Override + public void setTrustStorePath(String path) { + throw new IllegalArgumentException("Could not find trustStore at " + path); + } + } + + private static void restoreProperty(String key, String value) { + if (value == null) { + System.clearProperty(key); + } else { + System.setProperty(key, value); + } + } + +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java b/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java index a091dd29..65fc7904 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java @@ -106,6 +106,8 @@ public class ServerBuilder { private final TeardownableServer server = new TeardownableServer(); private final HttpConfiguration httpsConfig = new HttpConfiguration(); private boolean withSSL; + private String serverKeyStorePathOverride; + private String serverKeyStoreTypeOverride; private HTTP2ServerConnectionFactory http2ConnectionFactory; private HttpConnectionFactory http1ConnectionFactory; private HTTP2CServerConnectionFactory http2cConnectionFactory; @@ -139,6 +141,15 @@ public ServerBuilder withSSL() { return this; } + /** + * Uses a filesystem keystore for the server TLS certificate (e.g. an untrusted JKS in tests). + */ + public ServerBuilder withServerKeyStorePath(String keyStorePath, String keyStoreType) { + this.serverKeyStorePathOverride = keyStorePath; + this.serverKeyStoreTypeOverride = keyStoreType; + return this; + } + public ServerBuilder withALPN() { this.ALPN = true; return this; @@ -244,7 +255,13 @@ private List buildConnectionFactories() { private SslContextFactory.Server buildServerSslContextFactory() { SslContextFactory.Server sslContextFactory = new SslContextFactory.Server(); - sslContextFactory.setKeyStorePath(getKeyStorePathAsUriPathInSSLContextFactoryFormat()); + if (serverKeyStorePathOverride != null) { + sslContextFactory.setKeyStorePath( + SslStorePathResolver.toJettyFileUri(serverKeyStorePathOverride)); + sslContextFactory.setKeyStoreType(serverKeyStoreTypeOverride); + } else { + sslContextFactory.setKeyStorePath(getKeyStorePathAsUriPathInSSLContextFactoryFormat()); + } sslContextFactory.setKeyStorePassword(KEYSTORE_PASSWORD); return sslContextFactory; } diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasSupportTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasSupportTest.java new file mode 100644 index 00000000..ab461564 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasSupportTest.java @@ -0,0 +1,149 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.Principal; +import java.util.HashMap; +import java.util.Map; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.X509ExtendedKeyManager; +import org.apache.jmeter.threads.JMeterContextService; +import org.apache.jmeter.threads.JMeterVariables; +import org.apache.jmeter.util.SSLManager; +import org.eclipse.jetty.io.ClientConnectionFactory; +import org.eclipse.jetty.io.ssl.SslClientConnectionFactory; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class SslClientCertAliasSupportTest extends HTTP2TestBase { + + private static final String CERT_ALIAS_VAR = "certAlias"; + private static final String KEY_ALIAS = "client"; + + private String previousKeyStore; + private String previousKeyStorePassword; + private String previousKeyStoreType; + private Path clientKeystore; + + @Before + public void setUpKeystore() throws Exception { + clientKeystore = TestSslKeyStores.createJks(KEY_ALIAS, "CN=client, O=SslAliasTest, C=US"); + + previousKeyStore = System.getProperty("javax.net.ssl.keyStore"); + previousKeyStorePassword = System.getProperty("javax.net.ssl.keyStorePassword"); + previousKeyStoreType = System.getProperty("javax.net.ssl.keyStoreType"); + + SSLManager.reset(); + System.setProperty("javax.net.ssl.keyStore", clientKeystore.toString()); + System.setProperty("javax.net.ssl.keyStorePassword", TestSslKeyStores.PASSWORD); + System.setProperty("javax.net.ssl.keyStoreType", "JKS"); + SSLManager.getInstance().configureKeystore(true, 0, -1, CERT_ALIAS_VAR); + + JMeterVariables variables = new JMeterVariables(); + variables.put(CERT_ALIAS_VAR, KEY_ALIAS); + JMeterContextService.getContext().setVariables(variables); + } + + @After + public void tearDown() throws IOException { + restoreProperty("javax.net.ssl.keyStore", previousKeyStore); + restoreProperty("javax.net.ssl.keyStorePassword", previousKeyStorePassword); + restoreProperty("javax.net.ssl.keyStoreType", previousKeyStoreType); + JMeterContextService.getContext().setVariables(null); + SSLManager.reset(); + if (clientKeystore != null) { + Files.deleteIfExists(clientKeystore); + Path parent = clientKeystore.getParent(); + if (parent != null) { + Files.deleteIfExists(parent); + } + clientKeystore = null; + } + } + + @Test + public void shouldResolveAliasOnJMeterThreadWhenVariableIsConfigured() { + assertThat(JMeterSslAliasResolver.resolveForRequest()).isEqualTo(KEY_ALIAS); + } + + @Test + public void shouldInjectAliasIntoConnectionContextFromRequestTag() throws Exception { + SslClientCertAliasTag tag = new SslClientCertAliasTag(KEY_ALIAS); + ClientConnectionFactory decorated = tag.apply((endPoint, context) -> null); + Map context = new HashMap<>(); + + decorated.newConnection(null, context); + + assertThat(SslClientCertAliasContext.readAlias(context)).isEqualTo(KEY_ALIAS); + } + + @Test + public void shouldChooseBoundAliasOnJettyThreadWithoutJMeterVariables() throws Exception { + String alias = JMeterSslAliasResolver.resolveForRequest(); + JMeterContextService.getContext().setVariables(null); + + JMeterJettySslContextFactory factory = new JMeterJettySslContextFactory(); + factory.start(); + try { + Method getKeyManagers = SslContextFactory.class + .getDeclaredMethod("getKeyManagers", KeyStore.class); + getKeyManagers.setAccessible(true); + KeyStore keyStore = KeyStore.getInstance("JKS"); + try (InputStream in = Files.newInputStream(clientKeystore)) { + keyStore.load(in, TestSslKeyStores.PASSWORD.toCharArray()); + } + KeyManager keyManager = ((KeyManager[]) getKeyManagers.invoke(factory, keyStore))[0]; + assertThat(keyManager).isInstanceOf(X509ExtendedKeyManager.class); + + SSLEngine engine = factory.getSslContext().createSSLEngine(); + SslClientCertAliasContext.bindEngine(engine, alias); + + assertThatCode(() -> ((X509ExtendedKeyManager) keyManager) + .chooseEngineClientAlias(new String[] {"RSA"}, new Principal[0], engine)) + .doesNotThrowAnyException(); + assertThat(((X509ExtendedKeyManager) keyManager) + .chooseEngineClientAlias(new String[] {"RSA"}, new Principal[0], engine)) + .isEqualTo(KEY_ALIAS); + } finally { + factory.stop(); + } + } + + @Test + public void shouldBindAliasFromSslEngineFactoryContext() throws Exception { + JMeterJettySslContextFactory factory = new JMeterJettySslContextFactory(); + factory.start(); + try { + Map context = new HashMap<>(); + context.put(SslClientCertAliasContext.CONNECTION_CONTEXT_KEY, KEY_ALIAS); + + Method method = SslClientConnectionFactory.SslEngineFactory.class + .getMethod("newSslEngine", String.class, int.class, Map.class); + SSLEngine engine = (SSLEngine) method.invoke(factory, "localhost", 443, context); + + assertThat(SslClientCertAliasContext.resolveFromEngine(engine)).isEqualTo(KEY_ALIAS); + } finally { + factory.stop(); + } + } + + private static void restoreProperty(String key, String value) { + if (value == null) { + System.clearProperty(key); + } else { + System.setProperty(key, value); + } + } + +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/SslPkixSimulationTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/SslPkixSimulationTest.java new file mode 100644 index 00000000..42736c46 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/SslPkixSimulationTest.java @@ -0,0 +1,216 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.core.ServerBuilder.TeardownableServer; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import com.blazemeter.jmeter.http2.sampler.JMeterTestUtils; +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.SSLSocket; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.util.JMeterUtils; +import org.apache.jmeter.util.SSLManager; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Reproduces BlazeMeter-like PKIX failures: client {@code javax.net.ssl.keyStore} configured + * (JKS) while the server presents a certificate that is not in the JVM trust store. + */ +public class SslPkixSimulationTest extends HTTP2TestBase { + + private static final String KEY_STORE_PROPERTY = "javax.net.ssl.keyStore"; + private static final String KEY_STORE_PASSWORD_PROPERTY = "javax.net.ssl.keyStorePassword"; + private static final String KEY_STORE_TYPE_PROPERTY = "javax.net.ssl.keyStoreType"; + + private String previousKeyStore; + private String previousKeyStorePassword; + private String previousKeyStoreType; + private String previousSharedThreadPoolProperty; + + private Path serverKeystore; + private Path clientKeystore; + private TeardownableServer server; + private int serverPort = -1; + private HTTP2JettyClient client; + private HTTP2Sampler sampler; + + @Before + public void setUp() throws Exception { + JMeterTestUtils.setupJmeterEnv(); + previousKeyStore = System.getProperty(KEY_STORE_PROPERTY); + previousKeyStorePassword = System.getProperty(KEY_STORE_PASSWORD_PROPERTY); + previousKeyStoreType = System.getProperty(KEY_STORE_TYPE_PROPERTY); + previousSharedThreadPoolProperty = JMeterUtils.getProperty("httpJettyClient.sharedThreadPool"); + JMeterUtils.setProperty("httpJettyClient.sharedThreadPool", "false"); + + serverKeystore = TestSslKeyStores.createJks("server", "CN=localhost, O=SslSim, C=US"); + clientKeystore = TestSslKeyStores.createJks("client", "CN=client, O=SslSim, C=US"); + + configureClientKeyStoreLikeBlazeMeter(clientKeystore); + startUntrustedHttpsServer(); + + sampler = new HTTP2Sampler(); + sampler.setMethod(HTTPConstants.GET); + sampler.setDomain(ServerBuilder.HOST_NAME); + sampler.setProtocol(HTTPConstants.PROTOCOL_HTTPS); + sampler.setPort(serverPort); + sampler.setPath(""); + } + + @After + public void tearDown() throws Exception { + if (client != null) { + client.stop(); + client = null; + } + if (sampler != null) { + sampler.threadFinished(); + sampler = null; + } + if (server != null) { + server.stop(); + server = null; + } + restoreProperty(KEY_STORE_PROPERTY, previousKeyStore); + restoreProperty(KEY_STORE_PASSWORD_PROPERTY, previousKeyStorePassword); + restoreProperty(KEY_STORE_TYPE_PROPERTY, previousKeyStoreType); + SSLManager.reset(); + if (previousSharedThreadPoolProperty == null) { + JMeterUtils.getJMeterProperties().remove("httpJettyClient.sharedThreadPool"); + } else { + JMeterUtils.setProperty("httpJettyClient.sharedThreadPool", previousSharedThreadPoolProperty); + } + deleteIfExists(serverKeystore); + deleteIfExists(clientKeystore); + } + + @Test + public void shouldReproducePkixWhenTrustAllIsSetButTrustManagersAreNotOverridden() { + TrustAllWithoutTrustManagerOverride factory = new TrustAllWithoutTrustManagerOverride(); + + assertThatThrownBy(() -> performTlsHandshake(factory)) + .isInstanceOf(SSLHandshakeException.class) + .hasMessageContaining("PKIX path building failed"); + } + + @Test + public void shouldHandshakeWhenJMeterJettySslContextFactoryIsUsed() { + assertThatCode(() -> performTlsHandshake(new JMeterJettySslContextFactory())) + .doesNotThrowAnyException(); + } + + @Test + public void shouldCompleteHttp2RequestWhenClientKeyStoreConfiguredAgainstUntrustedServer() + throws Exception { + client = new HTTP2JettyClient(); + client.start(); + + HTTPSampleResult result = client.sample( + sampler, + buildBaseResult(createUrl(ServerBuilder.SERVER_PATH_200), HTTPConstants.GET), + false, + 0); + + assertThat(result.isSuccessful()).isTrue(); + assertThat(result.getResponseDataAsString()).isEqualTo(ServerBuilder.SERVER_RESPONSE); + } + + private void startUntrustedHttpsServer() throws Exception { + server = new ServerBuilder() + .withHTTP1() + .withHTTP2() + .withALPN() + .withHTTP2C() + .withSSL() + .withServerKeyStorePath(serverKeystore.toString(), "JKS") + .buildServer(); + server.start(); + } + + private void configureClientKeyStoreLikeBlazeMeter(Path keyStorePath) { + SSLManager.reset(); + System.setProperty(KEY_STORE_PROPERTY, keyStorePath.toString()); + System.setProperty(KEY_STORE_PASSWORD_PROPERTY, TestSslKeyStores.PASSWORD); + System.setProperty(KEY_STORE_TYPE_PROPERTY, "JKS"); + } + + private void performTlsHandshake(SslContextFactory.Client factory) throws Exception { + if (serverPort < 0) { + ServerConnector connector = (ServerConnector) server.getConnectors()[0]; + serverPort = connector.getLocalPort(); + } + factory.start(); + try { + try (SSLSocket socket = (SSLSocket) factory.getSslContext().getSocketFactory() + .createSocket(ServerBuilder.HOST_NAME, serverPort)) { + socket.setSoTimeout(10_000); + socket.startHandshake(); + } + } finally { + factory.stop(); + } + } + + private URL createUrl(String path) throws Exception { + if (serverPort < 0) { + ServerConnector connector = (ServerConnector) server.getConnectors()[0]; + serverPort = connector.getLocalPort(); + } + sampler.setPort(serverPort); + return new URI(HTTPConstants.PROTOCOL_HTTPS, null, ServerBuilder.HOST_NAME, serverPort, path, + null, null).toURL(); + } + + private static HTTPSampleResult buildBaseResult(URL url, String method) { + HTTPSampleResult result = new HTTPSampleResult(); + result.setURL(url); + result.setHTTPMethod(method); + return result; + } + + private static void restoreProperty(String key, String value) { + if (value == null) { + System.clearProperty(key); + } else { + System.setProperty(key, value); + } + } + + private static void deleteIfExists(Path path) throws IOException { + if (path != null) { + Files.deleteIfExists(path); + } + } + + /** + * Mirrors {@code setTrustAll(true)} plus client keyStore configuration, but without the + * {@link JMeterJettySslContextFactory#getTrustManagers} override (pre-fix Jetty behaviour). + */ + private static final class TrustAllWithoutTrustManagerOverride extends SslContextFactory.Client { + + private TrustAllWithoutTrustManagerOverride() { + setTrustAll(true); + String keyStorePath = System.getProperty(KEY_STORE_PROPERTY); + if (keyStorePath != null && !keyStorePath.isEmpty() + && SslStorePathResolver.isFileBasedStoreLocation(keyStorePath)) { + setKeyStorePath(SslStorePathResolver.toJettyFileUri(keyStorePath)); + setKeyStoreType(SslStorePathResolver.resolveKeyStoreType(keyStorePath)); + setKeyStorePassword(System.getProperty(KEY_STORE_PASSWORD_PROPERTY)); + } + } + } + +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/SslStorePathResolverTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/SslStorePathResolverTest.java new file mode 100644 index 00000000..f9c27b1d --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/SslStorePathResolverTest.java @@ -0,0 +1,124 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.Assume.assumeTrue; + +import java.io.File; +import java.net.URI; +import java.nio.file.Paths; +import org.junit.Test; + +public class SslStorePathResolverTest { + + private static final String RELATIVE_STORE = "certs/keystore.p12"; + + @Test + public void naiveFilePrefixOnRelativePathTreatsFirstSegmentAsUriAuthority() { + URI malformed = URI.create("file://" + RELATIVE_STORE); + assertThat(malformed.getAuthority()).isEqualTo("certs"); + assertThat(malformed.getPath()).isEqualTo("/keystore.p12"); + } + + @Test + public void naiveFilePrefixOnRelativePathRejectsAuthorityOnUnix() { + assumeTrue(!isWindows()); + assertThatThrownBy(() -> Paths.get(URI.create("file://" + RELATIVE_STORE))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("authority"); + } + + @Test + public void toJettyFileUriMatchesFileToUriForRelativePath() { + assertThat(SslStorePathResolver.toJettyFileUri(RELATIVE_STORE)) + .isEqualTo(new File(RELATIVE_STORE).getAbsoluteFile().toURI().toString()); + } + + @Test + public void toJettyFileUriMatchesFileToUriForAbsolutePath() { + String absolute = new File(System.getProperty("user.dir"), RELATIVE_STORE) + .getAbsolutePath(); + assertThat(SslStorePathResolver.toJettyFileUri(absolute)) + .isEqualTo(new File(absolute).getAbsoluteFile().toURI().toString()); + } + + @Test + public void toJettyFileUriHasNoUriAuthorityForRelativePath() { + URI jettyUri = URI.create(SslStorePathResolver.toJettyFileUri(RELATIVE_STORE)); + assertThat(jettyUri.getAuthority()).isNull(); + assertThat(Paths.get(jettyUri)) + .isEqualTo(new File(RELATIVE_STORE).getAbsoluteFile().toPath()); + } + + @Test + public void noneIsNotAFileBasedStoreLocation() { + assertThat(SslStorePathResolver.isFileBasedStoreLocation("NONE")).isFalse(); + assertThat(SslStorePathResolver.isFileBasedStoreLocation("none")).isFalse(); + assertThat(SslStorePathResolver.isFileBasedStoreLocation(RELATIVE_STORE)).isTrue(); + } + + @Test + public void resolveKeyStoreTypeUsesPropertyWhenSet() { + String previous = System.getProperty("javax.net.ssl.keyStoreType"); + try { + System.setProperty("javax.net.ssl.keyStoreType", "PKCS12"); + assertThat(SslStorePathResolver.resolveKeyStoreType("certs/keystore.jks")) + .isEqualTo("PKCS12"); + } finally { + restoreProperty("javax.net.ssl.keyStoreType", previous); + } + } + + @Test + public void resolveKeyStoreTypeInfersPkcs12FromP12ExtensionLikeJMeter() { + String previous = System.getProperty("javax.net.ssl.keyStoreType"); + try { + System.clearProperty("javax.net.ssl.keyStoreType"); + assertThat(SslStorePathResolver.resolveKeyStoreType(RELATIVE_STORE)).isEqualTo("pkcs12"); + assertThat(SslStorePathResolver.resolveKeyStoreType("certs/keystore.jks")).isEqualTo("JKS"); + assertThat(SslStorePathResolver.resolveKeyStoreType("certs/keystore.pfx")).isEqualTo("JKS"); + } finally { + restoreProperty("javax.net.ssl.keyStoreType", previous); + } + } + + @Test + public void resolveTrustStoreTypeUsesPropertyWhenSet() { + String previous = System.getProperty("javax.net.ssl.trustStoreType"); + try { + System.setProperty("javax.net.ssl.trustStoreType", "JKS"); + assertThat(SslStorePathResolver.resolveTrustStoreType("certs/trust.p12")) + .isEqualTo("JKS"); + } finally { + restoreProperty("javax.net.ssl.trustStoreType", previous); + } + } + + @Test + public void resolveTrustStoreTypeInfersPkcs12FromP12OrPfxWhenUnset() { + String previous = System.getProperty("javax.net.ssl.trustStoreType"); + try { + System.clearProperty("javax.net.ssl.trustStoreType"); + assertThat(SslStorePathResolver.resolveTrustStoreType("certs/trust.p12")) + .isEqualTo("pkcs12"); + assertThat(SslStorePathResolver.resolveTrustStoreType("certs/trust.pfx")) + .isEqualTo("pkcs12"); + assertThat(SslStorePathResolver.resolveTrustStoreType("certs/trust.jks")).isEqualTo("JKS"); + } finally { + restoreProperty("javax.net.ssl.trustStoreType", previous); + } + } + + private static void restoreProperty(String key, String value) { + if (value == null) { + System.clearProperty(key); + } else { + System.setProperty(key, value); + } + } + + private static boolean isWindows() { + return File.separatorChar == '\\'; + } + +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/TestSslKeyStores.java b/src/test/java/com/blazemeter/jmeter/http2/core/TestSslKeyStores.java new file mode 100644 index 00000000..1bd1cf22 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/TestSslKeyStores.java @@ -0,0 +1,61 @@ +package com.blazemeter.jmeter.http2.core; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +/** + * Generates ephemeral JKS keystores for SSL handshake simulations. + */ +final class TestSslKeyStores { + + static final String PASSWORD = ServerBuilder.KEYSTORE_PASSWORD; + private static final String KEYTOOL = resolveKeytoolExecutable(); + + private TestSslKeyStores() { + } + + static Path createJks(String alias, String distinguishedName) throws IOException, InterruptedException { + Path keystore = Files.createTempDirectory("ssl-sim-").resolve(alias + ".jks"); + runKeytool(List.of( + KEYTOOL, + "-genkeypair", + "-alias", alias, + "-keyalg", "RSA", + "-keysize", "2048", + "-validity", "365", + "-keystore", keystore.toAbsolutePath().toString(), + "-storetype", "JKS", + "-storepass", PASSWORD, + "-keypass", PASSWORD, + "-dname", distinguishedName + )); + return keystore; + } + + private static void runKeytool(List command) throws IOException, InterruptedException { + Process process = new ProcessBuilder(command) + .redirectErrorStream(true) + .start(); + String output = new String(process.getInputStream().readAllBytes()); + int exitCode = process.waitFor(); + if (exitCode != 0) { + throw new IOException("keytool failed (exit " + exitCode + "): " + output); + } + } + + private static String resolveKeytoolExecutable() { + String javaHome = System.getProperty("java.home"); + Path candidate = Path.of(javaHome, "bin", isWindows() ? "keytool.exe" : "keytool"); + if (Files.isRegularFile(candidate)) { + return candidate.toString(); + } + return isWindows() ? "keytool.exe" : "keytool"; + } + + private static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase().contains("win"); + } + +} From b19f9118610810cbac749d3243998d954e8ef92c Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Thu, 2 Jul 2026 17:58:18 -0300 Subject: [PATCH 02/35] Gracefully handle keystore load failures and reinitialize SSLManager (#115) * Gracefully handle JMeter keystore load failures * Reinitialize SSLManager and client for mTLS client cert test --- .../http2/core/JMeterJettySslContextFactory.java | 14 +++++++++++++- .../jmeter/http2/core/HTTP2JettyClientTest.java | 15 ++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java b/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java index d6d02da4..c4caec94 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java @@ -50,7 +50,7 @@ public JMeterJettySslContextFactory() { System.getProperty("javax.net.ssl.keyStoreType"), keyStoreType); configureKeyStorePathForJetty(keyStorePath, jettyKeyStoreUri, keyStoreType); } - keys = getKeyStore((JsseSSLManager) SSLManager.getInstance()); + keys = loadJMeterKeyStore(keyStorePath); /* we need to set password after getting keystore since getKeystore may ask the user for the password. @@ -82,6 +82,18 @@ public JMeterJettySslContextFactory() { } } + private JmeterKeyStore loadJMeterKeyStore(String keyStorePath) { + try { + return getKeyStore((JsseSSLManager) SSLManager.getInstance()); + } catch (RuntimeException e) { + LOG.warn("Could not load JMeter keyStore from '{}': {}. " + + "Client certificate alias selection may be unavailable.", + keyStorePath, e.getMessage()); + lowLevelDebug("Could not load JMeter keyStore from '{}'", keyStorePath, e); + return null; + } + } + void configureKeyStorePathForJetty(String originalPath, String jettyUri, String storeType) { try { setKeyStorePath(jettyUri); diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java index 1aa3ed02..815c6dbb 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java @@ -1383,15 +1383,24 @@ public void shouldGetSuccessResponseWhenServerRequiresClientCertAndOneIsConfigur String keyStorePasswordPropertyName = "javax.net.ssl.keyStorePassword"; System.setProperty(keyStorePropertyName, getKeyStorePathAsUriPathWithNetSslKeyStoreFormat()); System.setProperty(keyStorePasswordPropertyName, KEYSTORE_PASSWORD); + System.setProperty("javax.net.ssl.keyStoreType", "PKCS12"); + SSLManager.reset(); client.stop(); - client = new HTTP2JettyClient(); + client = new HTTP2JettyClient(false, "client-cert-test", + HTTP2ClientProfileConfig.builder() + .enableHttp1(true) + .enableHttp2(false) + .enableHttp3(false) + .alpnEnabled(false) + .build()); client.start(); try { HTTPSampleResult result = sampleWithGet(); assertThat(result.getResponseDataAsString()).isEqualTo(SERVER_RESPONSE); } finally { - System.setProperty(keyStorePropertyName, ""); - System.setProperty(keyStorePasswordPropertyName, ""); + System.clearProperty(keyStorePropertyName); + System.clearProperty(keyStorePasswordPropertyName); + System.clearProperty("javax.net.ssl.keyStoreType"); SSLManager.reset(); } } From 5a31d83148a8e680a1552980d256c2eafef28f13 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Thu, 2 Jul 2026 19:33:59 -0300 Subject: [PATCH 03/35] Preserve pre-decoding headers (#107) --- .../jmeter/http2/core/HTTP2JettyClient.java | 24 ++- .../core/JmeterCompressionHeadersSupport.java | 144 ++++++++++++++++++ .../http2/core/HTTP2JettyClientTest.java | 9 +- .../JmeterCompressionHeadersSupportTest.java | 95 ++++++++++++ 4 files changed, 260 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupport.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupportTest.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index d8bb4578..9b7231f3 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -1134,7 +1134,7 @@ public HTTPSampleResult retryWithHTTP11Only(HTTP2Sampler sampler, HTTPSampleResu } ensureHostHeader(http11Request, url); - configureContentDecoders(httpClientHttp1Only, http11Request); + configureContentDecodersAndCapture(httpClientHttp1Only, http11Request); // Copy body if present setBody(http11Request, sampler, result); @@ -1200,7 +1200,7 @@ private ContentResponse sendWithHTTP11Only(Request originalRequest, } ensureHostHeader(http11Request, uri); - configureContentDecoders(httpClientHttp1Only, http11Request); + configureContentDecodersAndCapture(httpClientHttp1Only, http11Request); // Copy body if present if (originalRequest.getBody() != null) { @@ -1255,7 +1255,7 @@ private ContentResponse sendWithH2cPriorKnowledge(Request originalRequest) } } ensureHostHeader(h2cRequest, uri); - configureContentDecoders(httpClientH2cPrior, h2cRequest); + configureContentDecodersAndCapture(httpClientH2cPrior, h2cRequest); if (originalRequest.getBody() != null) { h2cRequest.body(originalRequest.getBody()); } @@ -1300,7 +1300,7 @@ private void samplePrepareRequest(Request request, addPreemptiveAuthorizationHeader(request, url, sampler.getAuthManager()); lowLevelDebug("Headers set, request URI: {}", request.getURI()); - configureContentDecoders(client, request); + configureContentDecodersAndCapture(client, request); String ae = request.getHeaders() != null ? request.getHeaders().get(HttpHeader.ACCEPT_ENCODING) @@ -2106,6 +2106,11 @@ private void configureContentDecoders(HttpClient client, Request request) { } } + private void configureContentDecodersAndCapture(HttpClient client, Request request) { + configureContentDecoders(client, request); + JmeterCompressionHeadersSupport.installCapture(request); + } + private HttpClient selectHttpClient(URI uri) { if (uri != null && "http".equalsIgnoreCase(uri.getScheme())) { if (!enableHttp2 && enableHttp1) { @@ -2513,7 +2518,7 @@ private Request cloneRequest(Request originalRequest, HttpClient client) request.body(originalRequest.getBody()); } SslClientCertAliasSupport.copyFromRequest(originalRequest, request); - configureContentDecoders(client, request); + configureContentDecodersAndCapture(client, request); return request; } @@ -3648,9 +3653,11 @@ private void setResultContentResponse(HTTPSampleResult result, result.setURL(contentResponse.getRequest().getURI().toURL()); } + HttpFields sampleResultHeaders = + JmeterCompressionHeadersSupport.headersForSampleResult(contentResponse); long headerBytes = (long) result.getResponseHeaders().length() // condensed length (without \r) - + (long) contentResponse.getHeaders().asString().length() // Add \r for each header + + (long) sampleResultHeaders.asString().length() // Add \r for each header + 1L // Add \r for initial header + 2L; // final \r\n before data result.setHeadersSize((int) headerBytes); @@ -3658,8 +3665,9 @@ private void setResultContentResponse(HTTPSampleResult result, private String extractResponseHeaders(ContentResponse contentResponse, String message) { + HttpFields headers = JmeterCompressionHeadersSupport.headersForSampleResult(contentResponse); return contentResponse.getVersion() + " " + contentResponse.getStatus() + " " + message + "\n" - + buildHeadersString(contentResponse.getHeaders()); + + buildHeadersString(headers); } private String extractRedirectLocation(ContentResponse contentResponse) { @@ -3749,6 +3757,8 @@ private byte[] maybeDecodeCompressedContent(ContentResponse contentResponse) { if (contentEncoding == null || contentEncoding.trim().isEmpty()) { return content; } + JmeterCompressionHeadersSupport.captureIfCompressed( + contentResponse.getRequest(), contentResponse.getHeaders()); String encodingToken = normalizeEncodingToken(contentEncoding); if (encodingToken.isEmpty()) { return content; diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupport.java b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupport.java new file mode 100644 index 00000000..b936947d --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupport.java @@ -0,0 +1,144 @@ +package com.blazemeter.jmeter.http2.core; + +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.client.Response; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpHeader; + +/** + * Preserves wire {@code Content-Encoding}, {@code Content-Length} and {@code Content-MD5} + * for compressed responses, matching Apache JMeter {@code HTTPHC4Impl} behaviour after + * HttpClient {@code ResponseContentEncoding} decodes the body (Bug 59401). + */ +final class JmeterCompressionHeadersSupport { + + static final String REQUEST_ATTR_WIRE_COMPRESSION_HEADERS = + "bzm.jmeter.wireCompressionHeaders"; + + private static final String[] HEADERS_TO_SAVE = { + HTTPConstants.HEADER_CONTENT_LENGTH, + HTTPConstants.HEADER_CONTENT_ENCODING, + "Content-MD5" + }; + + private JmeterCompressionHeadersSupport() { + } + + static void installCapture(Request request) { + if (request == null) { + return; + } + request.onResponseHeader(JmeterCompressionHeadersSupport::onResponseHeader); + request.onResponseHeaders(JmeterCompressionHeadersSupport::onResponseHeaders); + } + + private static boolean onResponseHeader(Response response, HttpField field) { + if (response != null && field != null && HttpHeader.CONTENT_ENCODING.is(field.getName())) { + Request request = response.getRequest(); + if (request != null) { + mergeSavedHeader(request, field.getName(), field.getValue()); + } + } + return true; + } + + private static void onResponseHeaders(Response response) { + if (response != null && response.getRequest() != null && response.getHeaders() != null) { + captureIfCompressed(response.getRequest(), response.getHeaders()); + } + } + + private static void mergeSavedHeader(Request request, String name, String value) { + if (request == null || name == null || value == null) { + return; + } + HttpFields.Mutable saved = getOrCreateSaved(request); + saved.put(name, value); + request.attribute(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS, saved); + } + + private static HttpFields.Mutable getOrCreateSaved(Request request) { + Object existing = request.getAttributes().get(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS); + if (existing instanceof HttpFields.Mutable) { + return (HttpFields.Mutable) existing; + } + if (existing instanceof HttpFields) { + HttpFields.Mutable copy = HttpFields.build((HttpFields) existing); + request.attribute(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS, copy); + return copy; + } + return HttpFields.build(); + } + + static void captureIfCompressed(Request request, HttpFields headers) { + if (request == null || headers == null) { + return; + } + String contentEncoding = headers.get(HttpHeader.CONTENT_ENCODING); + if (contentEncoding == null || contentEncoding.trim().isEmpty()) { + return; + } + HttpFields.Mutable saved = getOrCreateSaved(request); + for (String name : HEADERS_TO_SAVE) { + String value = headers.get(name); + if (value != null) { + saved.put(name, value); + } + } + if (saved.size() > 0) { + request.attribute(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS, saved); + } + } + + static HttpFields headersForSampleResult(ContentResponse contentResponse) { + if (contentResponse == null || contentResponse.getHeaders() == null) { + return contentResponse != null ? contentResponse.getHeaders() : HttpFields.EMPTY; + } + HttpFields current = contentResponse.getHeaders(); + Request request = contentResponse.getRequest(); + if (request == null) { + return current; + } + Object savedAttr = request.getAttributes().get(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS); + if (!(savedAttr instanceof HttpFields)) { + return current; + } + HttpFields saved = (HttpFields) savedAttr; + if (saved.size() == 0) { + return current; + } + HttpFields.Mutable merged = HttpFields.build(current); + // Jetty removes Content-Encoding but often leaves an updated Content-Length (decoded size). + // Match HTTPHC4Impl: restore all wire compression headers when encoding was stripped. + if (!hasContentEncoding(current) && hasContentEncoding(saved)) { + for (String name : HEADERS_TO_SAVE) { + String value = saved.get(name); + if (value != null) { + merged.put(name, value); + } + } + } else { + for (String name : HEADERS_TO_SAVE) { + if (merged.contains(name)) { + continue; + } + String value = saved.get(name); + if (value != null) { + merged.put(name, value); + } + } + } + return merged; + } + + private static boolean hasContentEncoding(HttpFields headers) { + if (headers == null) { + return false; + } + String contentEncoding = headers.get(HttpHeader.CONTENT_ENCODING); + return contentEncoding != null && !contentEncoding.trim().isEmpty(); + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java index 815c6dbb..e1630880 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java @@ -281,9 +281,8 @@ public void shouldReturnSuccessSampleResultWhenSuccessResponseWithContentTypeGzi sampler.setHeaderManager(hm); try { HTTPSampleResult result = sampleWithGet(SERVER_PATH_200_GZIP); - boolean hasGzipHeader = result.getResponseHeaders().contains("Content-Encoding: gzip"); - boolean hasBody = result.getResponseData() != null && result.getResponseData().length > 0; - assertThat(hasGzipHeader || hasBody).isTrue(); + assertThat(result.getResponseHeaders()).containsIgnoringCase("content-encoding: gzip"); + assertThat(result.getResponseData()).containsExactly(BINARY_RESPONSE_BODY); } finally { if (originalEnableHttp1 == null) { JMeterUtils.getJMeterProperties().remove("httpJettyClient.enableHttp1"); @@ -301,8 +300,8 @@ public void shouldReturnSuccessSampleResultWhenSuccessResponseWithContentTypeBro hm.add(new Header(HttpHeader.ACCEPT_ENCODING.asString(), "br")); sampler.setHeaderManager(hm); HTTPSampleResult result = sampleWithGet(SERVER_PATH_200_BROTLI); - // Verify that the request was successful and content was decompressed assertThat(result.isSuccessful()).isTrue(); + assertThat(result.getResponseHeaders()).containsIgnoringCase("content-encoding: br"); assertThat(result.getResponseData()).containsExactly(BINARY_RESPONSE_BODY); } @@ -314,8 +313,8 @@ public void shouldReturnSuccessSampleResultWhenSuccessResponseWithContentTypeZst hm.add(new Header(HttpHeader.ACCEPT_ENCODING.asString(), "zstd")); sampler.setHeaderManager(hm); HTTPSampleResult result = sampleWithGet(SERVER_PATH_200_ZSTD); - // Verify that the request was successful and content was decompressed assertThat(result.isSuccessful()).isTrue(); + assertThat(result.getResponseHeaders()).containsIgnoringCase("content-encoding: zstd"); assertThat(result.getResponseData()).containsExactly(BINARY_RESPONSE_BODY); } diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupportTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupportTest.java new file mode 100644 index 00000000..68187071 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupportTest.java @@ -0,0 +1,95 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; + +import java.util.HashMap; +import java.util.Map; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpHeader; +import org.junit.Test; +import org.mockito.Mockito; + +public class JmeterCompressionHeadersSupportTest { + + private static Request mockRequestWithAttributes() { + Map attributes = new HashMap<>(); + Request request = Mockito.mock(Request.class); + Mockito.when(request.getAttributes()).thenReturn(attributes); + Mockito.doAnswer(invocation -> { + attributes.put(invocation.getArgument(0), invocation.getArgument(1)); + return invocation.getMock(); + }).when(request).attribute(anyString(), any()); + return request; + } + + @Test + public void shouldRestoreWireCompressionHeadersRemovedByDecoder() { + Request request = mockRequestWithAttributes(); + + HttpFields wire = HttpFields.build() + .add(HttpHeader.CONTENT_ENCODING, "gzip") + .add(HttpHeader.CONTENT_LENGTH, "643"); + JmeterCompressionHeadersSupport.captureIfCompressed(request, wire); + + ContentResponse contentResponse = Mockito.mock(ContentResponse.class); + HttpFields decoded = HttpFields.build() + .add(HttpHeader.CONTENT_TYPE, "application/json") + .add(HttpHeader.CONTENT_LENGTH, "238"); + Mockito.when(contentResponse.getRequest()).thenReturn(request); + Mockito.when(contentResponse.getHeaders()).thenReturn(decoded); + + HttpFields merged = JmeterCompressionHeadersSupport.headersForSampleResult(contentResponse); + + assertThat(merged.get(HttpHeader.CONTENT_ENCODING)).isEqualTo("gzip"); + assertThat(merged.get(HttpHeader.CONTENT_LENGTH)).isEqualTo("643"); + assertThat(merged.get(HttpHeader.CONTENT_TYPE)).isEqualTo("application/json"); + } + + @Test + public void shouldNotOverrideExistingHeaders() { + Request request = mockRequestWithAttributes(); + + HttpFields wire = HttpFields.build() + .add(HttpHeader.CONTENT_ENCODING, "gzip") + .add(HttpHeader.CONTENT_LENGTH, "643"); + JmeterCompressionHeadersSupport.captureIfCompressed(request, wire); + + ContentResponse contentResponse = Mockito.mock(ContentResponse.class); + HttpFields decoded = HttpFields.build() + .add(HttpHeader.CONTENT_ENCODING, "gzip") + .add(HttpHeader.CONTENT_LENGTH, "238"); + Mockito.when(contentResponse.getRequest()).thenReturn(request); + Mockito.when(contentResponse.getHeaders()).thenReturn(decoded); + + HttpFields merged = JmeterCompressionHeadersSupport.headersForSampleResult(contentResponse); + + assertThat(merged.get(HttpHeader.CONTENT_LENGTH)).isEqualTo("238"); + } + + @Test + public void shouldCaptureContentMd5WhenPresent() { + Request request = mockRequestWithAttributes(); + + HttpFields wire = HttpFields.build() + .add(HttpHeader.CONTENT_ENCODING, "br") + .add(HTTPConstants.HEADER_CONTENT_LENGTH, "100") + .add("Content-MD5", "abc123"); + JmeterCompressionHeadersSupport.captureIfCompressed(request, wire); + + ContentResponse contentResponse = Mockito.mock(ContentResponse.class); + HttpFields decoded = HttpFields.build().add(HttpHeader.CONTENT_TYPE, "text/plain"); + Mockito.when(contentResponse.getRequest()).thenReturn(request); + Mockito.when(contentResponse.getHeaders()).thenReturn(decoded); + + HttpFields merged = JmeterCompressionHeadersSupport.headersForSampleResult(contentResponse); + + assertThat(merged.get(HttpHeader.CONTENT_ENCODING)).isEqualTo("br"); + assertThat(merged.get(HTTPConstants.HEADER_CONTENT_LENGTH)).isEqualTo("100"); + assertThat(merged.get("Content-MD5")).isEqualTo("abc123"); + } +} From 73184372aff338c45866c7dda00f5d22457ffdaa Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 3 Jul 2026 09:40:16 -0300 Subject: [PATCH 04/35] Preserve connection keep-alive for JMeter compatibility (#116) * Preserve connection keep alive --- .../jmeter/http2/core/HTTP2JettyClient.java | 6 +- .../core/JmeterRequestHeadersSupport.java | 104 ++++++++++++++ .../core/HttpKeepAliveSampleHeadersTest.java | 132 ++++++++++++++++++ .../core/JmeterRequestHeadersSupportTest.java | 77 ++++++++++ 4 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/JmeterRequestHeadersSupport.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/HttpKeepAliveSampleHeadersTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/JmeterRequestHeadersSupportTest.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index 9b7231f3..9b7f3fac 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -1138,6 +1138,7 @@ public HTTPSampleResult retryWithHTTP11Only(HTTP2Sampler sampler, HTTPSampleResu // Copy body if present setBody(http11Request, sampler, result); + JmeterRequestHeadersSupport.prepareFromSampler(http11Request, sampler.getUseKeepAlive()); // Send request lowLevelDebug("Sending HTTP/1.1 fallback request"); @@ -1199,6 +1200,7 @@ private ContentResponse sendWithHTTP11Only(Request originalRequest, // will be lost, but this is an edge case. } ensureHostHeader(http11Request, uri); + JmeterRequestHeadersSupport.copySamplerHeaderState(originalRequest, http11Request); configureContentDecodersAndCapture(httpClientHttp1Only, http11Request); @@ -1324,6 +1326,7 @@ private void samplePrepareRequest(Request request, result.sampleStart(); setBody(request, sampler, result); + JmeterRequestHeadersSupport.prepareFromSampler(request, sampler.getUseKeepAlive()); initializeSentBytes(result, request); } @@ -3452,7 +3455,8 @@ private String getSerializedRequestHeaders(Request request, boolean refresh) { if (!refresh && cached instanceof String) { return (String) cached; } - String serialized = buildHeadersString(request.getHeaders()); + String serialized = buildHeadersString( + JmeterRequestHeadersSupport.headersForSampleResult(request)); request.attribute(ATTR_REQUEST_HEADERS_SERIALIZED, serialized); return serialized; } diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JmeterRequestHeadersSupport.java b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterRequestHeadersSupport.java new file mode 100644 index 00000000..2c2c719f --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterRequestHeadersSupport.java @@ -0,0 +1,104 @@ +package com.blazemeter.jmeter.http2.core; + +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpVersion; + +/** + * Preserves JMeter-visible request headers in {@code HTTPSampleResult#getRequestHeaders()} + * when Jetty omits or strips them on the wire. + * + *

Counterpart of {@link JmeterCompressionHeadersSupport} for outbound request headers. + */ +final class JmeterRequestHeadersSupport { + + static final String ATTR_USE_KEEPALIVE = "bzm.useKeepAlive"; + + private JmeterRequestHeadersSupport() { + } + + /** + * Applies sampler-driven request headers before send, matching {@code HTTPHC4Impl#setupRequest}. + */ + static void prepareFromSampler(Request request, boolean useKeepAlive) { + if (request == null) { + return; + } + request.attribute(ATTR_USE_KEEPALIVE, useKeepAlive); + applyConnectionHeader(request, useKeepAlive); + // TODO: HC4 sends explicit empty User-Agent when none is configured (disableDefaultUserAgent). + // prepareEmptyUserAgentHeader(request); + } + + /** + * Copies sampler header settings to a cloned request (for example HTTP/1.1 fallback). + */ + static void copySamplerHeaderState(Request from, Request to) { + if (from == null || to == null) { + return; + } + Object useKeepAlive = from.getAttributes().get(ATTR_USE_KEEPALIVE); + if (useKeepAlive != null) { + to.attribute(ATTR_USE_KEEPALIVE, useKeepAlive); + } + } + + /** + * Request headers to record in the sample after Jetty may have adjusted the live fields. + */ + static HttpFields headersForSampleResult(Request request) { + if (request == null) { + return HttpFields.EMPTY; + } + HttpFields.Mutable merged = HttpFields.build(request.getHeaders()); + restoreConnectionHeaderForSample(request, merged); + // restoreEmptyUserAgentForSample(request, merged); + return merged; + } + + private static void applyConnectionHeader(Request request, boolean useKeepAlive) { + if (!shouldSendConnectionHeader(request)) { + return; + } + HttpFields.Mutable mutableHeaders = mutableHeaders(request); + if (mutableHeaders == null || mutableHeaders.contains(HttpHeader.CONNECTION)) { + return; + } + if (useKeepAlive) { + mutableHeaders.put(HTTPConstants.HEADER_CONNECTION, HTTPConstants.KEEP_ALIVE); + } else { + mutableHeaders.put(HTTPConstants.HEADER_CONNECTION, HTTPConstants.CONNECTION_CLOSE); + } + } + + private static void restoreConnectionHeaderForSample( + Request request, HttpFields.Mutable headers) { + Object useKeepAlive = request.getAttributes().get(ATTR_USE_KEEPALIVE); + if (useKeepAlive == null || !shouldSendConnectionHeader(request)) { + return; + } + if (Boolean.TRUE.equals(useKeepAlive)) { + headers.put(HTTPConstants.HEADER_CONNECTION, HTTPConstants.KEEP_ALIVE); + } else { + headers.put(HTTPConstants.HEADER_CONNECTION, HTTPConstants.CONNECTION_CLOSE); + } + } + + private static boolean shouldSendConnectionHeader(Request request) { + if (request != null && request.getVersion() == HttpVersion.HTTP_2) { + return false; + } + HttpFields headers = request.getHeaders(); + return headers == null || !headers.contains(HttpHeader.CONNECTION); + } + + private static HttpFields.Mutable mutableHeaders(Request request) { + HttpFields headers = request.getHeaders(); + if (headers instanceof HttpFields.Mutable) { + return (HttpFields.Mutable) headers; + } + return null; + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HttpKeepAliveSampleHeadersTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HttpKeepAliveSampleHeadersTest.java new file mode 100644 index 00000000..92f8e22d --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HttpKeepAliveSampleHeadersTest.java @@ -0,0 +1,132 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import com.blazemeter.jmeter.http2.sampler.JMeterTestUtils; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.util.JMeterUtils; +import org.junit.After; +import org.junit.BeforeClass; +import org.junit.Test; + +public class HttpKeepAliveSampleHeadersTest { + + private ExecutorService executor; + + @BeforeClass + public static void setupJmeter() { + JMeterTestUtils.setupJmeterEnv(); + } + + @After + public void tearDown() throws Exception { + if (executor != null) { + executor.shutdownNow(); + executor.awaitTermination(5, TimeUnit.SECONDS); + executor = null; + } + } + + @Test + public void sampleRequestHeadersIncludeKeepAliveWhenEnabled() throws Exception { + configureLegacyHttp1Only(); + + try (ServerSocket serverSocket = new ServerSocket(0)) { + int port = serverSocket.getLocalPort(); + Future received = startWireCapture(serverSocket); + + HTTP2JettyClient jettyClient = new HTTP2JettyClient(false, "keepalive-sample-test"); + jettyClient.start(); + try { + HTTPSampleResult sampleResult = samplePlainHttp(jettyClient, port, true); + String wireRequest = received.get(10, TimeUnit.SECONDS); + + assertThat(sampleResult.getRequestHeaders()) + .contains(HTTPConstants.HEADER_CONNECTION + ": " + HTTPConstants.KEEP_ALIVE); + assertThat(wireRequest).contains("GET /test HTTP/1.1"); + assertThat(wireRequest.toLowerCase()).doesNotContain("connection: keep-alive"); + } finally { + jettyClient.stop(); + } + } + } + + @Test + public void sampleRequestHeadersIncludeCloseWhenKeepAliveDisabled() throws Exception { + configureLegacyHttp1Only(); + + try (ServerSocket serverSocket = new ServerSocket(0)) { + int port = serverSocket.getLocalPort(); + Future received = startWireCapture(serverSocket); + + HTTP2JettyClient jettyClient = new HTTP2JettyClient(false, "keepalive-close-test"); + jettyClient.start(); + try { + HTTPSampleResult sampleResult = samplePlainHttp(jettyClient, port, false); + String wireRequest = received.get(10, TimeUnit.SECONDS); + + assertThat(sampleResult.getRequestHeaders()) + .contains(HTTPConstants.HEADER_CONNECTION + ": " + + HTTPConstants.CONNECTION_CLOSE); + assertThat(wireRequest.toLowerCase()).contains("connection: close"); + } finally { + jettyClient.stop(); + } + } + } + + private static void configureLegacyHttp1Only() { + JMeterUtils.setProperty("blazemeter.http.enableHttp2", "false"); + JMeterUtils.setProperty("blazemeter.http.enableHttp3", "false"); + JMeterUtils.setProperty("blazemeter.http.profile", "legacy"); + } + + private Future startWireCapture(ServerSocket serverSocket) { + executor = Executors.newSingleThreadExecutor(); + return executor.submit(() -> readRequest(serverSocket)); + } + + private static HTTPSampleResult samplePlainHttp(HTTP2JettyClient jettyClient, int port, + boolean useKeepAlive) throws Exception { + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod("GET"); + sampler.setDomain("localhost"); + sampler.setPort(port); + sampler.setPath("/test"); + sampler.setProtocol("http"); + sampler.setUseKeepAlive(useKeepAlive); + + URL url = new URL("http", "localhost", port, "/test"); + HTTPSampleResult result = new HTTPSampleResult(); + result.setSampleLabel("GET_NO_PARAMETERS"); + result.setHTTPMethod("GET"); + result.setURL(url); + + return jettyClient.sample(sampler, result, false, 0); + } + + private static String readRequest(ServerSocket serverSocket) throws Exception { + try (Socket socket = serverSocket.accept(); + InputStream in = socket.getInputStream(); + OutputStream out = socket.getOutputStream()) { + byte[] buffer = new byte[4096]; + int read = in.read(buffer); + out.write("HTTP/1.0 200 OK\r\nContent-Type: text/plain\r\n\r\n" + .getBytes(StandardCharsets.US_ASCII)); + out.flush(); + return new String(buffer, 0, read, StandardCharsets.US_ASCII); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/JmeterRequestHeadersSupportTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/JmeterRequestHeadersSupportTest.java new file mode 100644 index 00000000..b5079a35 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/JmeterRequestHeadersSupportTest.java @@ -0,0 +1,77 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; + +import java.util.HashMap; +import java.util.Map; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpVersion; +import org.junit.Test; +import org.mockito.Mockito; + +public class JmeterRequestHeadersSupportTest { + + private static Request mockRequest(HttpFields.Mutable headers) { + Map attributes = new HashMap<>(); + Request request = Mockito.mock(Request.class); + Mockito.when(request.getHeaders()).thenReturn(headers); + Mockito.when(request.getVersion()).thenReturn(HttpVersion.HTTP_1_1); + Mockito.when(request.getAttributes()).thenReturn(attributes); + Mockito.doAnswer(invocation -> { + attributes.put(invocation.getArgument(0), invocation.getArgument(1)); + return invocation.getMock(); + }).when(request).attribute(anyString(), any()); + return request; + } + + @Test + public void prepareFromSamplerAddsConnectionKeepAliveWhenEnabled() { + HttpFields.Mutable headers = HttpFields.build(); + Request request = mockRequest(headers); + + JmeterRequestHeadersSupport.prepareFromSampler(request, true); + + assertThat(headers.get(HttpHeader.CONNECTION)).isEqualTo(HTTPConstants.KEEP_ALIVE); + } + + @Test + public void headersForSampleResultRestoresKeepAliveAfterHeadersCleared() { + HttpFields.Mutable headers = HttpFields.build(); + Request request = mockRequest(headers); + JmeterRequestHeadersSupport.prepareFromSampler(request, true); + headers.remove(HttpHeader.CONNECTION); + + HttpFields sampleHeaders = JmeterRequestHeadersSupport.headersForSampleResult(request); + + assertThat(sampleHeaders.get(HttpHeader.CONNECTION)).isEqualTo(HTTPConstants.KEEP_ALIVE); + } + + @Test + public void headersForSampleResultRestoresConnectionCloseWhenDisabled() { + HttpFields.Mutable headers = HttpFields.build(); + Request request = mockRequest(headers); + JmeterRequestHeadersSupport.prepareFromSampler(request, false); + headers.remove(HttpHeader.CONNECTION); + + HttpFields sampleHeaders = JmeterRequestHeadersSupport.headersForSampleResult(request); + + assertThat(sampleHeaders.get(HttpHeader.CONNECTION)) + .isEqualTo(HTTPConstants.CONNECTION_CLOSE); + } + + @Test + public void prepareFromSamplerDoesNotOverrideExplicitConnectionHeader() { + HttpFields.Mutable headers = HttpFields.build() + .add(HttpHeader.CONNECTION, "Upgrade"); + Request request = mockRequest(headers); + + JmeterRequestHeadersSupport.prepareFromSampler(request, true); + + assertThat(headers.get(HttpHeader.CONNECTION)).isEqualTo("Upgrade"); + } +} \ No newline at end of file From bdbfb14bd8b794ff479bb9cc033b66fbe4659f2f Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 3 Jul 2026 10:19:31 -0300 Subject: [PATCH 05/35] Handle HPACK errors and set dynamic response header size (#114) * Catch HPACK errors and Set Dynamic Response Header Size --- .../core/HTTP2FutureResponseListener.java | 31 +-- .../jmeter/http2/core/HTTP2JettyClient.java | 11 +- .../http2/core/HpackFailureDetector.java | 43 +++++ .../jmeter/http2/sampler/HTTP2Sampler.java | 5 +- ...ckLargeResponseHeadersIntegrationTest.java | 181 ++++++++++++++++++ .../core/LargeResponseHeaderHttpsServer.java | 105 ++++++++++ .../http2/sampler/TestableHTTP2Sampler.java | 21 ++ 7 files changed, 384 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/HpackFailureDetector.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/HpackLargeResponseHeadersIntegrationTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/LargeResponseHeaderHttpsServer.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/sampler/TestableHTTP2Sampler.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java index 8707111b..738cd203 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java @@ -112,16 +112,22 @@ public void onFailure(Response response, Throwable failure) { : "null"; lowLevelDebug("Failure: {}", failureInfo); - // Store the failure immediately + // Store the failure immediately. If HPACK decode failed earlier, + // avoid protocol_error handling. this.failure = failure; - + if (failure != null && HpackFailureDetector.indicatesHpackFailure(failure)) { + lowLevelDebug("HPACK-related failure detected for request"); + super.onFailure(response, failure); + return; + } + // Check if this is a protocol_error if (failure != null) { lowLevelDebug("Checking isProtocolError in onFailure() for: {}", failure.getClass().getName()); boolean isProtocolError = ProtocolErrorException.isProtocolError(failure); lowLevelDebug("isProtocolError returned: {}", isProtocolError); - + if (isProtocolError) { lowLevelDebug("=== PROTOCOL_ERROR DETECTED IN onFailure() ==="); lowLevelDebug("Original failure: {}: {}", @@ -131,13 +137,13 @@ public void onFailure(Response response, Throwable failure) { String message = failure.getMessage(); // Replace failure with ProtocolErrorException so it can be caught specifically this.failure = new ProtocolErrorException( - message != null ? message : "protocol_error", + message != null ? message : "protocol_error", failure); lowLevelDebug("Replaced with ProtocolErrorException: {}", this.failure.getClass().getName()); } } - + // Call super to maintain normal behavior super.onFailure(response, failure); } @@ -178,16 +184,15 @@ public void onComplete(Result result) { boolean isProtocolError = ProtocolErrorException.isProtocolError(failure); lowLevelDebug("isProtocolError returned: {}", isProtocolError); - if (isProtocolError) { + if (isProtocolError && !HpackFailureDetector.indicatesHpackFailure(failure)) { lowLevelDebug("=== PROTOCOL_ERROR DETECTED IN onComplete() ==="); lowLevelDebug("Original failure: {}: {}", failure.getClass().getName(), failure.getMessage()); lowLevelDebug("HTTP/2 protocol_error detected in onComplete() - " + "replacing with ProtocolErrorException"); String message = failure.getMessage(); - // Replace failure with ProtocolErrorException so it can be caught specifically failure = new ProtocolErrorException( - message != null ? message : "protocol_error", + message != null ? message : "protocol_error", failure); lowLevelDebug("Replaced with ProtocolErrorException: {}", failure.getClass().getName()); } @@ -406,6 +411,9 @@ private ContentResponse getResult() throws ExecutionException, ProtocolErrorExce throw (CancellationException) new CancellationException().initCause(failure); } if (failure != null) { // Failure and Response can coexist. + if (HpackFailureDetector.indicatesHpackFailure(failure)) { + lowLevelDebug("HPACK-related failure detected in getResult()"); + } if (response == null) { // Only generate exception response when an response not exist // Generated by nginx GOAWAY LOG.error("Request failed without response: exception type={}, message={}", @@ -418,7 +426,7 @@ private ContentResponse getResult() throws ExecutionException, ProtocolErrorExce boolean isProtocolError = ProtocolErrorException.isProtocolError(failure); LOG.error("ProtocolErrorException.isProtocolError() returned: {}", isProtocolError); - if (isProtocolError) { + if (isProtocolError && !HpackFailureDetector.indicatesHpackFailure(failure)) { String message = failure.getMessage(); LOG.error("HTTP/2 protocol_error detected in getResult() - " + "throwing ProtocolErrorException"); @@ -426,7 +434,7 @@ private ContentResponse getResult() throws ExecutionException, ProtocolErrorExce LOG.error(" - Server rejected HTTP/2 connection"); LOG.error(" - This will trigger HTTP/1.1 fallback"); throw new ProtocolErrorException(message != null ? message : "protocol_error", failure); - } else { + } else if (!HpackFailureDetector.indicatesHpackFailure(failure)) { LOG.error("Failure is NOT detected as protocol_error, will throw ExecutionException"); } @@ -444,7 +452,8 @@ private ContentResponse getResult() throws ExecutionException, ProtocolErrorExce response.getStatus(), response.getVersion(), failure.getClass().getName()); // Check if this is a protocol_error even though we have a response - if (ProtocolErrorException.isProtocolError(failure)) { + if (ProtocolErrorException.isProtocolError(failure) + && !HpackFailureDetector.indicatesHpackFailure(failure)) { String message = failure.getMessage(); LOG.error("HTTP/2 protocol_error detected after response - " + "throwing ProtocolErrorException"); diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index 9b7f3fac..c743a053 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -272,6 +272,11 @@ public HTTP2JettyClient(boolean http1UpgradeRequired, String name, ClientConnectionFactory.Info http11 = HttpClientConnectionFactory.HTTP11; HTTP2Client http2Client = new HTTP2Client(clientConnector); + // HTTP2Client defaults to 8 KiB; HttpClient defaults to -1 (no local HPACK cap). Match + // HttpClient here so parsers are not created with 8192. On start(), Jetty configure() + // re-syncs from HttpClient.getMaxResponseHeadersSize(), so this stays dynamic if callers + // change HttpClient before start(). + http2Client.setMaxResponseHeadersSize(-1); enableFrameLoggingIfConfigured(http2Client); // Add session listener to log SETTINGS frames received from server (for debugging Issue #12071) @@ -506,6 +511,7 @@ public HTTP2JettyClient(boolean http1UpgradeRequired, String name, ClientConnector h2cUpgradeConnector = createClientConnector(name + "-h2c-upgrade"); HTTP2Client http2cUpgradeClient = new HTTP2Client(h2cUpgradeConnector); + http2cUpgradeClient.setMaxResponseHeadersSize(-1); http2cUpgradeClient.setUseALPN(false); if (disableServerPush) { http2cUpgradeClient.setMaxConcurrentPushedStreams(0); @@ -524,6 +530,7 @@ public HTTP2JettyClient(boolean http1UpgradeRequired, String name, ClientConnector h2cConnector = createClientConnector(name + "-h2c"); HTTP2Client http2cClient = new HTTP2Client(h2cConnector); + http2cClient.setMaxResponseHeadersSize(-1); http2cClient.setUseALPN(false); if (disableServerPush) { http2cClient.setMaxConcurrentPushedStreams(0); @@ -1506,7 +1513,9 @@ public HTTPSampleResult sampleFromListener(HTTP2Sampler sampler, HTTPSampleResul LOG.error("isProtocolError(cause): {}", isProtocolErrorCause); LOG.error("isProtocolError(exception): {}", isProtocolErrorException); - if ((isProtocolErrorCause || isProtocolErrorException) && protocolErrorFallbackEnabled) { + if ((isProtocolErrorCause || isProtocolErrorException) && protocolErrorFallbackEnabled + && !HpackFailureDetector.indicatesHpackFailure(e) + && !HpackFailureDetector.indicatesHpackFailure(cause)) { LOG.warn("HTTP/2 protocol_error detected in sampleFromListener()! " + "Attempting fallback to HTTP/1.1"); LOG.warn("Error: {}", cause != null ? cause.getMessage() : e.getMessage()); diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HpackFailureDetector.java b/src/main/java/com/blazemeter/jmeter/http2/core/HpackFailureDetector.java new file mode 100644 index 00000000..f3bf1092 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HpackFailureDetector.java @@ -0,0 +1,43 @@ +package com.blazemeter.jmeter.http2.core; + +import static com.blazemeter.jmeter.http2.core.LowLevelDebugLog.lowLevelDebug; + +import java.util.Locale; + +/** + * Detects HPACK-related failures from Jetty/JMeter surfaced errors (messages and causes). + */ +public final class HpackFailureDetector { + + private HpackFailureDetector() { + } + + public static boolean indicatesHpackFailure(Throwable failure) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (isHpackRelated(current)) { + lowLevelDebug("HPACK-related failure detected: {}: {}", + current.getClass().getName(), current.getMessage()); + return true; + } + } + return false; + } + + private static boolean isHpackRelated(Throwable failure) { + if (failure.getClass().getName().contains("HpackException")) { + return true; + } + String message = failure.getMessage(); + if (message == null || message.isEmpty()) { + return false; + } + String lower = message.toLowerCase(Locale.ROOT); + if (lower.contains("invalid_hpack")) { + return true; + } + if (lower.contains("header size") && lower.contains(">")) { + return true; + } + return lower.contains("hpack") && lower.contains("header"); + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java b/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java index 529c2708..b19321a1 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java +++ b/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java @@ -6,6 +6,7 @@ import com.blazemeter.jmeter.http2.core.HTTP2ClientProfileConfig; import com.blazemeter.jmeter.http2.core.HTTP2FutureResponseListener; import com.blazemeter.jmeter.http2.core.HTTP2JettyClient; +import com.blazemeter.jmeter.http2.core.HpackFailureDetector; import com.blazemeter.jmeter.http2.core.ProtocolErrorException; import com.blazemeter.jmeter.http2.util.BzmHttpPluginProperties; import com.github.benmanes.caffeine.cache.Caffeine; @@ -478,7 +479,9 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe LOG.debug("isProtocolError(cause): {}", isProtocolErrorCause); LOG.debug("isProtocolError(exception): {}", isProtocolErrorException); - if (isProtocolErrorCause || isProtocolErrorException) { + if ((isProtocolErrorCause || isProtocolErrorException) + && !HpackFailureDetector.indicatesHpackFailure(e) + && !HpackFailureDetector.indicatesHpackFailure(cause)) { boolean fallbackEnabled = isProtocolErrorFallbackEnabled(); if (!fallbackEnabled) { LOG.warn("HTTP/2 protocol_error detected and fallback is DISABLED. " diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HpackLargeResponseHeadersIntegrationTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HpackLargeResponseHeadersIntegrationTest.java new file mode 100644 index 00000000..191de92b --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HpackLargeResponseHeadersIntegrationTest.java @@ -0,0 +1,181 @@ +package com.blazemeter.jmeter.http2.core; + +import static com.blazemeter.jmeter.http2.core.LargeResponseHeaderHttpsServer.HEADER_LIST_LIMIT; +import static com.blazemeter.jmeter.http2.core.ServerBuilder.HOST_NAME; +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import com.blazemeter.jmeter.http2.sampler.JMeterTestUtils; +import com.blazemeter.jmeter.http2.sampler.TestableHTTP2Sampler; +import java.lang.reflect.Method; +import java.net.URI; +import java.net.URL; +import java.util.concurrent.ExecutionException; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.util.JMeterUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Regression guard for oversized HTTP/2 response headers. + * + *

While the limit is not enforced correctly, Jetty surfaces HPACK-related failures + * (for example {@code invalid_hpack_block}) which {@link HpackFailureDetector} recognizes. + * After the production fix, the same scenario should succeed instead. + */ +public class HpackLargeResponseHeadersIntegrationTest extends HTTP2TestBase { + + private String savedSharedPool; + private String savedProtocolErrorFallback; + + private LargeResponseHeaderHttpsServer server; + private HTTP2JettyClient client; + private TestableHTTP2Sampler sampler; + private int serverPort = -1; + + @Before + public void setUp() throws Exception { + JMeterTestUtils.setupJmeterEnv(); + savedSharedPool = JMeterUtils.getProperty("httpJettyClient.sharedThreadPool"); + savedProtocolErrorFallback = JMeterUtils.getProperty("httpJettyClient.protocolErrorFallbackEnabled"); + JMeterUtils.setProperty("httpJettyClient.sharedThreadPool", "false"); + JMeterUtils.setProperty("httpJettyClient.protocolErrorFallbackEnabled", "false"); + + server = LargeResponseHeaderHttpsServer.start(); + serverPort = server.getPort(); + } + + @After + public void tearDown() throws Exception { + if (sampler != null) { + sampler.threadFinished(); + sampler = null; + } + if (client != null) { + client.stop(); + client = null; + } + if (server != null) { + server.stop(); + server = null; + } + serverPort = -1; + restoreProperty("httpJettyClient.sharedThreadPool", savedSharedPool); + restoreProperty("httpJettyClient.protocolErrorFallbackEnabled", savedProtocolErrorFallback); + } + + @Test + public void shouldSucceedWithLargeResponseHeadersUsingDefaultUnlimitedHttp2Limit() throws Exception { + client = new HTTP2JettyClient(false, "hpack-large-headers-unlimited-it", http2OnlyProfile()); + client.start(); + + HTTPSampleResult result = client.sample(buildSampler(), buildBaseResult(), false, 0); + + assertThat(result.isSuccessful()).isTrue(); + assertThat(result.getResponseCode()).isEqualTo("200"); + } + + @Test + public void shouldFailThroughJettyClientWhenResponseHeadersExceedLimit() throws Exception { + client = createHttp2OnlyClient(); + client.start(); + + org.junit.Assert.assertThrows(ExecutionException.class, + () -> client.sample(buildSampler(), buildBaseResult(), false, 0)); + } + + @Test + public void shouldDetectHpackRelatedFailureThroughSampler() throws Exception { + sampler = createHttp2OnlySampler(); + URL url = new URI(HTTPConstants.PROTOCOL_HTTPS, null, HOST_NAME, serverPort, "/", null, null) + .toURL(); + + HTTPSampleResult result = sampler.runSample(url, HTTPConstants.GET); + + assertThat(result.isSuccessful()).isFalse(); + assertThat(HpackFailureDetector.indicatesHpackFailure( + new Exception(result.getResponseDataAsString()))).isTrue(); + } + + private HTTP2JettyClient createHttp2OnlyClient() throws Exception { + HTTP2JettyClient jettyClient = new HTTP2JettyClient(false, "hpack-large-headers-it", + http2OnlyProfile()); + applyResponseHeaderLimit(jettyClient); + return jettyClient; + } + + private TestableHTTP2Sampler createHttp2OnlySampler() throws Exception { + TestableHTTP2Sampler http2Sampler = new TestableHTTP2Sampler(() -> { + HTTP2JettyClient jettyClient = createHttp2OnlyClient(); + jettyClient.start(); + return jettyClient; + }); + configureSampler(http2Sampler); + return http2Sampler; + } + + private HTTP2Sampler buildSampler() { + HTTP2Sampler http2Sampler = new HTTP2Sampler(); + configureSampler(http2Sampler); + return http2Sampler; + } + + private void configureSampler(HTTP2Sampler http2Sampler) { + http2Sampler.setMethod(HTTPConstants.GET); + http2Sampler.setDomain(HOST_NAME); + http2Sampler.setProtocol(HTTPConstants.PROTOCOL_HTTPS); + http2Sampler.setPort(serverPort); + http2Sampler.setPath("/"); + http2Sampler.setEnableHttp1(false); + http2Sampler.setEnableHttp2(true); + http2Sampler.setEnableHttp3(false); + http2Sampler.setFallbackEnabled(false); + http2Sampler.setProtocolErrorFallbackEnabled(false); + } + + private static HTTP2ClientProfileConfig http2OnlyProfile() { + return HTTP2ClientProfileConfig.builder() + .profile("browser-like") + .enableHttp3(false) + .enableHttp2(true) + .enableHttp1(false) + .alpnEnabled(true) + .fallbackEnabled(false) + .protocolErrorFallbackEnabled(false) + .altSvcCacheEnabled(false) + .http1OnlyCacheEnabled(false) + .build(); + } + + private static void applyResponseHeaderLimit(HTTP2JettyClient jettyClient) + throws ReflectiveOperationException { + Object httpClient = HTTP2JettyClient.class.getMethod("getHttpClient").invoke(jettyClient); + invoke(httpClient, "setMaxResponseHeadersSize", int.class, HEADER_LIST_LIMIT); + invoke(httpClient, "setMaxRequestHeadersSize", int.class, HEADER_LIST_LIMIT); + } + + private static void invoke(Object target, String methodName, Class paramType, Object arg) + throws ReflectiveOperationException { + Method method = target.getClass().getMethod(methodName, paramType); + method.invoke(target, arg); + } + + private HTTPSampleResult buildBaseResult() throws Exception { + HTTPSampleResult result = new HTTPSampleResult(); + result.setURL(new URI(HTTPConstants.PROTOCOL_HTTPS, null, HOST_NAME, serverPort, "/", null, null) + .toURL()); + result.setHTTPMethod(HTTPConstants.GET); + return result; + } + + private static void restoreProperty(String key, String value) { + if (value == null) { + JMeterUtils.getJMeterProperties().remove(key); + } else { + JMeterUtils.setProperty(key, value); + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/LargeResponseHeaderHttpsServer.java b/src/test/java/com/blazemeter/jmeter/http2/core/LargeResponseHeaderHttpsServer.java new file mode 100644 index 00000000..5ac5ed16 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/LargeResponseHeaderHttpsServer.java @@ -0,0 +1,105 @@ +package com.blazemeter.jmeter.http2.core; + +import java.nio.file.Paths; +import java.util.Arrays; +import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.SecureRequestCustomizer; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.SslConnectionFactory; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.ssl.SslContextFactory; + +/** + * HTTPS/HTTP2 server that returns a single response header whose uncompressed HPACK size is + * {@value #PRODUCTION_HEADER_SIZE} bytes, reproducing production's {@code Header size 8692 > 8192}. + */ +final class LargeResponseHeaderHttpsServer { + + static final int HEADER_LIST_LIMIT = 8192; + static final int PRODUCTION_HEADER_SIZE = 8692; + static final String LARGE_HEADER_NAME = "X-Large-Response-Header"; + static final String PRODUCTION_ERROR_MESSAGE = "Header size 8692 > 8192"; + + private static final int SERVER_HEADER_LIMIT = 128 * 1024; + + private final Server server; + + private LargeResponseHeaderHttpsServer(Server server) { + this.server = server; + } + + static LargeResponseHeaderHttpsServer start() throws Exception { + Server server = new Server(); + HttpConfiguration httpsConfig = new HttpConfiguration(); + httpsConfig.setSendServerVersion(false); + httpsConfig.setSendDateHeader(false); + httpsConfig.addCustomizer(new SecureRequestCustomizer()); + httpsConfig.setResponseHeaderSize(SERVER_HEADER_LIMIT); + httpsConfig.setMaxResponseHeaderSize(SERVER_HEADER_LIMIT); + + SslContextFactory.Server sslContextFactory = new SslContextFactory.Server(); + sslContextFactory.setKeyStorePath(resolveTestKeyStorePath()); + sslContextFactory.setKeyStorePassword(ServerBuilder.KEYSTORE_PASSWORD); + + HTTP2ServerConnectionFactory http2 = new HTTP2ServerConnectionFactory(httpsConfig); + HttpConfiguration httpConfig = new HttpConfiguration(httpsConfig); + HttpConnectionFactory http11 = new HttpConnectionFactory(httpConfig); + + ALPNServerConnectionFactory alpn = new ALPNServerConnectionFactory(); + alpn.setDefaultProtocol(http11.getProtocol()); + + ServerConnector connector = new ServerConnector(server, + new SslConnectionFactory(sslContextFactory, alpn.getProtocol()), + alpn, + http11, + http2); + connector.setPort(0); + server.addConnector(connector); + server.setHandler(new Handler.Abstract() { + @Override + public boolean handle(Request request, Response response, Callback callback) { + response.setStatus(200); + response.getHeaders().add(largeResponseHeader()); + Content.Sink.write(response, true, "ok", callback); + return true; + } + }); + server.start(); + return new LargeResponseHeaderHttpsServer(server); + } + + int getPort() { + return ((ServerConnector) server.getConnectors()[0]).getLocalPort(); + } + + void stop() throws Exception { + server.stop(); + } + + static HttpField largeResponseHeader() { + int valueLength = PRODUCTION_HEADER_SIZE - 32 - LARGE_HEADER_NAME.length(); + char[] padding = new char[valueLength]; + Arrays.fill(padding, 'x'); + return new HttpField(LARGE_HEADER_NAME, new String(padding)); + } + + private static String resolveTestKeyStorePath() { + try { + return Paths.get(LargeResponseHeaderHttpsServer.class.getResource("keystore.p12").toURI()) + .toAbsolutePath() + .normalize() + .toString(); + } catch (Exception e) { + throw new IllegalStateException("Failed to resolve test keystore", e); + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/sampler/TestableHTTP2Sampler.java b/src/test/java/com/blazemeter/jmeter/http2/sampler/TestableHTTP2Sampler.java new file mode 100644 index 00000000..64dec35a --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/sampler/TestableHTTP2Sampler.java @@ -0,0 +1,21 @@ +package com.blazemeter.jmeter.http2.sampler; + +import com.blazemeter.jmeter.http2.core.HTTP2JettyClient; +import java.net.URL; +import java.util.concurrent.Callable; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; + +/** + * Test helper exposing {@link HTTP2Sampler}'s protected {@code sample} entry point. + */ +public class TestableHTTP2Sampler extends HTTP2Sampler { + + public TestableHTTP2Sampler(Callable clientFactory) { + super(clientFactory); + } + + public HTTPSampleResult runSample(URL url, String method) throws Exception { + return sample(url, method, false, 0); + } + +} From 901be388fb50629daad6315569cfa514d6daef33 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 07:35:36 -0300 Subject: [PATCH 06/35] Bump org.apache.commons:commons-lang3 from 3.12.0 to 3.18.0 (#104) Bumps org.apache.commons:commons-lang3 from 3.12.0 to 3.18.0. --- updated-dependencies: - dependency-name: org.apache.commons:commons-lang3 dependency-version: 3.18.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index d6ce80bb..d397c483 100644 --- a/pom.xml +++ b/pom.xml @@ -66,7 +66,7 @@ org.apache.commons commons-lang3 - 3.12.0 + 3.18.0 compile From 921562661f729cb75b81052aba598a8e94cf1a86 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Tue, 7 Jul 2026 07:57:52 -0300 Subject: [PATCH 07/35] Update Jetty version from 12.1.6 to 12.1.7 (#119) --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index d397c483..89733bb5 100644 --- a/pom.xml +++ b/pom.xml @@ -31,8 +31,8 @@ UTF-8 UTF-8 5.4.1 - 12.1.6 - + 12.1.7 + 1.20.0 false From e69561620aac12242b2e07efda3e6afa814e1b76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:02:12 -0300 Subject: [PATCH 08/35] Bump org.assertj:assertj-core from 3.12.2 to 3.27.7 (#105) * Bump org.assertj:assertj-core from 3.12.2 to 3.27.7 Bumps [org.assertj:assertj-core](https://github.com/assertj/assertj) from 3.12.2 to 3.27.7. - [Release notes](https://github.com/assertj/assertj/releases) - [Commits](https://github.com/assertj/assertj/compare/assertj-core-3.12.2...assertj-build-3.27.7) --- updated-dependencies: - dependency-name: org.assertj:assertj-core dependency-version: 3.27.7 dependency-type: direct:development ... Signed-off-by: dependabot[bot] * fix: pin byte-buddy to 1.14.18 to resolve conflict between mockito-core 2.28.2 and assertj-core 3.27.7 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: David --- pom.xml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 89733bb5..d422f775 100644 --- a/pom.xml +++ b/pom.xml @@ -189,10 +189,27 @@ 2.28.2 test + + + net.bytebuddy + byte-buddy + 1.14.18 + test + + + net.bytebuddy + byte-buddy-agent + 1.14.18 + test + org.assertj assertj-core - 3.12.2 + 3.27.7 test From ea34d4b947c9de782973da3707d1104d59c5782f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:24:07 -0300 Subject: [PATCH 09/35] Bump guava from 29.0-jre to 32.0.0-jre (#52) Bumps [guava](https://github.com/google/guava) from 29.0-jre to 32.0.0-jre. - [Release notes](https://github.com/google/guava/releases) - [Commits](https://github.com/google/guava/commits) --- updated-dependencies: - dependency-name: com.google.guava:guava dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: David <3dgiordano@gmail.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index d422f775..3d643fda 100644 --- a/pom.xml +++ b/pom.xml @@ -292,7 +292,7 @@ com.google.guava guava - 29.0-jre + 32.0.0-jre test From 42ec36646034a772507f2f5090261e0243d95658 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Thu, 9 Jul 2026 16:21:00 -0300 Subject: [PATCH 10/35] Allow 401 status without requiring HTTP authentication (#120) * Allow 401 without HTTP AUTH --- .../jmeter/http2/core/HTTP2JettyClient.java | 7 + ...ustomWwwAuthenticationProtocolHandler.java | 379 ++++++++++++++++++ .../http2/core/HTTP2JettyClientTest.java | 28 ++ .../jmeter/http2/core/ServerBuilder.java | 11 + ...tp401WithoutWwwAuthenticateParityTest.java | 148 +++++++ 5 files changed, 573 insertions(+) create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/jetty/CustomWwwAuthenticationProtocolHandler.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/parity/Http401WithoutWwwAuthenticateParityTest.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index c743a053..a06550f1 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -2,6 +2,7 @@ import static com.blazemeter.jmeter.http2.core.LowLevelDebugLog.lowLevelDebug; +import com.blazemeter.jmeter.http2.core.jetty.CustomWwwAuthenticationProtocolHandler; import com.blazemeter.jmeter.http2.core.jetty.custom.http2.CustomClientConnectionFactoryOverHTTP2; import com.blazemeter.jmeter.http2.core.jetty.custom.http3.CustomClientConnectionFactoryOverHTTP3; import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; @@ -1049,6 +1050,7 @@ public void start() throws Exception { lowLevelDebug("Starting HttpClient: name={}, http1UpgradeRequired={}", httpClient.getName(), http1UpgradeRequired); httpClient.start(); + CustomWwwAuthenticationProtocolHandler.install(httpClient); lowLevelDebug("HttpClient started successfully"); } else { lowLevelDebug("HttpClient already started"); @@ -1056,22 +1058,26 @@ public void start() throws Exception { if (httpClientNoH3 != httpClient && !httpClientNoH3.isStarted()) { lowLevelDebug("Starting HttpClient (no HTTP/3): name={}", httpClientNoH3.getName()); httpClientNoH3.start(); + CustomWwwAuthenticationProtocolHandler.install(httpClientNoH3); lowLevelDebug("HttpClient (no HTTP/3) started successfully"); } if (!httpClientHttp1Only.isStarted()) { lowLevelDebug("Starting HttpClient (HTTP/1.1 only): name={}", httpClientHttp1Only.getName()); httpClientHttp1Only.start(); + CustomWwwAuthenticationProtocolHandler.install(httpClientHttp1Only); lowLevelDebug("HttpClient (HTTP/1.1 only) started successfully"); } if (!httpClientH2cPrior.isStarted()) { lowLevelDebug("Starting HttpClient (H2C prior knowledge): name={}", httpClientH2cPrior.getName()); httpClientH2cPrior.start(); + CustomWwwAuthenticationProtocolHandler.install(httpClientH2cPrior); lowLevelDebug("HttpClient (H2C prior knowledge) started successfully"); } if (!httpClientH2cUpgrade.isStarted()) { lowLevelDebug("Starting HttpClient (H2C upgrade): name={}", httpClientH2cUpgrade.getName()); httpClientH2cUpgrade.start(); + CustomWwwAuthenticationProtocolHandler.install(httpClientH2cUpgrade); lowLevelDebug("HttpClient (H2C upgrade) started successfully"); } } @@ -1107,6 +1113,7 @@ private HttpClient createHTTP11OnlyClient(String name) throws Exception { // Start the client if (!http11Client.isStarted()) { http11Client.start(); + CustomWwwAuthenticationProtocolHandler.install(http11Client); lowLevelDebug("HTTP/1.1-only fallback client started"); } diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/CustomWwwAuthenticationProtocolHandler.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/CustomWwwAuthenticationProtocolHandler.java new file mode 100644 index 00000000..027153c3 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/CustomWwwAuthenticationProtocolHandler.java @@ -0,0 +1,379 @@ +package com.blazemeter.jmeter.http2.core.jetty; + +import java.lang.reflect.Method; +import java.net.URI; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.eclipse.jetty.client.Authentication; +import org.eclipse.jetty.client.Authentication.HeaderInfo; +import org.eclipse.jetty.client.AuthenticationProtocolHandler; +import org.eclipse.jetty.client.Connection; +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.HttpRequestException; +import org.eclipse.jetty.client.ProtocolHandler; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.client.Response; +import org.eclipse.jetty.client.Result; +import org.eclipse.jetty.client.RetainingResponseListener; +import org.eclipse.jetty.client.WWWAuthenticationProtocolHandler; +import org.eclipse.jetty.client.internal.HttpContentResponse; +import org.eclipse.jetty.client.transport.HttpConversation; +import org.eclipse.jetty.client.transport.HttpRequest; +import org.eclipse.jetty.client.transport.ResponseListeners; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.QuotedCSV; +import org.eclipse.jetty.util.NanoTime; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Custom replacement for Jetty's {@link WWWAuthenticationProtocolHandler}. + * + *

Why Jetty's default is too strict for load-testing clients

+ * + *

Jetty's handler assumes that every HTTP 401 is an HTTP Authentication challenge + * (Basic, Digest, etc.) in the sense of RFC 7235 / RFC 9110. Its {@code accept()} matches on + * status 401 alone (before response headers are available). Later, if {@code WWW-Authenticate} is + * missing or unparseable, it fails the exchange with {@code HttpResponseException}: + * {@code "HTTP protocol violation: Authentication challenge without WWW-Authenticate header"}. + * + *

That reading of 401 is too narrow. A service is not required to use the + * {@code WWW-Authenticate} / {@code Authorization} HTTP Auth framework. It may return 401 simply + * to mean "not authorized" while using another mechanism entirely, for example: + *

    + *
  • Bearer / JWT (or other) tokens in {@code Authorization} or a custom header
  • + *
  • API keys, cookies / session, OAuth / OIDC flows
  • + *
  • Application-level errors with a JSON/XML body and no challenge header
  • + *
+ * In those cases there is no HTTP Auth challenge to advertise, so omitting + * {@code WWW-Authenticate} is normal. Treating it as a protocol violation turns a usable 401 + * sample into a Non-HTTP error in the sampler. + * + *

Parity with JMeter HTTP (HttpClient4)

+ * + *

Apache JMeter's HttpClient4 path does not fail the exchange when 401 lacks + * {@code WWW-Authenticate}: the sampler receives a normal 401 result. This handler restores that + * behavior for the BlazeMeter HTTP (Jetty) client. + * + *

What this changes vs Jetty's original

+ * + *
    + *
  • 401 without a usable {@code WWW-Authenticate} challenge: forward the response to + * the application (same outcome as HttpClient4).
  • + *
  • 401 with a valid challenge and matching credentials in the + * {@link org.eclipse.jetty.client.AuthenticationStore}: keep Jetty-style challenge-response + * Basic/Digest retry.
  • + *
+ * + *

Why a full custom handler (not a small subclass override)

+ * + *

The protocol-violation branch lives in Jetty's private inner class + * {@code AuthenticationProtocolHandler.AuthenticationListener#onComplete}. There is no protected + * hook to override only the "401 without {@code WWW-Authenticate}" path, and helpers such as + * {@code forwardSuccessComplete} are private to that listener. Extending + * {@link WWWAuthenticationProtocolHandler} therefore cannot change that behavior without + * reimplementing {@link #getResponseListener()}. This class replaces the stock handler and keeps + * challenge-response Basic/Digest when a real challenge is present. + * + *

Install via {@link #install(HttpClient)} after {@link HttpClient#start()} so it replaces the + * handler Jetty registers in {@code doStart()}. + */ +public final class CustomWwwAuthenticationProtocolHandler implements ProtocolHandler { + + private static final Logger LOG = + LoggerFactory.getLogger(CustomWwwAuthenticationProtocolHandler.class); + private static final String ATTRIBUTE = + CustomWwwAuthenticationProtocolHandler.class.getName() + ".attribute"; + private static final Pattern CHALLENGE_PATTERN = Pattern.compile( + "(?[!#$%&'*+\\-.^_`|~0-9A-Za-z]+)" + + "|(?:(?[!#$%&'*+\\-.^_`|~0-9A-Za-z]+)\\s+)?" + + "(?:(?[a-zA-Z0-9\\-._~+/]+=*)" + + "|(?[!#$%&'*+\\-.^_`|~0-9A-Za-z]+)\\s*=\\s*(?:(?.*)))"); + + private final HttpClient client; + private final int maxContentLength; + + public CustomWwwAuthenticationProtocolHandler(HttpClient client) { + this(client, AuthenticationProtocolHandler.DEFAULT_MAX_CONTENT_LENGTH); + } + + public CustomWwwAuthenticationProtocolHandler(HttpClient client, int maxContentLength) { + this.client = client; + this.maxContentLength = maxContentLength; + } + + /** + * Removes Jetty's default www-authenticate handler and installs this one. Must be called after + * {@link HttpClient#start()}. + */ + public static void install(HttpClient httpClient) { + if (httpClient == null) { + return; + } + httpClient.getProtocolHandlers().remove(WWWAuthenticationProtocolHandler.NAME); + httpClient.getProtocolHandlers() + .put(new CustomWwwAuthenticationProtocolHandler(httpClient)); + } + + @Override + public String getName() { + return WWWAuthenticationProtocolHandler.NAME; + } + + @Override + public boolean accept(Request request, Response response) { + return response.getStatus() == HttpStatus.UNAUTHORIZED_401; + } + + @Override + public Response.Listener getResponseListener() { + return new AuthenticationListener(); + } + + private List getHeaderInfo(String header) { + List headerInfos = new ArrayList<>(); + for (String value : new QuotedCSV(true, header)) { + Matcher m = CHALLENGE_PATTERN.matcher(value); + if (!m.matches()) { + continue; + } + if (m.group("schemeOnly") != null) { + headerInfos.add(new HeaderInfo(HttpHeader.AUTHORIZATION, m.group(1), new HashMap<>())); + continue; + } + if (m.group("scheme") != null) { + headerInfos.add( + new HeaderInfo(HttpHeader.AUTHORIZATION, m.group("scheme"), new HashMap<>())); + } + if (headerInfos.isEmpty()) { + throw new IllegalArgumentException("Parameters without auth-scheme"); + } + Map authParams = headerInfos.get(headerInfos.size() - 1).getParameters(); + if (m.group("paramName") != null) { + String paramVal = QuotedCSV.unquote(m.group("paramValue")); + authParams.put(m.group("paramName"), paramVal); + } else if (m.group("token68") != null) { + if (!authParams.isEmpty()) { + throw new IllegalArgumentException("token68 after auth-params"); + } + authParams.put("base64", m.group("token68")); + } + } + return headerInfos; + } + + private class AuthenticationListener extends RetainingResponseListener { + private AuthenticationListener() { + super(maxContentLength); + } + + @Override + public void onSuccess(Response response) { + super.onSuccess(response); + Request request = response.getRequest(); + if (request.getBody() != null) { + request.abort(new HttpRequestException( + "Aborting request after receiving a %d response".formatted(response.getStatus()), + request)); + } + } + + @Override + public void onComplete(Result result) { + HttpRequest request = (HttpRequest) result.getRequest(); + ContentResponse response = + new HttpContentResponse(result.getResponse(), getContent(), getMediaType(), + getEncoding()); + + HttpConversation conversation = request.getConversation(); + if (conversation.getAttribute(ATTRIBUTE) != null) { + if (LOG.isDebugEnabled()) { + LOG.debug("Bad credentials for {}", request); + } + forwardSuccessComplete(request, response); + return; + } + + List headerInfos = parseAuthenticateHeader(response); + if (headerInfos.isEmpty()) { + // JMeter HttpClient4 parity: tolerate 401 without WWW-Authenticate. + if (LOG.isDebugEnabled()) { + LOG.debug("401 without WWW-Authenticate for {}; returning response to application", + request); + } + forwardSuccessComplete(request, response); + return; + } + + Authentication authentication = null; + HeaderInfo headerInfo = null; + URI authURI = resolveURI(request, request.getURI()); + for (HeaderInfo element : headerInfos) { + authentication = client.getAuthenticationStore() + .findAuthentication(element.getType(), authURI, element.getRealm()); + if (authentication != null) { + headerInfo = element; + break; + } + } + if (authentication == null) { + if (LOG.isDebugEnabled()) { + LOG.debug("No authentication available for {}", request); + } + forwardSuccessComplete(request, response); + return; + } + + Request.Content content = request.getBody(); + if (content != null && !content.rewind()) { + if (LOG.isDebugEnabled()) { + LOG.debug("Request content not reproducible for {}", request); + } + forwardSuccessComplete(request, response); + return; + } + + try { + Authentication.Result authnResult = + authentication.authenticate(request, response, headerInfo, conversation); + if (LOG.isDebugEnabled()) { + LOG.debug("Authentication result {}", authnResult); + } + if (authnResult == null) { + forwardSuccessComplete(request, response); + return; + } + + conversation.setAttribute(ATTRIBUTE, true); + + Request newRequest = copyRequest(request, request.getURI()); + if (HttpMethod.CONNECT.is(newRequest.getMethod())) { + newRequest.path(request.getPath()); + } + + long timeoutNanoTime = request.getTimeoutNanoTime(); + if (timeoutNanoTime < Long.MAX_VALUE) { + long newTimeout = NanoTime.until(timeoutNanoTime); + if (newTimeout > 0) { + newRequest.timeout(newTimeout, TimeUnit.NANOSECONDS); + } else { + TimeoutException failure = new TimeoutException( + "Total timeout " + request.getConversation().getTimeout() + " ms elapsed"); + forwardFailureComplete(request, failure, response, failure); + return; + } + } + + authnResult.apply(newRequest); + copyIfAbsent(request, newRequest, HttpHeader.AUTHORIZATION); + copyIfAbsent(request, newRequest, HttpHeader.PROXY_AUTHORIZATION); + + AfterAuthenticationListener listener = new AfterAuthenticationListener(authnResult); + Connection connection = + (Connection) request.getAttributes().get(Connection.class.getName()); + if (connection != null) { + connection.send(newRequest, listener); + } else { + newRequest.send(listener); + } + } catch (Throwable ex) { + if (LOG.isDebugEnabled()) { + LOG.atDebug().setCause(ex).log("Authentication failed"); + } + forwardFailureComplete(request, null, response, ex); + } + } + + private URI resolveURI(HttpRequest request, URI uri) { + if (uri != null) { + return uri; + } + String target = request.getScheme() + "://" + request.getHost(); + int port = request.getPort(); + if (port > 0) { + target += ":" + port; + } + return URI.create(target); + } + + private void copyIfAbsent(HttpRequest oldRequest, Request newRequest, HttpHeader header) { + HttpField field = oldRequest.getHeaders().getField(header); + if (field != null && !newRequest.getHeaders().contains(header)) { + newRequest.headers(headers -> headers.put(field)); + } + } + + private void forwardSuccessComplete(HttpRequest request, Response response) { + HttpConversation conversation = request.getConversation(); + conversation.updateResponseListeners(null); + ResponseListeners responseListeners = conversation.getResponseListeners(); + responseListeners.emitSuccessComplete(new Result(request, response)); + } + + private void forwardFailureComplete(HttpRequest request, Throwable requestFailure, + Response response, Throwable responseFailure) { + HttpConversation conversation = request.getConversation(); + conversation.updateResponseListeners(null); + ResponseListeners responseListeners = conversation.getResponseListeners(); + if (responseFailure == null) { + responseListeners.emitSuccess(response); + } else { + responseListeners.emitFailure(response, responseFailure); + } + responseListeners.notifyComplete( + new Result(request, requestFailure, response, responseFailure)); + } + + private List parseAuthenticateHeader(Response response) { + List result = new ArrayList<>(); + List values = response.getHeaders().getValuesList(HttpHeader.WWW_AUTHENTICATE); + for (String value : values) { + try { + result.addAll(getHeaderInfo(value)); + } catch (IllegalArgumentException e) { + if (LOG.isDebugEnabled()) { + LOG.debug("Failed to parse authentication header", e); + } + } + } + return result; + } + } + + private Request copyRequest(Request request, URI uri) { + try { + Method copyRequest = + HttpClient.class.getDeclaredMethod("copyRequest", Request.class, URI.class); + copyRequest.setAccessible(true); + return (Request) copyRequest.invoke(client, request, uri); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to copy Jetty request for authentication retry", e); + } + } + + private class AfterAuthenticationListener implements Response.Listener { + private final Authentication.Result authenticationResult; + + private AfterAuthenticationListener(Authentication.Result authenticationResult) { + this.authenticationResult = authenticationResult; + } + + @Override + public void onSuccess(Response response) { + int status = response.getStatus(); + if (HttpStatus.isSuccess(status) || HttpStatus.isRedirectionWithLocation(status)) { + client.getAuthenticationStore().addAuthenticationResult(authenticationResult); + } + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java index e1630880..8a66b07c 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java @@ -20,6 +20,7 @@ import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_200_WITH_BODY; import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_302; import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_400; +import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_401_NO_WWW_AUTHENTICATE; import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_BIG_RESPONSE; import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_200_DEFLATE; import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_JSON_ONLY; @@ -602,6 +603,33 @@ public void shouldReturnFailureSampleResultWhenResponse400() throws Exception { validateResponse(sampleWithGet(SERVER_PATH_400), expected); } + /** + * 401 without {@code WWW-Authenticate} is valid when the server uses a non-HTTP-Auth mechanism + * (token, API key, etc.). Jetty's default would throw; {@code CustomWwwAuthenticationProtocolHandler} + * returns the 401 like JMeter HttpClient4. + */ + @Test + public void shouldReturn401WhenServerOmitsWwwAuthenticateHeader() throws Exception { + buildStartedServer(); + HTTPSampleResult result = sampleWithGet(SERVER_PATH_401_NO_WWW_AUTHENTICATE); + softly.assertThat(result.getResponseCode()).isEqualTo("401"); + softly.assertThat(result.isSuccessful()).isFalse(); + softly.assertThat(result.getResponseDataAsString()).contains("Unauthorized"); + } + + /** + * Same as {@link #shouldReturn401WhenServerOmitsWwwAuthenticateHeader()} with Auth Manager + * configured: without a challenge header there is nothing to match, so the 401 is returned. + */ + @Test + public void shouldReturn401WithoutWwwAuthenticateEvenWhenAuthManagerConfigured() throws Exception { + buildStartedServer(); + configureAuthManager(Mechanism.BASIC); + HTTPSampleResult result = sampleWithGet(SERVER_PATH_401_NO_WWW_AUTHENTICATE); + softly.assertThat(result.getResponseCode()).isEqualTo("401"); + softly.assertThat(result.isSuccessful()).isFalse(); + } + @Test(expected = UnsupportedOperationException.class) public void shouldReturnErrorMessageWhenMethodIsNotSupported() throws Exception { client.sample(sampler, buildBaseResult(createURL(SERVER_PATH_200), "MethodNotSupported"), false, diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java b/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java index 65fc7904..818e0fa8 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java @@ -81,6 +81,11 @@ public class ServerBuilder { public static final String SERVER_PATH_200_FILE_SENT = "/test/file"; public static final String SERVER_PATH_BIG_RESPONSE = "/test/big-response"; public static final String SERVER_PATH_400 = "/test/400"; + /** + * 401 without {@code WWW-Authenticate}: common for app-level auth (JWT, API key, etc.), not an + * HTTP Auth challenge. Used to assert Jetty does not fail the exchange (JMeter HttpClient4 parity). + */ + public static final String SERVER_PATH_401_NO_WWW_AUTHENTICATE = "/test/401-no-www-authenticate"; public static final String SERVER_PATH_302 = "/test/302"; public static final String SERVER_PATH_200_WITH_BODY = "/test/body"; public static final String SERVER_PATH_JSON_ONLY = "/test/json-only"; @@ -306,6 +311,12 @@ protected void service(HttpServletRequest req, HttpServletResponse resp) case SERVER_PATH_400: resp.setStatus(HttpStatus.BAD_REQUEST_400); break; + case SERVER_PATH_401_NO_WWW_AUTHENTICATE: + // App-level unauthorized: no WWW-Authenticate (not an HTTP Auth challenge). + resp.setStatus(HttpStatus.UNAUTHORIZED_401); + resp.setContentType("text/plain; charset=utf-8"); + resp.getWriter().write("Unauthorized"); + break; case SERVER_PATH_302: resp.addHeader(HTTPConstants.HEADER_LOCATION, "https://localhost:" + req.getLocalPort() + SERVER_PATH_200); diff --git a/src/test/java/com/blazemeter/jmeter/http2/parity/Http401WithoutWwwAuthenticateParityTest.java b/src/test/java/com/blazemeter/jmeter/http2/parity/Http401WithoutWwwAuthenticateParityTest.java new file mode 100644 index 00000000..e921b716 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/parity/Http401WithoutWwwAuthenticateParityTest.java @@ -0,0 +1,148 @@ +package com.blazemeter.jmeter.http2.parity; + +import static com.blazemeter.jmeter.http2.core.ServerBuilder.HOST_NAME; +import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_401_NO_WWW_AUTHENTICATE; +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.core.HTTP2ClientProfileConfig; +import com.blazemeter.jmeter.http2.core.HTTP2JettyClient; +import com.blazemeter.jmeter.http2.core.ServerBuilder; +import com.blazemeter.jmeter.http2.core.ServerBuilder.TeardownableServer; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import com.blazemeter.jmeter.http2.sampler.JMeterTestUtils; +import java.lang.reflect.Method; +import java.net.URI; +import java.net.URL; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase; +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerFactory; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.util.JMeterUtils; +import org.eclipse.jetty.server.ServerConnector; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Side-by-side parity for HTTP 401 responses that omit {@code WWW-Authenticate}. + * + *

A 401 does not imply HTTP Auth ({@code WWW-Authenticate}). Servers often use JWT, API keys, + * cookies, or other schemes and still return 401 without that header. Jetty's default handler + * treats every 401 as an HTTP Auth challenge and fails without the header; JMeter HttpClient4 + * returns a normal 401 sample. {@code CustomWwwAuthenticationProtocolHandler} aligns BlazeMeter + * HTTP with that HttpClient4 behavior. + */ +public class Http401WithoutWwwAuthenticateParityTest extends HTTP2TestBase { + + private static final String HTTP_CLIENT4 = "HttpClient4"; + + private TeardownableServer server; + private int serverPort; + private HTTP2JettyClient pluginClient; + private HTTP2Sampler sampler; + private String originalSharedThreadPoolProperty; + + @BeforeClass + public static void setupClass() { + JMeterTestUtils.setupJmeterEnv(); + } + + @Before + public void setUp() throws Exception { + originalSharedThreadPoolProperty = JMeterUtils.getProperty("httpJettyClient.sharedThreadPool"); + JMeterUtils.setProperty("httpJettyClient.sharedThreadPool", "false"); + JMeterUtils.setProperty("httpJettyClient.enableHttp1", "true"); + JMeterUtils.setProperty("httpJettyClient.enableHttp2", "false"); + JMeterUtils.setProperty("httpJettyClient.enableHttp3", "false"); + + server = new ServerBuilder().withHTTP1().buildServer(); + server.start(); + serverPort = ((ServerConnector) server.getConnectors()[0]).getLocalPort(); + + sampler = new HTTP2Sampler(); + sampler.setMethod(HTTPConstants.GET); + sampler.setProtocol(HTTPConstants.PROTOCOL_HTTP); + sampler.setDomain(HOST_NAME); + sampler.setPort(serverPort); + sampler.setPath(SERVER_PATH_401_NO_WWW_AUTHENTICATE); + sampler.setFollowRedirects(false); + sampler.setAutoRedirects(false); + + pluginClient = new HTTP2JettyClient(false, "401-parity", + HTTP2ClientProfileConfig.builder() + .enableHttp1(true) + .enableHttp2(false) + .enableHttp3(false) + .build()); + pluginClient.start(); + } + + @After + public void tearDown() throws Exception { + if (pluginClient != null) { + pluginClient.stop(); + } + if (sampler != null) { + sampler.threadFinished(); + } + if (server != null) { + server.stop(); + } + if (originalSharedThreadPoolProperty == null) { + JMeterUtils.getJMeterProperties().remove("httpJettyClient.sharedThreadPool"); + } else { + JMeterUtils.setProperty("httpJettyClient.sharedThreadPool", + originalSharedThreadPoolProperty); + } + } + + @Test + public void pluginShouldMatchHttpClient4For401WithoutWwwAuthenticate() throws Exception { + URL url = targetUrl(); + HTTPSampleResult reference = sampleHttpClient4(url); + HTTPSampleResult plugin = samplePlugin(url); + + assertThat(reference.getResponseCode()).isEqualTo("401"); + assertThat(plugin.getResponseCode()) + .as("response code") + .isEqualTo(reference.getResponseCode()); + assertThat(plugin.isSuccessful()) + .as("success flag") + .isEqualTo(reference.isSuccessful()); + assertThat(plugin.getResponseDataAsString()) + .as("response body") + .isEqualTo(reference.getResponseDataAsString()); + } + + private URL targetUrl() throws Exception { + return URI.create("http://" + HOST_NAME + ":" + serverPort + + SERVER_PATH_401_NO_WWW_AUTHENTICATE).toURL(); + } + + private HTTPSampleResult sampleHttpClient4(URL url) throws Exception { + HTTPSamplerBase hc4 = HTTPSamplerFactory.newInstance(HTTP_CLIENT4); + hc4.setMethod(sampler.getMethod()); + hc4.setProtocol(sampler.getProtocol()); + hc4.setDomain(sampler.getDomain()); + hc4.setPort(sampler.getPort()); + hc4.setPath(sampler.getPath()); + hc4.setFollowRedirects(sampler.getFollowRedirects()); + hc4.setAutoRedirects(sampler.getAutoRedirects()); + hc4.setUseKeepAlive(true); + Method sample = HTTPSamplerBase.class.getDeclaredMethod( + "sample", URL.class, String.class, boolean.class, int.class); + sample.setAccessible(true); + return (HTTPSampleResult) sample.invoke(hc4, url, sampler.getMethod(), false, 1); + } + + private HTTPSampleResult samplePlugin(URL url) throws Exception { + HTTPSampleResult shell = new HTTPSampleResult(); + shell.setURL(url); + shell.setHTTPMethod(sampler.getMethod()); + shell.setSampleLabel(sampler.getName()); + pluginClient.loadProperties(); + return pluginClient.sample(sampler, shell, false, 0); + } +} From 9057a4baf682ba04d36886040cc381b7170a53c3 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 10 Jul 2026 15:44:03 -0300 Subject: [PATCH 11/35] Add UA when default_user_agent_disabled is false (#123) --- .../core/JmeterRequestHeadersSupport.java | 44 ++++++++++++- .../core/JmeterRequestHeadersSupportTest.java | 61 ++++++++++++++++++- 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JmeterRequestHeadersSupport.java b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterRequestHeadersSupport.java index 2c2c719f..fd3b4f7c 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/JmeterRequestHeadersSupport.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterRequestHeadersSupport.java @@ -1,6 +1,8 @@ package com.blazemeter.jmeter.http2.core; +import org.apache.commons.lang3.StringUtils; import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.util.JMeterUtils; import org.eclipse.jetty.client.Request; import org.eclipse.jetty.http.HttpFields; import org.eclipse.jetty.http.HttpHeader; @@ -16,6 +18,11 @@ final class JmeterRequestHeadersSupport { static final String ATTR_USE_KEEPALIVE = "bzm.useKeepAlive"; + static final String DEFAULT_USER_AGENT_PROPERTY = + "httpclient4.default_user_agent_disabled"; + + private static final String DEFAULT_USER_AGENT = buildDefaultUserAgent(); + private JmeterRequestHeadersSupport() { } @@ -28,8 +35,7 @@ static void prepareFromSampler(Request request, boolean useKeepAlive) { } request.attribute(ATTR_USE_KEEPALIVE, useKeepAlive); applyConnectionHeader(request, useKeepAlive); - // TODO: HC4 sends explicit empty User-Agent when none is configured (disableDefaultUserAgent). - // prepareEmptyUserAgentHeader(request); + applyDefaultUserAgentHeader(request); } /** @@ -54,7 +60,6 @@ static HttpFields headersForSampleResult(Request request) { } HttpFields.Mutable merged = HttpFields.build(request.getHeaders()); restoreConnectionHeaderForSample(request, merged); - // restoreEmptyUserAgentForSample(request, merged); return merged; } @@ -73,6 +78,39 @@ private static void applyConnectionHeader(Request request, boolean useKeepAlive) } } + + /** + * Matches {@code HTTPHC4Impl}: when no {@code User-Agent} is configured, send a plugin default + * unless {@code httpclient4.default_user_agent_disabled=true}. + */ + private static void applyDefaultUserAgentHeader(Request request) { + if (isDefaultUserAgentDisabled()) { + return; + } + HttpFields.Mutable mutableHeaders = mutableHeaders(request); + if (mutableHeaders == null || mutableHeaders.contains(HttpHeader.USER_AGENT)) { + return; + } + mutableHeaders.put(HttpHeader.USER_AGENT, DEFAULT_USER_AGENT); + } + + static boolean isDefaultUserAgentDisabled() { + return JMeterUtils.getPropDefault(DEFAULT_USER_AGENT_PROPERTY, false); + } + + static String defaultUserAgent() { + return DEFAULT_USER_AGENT; + } + + private static String buildDefaultUserAgent() { + Package pkg = JmeterRequestHeadersSupport.class.getPackage(); + String version = pkg != null ? pkg.getImplementationVersion() : null; + if (StringUtils.isBlank(version)) { + return "BlazeMeter HTTP"; + } + return "BlazeMeter HTTP/" + version; + } + private static void restoreConnectionHeaderForSample( Request request, HttpFields.Mutable headers) { Object useKeepAlive = request.getAttributes().get(ATTR_USE_KEEPALIVE); diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/JmeterRequestHeadersSupportTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/JmeterRequestHeadersSupportTest.java index b5079a35..2d1e5626 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/JmeterRequestHeadersSupportTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/JmeterRequestHeadersSupportTest.java @@ -4,17 +4,28 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import com.blazemeter.jmeter.http2.HTTP2TestBase; import java.util.HashMap; import java.util.Map; import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.util.JMeterUtils; import org.eclipse.jetty.client.Request; import org.eclipse.jetty.http.HttpFields; import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpVersion; +import org.junit.After; import org.junit.Test; import org.mockito.Mockito; -public class JmeterRequestHeadersSupportTest { +public class JmeterRequestHeadersSupportTest extends HTTP2TestBase { + + private static final String UA_DISABLED_PROP = + JmeterRequestHeadersSupport.DEFAULT_USER_AGENT_PROPERTY; + + @After + public void clearUserAgentProperty() { + JMeterUtils.getJMeterProperties().remove(UA_DISABLED_PROP); + } private static Request mockRequest(HttpFields.Mutable headers) { Map attributes = new HashMap<>(); @@ -74,4 +85,50 @@ public void prepareFromSamplerDoesNotOverrideExplicitConnectionHeader() { assertThat(headers.get(HttpHeader.CONNECTION)).isEqualTo("Upgrade"); } -} \ No newline at end of file + + @Test + public void prepareFromSamplerAddsDefaultUserAgentWhenMissing() { + HttpFields.Mutable headers = HttpFields.build(); + Request request = mockRequest(headers); + + JmeterRequestHeadersSupport.prepareFromSampler(request, true); + + assertThat(headers.get(HttpHeader.USER_AGENT)) + .isEqualTo(JmeterRequestHeadersSupport.defaultUserAgent()); + assertThat(headers.get(HttpHeader.USER_AGENT)).startsWith("BlazeMeter HTTP"); + } + + @Test + public void prepareFromSamplerDoesNotOverrideConfiguredUserAgent() { + HttpFields.Mutable headers = HttpFields.build() + .add(HttpHeader.USER_AGENT, "CustomAgent/1.0"); + Request request = mockRequest(headers); + + JmeterRequestHeadersSupport.prepareFromSampler(request, true); + + assertThat(headers.get(HttpHeader.USER_AGENT)).isEqualTo("CustomAgent/1.0"); + } + + @Test + public void prepareFromSamplerKeepsEmptyUserAgentFromHeaderManager() { + HttpFields.Mutable headers = HttpFields.build() + .add(HttpHeader.USER_AGENT, ""); + Request request = mockRequest(headers); + + JmeterRequestHeadersSupport.prepareFromSampler(request, true); + + assertThat(headers.contains(HttpHeader.USER_AGENT)).isTrue(); + assertThat(headers.get(HttpHeader.USER_AGENT)).isEmpty(); + } + + @Test + public void prepareFromSamplerOmitsDefaultUserAgentWhenPropertyDisabled() { + JMeterUtils.setProperty(UA_DISABLED_PROP, "true"); + HttpFields.Mutable headers = HttpFields.build(); + Request request = mockRequest(headers); + + JmeterRequestHeadersSupport.prepareFromSampler(request, true); + + assertThat(headers.contains(HttpHeader.USER_AGENT)).isFalse(); + } +} From bbd856bd8c6d78c43013e3e81b53ce73c128e64f Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 10 Jul 2026 15:57:34 -0300 Subject: [PATCH 12/35] Support HEAD method (#122) --- .../jmeter/http2/core/HTTP2JettyClient.java | 4 +- .../http2/core/HTTP2JettyClientTest.java | 41 +++++++++++++++++++ .../jmeter/http2/core/ServerBuilder.java | 5 ++- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index a06550f1..42c57178 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -133,8 +133,8 @@ public class HTTP2JettyClient { "HTTP2JettyClient build: host-header-filter+http3-always-v2026-01-26"; private static final boolean FORCE_HTTP2_ONLY = false; private static final Set SUPPORTED_METHODS = new HashSet<>(Arrays - .asList(HTTPConstants.GET, HTTPConstants.POST, HTTPConstants.PUT, HTTPConstants.PATCH, - HTTPConstants.OPTIONS, HTTPConstants.DELETE)); + .asList(HTTPConstants.GET, HTTPConstants.HEAD, HTTPConstants.POST, HTTPConstants.PUT, + HTTPConstants.PATCH, HTTPConstants.OPTIONS, HTTPConstants.DELETE)); private static final Set METHODS_WITH_BODY = new HashSet<>(Arrays .asList(HTTPConstants.POST, HTTPConstants.PUT, HTTPConstants.PATCH)); private static final Path ALPN_DEBUG_LOG_PATH = resolveAlpnLogPath(); diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java index 8a66b07c..dd29b997 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java @@ -185,6 +185,47 @@ public void shouldGetResponseWhenGetMethodIsSent() throws Exception { assertThat(result.getResponseDataAsString()).isEqualTo(SERVER_RESPONSE); } + @Test + public void shouldSucceedWhenHeadMethodIsSent() throws Exception { + buildStartedServer(); + sampler.setMethod(HTTPConstants.HEAD); + HTTPSampleResult result = sample(SERVER_PATH_200, HTTPConstants.HEAD); + softly.assertThat(result.isSuccessful()).isTrue(); + softly.assertThat(result.getResponseCode()).isEqualTo("200"); + softly.assertThat(result.getHTTPMethod()).isEqualTo(HTTPConstants.HEAD); + // HEAD responses must not carry a body. + softly.assertThat(result.getResponseDataAsString()).isEmpty(); + } + + @Test + public void shouldNotSendBodyWhenHeadMethodWithArguments() throws Exception { + buildStartedServer(); + sampler.setMethod(HTTPConstants.HEAD); + sampler.addArgument("test1", TEST_ARGUMENT_1); + sampler.addArgument("test2", TEST_ARGUMENT_2); + HTTPSampleResult result = sample(SERVER_PATH_200, HTTPConstants.HEAD); + softly.assertThat(result.isSuccessful()).isTrue(); + softly.assertThat(result.getResponseCode()).isEqualTo("200"); + softly.assertThat(result.getResponseDataAsString()).isEmpty(); + // HEAD is not a body method; arguments must not become a request entity. + softly.assertThat(result.getQueryString()).isNullOrEmpty(); + softly.assertThat(result.getRequestHeaders()).doesNotContain("Content-Length"); + } + + @Test + public void shouldFollowRedirectWhenHeadMethodAndFollowRedirectEnabled() throws Exception { + buildStartedServer(); + sampler.setMethod(HTTPConstants.HEAD); + sampler.setFollowRedirects(true); + HTTPSampleResult result = sample(SERVER_PATH_302, HTTPConstants.HEAD); + softly.assertThat(result.isSuccessful()).isTrue(); + softly.assertThat(result.getResponseCode()).isEqualTo("200"); + softly.assertThat(result.getSubResults().length).isGreaterThan(0); + softly.assertThat(result.getRedirectLocation()) + .isEqualTo("https://localhost:" + getActivePort() + SERVER_PATH_200); + softly.assertThat(result.getResponseDataAsString()).isEmpty(); + } + private void buildStartedServer() throws Exception { server = new ServerBuilder() .withHTTP2() diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java b/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java index 818e0fa8..28cb5015 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java @@ -298,7 +298,10 @@ protected void service(HttpServletRequest req, HttpServletResponse resp) case SERVER_PATH_200: resp.setStatus(HttpStatus.OK_200); resp.setContentType(MimeTypes.MIME_TEXT_HTML + ";" + StandardCharsets.UTF_8.name()); - resp.getWriter().write(SERVER_RESPONSE); + // HEAD must not include a response body (RFC 9110). + if (!"HEAD".equalsIgnoreCase(req.getMethod())) { + resp.getWriter().write(SERVER_RESPONSE); + } break; case SERVER_PATH_SLOW: try { From d290ac1c034715ce23347b6dbf826cbdfb187f59 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Wed, 15 Jul 2026 18:12:41 -0300 Subject: [PATCH 13/35] Customize HPACK header validation (#121) * Customize header validation for HPACK. * Remove reflection and add shims to gain access * Test CustomHpackDecoder --- pom.xml | 6 +- .../jmeter/http2/core/HTTP2JettyClient.java | 4 +- ...ustomClientConnectionFactoryOverHTTP2.java | 14 +- .../CustomHTTP2ClientConnectionFactory.java | 164 ++++++++ .../http2/CustomHttp2HeaderNormalizer.java | 118 ++++++ .../CustomHttpClientTransportOverHTTP2.java | 27 ++ .../core/jetty/custom/http2/CustomParser.java | 37 ++ ...ustomClientConnectionFactoryOverHTTP3.java | 11 +- .../CustomHttp2SessionContainerAccessor.java | 27 ++ .../CustomHttp2ClientConfigurer.java | 31 ++ .../jetty/http2/hpack/CustomHpackDecoder.java | 368 ++++++++++++++++++ .../CustomHttp3ClientConfigurer.java | 31 ++ .../core/HpackAkamaiStatusDecoderTest.java | 169 ++++++++ .../HpackAkamaiStatusIntegrationTest.java | 225 +++++++++++ .../CustomHttp2HeaderNormalizerTest.java | 33 ++ 15 files changed, 1240 insertions(+), 25 deletions(-) create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHTTP2ClientConnectionFactory.java create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttp2HeaderNormalizer.java create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttpClientTransportOverHTTP2.java create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomParser.java create mode 100644 src/main/java/org/eclipse/jetty/http2/client/CustomHttp2SessionContainerAccessor.java create mode 100644 src/main/java/org/eclipse/jetty/http2/client/transport/CustomHttp2ClientConfigurer.java create mode 100644 src/main/java/org/eclipse/jetty/http2/hpack/CustomHpackDecoder.java create mode 100644 src/main/java/org/eclipse/jetty/http3/client/transport/CustomHttp3ClientConfigurer.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusDecoderTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusIntegrationTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttp2HeaderNormalizerTest.java diff --git a/pom.xml b/pom.xml index 3d643fda..71087706 100644 --- a/pom.xml +++ b/pom.xml @@ -31,9 +31,9 @@ UTF-8 UTF-8 5.4.1 - 12.1.7 - - 1.20.0 + 12.1.11 + + 1.23.0 false diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index 42c57178..7b48ff67 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -4,6 +4,7 @@ import com.blazemeter.jmeter.http2.core.jetty.CustomWwwAuthenticationProtocolHandler; import com.blazemeter.jmeter.http2.core.jetty.custom.http2.CustomClientConnectionFactoryOverHTTP2; +import com.blazemeter.jmeter.http2.core.jetty.custom.http2.CustomHttpClientTransportOverHTTP2; import com.blazemeter.jmeter.http2.core.jetty.custom.http3.CustomClientConnectionFactoryOverHTTP3; import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; import com.blazemeter.jmeter.http2.util.BzmHttpPluginProperties; @@ -100,7 +101,6 @@ import org.eclipse.jetty.http2.HTTP2Session; import org.eclipse.jetty.http2.api.Session; import org.eclipse.jetty.http2.client.HTTP2Client; -import org.eclipse.jetty.http2.client.transport.HttpClientTransportOverHTTP2; import org.eclipse.jetty.http2.frames.Frame; import org.eclipse.jetty.http2.frames.GoAwayFrame; import org.eclipse.jetty.http2.frames.HeadersFrame; @@ -538,7 +538,7 @@ public HTTP2JettyClient(boolean http1UpgradeRequired, String name, } else { http2cClient.setMaxConcurrentPushedStreams(maxConcurrentPushedStreams); } - HttpClientTransport h2cTransport = new HttpClientTransportOverHTTP2(http2cClient); + HttpClientTransport h2cTransport = new CustomHttpClientTransportOverHTTP2(http2cClient); configureTransport(h2cTransport); this.httpClientH2cPrior = new HttpClient(h2cTransport); configureHttpClient(this.httpClientH2cPrior, h2cConnector); diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomClientConnectionFactoryOverHTTP2.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomClientConnectionFactoryOverHTTP2.java index b642ade8..3a7c0b11 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomClientConnectionFactoryOverHTTP2.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomClientConnectionFactoryOverHTTP2.java @@ -10,7 +10,7 @@ import org.eclipse.jetty.client.transport.HttpClientConnectionFactory; import org.eclipse.jetty.client.transport.HttpDestination; import org.eclipse.jetty.http2.client.HTTP2Client; -import org.eclipse.jetty.http2.client.HTTP2ClientConnectionFactory; +import org.eclipse.jetty.http2.client.transport.CustomHttp2ClientConfigurer; import org.eclipse.jetty.http2.client.transport.internal.HttpConnectionOverHTTP2; import org.eclipse.jetty.io.ClientConnectionFactory; import org.eclipse.jetty.io.EndPoint; @@ -22,7 +22,7 @@ */ public class CustomClientConnectionFactoryOverHTTP2 extends ContainerLifeCycle implements ClientConnectionFactory, HttpClient.Aware { - private final ClientConnectionFactory factory = new HTTP2ClientConnectionFactory(); + private final ClientConnectionFactory factory = new CustomHTTP2ClientConnectionFactory(); private final HTTP2Client http2Client; public CustomClientConnectionFactoryOverHTTP2(HTTP2Client http2Client) { @@ -32,15 +32,7 @@ public CustomClientConnectionFactoryOverHTTP2(HTTP2Client http2Client) { @Override public void setHttpClient(HttpClient httpClient) { - try { - java.lang.reflect.Method configureMethod = org.eclipse.jetty.http2.client.transport - .HttpClientTransportOverHTTP2.class.getDeclaredMethod( - "configure", HttpClient.class, HTTP2Client.class); - configureMethod.setAccessible(true); - configureMethod.invoke(null, httpClient, http2Client); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("Unable to configure HTTP/2 transport", e); - } + CustomHttp2ClientConfigurer.configure(httpClient, http2Client); } @Override diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHTTP2ClientConnectionFactory.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHTTP2ClientConnectionFactory.java new file mode 100644 index 00000000..d8245afd --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHTTP2ClientConnectionFactory.java @@ -0,0 +1,164 @@ +package com.blazemeter.jmeter.http2.core.jetty.custom.http2; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.eclipse.jetty.http2.FlowControlStrategy; +import org.eclipse.jetty.http2.HTTP2Connection; +import org.eclipse.jetty.http2.HTTP2Session; +import org.eclipse.jetty.http2.api.Session; +import org.eclipse.jetty.http2.client.CustomHttp2SessionContainerAccessor; +import org.eclipse.jetty.http2.client.HTTP2Client; +import org.eclipse.jetty.http2.client.internal.HTTP2ClientSession; +import org.eclipse.jetty.http2.frames.Frame; +import org.eclipse.jetty.http2.frames.PrefaceFrame; +import org.eclipse.jetty.http2.frames.SettingsFrame; +import org.eclipse.jetty.http2.frames.WindowUpdateFrame; +import org.eclipse.jetty.http2.generator.Generator; +import org.eclipse.jetty.http2.hpack.HpackContext; +import org.eclipse.jetty.io.ByteBufferPool; +import org.eclipse.jetty.io.ClientConnectionFactory; +import org.eclipse.jetty.io.Connection; +import org.eclipse.jetty.io.EndPoint; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.Promise; + +/** + * Jetty 12.1.11 {@code HTTP2ClientConnectionFactory} variant that installs + * {@link CustomParser} and {@link org.eclipse.jetty.http2.hpack.CustomHpackDecoder} + * for Firefox-inspired soft header handling. + */ +public class CustomHTTP2ClientConnectionFactory implements ClientConnectionFactory { + + @Override + public Connection newConnection(EndPoint endPoint, Map context) { + HTTP2Client client = (HTTP2Client) context.get(HTTP2Client.CONTEXT_KEY); + ByteBufferPool bufferPool = client.getByteBufferPool(); + Session.Listener listener = + (Session.Listener) context.get(HTTP2Client.SESSION_LISTENER_CONTEXT_KEY); + @SuppressWarnings("unchecked") + Promise sessionPromise = + (Promise) context.get(HTTP2Client.SESSION_PROMISE_CONTEXT_KEY); + + Generator generator = new Generator(bufferPool, client.isUseOutputDirectByteBuffers(), + client.getMaxHeaderBlockFragment()); + generator.getHpackEncoder().setMaxHeaderListSize(client.getMaxRequestHeadersSize()); + + FlowControlStrategy flowControl = client.getFlowControlStrategyFactory() + .newFlowControlStrategy(); + + CustomParser parser = new CustomParser(bufferPool, client.getMaxResponseHeadersSize()); + parser.setMaxSettingsKeys(client.getMaxSettingsKeys()); + + HTTP2ClientSession session = new HTTP2ClientSession(client.getScheduler(), endPoint, parser, + generator, listener, flowControl); + session.setMaxLocalStreams(client.getMaxLocalStreams()); + session.setMaxRemoteStreams(client.getMaxConcurrentPushedStreams()); + session.setMaxEncoderTableCapacity(client.getMaxEncoderTableCapacity()); + long streamIdleTimeout = client.getStreamIdleTimeout(); + if (streamIdleTimeout > 0) { + session.setStreamIdleTimeout(streamIdleTimeout); + } + + HTTP2ClientConnection connection = + new HTTP2ClientConnection(client, endPoint, session, sessionPromise, listener); + context.put(HTTP2Connection.class.getName(), connection); + connection.addEventListener(CustomHttp2SessionContainerAccessor.getSessionContainer(client)); + client.getEventListeners().forEach(session::addEventListener); + parser.init(connection); + + return customize(connection, context); + } + + private static class HTTP2ClientConnection extends HTTP2Connection implements Callback { + private final HTTP2Client client; + private final Promise promise; + private final Session.Listener listener; + + private HTTP2ClientConnection(HTTP2Client client, EndPoint endpoint, + HTTP2ClientSession session, Promise sessionPromise, + Session.Listener listener) { + super(client.getByteBufferPool(), client.getExecutor(), endpoint, session, + client.getInputBufferSize(), -1); + this.client = client; + this.promise = sessionPromise; + this.listener = listener; + setUseInputDirectByteBuffers(client.isUseInputDirectByteBuffers()); + setUseOutputDirectByteBuffers(client.isUseOutputDirectByteBuffers()); + } + + @Override + public void onOpen() { + HTTP2Session session = getSession(); + session.notifyLifeCycleOpen(); + + Map settings = listener.onPreface(session); + settings = settings == null ? new HashMap<>() : new HashMap<>(settings); + + settings.compute(SettingsFrame.HEADER_TABLE_SIZE, (k, v) -> { + if (v == null) { + v = client.getMaxDecoderTableCapacity(); + if (v == HpackContext.DEFAULT_MAX_TABLE_CAPACITY) { + v = null; + } + } + return v; + }); + settings.computeIfAbsent(SettingsFrame.MAX_CONCURRENT_STREAMS, + k -> client.getMaxConcurrentPushedStreams()); + settings.compute(SettingsFrame.INITIAL_WINDOW_SIZE, (k, v) -> { + if (v == null) { + v = client.getInitialStreamRecvWindow(); + if (v == FlowControlStrategy.DEFAULT_WINDOW_SIZE) { + v = null; + } + } + return v; + }); + settings.compute(SettingsFrame.MAX_FRAME_SIZE, (k, v) -> { + if (v == null) { + v = client.getMaxFrameSize(); + if (v == Frame.DEFAULT_MAX_SIZE) { + v = null; + } + } + return v; + }); + settings.compute(SettingsFrame.MAX_HEADER_LIST_SIZE, (k, v) -> { + if (v == null) { + v = client.getMaxResponseHeadersSize(); + if (v <= 0) { + v = null; + } + } + return v; + }); + + PrefaceFrame prefaceFrame = new PrefaceFrame(); + SettingsFrame settingsFrame = new SettingsFrame(settings, false); + + int windowDelta = + client.getInitialSessionRecvWindow() - FlowControlStrategy.DEFAULT_WINDOW_SIZE; + session.updateRecvWindow(windowDelta); + if (windowDelta > 0) { + session.frames(null, + List.of(prefaceFrame, settingsFrame, new WindowUpdateFrame(0, windowDelta)), this); + } else { + session.frames(null, List.of(prefaceFrame, settingsFrame), this); + } + } + + @Override + public void succeeded() { + super.onOpen(); + promise.succeeded(getSession()); + produce(); + } + + @Override + public void failed(Throwable ex) { + close(); + promise.failed(ex); + } + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttp2HeaderNormalizer.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttp2HeaderNormalizer.java new file mode 100644 index 00000000..cc8e58b7 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttp2HeaderNormalizer.java @@ -0,0 +1,118 @@ +package com.blazemeter.jmeter.http2.core.jetty.custom.http2; + +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpTokens; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Firefox-inspired HTTP/2 header softening for load-testing clients. + * + *

Browsers (especially Chrome) reject non-numeric {@code :status} values. This helper is more + * permissive: it extracts a leading status code (Chrome HTTP/1 {@code ParseStatus} style) and + * trims soft-illegal field values so sampling can continue while logging the anomaly. + */ +public final class CustomHttp2HeaderNormalizer { + + private static final Logger LOG = LoggerFactory.getLogger(CustomHttp2HeaderNormalizer.class); + + private CustomHttp2HeaderNormalizer() { + } + + /** + * Normalizes a decoded header value before Jetty builds/emits the field. + * + * @return the value to use (possibly unchanged) + */ + public static String normalize(HttpHeader header, String name, String value) { + if (value == null) { + return null; + } + if (isStatusPseudoHeader(header, name)) { + return normalizeStatus(value); + } + return softenFieldValue(name, value); + } + + static boolean isStatusPseudoHeader(HttpHeader header, String name) { + if (header == HttpHeader.C_STATUS) { + return true; + } + return name != null && ":status".equalsIgnoreCase(name); + } + + /** + * Accepts {@code 299}, {@code 299 Akamai}, or leading spaces before digits. + * Returns the original value when no leading status code can be recovered. + */ + static String normalizeStatus(String value) { + String trimmedLeading = trimLeadingSpaces(value); + int digits = 0; + while (digits < trimmedLeading.length() + && trimmedLeading.charAt(digits) >= '0' + && trimmedLeading.charAt(digits) <= '9') { + digits++; + } + if (digits == 0) { + return value; + } + String code = trimmedLeading.substring(0, digits); + if (code.equals(value)) { + return value; + } + String reason = trimmedLeading.substring(digits).trim(); + if (reason.isEmpty() && code.equals(trimmedLeading)) { + if (!code.equals(value)) { + LOG.warn("Normalized HTTP/2 :status from [{}] to [{}]", value, code); + } + return code; + } + LOG.warn("Normalized HTTP/2 :status from [{}] to [{}] (ignored reason [{}])", + value, code, reason); + return code; + } + + /** + * Softens values that fail only because of leading/trailing SP/HTAB (RFC 9113). + * Control characters and other illegal octets are left untouched so Jetty can still reject them. + */ + static String softenFieldValue(String name, String value) { + if (HttpTokens.isLegalFieldValue(value)) { + return value; + } + String trimmed = trimHttpWhitespace(value); + if (trimmed.equals(value)) { + return value; + } + if (HttpTokens.isLegalFieldValue(trimmed)) { + LOG.warn("Trimmed illegal HTTP/2 header value for [{}]: [{}] -> [{}]", + name, value, trimmed); + return trimmed; + } + return value; + } + + private static String trimLeadingSpaces(String value) { + int i = 0; + while (i < value.length() && value.charAt(i) == ' ') { + i++; + } + return i == 0 ? value : value.substring(i); + } + + private static String trimHttpWhitespace(String value) { + int start = 0; + int end = value.length(); + while (start < end && isHttpWhitespace(value.charAt(start))) { + start++; + } + while (end > start && isHttpWhitespace(value.charAt(end - 1))) { + end--; + } + return (start == 0 && end == value.length()) ? value : value.substring(start, end); + } + + private static boolean isHttpWhitespace(char c) { + return c == ' ' || c == '\t'; + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttpClientTransportOverHTTP2.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttpClientTransportOverHTTP2.java new file mode 100644 index 00000000..e0112f13 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttpClientTransportOverHTTP2.java @@ -0,0 +1,27 @@ +package com.blazemeter.jmeter.http2.core.jetty.custom.http2; + +import java.io.IOException; +import java.util.Map; +import org.eclipse.jetty.http2.client.HTTP2Client; +import org.eclipse.jetty.http2.client.transport.HttpClientTransportOverHTTP2; +import org.eclipse.jetty.io.Connection; +import org.eclipse.jetty.io.EndPoint; + +/** + * Custom HTTP/2 client transport that uses {@link CustomHTTP2ClientConnectionFactory}. + */ +public class CustomHttpClientTransportOverHTTP2 extends HttpClientTransportOverHTTP2 { + + private final CustomHTTP2ClientConnectionFactory connectionFactory = + new CustomHTTP2ClientConnectionFactory(); + + public CustomHttpClientTransportOverHTTP2(HTTP2Client http2Client) { + super(http2Client); + } + + @Override + public Connection newConnection(EndPoint endPoint, Map context) + throws IOException { + return connectionFactory.newConnection(endPoint, context); + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomParser.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomParser.java new file mode 100644 index 00000000..add71eee --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomParser.java @@ -0,0 +1,37 @@ +package com.blazemeter.jmeter.http2.core.jetty.custom.http2; + +import java.lang.reflect.Field; +import org.eclipse.jetty.http2.hpack.CustomHpackDecoder; +import org.eclipse.jetty.http2.parser.Parser; +import org.eclipse.jetty.io.ByteBufferPool; +import org.eclipse.jetty.io.RateControl; + +/** + * Jetty HTTP/2 {@link Parser} that installs {@link CustomHpackDecoder} before {@link #init}. + * + *

{@code hpackDecoder} is {@code final} in Jetty; we replace it reflectively after + * {@code super(...)} and before {@code init(...)} so {@code HeaderBlockParser} captures the + * custom decoder. + */ +public class CustomParser extends Parser { + + public CustomParser(ByteBufferPool bufferPool, int maxHeaderSize) { + super(bufferPool, maxHeaderSize); + replaceHpackDecoder(maxHeaderSize); + } + + public CustomParser(ByteBufferPool bufferPool, int maxHeaderSize, RateControl rateControl) { + super(bufferPool, maxHeaderSize, rateControl); + replaceHpackDecoder(maxHeaderSize); + } + + private void replaceHpackDecoder(int maxHeaderSize) { + try { + Field field = Parser.class.getDeclaredField("hpackDecoder"); + field.setAccessible(true); + field.set(this, new CustomHpackDecoder(maxHeaderSize, this::getBeginNanoTime)); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to install CustomHpackDecoder into Parser", e); + } + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http3/CustomClientConnectionFactoryOverHTTP3.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http3/CustomClientConnectionFactoryOverHTTP3.java index 4ec94d25..d876ba6c 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http3/CustomClientConnectionFactoryOverHTTP3.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http3/CustomClientConnectionFactoryOverHTTP3.java @@ -7,7 +7,7 @@ import org.eclipse.jetty.client.transport.HttpClientTransportDynamic; import org.eclipse.jetty.http3.client.HTTP3Client; import org.eclipse.jetty.http3.client.HTTP3ClientConnectionFactory; -import org.eclipse.jetty.http3.client.transport.HttpClientTransportOverHTTP3; +import org.eclipse.jetty.http3.client.transport.CustomHttp3ClientConfigurer; import org.eclipse.jetty.io.ClientConnectionFactory; import org.eclipse.jetty.io.EndPoint; import org.eclipse.jetty.io.Transport; @@ -31,14 +31,7 @@ public CustomClientConnectionFactoryOverHTTP3(HTTP3Client http3Client) { @Override public void setHttpClient(HttpClient httpClient) { - try { - java.lang.reflect.Method configureMethod = HttpClientTransportOverHTTP3.class - .getDeclaredMethod("configure", HttpClient.class, HTTP3Client.class); - configureMethod.setAccessible(true); - configureMethod.invoke(null, httpClient, http3Client); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("Unable to configure HTTP/3 transport", e); - } + CustomHttp3ClientConfigurer.configure(httpClient, http3Client); } @Override diff --git a/src/main/java/org/eclipse/jetty/http2/client/CustomHttp2SessionContainerAccessor.java b/src/main/java/org/eclipse/jetty/http2/client/CustomHttp2SessionContainerAccessor.java new file mode 100644 index 00000000..168e5624 --- /dev/null +++ b/src/main/java/org/eclipse/jetty/http2/client/CustomHttp2SessionContainerAccessor.java @@ -0,0 +1,27 @@ +package org.eclipse.jetty.http2.client; + +import org.eclipse.jetty.http2.SessionContainer; + +/** + * Deliberately placed in this Jetty package, not a {@code com.blazemeter} package: it exposes + * {@link HTTP2Client}'s package-private {@code getSessionContainer()} to our code without + * reflection. + * + *

{@code com.blazemeter...custom.http2.CustomHTTP2ClientConnectionFactory} rebuilds the + * connection setup that {@code HTTP2ClientConnectionFactory#newConnection} does upstream, + * including registering the client's session container as an event listener on the new + * connection. That container is only reachable via this package-private accessor, so this shim + * exposes it publicly instead of relying on reflection. + * + *

Keep in sync when upgrading Jetty: if {@code getSessionContainer} is ever made public or + * removed, this shim (and the caller) should be updated accordingly. + */ +public final class CustomHttp2SessionContainerAccessor { + + private CustomHttp2SessionContainerAccessor() { + } + + public static SessionContainer getSessionContainer(HTTP2Client client) { + return client.getSessionContainer(); + } +} diff --git a/src/main/java/org/eclipse/jetty/http2/client/transport/CustomHttp2ClientConfigurer.java b/src/main/java/org/eclipse/jetty/http2/client/transport/CustomHttp2ClientConfigurer.java new file mode 100644 index 00000000..88e975c1 --- /dev/null +++ b/src/main/java/org/eclipse/jetty/http2/client/transport/CustomHttp2ClientConfigurer.java @@ -0,0 +1,31 @@ +package org.eclipse.jetty.http2.client.transport; + +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.http2.client.HTTP2Client; + +/** + * Deliberately placed in this Jetty package, not a {@code com.blazemeter} package: it exposes + * {@link HttpClientTransportOverHTTP2}'s package-private {@code configure(HttpClient, HTTP2Client)} + * to our code without reflection. + * + *

{@code com.blazemeter...custom.http2.CustomClientConnectionFactoryOverHTTP2} is a + * {@code ClientConnectionFactory} plus {@code HttpClient.Aware} (used inside + * {@code HttpClientTransportDynamic}), not an {@code HttpClientTransport} subclass, so unlike + * {@code CustomHttpClientTransportOverHTTP2} it cannot inherit + * {@code HttpClientTransportOverHTTP2.doStart()} β€” which calls {@code configure} for free + * from within Jetty's own package. It must call {@code configure} itself from + * {@code setHttpClient(HttpClient)}, and since that method is package-private, this shim + * exposes it publicly instead of relying on reflection. + * + *

Keep in sync when upgrading Jetty: if {@code configure} is ever made public or removed, this + * shim (and the caller) should be updated accordingly. + */ +public final class CustomHttp2ClientConfigurer { + + private CustomHttp2ClientConfigurer() { + } + + public static void configure(HttpClient httpClient, HTTP2Client http2Client) { + HttpClientTransportOverHTTP2.configure(httpClient, http2Client); + } +} diff --git a/src/main/java/org/eclipse/jetty/http2/hpack/CustomHpackDecoder.java b/src/main/java/org/eclipse/jetty/http2/hpack/CustomHpackDecoder.java new file mode 100644 index 00000000..45d9aa82 --- /dev/null +++ b/src/main/java/org/eclipse/jetty/http2/hpack/CustomHpackDecoder.java @@ -0,0 +1,368 @@ +package org.eclipse.jetty.http2.hpack; + +import com.blazemeter.jmeter.http2.core.jetty.custom.http2.CustomHttp2HeaderNormalizer; +import java.nio.ByteBuffer; +import java.util.function.LongSupplier; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpTokens; +import org.eclipse.jetty.http.MetaData; +import org.eclipse.jetty.http.PreEncodedHttpField; +import org.eclipse.jetty.http.compression.EncodingException; +import org.eclipse.jetty.http.compression.HuffmanDecoder; +import org.eclipse.jetty.http.compression.NBitIntegerDecoder; +import org.eclipse.jetty.http2.hpack.HpackContext.Entry; +import org.eclipse.jetty.http2.hpack.internal.AuthorityHttpField; +import org.eclipse.jetty.http2.hpack.internal.MetaDataBuilder; +import org.eclipse.jetty.util.BufferUtil; +import org.eclipse.jetty.util.CharsetStringBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Jetty 12.1.11 {@link HpackDecoder} with Firefox-inspired soft normalization for load testing. + * + *

Keep in sync when upgrading Jetty; only the normalize hooks differ from upstream. + * + *

Deliberately placed in this Jetty package, not a {@code com.blazemeter} package: the + * {@link HpackDecoder#getHpackContext() context} it inherits is {@code private}, so this class + * must build its own {@link HpackContext}, and that constructor is package-private in Jetty + * (verified for both 12.1.7 and 12.1.11 via {@code javap}). Every other member this class uses + * from {@code HpackContext}, {@code MetaDataBuilder} and {@code HpackDecoder} is {@code public}; + * only the {@code HpackContext(int)} constructor forces the package match. + * + *

Keep in sync when upgrading Jetty: if {@code HpackContext(int)} is ever made public, this + * class could move to a {@code com.blazemeter} package instead of overriding every accessor. + * + *

This is not thread safe and may only be called by 1 thread at a time. + */ +public class CustomHpackDecoder extends HpackDecoder { + private static final Logger LOG = LoggerFactory.getLogger(CustomHpackDecoder.class); + private static final HttpField LOWER_CASE_CONTENT_LENGTH_0 = + new PreEncodedHttpField(HttpHeader.CONTENT_LENGTH, "content-length", "0"); + + private final HpackContext context; + private final MetaDataBuilder builder; + private final HuffmanDecoder huffmanDecoder; + private final NBitIntegerDecoder integerDecoder; + private final LongSupplier beginNanoTimeSupplier; + private int maxTableCapacity; + + /** + * @param maxHeaderSize the maximum allowed size of a decoded headers block, expressed as total + * of all name and value bytes, plus 32 bytes per field + * @param beginNanoTimeSupplier the supplier of a nano timestamp taken at the time the first byte + * was read + */ + public CustomHpackDecoder(int maxHeaderSize, LongSupplier beginNanoTimeSupplier) { + super(maxHeaderSize, beginNanoTimeSupplier); + this.beginNanoTimeSupplier = beginNanoTimeSupplier; + context = new HpackContext(HpackContext.DEFAULT_MAX_TABLE_CAPACITY); + builder = new MetaDataBuilder(maxHeaderSize); + huffmanDecoder = new HuffmanDecoder(); + integerDecoder = new NBitIntegerDecoder(); + setMaxTableCapacity(HpackContext.DEFAULT_MAX_TABLE_CAPACITY); + } + + @Override + public HpackContext getHpackContext() { + return context; + } + + @Override + public int getMaxTableCapacity() { + return maxTableCapacity; + } + + /** + * Sets the limit for the capacity of the dynamic header table. + * + *

This value acts as a limit for the values received from the remote peer via the HPACK + * dynamic table size update instruction. + * + *

After calling this method, a SETTINGS frame must be sent to the other peer, containing the + * {@code SETTINGS_HEADER_TABLE_SIZE} setting with the value passed as argument to this method. + * + * @param maxTableCapacity the limit for capacity of the dynamic header table + */ + @Override + public void setMaxTableCapacity(int maxTableCapacity) { + this.maxTableCapacity = maxTableCapacity; + } + + @Override + public int getMaxHeaderListSize() { + return builder.getMaxSize(); + } + + @Override + public void setMaxHeaderListSize(int maxHeaderListSize) { + builder.setMaxSize(maxHeaderListSize); + } + + @Override + public MetaData decode(ByteBuffer buffer) + throws HpackException.SessionException, HpackException.StreamException { + if (LOG.isDebugEnabled()) { + LOG.debug(String.format("CtxTbl[%x] decoding %d octets", context.hashCode(), + buffer.remaining())); + } + + // If the buffer is larger than the max headers size, don't even start decoding it. + int maxSize = builder.getMaxSize(); + if (maxSize > 0 && buffer.remaining() > maxSize) { + throw new HpackException.SessionException("Header fields size too large"); + } + + try { + boolean emitted = false; + while (buffer.hasRemaining()) { + if (LOG.isDebugEnabled()) { + LOG.debug("decode {}", BufferUtil.toHexString(buffer)); + } + + byte b = buffer.get(); + if (b < 0) { + // 7.1 indexed if the high bit is set + int index = integerDecode(buffer, 7); + Entry entry = context.get(index); + if (entry == null) { + throw new HpackException.SessionException("Unknown index %d", index); + } + + HttpField field = entry.getHttpField(); + if (entry.isStatic()) { + if (LOG.isDebugEnabled()) { + LOG.debug("decode IdxStatic {}", entry); + } + emitted = true; + builder.emit(field); + } else { + if (LOG.isDebugEnabled()) { + LOG.debug("decode Idx {}", entry); + } + + String name = field.getName(); + if (!HttpTokens.isLegalH2H3FieldName(name)) { + builder.streamException("Illegal header name %s", name); + } + + String value = CustomHttp2HeaderNormalizer.normalize(field.getHeader(), + field.getName(), field.getValue()); + if (!value.equals(field.getValue())) { + field = new HttpField(field.getHeader(), field.getName(), value); + } + if (!HttpTokens.isLegalFieldValue(value)) { + builder.streamException("Illegal header value %s", value); + } + + emitted = true; + builder.emit(field); + } + } else { + // look at the first nibble in detail + byte f = (byte) ((b & 0xF0) >> 4); + String name; + HttpHeader header = null; + String value; + + boolean indexed; + int nameIndex; + + switch (f) { + case 2: // 7.3 + case 3: // 7.3 + // change table size + int size = integerDecode(buffer, 5); + if (LOG.isDebugEnabled()) { + LOG.debug("decode resize={}", size); + } + if (size > getMaxTableCapacity()) { + throw new HpackException.CompressionException( + "Dynamic table resize exceeded max limit"); + } + if (emitted) { + throw new HpackException.CompressionException( + "Dynamic table resize after fields"); + } + context.resize(size); + continue; + + case 0: // 7.2.2 + case 1: // 7.2.3 + indexed = false; + nameIndex = integerDecode(buffer, 4); + break; + + case 4: // 7.2.1 + case 5: // 7.2.1 + case 6: // 7.2.1 + case 7: // 7.2.1 + indexed = true; + nameIndex = integerDecode(buffer, 6); + break; + + default: + throw new IllegalStateException(); + } + + boolean huffmanName = false; + + // decode the name + if (nameIndex > 0) { + Entry nameEntry = context.get(nameIndex); + if (nameEntry == null) { + throw new HpackException.CompressionException("Unknown index %d", nameIndex); + } + name = nameEntry.getHttpField().getName(); + header = nameEntry.getHttpField().getHeader(); + } else { + huffmanName = (buffer.get() & 0x80) == 0x80; + int length = integerDecode(buffer, 7); + if (huffmanName) { + name = huffmanDecode(buffer, length); + } else { + name = toISO88591String(buffer, length); + } + } + + boolean illegalName = !HttpTokens.isLegalH2H3FieldName(name); + if (illegalName) { + builder.streamException("Illegal header name %s", name); + } else { + header = HttpHeader.CACHE.get(name); + } + + // decode the value + boolean huffmanValue = (buffer.get() & 0x80) == 0x80; + int length = integerDecode(buffer, 7); + if (huffmanValue) { + value = huffmanDecode(buffer, length); + } else { + value = toISO88591String(buffer, length); + } + + value = CustomHttp2HeaderNormalizer.normalize(header, name, value); + + boolean illegalValue = !HttpTokens.isLegalFieldValue(value); + if (illegalValue) { + builder.streamException("Illegal header value %s", value); + } + + HttpField httpField = processField(header, name, value, indexed); + emitted = true; + builder.emit(httpField); + + // If indexed add to dynamic table. + if (indexed) { + context.add(httpField); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("decoded '{}' by {}/{}/{}", httpField, + nameIndex > 0 ? "IdxName" : (huffmanName ? "HuffName" : "LitName"), + huffmanValue ? "HuffVal" : "LitVal", indexed ? "Idx" : ""); + } + } + } + + builder.setBeginNanoTime(beginNanoTimeSupplier.getAsLong()); + return builder.build(); + } catch (HpackException ex) { + throw ex; + } catch (Throwable t) { + throw new HpackException.SessionException(t, "HPACK decoding failure: %s", t.getMessage()); + } + } + + private HttpField processField(HttpHeader header, String name, String value, boolean indexed) + throws HpackException.SessionException { + HttpField field; + if (header == null) { + // just make a normal field and bypass header name lookup + field = new HttpField(null, name, value); + } else { + try { + // might be worthwhile to create a value HttpField if it is indexed + // and/or of a type that may be looked up multiple times. + switch (header) { + case C_STATUS: + if (indexed) { + field = new HttpField.IntValueHttpField(header, name, value); + } else { + field = new HttpField(header, name, value); + } + break; + case C_AUTHORITY: + field = new AuthorityHttpField(value); + break; + case CONTENT_LENGTH: + if ("0".equals(value)) { + field = LOWER_CASE_CONTENT_LENGTH_0; + } else { + field = new HttpField.LongValueHttpField(header, name, value); + } + break; + default: + field = new HttpField(header, name, value); + break; + } + } catch (Throwable t) { + builder.streamException(t); + field = new HttpField(header, name, value); + } + } + + return field; + } + + private int integerDecode(ByteBuffer buffer, int prefix) + throws HpackException.CompressionException { + try { + if (prefix != 8) { + buffer.position(buffer.position() - 1); + } + + integerDecoder.setPrefix(prefix); + int decodedInt = integerDecoder.decodeInt(buffer); + if (decodedInt < 0) { + throw new EncodingException("invalid integer encoding"); + } + return decodedInt; + } catch (EncodingException e) { + throw new HpackException.CompressionException(e, e.getMessage()); + } finally { + integerDecoder.reset(); + } + } + + private String huffmanDecode(ByteBuffer buffer, int length) + throws HpackException.CompressionException { + try { + huffmanDecoder.setLength(length); + String decoded = huffmanDecoder.decode(buffer); + if (decoded == null) { + throw new HpackException.CompressionException("invalid string encoding"); + } + return decoded; + } catch (EncodingException e) { + throw new HpackException.CompressionException(e, e.getMessage()); + } finally { + huffmanDecoder.reset(); + } + } + + public static String toISO88591String(ByteBuffer buffer, int length) { + CharsetStringBuilder.Iso88591StringBuilder stringBuilder = + new CharsetStringBuilder.Iso88591StringBuilder(); + for (int i = 0; i < length; ++i) { + stringBuilder.append(buffer.get()); + } + return stringBuilder.build(); + } + + @Override + public String toString() { + return String.format("CustomHpackDecoder@%x{%s}", hashCode(), context); + } +} diff --git a/src/main/java/org/eclipse/jetty/http3/client/transport/CustomHttp3ClientConfigurer.java b/src/main/java/org/eclipse/jetty/http3/client/transport/CustomHttp3ClientConfigurer.java new file mode 100644 index 00000000..415cedc0 --- /dev/null +++ b/src/main/java/org/eclipse/jetty/http3/client/transport/CustomHttp3ClientConfigurer.java @@ -0,0 +1,31 @@ +package org.eclipse.jetty.http3.client.transport; + +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.http3.client.HTTP3Client; + +/** + * Deliberately placed in this Jetty package, not a {@code com.blazemeter} package: it exposes + * {@link HttpClientTransportOverHTTP3}'s package-private {@code configure(HttpClient, HTTP3Client)} + * to our code without reflection. + * + *

{@code com.blazemeter...custom.http3.CustomClientConnectionFactoryOverHTTP3} is a + * {@code ClientConnectionFactory} plus {@code HttpClient.Aware} (used inside + * {@code HttpClientTransportDynamic}), not an {@code HttpClientTransport} subclass, so it cannot + * inherit {@code HttpClientTransportOverHTTP3.doStart()} β€” which calls {@code configure} for free + * from within Jetty's own package. It must call {@code configure} itself from + * {@code setHttpClient(HttpClient)}, and since that method is package-private, this shim exposes + * it publicly instead of relying on reflection. Mirrors + * {@code org.eclipse.jetty.http2.client.transport.CustomHttp2ClientConfigurer} for the HTTP/2 case. + * + *

Keep in sync when upgrading Jetty: if {@code configure} is ever made public or removed, this + * shim (and the caller) should be updated accordingly. + */ +public final class CustomHttp3ClientConfigurer { + + private CustomHttp3ClientConfigurer() { + } + + public static void configure(HttpClient httpClient, HTTP3Client http3Client) { + HttpClientTransportOverHTTP3.configure(httpClient, http3Client); + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusDecoderTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusDecoderTest.java new file mode 100644 index 00000000..7a66aa60 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusDecoderTest.java @@ -0,0 +1,169 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import org.eclipse.jetty.http.HttpTokens; +import org.eclipse.jetty.http.MetaData; +import org.eclipse.jetty.http2.hpack.HpackDecoder; +import org.eclipse.jetty.http2.hpack.HpackException; +import org.eclipse.jetty.http2.hpack.CustomHpackDecoder; +import org.junit.Test; + +/** + * Reproduces Jetty HPACK failures seen with Akamai-style status values such as + * {@code :status: 299 Akamai}, and verifies the tolerant decoder accepts them. + * + *

HTTP/2 {@code :status} must be a 3-digit integer only (RFC 9113). Some intermediaries + * still emit an HTTP/1-style reason phrase in the pseudo-header. Stock Jetty fails while + * parsing that value as an int. {@link CustomHpackDecoder} normalizes leading digits + * (Firefox-inspired soft handling for load testing). + */ +public class HpackAkamaiStatusDecoderTest { + + private static final String AKAMAI_STATUS_WITH_REASON = "299 Akamai"; + + @Test + public void akamaiStatusWithReasonIsLegalFieldValue() { + assertThat(HttpTokens.isLegalFieldValue(AKAMAI_STATUS_WITH_REASON)).isTrue(); + assertThat(HttpTokens.isLegalFieldValue("299")).isTrue(); + } + + @Test + public void trailingSpaceMakesAkamaiStatusIllegalFieldValue() { + assertThat(HttpTokens.isLegalFieldValue(AKAMAI_STATUS_WITH_REASON + " ")).isFalse(); + assertThat(HttpTokens.isLegalFieldValue(" " + AKAMAI_STATUS_WITH_REASON)).isFalse(); + } + + @Test + public void stockDecoderFailsOnStatusPseudoHeaderWithAkamaiReasonPhrase() { + ByteBuffer block = literalStatusHeader(AKAMAI_STATUS_WITH_REASON, false); + HpackDecoder decoder = new HpackDecoder(16 * 1024, System::nanoTime); + + assertThatThrownBy(() -> decoder.decode(block)) + .isInstanceOf(HpackException.SessionException.class) + .hasMessageContaining("HPACK decoding failure"); + } + + @Test + public void stockDecoderFailsOnIndexedStatusPseudoHeaderWithAkamaiReasonPhrase() { + ByteBuffer block = literalStatusHeader(AKAMAI_STATUS_WITH_REASON, true); + HpackDecoder decoder = new HpackDecoder(16 * 1024, System::nanoTime); + + assertThatThrownBy(() -> decoder.decode(block)) + .isInstanceOf(HpackException.SessionException.class) + .hasMessageContaining("HPACK decoding failure"); + } + + @Test + public void tolerantDecoderAcceptsStatusPseudoHeaderWithAkamaiReasonPhrase() throws Exception { + ByteBuffer block = literalStatusHeader(AKAMAI_STATUS_WITH_REASON, false); + MetaData metadata = new CustomHpackDecoder(16 * 1024, System::nanoTime).decode(block); + + assertThat(metadata).isInstanceOf(MetaData.Response.class); + assertThat(((MetaData.Response) metadata).getStatus()).isEqualTo(299); + } + + @Test + public void tolerantDecoderAcceptsIndexedStatusPseudoHeaderWithAkamaiReasonPhrase() + throws Exception { + ByteBuffer block = literalStatusHeader(AKAMAI_STATUS_WITH_REASON, true); + MetaData metadata = new CustomHpackDecoder(16 * 1024, System::nanoTime).decode(block); + + assertThat(metadata).isInstanceOf(MetaData.Response.class); + assertThat(((MetaData.Response) metadata).getStatus()).isEqualTo(299); + } + + @Test + public void shouldDecodeNumericStatus299WithoutReason() throws Exception { + ByteBuffer block = literalStatusHeader("299", false); + MetaData metadata = new CustomHpackDecoder(16 * 1024, System::nanoTime).decode(block); + + assertThat(metadata).isInstanceOf(MetaData.Response.class); + assertThat(((MetaData.Response) metadata).getStatus()).isEqualTo(299); + } + + @Test + public void stockDecoderReportsIllegalHeaderValueWhenWarningHasTrailingSpace() { + ByteBuffer block = status200PlusWarning(AKAMAI_STATUS_WITH_REASON + " "); + HpackDecoder decoder = new HpackDecoder(16 * 1024, System::nanoTime); + + assertThatThrownBy(() -> decoder.decode(block)) + .isInstanceOf(HpackException.StreamException.class) + .hasMessageContaining("Illegal header value") + .hasMessageContaining(AKAMAI_STATUS_WITH_REASON); + } + + @Test + public void tolerantDecoderTrimsTrailingSpaceOnWarningHeader() throws Exception { + ByteBuffer block = status200PlusWarning(AKAMAI_STATUS_WITH_REASON + " "); + MetaData metadata = new CustomHpackDecoder(16 * 1024, System::nanoTime).decode(block); + + assertThat(metadata).isInstanceOf(MetaData.Response.class); + assertThat(((MetaData.Response) metadata).getStatus()).isEqualTo(200); + assertThat(metadata.getHttpFields().get("warning")).isEqualTo(AKAMAI_STATUS_WITH_REASON); + } + + @Test + public void realOversizedBlockSessionExceptionIsDetectedAsHpackFailure() { + ByteBuffer block = literalStatusHeader("200", false); + // Buffer is 5 bytes (0x08, len=3, '2','0','0'); cap below that to trip the + // whole-block guard at the top of CustomHpackDecoder.decode(). + CustomHpackDecoder decoder = new CustomHpackDecoder(4, System::nanoTime); + + Throwable thrown = catchThrowable(() -> decoder.decode(block)); + + assertThat(thrown) + .isInstanceOf(HpackException.SessionException.class) + .hasMessageContaining("Header fields size too large"); + assertThat(HpackFailureDetector.indicatesHpackFailure(thrown)) + .as("HpackFailureDetector must recognize CustomHpackDecoder's own SessionException") + .isTrue(); + } + + @Test + public void realOversizedFieldSessionExceptionIsDetectedAsHpackFailure() { + // Encoded block is only 12 bytes (0x08, len=10, 10 value bytes), but the decoded + // ":status" field accounts for 7 (name) + 10 (value) + 32 (overhead) = 49 logical bytes, + // so maxHeaderSize=20 clears the whole-block guard yet still trips MetaDataBuilder.emit(). + ByteBuffer block = literalStatusHeader("2000000000", false); + CustomHpackDecoder decoder = new CustomHpackDecoder(20, System::nanoTime); + + Throwable thrown = catchThrowable(() -> decoder.decode(block)); + + assertThat(thrown) + .isInstanceOf(HpackException.SessionException.class) + .hasMessageContaining("Header size") + .hasMessageContaining("49 > 20"); + assertThat(HpackFailureDetector.indicatesHpackFailure(thrown)) + .as("HpackFailureDetector must recognize CustomHpackDecoder's own SessionException") + .isTrue(); + } + + private static ByteBuffer literalStatusHeader(String value, boolean indexed) { + byte[] valueBytes = value.getBytes(StandardCharsets.ISO_8859_1); + ByteBuffer buffer = ByteBuffer.allocate(2 + valueBytes.length); + buffer.put(indexed ? (byte) 0x48 : (byte) 0x08); + buffer.put((byte) valueBytes.length); + buffer.put(valueBytes); + buffer.flip(); + return buffer; + } + + private static ByteBuffer status200PlusWarning(String warningValue) { + byte[] name = "warning".getBytes(StandardCharsets.ISO_8859_1); + byte[] value = warningValue.getBytes(StandardCharsets.ISO_8859_1); + ByteBuffer buffer = ByteBuffer.allocate(4 + name.length + value.length); + buffer.put((byte) 0x88); + buffer.put((byte) 0x00); + buffer.put((byte) name.length); + buffer.put(name); + buffer.put((byte) value.length); + buffer.put(value); + buffer.flip(); + return buffer; + } +} \ No newline at end of file diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusIntegrationTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusIntegrationTest.java new file mode 100644 index 00000000..2257e0c6 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusIntegrationTest.java @@ -0,0 +1,225 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import com.blazemeter.jmeter.http2.sampler.JMeterTestUtils; +import java.net.URI; +import java.net.URL; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.util.JMeterUtils; +import org.eclipse.jetty.http2.HTTP2Session; +import org.eclipse.jetty.http2.api.Stream; +import org.eclipse.jetty.http2.api.server.ServerSessionListener; +import org.eclipse.jetty.http2.frames.FrameType; +import org.eclipse.jetty.http2.frames.HeadersFrame; +import org.eclipse.jetty.http2.server.RawHTTP2ServerConnectionFactory; +import org.eclipse.jetty.io.EndPoint; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.util.Callback; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * End-to-end coverage of Akamai-style {@code :status: 299 Akamai} responses over HTTP/2. + * + *

Jetty's normal response encoder never puts a reason phrase into {@code :status}, so this + * test uses {@link RawHTTP2ServerConnectionFactory} and writes a raw HEADERS frame whose HPACK + * block contains the non-numeric status value reported in production. + * + *

Stock Jetty rejects that value; the plugin's tolerant HPACK path (Firefox-inspired soft + * normalization) accepts it and surfaces response code {@code 299}. + */ +public class HpackAkamaiStatusIntegrationTest extends HTTP2TestBase { + + private static final String AKAMAI_STATUS_WITH_REASON = "299 Akamai"; + private static final byte HEADERS_END_STREAM_END_HEADERS = 0x05; + + private String savedSharedPool; + private String savedProtocolErrorFallback; + private String savedPriorKnowledge; + private Server server; + private HTTP2JettyClient client; + private int serverPort = -1; + + @Before + public void setUp() throws Exception { + JMeterTestUtils.setupJmeterEnv(); + savedSharedPool = JMeterUtils.getProperty("httpJettyClient.sharedThreadPool"); + savedProtocolErrorFallback = + JMeterUtils.getProperty("httpJettyClient.protocolErrorFallbackEnabled"); + savedPriorKnowledge = JMeterUtils.getProperty("httpJettyClient.http2PriorKnowledge"); + JMeterUtils.setProperty("httpJettyClient.sharedThreadPool", "false"); + JMeterUtils.setProperty("httpJettyClient.protocolErrorFallbackEnabled", "false"); + JMeterUtils.setProperty("httpJettyClient.http2PriorKnowledge", "true"); + + server = startRawAkamaiStatusServer(); + serverPort = ((ServerConnector) server.getConnectors()[0]).getLocalPort(); + } + + @After + public void tearDown() throws Exception { + if (client != null) { + client.stop(); + client = null; + } + if (server != null) { + server.stop(); + server = null; + } + serverPort = -1; + restoreProperty("httpJettyClient.sharedThreadPool", savedSharedPool); + restoreProperty("httpJettyClient.protocolErrorFallbackEnabled", savedProtocolErrorFallback); + restoreProperty("httpJettyClient.http2PriorKnowledge", savedPriorKnowledge); + } + + @Test + public void shouldAcceptAkamaiStatusWithReasonPhraseThroughJettyClient() throws Exception { + client = new HTTP2JettyClient(false, "hpack-akamai-status-it", http2OnlyProfile()); + client.start(); + + HTTPSampleResult result = client.sample(buildSampler(), buildBaseResult(), false, 0); + + assertThat(result.isSuccessful()) + .as("Tolerant client must accept :status with an HTTP/1 reason phrase") + .isTrue(); + assertThat(result.getResponseCode()).isEqualTo("299"); + } + + private static Server startRawAkamaiStatusServer() throws Exception { + Server jetty = new Server(); + HttpConfiguration httpConfig = new HttpConfiguration(); + RawHTTP2ServerConnectionFactory http2 = + new RawHTTP2ServerConnectionFactory(httpConfig, new ServerSessionListener() { + @Override + public Stream.Listener onNewStream(Stream stream, HeadersFrame frame) { + writeRawStatusWithReason(stream); + return null; + } + }); + ServerConnector connector = new ServerConnector(jetty, http2); + connector.setPort(0); + jetty.addConnector(connector); + jetty.start(); + return jetty; + } + + /** + * Writes a HEADERS frame with HPACK {@code :status: 299 Akamai} directly to the connection, + * bypassing Jetty's encoder which would strip the reason phrase. + */ + private static void writeRawStatusWithReason(Stream stream) { + try { + HTTP2Session session = (HTTP2Session) stream.getSession(); + EndPoint endPoint = session.getEndPoint(); + ByteBuffer frame = buildHeadersFrame(stream.getId(), + literalStatusHpack(AKAMAI_STATUS_WITH_REASON)); + + CountDownLatch written = new CountDownLatch(1); + AtomicReference writeFailure = new AtomicReference<>(); + endPoint.write(new Callback() { + @Override + public void succeeded() { + written.countDown(); + } + + @Override + public void failed(Throwable x) { + writeFailure.set(x); + written.countDown(); + } + }, frame); + if (!written.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out writing raw HEADERS frame"); + } + if (writeFailure.get() != null) { + throw new IllegalStateException("Failed writing raw HEADERS frame", writeFailure.get()); + } + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static ByteBuffer buildHeadersFrame(int streamId, byte[] hpack) { + ByteBuffer frame = ByteBuffer.allocate(9 + hpack.length); + int length = hpack.length; + frame.put((byte) ((length >>> 16) & 0xFF)); + frame.put((byte) ((length >>> 8) & 0xFF)); + frame.put((byte) (length & 0xFF)); + frame.put((byte) FrameType.HEADERS.getType()); + frame.put(HEADERS_END_STREAM_END_HEADERS); + frame.putInt(streamId); + frame.put(hpack); + frame.flip(); + return frame; + } + + private static byte[] literalStatusHpack(String value) { + byte[] valueBytes = value.getBytes(StandardCharsets.ISO_8859_1); + byte[] block = new byte[2 + valueBytes.length]; + block[0] = 0x08; // literal header field without indexing - name index 8 (:status) + block[1] = (byte) valueBytes.length; + System.arraycopy(valueBytes, 0, block, 2, valueBytes.length); + return block; + } + + private HTTP2Sampler buildSampler() { + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod(HTTPConstants.GET); + sampler.setDomain("localhost"); + sampler.setProtocol(HTTPConstants.PROTOCOL_HTTP); + sampler.setPort(serverPort); + sampler.setPath("/"); + sampler.setEnableHttp1(false); + sampler.setEnableHttp2(true); + sampler.setEnableHttp3(false); + sampler.setFallbackEnabled(false); + sampler.setProtocolErrorFallbackEnabled(false); + sampler.setHttp2PriorKnowledgeEnabled(true); + return sampler; + } + + private static HTTP2ClientProfileConfig http2OnlyProfile() { + return HTTP2ClientProfileConfig.builder() + .profile("browser-like") + .enableHttp3(false) + .enableHttp2(true) + .enableHttp1(false) + .alpnEnabled(false) + .fallbackEnabled(false) + .protocolErrorFallbackEnabled(false) + .altSvcCacheEnabled(false) + .http1OnlyCacheEnabled(false) + .http2PriorKnowledgeEnabled(true) + .build(); + } + + private HTTPSampleResult buildBaseResult() throws Exception { + HTTPSampleResult result = new HTTPSampleResult(); + URL url = new URI(HTTPConstants.PROTOCOL_HTTP, null, "localhost", serverPort, "/", null, null) + .toURL(); + result.setURL(url); + result.setHTTPMethod(HTTPConstants.GET); + return result; + } + + private static void restoreProperty(String key, String value) { + if (value == null) { + JMeterUtils.getJMeterProperties().remove(key); + } else { + JMeterUtils.setProperty(key, value); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttp2HeaderNormalizerTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttp2HeaderNormalizerTest.java new file mode 100644 index 00000000..0cc95000 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttp2HeaderNormalizerTest.java @@ -0,0 +1,33 @@ +package com.blazemeter.jmeter.http2.core.jetty.custom.http2; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.eclipse.jetty.http.HttpHeader; +import org.junit.Test; + +public class CustomHttp2HeaderNormalizerTest { + + @Test + public void normalizesStatusWithReasonPhrase() { + assertThat(CustomHttp2HeaderNormalizer.normalize( + HttpHeader.C_STATUS, ":status", "299 Akamai")).isEqualTo("299"); + } + + @Test + public void leavesNumericStatusUnchanged() { + assertThat(CustomHttp2HeaderNormalizer.normalize( + HttpHeader.C_STATUS, ":status", "299")).isEqualTo("299"); + } + + @Test + public void trimsSoftIllegalWhitespaceOnRegularHeaders() { + assertThat(CustomHttp2HeaderNormalizer.normalize( + null, "warning", "299 Akamai ")).isEqualTo("299 Akamai"); + } + + @Test + public void leavesHardIllegalValuesUntouched() { + assertThat(CustomHttp2HeaderNormalizer.normalize( + null, "x-test", "bad\nvalue")).isEqualTo("bad\nvalue"); + } +} \ No newline at end of file From 615da71269e5ae090bd3227cca4aa860754bc3c4 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Thu, 16 Jul 2026 14:50:05 -0300 Subject: [PATCH 14/35] Delegate cached-resource sample result to JMeter HTTPHC4Impl (#125) * Delegate cached-resource sample result to JMeter HTTPHC4Impl * Fix flaky test --- .../jmeter/http2/core/JettyCacheManager.java | 49 ++++----- .../http2/core/HTTP2JettyClientTest.java | 2 + .../core/HttpKeepAliveSampleHeadersTest.java | 3 + .../http2/core/JettyCacheManagerTest.java | 103 ++++++++++++++++++ .../core/JmeterCachedResourceModeSupport.java | 48 ++++++++ 5 files changed, 178 insertions(+), 27 deletions(-) create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/JettyCacheManagerTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/JmeterCachedResourceModeSupport.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JettyCacheManager.java b/src/main/java/com/blazemeter/jmeter/http2/core/JettyCacheManager.java index 43ead3c7..4d2d72be 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/JettyCacheManager.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/JettyCacheManager.java @@ -17,11 +17,12 @@ import org.apache.http.message.BasicHeader; import org.apache.http.message.BasicHttpResponse; import org.apache.jmeter.protocol.http.control.CacheManager; +import org.apache.jmeter.protocol.http.sampler.HTTPHC4Impl; import org.apache.jmeter.protocol.http.sampler.HTTPHC4Impl.HttpDelete; import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase; import org.apache.jmeter.protocol.http.sampler.HttpWebdav; import org.apache.jmeter.protocol.http.util.HTTPConstants; -import org.apache.jmeter.util.JMeterUtils; import org.eclipse.jetty.client.ContentResponse; import org.eclipse.jetty.client.Request; import org.eclipse.jetty.http.HttpFields; @@ -94,35 +95,29 @@ public boolean inCache(URL url, HttpFields headers) { } public HTTPSampleResult buildCachedSampleResult(HTTPSampleResult res) { - CachedResourceMode cachedResourceMode = CachedResourceMode.valueOf( - JMeterUtils.getPropDefault("cache_manager.cached_resource_mode", - CachedResourceMode.RETURN_NO_SAMPLE.toString())); - switch (cachedResourceMode) { - case RETURN_NO_SAMPLE: - return null; - case RETURN_200_CACHE: - res.sampleEnd(); - res.setResponseCodeOK(); - res.setResponseMessage( - JMeterUtils.getPropDefault("RETURN_200_CACHE.message", "(ex cache)")); - res.setSuccessful(true); - return res; - case RETURN_CUSTOM_STATUS: - res.sampleEnd(); - res.setResponseCode(JMeterUtils.getProperty("RETURN_CUSTOM_STATUS.code")); - res.setResponseMessage( - JMeterUtils.getPropDefault("RETURN_CUSTOM_STATUS.message", "(ex cache)")); - res.setSuccessful(true); - return res; - default: - throw new IllegalStateException("Unknown CACHED_RESOURCE_MODE"); + // Match HttpClient4: HTTPAbstractImpl snapshots cache_manager.cached_resource_mode once. + return CachedResourceResultBridge.forCachedResource(res); + } + + private static final class CachedResourceResultBridge extends HTTPHC4Impl { + private static final CachedResourceResultBridge INSTANCE = + new CachedResourceResultBridge(new BridgeSampler()); + + private CachedResourceResultBridge(HTTPSamplerBase sampler) { + super(sampler); + } + + private static HTTPSampleResult forCachedResource(HTTPSampleResult res) { + return INSTANCE.updateSampleResultForResourceInCache(res); } } - private enum CachedResourceMode { - RETURN_200_CACHE, - RETURN_NO_SAMPLE, - RETURN_CUSTOM_STATUS + private static final class BridgeSampler extends HTTPSamplerBase { + @Override + protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRedirect, + int depth) { + throw new UnsupportedOperationException("cache bridge"); + } } public void saveDetails(ContentResponse contentResponse, HTTPSampleResult result) { diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java index dd29b997..f2d6905d 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java @@ -1006,6 +1006,7 @@ public void shouldNotGetSubResultWhenResourceIsCachedWithCode() throws Exception JMeterUtils.setProperty("cache_manager.cached_resource_mode", "RETURN_CUSTOM_STATUS"); JMeterUtils.setProperty("RETURN_CUSTOM_STATUS.message", message); JMeterUtils.setProperty("RETURN_CUSTOM_STATUS.code", responseCode); + JmeterCachedResourceModeSupport.refreshSnapshotFromProperties(); configureCacheManagerToSampler(true, false); HTTPSampleResult firstRequestExpected = buildResult(true, Code.OK, hostHeader(), null, null, createURL(SERVER_PATH_200_EMBEDDED), HTTPConstants.GET); @@ -1036,6 +1037,7 @@ public void shouldNotGetSubResultWhenResourceIsCachedWithoutCode() throws Except String message = "message"; JMeterUtils.setProperty("cache_manager.cached_resource_mode", "RETURN_200_CACHE"); JMeterUtils.setProperty("RETURN_200_CACHE.message", message); + JmeterCachedResourceModeSupport.refreshSnapshotFromProperties(); configureCacheManagerToSampler(true, false); // First request must connect to the server HTTPSampleResult expected = buildResult(true, Code.OK, diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HttpKeepAliveSampleHeadersTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HttpKeepAliveSampleHeadersTest.java index 92f8e22d..9762d803 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HttpKeepAliveSampleHeadersTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HttpKeepAliveSampleHeadersTest.java @@ -37,6 +37,9 @@ public void tearDown() throws Exception { executor.awaitTermination(5, TimeUnit.SECONDS); executor = null; } + JMeterUtils.getJMeterProperties().remove("blazemeter.http.enableHttp2"); + JMeterUtils.getJMeterProperties().remove("blazemeter.http.enableHttp3"); + JMeterUtils.getJMeterProperties().remove("blazemeter.http.profile"); } @Test diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/JettyCacheManagerTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/JettyCacheManagerTest.java new file mode 100644 index 00000000..4bf6c95b --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/JettyCacheManagerTest.java @@ -0,0 +1,103 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import org.apache.jmeter.protocol.http.control.CacheManager; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.util.JMeterUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * {@link JettyCacheManager#buildCachedSampleResult} delegates to JMeter's own + * {@code HTTPAbstractImpl#updateSampleResultForResourceInCache}, so its behaviour for each + * {@code cache_manager.cached_resource_mode} matches {@code HTTPHC4Impl} exactly instead of a + * hand-rolled reimplementation. + */ +public class JettyCacheManagerTest extends HTTP2TestBase { + + private static final String MODE_PROPERTY = "cache_manager.cached_resource_mode"; + private static final String RETURN_200_MESSAGE_PROPERTY = "RETURN_200_CACHE.message"; + private static final String CUSTOM_STATUS_CODE_PROPERTY = "RETURN_CUSTOM_STATUS.code"; + private static final String CUSTOM_STATUS_MESSAGE_PROPERTY = "RETURN_CUSTOM_STATUS.message"; + + private final JettyCacheManager jettyCacheManager = + JettyCacheManager.fromCacheManager(new CacheManager()); + + @Before + @After + public void resetCacheModeProperties() throws ReflectiveOperationException { + // Other test classes (e.g. HTTP2JettyClientTest) set these same JMeter properties without + // cleaning up; clear them both before and after so this test never depends on run order. + JMeterUtils.getJMeterProperties().remove(MODE_PROPERTY); + JMeterUtils.getJMeterProperties().remove(RETURN_200_MESSAGE_PROPERTY); + JMeterUtils.getJMeterProperties().remove(CUSTOM_STATUS_CODE_PROPERTY); + JMeterUtils.getJMeterProperties().remove(CUSTOM_STATUS_MESSAGE_PROPERTY); + JmeterCachedResourceModeSupport.refreshSnapshotFromProperties(); + } + + private static HTTPSampleResult newSampleResult() { + HTTPSampleResult result = new HTTPSampleResult(); + result.sampleStart(); + return result; + } + + private static void setCacheMode(String mode) throws ReflectiveOperationException { + JMeterUtils.setProperty(MODE_PROPERTY, mode); + JmeterCachedResourceModeSupport.refreshSnapshotFromProperties(); + } + + @Test + public void returnsNoSampleWhenModeIsReturnNoSample() throws Exception { + setCacheMode("RETURN_NO_SAMPLE"); + + HTTPSampleResult result = jettyCacheManager.buildCachedSampleResult(newSampleResult()); + + assertThat(result).isNull(); + } + + @Test + public void returns200OkWithConfigurableMessageWhenModeIsReturn200Cache() throws Exception { + JMeterUtils.setProperty(RETURN_200_MESSAGE_PROPERTY, "(from cache)"); + setCacheMode("RETURN_200_CACHE"); + HTTPSampleResult sample = newSampleResult(); + + HTTPSampleResult result = jettyCacheManager.buildCachedSampleResult(sample); + + assertThat(result).isSameAs(sample); + assertThat(result.getResponseCode()).isEqualTo("200"); + assertThat(result.getResponseMessage()).isEqualTo("(from cache)"); + assertThat(result.isSuccessful()).isTrue(); + } + + @Test + public void return200CacheFallsBackToDefaultMessageWhenPropertyUnset() throws Exception { + setCacheMode("RETURN_200_CACHE"); + + HTTPSampleResult result = jettyCacheManager.buildCachedSampleResult(newSampleResult()); + + assertThat(result.getResponseMessage()).isEqualTo("(ex cache)"); + } + + @Test + public void returnsCustomStatusWhenModeIsReturnCustomStatus() throws Exception { + JMeterUtils.setProperty(CUSTOM_STATUS_CODE_PROPERTY, "304"); + JMeterUtils.setProperty(CUSTOM_STATUS_MESSAGE_PROPERTY, "Not Modified (cached)"); + setCacheMode("RETURN_CUSTOM_STATUS"); + HTTPSampleResult sample = newSampleResult(); + + HTTPSampleResult result = jettyCacheManager.buildCachedSampleResult(sample); + + assertThat(result).isSameAs(sample); + assertThat(result.getResponseCode()).isEqualTo("304"); + assertThat(result.getResponseMessage()).isEqualTo("Not Modified (cached)"); + assertThat(result.isSuccessful()).isTrue(); + } + + @Test + public void fromCacheManagerReturnsNullWhenNoCacheManagerConfigured() { + assertThat(JettyCacheManager.fromCacheManager(null)).isNull(); + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/JmeterCachedResourceModeSupport.java b/src/test/java/com/blazemeter/jmeter/http2/core/JmeterCachedResourceModeSupport.java new file mode 100644 index 00000000..eab6661c --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/JmeterCachedResourceModeSupport.java @@ -0,0 +1,48 @@ +package com.blazemeter.jmeter.http2.core; + +import java.lang.reflect.Field; +import org.apache.jmeter.util.JMeterUtils; +import sun.misc.Unsafe; + +/** + * JMeter snapshots {@code cache_manager.cached_resource_mode} in static final fields on + * {@code HTTPAbstractImpl} class init. Tests that change the property must refresh that snapshot. + */ +public final class JmeterCachedResourceModeSupport { + + private static final String HTTP_ABSTRACT_IMPL = + "org.apache.jmeter.protocol.http.sampler.HTTPAbstractImpl"; + private static final String CACHED_RESOURCE_MODE_ENUM = + "org.apache.jmeter.protocol.http.sampler.HTTPAbstractImpl$CachedResourceMode"; + + private JmeterCachedResourceModeSupport() { + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + public static void refreshSnapshotFromProperties() throws ReflectiveOperationException { + Class abstractImpl = Class.forName(HTTP_ABSTRACT_IMPL); + Class modeEnum = (Class) Class.forName(CACHED_RESOURCE_MODE_ENUM); + String modeName = JMeterUtils.getPropDefault("cache_manager.cached_resource_mode", + "RETURN_NO_SAMPLE"); + setStaticFinal(abstractImpl, "CACHED_RESOURCE_MODE", + Enum.valueOf(modeEnum, modeName)); + setStaticFinal(abstractImpl, "RETURN_200_CACHE_MESSAGE", + JMeterUtils.getPropDefault("RETURN_200_CACHE.message", "(ex cache)")); + setStaticFinal(abstractImpl, "RETURN_CUSTOM_STATUS_CODE", + JMeterUtils.getProperty("RETURN_CUSTOM_STATUS.code")); + setStaticFinal(abstractImpl, "RETURN_CUSTOM_STATUS_MESSAGE", + JMeterUtils.getPropDefault("RETURN_CUSTOM_STATUS.message", "(ex cache)")); + } + + private static void setStaticFinal(Class clazz, String name, Object value) + throws ReflectiveOperationException { + Field field = clazz.getDeclaredField(name); + field.setAccessible(true); + Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + Unsafe unsafe = (Unsafe) unsafeField.get(null); + Object base = unsafe.staticFieldBase(field); + long offset = unsafe.staticFieldOffset(field); + unsafe.putObject(base, offset, value); + } +} From 5569170e4d3621a58037b0e631d110bb92c66767 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Thu, 16 Jul 2026 15:37:56 -0300 Subject: [PATCH 15/35] Decode both zlib-wrapped and raw deflate content (#126) --- .../core/DeflateContentDecoderFactory.java | 8 +++ .../jmeter/http2/core/HTTP2JettyClient.java | 46 +++++++++++-- .../HTTP2JettyClientDeflateDecodeTest.java | 69 +++++++++++++++++++ 3 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientDeflateDecodeTest.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/DeflateContentDecoderFactory.java b/src/main/java/com/blazemeter/jmeter/http2/core/DeflateContentDecoderFactory.java index f1bd9a67..0aa739f1 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/DeflateContentDecoderFactory.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/DeflateContentDecoderFactory.java @@ -12,6 +12,14 @@ import org.eclipse.jetty.util.BufferUtil; import org.eclipse.jetty.util.IO; +/** + * Jetty's compression module ({@code org.eclipse.jetty.compression.*}) ships gzip, brotli, and + * zstd decoders but no deflate one, so this streams deflate content through {@link Inflater} + * directly. Note this only handles zlib-wrapped deflate (RFC 1950); it is not registered as a + * per-request decoder in {@link HTTP2JettyClient} because retrying as raw/headerless deflate + * (RFC 1951) requires the fully buffered response body - see + * {@link HTTP2JettyClient#decodeDeflate}. + */ public class DeflateContentDecoderFactory extends ContentDecoder.Factory { private final ByteBufferPool bufferPool; diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index 7b48ff67..c03ea6e4 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -48,6 +48,7 @@ import java.util.regex.Pattern; import java.util.stream.StreamSupport; import java.util.zip.GZIPInputStream; +import java.util.zip.Inflater; import java.util.zip.InflaterInputStream; import org.apache.commons.lang3.StringUtils; import org.apache.jmeter.protocol.http.control.AuthManager; @@ -214,6 +215,10 @@ public class HTTP2JettyClient { private CompressionContentDecoderFactory brotliDecoderFactory; private CompressionContentDecoderFactory zstdDecoderFactory; private ContentDecoder.Factory gzipDecoderFactory; + // Jetty's compression module ships gzip/brotli/zstd decoders but no deflate one, so + // DeflateContentDecoderFactory is our own Inflater-based implementation (not a Jetty class). + // Kept initialized for the disableDeflateDecoder diagnostic toggle, but no longer registered + // per-request; see the comment in configureContentDecoders() below. private DeflateContentDecoderFactory deflateDecoderFactory; private boolean decoderFactoriesInitialized = false; private int quicMaxIdleTimeout = 30000; @@ -2104,9 +2109,14 @@ private void configureContentDecoders(HttpClient client, Request request) { } else if (addGzip && disableGzipDecoder) { lowLevelDebug("Gzip decoder disabled by blazemeter.http.disableGzipDecoder"); } - if (addDeflate && deflateDecoderFactory != null && !disableDeflateDecoder) { - factories.put(deflateDecoderFactory); - } else if (addDeflate && disableDeflateDecoder) { + // Not registering deflateDecoderFactory here on purpose. It decodes per-chunk as bytes + // arrive off the wire, so if the first bytes don't inflate as zlib-wrapped (RFC 1950) there's + // no clean way to rewind and retry as raw/headerless deflate (RFC 1951) - some servers send + // "Content-Encoding: deflate" as raw deflate despite the RFC implying zlib. getDecodedContent() + // below instead decodes the fully buffered response body, where retrying with a fresh + // Inflater is trivial, so all deflate decoding is funneled through decodeDeflate() there to + // match HttpClient4 (which also tries zlib first, then raw). + if (addDeflate && disableDeflateDecoder) { lowLevelDebug("Deflate decoder disabled by blazemeter.http.disableDeflateDecoder"); } @@ -3788,7 +3798,11 @@ private byte[] maybeDecodeCompressedContent(ContentResponse contentResponse) { // should have handled it and re-decoding only adds CPU/alloc pressure. boolean skipRedundantManualDecode = Boolean.parseBoolean( System.getProperty(PROP_SKIP_REDUNDANT_MANUAL_DECODE, "true")); + // deflate is excluded from this fast path: it is never registered as a per-request Jetty + // decoder (see configureContentDecoders() above), so it must always go through decodeDeflate() + // below, which is the only place that tries both deflate variants. if (skipRedundantManualDecode + && !"deflate".equals(encodingToken) && requestAdvertisedEncoding(contentResponse.getRequest(), encodingToken)) { return content; } @@ -3860,15 +3874,33 @@ private byte[] decodeGzip(byte[] content, String contentEncoding) { } } + /** + * "Content-Encoding: deflate" is ambiguous in practice: the RFC implies zlib-wrapped deflate + * (RFC 1950), but plenty of real servers send raw/headerless deflate (RFC 1951) under the same + * header. Try zlib first, then raw, before giving up - matching HttpClient4's behavior. + */ private byte[] decodeDeflate(byte[] content, String contentEncoding) { - try (InputStream input = new InflaterInputStream(new ByteArrayInputStream(content)); + byte[] zlibDecoded = tryInflateDeflate(content, false); + if (zlibDecoded != null) { + return zlibDecoded; + } + byte[] rawDecoded = tryInflateDeflate(content, true); + if (rawDecoded != null) { + return rawDecoded; + } + lowLevelDebug("Failed to decode deflate content ({}), keeping original bytes", + contentEncoding); + return content; + } + + private byte[] tryInflateDeflate(byte[] content, boolean nowrap) { + try (InflaterInputStream input = new InflaterInputStream( + new ByteArrayInputStream(content), new Inflater(nowrap)); ByteArrayOutputStream output = new ByteArrayOutputStream(content.length)) { copy(input, output); return output.toByteArray(); } catch (IOException e) { - lowLevelDebug("Failed to decode deflate content ({}), keeping original bytes", - contentEncoding, e); - return content; + return null; } } diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientDeflateDecodeTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientDeflateDecodeTest.java new file mode 100644 index 00000000..227d16b9 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientDeflateDecodeTest.java @@ -0,0 +1,69 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import java.io.ByteArrayOutputStream; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; +import org.junit.Test; + +/** + * {@link HTTP2JettyClient}'s manual deflate decode must accept both zlib-wrapped (RFC 1950, + * what {@link java.util.zip.Deflater} produces by default) and raw/headerless (RFC 1951) deflate + * streams, matching HttpClient4 (some servers send raw deflate under a {@code Content-Encoding: + * deflate} header). + */ +public class HTTP2JettyClientDeflateDecodeTest extends HTTP2TestBase { + + private static final String PLAIN_TEXT = + "HttpClient4 accepts both zlib-wrapped and raw deflate streams."; + + @Test + public void decodesZlibWrappedDeflateContent() throws Exception { + byte[] compressed = deflate(PLAIN_TEXT, false); + + byte[] decoded = decodeDeflate(compressed); + + assertThat(new String(decoded, StandardCharsets.UTF_8)).isEqualTo(PLAIN_TEXT); + } + + @Test + public void decodesRawHeaderlessDeflateContent() throws Exception { + byte[] compressed = deflate(PLAIN_TEXT, true); + + byte[] decoded = decodeDeflate(compressed); + + assertThat(new String(decoded, StandardCharsets.UTF_8)).isEqualTo(PLAIN_TEXT); + } + + @Test + public void returnsOriginalBytesWhenContentIsNotValidDeflate() throws Exception { + byte[] garbage = "not compressed at all".getBytes(StandardCharsets.UTF_8); + + byte[] decoded = decodeDeflate(garbage); + + assertThat(decoded).isEqualTo(garbage); + } + + private static byte[] deflate(String text, boolean nowrap) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Deflater deflater = new Deflater(Deflater.DEFAULT_COMPRESSION, nowrap); + try (DeflaterOutputStream deflaterOut = new DeflaterOutputStream(out, deflater)) { + deflaterOut.write(text.getBytes(StandardCharsets.UTF_8)); + } finally { + deflater.end(); + } + return out.toByteArray(); + } + + private static byte[] decodeDeflate(byte[] content) throws Exception { + HTTP2JettyClient client = new HTTP2JettyClient(false, "deflate-decode-test"); + Method method = HTTP2JettyClient.class + .getDeclaredMethod("decodeDeflate", byte[].class, String.class); + method.setAccessible(true); + return (byte[]) method.invoke(client, content, "deflate"); + } +} From 45b363073464e78aac0ccdc2dfdffb1d5bc710cc Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Thu, 16 Jul 2026 16:34:19 -0300 Subject: [PATCH 16/35] Fix User-Agent is the last request header (#128) --- .../jmeter/http2/sampler/HTTP2Sampler.java | 16 +++--- .../HTTP2SamplerUserAgentExtractionTest.java | 50 +++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 src/test/java/com/blazemeter/jmeter/http2/sampler/HTTP2SamplerUserAgentExtractionTest.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java b/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java index b19321a1..56f28ef7 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java +++ b/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java @@ -758,13 +758,15 @@ private String getUserAgent(HTTPSampleResult sampleResult) { // see HTTPJavaImpl#getConnectionHeaders //': ' is used by JMeter to fill-in requestHeaders, see getConnectionHeaders final String userAgentPrefix = USER_AGENT + ": "; - String userAgentHdr = res.substring( - index + userAgentPrefix.length(), - res.indexOf( - '\n', - // '\n' is used by JMeter to fill-in requestHeaders, see getConnectionHeaders - index + userAgentPrefix.length() + 1)); - return userAgentHdr.trim(); + int valueStart = index + userAgentPrefix.length(); + // '\n' is used by JMeter to fill-in requestHeaders, see getConnectionHeaders. When + // User-Agent is the last header, there's no trailing '\n' and indexOf returns -1; fall + // back to the end of the string instead of feeding -1 into substring(). + int lineEnd = res.indexOf('\n', valueStart); + if (lineEnd < 0) { + lineEnd = res.length(); + } + return res.substring(valueStart, lineEnd).trim(); } else { if (LOG.isDebugEnabled()) { LOG.debug("No user agent extracted from requestHeaders:{}", res); diff --git a/src/test/java/com/blazemeter/jmeter/http2/sampler/HTTP2SamplerUserAgentExtractionTest.java b/src/test/java/com/blazemeter/jmeter/http2/sampler/HTTP2SamplerUserAgentExtractionTest.java new file mode 100644 index 00000000..9700a4b2 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/sampler/HTTP2SamplerUserAgentExtractionTest.java @@ -0,0 +1,50 @@ +package com.blazemeter.jmeter.http2.sampler; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import java.lang.reflect.Method; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.junit.Test; + +/** + * {@link HTTP2Sampler#getUserAgent} reads the {@code User-Agent} value out of the serialized + * {@code requestHeaders} string to reuse it when parsing embedded resources. When User-Agent is + * the last header, there's no trailing {@code '\n'} to bound the substring. + */ +public class HTTP2SamplerUserAgentExtractionTest extends HTTP2TestBase { + + @Test + public void extractsUserAgentWhenItIsTheLastHeader() throws Exception { + String requestHeaders = "Host: example.org\nUser-Agent: Mozilla/5.0 (test)"; + + String userAgent = getUserAgent(requestHeaders); + + assertThat(userAgent).isEqualTo("Mozilla/5.0 (test)"); + } + + @Test + public void extractsUserAgentWhenFollowedByOtherHeaders() throws Exception { + String requestHeaders = "Host: example.org\nUser-Agent: Mozilla/5.0 (test)\nAccept: */*\n"; + + String userAgent = getUserAgent(requestHeaders); + + assertThat(userAgent).isEqualTo("Mozilla/5.0 (test)"); + } + + @Test + public void returnsNullWhenNoUserAgentHeaderPresent() throws Exception { + String requestHeaders = "Host: example.org\nAccept: */*\n"; + + assertThat(getUserAgent(requestHeaders)).isNull(); + } + + private static String getUserAgent(String requestHeaders) throws Exception { + HTTPSampleResult sampleResult = new HTTPSampleResult(); + sampleResult.setRequestHeaders(requestHeaders); + + Method method = HTTP2Sampler.class.getDeclaredMethod("getUserAgent", HTTPSampleResult.class); + method.setAccessible(true); + return (String) method.invoke(new HTTP2Sampler(), sampleResult); + } +} From 0f5eab6ec2d9e2cf311b7c6973f795c7045bff1c Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Thu, 16 Jul 2026 16:35:01 -0300 Subject: [PATCH 17/35] Harden HTTP/2 cleartext (h2c) upgrade negotiation and fallback (#127) --- .../jmeter/http2/core/HTTP2JettyClient.java | 281 ++++++++++++++---- .../core/HTTP2JettyClientH2cFallbackTest.java | 170 +++++++++++ 2 files changed, 390 insertions(+), 61 deletions(-) create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientH2cFallbackTest.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index c03ea6e4..847d8532 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -21,6 +21,7 @@ import java.net.URL; import java.net.URLDecoder; import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -147,6 +148,7 @@ public class HTTP2JettyClient { private static final String DEFAULT_FILE_MIME_TYPE = "application/octet-stream"; private static final String ALT_SVC_HEADER = "alt-svc"; private static final String ATTR_HTTP3_ATTEMPTED = "bzm.http3.attempted"; + private static final String ATTR_H2C_FALLBACK_ATTEMPTED = "bzm.h2cFallbackAttempted"; private static final String ATTR_ORIGIN_KEY = "bzm.http3.origin"; private static final String ATTR_REQUEST_HEADERS_SERIALIZED = "bzm.request.headers.serialized"; private static final String PROP_SKIP_REDUNDANT_MANUAL_DECODE = @@ -1191,51 +1193,12 @@ private ContentResponse sendWithHTTP11Only(Request originalRequest, lowLevelDebug("Retrying request with HTTP/1.1 only: method={}, URI={}", originalRequest.getMethod(), uri); - clearContentDecoders(httpClientHttp1Only); - try { - // Rebuild the request with the shared HTTP/1.1-only client - Request http11Request = httpClientHttp1Only.newRequest(uri) - .method(originalRequest.getMethod()) - .timeout(requestTimeout, TimeUnit.MILLISECONDS) - .followRedirects(originalRequest.isFollowRedirects()); - - // Copy headers from original request - if (originalRequest.getHeaders() != null) { - HttpFields originalHeaders = originalRequest.getHeaders(); - HttpFields requestHeaders = http11Request.getHeaders(); - if (requestHeaders instanceof HttpFields.Mutable) { - HttpFields.Mutable newHeaders = (HttpFields.Mutable) requestHeaders; - originalHeaders.forEach(field -> { - // Skip HTTP/2 pseudo-headers (they don't exist in HTTP/1.1) - String name = field.getName(); - if (!name.startsWith(":")) { - newHeaders.put(name, field.getValue()); - } - }); - } - // Note: In Jetty 12, Request headers are typically mutable. - // If they're not mutable, the headers from the original request - // will be lost, but this is an edge case. - } - ensureHostHeader(http11Request, uri); - JmeterRequestHeadersSupport.copySamplerHeaderState(originalRequest, http11Request); - - configureContentDecodersAndCapture(httpClientHttp1Only, http11Request); - - // Copy body if present - if (originalRequest.getBody() != null) { - http11Request.body(originalRequest.getBody()); - } - SslClientCertAliasSupport.copyFromRequest(originalRequest, http11Request); - - // Send request and wait for response + Request http11Request = buildHttp11FallbackRequest(originalRequest); lowLevelDebug("Sending HTTP/1.1 fallback request"); ContentResponse response = http11Request.send(); - lowLevelDebug("HTTP/1.1 fallback request succeeded: status={}, version={}", response.getStatus(), response.getVersion()); - return response; } catch (Exception e) { LOG.error("HTTP/1.1 fallback also failed for URI: {}", uri, e); @@ -1243,6 +1206,132 @@ private ContentResponse sendWithHTTP11Only(Request originalRequest, } } + private Request buildHttp11FallbackRequest(Request originalRequest) { + URI uri = originalRequest.getURI(); + clearContentDecoders(httpClientHttp1Only); + Request http11Request = httpClientHttp1Only.newRequest(uri) + .method(originalRequest.getMethod()) + .timeout(requestTimeout, TimeUnit.MILLISECONDS) + .followRedirects(originalRequest.isFollowRedirects()); + JmeterRequestHeadersSupport.copySamplerHeaderState(originalRequest, http11Request); + http11Request.attribute(ATTR_H2C_FALLBACK_ATTEMPTED, Boolean.TRUE); + if (originalRequest.getHeaders() != null) { + HttpFields originalHeaders = originalRequest.getHeaders(); + HttpFields requestHeaders = http11Request.getHeaders(); + if (requestHeaders instanceof HttpFields.Mutable) { + HttpFields.Mutable newHeaders = (HttpFields.Mutable) requestHeaders; + originalHeaders.forEach(field -> { + // Skip HTTP/2 pseudo-headers and h2c upgrade headers - neither exists in HTTP/1.1, + // and re-sending them would trigger another (futile) upgrade attempt on this retry. + String name = field.getName(); + if (name.startsWith(":") || isH2cUpgradeHeader(name, field.getValue())) { + return; + } + newHeaders.put(name, field.getValue()); + }); + } + } + ensureHostHeader(http11Request, uri); + Object useKeepAlive = + http11Request.getAttributes().get(JmeterRequestHeadersSupport.ATTR_USE_KEEPALIVE); + if (useKeepAlive instanceof Boolean) { + JmeterRequestHeadersSupport.prepareFromSampler(http11Request, (Boolean) useKeepAlive); + } + configureContentDecodersAndCapture(httpClientHttp1Only, http11Request); + if (originalRequest.getBody() != null) { + http11Request.body(originalRequest.getBody()); + } + SslClientCertAliasSupport.copyFromRequest(originalRequest, http11Request); + return http11Request; + } + + private static boolean isH2cUpgradeHeader(String name, String value) { + if (HttpHeader.UPGRADE.is(name) || HttpHeader.HTTP2_SETTINGS.is(name)) { + return true; + } + if (HttpHeader.CONNECTION.is(name) && value != null) { + String lower = value.toLowerCase(Locale.ROOT); + return lower.contains("upgrade") || lower.contains("http2-settings"); + } + return false; + } + + /** True if {@code request} carried an {@code Upgrade: h2c} header or attribute. */ + private boolean wasH2cUpgradeAttempt(Request request) { + if (request == null) { + return false; + } + Object protocol = request.getAttributes().get(HttpUpgrader.PROTOCOL_ATTRIBUTE); + if ("h2c".equals(protocol)) { + return true; + } + HttpFields headers = request.getHeaders(); + if (headers == null) { + return false; + } + String upgrade = headers.get(HttpHeader.UPGRADE); + return upgrade != null && upgrade.toLowerCase(Locale.ROOT).contains("h2c"); + } + + /** + * The server answered (no timeout/error) but never actually negotiated HTTP/2 despite our + * {@code Upgrade: h2c} attempt - e.g. it silently ignored the header, as a compliant HTTP/1.1 + * server that doesn't support h2c is allowed to do. Retry once with a plain HTTP/1.1 request. + */ + private boolean shouldRetryAfterFailedH2cUpgrade(Request request, ContentResponse response) { + if (!enableHttp1 || !http1UpgradeRequired || request == null || response == null) { + return false; + } + URI uri = request.getURI(); + if (uri == null || !"http".equalsIgnoreCase(uri.getScheme())) { + return false; + } + if (Boolean.TRUE.equals( + request.getAttributes().get(ATTR_H2C_FALLBACK_ATTEMPTED))) { + return false; + } + if (!wasH2cUpgradeAttempt(request)) { + return false; + } + return response.getVersion() != HttpVersion.HTTP_2; + } + + private void markCleartextHttp1Only(URI uri) { + if (!enableHttp1 || !http1OnlyCacheEnabled || http1OnlyCooldownMs <= 0 || uri == null) { + return; + } + if (!"http".equalsIgnoreCase(uri.getScheme())) { + return; + } + markHttp1OnlyOrigin(originKey(uri)); + } + + /** + * Retries a request that timed out or failed while attempting an h2c upgrade, as plain + * HTTP/1.1. Returns {@code null} (instead of throwing) when the failure isn't h2c-related, or + * when a fallback was already attempted for this request, so callers can fall through to their + * existing (non-h2c) handling. + */ + private ContentResponse tryCleartextHttp11FallbackAfterH2cFailure( + Request request, HTTP2FutureResponseListener listener) + throws InterruptedException, TimeoutException, ExecutionException { + if (!enableHttp1 || request == null) { + return null; + } + URI uri = request.getURI(); + if (uri == null || !"http".equalsIgnoreCase(uri.getScheme()) + || !wasH2cUpgradeAttempt(request)) { + return null; + } + if (Boolean.TRUE.equals( + request.getAttributes().get(ATTR_H2C_FALLBACK_ATTEMPTED))) { + return null; + } + lowLevelDebug("Falling back to HTTP/1.1 after failed H2C upgrade for {}", uri); + markCleartextHttp1Only(uri); + return sendWithHTTP11Only(request, listener); + } + private ContentResponse sendWithH2cPriorKnowledge(Request originalRequest) throws InterruptedException, TimeoutException, ExecutionException { URI uri = originalRequest.getURI(); @@ -1442,9 +1531,9 @@ public HTTPSampleResult sample(HTTP2Sampler sampler, HTTPSampleResult result, } throw e; } catch (ExecutionException e) { - if (protocolErrorFallbackEnabled && enableHttp1 - && ProtocolErrorException.isProtocolError(e)) { - LOG.warn("Protocol error during send(), retrying with HTTP/1.1 only"); + Throwable cause = e.getCause(); + if (shouldFallbackToHttp11AfterTransportFailure(cause, e)) { + LOG.warn("Transport failure during send(), retrying with HTTP/1.1 only"); return retryWithHTTP11Only(sampler, result); } throw e; @@ -1587,6 +1676,14 @@ public ContentResponse send(Request request, HTTP2FutureResponseListener listene return getContent(listener, request); } catch (TimeoutException e) { if (http1UpgradeRequired && "http".equalsIgnoreCase(uri.getScheme())) { + try { + ContentResponse fallback = tryCleartextHttp11FallbackAfterH2cFailure(request, listener); + if (fallback != null) { + return fallback; + } + } catch (Exception fallbackException) { + LOG.error("HTTP/1.1 fallback after H2C timeout failed", fallbackException); + } try { LOG.warn("H2C upgrade timed out; retrying with prior knowledge"); return sendWithH2cPriorKnowledge(request); @@ -1621,10 +1718,8 @@ public ContentResponse send(Request request, HTTP2FutureResponseListener listene throw e; } } - if ((cause instanceof ProtocolErrorException - || ProtocolErrorException.isProtocolError(cause)) - && protocolErrorFallbackEnabled) { - LOG.warn("HTTP/2 protocol_error detected in send()! Attempting fallback to HTTP/1.1"); + if (shouldFallbackToHttp11AfterTransportFailure(cause, e)) { + LOG.warn("Transport failure detected in send()! Attempting fallback to HTTP/1.1"); LOG.warn("Error details: message='{}', exception={}", cause != null ? cause.getMessage() : e.getMessage(), cause != null ? cause.getClass().getName() : "unknown"); @@ -1841,6 +1936,17 @@ private ContentResponse getContent(HTTP2FutureResponseListener listener, Request response.getStatus(), response.getVersion(), elapsed, contentLength); int headerCount = response.getHeaders() != null ? response.getHeaders().size() : 0; lowLevelDebug("Response headers: {}", headerCount); + if (originalRequest != null + && shouldRetryAfterFailedH2cUpgrade(originalRequest, response)) { + lowLevelDebug("H2C upgrade did not negotiate HTTP/2; retrying with HTTP/1.1 for {}", + originalRequest.getURI()); + markCleartextHttp1Only(originalRequest.getURI()); + ContentResponse fallbackResponse = sendWithHTTP11Only(originalRequest, listener); + updateHttp1OnlyCache(originalRequest, fallbackResponse); + updateH2cCache(originalRequest, fallbackResponse); + updateAltSvcCache(originalRequest, fallbackResponse.getHeaders()); + return fallbackResponse; + } if (originalRequest != null && response.getVersion() == HttpVersion.HTTP_3) { recordHttp3Success(originalRequest.getURI()); } @@ -1855,6 +1961,21 @@ private ContentResponse getContent(HTTP2FutureResponseListener listener, Request long endGet = System.currentTimeMillis(); long elapsed = endGet - getStart; LOG.error("Request timeout after {}ms: {}", elapsed, e.getMessage()); + if (originalRequest != null) { + try { + ContentResponse fallback = + tryCleartextHttp11FallbackAfterH2cFailure(originalRequest, listener); + if (fallback != null) { + updateHttp1OnlyCache(originalRequest, fallback); + updateH2cCache(originalRequest, fallback); + updateAltSvcCache(originalRequest, fallback.getHeaders()); + return fallback; + } + } catch (Exception fallbackException) { + LOG.error("HTTP/1.1 fallback after H2C timeout in getContent() failed", + fallbackException); + } + } throw new TimeoutException("The request took more than " + elapsed + " milliseconds to complete"); } catch (ExecutionException e) { @@ -1886,10 +2007,8 @@ private ContentResponse getContent(HTTP2FutureResponseListener listener, Request } } } - if ((cause instanceof ProtocolErrorException - || ProtocolErrorException.isProtocolError(cause)) - && protocolErrorFallbackEnabled) { - LOG.warn("HTTP/2 protocol_error detected in getContent()! " + if (shouldFallbackToHttp11AfterTransportFailure(cause, e)) { + LOG.warn("Transport failure detected in getContent()! " + "Attempting fallback to HTTP/1.1"); LOG.warn("Error details: message='{}', exception={}", cause != null ? cause.getMessage() : e.getMessage(), @@ -2142,6 +2261,10 @@ private void configureContentDecodersAndCapture(HttpClient client, Request reque private HttpClient selectHttpClient(URI uri) { if (uri != null && "http".equalsIgnoreCase(uri.getScheme())) { + if (enableHttp1 && isHttp1Only(uri)) { + lowLevelDebug("HTTP/1.1-only cache hit for cleartext origin {}", originKey(uri)); + return httpClientHttp1Only; + } if (!enableHttp2 && enableHttp1) { return httpClientHttp1Only; } @@ -2389,23 +2512,34 @@ private void updateHttp1OnlyCache(Request request, Response response) { return; } URI uri = request.getURI(); - if (uri == null || !"https".equalsIgnoreCase(uri.getScheme())) { + if (uri == null) { return; } String origin = originKey(uri); HttpVersion version = response.getVersion(); - if (version == HttpVersion.HTTP_1_1) { - Http1OnlyEntry entry = new Http1OnlyEntry(); - entry.expiresAt = System.currentTimeMillis() + http1OnlyCooldownMs; - HTTP1_ONLY_CACHE.put(origin, entry); - lowLevelDebug("HTTP/1.1-only cache set for origin {} until {}", origin, entry.expiresAt); - } else if (version != null) { - if (HTTP1_ONLY_CACHE.remove(origin) != null) { + if ("https".equalsIgnoreCase(uri.getScheme())) { + if (version == HttpVersion.HTTP_1_1) { + markHttp1OnlyOrigin(origin); + } else if (version != null && HTTP1_ONLY_CACHE.remove(origin) != null) { lowLevelDebug("HTTP/1.1-only cache cleared for origin {}", origin); } + return; + } + // Cleartext origin that attempted an h2c upgrade but didn't get HTTP/2 back: cache it as + // HTTP/1.1-only too, so later requests to the same origin skip the futile upgrade attempt. + if ("http".equalsIgnoreCase(uri.getScheme()) && wasH2cUpgradeAttempt(request) + && version != HttpVersion.HTTP_2) { + markHttp1OnlyOrigin(origin); } } + private void markHttp1OnlyOrigin(String origin) { + Http1OnlyEntry entry = new Http1OnlyEntry(); + entry.expiresAt = System.currentTimeMillis() + http1OnlyCooldownMs; + HTTP1_ONLY_CACHE.put(origin, entry); + lowLevelDebug("HTTP/1.1-only cache set for origin {} until {}", origin, entry.expiresAt); + } + private void updateH2cCache(Request request, Response response) { if (!enableHttp2 || !h2cCacheEnabled || h2cCacheTtlMs <= 0) { return; @@ -2467,6 +2601,31 @@ RetryableRequestException findRetryableRequestException(Throwable cause) { return null; } + /** + * Unifies the two failure modes that warrant an HTTP/1.1 fallback: an explicit HTTP/2 + * {@code protocol_error}, and a {@link ClosedChannelException} anywhere in the cause chain - + * some servers drop the connection outright instead of returning a clean protocol error when + * they don't like the request (e.g. a failed h2c upgrade attempt). + */ + private boolean shouldFallbackToHttp11AfterTransportFailure(Throwable cause, + Throwable wrapped) { + if (!protocolErrorFallbackEnabled || !enableHttp1) { + return false; + } + return ProtocolErrorException.isProtocolError(wrapped) + || ProtocolErrorException.isProtocolError(cause) + || isClosedChannelFailure(cause != null ? cause : wrapped); + } + + private static boolean isClosedChannelFailure(Throwable throwable) { + for (Throwable current = throwable; current != null; current = current.getCause()) { + if (current instanceof ClosedChannelException) { + return true; + } + } + return false; + } + private ContentResponse retryAfterGoAway(Request originalRequest) throws InterruptedException, TimeoutException, ExecutionException { if (originalRequest == null) { @@ -3028,7 +3187,7 @@ private void setHeaders(Request request, URL url, HeaderManager headerManager) { // 1. The connection is already HTTP/2 (negotiated via ALPN) // 2. Upgrade headers are for cleartext HTTP, not HTTPS // 3. It violates the HTTP/2 protocol (RFC 7540) - if (http1UpgradeRequired && !"https".equalsIgnoreCase(url.getProtocol()) + if (http1UpgradeRequired && enableHttp2 && !"https".equalsIgnoreCase(url.getProtocol()) && !shouldUseH2cPriorKnowledge(request.getURI())) { Mutable headers = ((Mutable) request.getHeaders()); addHeaderIfMissing(HttpHeader.UPGRADE, "h2c", headers); @@ -3200,7 +3359,7 @@ private void filterInvalidHTTP2Headers(Request request) { // For HTTPS connections, we assume HTTP/2 if ALPN negotiated it // For HTTP connections, we check if upgrade headers are present boolean isHTTP2 = "https".equalsIgnoreCase(request.getURI().getScheme()) - || (http1UpgradeRequired && headers.contains(HttpHeader.UPGRADE)); + || (enableHttp2 && http1UpgradeRequired && headers.contains(HttpHeader.UPGRADE)); if (isHTTP2) { // HTTP/2 does not support Connection header except for upgrade (which we handle separately) diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientH2cFallbackTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientH2cFallbackTest.java new file mode 100644 index 00000000..60457fe1 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientH2cFallbackTest.java @@ -0,0 +1,170 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.core.ServerBuilder.TeardownableServer; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import java.lang.reflect.Method; +import java.net.URI; +import java.net.URL; +import java.nio.channels.ClosedChannelException; +import java.util.concurrent.ExecutionException; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpVersion; +import org.eclipse.jetty.server.ServerConnector; +import org.junit.After; +import org.junit.Test; + +/** + * A cleartext ({@code http://}) server that never negotiates {@code h2c} shouldn't ever surface + * as a sample error or a hung request - {@link HTTP2JettyClient} must detect the failed upgrade + * and retry as plain HTTP/1.1, exactly like a compliant client talking to an HTTP/1.1-only + * server. + */ +public class HTTP2JettyClientH2cFallbackTest extends HTTP2TestBase { + + private TeardownableServer server; + private HttpClient probeClient; + + @After + public void tearDown() throws Exception { + if (server != null) { + server.stop(); + } + if (probeClient != null) { + probeClient.stop(); + } + } + + @Test + public void sampleAgainstHttp1OnlyServerSucceedsDespiteAttemptedH2cUpgrade() throws Exception { + int port = startHttp1OnlyServer(); + // http1UpgradeRequired=true: the client already believes this origin needs the explicit + // Upgrade: h2c header dance (e.g. a prior response on this origin wasn't HTTP/2). + HTTP2JettyClient client = new HTTP2JettyClient(true, "h2c-fallback-test"); + client.start(); + try { + HTTPSampleResult result = sampleGet(client, port); + assertThat(result.isSuccessful()).isTrue(); + assertThat(result.getResponseCode()).isEqualTo("200"); + } finally { + client.stop(); + } + } + + @Test + public void secondRequestToSameOriginSkipsRepeatedFailedH2cUpgrade() throws Exception { + int port = startHttp1OnlyServer(); + HTTP2JettyClient client = new HTTP2JettyClient(true, "h2c-fallback-cache-test"); + client.start(); + try { + sampleGet(client, port); + URI origin = URI.create("http://localhost:" + port); + assertThat((Boolean) invokePrivate(client, "isHttp1Only", new Class[] {URI.class}, + origin)).isTrue(); + } finally { + client.stop(); + } + } + + @Test + public void wasH2cUpgradeAttemptDetectsUpgradeHeader() throws Exception { + HTTP2JettyClient client = newClientForReflection(); + Request withUpgrade = newProbeRequest("http://example.invalid/") + .headers(h -> h.put(HttpHeader.UPGRADE, "h2c")); + Request withoutUpgrade = newProbeRequest("http://example.invalid/"); + + assertThat((Boolean) invokePrivate(client, "wasH2cUpgradeAttempt", + new Class[] {Request.class}, withUpgrade)).isTrue(); + assertThat((Boolean) invokePrivate(client, "wasH2cUpgradeAttempt", + new Class[] {Request.class}, withoutUpgrade)).isFalse(); + } + + @Test + public void buildHttp11FallbackRequestStripsH2cHeadersAndMarksAttempted() throws Exception { + HTTP2JettyClient client = newClientForReflection(); + Request original = newProbeRequest("http://example.invalid/") + .headers(h -> h + .put(HttpHeader.UPGRADE, "h2c") + .put(HttpHeader.HTTP2_SETTINGS, "AAMAAABkAAQAoAAAAAIAAAAA") + .put(HttpHeader.CONNECTION, "Upgrade, HTTP2-Settings") + .put("X-Custom", "keep-me")); + + Request fallback = (Request) invokePrivate(client, "buildHttp11FallbackRequest", + new Class[] {Request.class}, original); + + HttpFields fallbackHeaders = fallback.getHeaders(); + assertThat(fallbackHeaders.get(HttpHeader.UPGRADE)).isNull(); + assertThat(fallbackHeaders.get(HttpHeader.HTTP2_SETTINGS)).isNull(); + assertThat(fallbackHeaders.get("X-Custom")).isEqualTo("keep-me"); + String fallbackAttemptedAttribute = (String) readPrivateStaticField( + HTTP2JettyClient.class, "ATTR_H2C_FALLBACK_ATTEMPTED"); + assertThat(fallback.getAttributes().get(fallbackAttemptedAttribute)).isEqualTo(Boolean.TRUE); + } + + @Test + public void isClosedChannelFailureFindsItAnywhereInCauseChain() throws Exception { + HTTP2JettyClient client = newClientForReflection(); + Exception wrapped = new ExecutionException( + new RuntimeException("network blip", new ClosedChannelException())); + Exception unrelated = new ExecutionException(new RuntimeException("unrelated failure")); + + assertThat((Boolean) invokePrivate(client, "shouldFallbackToHttp11AfterTransportFailure", + new Class[] {Throwable.class, Throwable.class}, wrapped.getCause(), wrapped)) + .isTrue(); + assertThat((Boolean) invokePrivate(client, "shouldFallbackToHttp11AfterTransportFailure", + new Class[] {Throwable.class, Throwable.class}, unrelated.getCause(), unrelated)) + .isFalse(); + } + + private int startHttp1OnlyServer() throws Exception { + server = new ServerBuilder().withHTTP1().buildServer(); + server.start(); + return ((ServerConnector) server.getConnectors()[0]).getLocalPort(); + } + + private static HTTPSampleResult sampleGet(HTTP2JettyClient client, int port) throws Exception { + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod("GET"); + sampler.setDomain("localhost"); + sampler.setPort(port); + sampler.setPath(ServerBuilder.SERVER_PATH_200); + sampler.setProtocol("http"); + + HTTPSampleResult result = new HTTPSampleResult(); + result.setSampleLabel("GET_H2C_FALLBACK"); + result.setHTTPMethod("GET"); + result.setURL(new URL("http", "localhost", port, ServerBuilder.SERVER_PATH_200)); + + return client.sample(sampler, result, false, 0); + } + + private HTTP2JettyClient newClientForReflection() { + return new HTTP2JettyClient(false, "h2c-reflection-test"); + } + + private Request newProbeRequest(String uri) { + if (probeClient == null) { + probeClient = new HttpClient(); + } + return probeClient.newRequest(URI.create(uri)); + } + + private static Object invokePrivate(Object target, String methodName, Class[] paramTypes, + Object... args) throws Exception { + Method method = HTTP2JettyClient.class.getDeclaredMethod(methodName, paramTypes); + method.setAccessible(true); + return method.invoke(target, args); + } + + private static Object readPrivateStaticField(Class type, String fieldName) throws Exception { + java.lang.reflect.Field field = type.getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(null); + } +} From 78a7b424ebf013b54ad6bba2c046fb3de7cd42f2 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Thu, 16 Jul 2026 17:14:42 -0300 Subject: [PATCH 18/35] Fix Exception when Cookie is the only request header (#129) --- .../jmeter/http2/core/HTTP2JettyClient.java | 5 ++ .../HTTP2JettyClientHeaderStringTest.java | 51 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientHeaderStringTest.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index 847d8532..cb3fc412 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -3789,6 +3789,11 @@ private String buildHeadersString(HttpFields headers) { } else { String ret = HttpFields.build(headers).remove(HTTPConstants.HEADER_COOKIE).toString() .replace("\r\n", "\n"); + // When Cookie was the only header, removing it leaves an empty string; ret.length() - 1 + // would then be -1, which substring() rejects. + if (ret.isEmpty()) { + return ""; + } return ret.substring(0, ret.length() - 1); // removing final separator not included in jmeter headers } diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientHeaderStringTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientHeaderStringTest.java new file mode 100644 index 00000000..f6d32124 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientHeaderStringTest.java @@ -0,0 +1,51 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import java.lang.reflect.Method; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.eclipse.jetty.http.HttpFields; +import org.junit.Test; + +/** + * {@link HTTP2JettyClient#buildHeadersString} strips the {@code Cookie} header before serializing + * request headers into the sample result. When Cookie was the only header, the stripped result is + * empty, and trimming a trailing separator from an empty string used to crash. + */ +public class HTTP2JettyClientHeaderStringTest extends HTTP2TestBase { + + @Test + public void returnsEmptyStringWhenCookieWasTheOnlyHeader() throws Exception { + HttpFields headers = HttpFields.build().add(HTTPConstants.HEADER_COOKIE, "session=abc"); + + String result = buildHeadersString(headers); + + assertThat(result).isEmpty(); + } + + @Test + public void returnsEmptyStringWhenHeadersIsNull() throws Exception { + assertThat(buildHeadersString(null)).isEmpty(); + } + + @Test + public void stripsCookieButKeepsOtherHeaders() throws Exception { + HttpFields headers = HttpFields.build() + .add(HTTPConstants.HEADER_COOKIE, "session=abc") + .add("Accept", "*/*"); + + String result = buildHeadersString(headers); + + assertThat(result).contains("Accept"); + assertThat(result).doesNotContain(HTTPConstants.HEADER_COOKIE); + } + + private static String buildHeadersString(HttpFields headers) throws Exception { + HTTP2JettyClient client = new HTTP2JettyClient(false, "header-string-test"); + Method method = HTTP2JettyClient.class.getDeclaredMethod("buildHeadersString", + HttpFields.class); + method.setAccessible(true); + return (String) method.invoke(client, headers); + } +} From 4d0b181c4ddb7da70ea63ce8f5595a6136412f15 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 17 Jul 2026 10:15:47 -0300 Subject: [PATCH 19/35] Effective post-redirect request headers and sentBytes when autoRedirects follows (#134) --- .../jmeter/http2/core/HTTP2JettyClient.java | 13 ++- ...ettyClientEffectiveRequestHeadersTest.java | 106 ++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientEffectiveRequestHeadersTest.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index cb3fc412..9d22d002 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -1460,7 +1460,18 @@ private void postContentResponse(HTTP2Sampler sampler, Request request, JettyCacheManager cacheManager) throws IOException { http1UpgradeRequired = contentResponse.getVersion() != HttpVersion.HTTP_2; - result.setRequestHeaders(getSerializedRequestHeaders(request, true)); + // When autoRedirects silently follows a redirect chain at the transport layer, contentResponse + // carries the LAST request Jetty actually sent - not the original one passed in here. Report + // headers/sentBytes for that effective request, matching what HttpClient4 shows for the same + // scenario, instead of the pre-redirect request's (possibly different host/method/headers). + Request effectiveRequest = contentResponse.getRequest() != null + ? contentResponse.getRequest() + : request; + result.setRequestHeaders(getSerializedRequestHeaders(effectiveRequest, true)); + long headerBytes = estimateRequestHeaderBytes(effectiveRequest); + if (headerBytes > result.getSentBytes()) { + result.setSentBytes(headerBytes); + } setResultContentResponse(result, contentResponse); saveCookiesInCookieManager(contentResponse, request.getURI().toURL(), sampler.getCookieManager()); diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientEffectiveRequestHeadersTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientEffectiveRequestHeadersTest.java new file mode 100644 index 00000000..3a59afd1 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientEffectiveRequestHeadersTest.java @@ -0,0 +1,106 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import java.net.URL; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.util.Callback; +import org.junit.After; +import org.junit.Test; + +/** + * When {@code autoRedirects} is enabled, Jetty follows a redirect chain silently at the + * transport layer and {@code ContentResponse.getRequest()} carries the LAST request actually + * sent, not the one {@link HTTP2JettyClient#postContentResponse} originally built. HttpClient4 + * reports headers/sentBytes for that effective, final request; this used to instead report the + * pre-redirect request's headers (e.g. the wrong {@code Host}). + */ +public class HTTP2JettyClientEffectiveRequestHeadersTest extends HTTP2TestBase { + + private Server redirectOrigin; + private Server redirectTarget; + private HTTP2JettyClient client; + + @After + public void tearDown() throws Exception { + if (client != null) { + client.stop(); + } + if (redirectOrigin != null) { + redirectOrigin.stop(); + } + if (redirectTarget != null) { + redirectTarget.stop(); + } + } + + @Test + public void reportsHeadersAndSentBytesFromTheFinalRequestAfterAutoRedirect() throws Exception { + redirectTarget = new Server(); + int targetPort = startPlainHttpServer(redirectTarget, new Handler.Abstract() { + @Override + public boolean handle(Request request, Response response, Callback callback) { + response.setStatus(HttpStatus.OK_200); + Content.Sink.write(response, true, "target reached", callback); + return true; + } + }); + + redirectOrigin = new Server(); + int originPort = startPlainHttpServer(redirectOrigin, new Handler.Abstract() { + @Override + public boolean handle(Request request, Response response, Callback callback) { + response.getHeaders().add(HTTPConstants.HEADER_LOCATION, + "http://localhost:" + targetPort + "/landed"); + response.setStatus(HttpStatus.FOUND_302); + callback.succeeded(); + return true; + } + }); + + client = new HTTP2JettyClient(false, "effective-request-headers-test"); + client.start(); + + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod(HTTPConstants.GET); + sampler.setDomain("localhost"); + sampler.setPort(originPort); + sampler.setPath("/start"); + sampler.setProtocol("http"); + sampler.setAutoRedirects(true); + + HTTPSampleResult result = new HTTPSampleResult(); + result.setSampleLabel("GET_EFFECTIVE_REQUEST_HEADERS"); + result.setHTTPMethod(HTTPConstants.GET); + result.setURL(new URL("http", "localhost", originPort, "/start")); + + HTTPSampleResult sampled = client.sample(sampler, result, false, 0); + + assertThat(sampled.isSuccessful()).isTrue(); + assertThat(sampled.getResponseCode()).isEqualTo("200"); + assertThat(sampled.getRequestHeaders()) + .as("must report the Host of the final (post-redirect) request, not the original one") + .contains("Host: localhost:" + targetPort) + .doesNotContain("Host: localhost:" + originPort); + assertThat(sampled.getSentBytes()).isGreaterThan(0); + } + + private int startPlainHttpServer(Server server, Handler handler) throws Exception { + ServerConnector connector = new ServerConnector(server); + connector.setPort(0); + server.addConnector(connector); + server.setHandler(handler); + server.start(); + return connector.getLocalPort(); + } +} From e698229e86d6a4749555c256b159a2f99da874cd Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 17 Jul 2026 10:16:23 -0300 Subject: [PATCH 20/35] Catch Cookie header even without a CookieManager (#130) --- .../jmeter/http2/core/HTTP2JettyClient.java | 10 +++ ...TTP2JettyClientManualCookieHeaderTest.java | 81 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientManualCookieHeaderTest.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index 9d22d002..62140524 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -1421,6 +1421,16 @@ private void samplePrepareRequest(Request request, CookieManager cookieManager = sampler.getCookieManager(); if (cookieManager != null) { result.setCookies(buildCookies(request, url, cookieManager)); + } else { + // HttpClient4 reports whatever Cookie header actually went out, even when it wasn't + // built by a CookieManager (e.g. set directly via HeaderManager). + HttpFields headers = request.getHeaders(); + if (headers != null) { + String cookieHeader = headers.get(HttpHeader.COOKIE); + if (cookieHeader != null && !cookieHeader.isEmpty()) { + result.setCookies(cookieHeader); + } + } } if (!sampler.getProxyHost().isEmpty()) { diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientManualCookieHeaderTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientManualCookieHeaderTest.java new file mode 100644 index 00000000..820aa389 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientManualCookieHeaderTest.java @@ -0,0 +1,81 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.core.ServerBuilder.TeardownableServer; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import java.net.URL; +import org.apache.jmeter.protocol.http.control.Header; +import org.apache.jmeter.protocol.http.control.HeaderManager; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.server.ServerConnector; +import org.junit.After; +import org.junit.Test; + +/** + * HttpClient4 reports whatever {@code Cookie} header actually went out on the wire, regardless + * of whether a {@code CookieManager} built it. {@link HTTP2JettyClient} only populated + * {@code result.getCookies()} when a {@code CookieManager} was configured, missing cookies set + * directly via a {@code HeaderManager}. + */ +public class HTTP2JettyClientManualCookieHeaderTest extends HTTP2TestBase { + + private TeardownableServer server; + + @After + public void tearDown() throws Exception { + if (server != null) { + server.stop(); + } + } + + @Test + public void capturesCookieSetDirectlyViaHeaderManagerWithoutCookieManager() throws Exception { + HTTPSampleResult result = sampleGetWithCookieHeader("session=abc123"); + + assertThat(result.getCookies()).isEqualTo("session=abc123"); + } + + @Test + public void leavesCookiesBlankWhenNoCookieHeaderAndNoCookieManager() throws Exception { + HTTPSampleResult result = sampleGetWithCookieHeader(null); + + assertThat(result.getCookies()).isEmpty(); + } + + private HTTPSampleResult sampleGetWithCookieHeader(String cookieHeaderValue) throws Exception { + int port = startHttp1OnlyServer(); + HTTP2JettyClient client = new HTTP2JettyClient(false, "manual-cookie-header-test"); + client.start(); + try { + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod("GET"); + sampler.setDomain("localhost"); + sampler.setPort(port); + sampler.setPath(ServerBuilder.SERVER_PATH_200); + sampler.setProtocol("http"); + if (cookieHeaderValue != null) { + HeaderManager headerManager = new HeaderManager(); + headerManager.add(new Header(HttpHeader.COOKIE.asString(), cookieHeaderValue)); + sampler.setHeaderManager(headerManager); + } + + HTTPSampleResult result = new HTTPSampleResult(); + result.setSampleLabel("GET_MANUAL_COOKIE"); + result.setHTTPMethod("GET"); + result.setURL(new URL("http", "localhost", port, ServerBuilder.SERVER_PATH_200)); + + return client.sample(sampler, result, false, 0); + } finally { + client.stop(); + } + } + + private int startHttp1OnlyServer() throws Exception { + server = new ServerBuilder().withHTTP1().buildServer(); + server.start(); + return ((ServerConnector) server.getConnectors()[0]).getLocalPort(); + } +} From 69e4ed19f25167ffa4960971a47cfd8b87ab1bf5 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 17 Jul 2026 10:16:53 -0300 Subject: [PATCH 21/35] Fix multipart part header casing and raw argument values (#133) --- .../jmeter/http2/core/HTTP2JettyClient.java | 50 ++++++++------ ...2JettyClientMultipartHeaderCasingTest.java | 66 +++++++++++++++++++ .../http2/core/HTTP2JettyClientTest.java | 25 +++---- 3 files changed, 108 insertions(+), 33 deletions(-) create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientMultipartHeaderCasingTest.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index 62140524..611a7dc1 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -3677,6 +3677,11 @@ private Charset buildCharsetOrDefault(String contentEncoding, Charset defaultCha return !contentEncoding.isEmpty() ? Charset.forName(contentEncoding) : defaultCharset; } + /** HttpClient4 writes raw argument values in multipart parts (not URL-encoded). */ + private static String multipartArgumentValue(HTTPArgument arg) { + return arg.getValue(); + } + private String buildArgumentPartRequestBody(HTTPArgument arg, Charset contentCharset, String contentEncoding, String boundary) throws UnsupportedEncodingException { @@ -3684,20 +3689,29 @@ private String buildArgumentPartRequestBody(HTTPArgument arg, Charset contentCha String contentType = arg.getContentType() + "; charset=" + contentCharset.name(); String encoding = StringUtils.isNotBlank(contentEncoding) ? contentEncoding : "8bit"; return buildPartBody(boundary, disposition, contentType, encoding, - arg.getEncodedValue(contentCharset.name())); + multipartArgumentValue(arg)); } private String buildPartBody(String boundary, String disposition, String contentType, String encoding, String value) { - return MULTI_PART_SEPARATOR + boundary + LINE_SEPARATOR + - HttpFields.build() - .add("Content-Disposition", "form-data; " + disposition) - .add(HttpHeader.CONTENT_TYPE.toString(), contentType) - .add("Content-Transfer-Encoding", encoding) - .toString() + return MULTI_PART_SEPARATOR + boundary + LINE_SEPARATOR + + formatMultipartPartHeaders("form-data; " + disposition, contentType, encoding) + value + LINE_SEPARATOR; } + /** HC4 canonical header casing; Jetty {@link HttpFields#toString()} lowercases names. */ + private String formatMultipartPartHeaders(String disposition, String contentType, + String transferEncoding) { + StringBuilder headers = new StringBuilder(); + headers.append("Content-Disposition: ").append(disposition).append(LINE_SEPARATOR); + headers.append("Content-Type: ").append(contentType).append(LINE_SEPARATOR); + if (transferEncoding != null && !transferEncoding.isEmpty()) { + headers.append("Content-Transfer-Encoding: ").append(transferEncoding) + .append(LINE_SEPARATOR); + } + return headers.toString(); + } + private String buildFilePartRequestBody(HTTPFileArg file, String fileName, String boundary) { String disposition = "name=\"" + file.getParamName() + "\"; filename=\"" + fileName + "\""; return buildPartBody(boundary, disposition, file.getMimeType(), "binary", @@ -3746,14 +3760,12 @@ private byte[] buildMultipartBodyBytes(HTTP2Sampler sampler, String boundary, argContentType = argContentType + "; charset=" + contentCharset.name().toLowerCase(Locale.ROOT); - Mutable partHeaders = HttpFields.build() - .add("Content-Disposition", "form-data; name=\"" + arg.getEncodedName() + "\"") - .add(HttpHeader.CONTENT_TYPE, argContentType); + String partHeaders = formatMultipartPartHeaders( + "form-data; name=\"" + arg.getEncodedName() + "\"", argContentType, "8bit"); output.write(boundaryLine.getBytes(StandardCharsets.US_ASCII)); - output.write(partHeaders.toString().getBytes(StandardCharsets.US_ASCII)); - output.write(newLine.getBytes(StandardCharsets.US_ASCII)); - String argValue = arg.getEncodedValue(contentCharset.name()); + output.write(partHeaders.getBytes(StandardCharsets.US_ASCII)); + String argValue = multipartArgumentValue(arg); output.write(argValue.getBytes(contentCharset)); output.write(newLine.getBytes(StandardCharsets.US_ASCII)); } @@ -3767,16 +3779,12 @@ private byte[] buildMultipartBodyBytes(HTTP2Sampler sampler, String boundary, String fileName = Paths.get(file.getPath()).getFileName().toString(); String mimeTypeFile = extractFileMimeType(hasContentTypeHeader, file); - // Build headers using HttpFields to match the format expected by tests - // The test uses HttpFields.build().toString() which has a specific format - Mutable partHeaders = HttpFields.build() - .add("Content-Disposition", - "form-data; name=\"" + file.getParamName() + "\"; filename=\"" + fileName + "\"") - .add(HttpHeader.CONTENT_TYPE, mimeTypeFile); + String partHeaders = formatMultipartPartHeaders( + "form-data; name=\"" + file.getParamName() + "\"; filename=\"" + fileName + "\"", + mimeTypeFile, "binary"); output.write(boundaryLine.getBytes(StandardCharsets.US_ASCII)); - output.write(partHeaders.toString().getBytes(StandardCharsets.US_ASCII)); - output.write(newLine.getBytes(StandardCharsets.US_ASCII)); + output.write(partHeaders.getBytes(StandardCharsets.US_ASCII)); // Read and write file content try (InputStream fileStream = Files.newInputStream(Paths.get(file.getPath()))) { diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientMultipartHeaderCasingTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientMultipartHeaderCasingTest.java new file mode 100644 index 00000000..590ef4fb --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientMultipartHeaderCasingTest.java @@ -0,0 +1,66 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import java.lang.reflect.Method; +import org.apache.jmeter.protocol.http.util.HTTPArgument; +import org.junit.Test; + +/** + * HttpClient4's multipart writer (Apache HttpMime) writes part headers with canonical casing + * ({@code Content-Disposition}, {@code Content-Type}, {@code Content-Transfer-Encoding}) and puts + * the raw argument value in the part body, never URL-encoded. {@link HTTP2JettyClient} used to + * build these part headers with Jetty's {@code HttpFields}, which lowercases header names and + * omitted {@code Content-Transfer-Encoding} entirely, and wrote {@code arg.getEncodedValue(...)} + * instead of the raw value. + */ +public class HTTP2JettyClientMultipartHeaderCasingTest extends HTTP2TestBase { + + private static final HTTP2JettyClient CLIENT = new HTTP2JettyClient(false, "multipart-test"); + + @Test + public void formatsPartHeadersWithCanonicalCasingAndTransferEncoding() throws Exception { + String headers = invokeFormatMultipartPartHeaders( + "form-data; name=\"field\"", "text/plain; charset=utf-8", "8bit"); + + assertThat(headers).isEqualTo( + "Content-Disposition: form-data; name=\"field\"\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "Content-Transfer-Encoding: 8bit\r\n"); + } + + @Test + public void omitsTransferEncodingLineWhenNotProvided() throws Exception { + String headers = invokeFormatMultipartPartHeaders( + "form-data; name=\"field\"", "text/plain", null); + + assertThat(headers).isEqualTo( + "Content-Disposition: form-data; name=\"field\"\r\n" + + "Content-Type: text/plain\r\n"); + } + + @Test + public void multipartArgumentValueReturnsRawValueNotUrlEncoded() throws Exception { + HTTPArgument arg = new HTTPArgument("field", "a value & more = stuff"); + + String value = invokeMultipartArgumentValue(arg); + + assertThat(value).isEqualTo("a value & more = stuff"); + } + + private String invokeFormatMultipartPartHeaders(String disposition, String contentType, + String transferEncoding) throws Exception { + Method method = HTTP2JettyClient.class.getDeclaredMethod( + "formatMultipartPartHeaders", String.class, String.class, String.class); + method.setAccessible(true); + return (String) method.invoke(CLIENT, disposition, contentType, transferEncoding); + } + + private String invokeMultipartArgumentValue(HTTPArgument arg) throws Exception { + Method method = HTTP2JettyClient.class.getDeclaredMethod( + "multipartArgumentValue", HTTPArgument.class); + method.setAccessible(true); + return (String) method.invoke(null, arg); + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java index f2d6905d..e1671213 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java @@ -1140,14 +1140,16 @@ private byte[] buildByteArrayFromFilesAndParams(HTTPSampleResult expected, String dashDash = "--"; // Set body response for Arguments args.forEach(httpArgument -> { - Mutable headerParam = HttpFields.build() - .add("Content-Disposition", "form-data; name=\"" + httpArgument.getEncodedName() + "\"") - .add(HttpHeader.CONTENT_TYPE, "text/plain; charset=utf-8"); + // Canonical HC4 header casing (not Jetty's HttpFields, which lowercases names) and an + // explicit Content-Transfer-Encoding, matching HTTP2JettyClient#formatMultipartPartHeaders. + String headerParam = "Content-Disposition: form-data; name=\"" + + httpArgument.getEncodedName() + "\"" + newLine + + "Content-Type: text/plain; charset=utf-8" + newLine + + "Content-Transfer-Encoding: 8bit" + newLine; try { - String headerParamWithBoundary = boundary + newLine + headerParam.toString(); + String headerParamWithBoundary = boundary + newLine + headerParam; output.write(headerParamWithBoundary.getBytes(StandardCharsets.US_ASCII)); - output.write(newLine.getBytes(StandardCharsets.US_ASCII)); - output.write(httpArgument.getEncodedValue().getBytes(StandardCharsets.UTF_8)); + output.write(httpArgument.getValue().getBytes(StandardCharsets.UTF_8)); output.write(newLine.getBytes(StandardCharsets.US_ASCII)); } catch (IOException e) { e.printStackTrace(); @@ -1157,17 +1159,16 @@ private byte[] buildByteArrayFromFilesAndParams(HTTPSampleResult expected, // Set body response for Files files.forEach(file -> { String fileName = Paths.get((file.getPath())).getFileName().toString(); - Mutable headerFile = HttpFields.build() - .add("Content-Disposition", "form-data; name=\"" + file.getParamName() - + "\"; " + "filename=\"" + fileName + "\"") - .add(HttpHeader.CONTENT_TYPE, file.getMimeType()); + String headerFile = "Content-Disposition: form-data; name=\"" + file.getParamName() + + "\"; filename=\"" + fileName + "\"" + newLine + + "Content-Type: " + file.getMimeType() + newLine + + "Content-Transfer-Encoding: binary" + newLine; try { String filePath = file.getPath(); InputStream inputStream = Files.newInputStream(Paths.get(filePath)); byte[] data = sampler.readResponse(expected, inputStream, 0); - String headerFileWithBoundary = boundary + newLine + headerFile.toString(); + String headerFileWithBoundary = boundary + newLine + headerFile; output.write(headerFileWithBoundary.getBytes(StandardCharsets.US_ASCII)); - output.write(newLine.getBytes(StandardCharsets.US_ASCII)); output.write(data); output.write(newLine.getBytes(StandardCharsets.US_ASCII)); } catch (IOException e) { From 9b75b844a8632198f2deba38642b80ec7b5270ef Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 17 Jul 2026 12:54:43 -0300 Subject: [PATCH 22/35] Load HTTP response parsers lazily (#137) --- .../jmeter/http2/sampler/HTTP2Sampler.java | 69 +++++++----- ...2SamplerLazyResponseParserLoadingTest.java | 101 ++++++++++++++++++ 2 files changed, 143 insertions(+), 27 deletions(-) create mode 100644 src/test/java/com/blazemeter/jmeter/http2/sampler/HTTP2SamplerLazyResponseParserLoadingTest.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java b/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java index 56f28ef7..d4be1992 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java +++ b/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java @@ -116,33 +116,6 @@ protected Map childValue( private static final String USER_AGENT = "User-Agent"; // $NON-NLS-1$ private static final boolean USE_JAVA_REGEX = !getPropDefault( "jmeter.regex.engine", "oro").equalsIgnoreCase("oro"); - private static final String RESPONSE_PARSERS = // list of parsers - JMeterUtils.getProperty("HTTPResponse.parsers"); //$NON-NLS-1$ - - static { - String[] parsers = - JOrphanUtils.split(RESPONSE_PARSERS, " ", true); // returns empty array for null - for (final String parser : parsers) { - String classname = JMeterUtils.getProperty(parser + ".className"); //$NON-NLS-1$ - if (classname == null) { - LOG.error("Cannot find .className property for {}, ensure you set property: '{}.className'", - parser, parser); - continue; - } - String typeList = JMeterUtils.getProperty(parser + ".types"); //$NON-NLS-1$ - if (typeList != null) { - String[] types = JOrphanUtils.split(typeList, " ", true); - for (final String type : types) { - registerParser(type, classname); - } - } else { - LOG.warn( - "Cannot find .types property for {}, as a consequence parser " + - "will not be used, to make it usable, define property:'{}.types'", - parser, parser); - } - } - } private final transient Callable clientFactory; private final boolean dumpAtThreadEnd = @@ -739,8 +712,50 @@ static void registerParser(String contentType, String className) { PARSERS_FOR_CONTENT_TYPE.put(contentType, className); } + /** + * JMeter batch runs configure HTML parsers via {@code -q jmeter-batch.properties}, loaded after + * this class may already have been initialized - so register parsers lazily on first use + * instead of in a static block, to read whatever properties are actually in effect by then. + */ + private static void ensureResponseParsersLoaded() { + if (!PARSERS_FOR_CONTENT_TYPE.isEmpty()) { + return; + } + synchronized (PARSERS_FOR_CONTENT_TYPE) { + if (PARSERS_FOR_CONTENT_TYPE.isEmpty()) { + loadResponseParsersFromProperties(); + } + } + } + + private static void loadResponseParsersFromProperties() { + String responseParsers = JMeterUtils.getProperty("HTTPResponse.parsers"); //$NON-NLS-1$ + String[] parsers = JOrphanUtils.split(responseParsers, " ", true); // empty array for null + for (final String parser : parsers) { + String classname = JMeterUtils.getProperty(parser + ".className"); //$NON-NLS-1$ + if (classname == null) { + LOG.error("Cannot find .className property for {}, ensure you set property: '{}.className'", + parser, parser); + continue; + } + String typeList = JMeterUtils.getProperty(parser + ".types"); //$NON-NLS-1$ + if (typeList != null) { + String[] types = JOrphanUtils.split(typeList, " ", true); + for (final String type : types) { + registerParser(type, classname); + } + } else { + LOG.warn( + "Cannot find .types property for {}, as a consequence parser " + + "will not be used, to make it usable, define property:'{}.types'", + parser, parser); + } + } + } + private LinkExtractorParser getParser(HTTPSampleResult res) throws LinkExtractorParseException { + ensureResponseParsersLoaded(); String parserClassName = PARSERS_FOR_CONTENT_TYPE.get(res.getMediaType()); if (!StringUtils.isEmpty(parserClassName)) { diff --git a/src/test/java/com/blazemeter/jmeter/http2/sampler/HTTP2SamplerLazyResponseParserLoadingTest.java b/src/test/java/com/blazemeter/jmeter/http2/sampler/HTTP2SamplerLazyResponseParserLoadingTest.java new file mode 100644 index 00000000..f16c1e46 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/sampler/HTTP2SamplerLazyResponseParserLoadingTest.java @@ -0,0 +1,101 @@ +package com.blazemeter.jmeter.http2.sampler; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; +import org.apache.jmeter.util.JMeterUtils; +import org.junit.After; +import org.junit.Test; + +/** + * {@link HTTP2Sampler} used to register response parsers ({@code HTTPResponse.parsers} and + * friends) in a static initializer, which runs once at class-load time. JMeter batch runs + * configure these via {@code -q jmeter-batch.properties}, loaded after this class may already + * have been initialized, so the static block would read stale/default properties forever. Parsers + * must instead load lazily, on first use, so whatever properties are actually in effect by then + * get picked up. + */ +public class HTTP2SamplerLazyResponseParserLoadingTest extends HTTP2TestBase { + + private static final String TEST_CONTENT_TYPE = "text/test-lazy-parser"; + private static final String TEST_PARSER_KEY = "testLazyParser"; + + private Map originalParsers; + private String originalResponseParsersProperty; + private String originalClassNameProperty; + private String originalTypesProperty; + + @After + public void tearDown() throws Exception { + restoreProperty("HTTPResponse.parsers", originalResponseParsersProperty); + restoreProperty(TEST_PARSER_KEY + ".className", originalClassNameProperty); + restoreProperty(TEST_PARSER_KEY + ".types", originalTypesProperty); + if (originalParsers != null) { + Map parsers = parsersField(); + parsers.clear(); + parsers.putAll(originalParsers); + } + } + + @Test + public void loadsParsersOnFirstUseFromPropertiesSetAfterClassInitialization() throws Exception { + Map parsers = parsersField(); + originalParsers = new HashMap<>(parsers); + parsers.clear(); + + originalResponseParsersProperty = JMeterUtils.getProperty("HTTPResponse.parsers"); + originalClassNameProperty = JMeterUtils.getProperty(TEST_PARSER_KEY + ".className"); + originalTypesProperty = JMeterUtils.getProperty(TEST_PARSER_KEY + ".types"); + + // Set AFTER the class (and its old static block, if it still existed) would already have + // run - simulating a JMeter batch run applying -q jmeter-batch.properties post-class-load. + JMeterUtils.setProperty("HTTPResponse.parsers", TEST_PARSER_KEY); + JMeterUtils.setProperty(TEST_PARSER_KEY + ".className", + "org.apache.jmeter.protocol.http.parser.RegexpHTMLParser"); + JMeterUtils.setProperty(TEST_PARSER_KEY + ".types", TEST_CONTENT_TYPE); + + invokeEnsureResponseParsersLoaded(); + + assertThat(parsersField()) + .as("parser registered from properties set after class initialization") + .containsEntry(TEST_CONTENT_TYPE, "org.apache.jmeter.protocol.http.parser.RegexpHTMLParser"); + } + + @Test + public void doesNotReloadOnceParsersAreAlreadyRegistered() throws Exception { + Map parsers = parsersField(); + originalParsers = new HashMap<>(parsers); + parsers.put("text/already-loaded-marker", "some.Marker"); + + // HTTPResponse.parsers left unset/whatever it currently is: if this reloaded, it would not + // add our marker back after a clear, but it also must not wipe it out just by being called. + invokeEnsureResponseParsersLoaded(); + + assertThat(parsersField()).containsKey("text/already-loaded-marker"); + } + + @SuppressWarnings("unchecked") + private static Map parsersField() throws Exception { + Field field = HTTP2Sampler.class.getDeclaredField("PARSERS_FOR_CONTENT_TYPE"); + field.setAccessible(true); + return (Map) field.get(null); + } + + private static void invokeEnsureResponseParsersLoaded() throws Exception { + Method method = HTTP2Sampler.class.getDeclaredMethod("ensureResponseParsersLoaded"); + method.setAccessible(true); + method.invoke(null); + } + + private static void restoreProperty(String key, String originalValue) { + if (originalValue == null) { + JMeterUtils.getJMeterProperties().remove(key); + } else { + JMeterUtils.setProperty(key, originalValue); + } + } +} From b0420af21ff02ebe6b263d15e8d843a3a83e36f6 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 17 Jul 2026 12:55:02 -0300 Subject: [PATCH 23/35] Support file:// URLs in sampler (#136) * Support file:// sampler URLs * Support file:// on embedded resources --- .../jmeter/http2/core/HTTP2JettyClient.java | 60 ++++++++++++ .../jmeter/http2/sampler/HTTP2Sampler.java | 67 +++++++++++++ ...P2JettyClientFileProtocolSamplingTest.java | 95 +++++++++++++++++++ ...SamplerNestedFileEmbeddedResourceTest.java | 89 +++++++++++++++++ 4 files changed, 311 insertions(+) create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileProtocolSamplingTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/HTTP2SamplerNestedFileEmbeddedResourceTest.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index 611a7dc1..4ea3a699 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -19,6 +19,7 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.net.URLConnection; import java.net.URLDecoder; import java.nio.ByteBuffer; import java.nio.channels.ClosedChannelException; @@ -142,6 +143,11 @@ public class HTTP2JettyClient { private static final Path ALPN_DEBUG_LOG_PATH = resolveAlpnLogPath(); private static final boolean ADD_CONTENT_TYPE_TO_POST_IF_MISSING = JMeterUtils.getPropDefault( "http.post_add_content_type_if_missing", false); + // Matches HTTPFileImpl: caps stored response data for file:// samples, while sampleEnd's + // bodySize still reflects the true total bytes read. + private static final int MAX_FILE_SAMPLE_BYTES_TO_STORE = JMeterUtils.getPropDefault( + "httpsampler.max_bytes_to_store_per_request", 10 * 1024 * 1024); + private static final int FILE_SAMPLE_BUFFER_SIZE = 4096; private static final Pattern PORT_PATTERN = Pattern.compile("\\d+"); private static final String MULTI_PART_SEPARATOR = "--"; private static final String LINE_SEPARATOR = "\r\n"; @@ -1524,6 +1530,11 @@ public HTTPSampleResult sample(HTTP2Sampler sampler, HTTPSampleResult result, lowLevelDebug("=== HTTP2JettyClient.sample() called ==="); lowLevelDebug("Method: {}, URL: {}", result.getHTTPMethod(), result.getURL()); + URL sampleUrl = result.getURL(); + if (sampleUrl != null && "file".equalsIgnoreCase(sampleUrl.getProtocol())) { + return sampleLocalFile(sampler, result, sampleUrl, areFollowingRedirect, depth); + } + errorWhenNotSupportedMethod(result.getHTTPMethod()); setAuthManager(sampler); RequestContext context = buildRequestContext(result, resolveClientForRequest(sampler, result)); @@ -1568,6 +1579,55 @@ public HTTPSampleResult sample(HTTP2Sampler sampler, HTTPSampleResult result, return sampler.resultProcessing(areFollowingRedirect, depth, result); } + /** + * Matches HttpClient4's {@code HTTPFileImpl}: {@code file://} samples always use GET, open the + * URL directly (Java's built-in {@code file} URL handler - relative paths resolve against the + * JVM's working directory, not via {@link org.apache.jmeter.services.FileServer}), and always + * report {@code text/html} as the content type regardless of the file's actual type, since this + * exists to test the HTML embedded-resource parser against local fixtures, not to serve + * arbitrary static files. + */ + private HTTPSampleResult sampleLocalFile(HTTP2Sampler sampler, HTTPSampleResult result, URL url, + boolean areFollowingRedirect, int depth) + throws Exception { + result.setHTTPMethod(HTTPConstants.GET); + result.setURL(url); + result.setSampleLabel(url.toString()); + result.sampleStart(); + + ByteArrayOutputStream output = new ByteArrayOutputStream(FILE_SAMPLE_BUFFER_SIZE); + long totalBytes = 0; + URLConnection connection = url.openConnection(); + try (InputStream inputStream = connection.getInputStream()) { + byte[] buffer = new byte[FILE_SAMPLE_BUFFER_SIZE]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + if (totalBytes < MAX_FILE_SAMPLE_BYTES_TO_STORE) { + int toStore = (int) Math.min(bytesRead, MAX_FILE_SAMPLE_BYTES_TO_STORE - totalBytes); + output.write(buffer, 0, toStore); + } + totalBytes += bytesRead; + } + } + + result.sampleEnd(); + result.setResponseData(output.toByteArray()); + result.setBodySize(totalBytes); + result.setResponseCodeOK(); + result.setResponseMessageOK(); + result.setSuccessful(true); + String contentType = "text/html"; + String contentEncoding = sampler.getContentEncoding(); + if (StringUtils.isNotBlank(contentEncoding)) { + contentType = contentType + "; charset=" + contentEncoding; + } + result.setContentType(contentType); + result.setEncodingAndType(contentType); + + resetSamplerDataBeforeResultProcessing(result); + return sampler.resultProcessing(areFollowingRedirect, depth, result); + } + public HTTPSampleResult sampleFromListener(HTTP2Sampler sampler, HTTPSampleResult result, boolean areFollowingRedirect, int depth, HTTP2FutureResponseListener listener diff --git a/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java b/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java index d4be1992..833a3470 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java +++ b/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java @@ -631,6 +631,58 @@ private void copyJettyProtocolSettingsToEmbeddedSampler(HTTP2Sampler embedded) { embedded.setHttp1UpgradeEnabled(isHttp1UpgradeEnabled()); } + /** + * Builds an embedded-resource child sampler for a {@code file://} URL discovered by the HTML + * parser (e.g. a relative {@code href}/{@code src} resolved against a {@code file://} parent). + * {@code setImageParser} is enabled for {@code .html}/{@code .htm} targets so nested embedded + * resources (e.g. an iframe pointing at another local HTML file) are themselves parsed and + * downloaded, matching how a real HTTP-embedded HTML resource would recurse. + */ + private HTTP2Sampler newFileEmbeddedSampler(URL url) { + HTTP2Sampler fileSampler = new HTTP2Sampler(); + copyJettyProtocolSettingsToEmbeddedSampler(fileSampler); + String path = url.getPath(); + boolean htmlResource = path != null + && (path.endsWith(".html") || path.endsWith(".htm")); + fileSampler.setImageParser(htmlResource); + fileSampler.setMethod(HTTPConstants.GET); + fileSampler.setProtocol(url.getProtocol()); + fileSampler.setDomain(url.getHost()); + fileSampler.setPort(url.getPort()); + if (url.getQuery() == null) { + fileSampler.setPath(url.getPath()); + } else { + fileSampler.setPath(url.getPath() + url.getQuery()); + } + fileSampler.setHeaderManager(getHeaderManager()); + fileSampler.setCookieManager(getCookieManager()); + return fileSampler; + } + + /** {@code file://} URLs have no HTTP-style path to label sub-results with; build one instead. */ + private static String formatFileEmbeddedLabel(URL url, int index) { + String path = url.getPath(); + if (path != null && path.startsWith("/")) { + path = path.substring(1); + } + return url.getProtocol() + ":" + path + "-" + index; + } + + private static void relabelFileEmbeddedChildren(HTTPSampleResult parent) { + int childIndex = 0; + for (SampleResult child : parent.getSubResults()) { + if (child instanceof HTTPSampleResult) { + HTTPSampleResult httpChild = (HTTPSampleResult) child; + if (httpChild.getURL() != null + && "file".equalsIgnoreCase(httpChild.getURL().getProtocol())) { + httpChild.setSampleLabel( + formatFileEmbeddedLabel(httpChild.getURL(), childIndex++)); + relabelFileEmbeddedChildren(httpChild); + } + } + } + } + private HTTP2JettyClient buildClient() throws Exception { HTTP2ClientKey connectionKey = buildConnectionKey(); HTTP2JettyClient client = new HTTP2JettyClient(isHttp1UpgradeEnabled(), @@ -948,6 +1000,7 @@ protected HTTPSampleResult downloadPageResources(final HTTPSampleResult pRes, setSyncRequest(!isConcurrentDwn); // Change default from main request based on sub request + int fileEmbeddedIndex = 0; while (urls.hasNext()) { Object binURL = urls.next(); // See catch clause below try { @@ -980,6 +1033,20 @@ protected HTTPSampleResult downloadPageResources(final HTTPSampleResult pRes, continue; } + if ("file".equalsIgnoreCase(url.getProtocol())) { + HTTP2Sampler fileSampler = newFileEmbeddedSampler(url); + HTTPSampleResult binRes = + fileSampler.sample(url, HTTPConstants.GET, false, frameDepth + 1); + if (binRes != null) { + binRes.setSampleLabel(formatFileEmbeddedLabel(url, fileEmbeddedIndex++)); + relabelFileEmbeddedChildren(binRes); + } + subres.addSubResult(binRes); + setParentSampleSuccess(subres, + subres.isSuccessful() && (binRes == null || binRes.isSuccessful())); + continue; + } + HTTP2Sampler h2s = new HTTP2Sampler(); copyJettyProtocolSettingsToEmbeddedSampler(h2s); h2s.setMethod("GET"); diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileProtocolSamplingTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileProtocolSamplingTest.java new file mode 100644 index 00000000..1c91e78e --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileProtocolSamplingTest.java @@ -0,0 +1,95 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.junit.After; +import org.junit.Test; + +/** + * HttpClient4's {@code HTTPFileImpl} handles {@code file://} sampler URLs (used by JMeter's own + * HTML-parser regression fixtures to test the embedded-resource parser against local files + * without a live server): it always uses GET, opens the URL directly via Java's built-in + * {@code file} handler, and always reports {@code text/html} as the content type regardless of + * the file's real type - not a guess based on the file extension. + */ +public class HTTP2JettyClientFileProtocolSamplingTest extends HTTP2TestBase { + + private HTTP2JettyClient client; + private Path tempFile; + + @After + public void tearDown() throws Exception { + if (client != null) { + client.stop(); + } + if (tempFile != null) { + Files.deleteIfExists(tempFile); + } + } + + @Test + public void samplesLocalFileWithHardcodedGetMethodAndTextHtmlContentType() throws Exception { + String fileContent = "not-actually-html-content"; + // .txt on purpose: content type must be hardcoded to text/html, not guessed from extension. + tempFile = Files.createTempFile("http2-file-protocol-test", ".txt"); + Files.write(tempFile, fileContent.getBytes(StandardCharsets.UTF_8)); + URL fileUrl = tempFile.toUri().toURL(); + + HTTPSampleResult sampled = sampleFileUrl(fileUrl, null); + + assertThat(sampled.isSuccessful()).isTrue(); + assertThat(sampled.getResponseCode()).isEqualTo("200"); + assertThat(sampled.getHTTPMethod()).isEqualTo(HTTPConstants.GET); + assertThat(sampled.getContentType()).isEqualTo("text/html"); + assertThat(sampled.getResponseDataAsString()).isEqualTo(fileContent); + } + + @Test + public void appendsSamplerContentEncodingAsCharsetOnTheContentType() throws Exception { + tempFile = Files.createTempFile("http2-file-protocol-test", ".txt"); + Files.write(tempFile, "content".getBytes(StandardCharsets.UTF_8)); + URL fileUrl = tempFile.toUri().toURL(); + + HTTPSampleResult sampled = sampleFileUrl(fileUrl, "UTF-8"); + + assertThat(sampled.getContentType()).isEqualTo("text/html; charset=UTF-8"); + } + + @Test + public void propagatesAnErrorWhenTheFileDoesNotExist() throws Exception { + tempFile = Files.createTempFile("http2-file-protocol-test", ".txt"); + Files.delete(tempFile); + URL fileUrl = tempFile.toUri().toURL(); + tempFile = null; + + org.junit.Assert.assertThrows(IOException.class, () -> sampleFileUrl(fileUrl, null)); + } + + private HTTPSampleResult sampleFileUrl(URL fileUrl, String contentEncoding) throws Exception { + client = new HTTP2JettyClient(false, "file-protocol-sampling-test"); + client.start(); + + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod(HTTPConstants.GET); + sampler.setProtocol("file"); + if (contentEncoding != null) { + sampler.setContentEncoding(contentEncoding); + } + + HTTPSampleResult result = new HTTPSampleResult(); + result.setSampleLabel("GET_FILE_PROTOCOL"); + result.setHTTPMethod(HTTPConstants.GET); + result.setURL(fileUrl); + + return client.sample(sampler, result, false, 0); + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2SamplerNestedFileEmbeddedResourceTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2SamplerNestedFileEmbeddedResourceTest.java new file mode 100644 index 00000000..fef90ef4 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2SamplerNestedFileEmbeddedResourceTest.java @@ -0,0 +1,89 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import java.lang.reflect.Method; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.samplers.SampleResult; +import org.junit.After; +import org.junit.Test; + +/** + * A {@code file://} page with an embedded resource that is itself HTML (e.g. an iframe) must + * have ITS OWN embedded resources downloaded too, exactly like a real HTTP-embedded HTML resource + * would recurse. {@link HTTP2Sampler}'s generic embedded-resource child sampler never enabled + * {@code setImageParser} on the child, so this second level of embedded {@code file://} resources + * was silently never downloaded. + */ +public class HTTP2SamplerNestedFileEmbeddedResourceTest extends HTTP2TestBase { + + private Path tempDir; + + @After + public void tearDown() throws Exception { + if (tempDir != null) { + try (var stream = Files.walk(tempDir)) { + stream.sorted(Comparator.reverseOrder()).forEach(path -> path.toFile().delete()); + } + } + } + + @Test + public void downloadsEmbeddedResourcesOfANestedFileEmbeddedHtmlResource() throws Exception { + tempDir = Files.createTempDirectory("http2-nested-file-embedded-test"); + Files.writeString(tempDir.resolve("parent.html"), + "", + StandardCharsets.UTF_8); + Files.writeString(tempDir.resolve("child.html"), + "", + StandardCharsets.UTF_8); + Files.write(tempDir.resolve("pixel.png"), "pixel-bytes".getBytes(StandardCharsets.UTF_8)); + + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod(HTTPConstants.GET); + sampler.setProtocol("file"); + sampler.setImageParser(true); + sampler.setConcurrentDwn(false); + + URL parentUrl = tempDir.resolve("parent.html").toUri().toURL(); + HTTPSampleResult pageResult = invokeSample(sampler, parentUrl); + + SampleResult childHtmlResult = findSubResultByUrlContains(pageResult, "child.html"); + assertThat(childHtmlResult).as("nested file:// HTML sub-result (iframe)").isNotNull(); + + SampleResult pixelResult = findSubResultByUrlContains(pageResult, "pixel.png"); + assertThat(pixelResult) + .as("the nested HTML resource's own embedded image must have been downloaded too") + .isNotNull(); + assertThat(((HTTPSampleResult) pixelResult).getResponseDataAsString()) + .isEqualTo("pixel-bytes"); + } + + private static HTTPSampleResult invokeSample(HTTP2Sampler sampler, URL url) throws Exception { + Method sample = HTTP2Sampler.class.getDeclaredMethod( + "sample", URL.class, String.class, boolean.class, int.class); + sample.setAccessible(true); + return (HTTPSampleResult) sample.invoke(sampler, url, HTTPConstants.GET, false, 0); + } + + private static SampleResult findSubResultByUrlContains(SampleResult root, String substring) { + if (root.getUrlAsString() != null && root.getUrlAsString().contains(substring)) { + return root; + } + for (SampleResult sr : root.getSubResults()) { + SampleResult found = findSubResultByUrlContains(sr, substring); + if (found != null) { + return found; + } + } + return null; + } +} From a99f1c8f7ea11b3d485c20ec0729d0b17eb938e4 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 17 Jul 2026 12:55:21 -0300 Subject: [PATCH 24/35] Resolve HTTP file arguments via FileServer (#135) --- .../jmeter/http2/core/HTTP2JettyClient.java | 31 ++++- .../HTTP2JettyClientFileResolutionTest.java | 112 ++++++++++++++++++ 2 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileResolutionTest.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index 4ea3a699..47c7931d 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -11,6 +11,7 @@ import com.github.luben.zstd.ZstdInputStream; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; @@ -63,6 +64,7 @@ import org.apache.jmeter.protocol.http.util.HTTPArgument; import org.apache.jmeter.protocol.http.util.HTTPConstants; import org.apache.jmeter.protocol.http.util.HTTPFileArg; +import org.apache.jmeter.services.FileServer; import org.apache.jmeter.testelement.property.JMeterProperty; import org.apache.jmeter.util.JMeterUtils; import org.brotli.dec.BrotliInputStream; @@ -3606,7 +3608,7 @@ private void setBody(Request request, HTTP2Sampler sampler, HTTPSampleResult res if (StringUtils.isBlank(file.getParamName())) { throw new IllegalStateException("Param name is blank"); } - String fileName = Paths.get((file.getPath())).getFileName().toString(); + String fileName = resolveHttpFile(file.getPath()).getName(); postBody.append(buildFilePartRequestBody(file, fileName, boundary)); } postBody.append(MULTI_PART_SEPARATOR).append(boundary).append(MULTI_PART_SEPARATOR) @@ -3631,7 +3633,7 @@ private void setBody(Request request, HTTP2Sampler sampler, HTTPSampleResult res } // In Jetty 12, PathRequestContent implements Request.Content directly Request.Content requestContent = - new PathRequestContent(mimeTypeFile, Path.of(file.getPath())); + new PathRequestContent(mimeTypeFile, resolveHttpFile(file.getPath()).toPath()); request.body(requestContent); postBody.append(""); } else { @@ -3790,6 +3792,26 @@ private String extractFileMimeType(boolean hasContentTypeHeader, HTTPFileArg fil return ret == null ? DEFAULT_FILE_MIME_TYPE : ret; } + /** + * Resolves a sampler file path the same way HttpClient4 does: relative to the running test + * plan's directory via {@link FileServer}, falling back to JMeter's {@code bin} directory for + * files bundled alongside JMeter itself. + */ + private File resolveHttpFile(String path) throws IOException { + if (StringUtils.isBlank(path)) { + throw new IOException("Empty HTTP file path"); + } + File resolved = FileServer.getFileServer().getResolvedFile(path); + if (resolved.isFile()) { + return resolved; + } + Path inBin = Paths.get(JMeterUtils.getJMeterBinDir(), path); + if (Files.isRegularFile(inBin)) { + return inBin.toFile(); + } + throw new IOException("HTTP file not found: " + path); + } + /** * Builds multipart/form-data body as bytes for Jetty 12. * In Jetty 12, MultiPartRequestContent API changed, so we build the body manually. @@ -3836,7 +3858,8 @@ private byte[] buildMultipartBodyBytes(HTTP2Sampler sampler, String boundary, if (StringUtils.isBlank(file.getParamName())) { throw new IllegalStateException("Param name is blank"); } - String fileName = Paths.get(file.getPath()).getFileName().toString(); + File resolvedFile = resolveHttpFile(file.getPath()); + String fileName = resolvedFile.getName(); String mimeTypeFile = extractFileMimeType(hasContentTypeHeader, file); String partHeaders = formatMultipartPartHeaders( @@ -3847,7 +3870,7 @@ private byte[] buildMultipartBodyBytes(HTTP2Sampler sampler, String boundary, output.write(partHeaders.getBytes(StandardCharsets.US_ASCII)); // Read and write file content - try (InputStream fileStream = Files.newInputStream(Paths.get(file.getPath()))) { + try (InputStream fileStream = Files.newInputStream(resolvedFile.toPath())) { byte[] buffer = new byte[8192]; int bytesRead; while ((bytesRead = fileStream.read(buffer)) != -1) { diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileResolutionTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileResolutionTest.java new file mode 100644 index 00000000..03a7838e --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileResolutionTest.java @@ -0,0 +1,112 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.core.ServerBuilder.TeardownableServer; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.protocol.http.util.HTTPFileArg; +import org.apache.jmeter.services.FileServer; +import org.eclipse.jetty.server.ServerConnector; +import org.junit.After; +import org.junit.Test; + +/** + * HttpClient4 resolves an HTTP request file argument via {@link FileServer}, relative to the + * running test plan's directory (falling back to JMeter's {@code bin} dir) - not relative to the + * JVM's working directory. {@link HTTP2JettyClient} used to call {@code Paths.get(file.getPath())} + * directly, so a relative path that resolves fine for a real JMeter test plan would fail here. + */ +public class HTTP2JettyClientFileResolutionTest extends HTTP2TestBase { + + private TeardownableServer server; + private HTTP2JettyClient client; + private Path tempDir; + + @After + public void tearDown() throws Exception { + FileServer.getFileServer().resetBase(); + if (client != null) { + client.stop(); + } + if (server != null) { + server.stop(); + } + if (tempDir != null) { + Files.walk(tempDir) + .sorted(java.util.Comparator.reverseOrder()) + .forEach(path -> path.toFile().delete()); + } + } + + @Test + public void resolvesSingleFilePostBodyRelativeToFileServerBaseDir() throws Exception { + String fileContent = "resolved-via-fileserver-base-dir"; + tempDir = Files.createTempDirectory("http2-file-resolution-test"); + Files.write(tempDir.resolve("upload.txt"), fileContent.getBytes(StandardCharsets.UTF_8)); + FileServer.getFileServer().setBase(tempDir.toFile()); + + int port = startHttp1OnlyServer(); + client = new HTTP2JettyClient(false, "file-resolution-test"); + client.start(); + + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod(HTTPConstants.POST); + sampler.setDomain("localhost"); + sampler.setPort(port); + sampler.setPath(ServerBuilder.SERVER_PATH_200_FILE_SENT); + sampler.setProtocol("http"); + // Bare filename, no directory: can only resolve via FileServer's configured base dir, never + // via a plain Paths.get(...) relative to the JVM's working directory. + sampler.setHTTPFiles(new HTTPFileArg[] {new HTTPFileArg("upload.txt", "", "text/plain")}); + + HTTPSampleResult result = new HTTPSampleResult(); + result.setSampleLabel("POST_FILE_RESOLUTION"); + result.setHTTPMethod(HTTPConstants.POST); + result.setURL(new URL("http", "localhost", port, ServerBuilder.SERVER_PATH_200_FILE_SENT)); + + HTTPSampleResult sampled = client.sample(sampler, result, false, 0); + + assertThat(sampled.isSuccessful()).isTrue(); + assertThat(sampled.getResponseDataAsString()).isEqualTo(fileContent); + } + + @Test + public void throwsAClearErrorWhenFileCannotBeResolvedAnywhere() throws Exception { + tempDir = Files.createTempDirectory("http2-file-resolution-test-missing"); + FileServer.getFileServer().setBase(tempDir.toFile()); + + int port = startHttp1OnlyServer(); + client = new HTTP2JettyClient(false, "file-resolution-missing-test"); + client.start(); + + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod(HTTPConstants.POST); + sampler.setDomain("localhost"); + sampler.setPort(port); + sampler.setPath(ServerBuilder.SERVER_PATH_200_FILE_SENT); + sampler.setProtocol("http"); + sampler.setHTTPFiles( + new HTTPFileArg[] {new HTTPFileArg("does-not-exist.txt", "", "text/plain")}); + + HTTPSampleResult result = new HTTPSampleResult(); + result.setSampleLabel("POST_FILE_RESOLUTION_MISSING"); + result.setHTTPMethod(HTTPConstants.POST); + result.setURL(new URL("http", "localhost", port, ServerBuilder.SERVER_PATH_200_FILE_SENT)); + + org.junit.Assert.assertThrows(java.io.IOException.class, + () -> client.sample(sampler, result, false, 0)); + } + + private int startHttp1OnlyServer() throws Exception { + server = new ServerBuilder().withHTTP1().buildServer(); + server.start(); + return ((ServerConnector) server.getConnectors()[0]).getLocalPort(); + } +} From 5446ce4cf7d6e7ee4158514c22c1e477df017374 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 17 Jul 2026 12:55:39 -0300 Subject: [PATCH 25/35] Fix inverted Content-Type check and gate request-body attachment (#132) --- .../jmeter/http2/core/HTTP2JettyClient.java | 128 ++++++++++---- ...ttyClientBodyAttachAndContentTypeTest.java | 160 ++++++++++++++++++ .../http2/core/HTTP2JettyClientTest.java | 4 +- 3 files changed, 257 insertions(+), 35 deletions(-) create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientBodyAttachAndContentTypeTest.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index 47c7931d..c8b36a91 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -157,6 +157,7 @@ public class HTTP2JettyClient { private static final String ALT_SVC_HEADER = "alt-svc"; private static final String ATTR_HTTP3_ATTEMPTED = "bzm.http3.attempted"; private static final String ATTR_H2C_FALLBACK_ATTEMPTED = "bzm.h2cFallbackAttempted"; + private static final String ATTR_SKIP_H2C_UPGRADE = "bzm.skipH2cUpgrade"; private static final String ATTR_ORIGIN_KEY = "bzm.http3.origin"; private static final String ATTR_REQUEST_HEADERS_SERIALIZED = "bzm.request.headers.serialized"; private static final String PROP_SKIP_REDUNDANT_MANUAL_DECODE = @@ -1166,7 +1167,7 @@ public HTTPSampleResult retryWithHTTP11Only(HTTP2Sampler sampler, HTTPSampleResu configureContentDecodersAndCapture(httpClientHttp1Only, http11Request); // Copy body if present - setBody(http11Request, sampler, result); + setBody(http11Request, sampler, result, false); JmeterRequestHeadersSupport.prepareFromSampler(http11Request, sampler.getUseKeepAlive()); // Send request @@ -1406,6 +1407,14 @@ private void samplePrepareRequest(Request request, HTTP2Sampler sampler, HTTPSampleResult result, HttpClient client) throws IOException { + samplePrepareRequest(request, sampler, result, client, false); + } + + private void samplePrepareRequest(Request request, + HTTP2Sampler sampler, + HTTPSampleResult result, + HttpClient client, + boolean areFollowingRedirect) throws IOException { URL url = result.getURL(); lowLevelDebug("Preparing request: URL={}, method={}", url, result.getHTTPMethod()); @@ -1413,6 +1422,12 @@ private void samplePrepareRequest(Request request, request.followRedirects(sampler.getAutoRedirects()); String method = result.getHTTPMethod(); request.method(method); + if (shouldAttachRequestBody(sampler, result, areFollowingRedirect)) { + // The h2c Upgrade dance sends this request as plain HTTP/1.1 first; attaching a body + // to it works but isn't well-supported across servers, so skip the upgrade attempt + // entirely for bodied cleartext requests (see resolveClientForRequest). + request.attribute(ATTR_SKIP_H2C_UPGRADE, Boolean.TRUE); + } setHeaders(request, url, sampler.getHeaderManager()); ensureHostHeader(request, url); addPreemptiveAuthorizationHeader(request, url, sampler.getAuthManager()); @@ -1451,7 +1466,7 @@ private void samplePrepareRequest(Request request, } result.sampleStart(); - setBody(request, sampler, result); + setBody(request, sampler, result, areFollowingRedirect); JmeterRequestHeadersSupport.prepareFromSampler(request, sampler.getUseKeepAlive()); initializeSentBytes(result, request); @@ -1542,7 +1557,7 @@ public HTTPSampleResult sample(HTTP2Sampler sampler, HTTPSampleResult result, RequestContext context = buildRequestContext(result, resolveClientForRequest(sampler, result)); Request request = context.request; - samplePrepareRequest(request, sampler, result, context.client); + samplePrepareRequest(request, sampler, result, context.client, areFollowingRedirect); JettyCacheManager cacheManager = JettyCacheManager.fromCacheManager(sampler.getCacheManager()); @@ -3184,7 +3199,13 @@ private RequestContext buildRequestContext(HTTPSampleResult result, HttpClient c private HttpClient resolveClientForRequest(HTTP2Sampler sampler, HTTPSampleResult result) throws URISyntaxException { - return selectHttpClient(result.getURL().toURI()); + URI uri = result.getURL().toURI(); + if ("http".equalsIgnoreCase(uri.getScheme()) + && shouldAttachRequestBody(sampler, result, false)) { + lowLevelDebug("Cleartext request with body; using HTTP/1.1-only client for {}", uri); + return httpClientHttp1Only; + } + return selectHttpClient(uri); } private boolean requestAdvertisesEncoding(HTTP2Sampler sampler, String encoding) { @@ -3271,7 +3292,8 @@ private void setHeaders(Request request, URL url, HeaderManager headerManager) { // 2. Upgrade headers are for cleartext HTTP, not HTTPS // 3. It violates the HTTP/2 protocol (RFC 7540) if (http1UpgradeRequired && enableHttp2 && !"https".equalsIgnoreCase(url.getProtocol()) - && !shouldUseH2cPriorKnowledge(request.getURI())) { + && !shouldUseH2cPriorKnowledge(request.getURI()) + && !Boolean.TRUE.equals(request.getAttributes().get(ATTR_SKIP_H2C_UPGRADE))) { Mutable headers = ((Mutable) request.getHeaders()); addHeaderIfMissing(HttpHeader.UPGRADE, "h2c", headers); addHeaderIfMissing(HttpHeader.HTTP2_SETTINGS, buildH2cSettingsHeaderValue(), headers); @@ -3558,13 +3580,18 @@ private void addProxyIfEmpty(HttpClient target, String host, int port, boolean s } } - private void setBody(Request request, HTTP2Sampler sampler, HTTPSampleResult result) + private void setBody(Request request, HTTP2Sampler sampler, HTTPSampleResult result, + boolean areFollowingRedirect) throws IOException { + if (!shouldAttachRequestBody(sampler, result, areFollowingRedirect)) { + result.setQueryString(""); + return; + } String contentEncoding = sampler.getContentEncoding(); String contentTypeHeader = request.getHeaders() != null ? request.getHeaders().get(HTTPConstants.HEADER_CONTENT_TYPE) : null; - boolean hasContentTypeHeader = contentTypeHeader != null && contentTypeHeader.isEmpty(); + boolean hasContentTypeHeader = StringUtils.isNotBlank(contentTypeHeader); StringBuilder postBody = new StringBuilder(); if (sampler.getUseMultipart()) { // In Jetty 12, MultiPartRequestContent API has changed significantly @@ -3637,44 +3664,56 @@ private void setBody(Request request, HTTP2Sampler sampler, HTTPSampleResult res request.body(requestContent); postBody.append(""); } else { - if (!hasContentTypeHeader && ADD_CONTENT_TYPE_TO_POST_IF_MISSING) { - HttpFields headers = request.getHeaders(); - if (headers instanceof HttpFields.Mutable) { - ((HttpFields.Mutable) headers).put(HTTPConstants.HEADER_CONTENT_TYPE, - HTTPConstants.APPLICATION_X_WWW_FORM_URLENCODED); - } - } Charset contentCharset = buildCharsetOrDefault(contentEncoding, StandardCharsets.UTF_8); if (sampler.getSendParameterValuesAsPostBody()) { + if (!hasContentTypeHeader) { + HttpFields headers = request.getHeaders(); + if (headers instanceof HttpFields.Mutable) { + ((HttpFields.Mutable) headers).put(HTTPConstants.HEADER_CONTENT_TYPE, + "text/plain; charset=" + contentCharset.name()); + } + } for (JMeterProperty jMeterProperty : sampler.getArguments()) { HTTPArgument arg = (HTTPArgument) jMeterProperty.getObjectValue(); postBody.append(arg.getEncodedValue(contentCharset.name())); } + String bodyContentType = request.getHeaders() != null + ? request.getHeaders().get(HTTPConstants.HEADER_CONTENT_TYPE) + : null; // In Jetty 12, StringRequestContent implements Request.Content directly Request.Content requestContent = - new StringRequestContent(contentTypeHeader, postBody.toString(), - contentCharset); + new StringRequestContent(bodyContentType, postBody.toString(), contentCharset); request.body(requestContent); - } else if (isMethodWithBody(sampler.getMethod())) { - Fields fields = new Fields(); - for (JMeterProperty p : sampler.getArguments()) { - HTTPArgument arg = (HTTPArgument) p.getObjectValue(); - String parameterName = arg.getName(); - if (!arg.isSkippable(parameterName)) { - String parameterValue = arg.getValue(); - if (!arg.isAlwaysEncoded()) { - // The FormRequestContent always urlencodes both name and value, in this case the - // value is already encoded by the user so is needed to decode the value now, so - // that when the httpclient encodes it, we end up with the same value as the user - // had entered. - parameterName = URLDecoder.decode(parameterName, contentCharset.name()); - parameterValue = URLDecoder.decode(parameterValue, contentCharset.name()); + } else { + if (!hasContentTypeHeader && ADD_CONTENT_TYPE_TO_POST_IF_MISSING + && isMethodWithBody(sampler.getMethod())) { + HttpFields headers = request.getHeaders(); + if (headers instanceof HttpFields.Mutable) { + ((HttpFields.Mutable) headers).put(HTTPConstants.HEADER_CONTENT_TYPE, + HTTPConstants.APPLICATION_X_WWW_FORM_URLENCODED); + } + } + if (isMethodWithBody(sampler.getMethod())) { + Fields fields = new Fields(); + for (JMeterProperty p : sampler.getArguments()) { + HTTPArgument arg = (HTTPArgument) p.getObjectValue(); + String parameterName = arg.getName(); + if (!arg.isSkippable(parameterName)) { + String parameterValue = arg.getValue(); + if (!arg.isAlwaysEncoded()) { + // The FormRequestContent always urlencodes both name and value, in this case + // the value is already encoded by the user so is needed to decode the value + // now, so that when the httpclient encodes it, we end up with the same value + // as the user had entered. + parameterName = URLDecoder.decode(parameterName, contentCharset.name()); + parameterValue = URLDecoder.decode(parameterValue, contentCharset.name()); + } + fields.add(parameterName, parameterValue); } - fields.add(parameterName, parameterValue); } + postBody.append(FormRequestContent.convert(fields)); + request.body(new FormRequestContent(fields, contentCharset)); } - postBody.append(FormRequestContent.convert(fields)); - request.body(new FormRequestContent(fields, contentCharset)); } } } @@ -3891,6 +3930,29 @@ private boolean isMethodWithBody(String method) { return METHODS_WITH_BODY.contains(method); } + /** + * Matches HttpClient4: entities are only attached for POST/PUT/PATCH, or GET/DELETE when + * {@code postBodyRaw} is enabled ({@code HttpGetWithEntity}). + */ + private boolean shouldAttachRequestBody(HTTP2Sampler sampler, HTTPSampleResult result, + boolean areFollowingRedirect) { + String method = resolveRequestMethod(sampler, result); + if (areFollowingRedirect && !isMethodWithBody(method)) { + return false; + } + if (isMethodWithBody(method)) { + return true; + } + return sampler.getSendParameterValuesAsPostBody(); + } + + private String resolveRequestMethod(HTTP2Sampler sampler, HTTPSampleResult result) { + if (result != null && StringUtils.isNotBlank(result.getHTTPMethod())) { + return result.getHTTPMethod(); + } + return sampler.getMethod(); + } + private boolean isSupportedMethod(String method) { return SUPPORTED_METHODS.contains(method); } diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientBodyAttachAndContentTypeTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientBodyAttachAndContentTypeTest.java new file mode 100644 index 00000000..15edd13f --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientBodyAttachAndContentTypeTest.java @@ -0,0 +1,160 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.core.ServerBuilder.TeardownableServer; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import java.lang.reflect.Method; +import java.net.URL; +import org.apache.jmeter.protocol.http.control.Header; +import org.apache.jmeter.protocol.http.control.HeaderManager; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.server.ServerConnector; +import org.junit.After; +import org.junit.Test; + +/** + * HttpClient4 only attaches an entity for POST/PUT/PATCH, or for GET/DELETE when + * {@code postBodyRaw} is enabled; and it never overwrites a {@code Content-Type} header the + * user already set. {@link HTTP2JettyClient#setBody} used to attach a body regardless of the + * request method, and its {@code hasContentTypeHeader} check was inverted + * ({@code contentTypeHeader != null && contentTypeHeader.isEmpty()}, true only when the header + * exists AND is blank), so it treated almost every real Content-Type header as absent and + * clobbered it. Fixing that also lets a bodied cleartext request skip the {@code Upgrade: h2c} + * dance entirely (see {@code ATTR_SKIP_H2C_UPGRADE}), since attaching a body to an in-flight h2c + * upgrade isn't well-supported. + */ +public class HTTP2JettyClientBodyAttachAndContentTypeTest extends HTTP2TestBase { + + private TeardownableServer server; + private HTTP2JettyClient client; + + @After + public void tearDown() throws Exception { + if (client != null) { + client.stop(); + } + if (server != null) { + server.stop(); + } + } + + @Test + public void preservesExplicitContentTypeHeaderForRawPostBody() throws Exception { + HTTPSampleResult result = sampleRawPostBody("application/json"); + + assertThat(result.getRequestHeaders()).contains("Content-Type: application/json"); + } + + @Test + public void defaultsRawPostBodyContentTypeToTextPlainWhenNotSet() throws Exception { + HTTPSampleResult result = sampleRawPostBody(null); + + assertThat(result.getRequestHeaders()).contains("Content-Type: text/plain; charset=UTF-8"); + } + + @Test + public void skipsH2cUpgradeHeadersForCleartextRequestWithBody() throws Exception { + int port = startHttp1OnlyServer(); + // http1UpgradeRequired=true: the client would normally attempt the Upgrade: h2c dance on + // this cleartext origin (see HTTP2JettyClientH2cFallbackTest for that base behavior with a + // bodyless GET); a bodied request must skip it instead. + client = new HTTP2JettyClient(true, "skip-h2c-upgrade-test"); + client.start(); + + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod(HTTPConstants.POST); + sampler.setDomain("localhost"); + sampler.setPort(port); + sampler.setPath(ServerBuilder.SERVER_PATH_200_WITH_BODY); + sampler.setProtocol("http"); + sampler.setPostBodyRaw(true); + sampler.addArgument("", "payload"); + + HTTPSampleResult result = new HTTPSampleResult(); + result.setSampleLabel("POST_SKIP_H2C_UPGRADE"); + result.setHTTPMethod(HTTPConstants.POST); + result.setURL(new URL("http", "localhost", port, ServerBuilder.SERVER_PATH_200_WITH_BODY)); + + HTTPSampleResult sampled = client.sample(sampler, result, false, 0); + + assertThat(sampled.isSuccessful()).isTrue(); + assertThat(sampled.getRequestHeaders()).doesNotContainIgnoringCase("upgrade"); + } + + @Test + public void attachesBodyForPostRegardlessOfPostBodyRawFlag() throws Exception { + assertThat(invokeShouldAttachRequestBody(HTTPConstants.POST, false, false)).isTrue(); + assertThat(invokeShouldAttachRequestBody(HTTPConstants.PUT, false, false)).isTrue(); + assertThat(invokeShouldAttachRequestBody(HTTPConstants.PATCH, false, false)).isTrue(); + } + + @Test + public void onlyAttachesBodyForGetOrDeleteWhenPostBodyRawEnabled() throws Exception { + assertThat(invokeShouldAttachRequestBody(HTTPConstants.GET, false, false)).isFalse(); + assertThat(invokeShouldAttachRequestBody(HTTPConstants.GET, true, false)).isTrue(); + assertThat(invokeShouldAttachRequestBody(HTTPConstants.DELETE, false, false)).isFalse(); + assertThat(invokeShouldAttachRequestBody(HTTPConstants.DELETE, true, false)).isTrue(); + } + + @Test + public void neverAttachesBodyWhenFollowingARedirectToANonBodyMethod() throws Exception { + // A 302/303 redirect downgrades the follow-up request to GET, dropping any original body - + // matching HttpClient4, which never resends an entity after that kind of redirect. + assertThat(invokeShouldAttachRequestBody(HTTPConstants.GET, true, true)).isFalse(); + assertThat(invokeShouldAttachRequestBody(HTTPConstants.POST, false, true)).isTrue(); + } + + private HTTPSampleResult sampleRawPostBody(String explicitContentType) throws Exception { + int port = startHttp1OnlyServer(); + client = new HTTP2JettyClient(false, "raw-post-body-content-type-test"); + client.start(); + + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod(HTTPConstants.POST); + sampler.setDomain("localhost"); + sampler.setPort(port); + sampler.setPath(ServerBuilder.SERVER_PATH_200_WITH_BODY); + sampler.setProtocol("http"); + sampler.setPostBodyRaw(true); + sampler.addArgument("", "raw-body-content"); + if (explicitContentType != null) { + HeaderManager headerManager = new HeaderManager(); + headerManager.add(new Header(HttpHeader.CONTENT_TYPE.asString(), explicitContentType)); + sampler.setHeaderManager(headerManager); + } + + HTTPSampleResult result = new HTTPSampleResult(); + result.setSampleLabel("POST_RAW_BODY_CONTENT_TYPE"); + result.setHTTPMethod(HTTPConstants.POST); + result.setURL(new URL("http", "localhost", port, ServerBuilder.SERVER_PATH_200_WITH_BODY)); + + return client.sample(sampler, result, false, 0); + } + + private int startHttp1OnlyServer() throws Exception { + server = new ServerBuilder().withHTTP1().buildServer(); + server.start(); + return ((ServerConnector) server.getConnectors()[0]).getLocalPort(); + } + + private boolean invokeShouldAttachRequestBody(String method, boolean postBodyRaw, + boolean areFollowingRedirect) throws Exception { + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod(method); + sampler.setPostBodyRaw(postBodyRaw); + + HTTPSampleResult result = new HTTPSampleResult(); + result.setHTTPMethod(method); + + HTTP2JettyClient reflectionClient = new HTTP2JettyClient(false, "should-attach-body-test"); + Method shouldAttachRequestBody = HTTP2JettyClient.class.getDeclaredMethod( + "shouldAttachRequestBody", HTTP2Sampler.class, HTTPSampleResult.class, boolean.class); + shouldAttachRequestBody.setAccessible(true); + return (boolean) shouldAttachRequestBody.invoke( + reflectionClient, sampler, result, areFollowingRedirect); + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java index e1671213..4a7c76c9 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java @@ -453,7 +453,7 @@ public void shouldSendBodyInformationWhenRequestWithBodyRaw() throws Exception { String requestBody = TEST_ARGUMENT_1 + TEST_ARGUMENT_2; HTTPSampleResult httpSampleResult = buildResult(true, Code.OK, hostHeader(), - requestBody.getBytes(StandardCharsets.UTF_8), "application/octet-stream", + requestBody.getBytes(StandardCharsets.UTF_8), "text/plain; charset=UTF-8", createURL(SERVER_PATH_200_WITH_BODY), HTTPConstants.POST); validateResponse(sample(SERVER_PATH_200_WITH_BODY, HTTPConstants.POST), httpSampleResult); @@ -628,7 +628,7 @@ public void shouldSendBodyWhenDeleteMethodWithRawData() throws Exception { buildResult(true, HttpStatus.Code.OK, HttpFields.build().add(HttpHeader.HOST, hostHeaderValue()), requestBody.getBytes(StandardCharsets.UTF_8), - "application/octet-stream", createURL(SERVER_PATH_200_WITH_BODY), + "text/plain; charset=UTF-8", createURL(SERVER_PATH_200_WITH_BODY), HTTPConstants.DELETE); validateResponse(sample(SERVER_PATH_200_WITH_BODY, HTTPConstants.DELETE), expected); From 3462083f0763e1b52b37b723b026965d8d151c63 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 17 Jul 2026 12:55:52 -0300 Subject: [PATCH 26/35] Remove duplicate fallback and fix stale body reuse (#131) --- .../core/HTTP2FutureResponseListener.java | 67 +--------- .../jmeter/http2/core/HTTP2JettyClient.java | 15 ++- ...stenerDoubleFallbackInvestigationTest.java | 50 ++++++++ ...entFallbackBodyReuseInvestigationTest.java | 121 ++++++++++++++++++ .../http2/core/HTTP2JettyClientTest.java | 12 +- 5 files changed, 193 insertions(+), 72 deletions(-) create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListenerDoubleFallbackInvestigationTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFallbackBodyReuseInvestigationTest.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java index 738cd203..328e87dc 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java @@ -3,7 +3,6 @@ import static com.blazemeter.jmeter.http2.core.LowLevelDebugLog.lowLevelDebug; import java.io.IOException; -import java.net.URI; import java.nio.charset.Charset; import java.nio.charset.IllegalCharsetNameException; import java.nio.charset.StandardCharsets; @@ -16,12 +15,9 @@ import java.util.concurrent.TimeoutException; import org.eclipse.jetty.client.BufferingResponseListener; import org.eclipse.jetty.client.ContentResponse; -import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.Request; import org.eclipse.jetty.client.Response; import org.eclipse.jetty.client.Result; -import org.eclipse.jetty.http.HttpFields; -import org.eclipse.jetty.http.HttpHeader; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,7 +30,6 @@ public class HTTP2FutureResponseListener extends BufferingResponseListener private volatile boolean onCompleteCalled = false; private final CountDownLatch latch = new CountDownLatch(1); private Request request; - private HttpClient fallbackHttp1Client; private ContentResponse response; private Throwable failure; private volatile boolean cancelled; @@ -64,10 +59,6 @@ public Request getRequest() { return request; } - public void setFallbackHttp1Client(HttpClient fallbackHttp1Client) { - this.fallbackHttp1Client = fallbackHttp1Client; - } - protected void setStart() { if (this.responseStart == 0) { this.responseStart = System.currentTimeMillis(); @@ -346,13 +337,9 @@ public ContentResponse get() throws InterruptedException, ExecutionException { try { return getResult(); } catch (ProtocolErrorException e) { - ContentResponse fallback = tryHttp11Fallback(); - if (fallback != null) { - return fallback; - } LOG.error("ProtocolErrorException caught in get(), wrapping in ExecutionException"); - // Wrap ProtocolErrorException in ExecutionException to maintain interface contract - // The calling code will unwrap it and handle the fallback + // Wrap ProtocolErrorException in ExecutionException so HTTP2JettyClient (the single, + // centralized place for HTTP/1.1 fallback decisions) can unwrap it and handle the fallback. throw new ExecutionException(e); } } @@ -376,13 +363,9 @@ public ContentResponse get(long timeout, TimeUnit unit) try { return getResult(); } catch (ProtocolErrorException e) { - ContentResponse fallback = tryHttp11Fallback(); - if (fallback != null) { - return fallback; - } LOG.error("ProtocolErrorException caught in get(timeout), wrapping in ExecutionException"); - // Wrap ProtocolErrorException in ExecutionException to maintain interface contract - // The calling code will unwrap it and handle the fallback + // Wrap ProtocolErrorException in ExecutionException so HTTP2JettyClient (the single, + // centralized place for HTTP/1.1 fallback decisions) can unwrap it and handle the fallback. throw new ExecutionException(e); } } @@ -474,47 +457,5 @@ private ContentResponse getResult() throws ExecutionException, ProtocolErrorExce return response; } - private ContentResponse tryHttp11Fallback() { - if (fallbackHttp1Client == null || request == null) { - return null; - } - try { - Request http11Request = fallbackHttp1Client.newRequest(request.getURI()) - .method(request.getMethod()) - .followRedirects(request.isFollowRedirects()); - if (request.getHeaders() != null) { - HttpFields originalHeaders = request.getHeaders(); - HttpFields requestHeaders = http11Request.getHeaders(); - if (requestHeaders instanceof HttpFields.Mutable) { - HttpFields.Mutable newHeaders = (HttpFields.Mutable) requestHeaders; - originalHeaders.forEach(field -> { - String name = field.getName(); - if (!name.startsWith(":")) { - newHeaders.put(name, field.getValue()); - } - }); - if (!newHeaders.contains(HttpHeader.HOST)) { - URI uri = request.getURI(); - String host = uri.getHost() != null ? uri.getHost() : uri.getAuthority(); - int port = uri.getPort(); - int defaultPort = "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80; - boolean includePort = port > 0 && port != defaultPort; - String hostValue = includePort ? host + ":" + port : host; - newHeaders.put(HttpHeader.HOST, hostValue); - } - } - } - if (request.getBody() != null) { - http11Request.body(request.getBody()); - } - SslClientCertAliasSupport.copyFromRequest(request, http11Request); - lowLevelDebug("Retrying request with HTTP/1.1 in listener fallback: {}", request.getURI()); - return http11Request.send(); - } catch (Exception e) { - LOG.error("HTTP/1.1 fallback in listener failed", e); - return null; - } - } - } diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index c8b36a91..007c3012 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -1247,8 +1247,18 @@ private Request buildHttp11FallbackRequest(Request originalRequest) { JmeterRequestHeadersSupport.prepareFromSampler(http11Request, (Boolean) useKeepAlive); } configureContentDecodersAndCapture(httpClientHttp1Only, http11Request); - if (originalRequest.getBody() != null) { - http11Request.body(originalRequest.getBody()); + Request.Content body = originalRequest.getBody(); + if (body != null) { + // The original attempt may already have read some or all of this content before + // failing; rewind it back to the start before reuse, exactly as Jetty's own retry + // paths do (see AuthenticationProtocolHandler/HttpRedirector). Skipping this can send + // a fallback request that declares the right Content-Length but no actual body bytes, + // hanging the server until its idle timeout instead of failing fast. + if (!body.rewind()) { + throw new IllegalStateException( + "Request body for " + uri + " is not reproducible for HTTP/1.1 fallback retry"); + } + http11Request.body(body); } SslClientCertAliasSupport.copyFromRequest(originalRequest, http11Request); return http11Request; @@ -1527,7 +1537,6 @@ public Request sampleAsync(HTTP2Sampler sampler, lowLevelDebug("Request built: URI={}, method={}", request.getURI(), request.getMethod()); samplePrepareRequest(request, sampler, result, context.client); listener.setRequest(request); - listener.setFallbackHttp1Client(httpClientHttp1Only); lowLevelDebug("Request prepared, ready to send"); return request; diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListenerDoubleFallbackInvestigationTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListenerDoubleFallbackInvestigationTest.java new file mode 100644 index 00000000..8f87fa49 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListenerDoubleFallbackInvestigationTest.java @@ -0,0 +1,50 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import java.net.URI; +import java.util.concurrent.ExecutionException; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.client.Result; +import org.junit.After; +import org.junit.Test; + +/** + * Item #14: {@link HTTP2FutureResponseListener} used to resolve a {@code protocol_error} on its + * own (via a {@code tryHttp11Fallback()}/{@code fallbackHttp1Client} pair), silently, before + * {@link HTTP2JettyClient} - the single place that should own the HTTP/1.1 fallback decision via + * {@code shouldFallbackToHttp11AfterTransportFailure}/{@code buildHttp11FallbackRequest} - ever + * saw the failure. That duplicate fallback path is gone; {@link #get} must now always propagate + * a {@code protocol_error} to the caller. + */ +public class HTTP2FutureResponseListenerDoubleFallbackInvestigationTest extends HTTP2TestBase { + + private HttpClient probeClient; + + @After + public void tearDown() throws Exception { + if (probeClient != null) { + probeClient.stop(); + } + } + + @Test + public void getPropagatesProtocolErrorInsteadOfResolvingItSilently() throws Exception { + probeClient = new HttpClient(); + probeClient.start(); + + HTTP2FutureResponseListener listener = new HTTP2FutureResponseListener(2 * 1024 * 1024); + Request originalRequest = probeClient.newRequest(URI.create("http://localhost:1/unused")); + listener.setRequest(originalRequest); + + ProtocolErrorException simulatedProtocolError = + new ProtocolErrorException("simulated for regression test"); + listener.onComplete(new Result(originalRequest, simulatedProtocolError, null)); + + ExecutionException thrown = org.junit.Assert.assertThrows(ExecutionException.class, + listener::get); + assertThat(ProtocolErrorException.isProtocolError(thrown.getCause())).isTrue(); + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFallbackBodyReuseInvestigationTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFallbackBodyReuseInvestigationTest.java new file mode 100644 index 00000000..a7a8c025 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFallbackBodyReuseInvestigationTest.java @@ -0,0 +1,121 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.core.ServerBuilder.TeardownableServer; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.client.StringRequestContent; +import org.eclipse.jetty.server.ServerConnector; +import org.junit.After; +import org.junit.Test; + +/** + * Item #14, phase 2: {@code HTTP2JettyClient#buildHttp11FallbackRequest} reuses the failed + * request's {@code Request.Content} instead of rebuilding the body from scratch (unlike + * {@code retryWithHTTP11Only}, the sampler-based last-resort fallback). Jetty's own retry paths + * ({@code AuthenticationProtocolHandler}, {@code HttpRedirector}) always call + * {@code Request.Content#rewind()} before reusing content on a retry, and abort the retry if it + * returns {@code false}. {@code buildHttp11FallbackRequest} used to skip that call: if the + * original content had already been (partially or fully) read before the transport failure was + * detected, the fallback request would declare the original {@code Content-Length} but send no + * body bytes, hanging the server until its idle timeout instead of failing fast or resending the + * body correctly. + */ +public class HTTP2JettyClientFallbackBodyReuseInvestigationTest extends HTTP2TestBase { + + private TeardownableServer server; + private HTTP2JettyClient client; + private HttpClient probeClient; + + @After + public void tearDown() throws Exception { + if (probeClient != null) { + probeClient.stop(); + } + if (client != null) { + client.stop(); + } + if (server != null) { + server.stop(); + } + } + + @Test + public void reusingOriginalRequestBodyAfterAFailedSendStillDeliversFullContent() + throws Exception { + int port = startHttp1OnlyServer(); + client = new HTTP2JettyClient(false, "fallback-body-reuse-test"); + client.start(); + probeClient = new HttpClient(); + probeClient.start(); + + String bodyText = "hello=world&keep=this-data-intact"; + Request originalRequest = probeClient.newRequest( + URI.create("http://localhost:" + port + ServerBuilder.SERVER_PATH_200_WITH_BODY)) + .method("POST") + .body(new StringRequestContent(bodyText, StandardCharsets.UTF_8)); + + // Simulate "the original request already failed once" by sending it for real first - + // this drains its Request.Content exactly like a real send would, before the fallback + // reuses the same content object. + ContentResponse firstAttempt = originalRequest.send(); + assertThat(firstAttempt.getContentAsString()).isEqualTo(bodyText); + + Request fallbackRequest = buildFallback(originalRequest); + ContentResponse fallbackAttempt = fallbackRequest.send(); + + assertThat(fallbackAttempt.getContentAsString()) + .as("the fallback request must resend the full original body, not an empty/stale one") + .isEqualTo(bodyText); + } + + @Test + public void throwsInsteadOfSendingAnEmptyBodyWhenContentCannotBeRewound() throws Exception { + int port = startHttp1OnlyServer(); + client = new HTTP2JettyClient(false, "fallback-body-reuse-test"); + client.start(); + probeClient = new HttpClient(); + probeClient.start(); + + Request originalRequest = probeClient.newRequest( + URI.create("http://localhost:" + port + ServerBuilder.SERVER_PATH_200_WITH_BODY)) + .method("POST") + .body(new NonRewindableStringContent("cannot-reuse-me")); + + InvocationTargetException thrown = org.junit.Assert.assertThrows( + InvocationTargetException.class, () -> buildFallback(originalRequest)); + + assertThat(thrown.getCause()).isInstanceOf(IllegalStateException.class); + } + + private Request buildFallback(Request originalRequest) throws Exception { + Method buildFallback = HTTP2JettyClient.class + .getDeclaredMethod("buildHttp11FallbackRequest", Request.class); + buildFallback.setAccessible(true); + return (Request) buildFallback.invoke(client, originalRequest); + } + + private int startHttp1OnlyServer() throws Exception { + server = new ServerBuilder().withHTTP1().buildServer(); + server.start(); + return ((ServerConnector) server.getConnectors()[0]).getLocalPort(); + } + + private static final class NonRewindableStringContent extends StringRequestContent { + private NonRewindableStringContent(String content) { + super(content, StandardCharsets.UTF_8); + } + + @Override + public boolean rewind() { + return false; + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java index 4a7c76c9..8142961f 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java @@ -1510,13 +1510,13 @@ public void shouldSuccessfulSendRequestWhenExclusiveHTTP2ServerWithoutALPN() thr client = new HTTP2JettyClient(true, "Test"); client.start(); - Request httpRequest = client.sampleAsync( - sampler, - buildBaseResult(createURL(SERVER_PATH_200), HTTPConstants.GET), - sampler.getFutureResponseListener()); + HTTPSampleResult result = buildBaseResult(createURL(SERVER_PATH_200), HTTPConstants.GET); + Request httpRequest = client.sampleAsync(sampler, result, sampler.getFutureResponseListener()); httpRequest.send(listener); - ContentResponse contentResponse = listener.get(); - assertThat(contentResponse.getContent()).isNotEmpty(); + // Goes through sampleFromListener() -> getContent(), the single place that owns the + // HTTP/1.1 fallback decision (the listener no longer resolves protocol_error on its own). + HTTPSampleResult sampleResult = client.sampleFromListener(sampler, result, false, 0, listener); + assertThat(sampleResult.getResponseData()).isNotEmpty(); } @Test From 60b9a524d508bf54254b7e5400510c4e48e4b02d Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 17 Jul 2026 14:59:39 -0300 Subject: [PATCH 27/35] Map Jetty client failures to HttpClient4-equivalent exception types (#138) --- .../core/JmeterHttpClientExceptionMapper.java | 104 ++++++++++++++++++ .../jmeter/http2/sampler/HTTP2Sampler.java | 5 +- .../JmeterHttpClientExceptionMapperTest.java | 48 ++++++++ 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapper.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapperTest.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapper.java b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapper.java new file mode 100644 index 00000000..a6a663c3 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapper.java @@ -0,0 +1,104 @@ +package com.blazemeter.jmeter.http2.core; + +import java.net.ConnectException; +import java.net.InetAddress; +import java.net.URL; +import java.net.UnknownHostException; +import java.util.Locale; +import java.util.concurrent.ExecutionException; +import org.apache.http.HttpHost; +import org.apache.http.client.ClientProtocolException; +import org.apache.http.conn.HttpHostConnectException; + +/** + * Maps Jetty client failures to the exception types JMeter {@code HTTPHC4Impl} reports in + * {@code HTTPSampleResult} for semantic parity. + */ +public final class JmeterHttpClientExceptionMapper { + + private JmeterHttpClientExceptionMapper() { + } + + public static Throwable forSampleResult(Throwable thrown, boolean autoRedirects) { + return forSampleResult(thrown, autoRedirects, null); + } + + public static Throwable forSampleResult(Throwable thrown, boolean autoRedirects, URL url) { + if (thrown == null) { + return null; + } + Throwable root = unwrap(thrown); + if (autoRedirects && isAutoRedirectProtocolFailure(root)) { + return new ClientProtocolException(); + } + Throwable connectMapped = mapConnectFailure(root, url); + if (connectMapped != null) { + return connectMapped; + } + return thrown; + } + + private static Throwable unwrap(Throwable throwable) { + Throwable current = throwable; + while (current instanceof ExecutionException && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + private static boolean isAutoRedirectProtocolFailure(Throwable throwable) { + if (throwable == null) { + return false; + } + if (!"HttpResponseException".equals(throwable.getClass().getSimpleName())) { + return false; + } + String message = throwable.getMessage(); + return message != null && message.contains("Max redirects exceeded"); + } + + private static Throwable mapConnectFailure(Throwable root, URL url) { + if (root instanceof HttpHostConnectException) { + return root; + } + if (root instanceof ConnectException) { + HttpHost host = toHttpHost(url); + ConnectException cause = normalizeConnectCause((ConnectException) root); + if (host != null) { + try { + return new HttpHostConnectException(cause, host, + InetAddress.getAllByName(host.getHostName())); + } catch (UnknownHostException ignored) { + // Fall back to host-only message formatting. + } + } + return new HttpHostConnectException(cause, host); + } + return null; + } + + private static ConnectException normalizeConnectCause(ConnectException root) { + String message = root.getMessage(); + if (message != null && message.toLowerCase(Locale.ROOT).contains("getsockopt")) { + ConnectException normalized = new ConnectException("Connection refused: connect"); + normalized.initCause(root); + return normalized; + } + return root; + } + + private static HttpHost toHttpHost(URL url) { + if (url == null || url.getHost() == null || url.getHost().isEmpty()) { + return null; + } + int port = url.getPort(); + if (port < 0) { + port = url.getDefaultPort(); + } + String scheme = url.getProtocol(); + if (scheme == null || scheme.isEmpty()) { + return new HttpHost(url.getHost(), port); + } + return new HttpHost(url.getHost(), port, scheme); + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java b/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java index 833a3470..7888aa9e 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java +++ b/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java @@ -7,6 +7,7 @@ import com.blazemeter.jmeter.http2.core.HTTP2FutureResponseListener; import com.blazemeter.jmeter.http2.core.HTTP2JettyClient; import com.blazemeter.jmeter.http2.core.HpackFailureDetector; +import com.blazemeter.jmeter.http2.core.JmeterHttpClientExceptionMapper; import com.blazemeter.jmeter.http2.core.ProtocolErrorException; import com.blazemeter.jmeter.http2.util.BzmHttpPluginProperties; import com.github.benmanes.caffeine.cache.Caffeine; @@ -602,7 +603,9 @@ private HTTPSampleResult buildErrorResult(Exception e, HTTPSampleResult result) result.sampleEnd(); } } - return errorResult(e, result); + return errorResult( + JmeterHttpClientExceptionMapper.forSampleResult(e, getAutoRedirects(), result.getURL()), + result); } /** diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapperTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapperTest.java new file mode 100644 index 00000000..feb92b86 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapperTest.java @@ -0,0 +1,48 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.ConnectException; +import java.net.URL; +import java.util.concurrent.ExecutionException; +import org.apache.http.client.ClientProtocolException; +import org.apache.http.conn.HttpHostConnectException; +import org.eclipse.jetty.client.HttpResponseException; +import org.junit.Test; + +public class JmeterHttpClientExceptionMapperTest { + + @Test + public void shouldMapJettyMaxRedirectsToClientProtocolExceptionWhenAutoRedirectsEnabled() { + Throwable jetty = new ExecutionException( + new HttpResponseException("Max redirects exceeded 8", null)); + + Throwable mapped = JmeterHttpClientExceptionMapper.forSampleResult(jetty, true); + + assertThat(mapped).isInstanceOf(ClientProtocolException.class); + assertThat(mapped.getMessage()).isNull(); + } + + @Test + public void shouldKeepExecutionExceptionWhenAutoRedirectsDisabled() { + Throwable failure = new ExecutionException( + new HttpResponseException("Max redirects exceeded 8", null)); + + Throwable mapped = JmeterHttpClientExceptionMapper.forSampleResult(failure, false); + + assertThat(mapped).isInstanceOf(ExecutionException.class); + } + + @Test + public void shouldMapConnectExceptionToHttpHostConnectException() throws Exception { + Throwable failure = new ExecutionException( + new ConnectException("Connection refused: getsockopt")); + URL url = new URL("https://localhost:8082/"); + + Throwable mapped = JmeterHttpClientExceptionMapper.forSampleResult(failure, false, url); + + assertThat(mapped).isInstanceOf(HttpHostConnectException.class); + assertThat(mapped.getCause()).isInstanceOf(ConnectException.class); + assertThat(mapped.getMessage()).contains("localhost:8082"); + } +} From 88e1eddf48186bf5d99546b091e0c667b2de6543 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 17 Jul 2026 21:29:06 -0300 Subject: [PATCH 28/35] Add headless CLI tool to migrate stock JMeter HTTP samplers to BlazeMeter HTTP (#139) * Add headless CLI tool to migrate stock JMeter HTTP samplers to BlazeMeter HTTP * Cross compatibility 5.5 and 5.6.3 --- scripts/jmx-migrate.cmd | 14 ++ scripts/jmx-migrate.sh | 13 ++ .../http2/cli/JMeterCliEnvironment.java | 96 ++++++++ .../sampler/JmxBlazeMeterHttpMigrator.java | 155 +++++++++++++ .../sampler/JmxBlazeMeterHttpMigratorCli.java | 211 ++++++++++++++++++ .../http2/cli/JMeterCliEnvironmentTest.java | 42 ++++ .../JmxBlazeMeterHttpMigratorCliTest.java | 194 ++++++++++++++++ .../JmxBlazeMeterHttpMigratorTest.java | 135 +++++++++++ 8 files changed, 860 insertions(+) create mode 100644 scripts/jmx-migrate.cmd create mode 100644 scripts/jmx-migrate.sh create mode 100644 src/main/java/com/blazemeter/jmeter/http2/cli/JMeterCliEnvironment.java create mode 100644 src/main/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigrator.java create mode 100644 src/main/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigratorCli.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/cli/JMeterCliEnvironmentTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigratorCliTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigratorTest.java diff --git a/scripts/jmx-migrate.cmd b/scripts/jmx-migrate.cmd new file mode 100644 index 00000000..5e3ada18 --- /dev/null +++ b/scripts/jmx-migrate.cmd @@ -0,0 +1,14 @@ +@echo off +rem Migrates stock JMeter HTTP Request samplers to the BlazeMeter HTTP sampler in a .jmx file +rem or directory. Run from a real JMeter installation with jmeter-bzm-http2-*.jar installed +rem under lib\ext\ (this script assumes it lives at \bin\jmx-migrate.cmd). +rem +rem Usage: see `jmx-migrate.cmd --help` + +setlocal +set SCRIPT_DIR=%~dp0 +set JMETER_HOME_DIR=%SCRIPT_DIR%.. + +java -Djava.awt.headless=true ^ + -cp "%JMETER_HOME_DIR%\lib\*;%JMETER_HOME_DIR%\lib\ext\*" ^ + com.blazemeter.jmeter.http2.sampler.JmxBlazeMeterHttpMigratorCli %* diff --git a/scripts/jmx-migrate.sh b/scripts/jmx-migrate.sh new file mode 100644 index 00000000..89f4286d --- /dev/null +++ b/scripts/jmx-migrate.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Migrates stock JMeter HTTP Request samplers to the BlazeMeter HTTP sampler in a .jmx file +# or directory. Run from a real JMeter installation with jmeter-bzm-http2-*.jar installed +# under lib/ext/ (this script assumes it lives at /bin/jmx-migrate.sh). +# +# Usage: see `jmx-migrate.sh --help` + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +JMETER_HOME_DIR=$(cd "$SCRIPT_DIR/.." && pwd) + +exec java -Djava.awt.headless=true \ + -cp "$JMETER_HOME_DIR/lib/*:$JMETER_HOME_DIR/lib/ext/*" \ + com.blazemeter.jmeter.http2.sampler.JmxBlazeMeterHttpMigratorCli "$@" diff --git a/src/main/java/com/blazemeter/jmeter/http2/cli/JMeterCliEnvironment.java b/src/main/java/com/blazemeter/jmeter/http2/cli/JMeterCliEnvironment.java new file mode 100644 index 00000000..efb9b295 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/cli/JMeterCliEnvironment.java @@ -0,0 +1,96 @@ +package com.blazemeter.jmeter.http2.cli; + +import java.io.File; +import java.net.URISyntaxException; +import java.security.CodeSource; +import org.apache.jmeter.util.JMeterUtils; + +/** + * Bootstraps a headless JMeter environment for command-line tools shipped inside this plugin's + * jar, without requiring the JMeter GUI or the lightweight test emulator used by unit tests. + * + *

Resolves {@code JMETER_HOME} by locating this jar under {@code /lib/ext/}, + * matching how JMeter loads plugins; falls back to the {@code JMETER_HOME} environment variable + * or the {@code jmeter.home} system property when the jar isn't installed there. + */ +public final class JMeterCliEnvironment { + + private static volatile boolean initialized; + + private JMeterCliEnvironment() { + } + + public static synchronized void ensureInitialized() { + if (initialized) { + return; + } + System.setProperty("java.awt.headless", "true"); + if (isNotBlank(JMeterUtils.getJMeterHome())) { + // Some other bootstrap (JMeter GUI/engine, or the in-process test emulator) already + // configured JMeterUtils; reloading properties here would discard that configuration. + initialized = true; + return; + } + String home = resolveJMeterHome(); + JMeterUtils.setJMeterHome(home); + File propertiesFile = new File(new File(home, "bin"), "jmeter.properties"); + JMeterUtils.loadJMeterProperties(propertiesFile.getAbsolutePath()); + JMeterUtils.initLocale(); + JMeterUtils.initLogging(); + initialized = true; + } + + static String resolveJMeterHome() { + String fromProperty = System.getProperty("jmeter.home"); + if (isNotBlank(fromProperty)) { + return fromProperty.trim(); + } + String fromEnv = System.getenv("JMETER_HOME"); + if (isNotBlank(fromEnv)) { + return fromEnv.trim(); + } + String fromJarLocation = homeFromExtDirJar(locateOwnJarFile()); + if (fromJarLocation != null) { + return fromJarLocation; + } + throw new IllegalStateException( + "Could not determine JMeter home. This tool expects to run from /lib/ext/, " + + "or with -Djmeter.home= / the JMETER_HOME environment variable set."); + } + + /** + * @return {@code } when {@code jarFile} looks like {@code /lib/ext/some.jar}, + * {@code null} otherwise (e.g. when running from a build output directory). + */ + static String homeFromExtDirJar(File jarFile) { + if (jarFile == null) { + return null; + } + File extDir = jarFile.getParentFile(); + if (extDir == null || !"ext".equalsIgnoreCase(extDir.getName())) { + return null; + } + File libDir = extDir.getParentFile(); + if (libDir == null || !"lib".equalsIgnoreCase(libDir.getName())) { + return null; + } + File home = libDir.getParentFile(); + return home == null ? null : home.getAbsolutePath(); + } + + private static File locateOwnJarFile() { + try { + CodeSource codeSource = JMeterCliEnvironment.class.getProtectionDomain().getCodeSource(); + if (codeSource == null) { + return null; + } + return new File(codeSource.getLocation().toURI()); + } catch (URISyntaxException | IllegalArgumentException e) { + return null; + } + } + + private static boolean isNotBlank(String value) { + return value != null && !value.trim().isEmpty(); + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigrator.java b/src/main/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigrator.java new file mode 100644 index 00000000..e5d2b59c --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigrator.java @@ -0,0 +1,155 @@ +package com.blazemeter.jmeter.http2.sampler; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase; +import org.apache.jmeter.save.SaveService; +import org.apache.jmeter.testelement.TestElement; +import org.apache.jorphan.collections.HashTree; +import org.apache.jorphan.collections.ListedHashTree; + +/** + * Headless migration of JMeter test plans: replaces stock HTTP Request samplers with + * {@link HTTP2Sampler} while preserving child elements (assertions, timers, etc.). + */ +public final class JmxBlazeMeterHttpMigrator { + + private JmxBlazeMeterHttpMigrator() { + } + + public static HashTree loadTree(File jmxFile) throws IOException { + return SaveService.loadTree(jmxFile); + } + + public static void saveTree(HashTree tree, File jmxFile) throws IOException { + try (OutputStream out = java.nio.file.Files.newOutputStream(jmxFile.toPath())) { + SaveService.saveTree(tree, out); + } + } + + /** + * @return number of HTTP Request samplers replaced + */ + public static int migrateTree(HashTree tree) { + return migrateTreeWithDetails(tree).getReplacedCount(); + } + + public static MigrationResult migrateTreeWithDetails(HashTree tree) { + MigrationResult result = new MigrationResult(); + migrateInPlace(tree, result); + return result; + } + + public static HashTree migrateCopy(HashTree source) { + return migrateCopy(source, new MigrationResult()); + } + + public static HashTree migrateCopy(HashTree source, MigrationResult result) { + // Declared as HashTree (not ListedHashTree) so add(Object) binds to the stable base-class + // method: JMeter 5.6.3's ListedHashTree added a covariant add(Object) override that older + // jorphan releases (e.g. 5.5) don't have, which would throw NoSuchMethodError at runtime + // if this class were compiled against a jar with the override and loaded into an older one. + HashTree copy = new ListedHashTree(); + for (Object key : source.list()) { + Object newKey = maybeReplaceSampler(key, result); + HashTree sub = source.getTree(key); + if (sub != null && !sub.isEmpty()) { + copy.add(newKey, migrateCopy(sub, result)); + } else { + copy.add(newKey); + } + } + return copy; + } + + public static File migrateFile(File sourceJmx, File targetJmx) throws IOException { + HashTree tree = loadTree(sourceJmx); + migrateTree(tree); + saveTree(tree, targetJmx); + return targetJmx; + } + + public static int countMigratableSamplers(HashTree tree) { + int count = 0; + for (Object key : tree.list()) { + if (key instanceof TestElement + && HttpSamplerToBlazeMeterHttpMigrator.isMigratableApacheHttpSampler( + (TestElement) key)) { + count++; + } + HashTree sub = tree.getTree(key); + if (sub != null && !sub.isEmpty()) { + count += countMigratableSamplers(sub); + } + } + return count; + } + + public static int countHttp2Samplers(HashTree tree) { + int count = 0; + for (Object key : tree.list()) { + if (key instanceof HTTP2Sampler) { + count++; + } + HashTree sub = tree.getTree(key); + if (sub != null && !sub.isEmpty()) { + count += countHttp2Samplers(sub); + } + } + return count; + } + + private static void migrateInPlace(HashTree tree, MigrationResult result) { + List keys = new ArrayList<>(tree.list()); + for (Object key : keys) { + HashTree sub = tree.getTree(key); + if (sub != null && !sub.isEmpty()) { + migrateInPlace(sub, result); + } + if (key instanceof TestElement + && HttpSamplerToBlazeMeterHttpMigrator.isMigratableApacheHttpSampler( + (TestElement) key)) { + HTTPSamplerBase source = (HTTPSamplerBase) key; + HTTP2Sampler replacement = + HttpSamplerToBlazeMeterHttpMigrator.migrateFromApacheHttpSampler(source); + result.recordReplacement(source, replacement); + tree.replaceKey(key, replacement); + } + } + } + + private static Object maybeReplaceSampler(Object key, MigrationResult result) { + if (key instanceof TestElement + && HttpSamplerToBlazeMeterHttpMigrator.isMigratableApacheHttpSampler( + (TestElement) key)) { + HTTPSamplerBase source = (HTTPSamplerBase) key; + HTTP2Sampler replacement = + HttpSamplerToBlazeMeterHttpMigrator.migrateFromApacheHttpSampler(source); + result.recordReplacement(source, replacement); + return replacement; + } + return key; + } + + public static final class MigrationResult { + private int replacedCount; + private final List replacedLabels = new ArrayList<>(); + + private void recordReplacement(HTTPSamplerBase source, HTTP2Sampler replacement) { + replacedCount++; + replacedLabels.add(source.getName()); + replacement.setName(source.getName()); + } + + public int getReplacedCount() { + return replacedCount; + } + + public List getReplacedLabels() { + return replacedLabels; + } + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigratorCli.java b/src/main/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigratorCli.java new file mode 100644 index 00000000..a373e5b2 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigratorCli.java @@ -0,0 +1,211 @@ +package com.blazemeter.jmeter.http2.sampler; + +import com.blazemeter.jmeter.http2.cli.JMeterCliEnvironment; +import java.io.File; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; +import org.apache.jorphan.collections.HashTree; + +/** + * Command-line entry point for {@link JmxBlazeMeterHttpMigrator}: migrates stock JMeter HTTP + * Request samplers to {@link HTTP2Sampler} in one {@code .jmx} file, or in every {@code .jmx} + * file under a directory. + * + *

Run from {@code /lib/ext/} (where this plugin's jar is installed) with + * {@code /lib/*} and {@code /lib/ext/*} on the classpath; see + * {@code scripts/jmx-migrate.sh} / {@code scripts/jmx-migrate.cmd}. + */ +public final class JmxBlazeMeterHttpMigratorCli { + + static final int EXIT_OK = 0; + static final int EXIT_USAGE_ERROR = 1; + static final int EXIT_RUNTIME_ERROR = 2; + + private JmxBlazeMeterHttpMigratorCli() { + } + + public static void main(String[] args) { + System.exit(run(args, System.out, System.err)); + } + + public static int run(String[] args, PrintStream out, PrintStream err) { + CliOptions options; + try { + options = CliOptions.parse(args); + } catch (IllegalArgumentException e) { + err.println("Error: " + e.getMessage()); + printUsage(err); + return EXIT_USAGE_ERROR; + } + if (options.help) { + printUsage(out); + return EXIT_OK; + } + try { + JMeterCliEnvironment.ensureInitialized(); + if (options.source.isDirectory()) { + return runBatch(options, out); + } + return runSingleFile(options, out, err); + } catch (IOException e) { + err.println("Error: " + e.getMessage()); + return EXIT_RUNTIME_ERROR; + } + } + + private static int runSingleFile(CliOptions options, PrintStream out, PrintStream err) + throws IOException { + File source = options.source; + if (!source.isFile()) { + err.println("Error: source file not found: " + source); + return EXIT_RUNTIME_ERROR; + } + HashTree tree = JmxBlazeMeterHttpMigrator.loadTree(source); + if (options.dryRun) { + int count = JmxBlazeMeterHttpMigrator.countMigratableSamplers(tree); + out.println(count + " sampler(s) would be migrated in " + source); + return EXIT_OK; + } + JmxBlazeMeterHttpMigrator.MigrationResult result = + JmxBlazeMeterHttpMigrator.migrateTreeWithDetails(tree); + File destination = options.inPlace ? source : options.target; + JmxBlazeMeterHttpMigrator.saveTree(tree, destination); + out.println(result.getReplacedCount() + " sampler(s) migrated: " + source + " -> " + + destination); + return EXIT_OK; + } + + private static int runBatch(CliOptions options, PrintStream out) throws IOException { + List jmxFiles = findJmxFiles(options.source.toPath()); + if (jmxFiles.isEmpty()) { + out.println("No .jmx files found under " + options.source); + return EXIT_OK; + } + int totalReplaced = 0; + for (Path jmxFile : jmxFiles) { + HashTree tree = JmxBlazeMeterHttpMigrator.loadTree(jmxFile.toFile()); + if (options.dryRun) { + int count = JmxBlazeMeterHttpMigrator.countMigratableSamplers(tree); + totalReplaced += count; + out.println(count + " sampler(s) would be migrated in " + jmxFile); + continue; + } + JmxBlazeMeterHttpMigrator.MigrationResult result = + JmxBlazeMeterHttpMigrator.migrateTreeWithDetails(tree); + File destination = options.inPlace + ? jmxFile.toFile() + : resolveBatchDestination(options.source.toPath(), jmxFile, options.target.toPath()); + Files.createDirectories(destination.getAbsoluteFile().getParentFile().toPath()); + JmxBlazeMeterHttpMigrator.saveTree(tree, destination); + totalReplaced += result.getReplacedCount(); + out.println(result.getReplacedCount() + " sampler(s) migrated: " + jmxFile + " -> " + + destination); + } + out.println((options.dryRun ? "Total would migrate: " : "Total migrated: ") + totalReplaced + + " sampler(s) across " + jmxFiles.size() + " file(s)"); + return EXIT_OK; + } + + private static File resolveBatchDestination(Path sourceDir, Path jmxFile, Path targetDir) { + Path relative = sourceDir.toAbsolutePath().relativize(jmxFile.toAbsolutePath()); + return targetDir.resolve(relative).toFile(); + } + + private static List findJmxFiles(Path dir) throws IOException { + List result = new ArrayList<>(); + try (Stream stream = Files.walk(dir)) { + stream.filter(Files::isRegularFile) + .filter(p -> p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jmx")) + .sorted() + .forEach(result::add); + } + return result; + } + + private static void printUsage(PrintStream out) { + out.println("Usage:"); + out.println(" migrator [--dry-run]"); + out.println(" migrator --in-place [--dry-run]"); + out.println(" migrator --out [--dry-run]"); + out.println(" migrator --in-place [--dry-run]"); + out.println(" migrator --help"); + out.println(); + out.println("Replaces stock JMeter HTTP Request samplers with the BlazeMeter HTTP"); + out.println("sampler, in a single .jmx file or recursively across a directory."); + out.println(); + out.println("Options:"); + out.println(" --in-place overwrite the source file(s) instead of writing elsewhere"); + out.println(" --out

write migrated files to , mirroring the source layout"); + out.println(" --dry-run report how many samplers would migrate, without writing"); + out.println(" --help show this message"); + } + + private static final class CliOptions { + private File source; + private File target; + private boolean inPlace; + private boolean dryRun; + private boolean help; + + static CliOptions parse(String[] args) { + CliOptions options = new CliOptions(); + List positional = new ArrayList<>(); + int i = 0; + while (i < args.length) { + String arg = args[i]; + i++; + switch (arg) { + case "--help": + case "-h": + options.help = true; + break; + case "--in-place": + options.inPlace = true; + break; + case "--dry-run": + options.dryRun = true; + break; + case "--out": + if (i >= args.length) { + throw new IllegalArgumentException("--out requires a directory argument"); + } + options.target = new File(args[i]); + i++; + break; + default: + positional.add(arg); + } + } + if (options.help) { + return options; + } + if (positional.isEmpty()) { + throw new IllegalArgumentException("Missing argument"); + } + options.source = new File(positional.get(0)); + if (positional.size() > 1) { + if (options.target != null) { + throw new IllegalArgumentException("Specify either a positional target or --out, " + + "not both"); + } + options.target = new File(positional.get(1)); + } + if (positional.size() > 2) { + throw new IllegalArgumentException("Unexpected extra argument: " + positional.get(2)); + } + if (options.inPlace && options.target != null) { + throw new IllegalArgumentException("--in-place cannot be combined with a target"); + } + if (!options.dryRun && !options.inPlace && options.target == null) { + throw new IllegalArgumentException("Specify a target, --in-place, or --dry-run"); + } + return options; + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/cli/JMeterCliEnvironmentTest.java b/src/test/java/com/blazemeter/jmeter/http2/cli/JMeterCliEnvironmentTest.java new file mode 100644 index 00000000..eeace6e5 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/cli/JMeterCliEnvironmentTest.java @@ -0,0 +1,42 @@ +package com.blazemeter.jmeter.http2.cli; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import java.io.File; +import java.nio.file.Path; +import org.junit.Test; + +public class JMeterCliEnvironmentTest { + + @Test + public void resolvesHomeWhenJarIsUnderLibExt() { + Path home = Path.of("jmeter-home", "apache-jmeter-5.6.3"); + File jar = home.resolve("lib").resolve("ext").resolve("jmeter-bzm-http2.jar").toFile(); + assertEquals(home.toFile().getAbsolutePath(), JMeterCliEnvironment.homeFromExtDirJar(jar)); + } + + @Test + public void resolvesHomeWhenExtDirCasingDiffers() { + Path home = Path.of("jmeter-home", "jmeter"); + File jar = home.resolve("LIB").resolve("EXT").resolve("plugin.jar").toFile(); + assertEquals(home.toFile().getAbsolutePath(), JMeterCliEnvironment.homeFromExtDirJar(jar)); + } + + @Test + public void returnsNullWhenNotUnderLibExt() { + File jar = Path.of("some-build-dir", "target", "classes").toFile(); + assertNull(JMeterCliEnvironment.homeFromExtDirJar(jar)); + } + + @Test + public void returnsNullForNullJarFile() { + assertNull(JMeterCliEnvironment.homeFromExtDirJar(null)); + } + + @Test + public void returnsNullWhenParentChainIsTooShort() { + File jar = Path.of("ext", "plugin.jar").toFile(); + assertNull(JMeterCliEnvironment.homeFromExtDirJar(jar)); + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigratorCliTest.java b/src/test/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigratorCliTest.java new file mode 100644 index 00000000..ec995baf --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigratorCliTest.java @@ -0,0 +1,194 @@ +package com.blazemeter.jmeter.http2.sampler; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.apache.jmeter.config.Arguments; +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerProxy; +import org.apache.jorphan.collections.HashTree; +import org.apache.jorphan.collections.ListedHashTree; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class JmxBlazeMeterHttpMigratorCliTest extends HTTP2TestBase { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + private File sampleJmx; + private File otherSampleJmx; + + private ByteArrayOutputStream outBuffer; + private ByteArrayOutputStream errBuffer; + private PrintStream out; + private PrintStream err; + + @Before + public void setUpStreams() { + outBuffer = new ByteArrayOutputStream(); + errBuffer = new ByteArrayOutputStream(); + out = new PrintStream(outBuffer, true, StandardCharsets.UTF_8); + err = new PrintStream(errBuffer, true, StandardCharsets.UTF_8); + } + + @Before + public void writeSampleFixtures() throws Exception { + sampleJmx = tempFolder.newFile("sample.jmx"); + JmxBlazeMeterHttpMigrator.saveTree(buildSamplePlan("first-call", "/a"), sampleJmx); + otherSampleJmx = tempFolder.newFile("other-sample.jmx"); + JmxBlazeMeterHttpMigrator.saveTree(buildSamplePlan("second-call", "/b"), otherSampleJmx); + } + + @Test + public void printsUsageAndSucceedsOnHelp() { + int exitCode = JmxBlazeMeterHttpMigratorCli.run(new String[] {"--help"}, out, err); + assertEquals(JmxBlazeMeterHttpMigratorCli.EXIT_OK, exitCode); + assertTrue(outBuffer.toString(StandardCharsets.UTF_8).contains("Usage:")); + } + + @Test + public void failsWithUsageErrorWhenNoArguments() { + int exitCode = JmxBlazeMeterHttpMigratorCli.run(new String[0], out, err); + assertEquals(JmxBlazeMeterHttpMigratorCli.EXIT_USAGE_ERROR, exitCode); + assertTrue(errBuffer.toString(StandardCharsets.UTF_8).contains("Missing")); + } + + @Test + public void failsWithUsageErrorWhenTargetAndInPlaceConflict() { + int exitCode = JmxBlazeMeterHttpMigratorCli.run( + new String[] {sampleJmx.getPath(), "out.jmx", "--in-place"}, out, err); + assertEquals(JmxBlazeMeterHttpMigratorCli.EXIT_USAGE_ERROR, exitCode); + } + + @Test + public void failsWithUsageErrorWhenNeitherTargetNorInPlaceNorDryRun() { + int exitCode = + JmxBlazeMeterHttpMigratorCli.run(new String[] {sampleJmx.getPath()}, out, err); + assertEquals(JmxBlazeMeterHttpMigratorCli.EXIT_USAGE_ERROR, exitCode); + } + + @Test + public void failsWithRuntimeErrorWhenSourceFileMissing() { + File missing = new File(tempFolder.getRoot(), "missing.jmx"); + int exitCode = + JmxBlazeMeterHttpMigratorCli.run( + new String[] {missing.getPath(), "--in-place"}, out, err); + assertEquals(JmxBlazeMeterHttpMigratorCli.EXIT_RUNTIME_ERROR, exitCode); + } + + @Test + public void migratesSingleFileToTarget() throws Exception { + File target = new File(tempFolder.getRoot(), "migrated.jmx"); + int exitCode = JmxBlazeMeterHttpMigratorCli.run( + new String[] {sampleJmx.getPath(), target.getPath()}, out, err); + assertEquals(JmxBlazeMeterHttpMigratorCli.EXIT_OK, exitCode); + assertTrue(target.isFile()); + + HashTree migrated = JmxBlazeMeterHttpMigrator.loadTree(target); + assertEquals(0, JmxBlazeMeterHttpMigrator.countMigratableSamplers(migrated)); + assertTrue(JmxBlazeMeterHttpMigrator.countHttp2Samplers(migrated) > 0); + + HashTree original = JmxBlazeMeterHttpMigrator.loadTree(sampleJmx); + assertTrue("source file must stay untouched", + JmxBlazeMeterHttpMigrator.countMigratableSamplers(original) > 0); + } + + @Test + public void migratesSingleFileInPlace() throws Exception { + File source = tempFolder.newFile("in-place.jmx"); + Files.copy(sampleJmx.toPath(), source.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + + int exitCode = JmxBlazeMeterHttpMigratorCli.run( + new String[] {source.getPath(), "--in-place"}, out, err); + assertEquals(JmxBlazeMeterHttpMigratorCli.EXIT_OK, exitCode); + + HashTree migrated = JmxBlazeMeterHttpMigrator.loadTree(source); + assertEquals(0, JmxBlazeMeterHttpMigrator.countMigratableSamplers(migrated)); + assertTrue(JmxBlazeMeterHttpMigrator.countHttp2Samplers(migrated) > 0); + } + + @Test + public void dryRunReportsCountWithoutWriting() throws Exception { + File source = tempFolder.newFile("dry-run.jmx"); + Files.copy(sampleJmx.toPath(), source.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + long beforeModified = source.lastModified(); + long beforeLength = source.length(); + + int exitCode = JmxBlazeMeterHttpMigratorCli.run( + new String[] {source.getPath(), "--dry-run"}, out, err); + assertEquals(JmxBlazeMeterHttpMigratorCli.EXIT_OK, exitCode); + assertTrue(outBuffer.toString(StandardCharsets.UTF_8).contains("would be migrated")); + assertEquals(beforeLength, source.length()); + assertEquals(beforeModified, source.lastModified()); + } + + @Test + public void migratesDirectoryRecursivelyToOutputDirectory() throws Exception { + File sourceDir = tempFolder.newFolder("source"); + File nested = new File(sourceDir, "nested"); + assertTrue(nested.mkdirs()); + Files.copy(sampleJmx.toPath(), new File(sourceDir, "sample.jmx").toPath()); + Files.copy(otherSampleJmx.toPath(), new File(nested, "other-sample.jmx").toPath()); + + File targetDir = new File(tempFolder.getRoot(), "target"); + int exitCode = JmxBlazeMeterHttpMigratorCli.run( + new String[] {sourceDir.getPath(), "--out", targetDir.getPath()}, out, err); + assertEquals(JmxBlazeMeterHttpMigratorCli.EXIT_OK, exitCode); + + File migratedSample = new File(targetDir, "sample.jmx"); + File migratedOther = new File(new File(targetDir, "nested"), "other-sample.jmx"); + assertTrue(migratedSample.isFile()); + assertTrue(migratedOther.isFile()); + assertTrue(JmxBlazeMeterHttpMigrator.countHttp2Samplers( + JmxBlazeMeterHttpMigrator.loadTree(migratedSample)) > 0); + assertTrue(outBuffer.toString(StandardCharsets.UTF_8).contains("Total migrated:")); + } + + @Test + public void dryRunOverDirectoryReportsTotalsWithoutWriting() throws Exception { + File sourceDir = tempFolder.newFolder("source-dry"); + Files.copy(sampleJmx.toPath(), new File(sourceDir, "sample.jmx").toPath()); + + int exitCode = JmxBlazeMeterHttpMigratorCli.run( + new String[] {sourceDir.getPath(), "--dry-run"}, out, err); + assertEquals(JmxBlazeMeterHttpMigratorCli.EXIT_OK, exitCode); + assertTrue(outBuffer.toString(StandardCharsets.UTF_8).contains("Total would migrate:")); + assertFalse(new File(sourceDir, "sample.jmx.bak").exists()); + } + + @Test + public void reportsWhenDirectoryHasNoJmxFiles() throws Exception { + File emptyDir = tempFolder.newFolder("empty"); + int exitCode = JmxBlazeMeterHttpMigratorCli.run( + new String[] {emptyDir.getPath(), "--in-place"}, out, err); + assertEquals(JmxBlazeMeterHttpMigratorCli.EXIT_OK, exitCode); + assertTrue(outBuffer.toString(StandardCharsets.UTF_8).contains("No .jmx files found")); + } + + private static HashTree buildSamplePlan(String samplerName, String path) { + HashTree tree = new ListedHashTree(); + HTTPSamplerProxy sampler = new HTTPSamplerProxy(); + sampler.setName(samplerName); + sampler.setDomain("example.org"); + sampler.setPath(path); + sampler.setMethod("GET"); + sampler.setArguments(new Arguments()); + // A real .jmx always has guiclass set (by the GUI when the element is created); + // SaveService.loadTree NPEs reading it back otherwise. + sampler.setProperty(org.apache.jmeter.testelement.TestElement.GUI_CLASS, + "org.apache.jmeter.protocol.http.control.gui.HttpTestSampleGui"); + tree.add(sampler); + return tree; + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigratorTest.java b/src/test/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigratorTest.java new file mode 100644 index 00000000..31cde804 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigratorTest.java @@ -0,0 +1,135 @@ +package com.blazemeter.jmeter.http2.sampler; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import java.io.File; +import org.apache.jmeter.config.Arguments; +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerProxy; +import org.apache.jmeter.testelement.TestElement; +import org.apache.jorphan.collections.HashTree; +import org.apache.jorphan.collections.ListedHashTree; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class JmxBlazeMeterHttpMigratorTest extends HTTP2TestBase { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + private File samplePlanJmx; + + @Before + public void writeSamplePlan() throws Exception { + samplePlanJmx = tempFolder.newFile("sample-plan.jmx"); + JmxBlazeMeterHttpMigrator.saveTree(buildSamplePlanWithTwoSamplers(), samplePlanJmx); + } + + @Test + public void migratesAllApacheHttpSamplersInSamplePlan() throws Exception { + HashTree tree = JmxBlazeMeterHttpMigrator.loadTree(samplePlanJmx); + int before = JmxBlazeMeterHttpMigrator.countMigratableSamplers(tree); + assertTrue(before > 0); + + JmxBlazeMeterHttpMigrator.MigrationResult result = + JmxBlazeMeterHttpMigrator.migrateTreeWithDetails(tree); + assertEquals(before, result.getReplacedCount()); + assertEquals(0, JmxBlazeMeterHttpMigrator.countMigratableSamplers(tree)); + assertEquals(before, JmxBlazeMeterHttpMigrator.countHttp2Samplers(tree)); + } + + @Test + public void preservesSamplerPropertiesDuringMigration() throws Exception { + HTTPSamplerProxy src = new HTTPSamplerProxy(); + src.setName("api-call"); + src.setDomain("example.org"); + src.setPort(443); + src.setPath("/v1/items"); + src.setMethod("POST"); + src.setFollowRedirects(true); + + HashTree tree = new ListedHashTree(); + tree.add(src); + + JmxBlazeMeterHttpMigrator.migrateTree(tree); + Object migrated = tree.list().iterator().next(); + assertTrue(migrated instanceof HTTP2Sampler); + HTTP2Sampler dest = (HTTP2Sampler) migrated; + assertEquals("example.org", dest.getDomain()); + assertEquals(443, dest.getPort()); + assertEquals("/v1/items", dest.getPath()); + assertEquals("POST", dest.getMethod()); + assertTrue(dest.getFollowRedirects()); + assertEquals(HTTP2Sampler.class.getName(), dest.getPropertyAsString(TestElement.TEST_CLASS)); + assertFalse(dest.getPropertyAsString(TestElement.GUI_CLASS).contains("HTTPSampler")); + } + + @Test + public void migrateCopyPreservesChildElements() throws Exception { + HTTPSamplerProxy parent = new HTTPSamplerProxy(); + parent.setName("parent"); + org.apache.jmeter.assertions.ResponseAssertion assertion = + new org.apache.jmeter.assertions.ResponseAssertion(); + assertion.setName("assert-ok"); + HashTree tree = new ListedHashTree(); + HashTree sub = tree.add(parent); + sub.add(assertion); + + HashTree migrated = JmxBlazeMeterHttpMigrator.migrateCopy(tree); + Object key = migrated.list().iterator().next(); + assertTrue(key instanceof HTTP2Sampler); + HashTree childTree = migrated.getTree(key); + assertEquals(1, childTree.list().size()); + assertTrue(childTree.list().iterator().next() + instanceof org.apache.jmeter.assertions.ResponseAssertion); + } + + @Test + public void migrateFileWritesNewJmx() throws Exception { + File target = tempFolder.newFile("migrated.jmx"); + JmxBlazeMeterHttpMigrator.migrateFile(samplePlanJmx, target); + + HashTree loaded = JmxBlazeMeterHttpMigrator.loadTree(target); + assertEquals(0, JmxBlazeMeterHttpMigrator.countMigratableSamplers(loaded)); + assertTrue(JmxBlazeMeterHttpMigrator.countHttp2Samplers(loaded) > 0); + } + + private static HashTree buildSamplePlanWithTwoSamplers() { + HashTree tree = new ListedHashTree(); + + HTTPSamplerProxy first = new HTTPSamplerProxy(); + first.setName("first-call"); + first.setDomain("example.org"); + first.setPath("/a"); + first.setMethod("GET"); + first.setArguments(new Arguments()); + setGuiClass(first); + tree.add(first); + + HTTPSamplerProxy second = new HTTPSamplerProxy(); + second.setName("second-call"); + second.setDomain("example.org"); + second.setPath("/b"); + second.setMethod("POST"); + second.setArguments(new Arguments()); + setGuiClass(second); + tree.add(second); + + return tree; + } + + /** + * A real .jmx always has {@code guiclass} on each element (set by the GUI when the element is + * created); {@code SaveService.loadTree} NPEs reading it back otherwise, since + * {@code TestElementConverter} passes the (then-null) {@code guiclass} XML attribute straight + * into a {@code Properties} lookup. + */ + private static void setGuiClass(TestElement element) { + element.setProperty(TestElement.GUI_CLASS, + "org.apache.jmeter.protocol.http.control.gui.HttpTestSampleGui"); + } +} From f3a90a41d81f3c0b328179a0bfd3843f04a92c50 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 17 Jul 2026 21:48:34 -0300 Subject: [PATCH 29/35] Default minimum to JMeter to 5.5 --- pom.xml | 6 +++--- .../HTTP2JettyClientFileProtocolSamplingTest.java | 11 ++++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 71087706..7735dcb4 100644 --- a/pom.xml +++ b/pom.xml @@ -30,7 +30,7 @@ UTF-8 UTF-8 - 5.4.1 + 5.5 12.1.11 1.23.0 @@ -50,11 +50,11 @@ provided - + org.apache.httpcomponents httpcore - 4.4.14 + 4.4.15 compile diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileProtocolSamplingTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileProtocolSamplingTest.java index 1c91e78e..58188a84 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileProtocolSamplingTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileProtocolSamplingTest.java @@ -9,6 +9,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import org.apache.commons.lang3.StringUtils; import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; import org.apache.jmeter.protocol.http.util.HTTPConstants; import org.junit.After; @@ -49,7 +50,15 @@ public void samplesLocalFileWithHardcodedGetMethodAndTextHtmlContentType() throw assertThat(sampled.isSuccessful()).isTrue(); assertThat(sampled.getResponseCode()).isEqualTo("200"); assertThat(sampled.getHTTPMethod()).isEqualTo(HTTPConstants.GET); - assertThat(sampled.getContentType()).isEqualTo("text/html"); + // HTTP2Sampler.getContentEncoding()'s default is JMeter-version-dependent: 5.6.3 added a + // hardcoded UTF-8 schema default (HTTPSamplerBaseSchema), while 5.5 (our minimum supported + // version) leaves it blank until explicitly set. Read the sampler's actual runtime default + // instead of hardcoding the literal so this test passes on both supported versions. + String defaultContentEncoding = new HTTP2Sampler().getContentEncoding(); + String expectedContentType = StringUtils.isBlank(defaultContentEncoding) + ? "text/html" + : "text/html; charset=" + defaultContentEncoding; + assertThat(sampled.getContentType()).isEqualTo(expectedContentType); assertThat(sampled.getResponseDataAsString()).isEqualTo(fileContent); } From 0f7c22a25c32e432cdcefefb3485fa99a5fba2fa Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 17 Jul 2026 21:52:56 -0300 Subject: [PATCH 30/35] Matrix JMeter 5.5 and 5.6.3 for compatibility tests --- .github/workflows/ci-build.yaml | 16 +++++++++++++--- .github/workflows/ci-jmeter-compatibility.yaml | 8 +++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-build.yaml b/.github/workflows/ci-build.yaml index 61391b10..f5b93863 100644 --- a/.github/workflows/ci-build.yaml +++ b/.github/workflows/ci-build.yaml @@ -10,6 +10,13 @@ concurrency: jobs: build: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 5.5 is the minimum supported JMeter version, 5.6.3 the latest; both are exercised + # end-to-end (unit + HTTP parity regression tests) since ApacheJMeter_* are provided-scope + # and the same compiled plugin must behave correctly against either at runtime. + jmeter_version: ["5.5", "5.6.3"] steps: - name: Checkout uses: actions/checkout@v5 @@ -21,14 +28,17 @@ jobs: java-version: "17" cache: maven - - name: Build and test - run: xvfb-run -a mvn -U --batch-mode clean install + - name: Build and test (JMeter ${{ matrix.jmeter_version }}) + run: >- + xvfb-run -a mvn -U --batch-mode + -Djmeter.version=${{ matrix.jmeter_version }} + clean install - name: Upload build artifacts if: always() uses: actions/upload-artifact@v6 with: - name: ci-build-artifacts-${{ github.run_id }} + name: ci-build-artifacts-${{ matrix.jmeter_version }}-${{ github.run_id }} path: | target/jmeter-bzm-http2-*.jar target/jmeter-test diff --git a/.github/workflows/ci-jmeter-compatibility.yaml b/.github/workflows/ci-jmeter-compatibility.yaml index b0170a7b..93a7f4fa 100644 --- a/.github/workflows/ci-jmeter-compatibility.yaml +++ b/.github/workflows/ci-jmeter-compatibility.yaml @@ -55,7 +55,13 @@ jobs: env: JMETER_PATH_BASE: ${{ github.workspace }}/.jmeter run: | - JMETER_VERSION=5.6.3 JMETER_PATH=$JMETER_PATH_BASE/5.6.3 sh testJMeter.sh + # Verify the built plugin jar loads and runs against every JMeter version we claim + # to support: 5.5 (minimum) through 5.6.3 (latest), regardless of which version the + # jar itself was compiled against (jmeter.version in pom.xml is provided-scope). + for JMETER_VERSION in 5.5 5.6.3; do + echo "=== Compatibility check against JMeter $JMETER_VERSION ===" + JMETER_VERSION=$JMETER_VERSION JMETER_PATH=$JMETER_PATH_BASE/$JMETER_VERSION sh testJMeter.sh + done - name: Upload compatibility artifacts on failure if: failure() From c8ab2441e7e7542fedf9917f1d59c3505fa8a72e Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 17 Jul 2026 22:18:57 -0300 Subject: [PATCH 31/35] Revert "Matrix JMeter 5.5 and 5.6.3 for compatibility tests" This reverts commit 0f7c22a25c32e432cdcefefb3485fa99a5fba2fa. --- .github/workflows/ci-build.yaml | 16 +++------------- .github/workflows/ci-jmeter-compatibility.yaml | 8 +------- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci-build.yaml b/.github/workflows/ci-build.yaml index f5b93863..61391b10 100644 --- a/.github/workflows/ci-build.yaml +++ b/.github/workflows/ci-build.yaml @@ -10,13 +10,6 @@ concurrency: jobs: build: runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - # 5.5 is the minimum supported JMeter version, 5.6.3 the latest; both are exercised - # end-to-end (unit + HTTP parity regression tests) since ApacheJMeter_* are provided-scope - # and the same compiled plugin must behave correctly against either at runtime. - jmeter_version: ["5.5", "5.6.3"] steps: - name: Checkout uses: actions/checkout@v5 @@ -28,17 +21,14 @@ jobs: java-version: "17" cache: maven - - name: Build and test (JMeter ${{ matrix.jmeter_version }}) - run: >- - xvfb-run -a mvn -U --batch-mode - -Djmeter.version=${{ matrix.jmeter_version }} - clean install + - name: Build and test + run: xvfb-run -a mvn -U --batch-mode clean install - name: Upload build artifacts if: always() uses: actions/upload-artifact@v6 with: - name: ci-build-artifacts-${{ matrix.jmeter_version }}-${{ github.run_id }} + name: ci-build-artifacts-${{ github.run_id }} path: | target/jmeter-bzm-http2-*.jar target/jmeter-test diff --git a/.github/workflows/ci-jmeter-compatibility.yaml b/.github/workflows/ci-jmeter-compatibility.yaml index 93a7f4fa..b0170a7b 100644 --- a/.github/workflows/ci-jmeter-compatibility.yaml +++ b/.github/workflows/ci-jmeter-compatibility.yaml @@ -55,13 +55,7 @@ jobs: env: JMETER_PATH_BASE: ${{ github.workspace }}/.jmeter run: | - # Verify the built plugin jar loads and runs against every JMeter version we claim - # to support: 5.5 (minimum) through 5.6.3 (latest), regardless of which version the - # jar itself was compiled against (jmeter.version in pom.xml is provided-scope). - for JMETER_VERSION in 5.5 5.6.3; do - echo "=== Compatibility check against JMeter $JMETER_VERSION ===" - JMETER_VERSION=$JMETER_VERSION JMETER_PATH=$JMETER_PATH_BASE/$JMETER_VERSION sh testJMeter.sh - done + JMETER_VERSION=5.6.3 JMETER_PATH=$JMETER_PATH_BASE/5.6.3 sh testJMeter.sh - name: Upload compatibility artifacts on failure if: failure() From fa1777917f54ba0c1844c7c0cfd8e3de524ba6f0 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 17 Jul 2026 22:18:57 -0300 Subject: [PATCH 32/35] Revert "Default minimum to JMeter to 5.5" This reverts commit f3a90a41d81f3c0b328179a0bfd3843f04a92c50. --- pom.xml | 6 +++--- .../HTTP2JettyClientFileProtocolSamplingTest.java | 11 +---------- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/pom.xml b/pom.xml index 7735dcb4..71087706 100644 --- a/pom.xml +++ b/pom.xml @@ -30,7 +30,7 @@ UTF-8 UTF-8 - 5.5 + 5.4.1 12.1.11 1.23.0 @@ -50,11 +50,11 @@ provided - + org.apache.httpcomponents httpcore - 4.4.15 + 4.4.14 compile diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileProtocolSamplingTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileProtocolSamplingTest.java index 58188a84..1c91e78e 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileProtocolSamplingTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileProtocolSamplingTest.java @@ -9,7 +9,6 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import org.apache.commons.lang3.StringUtils; import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; import org.apache.jmeter.protocol.http.util.HTTPConstants; import org.junit.After; @@ -50,15 +49,7 @@ public void samplesLocalFileWithHardcodedGetMethodAndTextHtmlContentType() throw assertThat(sampled.isSuccessful()).isTrue(); assertThat(sampled.getResponseCode()).isEqualTo("200"); assertThat(sampled.getHTTPMethod()).isEqualTo(HTTPConstants.GET); - // HTTP2Sampler.getContentEncoding()'s default is JMeter-version-dependent: 5.6.3 added a - // hardcoded UTF-8 schema default (HTTPSamplerBaseSchema), while 5.5 (our minimum supported - // version) leaves it blank until explicitly set. Read the sampler's actual runtime default - // instead of hardcoding the literal so this test passes on both supported versions. - String defaultContentEncoding = new HTTP2Sampler().getContentEncoding(); - String expectedContentType = StringUtils.isBlank(defaultContentEncoding) - ? "text/html" - : "text/html; charset=" + defaultContentEncoding; - assertThat(sampled.getContentType()).isEqualTo(expectedContentType); + assertThat(sampled.getContentType()).isEqualTo("text/html"); assertThat(sampled.getResponseDataAsString()).isEqualTo(fileContent); } From cc82a7c499331f8d4b9ae05dbdb86d0ad405e94c Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 17 Jul 2026 22:27:43 -0300 Subject: [PATCH 33/35] Set default minimum JMeter version to 5.5 for compatibility tests (#140) * Default minimum to JMeter to 5.5 * Matrix JMeter 5.5 and 5.6.3 for compatibility tests --- .github/workflows/ci-build.yaml | 16 +++++++++++++--- .github/workflows/ci-jmeter-compatibility.yaml | 8 +++++++- pom.xml | 6 +++--- ...HTTP2JettyClientFileProtocolSamplingTest.java | 11 ++++++++++- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-build.yaml b/.github/workflows/ci-build.yaml index 61391b10..f5b93863 100644 --- a/.github/workflows/ci-build.yaml +++ b/.github/workflows/ci-build.yaml @@ -10,6 +10,13 @@ concurrency: jobs: build: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 5.5 is the minimum supported JMeter version, 5.6.3 the latest; both are exercised + # end-to-end (unit + HTTP parity regression tests) since ApacheJMeter_* are provided-scope + # and the same compiled plugin must behave correctly against either at runtime. + jmeter_version: ["5.5", "5.6.3"] steps: - name: Checkout uses: actions/checkout@v5 @@ -21,14 +28,17 @@ jobs: java-version: "17" cache: maven - - name: Build and test - run: xvfb-run -a mvn -U --batch-mode clean install + - name: Build and test (JMeter ${{ matrix.jmeter_version }}) + run: >- + xvfb-run -a mvn -U --batch-mode + -Djmeter.version=${{ matrix.jmeter_version }} + clean install - name: Upload build artifacts if: always() uses: actions/upload-artifact@v6 with: - name: ci-build-artifacts-${{ github.run_id }} + name: ci-build-artifacts-${{ matrix.jmeter_version }}-${{ github.run_id }} path: | target/jmeter-bzm-http2-*.jar target/jmeter-test diff --git a/.github/workflows/ci-jmeter-compatibility.yaml b/.github/workflows/ci-jmeter-compatibility.yaml index b0170a7b..93a7f4fa 100644 --- a/.github/workflows/ci-jmeter-compatibility.yaml +++ b/.github/workflows/ci-jmeter-compatibility.yaml @@ -55,7 +55,13 @@ jobs: env: JMETER_PATH_BASE: ${{ github.workspace }}/.jmeter run: | - JMETER_VERSION=5.6.3 JMETER_PATH=$JMETER_PATH_BASE/5.6.3 sh testJMeter.sh + # Verify the built plugin jar loads and runs against every JMeter version we claim + # to support: 5.5 (minimum) through 5.6.3 (latest), regardless of which version the + # jar itself was compiled against (jmeter.version in pom.xml is provided-scope). + for JMETER_VERSION in 5.5 5.6.3; do + echo "=== Compatibility check against JMeter $JMETER_VERSION ===" + JMETER_VERSION=$JMETER_VERSION JMETER_PATH=$JMETER_PATH_BASE/$JMETER_VERSION sh testJMeter.sh + done - name: Upload compatibility artifacts on failure if: failure() diff --git a/pom.xml b/pom.xml index 71087706..7735dcb4 100644 --- a/pom.xml +++ b/pom.xml @@ -30,7 +30,7 @@ UTF-8 UTF-8 - 5.4.1 + 5.5 12.1.11 1.23.0 @@ -50,11 +50,11 @@ provided - + org.apache.httpcomponents httpcore - 4.4.14 + 4.4.15 compile diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileProtocolSamplingTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileProtocolSamplingTest.java index 1c91e78e..58188a84 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileProtocolSamplingTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientFileProtocolSamplingTest.java @@ -9,6 +9,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import org.apache.commons.lang3.StringUtils; import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; import org.apache.jmeter.protocol.http.util.HTTPConstants; import org.junit.After; @@ -49,7 +50,15 @@ public void samplesLocalFileWithHardcodedGetMethodAndTextHtmlContentType() throw assertThat(sampled.isSuccessful()).isTrue(); assertThat(sampled.getResponseCode()).isEqualTo("200"); assertThat(sampled.getHTTPMethod()).isEqualTo(HTTPConstants.GET); - assertThat(sampled.getContentType()).isEqualTo("text/html"); + // HTTP2Sampler.getContentEncoding()'s default is JMeter-version-dependent: 5.6.3 added a + // hardcoded UTF-8 schema default (HTTPSamplerBaseSchema), while 5.5 (our minimum supported + // version) leaves it blank until explicitly set. Read the sampler's actual runtime default + // instead of hardcoding the literal so this test passes on both supported versions. + String defaultContentEncoding = new HTTP2Sampler().getContentEncoding(); + String expectedContentType = StringUtils.isBlank(defaultContentEncoding) + ? "text/html" + : "text/html; charset=" + defaultContentEncoding; + assertThat(sampled.getContentType()).isEqualTo(expectedContentType); assertThat(sampled.getResponseDataAsString()).isEqualTo(fileContent); } From 51c99d1a4b6342ed6d30778fc67dee7a27ffd927 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Sat, 18 Jul 2026 12:41:36 -0300 Subject: [PATCH 34/35] Cache Test: Restore original settings on exception. (#141) --- .../http2/core/HTTP2JettyClientTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java index 8142961f..6b515b96 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java @@ -1003,11 +1003,13 @@ public void shouldNotGetSubResultWhenResourceIsCachedWithCode() throws Exception sampler.setImageParser(true); String message = "message"; String responseCode = "300"; + String previousCacheMode = JMeterUtils.getProperty("cache_manager.cached_resource_mode"); JMeterUtils.setProperty("cache_manager.cached_resource_mode", "RETURN_CUSTOM_STATUS"); JMeterUtils.setProperty("RETURN_CUSTOM_STATUS.message", message); JMeterUtils.setProperty("RETURN_CUSTOM_STATUS.code", responseCode); JmeterCachedResourceModeSupport.refreshSnapshotFromProperties(); configureCacheManagerToSampler(true, false); + try { HTTPSampleResult firstRequestExpected = buildResult(true, Code.OK, hostHeader(), null, null, createURL(SERVER_PATH_200_EMBEDDED), HTTPConstants.GET); firstRequestExpected.setResponseData(BASIC_HTML_TEMPLATE, StandardCharsets.UTF_8.name()); @@ -1020,6 +1022,9 @@ public void shouldNotGetSubResultWhenResourceIsCachedWithCode() throws Exception firstRequestExpected.setSentBytes(0); firstRequestExpected.setResponseData("", StandardCharsets.UTF_8.name()); validateEmbeddedResultCached(sampleWithGet(SERVER_PATH_200_EMBEDDED), firstRequestExpected); + } finally { + restoreCacheResourceMode(previousCacheMode); + } } /** @@ -1035,10 +1040,12 @@ public void shouldNotGetSubResultWhenResourceIsCachedWithoutCode() throws Except buildStartedServer(); sampler.setImageParser(true); String message = "message"; + String previousCacheMode = JMeterUtils.getProperty("cache_manager.cached_resource_mode"); JMeterUtils.setProperty("cache_manager.cached_resource_mode", "RETURN_200_CACHE"); JMeterUtils.setProperty("RETURN_200_CACHE.message", message); JmeterCachedResourceModeSupport.refreshSnapshotFromProperties(); configureCacheManagerToSampler(true, false); + try { // First request must connect to the server HTTPSampleResult expected = buildResult(true, Code.OK, hostHeader(), null, null, createURL(SERVER_PATH_200_EMBEDDED), HTTPConstants.GET); @@ -1050,6 +1057,17 @@ public void shouldNotGetSubResultWhenResourceIsCachedWithoutCode() throws Except expected.setResponseData("", StandardCharsets.UTF_8.name()); expected.setResponseMessage(message); validateEmbeddedResultCached(sampleWithGet(SERVER_PATH_200_EMBEDDED), expected); + } finally { + restoreCacheResourceMode(previousCacheMode); + } + } + + private void restoreCacheResourceMode(String previousCacheMode) { + if (previousCacheMode == null) { + JMeterUtils.getJMeterProperties().remove("cache_manager.cached_resource_mode"); + } else { + JMeterUtils.setProperty("cache_manager.cached_resource_mode", previousCacheMode); + } } @Test From 16d63d728d94d8735822b40c3465f3464b3bc51d Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Sat, 18 Jul 2026 19:11:38 -0300 Subject: [PATCH 35/35] RFC 9110 Redirection and Legacy JMeter Redirect (#142) --- .../jmeter/http2/core/HTTP2JettyClient.java | 8 +- .../jmeter/http2/sampler/HTTP2Sampler.java | 111 +++++++++++++++ .../jmeter/http2/util/Rfc9110Redirects.java | 40 ++++++ .../http2/core/HTTP2JettyClientTest.java | 131 ++++++++++++++++++ .../jmeter/http2/core/ServerBuilder.java | 22 +++ 5 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/blazemeter/jmeter/http2/util/Rfc9110Redirects.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index 007c3012..0fafdb9a 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -8,6 +8,7 @@ import com.blazemeter.jmeter.http2.core.jetty.custom.http3.CustomClientConnectionFactoryOverHTTP3; import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; import com.blazemeter.jmeter.http2.util.BzmHttpPluginProperties; +import com.blazemeter.jmeter.http2.util.Rfc9110Redirects; import com.github.luben.zstd.ZstdInputStream; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -4022,7 +4023,12 @@ private void setResultContentResponse(HTTPSampleResult result, result.setSuccessful( contentResponse.getStatus() >= 200 && contentResponse.getStatus() <= 399); result.setResponseHeaders(extractResponseHeaders(contentResponse, responseMessage)); - if (result.isRedirect()) { + // Use the RFC 9110-correct redirect check (see Rfc9110Redirects), not result.isRedirect(): + // JMeter 5.6.3's version misses 307 for non-GET/HEAD methods, which would otherwise leave + // redirectLocation unset and break HTTP2Sampler.followRedirects()/resultProcessing() for + // that case. + if (Rfc9110Redirects.useLegacyMethodHandling() ? result.isRedirect() + : Rfc9110Redirects.isRedirect(result.getResponseCode())) { result.setRedirectLocation(extractRedirectLocation(contentResponse)); } diff --git a/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java b/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java index 7888aa9e..b1941550 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java +++ b/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java @@ -10,9 +10,11 @@ import com.blazemeter.jmeter.http2.core.JmeterHttpClientExceptionMapper; import com.blazemeter.jmeter.http2.core.ProtocolErrorException; import com.blazemeter.jmeter.http2.util.BzmHttpPluginProperties; +import com.blazemeter.jmeter.http2.util.Rfc9110Redirects; import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.LoadingCache; import com.helger.commons.annotation.VisibleForTesting; +import java.io.IOException; import java.lang.reflect.Field; import java.net.MalformedURLException; import java.net.URISyntaxException; @@ -757,11 +759,120 @@ private HTTP2JettyClient getClient() throws Exception { : buildClient(); } + /** + * JMeter 5.6.3's {@code HTTPSampleResult.isRedirect()} only recognizes a {@code 307} as a + * redirect for GET/HEAD, so a POST/PUT/PATCH/DELETE + 307 is returned as-is by the inherited + * {@code resultProcessing()} - {@link #followRedirects} below is never even reached. See + * {@link Rfc9110Redirects} for the fix this ports (apache/jmeter PR #6658) and why it's applied + * this way instead of overriding {@code HTTPSampleResult.isRedirect()} directly. This drives + * the follow-up itself only for that one gap, then hands off to the inherited + * {@code resultProcessing()} (marking {@code areFollowingRedirect=true}) for everything else + * (embedded resources, etc.) exactly as it would normally run. Falls back to the unfixed + * inherited behavior when {@link Rfc9110Redirects#useLegacyMethodHandling()}. + */ + @Override public HTTPSampleResult resultProcessing(final boolean pAreFollowingRedirect, final int frameDepth, final HTTPSampleResult pRes) { + if (!Rfc9110Redirects.useLegacyMethodHandling() + && !pAreFollowingRedirect + && !pRes.isRedirect() + && Rfc9110Redirects.isRedirect(pRes.getResponseCode()) + && getFollowRedirects()) { + HTTPSampleResult followed = followRedirects(pRes, frameDepth); + return super.resultProcessing(true, frameDepth, followed); + } return super.resultProcessing(pAreFollowingRedirect, frameDepth, pRes); } + /** + * Ported from {@code HTTPSamplerBase.followRedirects} (JMeter 5.6.3) with the fix from + * apache/jmeter PR #6658 (see {@link Rfc9110Redirects} and {@link #resultProcessing}): + * {@code computeMethodForRedirect} now takes the response code into account so 307/308 + * preserve the original method (RFC 9110 sections 15.4.8/15.4.9), instead of always rewriting + * to GET like 301/302/303 do. Uses {@link Rfc9110Redirects#isRedirect} instead of + * {@code HTTPSampleResult.isRedirect()} to decide whether to keep following a redirect chain, + * for the same reason {@link #resultProcessing} avoids relying on that method for 307. Falls + * back to the inherited, unfixed JMeter behavior when + * {@link Rfc9110Redirects#useLegacyMethodHandling()}. + */ + @Override + protected HTTPSampleResult followRedirects(HTTPSampleResult res, int frameDepth) { + if (Rfc9110Redirects.useLegacyMethodHandling()) { + return super.followRedirects(res, frameDepth); + } + HTTPSampleResult totalRes = new HTTPSampleResult(res); + totalRes.addRawSubResult(res); + HTTPSampleResult lastRes = res; + + int redirect; + for (redirect = 0; redirect < MAX_REDIRECTS; redirect++) { + boolean invalidRedirectUrl = false; + String location = lastRes.getRedirectLocation(); + if (JMeterUtils.getPropDefault("httpsampler.redirect.removeslashdotdot", true)) { + location = ConversionUtils.removeSlashDotDot(location); + } + location = encodeSpaces(location); + String method = computeMethodForRedirect(lastRes.getHTTPMethod(), lastRes.getResponseCode()); + + try { + URL url = ConversionUtils.makeRelativeURL(lastRes.getURL(), location); + url = ConversionUtils.sanitizeUrl(url).toURL(); + HTTPSampleResult tempRes = sample(url, method, true, frameDepth); + if (tempRes != null) { + lastRes = tempRes; + } else { + break; + } + } catch (MalformedURLException | URISyntaxException e) { + errorResult(e, lastRes); + invalidRedirectUrl = true; + } + if (lastRes.getSubResults() != null && lastRes.getSubResults().length > 0) { + for (SampleResult sub : lastRes.getSubResults()) { + totalRes.addSubResult(sub); + } + } else if (!invalidRedirectUrl) { + totalRes.addSubResult(lastRes); + } + + if (!Rfc9110Redirects.isRedirect(lastRes.getResponseCode())) { + break; + } + } + if (redirect >= MAX_REDIRECTS) { + lastRes = errorResult( + new IOException("Exceeded maximum number of redirects: " + MAX_REDIRECTS), + new HTTPSampleResult(lastRes)); + totalRes.addSubResult(lastRes); + } + + totalRes.setSampleLabel(totalRes.getSampleLabel() + "->" + lastRes.getSampleLabel()); + totalRes.setURL(lastRes.getURL()); + totalRes.setHTTPMethod(lastRes.getHTTPMethod()); + totalRes.setQueryString(lastRes.getQueryString()); + totalRes.setRequestHeaders(lastRes.getRequestHeaders()); + totalRes.setResponseData(lastRes.getResponseData()); + totalRes.setResponseCode(lastRes.getResponseCode()); + totalRes.setSuccessful(lastRes.isSuccessful()); + totalRes.setResponseMessage(lastRes.getResponseMessage()); + totalRes.setDataType(lastRes.getDataType()); + totalRes.setResponseHeaders(lastRes.getResponseHeaders()); + totalRes.setContentType(lastRes.getContentType()); + totalRes.setDataEncoding(lastRes.getDataEncodingNoDefault()); + return totalRes; + } + + private String computeMethodForRedirect(String initialMethod, String responseCode) { + if (HTTPConstants.SC_TEMPORARY_REDIRECT.equals(responseCode) + || HTTPConstants.SC_PERMANENT_REDIRECT.equals(responseCode)) { + return initialMethod; + } + if (!HTTPConstants.HEAD.equalsIgnoreCase(initialMethod)) { + return HTTPConstants.GET; + } + return initialMethod; + } + static void registerParser(String contentType, String className) { LOG.info("Parser for {} is {}", contentType, className); PARSERS_FOR_CONTENT_TYPE.put(contentType, className); diff --git a/src/main/java/com/blazemeter/jmeter/http2/util/Rfc9110Redirects.java b/src/main/java/com/blazemeter/jmeter/http2/util/Rfc9110Redirects.java new file mode 100644 index 00000000..a5411d73 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/util/Rfc9110Redirects.java @@ -0,0 +1,40 @@ +package com.blazemeter.jmeter.http2.util; + +import org.apache.jmeter.protocol.http.util.HTTPConstants; + +/** + * Shared by {@code HTTP2Sampler} and {@code HTTP2JettyClient}: JMeter 5.6.3's + * {@code HTTPSampleResult.isRedirect()} only recognizes a {@code 307} response as a redirect + * when the request method is GET or HEAD, and {@code HTTPSamplerBase.computeMethodForRedirect} + * rewrites every redirected method to GET regardless of status code - both violate RFC 9110 + * sections 15.4.8/15.4.9, which require 307/308 to preserve the original method (and therefore + * its body). Apache JMeter fixed this upstream in + * apache/jmeter PR #6658 (merged + * 2026-03-19), but that fix is not in 5.6.3 or any released JMeter version yet. + * + *

Set {@code -Dblazemeter.http.legacyRedirectMethodHandling=true} (or the legacy + * {@code HTTP2Sampler.*}/{@code httpJettyClient.*} equivalents) to opt back into JMeter 5.6.3's + * original behavior, bug included, for users who need byte-for-byte parity with stock JMeter. + */ +public final class Rfc9110Redirects { + + private static final String LEGACY_REDIRECT_METHOD_HANDLING_PROPERTY = + "blazemeter.http.legacyRedirectMethodHandling"; + + private Rfc9110Redirects() { + } + + public static boolean useLegacyMethodHandling() { + return BzmHttpPluginProperties.getPropDefault(LEGACY_REDIRECT_METHOD_HANDLING_PROPERTY, false); + } + + /** Matches {@code HTTPSampleResult.isRedirect()} post apache/jmeter PR #6658: unlike the + * unfixed JMeter 5.6.3 version, 307 is a redirect for any method, not just GET/HEAD. */ + public static boolean isRedirect(String responseCode) { + return HTTPConstants.SC_MOVED_PERMANENTLY.equals(responseCode) + || HTTPConstants.SC_MOVED_TEMPORARILY.equals(responseCode) + || HTTPConstants.SC_SEE_OTHER.equals(responseCode) + || HTTPConstants.SC_TEMPORARY_REDIRECT.equals(responseCode) + || HTTPConstants.SC_PERMANENT_REDIRECT.equals(responseCode); + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java index 6b515b96..647d97ce 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java @@ -19,6 +19,9 @@ import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_200_ZSTD; import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_200_WITH_BODY; import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_302; +import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_302_TO_ECHO; +import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_307_TO_ECHO; +import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_308_TO_ECHO; import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_400; import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_401_NO_WWW_AUTHENTICATE; import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_BIG_RESPONSE; @@ -226,6 +229,134 @@ public void shouldFollowRedirectWhenHeadMethodAndFollowRedirectEnabled() throws softly.assertThat(result.getResponseDataAsString()).isEmpty(); } + @Test + public void shouldNotResendRequestBodyWhenPostRedirectIsFollowedAsGet() throws Exception { + buildStartedServer(); + sampler.setMethod(HTTPConstants.POST); + sampler.setFollowRedirects(true); + sampler.addArgument("test1", TEST_ARGUMENT_1); + sampler.addArgument("test2", TEST_ARGUMENT_2); + + HTTPSampleResult result = sample(SERVER_PATH_302_TO_ECHO, HTTPConstants.POST); + + softly.assertThat(result.isSuccessful()).isTrue(); + softly.assertThat(result.getResponseCode()).isEqualTo("200"); + SampleResult[] subResults = result.getSubResults(); + softly.assertThat(subResults.length).isGreaterThan(0); + HTTPSampleResult followUp = (HTTPSampleResult) subResults[subResults.length - 1]; + softly.assertThat(followUp.getHTTPMethod()).isEqualTo(HTTPConstants.GET); + // SERVER_PATH_302_TO_ECHO redirects to SERVER_PATH_200_WITH_BODY, which echoes back + // any request body it receives. JMeter core rewrites POST to GET when following a + // 302, so the follow-up request must not carry the original POST entity (RFC 9110 + // section 15.4.3; matches HTTPSamplerProxy/HttpClient4 behavior). + softly.assertThat(followUp.getResponseDataAsString()).isEmpty(); + } + + @Test + public void shouldPreserveRequestBodyWhenPostRedirectIsFollowedAs307ViaAutoRedirects() + throws Exception { + // Unlike 301/302/303, RFC 9110 section 15.4.8 requires a 307 redirect to preserve the + // original method and body. With autoRedirects enabled, Jetty's own redirect support + // handles the follow-up (not JMeter's manual loop, see the *ViaFollowRedirectsOnly test + // below), and already implements this correctly - this test locks that in. + buildStartedServer(); + sampler.setMethod(HTTPConstants.POST); + sampler.setFollowRedirects(true); + sampler.setAutoRedirects(true); + sampler.addArgument("test1", TEST_ARGUMENT_1); + sampler.addArgument("test2", TEST_ARGUMENT_2); + + HTTPSampleResult result = sample(SERVER_PATH_307_TO_ECHO, HTTPConstants.POST); + + softly.assertThat(result.isSuccessful()).isTrue(); + softly.assertThat(result.getResponseCode()).isEqualTo("200"); + softly.assertThat(result.getResponseDataAsString()) + .isEqualTo("test1=" + TEST_ARGUMENT_1 + "&test2=" + TEST_ARGUMENT_2); + } + + @Test + public void shouldPreserveRequestBodyWhenPostRedirectIsFollowedAs307ViaFollowRedirectsOnly() + throws Exception { + // With autoRedirects OFF, JMeter's own manual follow-redirects loop drives this instead of + // Jetty. Apache JMeter got this wrong until apache/jmeter PR #6658 (merged 2026-03-19, not + // yet in the 5.6.3 release this project targets): HTTPSampleResult.isRedirect() only + // recognized 307 as a redirect for GET/HEAD (a POST+307 was returned as-is, never followed), + // and HTTPSamplerBase.computeMethodForRedirect() rewrote every redirected method to GET + // regardless of status code, dropping the body. HTTP2Sampler.resultProcessing() and + // .followRedirects() port JMeter's own logic with that upstream fix applied, so this path + // is correct even against JMeter 5.6.3's own (buggy) inherited implementation. + buildStartedServer(); + sampler.setMethod(HTTPConstants.POST); + sampler.setFollowRedirects(true); + sampler.setAutoRedirects(false); + sampler.addArgument("test1", TEST_ARGUMENT_1); + sampler.addArgument("test2", TEST_ARGUMENT_2); + + HTTPSampleResult result = sample(SERVER_PATH_307_TO_ECHO, HTTPConstants.POST); + + softly.assertThat(result.isSuccessful()).isTrue(); + softly.assertThat(result.getResponseCode()).isEqualTo("200"); + HTTPSampleResult followUp = + (HTTPSampleResult) result.getSubResults()[result.getSubResults().length - 1]; + softly.assertThat(followUp.getHTTPMethod()).isEqualTo(HTTPConstants.POST); + softly.assertThat(followUp.getResponseDataAsString()) + .isEqualTo("test1=" + TEST_ARGUMENT_1 + "&test2=" + TEST_ARGUMENT_2); + } + + @Test + public void shouldPreserveRequestBodyWhenPostRedirectIsFollowedAs308ViaFollowRedirectsOnly() + throws Exception { + // Same fix as the 307 case above (RFC 9110 section 15.4.9): unlike 307, JMeter 5.6.3's + // isRedirect() already recognized 308 for any method, but computeMethodForRedirect() still + // rewrote it to GET, dropping the body. + buildStartedServer(); + sampler.setMethod(HTTPConstants.POST); + sampler.setFollowRedirects(true); + sampler.setAutoRedirects(false); + sampler.addArgument("test1", TEST_ARGUMENT_1); + sampler.addArgument("test2", TEST_ARGUMENT_2); + + HTTPSampleResult result = sample(SERVER_PATH_308_TO_ECHO, HTTPConstants.POST); + + softly.assertThat(result.isSuccessful()).isTrue(); + softly.assertThat(result.getResponseCode()).isEqualTo("200"); + HTTPSampleResult followUp = + (HTTPSampleResult) result.getSubResults()[result.getSubResults().length - 1]; + softly.assertThat(followUp.getHTTPMethod()).isEqualTo(HTTPConstants.POST); + softly.assertThat(followUp.getResponseDataAsString()) + .isEqualTo("test1=" + TEST_ARGUMENT_1 + "&test2=" + TEST_ARGUMENT_2); + } + + @Test + public void shouldMatchJMeter563OriginalBehaviorWhenLegacyRedirectMethodHandlingEnabled() + throws Exception { + // Opt-out flag for users who need byte-for-byte parity with stock JMeter 5.6.3, bug + // included: a POST+307 is returned as-is (never followed), matching HTTPSampleResult + // .isRedirect()'s unfixed behavior for that status code. + String property = "blazemeter.http.legacyRedirectMethodHandling"; + String previous = JMeterUtils.getProperty(property); + JMeterUtils.setProperty(property, "true"); + try { + buildStartedServer(); + sampler.setMethod(HTTPConstants.POST); + sampler.setFollowRedirects(true); + sampler.setAutoRedirects(false); + sampler.addArgument("test1", TEST_ARGUMENT_1); + sampler.addArgument("test2", TEST_ARGUMENT_2); + + HTTPSampleResult result = sample(SERVER_PATH_307_TO_ECHO, HTTPConstants.POST); + + softly.assertThat(result.getResponseCode()).isEqualTo("307"); + softly.assertThat(result.getSubResults().length).isEqualTo(0); + } finally { + if (previous == null) { + JMeterUtils.getJMeterProperties().remove(property); + } else { + JMeterUtils.setProperty(property, previous); + } + } + } + private void buildStartedServer() throws Exception { server = new ServerBuilder() .withHTTP2() diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java b/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java index 28cb5015..8b9c0da8 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java @@ -87,6 +87,13 @@ public class ServerBuilder { */ public static final String SERVER_PATH_401_NO_WWW_AUTHENTICATE = "/test/401-no-www-authenticate"; public static final String SERVER_PATH_302 = "/test/302"; + /** Redirects to {@link #SERVER_PATH_200_WITH_BODY}, which echoes back whatever body it received. */ + public static final String SERVER_PATH_302_TO_ECHO = "/test/302-to-echo"; + /** Same as {@link #SERVER_PATH_302_TO_ECHO} but with a 307, which RFC 9110 requires to + * preserve the original method and body (unlike 301/302/303). */ + public static final String SERVER_PATH_307_TO_ECHO = "/test/307-to-echo"; + /** Same as {@link #SERVER_PATH_307_TO_ECHO} but with a 308 (also method/body-preserving). */ + public static final String SERVER_PATH_308_TO_ECHO = "/test/308-to-echo"; public static final String SERVER_PATH_200_WITH_BODY = "/test/body"; public static final String SERVER_PATH_JSON_ONLY = "/test/json-only"; public static final String SERVER_PATH_DELETE_DATA = "/test/delete"; @@ -325,6 +332,21 @@ protected void service(HttpServletRequest req, HttpServletResponse resp) "https://localhost:" + req.getLocalPort() + SERVER_PATH_200); resp.setStatus(HttpStatus.FOUND_302); break; + case SERVER_PATH_302_TO_ECHO: + resp.addHeader(HTTPConstants.HEADER_LOCATION, + "https://localhost:" + req.getLocalPort() + SERVER_PATH_200_WITH_BODY); + resp.setStatus(HttpStatus.FOUND_302); + break; + case SERVER_PATH_307_TO_ECHO: + resp.addHeader(HTTPConstants.HEADER_LOCATION, + "https://localhost:" + req.getLocalPort() + SERVER_PATH_200_WITH_BODY); + resp.setStatus(HttpStatus.TEMPORARY_REDIRECT_307); + break; + case SERVER_PATH_308_TO_ECHO: + resp.addHeader(HTTPConstants.HEADER_LOCATION, + "https://localhost:" + req.getLocalPort() + SERVER_PATH_200_WITH_BODY); + resp.setStatus(HttpStatus.PERMANENT_REDIRECT_308); + break; case SERVER_PATH_200_WITH_BODY: String bodyRequest = req.getReader().lines().collect(Collectors.joining()); resp.getWriter().write(bodyRequest);