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