1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
120
121
122
123
124
125
126
127
128
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
141
142
143 private static final int MAX_REDIRECTS = 5;
144
145 private static final String LOCATION = "Location";
146
147
148
149
150
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
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);
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
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
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
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
454 return new TransportListenerNotifyingInputStream(
455 task.newInputStream(), task.getListener(), task.getDataLength());
456 } catch (IOException e) {
457 throw new UncheckedIOException(e);
458 }
459 }),
460
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
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
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
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
518
519
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
546
547
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
562
563
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
591
592
593
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
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
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
714
715
716
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
766
767
768 .followRedirects(originScopedHeaders ? HttpClient.Redirect.NEVER : HttpClient.Redirect.NORMAL)
769 .connectTimeout(Duration.ofMillis(connectTimeout))
770
771
772
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
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
832
833
834
835
836
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
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
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
953
954
955 .maxRetries(retryCount)
956 .onException(t -> {
957
958
959
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
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 }