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