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.SSLParameters;
24 import javax.net.ssl.X509ExtendedTrustManager;
25 import javax.net.ssl.X509TrustManager;
26
27 import java.io.BufferedInputStream;
28 import java.io.IOException;
29 import java.io.InputStream;
30 import java.lang.reflect.InvocationTargetException;
31 import java.lang.reflect.Method;
32 import java.net.Authenticator;
33 import java.net.ConnectException;
34 import java.net.InetAddress;
35 import java.net.InetSocketAddress;
36 import java.net.PasswordAuthentication;
37 import java.net.ProxySelector;
38 import java.net.Socket;
39 import java.net.URI;
40 import java.net.URISyntaxException;
41 import java.net.UnknownHostException;
42 import java.net.http.HttpClient;
43 import java.net.http.HttpRequest;
44 import java.net.http.HttpResponse;
45 import java.nio.file.Files;
46 import java.nio.file.Path;
47 import java.nio.file.StandardCopyOption;
48 import java.security.cert.X509Certificate;
49 import java.time.Duration;
50 import java.time.Instant;
51 import java.time.ZoneId;
52 import java.time.ZonedDateTime;
53 import java.time.format.DateTimeFormatter;
54 import java.time.format.DateTimeParseException;
55 import java.util.Base64;
56 import java.util.Collections;
57 import java.util.HashMap;
58 import java.util.Locale;
59 import java.util.Map;
60 import java.util.concurrent.Semaphore;
61 import java.util.function.Function;
62 import java.util.function.Supplier;
63 import java.util.regex.Matcher;
64
65 import com.github.mizosoft.methanol.Methanol;
66 import org.eclipse.aether.ConfigurationProperties;
67 import org.eclipse.aether.RepositorySystemSession;
68 import org.eclipse.aether.repository.AuthenticationContext;
69 import org.eclipse.aether.repository.RemoteRepository;
70 import org.eclipse.aether.spi.connector.transport.AbstractTransporter;
71 import org.eclipse.aether.spi.connector.transport.GetTask;
72 import org.eclipse.aether.spi.connector.transport.PeekTask;
73 import org.eclipse.aether.spi.connector.transport.PutTask;
74 import org.eclipse.aether.spi.connector.transport.TransportTask;
75 import org.eclipse.aether.spi.connector.transport.http.ChecksumExtractor;
76 import org.eclipse.aether.spi.connector.transport.http.HttpTransporter;
77 import org.eclipse.aether.spi.connector.transport.http.HttpTransporterException;
78 import org.eclipse.aether.spi.io.PathProcessor;
79 import org.eclipse.aether.transfer.NoTransporterException;
80 import org.eclipse.aether.util.ConfigUtils;
81 import org.slf4j.Logger;
82 import org.slf4j.LoggerFactory;
83
84 import static java.nio.charset.StandardCharsets.ISO_8859_1;
85 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.ACCEPT_ENCODING;
86 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CACHE_CONTROL;
87 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_LENGTH;
88 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_RANGE;
89 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_RANGE_PATTERN;
90 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.IF_UNMODIFIED_SINCE;
91 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.LAST_MODIFIED;
92 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.MULTIPLE_CHOICES;
93 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.NOT_FOUND;
94 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.PRECONDITION_FAILED;
95 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.RANGE;
96 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.USER_AGENT;
97 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.CONFIG_PROP_HTTP_VERSION;
98 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.CONFIG_PROP_MAX_CONCURRENT_REQUESTS;
99 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.DEFAULT_HTTP_VERSION;
100 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.DEFAULT_MAX_CONCURRENT_REQUESTS;
101
102
103
104
105
106
107
108
109
110
111
112
113
114 final class JdkTransporter extends AbstractTransporter implements HttpTransporter {
115 private static final Logger LOGGER = LoggerFactory.getLogger(JdkTransporter.class);
116
117 private static final DateTimeFormatter RFC7231 = DateTimeFormatter.ofPattern(
118 "EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH)
119 .withZone(ZoneId.of("GMT"));
120
121 private static final long MODIFICATION_THRESHOLD = 60L * 1000L;
122
123 private final ChecksumExtractor checksumExtractor;
124
125 private final PathProcessor pathProcessor;
126
127 private final URI baseUri;
128
129 private final HttpClient client;
130
131 private final Map<String, String> headers;
132
133 private final int connectTimeout;
134
135 private final int requestTimeout;
136
137 private final Boolean expectContinue;
138
139 private final Semaphore maxConcurrentRequests;
140
141 private boolean preemptivePutAuth;
142
143 private boolean preemptiveAuth;
144
145 private PasswordAuthentication serverAuthentication;
146
147 JdkTransporter(
148 RepositorySystemSession session,
149 RemoteRepository repository,
150 int javaVersion,
151 ChecksumExtractor checksumExtractor,
152 PathProcessor pathProcessor)
153 throws NoTransporterException {
154 this.checksumExtractor = checksumExtractor;
155 this.pathProcessor = pathProcessor;
156 try {
157 URI uri = new URI(repository.getUrl()).parseServerAuthority();
158 if (uri.isOpaque()) {
159 throw new URISyntaxException(repository.getUrl(), "URL must not be opaque");
160 }
161 if (uri.getRawFragment() != null || uri.getRawQuery() != null) {
162 throw new URISyntaxException(repository.getUrl(), "URL must not have fragment or query");
163 }
164 String path = uri.getPath();
165 if (path == null) {
166 path = "/";
167 }
168 if (!path.startsWith("/")) {
169 path = "/" + path;
170 }
171 if (!path.endsWith("/")) {
172 path = path + "/";
173 }
174 this.baseUri = URI.create(uri.getScheme() + "://" + uri.getRawAuthority() + path);
175 } catch (URISyntaxException e) {
176 throw new NoTransporterException(repository, e.getMessage(), e);
177 }
178
179 HashMap<String, String> headers = new HashMap<>();
180 String userAgent = ConfigUtils.getString(
181 session, ConfigurationProperties.DEFAULT_USER_AGENT, ConfigurationProperties.USER_AGENT);
182 if (userAgent != null) {
183 headers.put(USER_AGENT, userAgent);
184 }
185 @SuppressWarnings("unchecked")
186 Map<Object, Object> configuredHeaders = (Map<Object, Object>) ConfigUtils.getMap(
187 session,
188 Collections.emptyMap(),
189 ConfigurationProperties.HTTP_HEADERS + "." + repository.getId(),
190 ConfigurationProperties.HTTP_HEADERS);
191 if (configuredHeaders != null) {
192 configuredHeaders.forEach((k, v) -> headers.put(String.valueOf(k), v != null ? String.valueOf(v) : null));
193 }
194 headers.put(CACHE_CONTROL, "no-cache, no-store");
195
196 this.connectTimeout = ConfigUtils.getInteger(
197 session,
198 ConfigurationProperties.DEFAULT_CONNECT_TIMEOUT,
199 ConfigurationProperties.CONNECT_TIMEOUT + "." + repository.getId(),
200 ConfigurationProperties.CONNECT_TIMEOUT);
201 this.requestTimeout = ConfigUtils.getInteger(
202 session,
203 ConfigurationProperties.DEFAULT_REQUEST_TIMEOUT,
204 ConfigurationProperties.REQUEST_TIMEOUT + "." + repository.getId(),
205 ConfigurationProperties.REQUEST_TIMEOUT);
206 String expectContinueConf = ConfigUtils.getString(
207 session,
208 null,
209 ConfigurationProperties.HTTP_EXPECT_CONTINUE + "." + repository.getId(),
210 ConfigurationProperties.HTTP_EXPECT_CONTINUE);
211 if (javaVersion > 19) {
212 this.expectContinue = expectContinueConf == null ? null : Boolean.parseBoolean(expectContinueConf);
213 } else {
214 this.expectContinue = null;
215 if (expectContinueConf != null) {
216 LOGGER.warn(
217 "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",
218 javaVersion);
219 }
220 }
221 final String httpsSecurityMode = ConfigUtils.getString(
222 session,
223 ConfigurationProperties.HTTPS_SECURITY_MODE_DEFAULT,
224 ConfigurationProperties.HTTPS_SECURITY_MODE + "." + repository.getId(),
225 ConfigurationProperties.HTTPS_SECURITY_MODE);
226
227 if (!ConfigurationProperties.HTTPS_SECURITY_MODE_DEFAULT.equals(httpsSecurityMode)
228 && !ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE.equals(httpsSecurityMode)) {
229 throw new IllegalArgumentException("Unsupported '" + httpsSecurityMode + "' HTTPS security mode.");
230 }
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 = ConfigUtils.getBoolean(
240 session,
241 ConfigurationProperties.DEFAULT_HTTP_PREEMPTIVE_AUTH,
242 ConfigurationProperties.HTTP_PREEMPTIVE_AUTH + "." + repository.getId(),
243 ConfigurationProperties.HTTP_PREEMPTIVE_AUTH);
244 this.preemptivePutAuth = ConfigUtils.getBoolean(
245 session,
246 ConfigurationProperties.DEFAULT_HTTP_PREEMPTIVE_PUT_AUTH,
247 ConfigurationProperties.HTTP_PREEMPTIVE_PUT_AUTH + "." + repository.getId(),
248 ConfigurationProperties.HTTP_PREEMPTIVE_PUT_AUTH);
249
250 this.headers = headers;
251 this.client = createClient(session, repository, insecure);
252 }
253
254 private URI resolve(TransportTask task) {
255 return baseUri.resolve(task.getLocation());
256 }
257
258 private ConnectException enhance(ConnectException connectException) {
259 ConnectException result = new ConnectException("Connection to " + baseUri.toASCIIString() + " refused");
260 result.initCause(connectException);
261 return result;
262 }
263
264 @Override
265 public int classify(Throwable error) {
266 if (error instanceof HttpTransporterException
267 && ((HttpTransporterException) error).getStatusCode() == NOT_FOUND) {
268 return ERROR_NOT_FOUND;
269 }
270 return ERROR_OTHER;
271 }
272
273 @Override
274 protected void implPeek(PeekTask task) throws Exception {
275 HttpRequest.Builder request =
276 HttpRequest.newBuilder().uri(resolve(task)).method("HEAD", HttpRequest.BodyPublishers.noBody());
277 headers.forEach(request::setHeader);
278
279 prepare(request);
280 try {
281 HttpResponse<Void> response = send(request.build(), HttpResponse.BodyHandlers.discarding());
282 if (response.statusCode() >= MULTIPLE_CHOICES) {
283 throw new HttpTransporterException(response.statusCode());
284 }
285 } catch (ConnectException e) {
286 throw enhance(e);
287 }
288 }
289
290 @Override
291 protected void implGet(GetTask task) throws Exception {
292 boolean resume = task.getResumeOffset() > 0L && task.getDataPath() != null;
293 HttpResponse<InputStream> response = null;
294
295 try {
296 while (true) {
297 HttpRequest.Builder request =
298 HttpRequest.newBuilder().uri(resolve(task)).GET();
299 headers.forEach(request::setHeader);
300
301 if (resume) {
302 long resumeOffset = task.getResumeOffset();
303 long lastModified = pathProcessor.lastModified(task.getDataPath(), 0L);
304 request.header(RANGE, "bytes=" + resumeOffset + '-');
305 request.header(
306 IF_UNMODIFIED_SINCE,
307 RFC7231.format(Instant.ofEpochMilli(lastModified - MODIFICATION_THRESHOLD)));
308 request.header(ACCEPT_ENCODING, "identity");
309 }
310
311 prepare(request);
312 try {
313 response = send(request.build(), HttpResponse.BodyHandlers.ofInputStream());
314 if (response.statusCode() >= MULTIPLE_CHOICES) {
315 if (resume && response.statusCode() == PRECONDITION_FAILED) {
316 closeBody(response);
317 resume = false;
318 continue;
319 }
320 try {
321 JdkRFC9457Reporter.INSTANCE.generateException(response, (statusCode, reasonPhrase) -> {
322 throw new HttpTransporterException(statusCode);
323 });
324 } finally {
325 closeBody(response);
326 }
327 }
328 } catch (ConnectException e) {
329 closeBody(response);
330 throw enhance(e);
331 }
332 break;
333 }
334
335 long offset = 0L,
336 length = response.headers().firstValueAsLong(CONTENT_LENGTH).orElse(-1L);
337 if (resume) {
338 String range = response.headers().firstValue(CONTENT_RANGE).orElse(null);
339 if (range != null) {
340 Matcher m = CONTENT_RANGE_PATTERN.matcher(range);
341 if (!m.matches()) {
342 throw new IOException("Invalid Content-Range header for partial download: " + range);
343 }
344 offset = Long.parseLong(m.group(1));
345 length = Long.parseLong(m.group(2)) + 1L;
346 if (offset < 0L || offset >= length || (offset > 0L && offset != task.getResumeOffset())) {
347 throw new IOException("Invalid Content-Range header for partial download from offset "
348 + task.getResumeOffset() + ": " + range);
349 }
350 }
351 }
352
353 final boolean downloadResumed = offset > 0L;
354 final Path dataFile = task.getDataPath();
355 if (dataFile == null) {
356 try (InputStream is = response.body()) {
357 utilGet(task, is, true, length, downloadResumed);
358 }
359 } else {
360 try (PathProcessor.CollocatedTempFile tempFile = pathProcessor.newTempFile(dataFile)) {
361 task.setDataPath(tempFile.getPath(), downloadResumed);
362 if (downloadResumed && Files.isRegularFile(dataFile)) {
363 try (InputStream inputStream = new BufferedInputStream(Files.newInputStream(dataFile))) {
364 Files.copy(inputStream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING);
365 }
366 }
367 try (InputStream is = response.body()) {
368 utilGet(task, is, true, length, downloadResumed);
369 }
370 tempFile.move();
371 } finally {
372 task.setDataPath(dataFile);
373 }
374 }
375 if (task.getDataPath() != null) {
376 String lastModifiedHeader = response.headers()
377 .firstValue(LAST_MODIFIED)
378 .orElse(null);
379 if (lastModifiedHeader != null) {
380 try {
381 pathProcessor.setLastModified(
382 task.getDataPath(),
383 ZonedDateTime.parse(lastModifiedHeader, RFC7231)
384 .toInstant()
385 .toEpochMilli());
386 } catch (DateTimeParseException e) {
387
388 }
389 }
390 }
391 Map<String, String> checksums = checksumExtractor.extractChecksums(headerGetter(response));
392 if (checksums != null && !checksums.isEmpty()) {
393 checksums.forEach(task::setChecksum);
394 }
395 } finally {
396 closeBody(response);
397 }
398 }
399
400 private static Function<String, String> headerGetter(HttpResponse<?> response) {
401 return s -> response.headers().firstValue(s).orElse(null);
402 }
403
404 private void closeBody(HttpResponse<InputStream> streamHttpResponse) throws IOException {
405 if (streamHttpResponse != null) {
406 InputStream body = streamHttpResponse.body();
407 if (body != null) {
408 body.close();
409 }
410 }
411 }
412
413 @Override
414 protected void implPut(PutTask task) throws Exception {
415 HttpRequest.Builder request = HttpRequest.newBuilder().uri(resolve(task));
416 if (expectContinue != null) {
417 request = request.expectContinue(expectContinue);
418 }
419 headers.forEach(request::setHeader);
420 try (PathProcessor.TempFile tempFile = pathProcessor.newTempFile()) {
421 utilPut(task, Files.newOutputStream(tempFile.getPath()), true);
422 request.PUT(HttpRequest.BodyPublishers.ofFile(tempFile.getPath()));
423
424 prepare(request);
425 try {
426 HttpResponse<Void> response = send(request.build(), HttpResponse.BodyHandlers.discarding());
427 if (response.statusCode() >= MULTIPLE_CHOICES) {
428 throw new HttpTransporterException(response.statusCode());
429 }
430 } catch (ConnectException e) {
431 throw enhance(e);
432 }
433 }
434 }
435
436 private void prepare(HttpRequest.Builder requestBuilder) {
437 if (serverAuthentication != null
438 && (preemptiveAuth
439 || (preemptivePutAuth && requestBuilder.build().method().equals("PUT")))) {
440
441 requestBuilder.setHeader(
442 "Authorization",
443 getBasicAuthValue(serverAuthentication.getUserName(), serverAuthentication.getPassword()));
444 }
445 }
446
447 static String getBasicAuthValue(String username, char[] password) {
448 StringBuilder sb = new StringBuilder(128);
449 sb.append(username).append(':').append(password);
450
451 return "Basic " + Base64.getEncoder().encodeToString(sb.toString().getBytes(ISO_8859_1));
452 }
453
454 private <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler)
455 throws Exception {
456 maxConcurrentRequests.acquire();
457 try {
458 return client.send(request, responseBodyHandler);
459 } finally {
460 maxConcurrentRequests.release();
461 }
462 }
463
464 @Override
465 protected void implClose() {
466 if (client != null) {
467 JdkTransporterCloser.closer(client).run();
468 }
469 }
470
471 private HttpClient createClient(RepositorySystemSession session, RemoteRepository repository, boolean insecure)
472 throws RuntimeException {
473
474 HashMap<Authenticator.RequestorType, PasswordAuthentication> authentications = new HashMap<>();
475 SSLContext sslContext = null;
476 try (AuthenticationContext repoAuthContext = AuthenticationContext.forRepository(session, repository)) {
477 if (repoAuthContext != null) {
478 sslContext = repoAuthContext.get(AuthenticationContext.SSL_CONTEXT, SSLContext.class);
479
480 String username = repoAuthContext.get(AuthenticationContext.USERNAME);
481 String password = repoAuthContext.get(AuthenticationContext.PASSWORD);
482 serverAuthentication = new PasswordAuthentication(username, password.toCharArray());
483 authentications.put(Authenticator.RequestorType.SERVER, serverAuthentication);
484 }
485 }
486
487 if (sslContext == null) {
488 try {
489 if (insecure) {
490 sslContext = SSLContext.getInstance("TLS");
491 X509ExtendedTrustManager tm = new X509ExtendedTrustManager() {
492 @Override
493 public void checkClientTrusted(X509Certificate[] chain, String authType) {}
494
495 @Override
496 public void checkServerTrusted(X509Certificate[] chain, String authType) {}
497
498 @Override
499 public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) {}
500
501 @Override
502 public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) {}
503
504 @Override
505 public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {}
506
507 @Override
508 public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {}
509
510 @Override
511 public X509Certificate[] getAcceptedIssuers() {
512 return null;
513 }
514 };
515 sslContext.init(null, new X509TrustManager[] {tm}, null);
516 } else {
517 sslContext = SSLContext.getDefault();
518 }
519 } catch (Exception e) {
520 if (e instanceof RuntimeException) {
521 throw (RuntimeException) e;
522 } else {
523 throw new IllegalStateException("SSL Context setup failure", e);
524 }
525 }
526 }
527
528 HttpClient.Builder builder = Methanol.newBuilder()
529 .version(HttpClient.Version.valueOf(ConfigUtils.getString(
530 session,
531 DEFAULT_HTTP_VERSION,
532 CONFIG_PROP_HTTP_VERSION + "." + repository.getId(),
533 CONFIG_PROP_HTTP_VERSION)))
534 .followRedirects(HttpClient.Redirect.NORMAL)
535 .connectTimeout(Duration.ofMillis(connectTimeout))
536
537
538
539 .requestTimeout(Duration.ofMillis(requestTimeout))
540 .sslContext(sslContext);
541
542 if (insecure) {
543 SSLParameters sslParameters = sslContext.getDefaultSSLParameters();
544 sslParameters.setEndpointIdentificationAlgorithm(null);
545 builder.sslParameters(sslParameters);
546 }
547
548 setLocalAddress(builder, () -> getHttpLocalAddress(session, repository));
549
550 if (repository.getProxy() != null) {
551 ProxySelector proxy = ProxySelector.of(new InetSocketAddress(
552 repository.getProxy().getHost(), repository.getProxy().getPort()));
553
554 builder.proxy(proxy);
555 try (AuthenticationContext proxyAuthContext = AuthenticationContext.forProxy(session, repository)) {
556 if (proxyAuthContext != null) {
557 String username = proxyAuthContext.get(AuthenticationContext.USERNAME);
558 String password = proxyAuthContext.get(AuthenticationContext.PASSWORD);
559
560 authentications.put(
561 Authenticator.RequestorType.PROXY,
562 new PasswordAuthentication(username, password.toCharArray()));
563 }
564 }
565 }
566
567 if (!authentications.isEmpty()) {
568 builder.authenticator(new Authenticator() {
569 @Override
570 protected PasswordAuthentication getPasswordAuthentication() {
571 return authentications.get(getRequestorType());
572 }
573 });
574 }
575
576 return builder.build();
577 }
578
579 private static InetAddress getHttpLocalAddress(RepositorySystemSession session, RemoteRepository repository) {
580 String bindAddress = ConfigUtils.getString(
581 session,
582 null,
583 ConfigurationProperties.HTTP_LOCAL_ADDRESS + "." + repository.getId(),
584 ConfigurationProperties.HTTP_LOCAL_ADDRESS);
585 if (bindAddress == null) {
586 return null;
587 }
588 try {
589 return InetAddress.getByName(bindAddress);
590 } catch (UnknownHostException uhe) {
591 throw new IllegalArgumentException(
592 "Given bind address (" + bindAddress + ") cannot be resolved for remote repository " + repository,
593 uhe);
594 }
595 }
596
597 private static void setLocalAddress(HttpClient.Builder builder, Supplier<InetAddress> addressSupplier) {
598 try {
599 final InetAddress address = addressSupplier.get();
600 if (address == null) {
601 return;
602 }
603
604 final Method mtd = builder.getClass().getDeclaredMethod("localAddress", InetAddress.class);
605 if (!mtd.canAccess(builder)) {
606 mtd.setAccessible(true);
607 }
608 mtd.invoke(builder, address);
609 } catch (final NoSuchMethodException nsme) {
610
611 } catch (InvocationTargetException e) {
612 throw new IllegalStateException(e.getTargetException());
613 } catch (IllegalAccessException e) {
614 throw new IllegalStateException(e);
615 }
616 }
617 }