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