diff --git a/.github/workflows/maven-verify.yml b/.github/workflows/maven-verify.yml index 112a68ee43..f9b104c56b 100644 --- a/.github/workflows/maven-verify.yml +++ b/.github/workflows/maven-verify.yml @@ -30,4 +30,4 @@ jobs: ff-site-run: false maven4-enabled: true verify-fail-fast: false - jdk-matrix: '[ "21", "25" ]' + jdk-matrix: '[ "21", "25", "26" ]' diff --git a/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java b/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java index d9d9ce391d..97af6cb179 100644 --- a/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java +++ b/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java @@ -514,6 +514,39 @@ public final class ConfigurationProperties { */ public static final String HTTPS_SECURITY_MODE_INSECURE = "insecure"; + public enum HttpVersion { + /** + * The default HTTP version supported by the respective transporter (the most recent stable one, usually HTTP/2) + */ + DEFAULT, + HTTP_1_1, + HTTP_2, + HTTP_3, + /** + * The maximum HTTP version supported by the respective transporter (may be unstable). + */ + MAXIMUM; + } + + /** + * The maximum and preferred HTTP version. It transparently falls back to lower version if remote server does not support it. + * Value must be a {@link HttpVersion} enum value or its String representation. Default is {@link #DEFAULT_HTTP_VERSION}. + * + * @configurationSource {@link RepositorySystemSession#getConfigProperties()} + * @configurationType {@link ConfigurationProperties.HttpVersion} + * @configurationDefaultValue {@link #DEFAULT_HTTP_VERSION} + * @configurationRepoIdSuffix Yes + * @since NEXT + */ + public static final String HTTP_VERSION = PREFIX_TRANSPORT_HTTP + "version"; + + /** + * Default value if {@link #HTTP_VERSION} is not set. + * + * @since NEXT + */ + public static final HttpVersion DEFAULT_HTTP_VERSION = HttpVersion.DEFAULT; + /** * A flag indicating which visitor should be used to "flatten" the dependency graph into list. In Maven 4 * the default is new "levelOrder", while Maven 3 used "preOrder". This property accepts values diff --git a/maven-resolver-demos/maven-resolver-demo-snippets/pom.xml b/maven-resolver-demos/maven-resolver-demo-snippets/pom.xml index 2da27f51ab..d77ae4ebad 100644 --- a/maven-resolver-demos/maven-resolver-demo-snippets/pom.xml +++ b/maven-resolver-demos/maven-resolver-demo-snippets/pom.xml @@ -53,7 +53,6 @@ - org.apache.maven.resolver @@ -147,4 +146,28 @@ test + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + enforce-bytecode-version + + + + + + org.eclipse.jetty.quic:jetty-quic-quiche-foreign + + + + + + + + + diff --git a/maven-resolver-test-http/pom.xml b/maven-resolver-test-http/pom.xml index 6845de4442..9521f6c748 100644 --- a/maven-resolver-test-http/pom.xml +++ b/maven-resolver-test-http/pom.xml @@ -77,6 +77,19 @@ org.eclipse.jetty.http2 jetty-http2-server + + org.eclipse.jetty.http3 + jetty-http3-server + + + org.eclipse.jetty.quic + jetty-quic-quiche-server + + + + org.eclipse.jetty.quic + jetty-quic-quiche-jna + org.eclipse.jetty.compression jetty-compression-server diff --git a/maven-resolver-test-http/src/main/java/org/eclipse/aether/internal/test/util/http/HttpServer.java b/maven-resolver-test-http/src/main/java/org/eclipse/aether/internal/test/util/http/HttpServer.java index ecffdd118a..6e5b3c86cd 100644 --- a/maven-resolver-test-http/src/main/java/org/eclipse/aether/internal/test/util/http/HttpServer.java +++ b/maven-resolver-test-http/src/main/java/org/eclipse/aether/internal/test/util/http/HttpServer.java @@ -51,12 +51,17 @@ import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpMethod; import org.eclipse.jetty.http.HttpURI; +import org.eclipse.jetty.http.HttpVersion; import org.eclipse.jetty.http.pathmap.MatchedResource; import org.eclipse.jetty.http.pathmap.PathMappings; import org.eclipse.jetty.http.pathmap.PathSpec; import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory; +import org.eclipse.jetty.http3.server.HTTP3ServerConnectionFactory; +import org.eclipse.jetty.http3.server.HTTP3ServerQuicConfiguration; import org.eclipse.jetty.io.ByteBufferPool; import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.quic.quiche.server.QuicheServerConnector; +import org.eclipse.jetty.quic.quiche.server.QuicheServerQuicConfiguration; import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.HttpConfiguration; import org.eclipse.jetty.server.HttpConnectionFactory; @@ -75,6 +80,7 @@ public class HttpServer { public static class LogEntry { + private final HttpVersion version; private final String method; @@ -86,12 +92,17 @@ public static class LogEntry { CountDownLatch responseHeadersAvailableSignal = new CountDownLatch(1); - public LogEntry(String method, String path, Map requestHeaders) { + public LogEntry(HttpVersion version, String method, String path, Map requestHeaders) { + this.version = version; this.method = method; this.path = path; this.requestHeaders = requestHeaders; } + public HttpVersion getVersion() { + return version; + } + public String getMethod() { return method; } @@ -127,7 +138,7 @@ public void setResponseHeaders(Map responseHeaders) { @Override public String toString() { - return method + " " + path; + return version + " " + method + " " + path; } } @@ -160,6 +171,8 @@ public enum ChecksumHeader { private ServerConnector httpsConnector; + private QuicheServerConnector http3Connector; + private String username; private String password; @@ -188,6 +201,10 @@ public int getHttpsPort() { return httpsConnector != null ? httpsConnector.getLocalPort() : -1; } + public int getHttp3Port() { + return http3Connector != null ? http3Connector.getLocalPort() : -1; + } + public String getHttpUrl() { return "http://" + getHost() + ":" + getHttpPort(); } @@ -196,37 +213,25 @@ public String getHttpsUrl() { return "https://" + getHost() + ":" + getHttpsPort(); } - public HttpServer addSslConnector() { - return addSslConnector(true, true); + public String getHttp3Url() { + return "https://" + getHost() + ":" + getHttp3Port(); } - public HttpServer addSelfSignedSslConnector() { - return addSslConnector(false, true); + public HttpServer addHttp2ConnectorWithMutualTLS() { + return addHttp2Connector(true, true); } - public HttpServer addSelfSignedSslConnectorHttp2Only() { - return addSslConnector(false, false); + public HttpServer addHttp2OnlyConnectorWithMutualTLS() { + return addHttp2Connector(false, true); } - private HttpServer addSslConnector(boolean needClientAuth, boolean needHttp11) { + public HttpServer addHttp2OnlyConnector() { + return addHttp2Connector(false, false); + } + + private HttpServer addHttp2Connector(boolean needClientAuth, boolean needHttp11) { if (httpsConnector == null) { - SslContextFactory.Server ssl = new SslContextFactory.Server(); - ssl.setNeedClientAuth(needClientAuth); - if (!needClientAuth) { - ssl.setKeyStorePath(HttpTransporterTest.KEY_STORE_SELF_SIGNED_PATH - .toAbsolutePath() - .toString()); - ssl.setKeyStorePassword("server-pwd"); - ssl.setSniRequired(false); - } else { - ssl.setKeyStorePath( - HttpTransporterTest.KEY_STORE_PATH.toAbsolutePath().toString()); - ssl.setKeyStorePassword("server-pwd"); - ssl.setTrustStorePath( - HttpTransporterTest.TRUST_STORE_PATH.toAbsolutePath().toString()); - ssl.setTrustStorePassword("client-pwd"); - ssl.setSniRequired(false); - } + SslContextFactory.Server ssl = createServerSslContextFactory(needClientAuth); HttpConfiguration httpsConfig = new HttpConfiguration(); SecureRequestCustomizer customizer = new SecureRequestCustomizer(); @@ -259,6 +264,42 @@ private HttpServer addSslConnector(boolean needClientAuth, boolean needHttp11) { return this; } + private SslContextFactory.Server createServerSslContextFactory(boolean needClientAuth) { + SslContextFactory.Server ssl = new SslContextFactory.Server(); + ssl.setSniRequired(false); + ssl.setKeyStorePath( + HttpTransporterTest.SERVER_STORE_PATH.toAbsolutePath().toString()); + ssl.setKeyStorePassword("server-pwd"); + ssl.setNeedClientAuth(needClientAuth); + if (needClientAuth) { + ssl.setTrustStorePath( + HttpTransporterTest.CLIENT_STORE_PATH.toAbsolutePath().toString()); + ssl.setTrustStorePassword("client-pwd"); + } + return ssl; + } + + public HttpServer addHttp3Connector(boolean needClientAuth) { + if (http3Connector == null) { + QuicheServerQuicConfiguration serverQuicConfig = HTTP3ServerQuicConfiguration.configure( + new QuicheServerQuicConfiguration(HttpTransporterTest.PEM_QUICHE_SERVER_PATH)); + http3Connector = new QuicheServerConnector( + server, + createServerSslContextFactory(needClientAuth), + serverQuicConfig, + new HTTP3ServerConnectionFactory()); + // TODO: should share same port as https connector, so that the client can use the same port for both + // TCP/UDP + server.addConnector(http3Connector); + try { + http3Connector.start(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + return this; + } + public List getLogEntries() { return logEntries; } @@ -322,6 +363,7 @@ public HttpServer start() throws Exception { server = new Server(); httpConnector = new ServerConnector(server); + // always add the HTTP 1.1 connector server.addConnector(httpConnector); server.setHandler(new LogHandler(new CompressionEnforcingHandler(new Handler.Sequence( @@ -484,20 +526,28 @@ private class LogHandler extends Handler.Wrapper { public boolean handle(Request req, Response response, Callback callback) throws Exception { LOGGER.info( - "{} {}{}", + "{} {} {}{}", + req.getConnectionMetaData().getHttpVersion(), req.getMethod(), req.getHttpURI().getDecodedPath(), req.getHttpURI().getQuery() != null ? "?" + req.getHttpURI().getQuery() : ""); Map requestHeaders = toUnmodifiableMap(req.getHeaders()); // capture request headers before other handlers modify them - LogEntry logEntry = new LogEntry(req.getMethod(), req.getHttpURI().getPathQuery(), requestHeaders); + LogEntry logEntry = new LogEntry( + req.getConnectionMetaData().getHttpVersion(), + req.getMethod(), + req.getHttpURI().getPathQuery(), + requestHeaders); logEntries.add(logEntry); // prevent closing the response before logging (assume all writes are synchronous for simplicity) boolean result = super.handle(req, response, callback); // capture response headers after other handlers modified them // at this point in time the connection may have been already closed (i.e. last chunk already sent) logEntry.setResponseHeaders(toUnmodifiableMap(response.getHeaders())); + // make sure to always consume the request body to avoid connection reset, even if the handler did not read + // it + Content.Source.consumeAll(req); if (result) { callback.succeeded(); } @@ -602,6 +652,7 @@ public boolean handle(Request req, Response response, Callback callback) throws Content.copy(req, Content.Sink.from(channel), fileWriteCallback); fileWriteCallback.block(); } catch (IOException e) { + LOGGER.warn("Failed to write file {}", file.getAbsolutePath(), e); file.delete(); throw e; } diff --git a/maven-resolver-test-http/src/main/java/org/eclipse/aether/internal/test/util/http/HttpTransporterTest.java b/maven-resolver-test-http/src/main/java/org/eclipse/aether/internal/test/util/http/HttpTransporterTest.java index 00bbbc2425..2175736b2e 100644 --- a/maven-resolver-test-http/src/main/java/org/eclipse/aether/internal/test/util/http/HttpTransporterTest.java +++ b/maven-resolver-test-http/src/main/java/org/eclipse/aether/internal/test/util/http/HttpTransporterTest.java @@ -29,7 +29,6 @@ import java.io.UncheckedIOException; import java.net.ServerSocket; import java.net.URI; -import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -69,6 +68,7 @@ import org.eclipse.aether.transfer.NoTransporterException; import org.eclipse.aether.transfer.TransferCancelledException; import org.eclipse.aether.util.repository.AuthenticationBuilder; +import org.eclipse.jetty.http.HttpVersion; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; @@ -94,11 +94,11 @@ @SuppressWarnings({"checkstyle:MethodName"}) public abstract class HttpTransporterTest { - protected static final Path KEY_STORE_PATH = Paths.get("target/keystore"); + protected static final Path SERVER_STORE_PATH = Paths.get("target/server-store"); - protected static final Path KEY_STORE_SELF_SIGNED_PATH = Paths.get("target/keystore-self-signed"); + protected static final Path CLIENT_STORE_PATH = Paths.get("target/client-store"); - protected static final Path TRUST_STORE_PATH = Paths.get("target/trustStore"); + protected static final Path PEM_QUICHE_SERVER_PATH = Paths.get("target/pems"); protected static SSLContext defaultSslContext; @@ -110,18 +110,14 @@ public abstract class HttpTransporterTest { @BeforeAll protected static void beforeAll() throws NoSuchAlgorithmException { // populate custom keystore and truststore - URL keyStoreUrl = HttpTransporterTest.class.getClassLoader().getResource("ssl/server-store"); - URL keyStoreSelfSignedUrl = - HttpTransporterTest.class.getClassLoader().getResource("ssl/server-store-selfsigned"); - URL trustStoreUrl = HttpTransporterTest.class.getClassLoader().getResource("ssl/client-store"); - try { - try (InputStream keyStoreStream = keyStoreUrl.openStream(); - InputStream keyStoreSelfSignedStream = keyStoreSelfSignedUrl.openStream(); - InputStream trustStoreStream = trustStoreUrl.openStream()) { - Files.copy(keyStoreStream, KEY_STORE_PATH, StandardCopyOption.REPLACE_EXISTING); - Files.copy(keyStoreSelfSignedStream, KEY_STORE_SELF_SIGNED_PATH, StandardCopyOption.REPLACE_EXISTING); - Files.copy(trustStoreStream, TRUST_STORE_PATH, StandardCopyOption.REPLACE_EXISTING); + try (InputStream keyStoreStream = + HttpTransporterTest.class.getClassLoader().getResourceAsStream("ssl/server-store"); + InputStream trustStoreStream = + HttpTransporterTest.class.getClassLoader().getResourceAsStream("ssl/client-store"); ) { + Files.copy(keyStoreStream, SERVER_STORE_PATH, StandardCopyOption.REPLACE_EXISTING); + Files.copy(trustStoreStream, CLIENT_STORE_PATH, StandardCopyOption.REPLACE_EXISTING); + Files.createDirectories(PEM_QUICHE_SERVER_PATH); } } catch (IOException e) { throw new UncheckedIOException(e); @@ -129,7 +125,7 @@ protected static void beforeAll() throws NoSuchAlgorithmException { // override default SSLContext to include our custom keystore and truststore (which are "cross connected" with // HttpServer) defaultSslContext = SSLContext.getDefault(); - SSLContext.setDefault(createSSLContext()); + SSLContext.setDefault(createClientSSLContext()); } @AfterAll @@ -140,23 +136,23 @@ protected static void afterAll() { } /** - * Creates an {@link SSLContext} that extends the default keystore and truststore with the entries - * from {@link #KEY_STORE_PATH} (password {@code "server-pwd"}) and {@link #TRUST_STORE_PATH} + * Creates an {@link SSLContext} for the client that extends the default keystore and truststore with the entries + * from {@link #SERVER_STORE_PATH} (password {@code "server-pwd"}) and {@link #CLIENT_STORE_PATH} * (password {@code "client-pwd"}). * * @return an {@link SSLContext} combining default and custom key/trust material */ - protected static SSLContext createSSLContext() { + protected static SSLContext createClientSSLContext() { try { // Load custom key store (KEY_STORE_PATH acts as truststore in "cross connected" setup) - KeyStore customTrustStore = KeyStore.getInstance("jks"); - try (InputStream is = Files.newInputStream(KEY_STORE_PATH)) { + KeyStore customTrustStore = KeyStore.getInstance("pkcs12"); + try (InputStream is = Files.newInputStream(SERVER_STORE_PATH)) { customTrustStore.load(is, "server-pwd".toCharArray()); } // Load custom trust store (TRUST_STORE_PATH acts as keystore in "cross connected" setup) - KeyStore customKeyStore = KeyStore.getInstance("jks"); - try (InputStream is = Files.newInputStream(TRUST_STORE_PATH)) { + KeyStore customKeyStore = KeyStore.getInstance("pkcs12"); + try (InputStream is = Files.newInputStream(CLIENT_STORE_PATH)) { customKeyStore.load(is, "client-pwd".toCharArray()); } @@ -285,6 +281,7 @@ protected void setUp(TestInfo testInfo) throws Exception { TestFileUtils.writeString(resumable, "resumable"); resumable.setLastModified(System.currentTimeMillis() - 90 * 1000); httpServer = new HttpServer().setRepoDir(repoDir).start(); + // always create a transporter connecting to the Http URL newTransporter(httpServer.getHttpUrl()); } @@ -483,14 +480,14 @@ protected void testPeek_ProxyUnauthenticated() throws Exception { @Test protected void testPeek_SSL() throws Exception { - httpServer.addSslConnector(); + httpServer.addHttp2ConnectorWithMutualTLS(); newTransporter(httpServer.getHttpsUrl()); transporter.peek(new PeekTask(URI.create("repo/file.txt"))); } @Test protected void testPeek_Redirect() throws Exception { - httpServer.addSslConnector(); + httpServer.addHttp2ConnectorWithMutualTLS(); transporter.peek(new PeekTask(URI.create("redirect/file.txt"))); transporter.peek(new PeekTask(URI.create("redirect/file.txt?scheme=https"))); } @@ -737,7 +734,7 @@ protected void testGet_RFC9457Response_with_missing_fields() throws Exception { @Test protected void testGet_SSL() throws Exception { - httpServer.addSslConnector(); + httpServer.addHttp2ConnectorWithMutualTLS(); newTransporter(httpServer.getHttpsUrl()); RecordingTransportListener listener = new RecordingTransportListener(); GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener); @@ -753,7 +750,7 @@ protected void testGet_SSL() throws Exception { @Test protected void testGet_SSL_WithServerErrors() throws Exception { httpServer.setServerErrorsBeforeWorks(1); - httpServer.addSslConnector(); + httpServer.addHttp2ConnectorWithMutualTLS(); newTransporter(httpServer.getHttpsUrl()); for (int i = 1; i < 3; i++) { try { @@ -775,7 +772,7 @@ protected void testGet_SSL_WithServerErrors() throws Exception { @Test protected void testGet_HTTPS_Unknown_SecurityMode() throws Exception { session.setConfigProperty(ConfigurationProperties.HTTPS_SECURITY_MODE, "unknown"); - httpServer.addSelfSignedSslConnector(); + httpServer.addHttp2OnlyConnectorWithMutualTLS(); try { newTransporter(httpServer.getHttpsUrl()); fail("Unsupported security mode"); @@ -786,11 +783,12 @@ protected void testGet_HTTPS_Unknown_SecurityMode() throws Exception { @Test protected void testGet_HTTPS_Insecure_SecurityMode() throws Exception { - // here we use alternate server-store-selfigned key (as the key set it static initializer is probably already - // used to init SSLContext/SSLSocketFactory/etc + // we have to reset the default ssl context to avoid the default truststore being used, which would make the + // test pass even if the security mode is not set to insecure + SSLContext.setDefault(defaultSslContext); session.setConfigProperty( ConfigurationProperties.HTTPS_SECURITY_MODE, ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE); - httpServer.addSelfSignedSslConnector(); + httpServer.addHttp2OnlyConnectorWithMutualTLS(); newTransporter(httpServer.getHttpsUrl()); RecordingTransportListener listener = new RecordingTransportListener(); GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener); @@ -801,16 +799,16 @@ protected void testGet_HTTPS_Insecure_SecurityMode() throws Exception { assertEquals(1, listener.getStartedCount()); assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount()); assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8)); + // restore the default SSL context used for all other tests + SSLContext.setDefault(createClientSSLContext()); } @Test protected void testGet_HTTPS_HTTP2Only_Insecure_SecurityMode() throws Exception { - // here we use alternate server-store-selfigned key (as the key set it static initializer is probably already - // used to init SSLContext/SSLSocketFactory/etc - enableHttp2Protocol(); + session.setConfigProperty(ConfigurationProperties.HTTP_VERSION, ConfigurationProperties.HttpVersion.HTTP_2); session.setConfigProperty( ConfigurationProperties.HTTPS_SECURITY_MODE, ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE); - httpServer.addSelfSignedSslConnectorHttp2Only(); + httpServer.addHttp2OnlyConnector(); newTransporter(httpServer.getHttpsUrl()); RecordingTransportListener listener = new RecordingTransportListener(); GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener); @@ -821,13 +819,34 @@ protected void testGet_HTTPS_HTTP2Only_Insecure_SecurityMode() throws Exception assertEquals(1, listener.getStartedCount()); assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount()); assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8)); + httpServer.getLogEntries().forEach(log -> { + assertEquals(HttpVersion.HTTP_2, log.getVersion()); + }); } - protected void enableHttp2Protocol() {} + @Test + protected void testGet_HTTP3() throws Exception { + session.setConfigProperty(ConfigurationProperties.HTTP_VERSION, ConfigurationProperties.HttpVersion.HTTP_3); + httpServer.addHttp3Connector(false); + httpServer.start(); + newTransporter(httpServer.getHttp3Url()); + RecordingTransportListener listener = new RecordingTransportListener(); + GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener); + transporter.get(task); + assertEquals("test", task.getDataString()); + assertEquals(0L, listener.getDataOffset()); + assertEquals(4L, listener.getDataLength()); + assertEquals(1, listener.getStartedCount()); + assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount()); + assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8)); + httpServer.getLogEntries().forEach(log -> { + assertEquals(HttpVersion.HTTP_3, log.getVersion()); + }); + } @Test protected void testGet_Redirect() throws Exception { - httpServer.addSslConnector(); + httpServer.addHttp2ConnectorWithMutualTLS(); RecordingTransportListener listener = new RecordingTransportListener(); GetTask task = new GetTask(URI.create("redirect/file.txt?scheme=https")).setListener(listener); transporter.get(task); @@ -1215,7 +1234,7 @@ protected void testPut_ProxyUnauthenticated() throws Exception { @Test protected void testPut_SSL() throws Exception { - httpServer.addSslConnector(); + httpServer.addHttp2ConnectorWithMutualTLS(); httpServer.setAuthentication("testuser", "testpass"); auth = new AuthenticationBuilder() .addUsername("testuser") diff --git a/maven-resolver-test-http/src/main/resources/ssl/README.txt b/maven-resolver-test-http/src/main/resources/ssl/README.txt index b1be71c2c7..d136fa4ac6 100644 --- a/maven-resolver-test-http/src/main/resources/ssl/README.txt +++ b/maven-resolver-test-http/src/main/resources/ssl/README.txt @@ -1,5 +1,9 @@ client-store generated via -> keytool -genkey -alias localhost -keypass client-pwd -keystore client-store -storepass client-pwd -validity 4096 -dname "cn=localhost, ou=None, L=Seattle, ST=Washington, o=ExampleOrg, c=US" -keyalg RSA +> keytool -genkey -alias localhost-client -keypass client-pwd -keystore client-store -storepass client-pwd -validity 4096 -dname "cn=localhost, ou=None, L=Seattle, ST=Washington, o=ExampleOrg, c=US" -keyalg RSA server-store generated via -> keytool -genkey -alias localhost -keypass server-pwd -keystore server-store -storepass server-pwd -validity 4096 -dname "cn=localhost, ou=None, L=Seattle, ST=Washington, o=ExampleOrg, c=US" -keyalg RSA +> keytool -genkey -alias localhost-server -keypass server-pwd -keystore server-store -storepass server-pwd -validity 4096 -dname "cn=localhost, ou=None, L=Seattle, ST=Washington, o=ExampleOrg, c=US" -keyalg RSA + +The server key has to have name "localhost" otherwise the hostname matching will fail (as the server is running on localhost as well). +Both certificates are self-signed i.e. have themselves as issuer and contain both public and private keys! +Although the latter is not necessary for trust stores it won't do any harm here. diff --git a/maven-resolver-test-http/src/main/resources/ssl/client-store b/maven-resolver-test-http/src/main/resources/ssl/client-store index fbfb39d829..f8fe6bd765 100644 Binary files a/maven-resolver-test-http/src/main/resources/ssl/client-store and b/maven-resolver-test-http/src/main/resources/ssl/client-store differ diff --git a/maven-resolver-test-http/src/main/resources/ssl/server-store b/maven-resolver-test-http/src/main/resources/ssl/server-store index 6137fee7b4..61dcd8217c 100644 Binary files a/maven-resolver-test-http/src/main/resources/ssl/server-store and b/maven-resolver-test-http/src/main/resources/ssl/server-store differ diff --git a/maven-resolver-test-http/src/main/resources/ssl/server-store-selfsigned b/maven-resolver-test-http/src/main/resources/ssl/server-store-selfsigned deleted file mode 100644 index caf6e52bef..0000000000 Binary files a/maven-resolver-test-http/src/main/resources/ssl/server-store-selfsigned and /dev/null differ diff --git a/maven-resolver-tools/pom.xml b/maven-resolver-tools/pom.xml index 677b73a59b..d1ebde2bbc 100644 --- a/maven-resolver-tools/pom.xml +++ b/maven-resolver-tools/pom.xml @@ -170,6 +170,25 @@ + + org.apache.maven.plugins + maven-enforcer-plugin + + + enforce-bytecode-version + + + + + + org.eclipse.jetty.quic:jetty-quic-quiche-foreign + + + + + + + org.codehaus.mojo exec-maven-plugin diff --git a/maven-resolver-tools/src/main/resources/configuration.md.vm b/maven-resolver-tools/src/main/resources/configuration.md.vm index 2b5669e898..9efd0a5aeb 100644 --- a/maven-resolver-tools/src/main/resources/configuration.md.vm +++ b/maven-resolver-tools/src/main/resources/configuration.md.vm @@ -68,6 +68,7 @@ From | To | With `String` | `int` | [`Integer.parseInt(...)`](https://docs.oracle.com/javase/7/docs/api/java/lang/Integer.html#parseInt(java.lang.String)) `String` | `long` | [`Long.parseLong(...)`](https://docs.oracle.com/javase/7/docs/api/java/lang/Long.html#parseLong(java.lang.String)) `String` | `float` | [`Float.parseFloat(...)`](https://docs.oracle.com/javase/7/docs/api/java/lang/Float.html#parseFloat(java.lang.String)) +`String` | `T extends Enum` | [`Enum.valueOf(...)`](https://docs.oracle.com/javase/7/docs/api/java/lang/Enum.html#valueOf(java.lang.Class,%20java.lang.String)) ## Set Configuration from Apache Maven diff --git a/maven-resolver-transport-apache/src/main/java/org/eclipse/aether/transport/apache/ApacheRFC9457Reporter.java b/maven-resolver-transport-apache/src/main/java/org/eclipse/aether/transport/apache/ApacheRFC9457Reporter.java index c9ff111683..41f43701bc 100644 --- a/maven-resolver-transport-apache/src/main/java/org/eclipse/aether/transport/apache/ApacheRFC9457Reporter.java +++ b/maven-resolver-transport-apache/src/main/java/org/eclipse/aether/transport/apache/ApacheRFC9457Reporter.java @@ -67,6 +67,9 @@ protected String getReasonPhrase(final CloseableHttpResponse response) { @Override protected String getBody(final CloseableHttpResponse response) throws IOException { + if (response.getEntity() == null) { + return ""; + } return EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); } } diff --git a/maven-resolver-transport-apache/src/test/java/org/eclipse/aether/transport/apache/ApacheTransporterTest.java b/maven-resolver-transport-apache/src/test/java/org/eclipse/aether/transport/apache/ApacheTransporterTest.java index d081687737..eca051c3af 100644 --- a/maven-resolver-transport-apache/src/test/java/org/eclipse/aether/transport/apache/ApacheTransporterTest.java +++ b/maven-resolver-transport-apache/src/test/java/org/eclipse/aether/transport/apache/ApacheTransporterTest.java @@ -36,7 +36,8 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Apache Transporter UT. @@ -58,6 +59,11 @@ protected Stream supportedCompressionAlgorithms() { @Test protected void testGet_HTTPS_HTTP2Only_Insecure_SecurityMode() throws Exception {} + @Override + @Disabled + @Test + protected void testGet_HTTP3() {} + @Test void testGet_WebDav() throws Exception { httpServer.setWebDav(true); @@ -106,7 +112,7 @@ void testPut_WebDav() throws Exception { @Test void testConnectionReuse() throws Exception { - httpServer.addSslConnector(); + httpServer.addHttp2ConnectorWithMutualTLS(); session.setCache(new DefaultRepositoryCache()); for (int i = 0; i < 3; i++) { newTransporter(httpServer.getHttpsUrl()); @@ -122,7 +128,7 @@ void testConnectionReuse() throws Exception { @Test void testConnectionNoReuse() throws Exception { - httpServer.addSslConnector(); + httpServer.addHttp2ConnectorWithMutualTLS(); session.setCache(new DefaultRepositoryCache()); session.setConfigProperty(ConfigurationProperties.HTTP_REUSE_CONNECTIONS, false); for (int i = 0; i < 3; i++) { diff --git a/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/pom.xml b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/pom.xml index 2a9369034c..12e3cc8dcb 100644 --- a/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/pom.xml +++ b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/pom.xml @@ -74,6 +74,12 @@ slf4j-simple test + + org.slf4j + jul-to-slf4j + ${slf4jVersion} + test + org.apache.maven.resolver maven-resolver-test-util diff --git a/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporter.java b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporter.java index 0cb866af96..75c197df19 100644 --- a/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporter.java +++ b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporter.java @@ -44,6 +44,7 @@ import java.net.URISyntaxException; import java.net.UnknownHostException; import java.net.http.HttpClient; +import java.net.http.HttpClient.Version; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.file.Files; @@ -71,6 +72,7 @@ import com.github.mizosoft.methanol.RetryInterceptor; import com.github.mizosoft.methanol.RetryInterceptor.Context; import org.eclipse.aether.ConfigurationProperties; +import org.eclipse.aether.ConfigurationProperties.HttpVersion; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.repository.AuthenticationContext; import org.eclipse.aether.repository.RemoteRepository; @@ -105,7 +107,6 @@ import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.USER_AGENT; import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.CONFIG_PROP_HTTP_VERSION; import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.CONFIG_PROP_MAX_CONCURRENT_REQUESTS; -import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.DEFAULT_HTTP_VERSION; import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.DEFAULT_MAX_CONCURRENT_REQUESTS; /** @@ -468,6 +469,51 @@ protected void implClose() { } } + HttpClient.Version getHttpVersion(RepositorySystemSession session, RemoteRepository repository) { + HttpVersion httpVersion = HttpTransporterUtils.getHttpVersion(session, repository); + if (httpVersion == ConfigurationProperties.DEFAULT_HTTP_VERSION) { + // Fall back to legacy JDK Transporter specific property when it is explicitly configured. + String configuredLegacyHttpVersion = ConfigUtils.getString( + session, null, CONFIG_PROP_HTTP_VERSION + "." + repository.getId(), CONFIG_PROP_HTTP_VERSION); + if (configuredLegacyHttpVersion != null) { + return resolveHttpVersion(configuredLegacyHttpVersion); + } + return HttpClient.Version.HTTP_2; + } else { + switch (httpVersion) { + case MAXIMUM: + return getMaximumSupportedHttpVersion(); + case HTTP_1_1: + return HttpClient.Version.HTTP_1_1; + case HTTP_2: + case DEFAULT: + return HttpClient.Version.HTTP_2; + case HTTP_3: + return resolveHttpVersion("HTTP_3"); + default: + throw new IllegalArgumentException("Unsupported HTTP version: " + httpVersion); + } + } + } + + private HttpClient.Version resolveHttpVersion(String requestedVersion) { + try { + return HttpClient.Version.valueOf(requestedVersion); + } catch (IllegalArgumentException e) { + HttpClient.Version maximumHttpVersion = getMaximumSupportedHttpVersion(); + LOGGER.warn( + "HTTP version '{}' is not supported by the running JRE, using '{}' instead", + requestedVersion, + maximumHttpVersion); + return maximumHttpVersion; + } + } + + HttpClient.Version getMaximumSupportedHttpVersion() { + HttpClient.Version[] values = HttpClient.Version.values(); + return values[values.length - 1]; + } + private HttpClient createClient(RepositorySystemSession session, RemoteRepository repository, boolean insecure) throws RuntimeException { @@ -484,9 +530,18 @@ private HttpClient createClient(RepositorySystemSession session, RemoteRepositor } } + Version httpVersion = getHttpVersion(session, repository); if (sslContext == null) { try { if (insecure) { + if (httpVersion.name().equals("HTTP_3")) { + // custom trust manager not supported for HTTP/3 (Quic) + // (https://github.com/openjdk/jdk/blob/631b675d7949a0e6312d8d6f45e2515d53b12f05/src/java.base/share/classes/sun/security/ssl/SSLContextImpl.java#L529) + // https://openjdk.org/jeps/517 + // https://mail.openjdk.org/archives/list/net-dev@openjdk.org/thread/LHSC7MWRFDJE2KGS2QMPFJPZX3XEQKOQ/ + throw new IllegalStateException( + "Insecure HTTPS connections are not supported for HTTP/3 (Quic)"); + } sslContext = SSLContext.getInstance("TLS"); X509ExtendedTrustManager tm = new X509ExtendedTrustManager() { @Override @@ -523,14 +578,15 @@ public X509Certificate[] getAcceptedIssuers() { throw new IllegalStateException("SSL Context setup failure", e); } } + } else { + if (insecure) { + throw new IllegalStateException( + "Insecure HTTPS connections are not supported when a custom SSLContext is configured"); + } } Methanol.Builder builder = Methanol.newBuilder() - .version(HttpClient.Version.valueOf(ConfigUtils.getString( - session, - DEFAULT_HTTP_VERSION, - CONFIG_PROP_HTTP_VERSION + "." + repository.getId(), - CONFIG_PROP_HTTP_VERSION))) + .version(httpVersion) .followRedirects(HttpClient.Redirect.NORMAL) .connectTimeout(Duration.ofMillis(connectTimeout)) // this only considers the time until the response header is received, see diff --git a/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporterConfigurationKeys.java b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporterConfigurationKeys.java index 83b72fa3d9..8b6c5e4574 100644 --- a/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporterConfigurationKeys.java +++ b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporterConfigurationKeys.java @@ -39,7 +39,9 @@ private JdkTransporterConfigurationKeys() {} * @configurationType {@link java.lang.String} * @configurationDefaultValue {@link #DEFAULT_HTTP_VERSION} * @configurationRepoIdSuffix Yes + * @deprecated Use {@link ConfigProperties#CONFIG_PROP_HTTP_VERSION} instead. This property is kept for backward compatibility and will be removed in future versions. */ + @Deprecated public static final String CONFIG_PROP_HTTP_VERSION = CONFIG_PROPS_PREFIX + "httpVersion"; public static final String DEFAULT_HTTP_VERSION = "HTTP_1_1"; diff --git a/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/test/java/org/eclipse/aether/transport/jdk/JdkTransporterTest.java b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/test/java/org/eclipse/aether/transport/jdk/JdkTransporterTest.java index c570fa5b0d..11c20facd6 100644 --- a/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/test/java/org/eclipse/aether/transport/jdk/JdkTransporterTest.java +++ b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/test/java/org/eclipse/aether/transport/jdk/JdkTransporterTest.java @@ -20,8 +20,11 @@ import java.net.ConnectException; import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpClient.Version; import java.util.stream.Stream; +import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.internal.impl.DefaultPathProcessor; import org.eclipse.aether.internal.test.util.TestUtils; import org.eclipse.aether.internal.test.util.http.HttpTransporterTest; @@ -29,7 +32,10 @@ import org.eclipse.aether.spi.connector.transport.PeekTask; import org.eclipse.aether.spi.connector.transport.Transporter; import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.condition.EnabledForJreRange; import org.junit.jupiter.api.condition.JRE; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -40,6 +46,7 @@ * JDK Transporter UT. * Related: No TLS proxy supported. */ +@TestMethodOrder(MethodOrderer.MethodName.class) class JdkTransporterTest extends HttpTransporterTest { private boolean isBetweenJava17and21() { @@ -80,12 +87,14 @@ protected void testPut_Authenticated_ExpectContinueRejected_ExplicitlyConfigured @Override @Test + @EnabledForJreRange(max = JRE.JAVA_25) protected void testRetryHandler_defaultCount_negative() throws Exception { // internally JDK client uses its own retry mechanism with - // 2 attempts for ConnectionExpiredException (prior Java 26) and 5 (default for - // system property "jdk.httpclient.redirects.retrylimit") attempts after, - // therefore explicitly limit the internal retry count to 1 - System.setProperty("jdk.httpclient.redirects.retrylimit", "1"); // this only affects Java 26+ + // 2 attempts for ConnectionExpiredException (prior Java 26) + // afterwards it uses 5 (default for + // system property "jdk.httpclient.redirects.retrylimit") attempts, + // limit the internal retry count to 1 has global effect and is too late here anyway therefore we skip this test + // for Java 26+. // Compare with https://github.com/mizosoft/methanol/issues/174 httpServer.setConnectionsToClose(8); try { @@ -95,13 +104,17 @@ protected void testRetryHandler_defaultCount_negative() throws Exception { } } - public JdkTransporterTest() { - super(() -> new JdkTransporterFactory(standardChecksumExtractor(), new DefaultPathProcessor())); + @Override + @EnabledForJreRange(min = JRE.JAVA_26) + @Test + protected void testGet_HTTP3() throws Exception { + // System.setProperty("jdk.httpclient.HttpClient.log", "ssl"); + // System.setProperty("jdk.internal.httpclient.debug", "out"); + super.testGet_HTTP3(); } - @Override - protected void enableHttp2Protocol() { - session.setConfigProperty(JdkTransporterConfigurationKeys.CONFIG_PROP_HTTP_VERSION, "HTTP_2"); + public JdkTransporterTest() { + super(() -> new JdkTransporterFactory(standardChecksumExtractor(), new DefaultPathProcessor())); } @Test @@ -126,4 +139,21 @@ void testGetBasicAuthValue() { "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==", JdkTransporter.getBasicAuthValue("Aladdin", "open sesame".toCharArray())); } + + @Test + void testMaximumHttpVersionAtRuntime() throws Exception { + RepositorySystemSession session = TestUtils.newSession(); + RemoteRepository remoteRepository = + new RemoteRepository.Builder("central", "default", "https://repo.maven.apache.org/maven2/").build(); + JdkTransporterFactory factory = new JdkTransporterFactory(s -> null, new DefaultPathProcessor()); + + try (Transporter transporter = factory.newInstance(session, remoteRepository)) { + JdkTransporter jdkTransporter = (JdkTransporter) transporter; + HttpClient.Version[] versions = HttpClient.Version.values(); + HttpClient.Version maximumSupported = versions[versions.length - 1]; + assertEquals(maximumSupported, jdkTransporter.getMaximumSupportedHttpVersion()); + // default should be returned which is HTTP_2 for all JRE versions + assertEquals(Version.HTTP_2, jdkTransporter.getHttpVersion(session, remoteRepository)); + } + } } diff --git a/maven-resolver-transport-jetty/pom.xml b/maven-resolver-transport-jetty/pom.xml index 87f8454548..08e1cabe38 100644 --- a/maven-resolver-transport-jetty/pom.xml +++ b/maven-resolver-transport-jetty/pom.xml @@ -72,6 +72,18 @@ org.eclipse.jetty.http2 jetty-http2-client-transport + + org.eclipse.jetty.http3 + jetty-http3-client-transport + + + org.eclipse.jetty.quic + jetty-quic-quiche-client + + + org.eclipse.jetty.quic + jetty-quic-quiche-jna + org.eclipse.jetty.compression @@ -114,6 +126,25 @@ + + org.apache.maven.plugins + maven-enforcer-plugin + + + enforce-bytecode-version + + + + + + org.eclipse.jetty.quic:jetty-quic-quiche-foreign + + + + + + + org.eclipse.sisu sisu-maven-plugin diff --git a/maven-resolver-transport-jetty/src/main/java/org/eclipse/aether/transport/jetty/JettyTransporter.java b/maven-resolver-transport-jetty/src/main/java/org/eclipse/aether/transport/jetty/JettyTransporter.java index 2b9e8248d5..7a8f4c50ac 100644 --- a/maven-resolver-transport-jetty/src/main/java/org/eclipse/aether/transport/jetty/JettyTransporter.java +++ b/maven-resolver-transport-jetty/src/main/java/org/eclipse/aether/transport/jetty/JettyTransporter.java @@ -19,7 +19,6 @@ package org.eclipse.aether.transport.jetty; import javax.net.ssl.SSLContext; -import javax.net.ssl.X509TrustManager; import java.io.IOException; import java.io.InputStream; @@ -28,7 +27,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; -import java.security.cert.X509Certificate; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ExecutionException; @@ -39,6 +40,7 @@ import java.util.regex.Matcher; import org.eclipse.aether.ConfigurationProperties; +import org.eclipse.aether.ConfigurationProperties.HttpVersion; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.repository.AuthenticationContext; import org.eclipse.aether.repository.RemoteRepository; @@ -67,7 +69,13 @@ import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http2.client.HTTP2Client; import org.eclipse.jetty.http2.client.transport.ClientConnectionFactoryOverHTTP2; +import org.eclipse.jetty.http3.client.HTTP3Client; +import org.eclipse.jetty.http3.client.HTTP3ClientQuicConfiguration; +import org.eclipse.jetty.http3.client.transport.ClientConnectionFactoryOverHTTP3; +import org.eclipse.jetty.io.ClientConnectionFactory; import org.eclipse.jetty.io.ClientConnector; +import org.eclipse.jetty.quic.quiche.client.QuicheClientQuicConfiguration; +import org.eclipse.jetty.quic.quiche.client.QuicheTransport; import org.eclipse.jetty.util.ssl.SslContextFactory; import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.ACCEPT_ENCODING; @@ -388,40 +396,27 @@ private HttpClient createClient() throws RuntimeException { } } - if (sslContext == null) { + SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(); + if (insecure) { + // this is also passed on to Quiche for HTTP/3 + sslContextFactory.setEndpointIdentificationAlgorithm(null); + sslContextFactory.setHostnameVerifier((name, context) -> true); + sslContextFactory.setTrustAll(true); + } else { try { - if (insecure) { - sslContext = SSLContext.getInstance("TLS"); - X509TrustManager tm = new X509TrustManager() { - @Override - public void checkClientTrusted(X509Certificate[] chain, String authType) {} - - @Override - public void checkServerTrusted(X509Certificate[] chain, String authType) {} - - @Override - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - }; - sslContext.init(null, new X509TrustManager[] {tm}, null); - } else { + if (sslContext == null) { + // use the JVM's default SSL context (potentially with custom keystores/truststores) + // https://github.com/jetty/jetty.project/issues/15378 sslContext = SSLContext.getDefault(); } - } catch (Exception e) { - if (e instanceof RuntimeException) { - throw (RuntimeException) e; - } else { - throw new IllegalStateException("SSL Context setup failure", e); - } + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SSL Context setup failure", e); } } - SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(); - sslContextFactory.setSslContext(sslContext); - if (insecure) { - sslContextFactory.setEndpointIdentificationAlgorithm(null); - sslContextFactory.setHostnameVerifier((name, context) -> true); + if (sslContext != null) { + // not properly supported by Quiche for HTTP/3, but Jetty will use it for HTTP/2 and HTTP/1.1 + sslContextFactory.setSslContext(sslContext); } ClientConnector clientConnector = new ClientConnector(); @@ -430,16 +425,35 @@ public X509Certificate[] getAcceptedIssuers() { HTTP2Client http2Client = new HTTP2Client(clientConnector); ClientConnectionFactoryOverHTTP2.HTTP2 http2 = new ClientConnectionFactoryOverHTTP2.HTTP2(http2Client); - HttpClientTransportDynamic transport; + QuicheClientQuicConfiguration clientQuicConfig = + HTTP3ClientQuicConfiguration.configure(new QuicheClientQuicConfiguration()); + HTTP3Client http3Client = new HTTP3Client(clientQuicConfig, clientConnector); + QuicheTransport transport = new QuicheTransport(clientQuicConfig); + ClientConnectionFactoryOverHTTP3.HTTP3 http3 = + new ClientConnectionFactoryOverHTTP3.HTTP3(http3Client, transport); + + Collection connectors = new ArrayList<>(); if ("https".equalsIgnoreCase(repository.getProtocol())) { - transport = new HttpClientTransportDynamic( - clientConnector, http2, HttpClientConnectionFactory.HTTP11); // HTTPS, prefer H2 - } else { - transport = new HttpClientTransportDynamic( - clientConnector, HttpClientConnectionFactory.HTTP11, http2); // plaintext HTTP, H2 cannot be used + HttpVersion httpVersion = HttpTransporterUtils.getHttpVersion(session, repository); + switch (httpVersion) { + case MAXIMUM: + case HTTP_3: + connectors.add(http3); + break; + // always support HTTP/2 as fallback for HTTP/3 + case HTTP_2: + case DEFAULT: + connectors.add(http2); + break; + default: + break; + } } + connectors.add(HttpClientConnectionFactory.HTTP11); // HTTP/1.1, always supported but has least priority - HttpClient httpClient = new HttpClient(transport); + HttpClientTransportDynamic dynamicTransport = new HttpClientTransportDynamic( + clientConnector, connectors.toArray(new ClientConnectionFactory.Info[0])); + HttpClient httpClient = new HttpClient(dynamicTransport); httpClient.setConnectTimeout(connectTimeout); httpClient.setIdleTimeout(requestTimeout); httpClient.setFollowRedirects(ConfigUtils.getBoolean( diff --git a/maven-resolver-transport-jetty/src/test/java/org/eclipse/aether/transport/jetty/JettyTransporterTest.java b/maven-resolver-transport-jetty/src/test/java/org/eclipse/aether/transport/jetty/JettyTransporterTest.java index 4917ad734a..74cd2e132c 100644 --- a/maven-resolver-transport-jetty/src/test/java/org/eclipse/aether/transport/jetty/JettyTransporterTest.java +++ b/maven-resolver-transport-jetty/src/test/java/org/eclipse/aether/transport/jetty/JettyTransporterTest.java @@ -20,6 +20,7 @@ import java.util.stream.Stream; +import org.eclipse.aether.ConfigurationProperties; import org.eclipse.aether.internal.test.util.http.HttpTransporterTest; import org.eclipse.aether.spi.io.PathProcessorSupport; import org.junit.jupiter.api.Disabled; @@ -75,6 +76,16 @@ protected void testRetryHandler_tooManyRequests_explicitCount_negative() {} @Test protected void testPut_Authenticated_ExpectContinueRejected_ExplicitlyConfiguredHeader() {} + @Override + @Test + protected void testGet_HTTP3() throws Exception { + // Jetty's HTTP/3 support is based on Quiche which does not consider the default SSL context + // (https://github.com/jetty/jetty.project/issues/15370) + session.setConfigProperty( + ConfigurationProperties.HTTPS_SECURITY_MODE, ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE); + super.testGet_HTTP3(); + } + public JettyTransporterTest() { super(() -> new JettyTransporterFactory(standardChecksumExtractor(), new PathProcessorSupport())); } diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/ConfigUtils.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/ConfigUtils.java index 523ffba147..ab1e9ca37d 100644 --- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/ConfigUtils.java +++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/ConfigUtils.java @@ -368,4 +368,49 @@ public static List parseCommaSeparatedNames(String commaSeparatedNames) public static List parseCommaSeparatedUniqueNames(String commaSeparatedNames) { return parseCommaSeparatedNames(commaSeparatedNames).stream().distinct().collect(toList()); } + + /** + * Gets the specified configuration property. + * @param the enum type + * + * @param properties the configuration properties to read, must not be {@code null} + * @param enumClass the enum class to read, must not be {@code null} + * @param defaultValue the default value to return in case none of the property keys is set to a boolean + * @param keys the property keys to read, must not be {@code null}. The specified keys are read one after one until + * a enum type {@code T} or a string (parsed using {@link Enum#valueOf(Class, String)} is found. + * @return the property value or {@code defaultValue} if none found + */ + public static > T getEnum( + Map properties, Class enumClass, T defaultValue, String... keys) { + for (String key : keys) { + Object value = properties.get(key); + + if (value instanceof Enum) { + return (T) value; + } else if (value instanceof String) { + return Enum.valueOf(enumClass, (String) value); + } + } + return defaultValue; + } + + /** + * Gets the specified configuration property. + * + * @param session the repository system session from which to read the configuration property, must not be + * {@code null} + * @param the enum type + * + * @param session the repository system session from which to read the configuration property, must not be + * {@code null} + * @param enumClass the enum class to read, must not be {@code null} + * @param defaultValue the default value to return in case none of the property keys is set to a boolean + * @param keys the property keys to read, must not be {@code null}. The specified keys are read one after one until + * a enum type {@code T} or a string (parsed using {@link Enum#valueOf(Class, String)} is found. + * @return the property value or {@code defaultValue} if none found + */ + public static > T getEnum( + RepositorySystemSession session, Class enumClass, T defaultValue, String... keys) { + return getEnum(session.getConfigProperties(), enumClass, defaultValue, keys); + } } diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/connector/transport/http/HttpTransporterUtils.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/connector/transport/http/HttpTransporterUtils.java index a8fe4efedf..787d1ff3e9 100644 --- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/connector/transport/http/HttpTransporterUtils.java +++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/connector/transport/http/HttpTransporterUtils.java @@ -315,6 +315,16 @@ public static Optional getHttpLocalAddress( return Optional.empty(); } + public static ConfigurationProperties.HttpVersion getHttpVersion( + RepositorySystemSession session, RemoteRepository repository) { + return ConfigUtils.getEnum( + session, + ConfigurationProperties.HttpVersion.class, + ConfigurationProperties.DEFAULT_HTTP_VERSION, + ConfigurationProperties.HTTP_VERSION + "." + repository.getId(), + ConfigurationProperties.HTTP_VERSION); + } + /** * Shared code to create "base {@link URI}" for most common HTTP remote repositories and all HTTP transports. * Note: this method just applies common validation and adjustments to URI, but it does not enforce protocol diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/ConfigUtilsTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/ConfigUtilsTest.java index d3797a314f..480fbafc8e 100644 --- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/ConfigUtilsTest.java +++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/ConfigUtilsTest.java @@ -199,4 +199,29 @@ void testGetFloat_NumberConversion() { config.put("some-number", -1234f); assertEquals(-1234f, ConfigUtils.getFloat(config, 0, "some-number"), 0.1f); } + + private enum TestEnum { + FIRST, + SECOND + } + + @Test + void testGetEnum() { + config.put("some-enum", TestEnum.SECOND); + assertEquals(TestEnum.SECOND, ConfigUtils.getEnum(config, TestEnum.class, TestEnum.FIRST, "some-enum")); + } + + @Test + void testGetEnum_StringConversion() { + config.put("some-enum", "SECOND"); + assertEquals(TestEnum.SECOND, ConfigUtils.getEnum(config, TestEnum.class, TestEnum.FIRST, "some-enum")); + } + + @Test + void testGetEnum_StringInvalidValue() { + config.put("some-enum", "second"); + assertThrows( + IllegalArgumentException.class, + () -> ConfigUtils.getEnum(config, TestEnum.class, TestEnum.FIRST, "some-enum")); + } } diff --git a/src/site/markdown/transporter-known-issues.md b/src/site/markdown/transporter-known-issues.md index c83d6eaa1d..09b4bc1dba 100644 --- a/src/site/markdown/transporter-known-issues.md +++ b/src/site/markdown/transporter-known-issues.md @@ -25,7 +25,8 @@ This page lists known issues related to various transporters. Given this transporter uses the Java HttpClient (available since Java 11), it is the user's best interest to use latest patch version of Java, as HttpClient is getting bugfixes regularly. -Known issues: +### Known issues: + * Does not properly support `aether.transport.http.requestTimeout` configuration prior Java 26, see [JDK-8208693](https://bugs.openjdk.org/browse/JDK-8208693) * No TLS Proxy support, see [here](https://dev.to/kdrakon/httpclient-can-t-connect-to-a-tls-proxy-118a) * No SOCKS proxy support, see [JDK-8214516](https://bugs.openjdk.org/browse/JDK-8214516) @@ -34,19 +35,29 @@ Known issues: Basic authentication disabled, see [here](https://www.oracle.com/java/technologies/javase/8u111-relnotes.html). To enable HTTP Basic authentication for Proxy TLS tunneling, one must set `jdk.http.auth.tunneling.disabledScheme` to empty string, e.g. by adding `-Djdk.http.auth.tunneling.disabledScheme=""` JVM argument. +* HTTP/3 is only supported with [Java 26 and above](https://inside.java/2025/10/22/http3-support/). Maven 4 uses this transport by default for HTTP(S) protocol. ## The `apache` (Apache HttpClient) Transporter -Transporter based on Apache HttpClient. +Transporter based on Apache HttpClient 4. To use this transporter in Maven 4, you need to specify `-Dmaven.resolver.transport=apache` user property. +### Known issues: + +* Does neither support HTTP/2 nor HTTP/3 + ## The `jetty` (Jetty HttpClient) Transporter -Transporter based on Jetty HttpClient. +Transporter based on Jetty HttpClient. It requires Java 17 or above. In Maven 4 this transport is not available by default (is not bundled). To use it, you need to add `org.apache.maven.resolver.transport:transport-http-jetty` artifact with its runtime dependencies to `/lib` directory of Maven. Once added to core classpath, it will take over the role of default transport. + +### Known issues: + +* Leveraging HTTP/3 with Jetty suffers from [poor performance due to usage of the native Quiche Rust library] +(https://github.com/jetty/jetty.project/discussions/13469#discussioncomment-14125855).