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