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.net.Authenticator;
27  import java.net.InetSocketAddress;
28  import java.net.PasswordAuthentication;
29  import java.net.ProxySelector;
30  import java.net.URI;
31  import java.net.URISyntaxException;
32  import java.net.http.HttpClient;
33  import java.net.http.HttpRequest;
34  import java.net.http.HttpResponse;
35  import java.nio.file.Files;
36  import java.nio.file.StandardCopyOption;
37  import java.nio.file.attribute.FileTime;
38  import java.security.NoSuchAlgorithmException;
39  import java.time.Duration;
40  import java.time.Instant;
41  import java.time.ZoneId;
42  import java.time.ZonedDateTime;
43  import java.time.format.DateTimeFormatter;
44  import java.time.format.DateTimeParseException;
45  import java.util.Collections;
46  import java.util.HashMap;
47  import java.util.Locale;
48  import java.util.Map;
49  import java.util.regex.Matcher;
50  import java.util.regex.Pattern;
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.transfer.NoTransporterException;
62  import org.eclipse.aether.util.ConfigUtils;
63  import org.eclipse.aether.util.FileUtils;
64  
65  /**
66   * JDK Transport using {@link HttpClient}.
67   *
68   * @since TBD
69   */
70  final class JdkHttpTransporter extends AbstractTransporter {
71      private static final int MULTIPLE_CHOICES = 300;
72  
73      private static final int NOT_FOUND = 404;
74  
75      private static final int PRECONDITION_FAILED = 412;
76  
77      private static final long MODIFICATION_THRESHOLD = 60L * 1000L;
78  
79      private static final String ACCEPT_ENCODING = "Accept-Encoding";
80  
81      private static final String CACHE_CONTROL = "Cache-Control";
82  
83      private static final String CONTENT_LENGTH = "Content-Length";
84  
85      private static final String CONTENT_RANGE = "Content-Range";
86  
87      private static final String IF_UNMODIFIED_SINCE = "If-Unmodified-Since";
88  
89      private static final String RANGE = "Range";
90  
91      private static final String USER_AGENT = "User-Agent";
92  
93      private static final String LAST_MODIFIED = "Last-Modified";
94  
95      private static final Pattern CONTENT_RANGE_PATTERN =
96              Pattern.compile("\\s*bytes\\s+([0-9]+)\\s*-\\s*([0-9]+)\\s*/.*");
97  
98      private final URI baseUri;
99  
100     private final HttpClient client;
101 
102     private final Map<String, String> headers;
103 
104     private final int requestTimeout;
105 
106     private final boolean expectContinue;
107 
108     JdkHttpTransporter(RepositorySystemSession session, RemoteRepository repository) throws NoTransporterException {
109         try {
110             URI uri = new URI(repository.getUrl()).parseServerAuthority();
111             if (uri.isOpaque()) {
112                 throw new URISyntaxException(repository.getUrl(), "URL must not be opaque");
113             }
114             if (uri.getRawFragment() != null || uri.getRawQuery() != null) {
115                 throw new URISyntaxException(repository.getUrl(), "URL must not have fragment or query");
116             }
117             String path = uri.getPath();
118             if (path == null) {
119                 path = "/";
120             }
121             if (!path.startsWith("/")) {
122                 path = "/" + path;
123             }
124             if (!path.endsWith("/")) {
125                 path = path + "/";
126             }
127             this.baseUri = URI.create(uri.getScheme() + "://" + uri.getRawAuthority() + path);
128         } catch (URISyntaxException e) {
129             throw new NoTransporterException(repository, e.getMessage(), e);
130         }
131 
132         HashMap<String, String> headers = new HashMap<>();
133         String userAgent = ConfigUtils.getString(
134                 session, ConfigurationProperties.DEFAULT_USER_AGENT, ConfigurationProperties.USER_AGENT);
135         if (userAgent != null) {
136             headers.put(USER_AGENT, userAgent);
137         }
138         @SuppressWarnings("unchecked")
139         Map<Object, Object> configuredHeaders = (Map<Object, Object>) ConfigUtils.getMap(
140                 session,
141                 Collections.emptyMap(),
142                 ConfigurationProperties.HTTP_HEADERS + "." + repository.getId(),
143                 ConfigurationProperties.HTTP_HEADERS);
144         if (configuredHeaders != null) {
145             configuredHeaders.forEach((k, v) -> headers.put(String.valueOf(k), v != null ? String.valueOf(v) : null));
146         }
147         headers.put(CACHE_CONTROL, "no-cache, no-store");
148 
149         this.requestTimeout = ConfigUtils.getInteger(
150                 session,
151                 ConfigurationProperties.DEFAULT_REQUEST_TIMEOUT,
152                 ConfigurationProperties.REQUEST_TIMEOUT + "." + repository.getId(),
153                 ConfigurationProperties.REQUEST_TIMEOUT);
154         this.expectContinue = ConfigUtils.getBoolean(
155                 session,
156                 ConfigurationProperties.DEFAULT_HTTP_EXPECT_CONTINUE,
157                 ConfigurationProperties.HTTP_EXPECT_CONTINUE + "." + repository.getId(),
158                 ConfigurationProperties.HTTP_EXPECT_CONTINUE);
159 
160         this.headers = headers;
161         this.client = getOrCreateClient(session, repository);
162     }
163 
164     private URI resolve(TransportTask task) {
165         return baseUri.resolve(task.getLocation());
166     }
167 
168     @Override
169     public int classify(Throwable error) {
170         if (error instanceof JdkHttpException && ((JdkHttpException) error).getStatusCode() == NOT_FOUND) {
171             return ERROR_NOT_FOUND;
172         }
173         return ERROR_OTHER;
174     }
175 
176     @Override
177     protected void implPeek(PeekTask task) throws Exception {
178         HttpRequest.Builder request = HttpRequest.newBuilder()
179                 .uri(resolve(task))
180                 .timeout(Duration.ofMillis(requestTimeout))
181                 .method("HEAD", HttpRequest.BodyPublishers.noBody());
182         headers.forEach(request::setHeader);
183         HttpResponse<Void> response = client.send(request.build(), HttpResponse.BodyHandlers.discarding());
184         if (response.statusCode() >= MULTIPLE_CHOICES) {
185             throw new JdkHttpException(response.statusCode());
186         }
187     }
188 
189     @Override
190     protected void implGet(GetTask task) throws Exception {
191         boolean resume = task.getResumeOffset() > 0L && task.getDataFile() != null;
192         HttpResponse<InputStream> response;
193 
194         while (true) {
195             HttpRequest.Builder request = HttpRequest.newBuilder()
196                     .uri(resolve(task))
197                     .timeout(Duration.ofMillis(requestTimeout))
198                     .method("GET", HttpRequest.BodyPublishers.noBody());
199             headers.forEach(request::setHeader);
200 
201             if (resume) {
202                 long resumeOffset = task.getResumeOffset();
203                 request.header(RANGE, "bytes=" + resumeOffset + '-');
204                 request.header(
205                         IF_UNMODIFIED_SINCE,
206                         RFC7231.format(
207                                 Instant.ofEpochMilli(task.getDataFile().lastModified() - MODIFICATION_THRESHOLD)));
208                 request.header(ACCEPT_ENCODING, "identity");
209             }
210 
211             response = client.send(request.build(), HttpResponse.BodyHandlers.ofInputStream());
212             if (response.statusCode() >= MULTIPLE_CHOICES) {
213                 if (resume && response.statusCode() == PRECONDITION_FAILED) {
214                     resume = false;
215                     continue;
216                 }
217                 throw new JdkHttpException(response.statusCode());
218             }
219             break;
220         }
221 
222         long offset = 0L,
223                 length = response.headers().firstValueAsLong(CONTENT_LENGTH).orElse(-1L);
224         if (resume) {
225             String range = response.headers().firstValue(CONTENT_RANGE).orElse(null);
226             if (range != null) {
227                 Matcher m = CONTENT_RANGE_PATTERN.matcher(range);
228                 if (!m.matches()) {
229                     throw new IOException("Invalid Content-Range header for partial download: " + range);
230                 }
231                 offset = Long.parseLong(m.group(1));
232                 length = Long.parseLong(m.group(2)) + 1L;
233                 if (offset < 0L || offset >= length || (offset > 0L && offset != task.getResumeOffset())) {
234                     throw new IOException("Invalid Content-Range header for partial download from offset "
235                             + task.getResumeOffset() + ": " + range);
236                 }
237             }
238         }
239 
240         final boolean downloadResumed = offset > 0L;
241         final File dataFile = task.getDataFile();
242         if (dataFile == null) {
243             try (InputStream is = response.body()) {
244                 utilGet(task, is, true, length, downloadResumed);
245             }
246         } else {
247             try (FileUtils.CollocatedTempFile tempFile = FileUtils.newTempFile(dataFile.toPath())) {
248                 task.setDataFile(tempFile.getPath().toFile(), downloadResumed);
249                 if (downloadResumed && Files.isRegularFile(dataFile.toPath())) {
250                     try (InputStream inputStream = Files.newInputStream(dataFile.toPath())) {
251                         Files.copy(inputStream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING);
252                     }
253                 }
254                 try (InputStream is = response.body()) {
255                     utilGet(task, is, true, length, downloadResumed);
256                 }
257                 tempFile.move();
258             } finally {
259                 task.setDataFile(dataFile);
260             }
261         }
262         if (task.getDataFile() != null) {
263             String lastModifiedHeader =
264                     response.headers().firstValue(LAST_MODIFIED).orElse(null); // note: Wagon also does first not last
265             if (lastModifiedHeader != null) {
266                 try {
267                     Files.setLastModifiedTime(
268                             task.getDataFile().toPath(),
269                             FileTime.fromMillis(ZonedDateTime.parse(lastModifiedHeader, RFC7231)
270                                     .toInstant()
271                                     .toEpochMilli()));
272                 } catch (DateTimeParseException e) {
273                     // fall through
274                 }
275             }
276         }
277         Map<String, String> checksums = extractXChecksums(response);
278         if (checksums != null) {
279             checksums.forEach(task::setChecksum);
280             return;
281         }
282         checksums = extractNexus2Checksums(response);
283         if (checksums != null) {
284             checksums.forEach(task::setChecksum);
285         }
286     }
287 
288     @Override
289     protected void implPut(PutTask task) throws Exception {
290         HttpRequest.Builder request = HttpRequest.newBuilder()
291                 .uri(resolve(task))
292                 .timeout(Duration.ofMillis(requestTimeout))
293                 .expectContinue(expectContinue);
294         headers.forEach(request::setHeader);
295         try (FileUtils.TempFile tempFile = FileUtils.newTempFile()) {
296             utilPut(task, Files.newOutputStream(tempFile.getPath()), true);
297             request.method("PUT", HttpRequest.BodyPublishers.ofFile(tempFile.getPath()));
298 
299             HttpResponse<Void> response = client.send(request.build(), HttpResponse.BodyHandlers.discarding());
300             if (response.statusCode() >= MULTIPLE_CHOICES) {
301                 throw new JdkHttpException(response.statusCode());
302             }
303         }
304     }
305 
306     @Override
307     protected void implClose() {
308         // nop
309     }
310 
311     private static Map<String, String> extractXChecksums(HttpResponse<?> response) {
312         String value;
313         HashMap<String, String> result = new HashMap<>();
314         // Central style: x-checksum-sha1: c74edb60ca2a0b57ef88d9a7da28f591e3d4ce7b
315         value = response.headers().firstValue("x-checksum-sha1").orElse(null);
316         if (value != null) {
317             result.put("SHA-1", value);
318         }
319         // Central style: x-checksum-md5: 9ad0d8e3482767c122e85f83567b8ce6
320         value = response.headers().firstValue("x-checksum-md5").orElse(null);
321         if (value != null) {
322             result.put("MD5", value);
323         }
324         if (!result.isEmpty()) {
325             return result;
326         }
327         // Google style: x-goog-meta-checksum-sha1: c74edb60ca2a0b57ef88d9a7da28f591e3d4ce7b
328         value = response.headers().firstValue("x-goog-meta-checksum-sha1").orElse(null);
329         if (value != null) {
330             result.put("SHA-1", value);
331         }
332         // Central style: x-goog-meta-checksum-sha1: 9ad0d8e3482767c122e85f83567b8ce6
333         value = response.headers().firstValue("x-goog-meta-checksum-md5").orElse(null);
334         if (value != null) {
335             result.put("MD5", value);
336         }
337 
338         return result.isEmpty() ? null : result;
339     }
340 
341     private static Map<String, String> extractNexus2Checksums(HttpResponse<?> response) {
342         // Nexus-style, ETag: "{SHA1{d40d68ba1f88d8e9b0040f175a6ff41928abd5e7}}"
343         String etag = response.headers().firstValue("ETag").orElse(null);
344         if (etag != null) {
345             int start = etag.indexOf("SHA1{"), end = etag.indexOf("}", start + 5);
346             if (start >= 0 && end > start) {
347                 return Collections.singletonMap("SHA-1", etag.substring(start + 5, end));
348             }
349         }
350         return null;
351     }
352 
353     private static final DateTimeFormatter RFC7231 = DateTimeFormatter.ofPattern(
354                     "EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH)
355             .withZone(ZoneId.of("GMT"));
356 
357     /**
358      * Visible for testing.
359      */
360     static final String HTTP_INSTANCE_KEY_PREFIX = JdkTransporterFactory.class.getName() + ".http.";
361 
362     private static HttpClient getOrCreateClient(RepositorySystemSession session, RemoteRepository repository)
363             throws NoTransporterException {
364         final String instanceKey = HTTP_INSTANCE_KEY_PREFIX + repository.getId();
365 
366         try {
367             return (HttpClient) session.getData().computeIfAbsent(instanceKey, () -> {
368                 HashMap<Authenticator.RequestorType, PasswordAuthentication> authentications = new HashMap<>();
369                 SSLContext sslContext = null;
370                 try {
371                     try (AuthenticationContext repoAuthContext =
372                             AuthenticationContext.forRepository(session, repository)) {
373                         if (repoAuthContext != null) {
374                             sslContext = repoAuthContext.get(AuthenticationContext.SSL_CONTEXT, SSLContext.class);
375 
376                             String username = repoAuthContext.get(AuthenticationContext.USERNAME);
377                             String password = repoAuthContext.get(AuthenticationContext.PASSWORD);
378 
379                             authentications.put(
380                                     Authenticator.RequestorType.SERVER,
381                                     new PasswordAuthentication(username, password.toCharArray()));
382                         }
383                     }
384 
385                     if (sslContext == null) {
386                         sslContext = SSLContext.getDefault();
387                     }
388 
389                     int connectTimeout = ConfigUtils.getInteger(
390                             session,
391                             ConfigurationProperties.DEFAULT_CONNECT_TIMEOUT,
392                             ConfigurationProperties.CONNECT_TIMEOUT + "." + repository.getId(),
393                             ConfigurationProperties.CONNECT_TIMEOUT);
394 
395                     HttpClient.Builder builder = HttpClient.newBuilder()
396                             .version(HttpClient.Version.HTTP_2)
397                             .followRedirects(HttpClient.Redirect.NORMAL)
398                             .connectTimeout(Duration.ofMillis(connectTimeout))
399                             .sslContext(sslContext);
400 
401                     JdkHttpTransporterCustomizer.customizeBuilder(session, repository, builder);
402 
403                     if (repository.getProxy() != null) {
404                         ProxySelector proxy = ProxySelector.of(new InetSocketAddress(
405                                 repository.getProxy().getHost(),
406                                 repository.getProxy().getPort()));
407 
408                         builder.proxy(proxy);
409                         try (AuthenticationContext proxyAuthContext =
410                                 AuthenticationContext.forProxy(session, repository)) {
411                             if (proxyAuthContext != null) {
412                                 String username = proxyAuthContext.get(AuthenticationContext.USERNAME);
413                                 String password = proxyAuthContext.get(AuthenticationContext.PASSWORD);
414 
415                                 authentications.put(
416                                         Authenticator.RequestorType.PROXY,
417                                         new PasswordAuthentication(username, password.toCharArray()));
418                             }
419                         }
420                     }
421 
422                     if (!authentications.isEmpty()) {
423                         builder.authenticator(new Authenticator() {
424                             @Override
425                             protected PasswordAuthentication getPasswordAuthentication() {
426                                 return authentications.get(getRequestorType());
427                             }
428                         });
429                     }
430 
431                     HttpClient result = builder.build();
432                     JdkHttpTransporterCustomizer.customizeHttpClient(session, repository, result);
433                     return result;
434                 } catch (NoSuchAlgorithmException e) {
435                     throw new WrapperEx(e);
436                 }
437             });
438         } catch (WrapperEx e) {
439             throw new NoTransporterException(repository, e.getCause());
440         }
441     }
442 
443     private static final class WrapperEx extends RuntimeException {
444         private WrapperEx(Throwable cause) {
445             super(cause);
446         }
447     }
448 }