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