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.TreeSet;
68  import java.util.concurrent.Semaphore;
69  import java.util.function.Function;
70  import java.util.regex.Matcher;
71  
72  import com.github.mizosoft.methanol.Methanol;
73  import com.github.mizosoft.methanol.RetryInterceptor;
74  import com.github.mizosoft.methanol.RetryInterceptor.Context;
75  import org.eclipse.aether.ConfigurationProperties;
76  import org.eclipse.aether.ConfigurationProperties.HttpVersion;
77  import org.eclipse.aether.RepositorySystemSession;
78  import org.eclipse.aether.repository.AuthenticationContext;
79  import org.eclipse.aether.repository.RemoteRepository;
80  import org.eclipse.aether.spi.connector.transport.AbstractTransporter;
81  import org.eclipse.aether.spi.connector.transport.GetTask;
82  import org.eclipse.aether.spi.connector.transport.PeekTask;
83  import org.eclipse.aether.spi.connector.transport.PutTask;
84  import org.eclipse.aether.spi.connector.transport.TransportListenerNotifyingInputStream;
85  import org.eclipse.aether.spi.connector.transport.TransportTask;
86  import org.eclipse.aether.spi.connector.transport.http.ChecksumExtractor;
87  import org.eclipse.aether.spi.connector.transport.http.HttpTransportPropertiesBuilder;
88  import org.eclipse.aether.spi.connector.transport.http.HttpTransporter;
89  import org.eclipse.aether.spi.connector.transport.http.HttpTransporterException;
90  import org.eclipse.aether.spi.io.PathProcessor;
91  import org.eclipse.aether.transfer.HttpTransportProperty;
92  import org.eclipse.aether.transfer.NoTransporterException;
93  import org.eclipse.aether.transfer.TransferCancelledException;
94  import org.eclipse.aether.transfer.TransferEvent;
95  import org.eclipse.aether.util.ConfigUtils;
96  import org.eclipse.aether.util.connector.transport.http.HttpTransporterUtils;
97  import org.slf4j.Logger;
98  import org.slf4j.LoggerFactory;
99  
100 import static java.nio.charset.StandardCharsets.ISO_8859_1;
101 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.ACCEPT_ENCODING;
102 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CACHE_CONTROL;
103 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_LENGTH;
104 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_RANGE;
105 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_RANGE_PATTERN;
106 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.IF_UNMODIFIED_SINCE;
107 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.LAST_MODIFIED;
108 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.MULTIPLE_CHOICES;
109 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.PRECONDITION_FAILED;
110 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.RANGE;
111 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.USER_AGENT;
112 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.CONFIG_PROP_HTTP_VERSION;
113 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.CONFIG_PROP_MAX_CONCURRENT_REQUESTS;
114 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.CONFIG_PROP_UNSCOPED_AUTHENTICATION;
115 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.DEFAULT_MAX_CONCURRENT_REQUESTS;
116 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.DEFAULT_UNSCOPED_AUTHENTICATION;
117 
118 /**
119  * JDK Transport using {@link HttpClient}.
120  * <p>
121  * Known issues:
122  * <ul>
123  *     <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>
124  * </ul>
125  * <p>
126  * Related: <a href="https://dev.to/kdrakon/httpclient-can-t-connect-to-a-tls-proxy-118a">No TLS proxy supported</a>.
127  *
128  * @since 2.0.0
129  */
130 final class JdkTransporter extends AbstractTransporter implements HttpTransporter {
131     private static final Logger LOGGER = LoggerFactory.getLogger(JdkTransporter.class);
132 
133     private static final DateTimeFormatter RFC7231 = DateTimeFormatter.ofPattern(
134                     "EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH)
135             .withZone(ZoneId.of("GMT"));
136 
137     private static final long MODIFICATION_THRESHOLD = 60L * 1000L;
138 
139     /**
140      * Maximum redirect hops followed when this transporter follows redirects itself (origin-scoped headers mode);
141      * same limit as the JDK {@link HttpClient}'s own default ({@code jdk.httpclient.redirects.retrylimit}).
142      */
143     private static final int MAX_REDIRECTS = 5;
144 
145     private static final String LOCATION = "Location";
146 
147     /**
148      * Classes of IOExceptions that should not be retried (because they are permanent failures).
149      * 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">
150      * Apache HttpClient's DefaultHttpRequestRetryHandler</a>.
151      */
152     private static final Set<Class<? extends IOException>> NON_RETRIABLE_IO_EXCEPTIONS = Set.of(
153             InterruptedIOException.class,
154             UnknownHostException.class,
155             ConnectException.class,
156             NoRouteToHostException.class,
157             SSLException.class);
158 
159     private final ChecksumExtractor checksumExtractor;
160 
161     private final PathProcessor pathProcessor;
162 
163     private final URI baseUri;
164 
165     private final HttpClient client;
166 
167     private final Map<String, String> headers;
168 
169     private final boolean originScopedHeaders;
170 
171     private final Set<String> crossOriginExcludedHeaders;
172 
173     private final int connectTimeout;
174 
175     private final int requestTimeout;
176 
177     private final Boolean expectContinue;
178 
179     private final Semaphore maxConcurrentRequests;
180 
181     private final boolean preemptivePutAuth;
182 
183     private final boolean preemptiveAuth;
184 
185     private final boolean sendRfc9457Accept;
186 
187     private PasswordAuthentication serverAuthentication;
188 
189     private PasswordAuthentication proxyAuthentication;
190 
191     JdkTransporter(
192             RepositorySystemSession session,
193             RemoteRepository repository,
194             int javaVersion,
195             ChecksumExtractor checksumExtractor,
196             PathProcessor pathProcessor)
197             throws NoTransporterException {
198         this.checksumExtractor = checksumExtractor;
199         this.pathProcessor = pathProcessor;
200         try {
201             this.baseUri = HttpTransporterUtils.getBaseUri(repository);
202         } catch (URISyntaxException e) {
203             throw new NoTransporterException(repository, e.getMessage(), e);
204         }
205 
206         HashMap<String, String> headers = new HashMap<>();
207         String userAgent = HttpTransporterUtils.getUserAgent(session, repository);
208         if (userAgent != null) {
209             headers.put(USER_AGENT, userAgent);
210         }
211         Map<String, String> configuredHeaders = HttpTransporterUtils.getHttpHeaders(session, repository);
212         if (configuredHeaders != null) {
213             headers.putAll(configuredHeaders);
214         }
215         headers.put(CACHE_CONTROL, "no-cache, no-store");
216 
217         this.connectTimeout = HttpTransporterUtils.getHttpConnectTimeout(session, repository);
218         this.requestTimeout = HttpTransporterUtils.getHttpRequestTimeout(session, repository);
219         Optional<Boolean> expectContinue = HttpTransporterUtils.getHttpExpectContinue(session, repository);
220         if (javaVersion > 19) {
221             this.expectContinue = expectContinue.orElse(null);
222         } else {
223             this.expectContinue = null;
224             if (expectContinue.isPresent()) {
225                 LOGGER.warn(
226                         "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",
227                         javaVersion);
228             }
229         }
230         final String httpsSecurityMode = HttpTransporterUtils.getHttpsSecurityMode(session, repository);
231         final boolean insecure = ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE.equals(httpsSecurityMode);
232 
233         this.maxConcurrentRequests = new Semaphore(ConfigUtils.getInteger(
234                 session,
235                 DEFAULT_MAX_CONCURRENT_REQUESTS,
236                 CONFIG_PROP_MAX_CONCURRENT_REQUESTS + "." + repository.getId(),
237                 CONFIG_PROP_MAX_CONCURRENT_REQUESTS));
238 
239         this.preemptiveAuth = HttpTransporterUtils.isHttpPreemptiveAuth(session, repository);
240         this.preemptivePutAuth = HttpTransporterUtils.isHttpPreemptivePutAuth(session, repository);
241         this.sendRfc9457Accept = HttpTransporterUtils.isHttpSendRfc9457Accept(session, repository);
242 
243         this.originScopedHeaders = ConfigUtils.getBoolean(
244                 session,
245                 JdkTransporterConfigurationKeys.DEFAULT_ORIGIN_SCOPED_HEADERS,
246                 JdkTransporterConfigurationKeys.CONFIG_PROP_ORIGIN_SCOPED_HEADERS + "." + repository.getId(),
247                 JdkTransporterConfigurationKeys.CONFIG_PROP_ORIGIN_SCOPED_HEADERS);
248         TreeSet<String> crossOriginExcludedHeaders = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
249         // preemptively applied Authorization (see #prepare) must never leave the repository origin either
250         crossOriginExcludedHeaders.add("Authorization");
251         if (configuredHeaders != null) {
252             crossOriginExcludedHeaders.addAll(configuredHeaders.keySet());
253         }
254         this.crossOriginExcludedHeaders = crossOriginExcludedHeaders;
255 
256         this.headers = headers;
257         this.client = createClient(session, repository, insecure);
258     }
259 
260     private URI resolve(TransportTask task) {
261         return baseUri.resolve(task.getLocation());
262     }
263 
264     private ConnectException enhance(ConnectException connectException) {
265         ConnectException result = new ConnectException("Connection to " + baseUri.toASCIIString() + " refused");
266         result.initCause(connectException);
267         return result;
268     }
269 
270     @Override
271     protected void implPeek(PeekTask task) throws Exception {
272         HttpRequest.Builder request =
273                 HttpRequest.newBuilder().uri(resolve(task)).method("HEAD", HttpRequest.BodyPublishers.noBody());
274         headers.forEach(request::setHeader);
275 
276         prepare(request);
277         try {
278             HttpResponse<Void> response = send(request.build(), HttpResponse.BodyHandlers.discarding());
279             task.getListener().transportPropertiesAvailable(createTransportProperties(response));
280             if (response.statusCode() >= MULTIPLE_CHOICES) {
281                 throw new HttpTransporterException(response.statusCode());
282             }
283         } catch (ConnectException e) {
284             throw enhance(e);
285         }
286     }
287 
288     @Override
289     protected void implGet(GetTask task) throws Exception {
290         boolean resume = task.getResumeOffset() > 0L && task.getDataPath() != null;
291         HttpResponse<InputStream> response = null;
292 
293         try {
294             while (true) {
295                 HttpRequest.Builder request =
296                         HttpRequest.newBuilder().uri(resolve(task)).GET();
297                 headers.forEach(request::setHeader);
298                 if (sendRfc9457Accept) {
299                     JdkRFC9457Reporter.INSTANCE.prepareRequest(request);
300                 }
301 
302                 if (resume) {
303                     long resumeOffset = task.getResumeOffset();
304                     long lastModified = pathProcessor.lastModified(task.getDataPath(), 0L);
305                     request.header(RANGE, "bytes=" + resumeOffset + '-');
306                     request.header(
307                             IF_UNMODIFIED_SINCE,
308                             RFC7231.format(Instant.ofEpochMilli(lastModified - MODIFICATION_THRESHOLD)));
309                     request.header(ACCEPT_ENCODING, "identity");
310                 }
311 
312                 prepare(request);
313                 try {
314                     response = send(request.build(), HttpResponse.BodyHandlers.ofInputStream());
315                     task.getListener().transportPropertiesAvailable(createTransportProperties(response));
316                     if (response.statusCode() >= MULTIPLE_CHOICES) {
317                         if (resume && response.statusCode() == PRECONDITION_FAILED) {
318                             closeBody(response);
319                             resume = false;
320                             continue;
321                         }
322                         JdkRFC9457Reporter.INSTANCE.generateException(response, (statusCode, reasonPhrase) -> {
323                             throw new HttpTransporterException(statusCode);
324                         });
325                     }
326                 } catch (ConnectException e) {
327                     throw enhance(e);
328                 }
329                 break;
330             }
331 
332             long offset = 0L,
333                     length = response.headers().firstValueAsLong(CONTENT_LENGTH).orElse(-1L);
334             if (resume) {
335                 String range = response.headers().firstValue(CONTENT_RANGE).orElse(null);
336                 if (range != null) {
337                     Matcher m = CONTENT_RANGE_PATTERN.matcher(range);
338                     if (!m.matches()) {
339                         throw new IOException("Invalid Content-Range header for partial download: " + range);
340                     }
341                     offset = Long.parseLong(m.group(1));
342                     length = Long.parseLong(m.group(2)) + 1L;
343                     if (offset < 0L || offset >= length || (offset > 0L && offset != task.getResumeOffset())) {
344                         throw new IOException("Invalid Content-Range header for partial download from offset "
345                                 + task.getResumeOffset() + ": " + range);
346                     }
347                 }
348             }
349 
350             final boolean downloadResumed = offset > 0L;
351             final Path dataFile = task.getDataPath();
352             if (dataFile == null) {
353                 try (InputStream is = response.body()) {
354                     utilGet(task, is, true, length, downloadResumed);
355                 }
356             } else {
357                 try (PathProcessor.CollocatedTempFile tempFile = pathProcessor.newTempFile(dataFile)) {
358                     task.setDataPath(tempFile.getPath(), downloadResumed);
359                     if (downloadResumed && Files.isRegularFile(dataFile)) {
360                         try (InputStream inputStream = new BufferedInputStream(Files.newInputStream(dataFile))) {
361                             Files.copy(inputStream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING);
362                         }
363                     }
364                     try (InputStream is = response.body()) {
365                         utilGet(task, is, true, length, downloadResumed);
366                     }
367                     tempFile.move();
368                 } finally {
369                     task.setDataPath(dataFile);
370                 }
371             }
372             if (task.getDataPath() != null) {
373                 String lastModifiedHeader = response.headers()
374                         .firstValue(LAST_MODIFIED)
375                         .orElse(null); // note: Wagon also does first not last
376                 if (lastModifiedHeader != null) {
377                     try {
378                         pathProcessor.setLastModified(
379                                 task.getDataPath(),
380                                 HttpTransporterUtils.clampRemoteLastModified(
381                                         ZonedDateTime.parse(lastModifiedHeader, RFC7231)
382                                                 .toInstant()
383                                                 .toEpochMilli()));
384                     } catch (DateTimeParseException e) {
385                         // fall through
386                     }
387                 }
388             }
389             Map<String, String> checksums = checksumExtractor.extractChecksums(headerGetter(response));
390             if (checksums != null && !checksums.isEmpty()) {
391                 checksums.forEach(task::setChecksum);
392             }
393         } finally {
394             closeBody(response);
395         }
396     }
397 
398     private Map<TransferEvent.TransportPropertyKey, Object> createTransportProperties(HttpResponse<?> response) {
399         HttpTransportPropertiesBuilder builder = new HttpTransportPropertiesBuilder(toHttpVersion(response.version()));
400         response.sslSession().ifPresent(ssl -> {
401             builder.withSslProtocol(ssl.getProtocol());
402             builder.withSslCipherSuite(ssl.getCipherSuite());
403         });
404         // TODO: add compression algorithm if any (https://github.com/mizosoft/methanol/issues/182)
405         return builder.build();
406     }
407 
408     static HttpTransportProperty.HttpVersion toHttpVersion(HttpClient.Version version) {
409         switch (version) {
410             case HTTP_1_1:
411                 return HttpTransportProperty.HttpVersion.HTTP_1_1;
412             case HTTP_2:
413                 return HttpTransportProperty.HttpVersion.HTTP_2;
414             default:
415                 // support HTTP_3 via name (as only part of Java 26+ API)
416                 if ("HTTP_3".equals(version.name())) {
417                     return HttpTransportProperty.HttpVersion.HTTP_3;
418                 } else {
419                     throw new IllegalArgumentException("Unsupported HTTP version: " + version);
420                 }
421         }
422     }
423 
424     private static Function<String, String> headerGetter(HttpResponse<?> response) {
425         return s -> response.headers().firstValue(s).orElse(null);
426     }
427 
428     private void closeBody(HttpResponse<InputStream> streamHttpResponse) throws IOException {
429         if (streamHttpResponse != null) {
430             InputStream body = streamHttpResponse.body();
431             if (body != null) {
432                 body.close();
433             }
434         }
435     }
436 
437     @Override
438     protected void implPut(PutTask task) throws Exception {
439         HttpRequest.Builder request = HttpRequest.newBuilder().uri(resolve(task));
440         if (expectContinue != null) {
441             request = request.expectContinue(expectContinue);
442         }
443         headers.forEach(request::setHeader);
444         if (sendRfc9457Accept) {
445             JdkRFC9457Reporter.INSTANCE.prepareRequest(request);
446         }
447         if (task.getDataLength() == 0L) {
448             request.PUT(HttpRequest.BodyPublishers.noBody());
449         } else {
450             request.PUT(HttpRequest.BodyPublishers.fromPublisher(
451                     HttpRequest.BodyPublishers.ofInputStream(() -> {
452                         try {
453                             // transport properties are not available for outgoing requests
454                             return new TransportListenerNotifyingInputStream(
455                                     task.newInputStream(), task.getListener(), task.getDataLength());
456                         } catch (IOException e) {
457                             throw new UncheckedIOException(e);
458                         }
459                     }),
460                     // this adds a content-length request header
461                     task.getDataLength()));
462         }
463         prepare(request);
464         HttpResponse<InputStream> response = null;
465         try {
466             response = send(request.build(), HttpResponse.BodyHandlers.ofInputStream());
467             task.getListener().transportPropertiesAvailable(createTransportProperties(response));
468             if (response.statusCode() >= MULTIPLE_CHOICES) {
469                 JdkRFC9457Reporter.INSTANCE.generateException(response, (statusCode, reasonPhrase) -> {
470                     throw new HttpTransporterException(statusCode);
471                 });
472             }
473         } catch (ConnectException e) {
474             throw enhance(e);
475         } catch (IOException e) {
476             // unwrap possible underlying exception from body supplier
477             Throwable rootCause = getRootCause(e);
478             if (rootCause instanceof TransferCancelledException) {
479                 throw (TransferCancelledException) rootCause;
480             }
481             throw e;
482         } finally {
483             closeBody(response);
484         }
485     }
486 
487     private void prepare(HttpRequest.Builder requestBuilder) {
488         if (preemptiveAuth
489                 || (preemptivePutAuth && requestBuilder.build().method().equals("PUT"))) {
490             if (serverAuthentication != null) {
491                 // https://stackoverflow.com/a/58612586
492                 requestBuilder.setHeader(
493                         "Authorization",
494                         getBasicAuthValue(serverAuthentication.getUserName(), serverAuthentication.getPassword()));
495             }
496             if (proxyAuthentication != null) {
497                 requestBuilder.setHeader(
498                         "Proxy-Authorization",
499                         getBasicAuthValue(proxyAuthentication.getUserName(), proxyAuthentication.getPassword()));
500             }
501         }
502     }
503 
504     static String getBasicAuthValue(String username, char[] password) {
505         // Java's HTTP client uses ISO-8859-1 for Basic auth encoding
506         return "Basic "
507                 + Base64.getEncoder().encodeToString((username + ':' + String.valueOf(password)).getBytes(ISO_8859_1));
508     }
509 
510     private <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler)
511             throws Exception {
512         maxConcurrentRequests.acquire();
513         try {
514             if (!originScopedHeaders) {
515                 return client.send(request, responseBodyHandler);
516             }
517             // the client is configured with Redirect.NEVER: follow redirects here instead, so that configured
518             // headers (and preemptively applied Authorization) can be scoped to the repository origin per hop -
519             // the JDK client itself re-sends all user-set headers on every hop it follows, with no per-hop hook
520             HttpRequest current = request;
521             int redirects = 0;
522             while (true) {
523                 HttpResponse<T> response =
524                         client.send(current, discardingOnFollowedRedirect(responseBodyHandler, current.uri()));
525                 URI target = followableRedirect(
526                         response.statusCode(),
527                         current.uri(),
528                         response.headers().firstValue(LOCATION).orElse(null));
529                 if (target == null) {
530                     return response;
531                 }
532                 redirects++;
533                 if (redirects > MAX_REDIRECTS) {
534                     throw new IOException("Too many redirects for " + request.uri() + " (max " + MAX_REDIRECTS
535                             + "), last redirect target: " + target);
536                 }
537                 current = redirectRequest(current, response.statusCode(), target, baseUri, crossOriginExcludedHeaders);
538             }
539         } finally {
540             maxConcurrentRequests.release();
541         }
542     }
543 
544     /**
545      * Body handler that discards the body of responses this transporter is about to follow as redirect (the
546      * caller only ever sees the final response of the hop chain), delegating everything else to the original
547      * handler.
548      */
549     private static <T> HttpResponse.BodyHandler<T> discardingOnFollowedRedirect(
550             HttpResponse.BodyHandler<T> delegate, URI requestUri) {
551         return responseInfo -> {
552             String location = responseInfo.headers().firstValue(LOCATION).orElse(null);
553             if (followableRedirect(responseInfo.statusCode(), requestUri, location) != null) {
554                 return HttpResponse.BodySubscribers.mapping(HttpResponse.BodySubscribers.discarding(), v -> null);
555             }
556             return delegate.apply(responseInfo);
557         };
558     }
559 
560     /**
561      * Returns the resolved redirect target if the response is a redirect this transporter follows, otherwise
562      * {@code null}. Follow rules mirror {@link HttpClient.Redirect#NORMAL}: redirect status with a resolvable
563      * {@code Location}, {@code http}/{@code https} targets only, and never a downgrade from https to http.
564      */
565     static URI followableRedirect(int statusCode, URI requestUri, String locationHeader) {
566         if (!isRedirect(statusCode) || locationHeader == null) {
567             return null;
568         }
569         final URI target;
570         try {
571             target = requestUri.resolve(locationHeader);
572         } catch (IllegalArgumentException e) {
573             return null;
574         }
575         String scheme = target.getScheme();
576         if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
577             return null;
578         }
579         if ("https".equalsIgnoreCase(requestUri.getScheme()) && !"https".equalsIgnoreCase(scheme)) {
580             return null;
581         }
582         return target;
583     }
584 
585     static boolean isRedirect(int statusCode) {
586         return statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 307 || statusCode == 308;
587     }
588 
589     /**
590      * Builds the next-hop request: the previous request re-targeted at the redirect target, with the origin-scoped
591      * headers (operator-configured headers and preemptively applied {@code Authorization}) removed when the target
592      * is not the repository origin (same scheme, case-insensitive host and effective port). Method rewriting
593      * mirrors {@link HttpClient.Redirect#NORMAL}.
594      */
595     static HttpRequest redirectRequest(
596             HttpRequest previous, int statusCode, URI target, URI baseUri, Set<String> crossOriginExcludedHeaders) {
597         HttpRequest.Builder builder = HttpRequest.newBuilder().uri(target).expectContinue(previous.expectContinue());
598         previous.version().ifPresent(builder::version);
599         previous.timeout().ifPresent(builder::timeout);
600         boolean sameOrigin = isSameOrigin(baseUri, target);
601         previous.headers().map().forEach((name, values) -> {
602             if (sameOrigin || !crossOriginExcludedHeaders.contains(name)) {
603                 for (String value : values) {
604                     builder.header(name, value);
605                 }
606             }
607         });
608         String method = previous.method();
609         if (statusCode == 303 && !"HEAD".equals(method)) {
610             builder.method("GET", HttpRequest.BodyPublishers.noBody());
611         } else if ((statusCode == 301 || statusCode == 302) && "POST".equals(method)) {
612             builder.method("GET", HttpRequest.BodyPublishers.noBody());
613         } else {
614             builder.method(method, previous.bodyPublisher().orElse(HttpRequest.BodyPublishers.noBody()));
615         }
616         return builder.build();
617     }
618 
619     static boolean isSameOrigin(URI origin, URI target) {
620         if (origin.getScheme() == null
621                 || origin.getHost() == null
622                 || target.getScheme() == null
623                 || target.getHost() == null) {
624             return false;
625         }
626         return origin.getScheme().equalsIgnoreCase(target.getScheme())
627                 && origin.getHost().equalsIgnoreCase(target.getHost())
628                 && effectivePort(origin.getScheme(), origin.getPort())
629                         == effectivePort(target.getScheme(), target.getPort());
630     }
631 
632     static int effectivePort(String scheme, int port) {
633         if (port >= 0) {
634             return port;
635         }
636         return "https".equalsIgnoreCase(scheme) ? 443 : 80;
637     }
638 
639     @Override
640     protected void implClose() {
641         if (client != null) {
642             JdkTransporterCloser.closer(client).run();
643         }
644     }
645 
646     HttpClient.Version getHttpVersion(RepositorySystemSession session, RemoteRepository repository) {
647         HttpVersion httpVersion = HttpTransporterUtils.getHttpVersion(session, repository);
648         if (httpVersion == ConfigurationProperties.DEFAULT_HTTP_VERSION) {
649             // Fall back to legacy JDK Transporter specific property when it is explicitly configured.
650             String configuredLegacyHttpVersion = ConfigUtils.getString(
651                     session, null, CONFIG_PROP_HTTP_VERSION + "." + repository.getId(), CONFIG_PROP_HTTP_VERSION);
652             if (configuredLegacyHttpVersion != null) {
653                 return resolveHttpVersion(configuredLegacyHttpVersion);
654             }
655             return HttpClient.Version.HTTP_2;
656         } else {
657             switch (httpVersion) {
658                 case MAXIMUM:
659                     return getMaximumSupportedHttpVersion();
660                 case HTTP_1_1:
661                     return HttpClient.Version.HTTP_1_1;
662                 case HTTP_2:
663                 case DEFAULT:
664                     return HttpClient.Version.HTTP_2;
665                 case HTTP_3:
666                     return resolveHttpVersion("HTTP_3");
667                 default:
668                     // unreachable but necessary for Checkstyle to not complain about missing default case
669                     throw new IllegalStateException("Unknown HTTP version: " + httpVersion);
670             }
671         }
672     }
673 
674     private HttpClient.Version resolveHttpVersion(String requestedVersion) {
675         try {
676             return HttpClient.Version.valueOf(requestedVersion);
677         } catch (IllegalArgumentException e) {
678             HttpClient.Version maximumHttpVersion = getMaximumSupportedHttpVersion();
679             LOGGER.warn(
680                     "HTTP version '{}' is not supported by the running JRE, using '{}' instead",
681                     requestedVersion,
682                     maximumHttpVersion);
683             return maximumHttpVersion;
684         }
685     }
686 
687     HttpClient.Version getMaximumSupportedHttpVersion() {
688         HttpClient.Version[] values = HttpClient.Version.values();
689         return values[values.length - 1];
690     }
691 
692     private HttpClient createClient(RepositorySystemSession session, RemoteRepository repository, boolean insecure)
693             throws RuntimeException {
694 
695         HashMap<Authenticator.RequestorType, PasswordAuthentication> authentications = new HashMap<>();
696         SSLContext sslContext = null;
697         try (AuthenticationContext repoAuthContext = AuthenticationContext.forRepository(session, repository)) {
698             if (repoAuthContext != null) {
699                 sslContext = repoAuthContext.get(AuthenticationContext.SSL_CONTEXT, SSLContext.class);
700 
701                 String username = repoAuthContext.get(AuthenticationContext.USERNAME);
702                 String password = repoAuthContext.get(AuthenticationContext.PASSWORD);
703                 serverAuthentication = new PasswordAuthentication(username, password.toCharArray());
704                 authentications.put(Authenticator.RequestorType.SERVER, serverAuthentication);
705             }
706         }
707 
708         Version httpVersion = getHttpVersion(session, repository);
709         if (sslContext == null) {
710             try {
711                 if (insecure) {
712                     if (httpVersion.name().equals("HTTP_3")) {
713                         // custom trust manager not supported for HTTP/3 (Quic)
714                         // (https://github.com/openjdk/jdk/blob/631b675d7949a0e6312d8d6f45e2515d53b12f05/src/java.base/share/classes/sun/security/ssl/SSLContextImpl.java#L529)
715                         // https://openjdk.org/jeps/517
716                         // https://mail.openjdk.org/archives/list/net-dev@openjdk.org/thread/LHSC7MWRFDJE2KGS2QMPFJPZX3XEQKOQ/
717                         throw new IllegalStateException(
718                                 "Insecure HTTPS connections are not supported for HTTP/3 (Quic)");
719                     }
720                     sslContext = SSLContext.getInstance("TLS");
721                     X509ExtendedTrustManager tm = new X509ExtendedTrustManager() {
722                         @Override
723                         public void checkClientTrusted(X509Certificate[] chain, String authType) {}
724 
725                         @Override
726                         public void checkServerTrusted(X509Certificate[] chain, String authType) {}
727 
728                         @Override
729                         public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) {}
730 
731                         @Override
732                         public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) {}
733 
734                         @Override
735                         public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {}
736 
737                         @Override
738                         public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {}
739 
740                         @Override
741                         public X509Certificate[] getAcceptedIssuers() {
742                             return null;
743                         }
744                     };
745                     sslContext.init(null, new X509TrustManager[] {tm}, null);
746                 } else {
747                     sslContext = SSLContext.getDefault();
748                 }
749             } catch (Exception e) {
750                 if (e instanceof RuntimeException) {
751                     throw (RuntimeException) e;
752                 } else {
753                     throw new IllegalStateException("SSL Context setup failure", e);
754                 }
755             }
756         } else {
757             if (insecure) {
758                 throw new IllegalStateException(
759                         "Insecure HTTPS connections are not supported when a custom SSLContext is configured");
760             }
761         }
762 
763         Methanol.Builder builder = Methanol.newBuilder()
764                 .version(httpVersion)
765                 // with origin-scoped headers (default) the transporter follows redirects itself (see #send) so
766                 // that configured headers can be dropped on hops leaving the repository origin; the JDK client
767                 // has no per-hop hook and re-sends all user-set headers on every hop it follows
768                 .followRedirects(originScopedHeaders ? HttpClient.Redirect.NEVER : HttpClient.Redirect.NORMAL)
769                 .connectTimeout(Duration.ofMillis(connectTimeout))
770                 // this only considers the time until the response header is received, see
771                 // https://bugs.openjdk.org/browse/JDK-8208693
772                 // but better than nothing
773                 .requestTimeout(Duration.ofMillis(requestTimeout))
774                 .sslContext(sslContext);
775 
776         if (insecure) {
777             SSLParameters sslParameters = sslContext.getDefaultSSLParameters();
778             sslParameters.setEndpointIdentificationAlgorithm(null);
779             builder.sslParameters(sslParameters);
780         }
781 
782         setLocalAddress(
783                 builder,
784                 HttpTransporterUtils.getHttpLocalAddress(session, repository).orElse(null));
785 
786         InetSocketAddress proxyAddress = null;
787         if (repository.getProxy() != null) {
788             proxyAddress = new InetSocketAddress(
789                     repository.getProxy().getHost(), repository.getProxy().getPort());
790             if (proxyAddress.isUnresolved()) {
791                 throw new IllegalStateException(
792                         "Proxy host " + repository.getProxy().getHost() + " could not be resolved");
793             }
794             builder.proxy(ProxySelector.of(proxyAddress));
795             try (AuthenticationContext proxyAuthContext = AuthenticationContext.forProxy(session, repository)) {
796                 if (proxyAuthContext != null) {
797                     String username = proxyAuthContext.get(AuthenticationContext.USERNAME);
798                     String password = proxyAuthContext.get(AuthenticationContext.PASSWORD);
799 
800                     proxyAuthentication = new PasswordAuthentication(username, password.toCharArray());
801                     authentications.put(Authenticator.RequestorType.PROXY, proxyAuthentication);
802                 }
803             }
804         }
805 
806         if (!authentications.isEmpty()) {
807             boolean unscopedAuthentication = ConfigUtils.getBoolean(
808                     session,
809                     DEFAULT_UNSCOPED_AUTHENTICATION,
810                     CONFIG_PROP_UNSCOPED_AUTHENTICATION + "." + repository.getId(),
811                     CONFIG_PROP_UNSCOPED_AUTHENTICATION);
812             if (unscopedAuthentication) {
813                 // legacy behavior, explicit opt-in only: hands out credentials to ANY challenging host
814                 builder.authenticator(new Authenticator() {
815                     @Override
816                     protected PasswordAuthentication getPasswordAuthentication() {
817                         return authentications.get(getRequestorType());
818                     }
819                 });
820             } else {
821                 builder.authenticator(new ScopedAuthenticator(baseUri, proxyAddress, authentications));
822             }
823         }
824 
825         configureRetryHandler(session, repository, builder);
826 
827         return builder.build();
828     }
829 
830     /**
831      * An {@link Authenticator} that only hands out credentials to the origin it was configured for. The JDK
832      * {@link HttpClient} consults the authenticator for any host that answers with an authentication challenge -
833      * including hosts reached by following redirects off the repository - so the requesting protocol, host and
834      * port must be verified against the repository base URI (or, for proxy challenges, against the configured
835      * proxy address) before any credential is returned. Legacy unscoped behavior is available as explicit opt-in
836      * via {@link JdkTransporterConfigurationKeys#CONFIG_PROP_UNSCOPED_AUTHENTICATION}.
837      */
838     static final class ScopedAuthenticator extends Authenticator {
839         private final URI baseUri;
840 
841         private final InetSocketAddress proxyAddress;
842 
843         private final Map<RequestorType, PasswordAuthentication> authentications;
844 
845         ScopedAuthenticator(
846                 URI baseUri,
847                 InetSocketAddress proxyAddress,
848                 Map<RequestorType, PasswordAuthentication> authentications) {
849             this.baseUri = Objects.requireNonNull(baseUri);
850             this.proxyAddress = proxyAddress;
851             this.authentications = new HashMap<>(authentications);
852         }
853 
854         @Override
855         protected PasswordAuthentication getPasswordAuthentication() {
856             PasswordAuthentication authentication = authentications.get(getRequestorType());
857             if (authentication == null) {
858                 return null;
859             }
860             if (getRequestorType() == RequestorType.PROXY) {
861                 if (proxyAddress == null
862                         || getRequestingHost() == null
863                         || !proxyAddress.getHostString().equalsIgnoreCase(getRequestingHost())
864                         || proxyAddress.getPort() != getRequestingPort()) {
865                     LOGGER.warn(
866                             "Refusing to send proxy credentials to '{}:{}': not the configured proxy '{}'",
867                             getRequestingHost(),
868                             getRequestingPort(),
869                             proxyAddress);
870                     return null;
871                 }
872                 return authentication;
873             }
874             // RequestorType.SERVER: the challenge must originate from the repository itself
875             if (!originMatchesBaseUri(getRequestingProtocol(), getRequestingHost(), getRequestingPort())) {
876                 LOGGER.warn(
877                         "Refusing to send repository credentials to '{}://{}:{}': it does not match the repository"
878                                 + " base URI '{}' (a redirect may have left the repository host); set the"
879                                 + " configuration property {}=true to restore the legacy unscoped behavior",
880                         getRequestingProtocol(),
881                         getRequestingHost(),
882                         getRequestingPort(),
883                         baseUri,
884                         CONFIG_PROP_UNSCOPED_AUTHENTICATION);
885                 return null;
886             }
887             return authentication;
888         }
889 
890         private boolean originMatchesBaseUri(String protocol, String host, int port) {
891             if (protocol == null || host == null || baseUri.getScheme() == null || baseUri.getHost() == null) {
892                 return false;
893             }
894             return protocol.equalsIgnoreCase(baseUri.getScheme())
895                     && host.equalsIgnoreCase(baseUri.getHost())
896                     && effectivePort(protocol, port) == effectivePort(baseUri.getScheme(), baseUri.getPort());
897         }
898 
899         private static int effectivePort(String protocol, int port) {
900             if (port >= 0) {
901                 return port;
902             }
903             return "https".equalsIgnoreCase(protocol) ? 443 : 80;
904         }
905     }
906 
907     private static class RetryLoggingListener implements RetryInterceptor.Listener {
908         private final int maxNumRetries;
909 
910         RetryLoggingListener(int maxNumRetries) {
911             this.maxNumRetries = maxNumRetries;
912         }
913 
914         @Override
915         public void onRetry(Context<?> context, HttpRequest nextRequest, Duration delay) {
916             LOGGER.warn(
917                     "{} request to {} failed (attempt {} of {}) due to {}. Retrying in {} ms...",
918                     context.request().method(),
919                     context.request().uri(),
920                     context.retryCount() + 1,
921                     maxNumRetries + 1,
922                     getReason(context),
923                     delay.toMillis());
924         }
925 
926         String getReason(Context<?> context) {
927             if (context.exception().isPresent()) {
928                 return context.exception().get().getMessage();
929             } else if (context.response().isPresent()) {
930                 return "status " + context.response().get().statusCode();
931             }
932             // should not happen
933             throw new IllegalStateException("No exception or response present in retry context");
934         }
935     }
936 
937     private static void configureRetryHandler(
938             RepositorySystemSession session, RemoteRepository repository, Methanol.Builder builder) {
939         int retryCount = HttpTransporterUtils.getHttpRetryHandlerCount(session, repository);
940         long retryInterval = HttpTransporterUtils.getHttpRetryHandlerInterval(session, repository);
941         long retryIntervalMax = HttpTransporterUtils.getHttpRetryHandlerIntervalMax(session, repository);
942         if (retryCount > 0) {
943             Methanol.Interceptor rateLimitingRetryInterceptor = RetryInterceptor.newBuilder()
944                     .maxRetries(retryCount)
945                     .onStatus(HttpTransporterUtils.getHttpServiceUnavailableCodes(session, repository)::contains)
946                     .listener(new RetryLoggingListener(retryCount))
947                     .backoff(RetryInterceptor.BackoffStrategy.retryAfterOr(RetryInterceptor.BackoffStrategy.linear(
948                             Duration.ofMillis(retryInterval), Duration.ofMillis(retryIntervalMax))))
949                     .build();
950             builder.interceptor(rateLimitingRetryInterceptor);
951             Methanol.Interceptor retryIoExceptionsInterceptor = RetryInterceptor.newBuilder()
952                     // this is in addition to the JDK internal retries (https://github.com/mizosoft/methanol/issues/174)
953                     // e.g. for connection timeouts this is hardcoded to 2 attempts:
954                     // https://github.com/openjdk/jdk/blob/640343f7d94894b0378ea5b1768eeac203a9aaf8/src/java.net.http/share/classes/jdk/internal/net/http/MultiExchange.java#L665
955                     .maxRetries(retryCount)
956                     .onException(t -> {
957                         // exceptions from body publishers are wrapped inside IOExceptions
958                         // but hard to distinguish from others, so just exclude some we know are emitted from body
959                         // suppliers (https://github.com/mizosoft/methanol/issues/179)
960                         Throwable rootCause = getRootCause(t);
961                         return t instanceof IOException
962                                 && !NON_RETRIABLE_IO_EXCEPTIONS.contains(t.getClass())
963                                 && !(rootCause instanceof TransferCancelledException);
964                     })
965                     .listener(new RetryLoggingListener(retryCount))
966                     .build();
967             builder.interceptor(retryIoExceptionsInterceptor);
968         }
969     }
970 
971     private static void setLocalAddress(HttpClient.Builder builder, InetAddress address) {
972         if (address == null) {
973             return;
974         }
975         try {
976             final Method mtd = builder.getClass().getDeclaredMethod("localAddress", InetAddress.class);
977             if (!mtd.canAccess(builder)) {
978                 mtd.setAccessible(true);
979             }
980             mtd.invoke(builder, address);
981         } catch (final NoSuchMethodException ignore) {
982             // skip, not yet in the API
983         } catch (InvocationTargetException e) {
984             throw new IllegalStateException(e.getTargetException());
985         } catch (IllegalAccessException e) {
986             throw new IllegalStateException(e);
987         }
988     }
989 
990     private static Throwable getRootCause(Throwable throwable) {
991         Objects.requireNonNull(throwable);
992         Throwable rootCause = throwable;
993         while (rootCause.getCause() != null && rootCause.getCause() != rootCause) {
994             rootCause = rootCause.getCause();
995         }
996         return rootCause;
997     }
998 }