View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.eclipse.aether.transport.jdk;
20  
21  import javax.net.ssl.SSLContext;
22  import javax.net.ssl.SSLEngine;
23  import javax.net.ssl.SSLException;
24  import javax.net.ssl.SSLParameters;
25  import javax.net.ssl.X509ExtendedTrustManager;
26  import javax.net.ssl.X509TrustManager;
27  
28  import java.io.BufferedInputStream;
29  import java.io.IOException;
30  import java.io.InputStream;
31  import java.io.InterruptedIOException;
32  import java.io.UncheckedIOException;
33  import java.lang.reflect.InvocationTargetException;
34  import java.lang.reflect.Method;
35  import java.net.Authenticator;
36  import java.net.ConnectException;
37  import java.net.InetAddress;
38  import java.net.InetSocketAddress;
39  import java.net.NoRouteToHostException;
40  import java.net.PasswordAuthentication;
41  import java.net.ProxySelector;
42  import java.net.Socket;
43  import java.net.URI;
44  import java.net.URISyntaxException;
45  import java.net.UnknownHostException;
46  import java.net.http.HttpClient;
47  import java.net.http.HttpClient.Version;
48  import java.net.http.HttpRequest;
49  import java.net.http.HttpResponse;
50  import java.nio.file.Files;
51  import java.nio.file.Path;
52  import java.nio.file.StandardCopyOption;
53  import java.security.cert.X509Certificate;
54  import java.time.Duration;
55  import java.time.Instant;
56  import java.time.ZoneId;
57  import java.time.ZonedDateTime;
58  import java.time.format.DateTimeFormatter;
59  import java.time.format.DateTimeParseException;
60  import java.util.Base64;
61  import java.util.HashMap;
62  import java.util.Locale;
63  import java.util.Map;
64  import java.util.Objects;
65  import java.util.Optional;
66  import java.util.Set;
67  import java.util.concurrent.Semaphore;
68  import java.util.function.Function;
69  import java.util.regex.Matcher;
70  
71  import com.github.mizosoft.methanol.Methanol;
72  import com.github.mizosoft.methanol.RetryInterceptor;
73  import com.github.mizosoft.methanol.RetryInterceptor.Context;
74  import org.eclipse.aether.ConfigurationProperties;
75  import org.eclipse.aether.ConfigurationProperties.HttpVersion;
76  import org.eclipse.aether.RepositorySystemSession;
77  import org.eclipse.aether.repository.AuthenticationContext;
78  import org.eclipse.aether.repository.RemoteRepository;
79  import org.eclipse.aether.spi.connector.transport.AbstractTransporter;
80  import org.eclipse.aether.spi.connector.transport.GetTask;
81  import org.eclipse.aether.spi.connector.transport.PeekTask;
82  import org.eclipse.aether.spi.connector.transport.PutTask;
83  import org.eclipse.aether.spi.connector.transport.TransportListenerNotifyingInputStream;
84  import org.eclipse.aether.spi.connector.transport.TransportTask;
85  import org.eclipse.aether.spi.connector.transport.http.ChecksumExtractor;
86  import org.eclipse.aether.spi.connector.transport.http.HttpTransportPropertiesBuilder;
87  import org.eclipse.aether.spi.connector.transport.http.HttpTransporter;
88  import org.eclipse.aether.spi.connector.transport.http.HttpTransporterException;
89  import org.eclipse.aether.spi.io.PathProcessor;
90  import org.eclipse.aether.transfer.HttpTransportProperty;
91  import org.eclipse.aether.transfer.NoTransporterException;
92  import org.eclipse.aether.transfer.TransferCancelledException;
93  import org.eclipse.aether.transfer.TransferEvent;
94  import org.eclipse.aether.util.ConfigUtils;
95  import org.eclipse.aether.util.connector.transport.http.HttpTransporterUtils;
96  import org.slf4j.Logger;
97  import org.slf4j.LoggerFactory;
98  
99  import static java.nio.charset.StandardCharsets.ISO_8859_1;
100 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.ACCEPT_ENCODING;
101 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CACHE_CONTROL;
102 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_LENGTH;
103 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_RANGE;
104 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_RANGE_PATTERN;
105 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.IF_UNMODIFIED_SINCE;
106 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.LAST_MODIFIED;
107 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.MULTIPLE_CHOICES;
108 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.PRECONDITION_FAILED;
109 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.RANGE;
110 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.USER_AGENT;
111 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.CONFIG_PROP_HTTP_VERSION;
112 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.CONFIG_PROP_MAX_CONCURRENT_REQUESTS;
113 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.DEFAULT_MAX_CONCURRENT_REQUESTS;
114 
115 /**
116  * JDK Transport using {@link HttpClient}.
117  * <p>
118  * Known issues:
119  * <ul>
120  *     <li>Does not properly support {@link ConfigurationProperties#REQUEST_TIMEOUT} prior Java 26, see <a href="https://bugs.openjdk.org/browse/JDK-8208693">JDK-8208693</a></li>
121  * </ul>
122  * <p>
123  * Related: <a href="https://dev.to/kdrakon/httpclient-can-t-connect-to-a-tls-proxy-118a">No TLS proxy supported</a>.
124  *
125  * @since 2.0.0
126  */
127 final class JdkTransporter extends AbstractTransporter implements HttpTransporter {
128     private static final Logger LOGGER = LoggerFactory.getLogger(JdkTransporter.class);
129 
130     private static final DateTimeFormatter RFC7231 = DateTimeFormatter.ofPattern(
131                     "EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH)
132             .withZone(ZoneId.of("GMT"));
133 
134     private static final long MODIFICATION_THRESHOLD = 60L * 1000L;
135 
136     /**
137      * Classes of IOExceptions that should not be retried (because they are permanent failures).
138      * Same as in <a href="https://github.com/apache/httpcomponents-client/blob/54900db4653d7f207477e6ee40135b88e9bcf832/httpclient/src/main/java/org/apache/http/impl/client/DefaultHttpRequestRetryHandler.java#L102">
139      * Apache HttpClient's DefaultHttpRequestRetryHandler</a>.
140      */
141     private static final Set<Class<? extends IOException>> NON_RETRIABLE_IO_EXCEPTIONS = Set.of(
142             InterruptedIOException.class,
143             UnknownHostException.class,
144             ConnectException.class,
145             NoRouteToHostException.class,
146             SSLException.class);
147 
148     private final ChecksumExtractor checksumExtractor;
149 
150     private final PathProcessor pathProcessor;
151 
152     private final URI baseUri;
153 
154     private final HttpClient client;
155 
156     private final Map<String, String> headers;
157 
158     private final int connectTimeout;
159 
160     private final int requestTimeout;
161 
162     private final Boolean expectContinue;
163 
164     private final Semaphore maxConcurrentRequests;
165 
166     private final boolean preemptivePutAuth;
167 
168     private final boolean preemptiveAuth;
169 
170     private final boolean sendRfc9457Accept;
171 
172     private PasswordAuthentication serverAuthentication;
173 
174     private PasswordAuthentication proxyAuthentication;
175 
176     JdkTransporter(
177             RepositorySystemSession session,
178             RemoteRepository repository,
179             int javaVersion,
180             ChecksumExtractor checksumExtractor,
181             PathProcessor pathProcessor)
182             throws NoTransporterException {
183         this.checksumExtractor = checksumExtractor;
184         this.pathProcessor = pathProcessor;
185         try {
186             this.baseUri = HttpTransporterUtils.getBaseUri(repository);
187         } catch (URISyntaxException e) {
188             throw new NoTransporterException(repository, e.getMessage(), e);
189         }
190 
191         HashMap<String, String> headers = new HashMap<>();
192         String userAgent = HttpTransporterUtils.getUserAgent(session, repository);
193         if (userAgent != null) {
194             headers.put(USER_AGENT, userAgent);
195         }
196         Map<String, String> configuredHeaders = HttpTransporterUtils.getHttpHeaders(session, repository);
197         if (configuredHeaders != null) {
198             headers.putAll(configuredHeaders);
199         }
200         headers.put(CACHE_CONTROL, "no-cache, no-store");
201 
202         this.connectTimeout = HttpTransporterUtils.getHttpConnectTimeout(session, repository);
203         this.requestTimeout = HttpTransporterUtils.getHttpRequestTimeout(session, repository);
204         Optional<Boolean> expectContinue = HttpTransporterUtils.getHttpExpectContinue(session, repository);
205         if (javaVersion > 19) {
206             this.expectContinue = expectContinue.orElse(null);
207         } else {
208             this.expectContinue = null;
209             if (expectContinue.isPresent()) {
210                 LOGGER.warn(
211                         "Configuration for Expect-Continue set but is ignored on Java versions below 20 (current java version is {}) due https://bugs.openjdk.org/browse/JDK-8286171",
212                         javaVersion);
213             }
214         }
215         final String httpsSecurityMode = HttpTransporterUtils.getHttpsSecurityMode(session, repository);
216         final boolean insecure = ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE.equals(httpsSecurityMode);
217 
218         this.maxConcurrentRequests = new Semaphore(ConfigUtils.getInteger(
219                 session,
220                 DEFAULT_MAX_CONCURRENT_REQUESTS,
221                 CONFIG_PROP_MAX_CONCURRENT_REQUESTS + "." + repository.getId(),
222                 CONFIG_PROP_MAX_CONCURRENT_REQUESTS));
223 
224         this.preemptiveAuth = HttpTransporterUtils.isHttpPreemptiveAuth(session, repository);
225         this.preemptivePutAuth = HttpTransporterUtils.isHttpPreemptivePutAuth(session, repository);
226         this.sendRfc9457Accept = HttpTransporterUtils.isHttpSendRfc9457Accept(session, repository);
227 
228         this.headers = headers;
229         this.client = createClient(session, repository, insecure);
230     }
231 
232     private URI resolve(TransportTask task) {
233         return baseUri.resolve(task.getLocation());
234     }
235 
236     private ConnectException enhance(ConnectException connectException) {
237         ConnectException result = new ConnectException("Connection to " + baseUri.toASCIIString() + " refused");
238         result.initCause(connectException);
239         return result;
240     }
241 
242     @Override
243     protected void implPeek(PeekTask task) throws Exception {
244         HttpRequest.Builder request =
245                 HttpRequest.newBuilder().uri(resolve(task)).method("HEAD", HttpRequest.BodyPublishers.noBody());
246         headers.forEach(request::setHeader);
247 
248         prepare(request);
249         try {
250             HttpResponse<Void> response = send(request.build(), HttpResponse.BodyHandlers.discarding());
251             task.getListener().transportPropertiesAvailable(createTransportProperties(response));
252             if (response.statusCode() >= MULTIPLE_CHOICES) {
253                 throw new HttpTransporterException(response.statusCode());
254             }
255         } catch (ConnectException e) {
256             throw enhance(e);
257         }
258     }
259 
260     @Override
261     protected void implGet(GetTask task) throws Exception {
262         boolean resume = task.getResumeOffset() > 0L && task.getDataPath() != null;
263         HttpResponse<InputStream> response = null;
264 
265         try {
266             while (true) {
267                 HttpRequest.Builder request =
268                         HttpRequest.newBuilder().uri(resolve(task)).GET();
269                 headers.forEach(request::setHeader);
270                 if (sendRfc9457Accept) {
271                     JdkRFC9457Reporter.INSTANCE.prepareRequest(request);
272                 }
273 
274                 if (resume) {
275                     long resumeOffset = task.getResumeOffset();
276                     long lastModified = pathProcessor.lastModified(task.getDataPath(), 0L);
277                     request.header(RANGE, "bytes=" + resumeOffset + '-');
278                     request.header(
279                             IF_UNMODIFIED_SINCE,
280                             RFC7231.format(Instant.ofEpochMilli(lastModified - MODIFICATION_THRESHOLD)));
281                     request.header(ACCEPT_ENCODING, "identity");
282                 }
283 
284                 prepare(request);
285                 try {
286                     response = send(request.build(), HttpResponse.BodyHandlers.ofInputStream());
287                     task.getListener().transportPropertiesAvailable(createTransportProperties(response));
288                     if (response.statusCode() >= MULTIPLE_CHOICES) {
289                         if (resume && response.statusCode() == PRECONDITION_FAILED) {
290                             closeBody(response);
291                             resume = false;
292                             continue;
293                         }
294                         JdkRFC9457Reporter.INSTANCE.generateException(response, (statusCode, reasonPhrase) -> {
295                             throw new HttpTransporterException(statusCode);
296                         });
297                     }
298                 } catch (ConnectException e) {
299                     throw enhance(e);
300                 }
301                 break;
302             }
303 
304             long offset = 0L,
305                     length = response.headers().firstValueAsLong(CONTENT_LENGTH).orElse(-1L);
306             if (resume) {
307                 String range = response.headers().firstValue(CONTENT_RANGE).orElse(null);
308                 if (range != null) {
309                     Matcher m = CONTENT_RANGE_PATTERN.matcher(range);
310                     if (!m.matches()) {
311                         throw new IOException("Invalid Content-Range header for partial download: " + range);
312                     }
313                     offset = Long.parseLong(m.group(1));
314                     length = Long.parseLong(m.group(2)) + 1L;
315                     if (offset < 0L || offset >= length || (offset > 0L && offset != task.getResumeOffset())) {
316                         throw new IOException("Invalid Content-Range header for partial download from offset "
317                                 + task.getResumeOffset() + ": " + range);
318                     }
319                 }
320             }
321 
322             final boolean downloadResumed = offset > 0L;
323             final Path dataFile = task.getDataPath();
324             if (dataFile == null) {
325                 try (InputStream is = response.body()) {
326                     utilGet(task, is, true, length, downloadResumed);
327                 }
328             } else {
329                 try (PathProcessor.CollocatedTempFile tempFile = pathProcessor.newTempFile(dataFile)) {
330                     task.setDataPath(tempFile.getPath(), downloadResumed);
331                     if (downloadResumed && Files.isRegularFile(dataFile)) {
332                         try (InputStream inputStream = new BufferedInputStream(Files.newInputStream(dataFile))) {
333                             Files.copy(inputStream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING);
334                         }
335                     }
336                     try (InputStream is = response.body()) {
337                         utilGet(task, is, true, length, downloadResumed);
338                     }
339                     tempFile.move();
340                 } finally {
341                     task.setDataPath(dataFile);
342                 }
343             }
344             if (task.getDataPath() != null) {
345                 String lastModifiedHeader = response.headers()
346                         .firstValue(LAST_MODIFIED)
347                         .orElse(null); // note: Wagon also does first not last
348                 if (lastModifiedHeader != null) {
349                     try {
350                         pathProcessor.setLastModified(
351                                 task.getDataPath(),
352                                 ZonedDateTime.parse(lastModifiedHeader, RFC7231)
353                                         .toInstant()
354                                         .toEpochMilli());
355                     } catch (DateTimeParseException e) {
356                         // fall through
357                     }
358                 }
359             }
360             Map<String, String> checksums = checksumExtractor.extractChecksums(headerGetter(response));
361             if (checksums != null && !checksums.isEmpty()) {
362                 checksums.forEach(task::setChecksum);
363             }
364         } finally {
365             closeBody(response);
366         }
367     }
368 
369     private Map<TransferEvent.TransportPropertyKey, Object> createTransportProperties(HttpResponse<?> response) {
370         HttpTransportPropertiesBuilder builder = new HttpTransportPropertiesBuilder(toHttpVersion(response.version()));
371         response.sslSession().ifPresent(ssl -> {
372             builder.withSslProtocol(ssl.getProtocol());
373             builder.withSslCipherSuite(ssl.getCipherSuite());
374         });
375         // TODO: add compression algorithm if any (https://github.com/mizosoft/methanol/issues/182)
376         return builder.build();
377     }
378 
379     static HttpTransportProperty.HttpVersion toHttpVersion(HttpClient.Version version) {
380         switch (version) {
381             case HTTP_1_1:
382                 return HttpTransportProperty.HttpVersion.HTTP_1_1;
383             case HTTP_2:
384                 return HttpTransportProperty.HttpVersion.HTTP_2;
385             default:
386                 // support HTTP_3 via name (as only part of Java 26+ API)
387                 if ("HTTP_3".equals(version.name())) {
388                     return HttpTransportProperty.HttpVersion.HTTP_3;
389                 } else {
390                     throw new IllegalArgumentException("Unsupported HTTP version: " + version);
391                 }
392         }
393     }
394 
395     private static Function<String, String> headerGetter(HttpResponse<?> response) {
396         return s -> response.headers().firstValue(s).orElse(null);
397     }
398 
399     private void closeBody(HttpResponse<InputStream> streamHttpResponse) throws IOException {
400         if (streamHttpResponse != null) {
401             InputStream body = streamHttpResponse.body();
402             if (body != null) {
403                 body.close();
404             }
405         }
406     }
407 
408     @Override
409     protected void implPut(PutTask task) throws Exception {
410         HttpRequest.Builder request = HttpRequest.newBuilder().uri(resolve(task));
411         if (expectContinue != null) {
412             request = request.expectContinue(expectContinue);
413         }
414         headers.forEach(request::setHeader);
415         if (sendRfc9457Accept) {
416             JdkRFC9457Reporter.INSTANCE.prepareRequest(request);
417         }
418         if (task.getDataLength() == 0L) {
419             request.PUT(HttpRequest.BodyPublishers.noBody());
420         } else {
421             request.PUT(HttpRequest.BodyPublishers.fromPublisher(
422                     HttpRequest.BodyPublishers.ofInputStream(() -> {
423                         try {
424                             // transport properties are not available for outgoing requests
425                             return new TransportListenerNotifyingInputStream(
426                                     task.newInputStream(), task.getListener(), task.getDataLength());
427                         } catch (IOException e) {
428                             throw new UncheckedIOException(e);
429                         }
430                     }),
431                     // this adds a content-length request header
432                     task.getDataLength()));
433         }
434         prepare(request);
435         HttpResponse<InputStream> response = null;
436         try {
437             response = send(request.build(), HttpResponse.BodyHandlers.ofInputStream());
438             task.getListener().transportPropertiesAvailable(createTransportProperties(response));
439             if (response.statusCode() >= MULTIPLE_CHOICES) {
440                 JdkRFC9457Reporter.INSTANCE.generateException(response, (statusCode, reasonPhrase) -> {
441                     throw new HttpTransporterException(statusCode);
442                 });
443             }
444         } catch (ConnectException e) {
445             throw enhance(e);
446         } catch (IOException e) {
447             // unwrap possible underlying exception from body supplier
448             Throwable rootCause = getRootCause(e);
449             if (rootCause instanceof TransferCancelledException) {
450                 throw (TransferCancelledException) rootCause;
451             }
452             throw e;
453         } finally {
454             closeBody(response);
455         }
456     }
457 
458     private void prepare(HttpRequest.Builder requestBuilder) {
459         if (preemptiveAuth
460                 || (preemptivePutAuth && requestBuilder.build().method().equals("PUT"))) {
461             if (serverAuthentication != null) {
462                 // https://stackoverflow.com/a/58612586
463                 requestBuilder.setHeader(
464                         "Authorization",
465                         getBasicAuthValue(serverAuthentication.getUserName(), serverAuthentication.getPassword()));
466             }
467             if (proxyAuthentication != null) {
468                 requestBuilder.setHeader(
469                         "Proxy-Authorization",
470                         getBasicAuthValue(proxyAuthentication.getUserName(), proxyAuthentication.getPassword()));
471             }
472         }
473     }
474 
475     static String getBasicAuthValue(String username, char[] password) {
476         // Java's HTTP client uses ISO-8859-1 for Basic auth encoding
477         return "Basic "
478                 + Base64.getEncoder().encodeToString((username + ':' + String.valueOf(password)).getBytes(ISO_8859_1));
479     }
480 
481     private <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler)
482             throws Exception {
483         maxConcurrentRequests.acquire();
484         try {
485             return client.send(request, responseBodyHandler);
486         } finally {
487             maxConcurrentRequests.release();
488         }
489     }
490 
491     @Override
492     protected void implClose() {
493         if (client != null) {
494             JdkTransporterCloser.closer(client).run();
495         }
496     }
497 
498     HttpClient.Version getHttpVersion(RepositorySystemSession session, RemoteRepository repository) {
499         HttpVersion httpVersion = HttpTransporterUtils.getHttpVersion(session, repository);
500         if (httpVersion == ConfigurationProperties.DEFAULT_HTTP_VERSION) {
501             // Fall back to legacy JDK Transporter specific property when it is explicitly configured.
502             String configuredLegacyHttpVersion = ConfigUtils.getString(
503                     session, null, CONFIG_PROP_HTTP_VERSION + "." + repository.getId(), CONFIG_PROP_HTTP_VERSION);
504             if (configuredLegacyHttpVersion != null) {
505                 return resolveHttpVersion(configuredLegacyHttpVersion);
506             }
507             return HttpClient.Version.HTTP_2;
508         } else {
509             switch (httpVersion) {
510                 case MAXIMUM:
511                     return getMaximumSupportedHttpVersion();
512                 case HTTP_1_1:
513                     return HttpClient.Version.HTTP_1_1;
514                 case HTTP_2:
515                 case DEFAULT:
516                     return HttpClient.Version.HTTP_2;
517                 case HTTP_3:
518                     return resolveHttpVersion("HTTP_3");
519                 default:
520                     // unreachable but necessary for Checkstyle to not complain about missing default case
521                     throw new IllegalStateException("Unknown HTTP version: " + httpVersion);
522             }
523         }
524     }
525 
526     private HttpClient.Version resolveHttpVersion(String requestedVersion) {
527         try {
528             return HttpClient.Version.valueOf(requestedVersion);
529         } catch (IllegalArgumentException e) {
530             HttpClient.Version maximumHttpVersion = getMaximumSupportedHttpVersion();
531             LOGGER.warn(
532                     "HTTP version '{}' is not supported by the running JRE, using '{}' instead",
533                     requestedVersion,
534                     maximumHttpVersion);
535             return maximumHttpVersion;
536         }
537     }
538 
539     HttpClient.Version getMaximumSupportedHttpVersion() {
540         HttpClient.Version[] values = HttpClient.Version.values();
541         return values[values.length - 1];
542     }
543 
544     private HttpClient createClient(RepositorySystemSession session, RemoteRepository repository, boolean insecure)
545             throws RuntimeException {
546 
547         HashMap<Authenticator.RequestorType, PasswordAuthentication> authentications = new HashMap<>();
548         SSLContext sslContext = null;
549         try (AuthenticationContext repoAuthContext = AuthenticationContext.forRepository(session, repository)) {
550             if (repoAuthContext != null) {
551                 sslContext = repoAuthContext.get(AuthenticationContext.SSL_CONTEXT, SSLContext.class);
552 
553                 String username = repoAuthContext.get(AuthenticationContext.USERNAME);
554                 String password = repoAuthContext.get(AuthenticationContext.PASSWORD);
555                 serverAuthentication = new PasswordAuthentication(username, password.toCharArray());
556                 authentications.put(Authenticator.RequestorType.SERVER, serverAuthentication);
557             }
558         }
559 
560         Version httpVersion = getHttpVersion(session, repository);
561         if (sslContext == null) {
562             try {
563                 if (insecure) {
564                     if (httpVersion.name().equals("HTTP_3")) {
565                         // custom trust manager not supported for HTTP/3 (Quic)
566                         // (https://github.com/openjdk/jdk/blob/631b675d7949a0e6312d8d6f45e2515d53b12f05/src/java.base/share/classes/sun/security/ssl/SSLContextImpl.java#L529)
567                         // https://openjdk.org/jeps/517
568                         // https://mail.openjdk.org/archives/list/net-dev@openjdk.org/thread/LHSC7MWRFDJE2KGS2QMPFJPZX3XEQKOQ/
569                         throw new IllegalStateException(
570                                 "Insecure HTTPS connections are not supported for HTTP/3 (Quic)");
571                     }
572                     sslContext = SSLContext.getInstance("TLS");
573                     X509ExtendedTrustManager tm = new X509ExtendedTrustManager() {
574                         @Override
575                         public void checkClientTrusted(X509Certificate[] chain, String authType) {}
576 
577                         @Override
578                         public void checkServerTrusted(X509Certificate[] chain, String authType) {}
579 
580                         @Override
581                         public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) {}
582 
583                         @Override
584                         public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) {}
585 
586                         @Override
587                         public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {}
588 
589                         @Override
590                         public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {}
591 
592                         @Override
593                         public X509Certificate[] getAcceptedIssuers() {
594                             return null;
595                         }
596                     };
597                     sslContext.init(null, new X509TrustManager[] {tm}, null);
598                 } else {
599                     sslContext = SSLContext.getDefault();
600                 }
601             } catch (Exception e) {
602                 if (e instanceof RuntimeException) {
603                     throw (RuntimeException) e;
604                 } else {
605                     throw new IllegalStateException("SSL Context setup failure", e);
606                 }
607             }
608         } else {
609             if (insecure) {
610                 throw new IllegalStateException(
611                         "Insecure HTTPS connections are not supported when a custom SSLContext is configured");
612             }
613         }
614 
615         Methanol.Builder builder = Methanol.newBuilder()
616                 .version(httpVersion)
617                 .followRedirects(HttpClient.Redirect.NORMAL)
618                 .connectTimeout(Duration.ofMillis(connectTimeout))
619                 // this only considers the time until the response header is received, see
620                 // https://bugs.openjdk.org/browse/JDK-8208693
621                 // but better than nothing
622                 .requestTimeout(Duration.ofMillis(requestTimeout))
623                 .sslContext(sslContext);
624 
625         if (insecure) {
626             SSLParameters sslParameters = sslContext.getDefaultSSLParameters();
627             sslParameters.setEndpointIdentificationAlgorithm(null);
628             builder.sslParameters(sslParameters);
629         }
630 
631         setLocalAddress(
632                 builder,
633                 HttpTransporterUtils.getHttpLocalAddress(session, repository).orElse(null));
634 
635         if (repository.getProxy() != null) {
636             InetSocketAddress proxyAddress = new InetSocketAddress(
637                     repository.getProxy().getHost(), repository.getProxy().getPort());
638             if (proxyAddress.isUnresolved()) {
639                 throw new IllegalStateException(
640                         "Proxy host " + repository.getProxy().getHost() + " could not be resolved");
641             }
642             builder.proxy(ProxySelector.of(proxyAddress));
643             try (AuthenticationContext proxyAuthContext = AuthenticationContext.forProxy(session, repository)) {
644                 if (proxyAuthContext != null) {
645                     String username = proxyAuthContext.get(AuthenticationContext.USERNAME);
646                     String password = proxyAuthContext.get(AuthenticationContext.PASSWORD);
647 
648                     proxyAuthentication = new PasswordAuthentication(username, password.toCharArray());
649                     authentications.put(Authenticator.RequestorType.PROXY, proxyAuthentication);
650                 }
651             }
652         }
653 
654         if (!authentications.isEmpty()) {
655             builder.authenticator(new Authenticator() {
656                 @Override
657                 protected PasswordAuthentication getPasswordAuthentication() {
658                     return authentications.get(getRequestorType());
659                 }
660             });
661         }
662 
663         configureRetryHandler(session, repository, builder);
664 
665         return builder.build();
666     }
667 
668     private static class RetryLoggingListener implements RetryInterceptor.Listener {
669         private final int maxNumRetries;
670 
671         RetryLoggingListener(int maxNumRetries) {
672             this.maxNumRetries = maxNumRetries;
673         }
674 
675         @Override
676         public void onRetry(Context<?> context, HttpRequest nextRequest, Duration delay) {
677             LOGGER.warn(
678                     "{} request to {} failed (attempt {} of {}) due to {}. Retrying in {} ms...",
679                     context.request().method(),
680                     context.request().uri(),
681                     context.retryCount() + 1,
682                     maxNumRetries + 1,
683                     getReason(context),
684                     delay.toMillis());
685         }
686 
687         String getReason(Context<?> context) {
688             if (context.exception().isPresent()) {
689                 return context.exception().get().getMessage();
690             } else if (context.response().isPresent()) {
691                 return "status " + context.response().get().statusCode();
692             }
693             // should not happen
694             throw new IllegalStateException("No exception or response present in retry context");
695         }
696     }
697 
698     private static void configureRetryHandler(
699             RepositorySystemSession session, RemoteRepository repository, Methanol.Builder builder) {
700         int retryCount = HttpTransporterUtils.getHttpRetryHandlerCount(session, repository);
701         long retryInterval = HttpTransporterUtils.getHttpRetryHandlerInterval(session, repository);
702         long retryIntervalMax = HttpTransporterUtils.getHttpRetryHandlerIntervalMax(session, repository);
703         if (retryCount > 0) {
704             Methanol.Interceptor rateLimitingRetryInterceptor = RetryInterceptor.newBuilder()
705                     .maxRetries(retryCount)
706                     .onStatus(HttpTransporterUtils.getHttpServiceUnavailableCodes(session, repository)::contains)
707                     .listener(new RetryLoggingListener(retryCount))
708                     .backoff(RetryInterceptor.BackoffStrategy.retryAfterOr(RetryInterceptor.BackoffStrategy.linear(
709                             Duration.ofMillis(retryInterval), Duration.ofMillis(retryIntervalMax))))
710                     .build();
711             builder.interceptor(rateLimitingRetryInterceptor);
712             Methanol.Interceptor retryIoExceptionsInterceptor = RetryInterceptor.newBuilder()
713                     // this is in addition to the JDK internal retries (https://github.com/mizosoft/methanol/issues/174)
714                     // e.g. for connection timeouts this is hardcoded to 2 attempts:
715                     // https://github.com/openjdk/jdk/blob/640343f7d94894b0378ea5b1768eeac203a9aaf8/src/java.net.http/share/classes/jdk/internal/net/http/MultiExchange.java#L665
716                     .maxRetries(retryCount)
717                     .onException(t -> {
718                         // exceptions from body publishers are wrapped inside IOExceptions
719                         // but hard to distinguish from others, so just exclude some we know are emitted from body
720                         // suppliers (https://github.com/mizosoft/methanol/issues/179)
721                         Throwable rootCause = getRootCause(t);
722                         return t instanceof IOException
723                                 && !NON_RETRIABLE_IO_EXCEPTIONS.contains(t.getClass())
724                                 && !(rootCause instanceof TransferCancelledException);
725                     })
726                     .listener(new RetryLoggingListener(retryCount))
727                     .build();
728             builder.interceptor(retryIoExceptionsInterceptor);
729         }
730     }
731 
732     private static void setLocalAddress(HttpClient.Builder builder, InetAddress address) {
733         if (address == null) {
734             return;
735         }
736         try {
737             final Method mtd = builder.getClass().getDeclaredMethod("localAddress", InetAddress.class);
738             if (!mtd.canAccess(builder)) {
739                 mtd.setAccessible(true);
740             }
741             mtd.invoke(builder, address);
742         } catch (final NoSuchMethodException ignore) {
743             // skip, not yet in the API
744         } catch (InvocationTargetException e) {
745             throw new IllegalStateException(e.getTargetException());
746         } catch (IllegalAccessException e) {
747             throw new IllegalStateException(e);
748         }
749     }
750 
751     private static Throwable getRootCause(Throwable throwable) {
752         Objects.requireNonNull(throwable);
753         Throwable rootCause = throwable;
754         while (rootCause.getCause() != null && rootCause.getCause() != rootCause) {
755             rootCause = rootCause.getCause();
756         }
757         return rootCause;
758     }
759 }