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