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