View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
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   * JDK Transport using {@link HttpClient}.
72   *
73   * @since 2.0.0
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); // note: Wagon also does first not last
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                         // fall through
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         // no-op
368     }
369 
370     private Map<String, String> extractXChecksums(HttpResponse<?> response) {
371         String value;
372         HashMap<String, String> result = new HashMap<>();
373         // Central style: x-checksum-sha1: c74edb60ca2a0b57ef88d9a7da28f591e3d4ce7b
374         value = response.headers().firstValue("x-checksum-sha1").orElse(null);
375         if (value != null) {
376             result.put("SHA-1", value);
377         }
378         // Central style: x-checksum-md5: 9ad0d8e3482767c122e85f83567b8ce6
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         // Google style: x-goog-meta-checksum-sha1: c74edb60ca2a0b57ef88d9a7da28f591e3d4ce7b
387         value = response.headers().firstValue("x-goog-meta-checksum-sha1").orElse(null);
388         if (value != null) {
389             result.put("SHA-1", value);
390         }
391         // Central style: x-goog-meta-checksum-sha1: 9ad0d8e3482767c122e85f83567b8ce6
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         // Nexus-style, ETag: "{SHA1{d40d68ba1f88d8e9b0040f175a6ff41928abd5e7}}"
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      * Visible for testing.
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         // todo: normally a single client per JVM is sufficient - in particular cause part of the config
452         //       is global and not per instance so we should create a client only when conf changes for a repo
453         //       else fallback on a global client
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             // skip, not yet in the API
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 }