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.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   * JDK Transport using {@link HttpClient}.
76   *
77   * @since 2.0.0
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); // note: Wagon also does first not last
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                         // fall through
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         // no-op
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      * Visible for testing.
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         // todo: normally a single client per JVM is sufficient - in particular cause part of the config
417         //       is global and not per instance so we should create a client only when conf changes for a repo
418         //       else fallback on a global client
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             // skip, not yet in the API
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 }