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