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.jetty;
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.URI;
27  import java.net.URISyntaxException;
28  import java.nio.file.Files;
29  import java.nio.file.StandardCopyOption;
30  import java.util.Collections;
31  import java.util.HashMap;
32  import java.util.Map;
33  import java.util.concurrent.ExecutionException;
34  import java.util.concurrent.TimeUnit;
35  import java.util.regex.Matcher;
36  import java.util.regex.Pattern;
37  
38  import org.eclipse.aether.ConfigurationProperties;
39  import org.eclipse.aether.RepositorySystemSession;
40  import org.eclipse.aether.repository.AuthenticationContext;
41  import org.eclipse.aether.repository.RemoteRepository;
42  import org.eclipse.aether.spi.connector.transport.AbstractTransporter;
43  import org.eclipse.aether.spi.connector.transport.GetTask;
44  import org.eclipse.aether.spi.connector.transport.PeekTask;
45  import org.eclipse.aether.spi.connector.transport.PutTask;
46  import org.eclipse.aether.spi.connector.transport.TransportTask;
47  import org.eclipse.aether.transfer.NoTransporterException;
48  import org.eclipse.aether.util.ConfigUtils;
49  import org.eclipse.aether.util.FileUtils;
50  import org.eclipse.jetty.client.HttpClient;
51  import org.eclipse.jetty.client.HttpProxy;
52  import org.eclipse.jetty.client.api.Authentication;
53  import org.eclipse.jetty.client.api.Request;
54  import org.eclipse.jetty.client.api.Response;
55  import org.eclipse.jetty.client.dynamic.HttpClientTransportDynamic;
56  import org.eclipse.jetty.client.http.HttpClientConnectionFactory;
57  import org.eclipse.jetty.client.util.BasicAuthentication;
58  import org.eclipse.jetty.client.util.InputStreamResponseListener;
59  import org.eclipse.jetty.client.util.PathRequestContent;
60  import org.eclipse.jetty.http.HttpHeader;
61  import org.eclipse.jetty.http2.client.HTTP2Client;
62  import org.eclipse.jetty.http2.client.http.ClientConnectionFactoryOverHTTP2;
63  import org.eclipse.jetty.io.ClientConnector;
64  import org.eclipse.jetty.util.ssl.SslContextFactory;
65  import org.slf4j.Logger;
66  import org.slf4j.LoggerFactory;
67  
68  /**
69   * A transporter for HTTP/HTTPS.
70   *
71   * @since 2.0.0
72   */
73  final class JettyTransporter extends AbstractTransporter {
74      private static final int MULTIPLE_CHOICES = 300;
75  
76      private static final int NOT_FOUND = 404;
77  
78      private static final int PRECONDITION_FAILED = 412;
79  
80      private static final long MODIFICATION_THRESHOLD = 60L * 1000L;
81  
82      private static final String ACCEPT_ENCODING = "Accept-Encoding";
83  
84      private static final String CONTENT_LENGTH = "Content-Length";
85  
86      private static final String CONTENT_RANGE = "Content-Range";
87  
88      private static final String IF_UNMODIFIED_SINCE = "If-Unmodified-Since";
89  
90      private static final String RANGE = "Range";
91  
92      private static final String USER_AGENT = "User-Agent";
93  
94      private static final Pattern CONTENT_RANGE_PATTERN =
95              Pattern.compile("\\s*bytes\\s+([0-9]+)\\s*-\\s*([0-9]+)\\s*/.*");
96  
97      private final URI baseUri;
98  
99      private final HttpClient client;
100 
101     private final int requestTimeout;
102 
103     private final Map<String, String> headers;
104 
105     JettyTransporter(RepositorySystemSession session, RemoteRepository repository) throws NoTransporterException {
106         try {
107             URI uri = new URI(repository.getUrl()).parseServerAuthority();
108             if (uri.isOpaque()) {
109                 throw new URISyntaxException(repository.getUrl(), "URL must not be opaque");
110             }
111             if (uri.getRawFragment() != null || uri.getRawQuery() != null) {
112                 throw new URISyntaxException(repository.getUrl(), "URL must not have fragment or query");
113             }
114             String path = uri.getPath();
115             if (path == null) {
116                 path = "/";
117             }
118             if (!path.startsWith("/")) {
119                 path = "/" + path;
120             }
121             if (!path.endsWith("/")) {
122                 path = path + "/";
123             }
124             this.baseUri = URI.create(uri.getScheme() + "://" + uri.getRawAuthority() + path);
125         } catch (URISyntaxException e) {
126             throw new NoTransporterException(repository, e.getMessage(), e);
127         }
128 
129         HashMap<String, String> headers = new HashMap<>();
130         String userAgent = ConfigUtils.getString(
131                 session, ConfigurationProperties.DEFAULT_USER_AGENT, ConfigurationProperties.USER_AGENT);
132         if (userAgent != null) {
133             headers.put(USER_AGENT, userAgent);
134         }
135         @SuppressWarnings("unchecked")
136         Map<Object, Object> configuredHeaders = (Map<Object, Object>) ConfigUtils.getMap(
137                 session,
138                 Collections.emptyMap(),
139                 ConfigurationProperties.HTTP_HEADERS + "." + repository.getId(),
140                 ConfigurationProperties.HTTP_HEADERS);
141         if (configuredHeaders != null) {
142             configuredHeaders.forEach((k, v) -> headers.put(String.valueOf(k), v != null ? String.valueOf(v) : null));
143         }
144 
145         this.headers = headers;
146 
147         this.requestTimeout = ConfigUtils.getInteger(
148                 session,
149                 ConfigurationProperties.DEFAULT_REQUEST_TIMEOUT,
150                 ConfigurationProperties.REQUEST_TIMEOUT + "." + repository.getId(),
151                 ConfigurationProperties.REQUEST_TIMEOUT);
152 
153         this.client = getOrCreateClient(session, repository);
154     }
155 
156     private URI resolve(TransportTask task) {
157         return baseUri.resolve(task.getLocation());
158     }
159 
160     @Override
161     public int classify(Throwable error) {
162         if (error instanceof JettyException && ((JettyException) error).getStatusCode() == NOT_FOUND) {
163             return ERROR_NOT_FOUND;
164         }
165         return ERROR_OTHER;
166     }
167 
168     @Override
169     protected void implPeek(PeekTask task) throws Exception {
170         Request request = client.newRequest(resolve(task))
171                 .timeout(requestTimeout, TimeUnit.MILLISECONDS)
172                 .method("HEAD");
173         request.headers(m -> headers.forEach(m::add));
174         Response response = request.send();
175         if (response.getStatus() >= MULTIPLE_CHOICES) {
176             throw new JettyException(response.getStatus());
177         }
178     }
179 
180     @Override
181     protected void implGet(GetTask task) throws Exception {
182         boolean resume = task.getResumeOffset() > 0L && task.getDataFile() != null;
183         Response response;
184         InputStreamResponseListener listener;
185 
186         while (true) {
187             Request request = client.newRequest(resolve(task))
188                     .timeout(requestTimeout, TimeUnit.MILLISECONDS)
189                     .method("GET");
190             request.headers(m -> headers.forEach(m::add));
191 
192             if (resume) {
193                 long resumeOffset = task.getResumeOffset();
194                 request.headers(h -> {
195                     h.add(RANGE, "bytes=" + resumeOffset + '-');
196                     h.addDateField(IF_UNMODIFIED_SINCE, task.getDataFile().lastModified() - MODIFICATION_THRESHOLD);
197                     h.remove(HttpHeader.ACCEPT_ENCODING);
198                     h.add(ACCEPT_ENCODING, "identity");
199                 });
200             }
201 
202             listener = new InputStreamResponseListener();
203             request.send(listener);
204             try {
205                 response = listener.get(requestTimeout, TimeUnit.MILLISECONDS);
206             } catch (ExecutionException e) {
207                 Throwable t = e.getCause();
208                 if (t instanceof Exception) {
209                     throw (Exception) t;
210                 } else {
211                     throw new RuntimeException(t);
212                 }
213             }
214             if (response.getStatus() >= MULTIPLE_CHOICES) {
215                 if (resume && response.getStatus() == PRECONDITION_FAILED) {
216                     resume = false;
217                     continue;
218                 }
219                 throw new JettyException(response.getStatus());
220             }
221             break;
222         }
223 
224         long offset = 0L, length = response.getHeaders().getLongField(CONTENT_LENGTH);
225         if (resume) {
226             String range = response.getHeaders().get(CONTENT_RANGE);
227             if (range != null) {
228                 Matcher m = CONTENT_RANGE_PATTERN.matcher(range);
229                 if (!m.matches()) {
230                     throw new IOException("Invalid Content-Range header for partial download: " + range);
231                 }
232                 offset = Long.parseLong(m.group(1));
233                 length = Long.parseLong(m.group(2)) + 1L;
234                 if (offset < 0L || offset >= length || (offset > 0L && offset != task.getResumeOffset())) {
235                     throw new IOException("Invalid Content-Range header for partial download from offset "
236                             + task.getResumeOffset() + ": " + range);
237                 }
238             }
239         }
240 
241         final boolean downloadResumed = offset > 0L;
242         final File dataFile = task.getDataFile();
243         if (dataFile == null) {
244             try (InputStream is = listener.getInputStream()) {
245                 utilGet(task, is, true, length, downloadResumed);
246             }
247         } else {
248             try (FileUtils.CollocatedTempFile tempFile = FileUtils.newTempFile(dataFile.toPath())) {
249                 task.setDataFile(tempFile.getPath().toFile(), downloadResumed);
250                 if (downloadResumed && Files.isRegularFile(dataFile.toPath())) {
251                     try (InputStream inputStream = Files.newInputStream(dataFile.toPath())) {
252                         Files.copy(inputStream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING);
253                     }
254                 }
255                 try (InputStream is = listener.getInputStream()) {
256                     utilGet(task, is, true, length, downloadResumed);
257                 }
258                 tempFile.move();
259             } finally {
260                 task.setDataFile(dataFile);
261             }
262         }
263         Map<String, String> checksums = extractXChecksums(response);
264         if (checksums != null) {
265             checksums.forEach(task::setChecksum);
266             return;
267         }
268         checksums = extractNexus2Checksums(response);
269         if (checksums != null) {
270             checksums.forEach(task::setChecksum);
271         }
272     }
273 
274     private static Map<String, String> extractXChecksums(Response response) {
275         String value;
276         HashMap<String, String> result = new HashMap<>();
277         // Central style: x-checksum-sha1: c74edb60ca2a0b57ef88d9a7da28f591e3d4ce7b
278         value = response.getHeaders().get("x-checksum-sha1");
279         if (value != null) {
280             result.put("SHA-1", value);
281         }
282         // Central style: x-checksum-md5: 9ad0d8e3482767c122e85f83567b8ce6
283         value = response.getHeaders().get("x-checksum-md5");
284         if (value != null) {
285             result.put("MD5", value);
286         }
287         if (!result.isEmpty()) {
288             return result;
289         }
290         // Google style: x-goog-meta-checksum-sha1: c74edb60ca2a0b57ef88d9a7da28f591e3d4ce7b
291         value = response.getHeaders().get("x-goog-meta-checksum-sha1");
292         if (value != null) {
293             result.put("SHA-1", value);
294         }
295         // Central style: x-goog-meta-checksum-sha1: 9ad0d8e3482767c122e85f83567b8ce6
296         value = response.getHeaders().get("x-goog-meta-checksum-md5");
297         if (value != null) {
298             result.put("MD5", value);
299         }
300 
301         return result.isEmpty() ? null : result;
302     }
303 
304     private static Map<String, String> extractNexus2Checksums(Response response) {
305         // Nexus-style, ETag: "{SHA1{d40d68ba1f88d8e9b0040f175a6ff41928abd5e7}}"
306         String etag = response.getHeaders().get("ETag");
307         if (etag != null) {
308             int start = etag.indexOf("SHA1{"), end = etag.indexOf("}", start + 5);
309             if (start >= 0 && end > start) {
310                 return Collections.singletonMap("SHA-1", etag.substring(start + 5, end));
311             }
312         }
313         return null;
314     }
315 
316     @Override
317     protected void implPut(PutTask task) throws Exception {
318         Request request = client.newRequest(resolve(task)).method("PUT").timeout(requestTimeout, TimeUnit.MILLISECONDS);
319         request.headers(m -> headers.forEach(m::add));
320         try (FileUtils.TempFile tempFile = FileUtils.newTempFile()) {
321             utilPut(task, Files.newOutputStream(tempFile.getPath()), true);
322             request.body(new PathRequestContent(tempFile.getPath()));
323 
324             Response response;
325             try {
326                 response = request.send();
327             } catch (ExecutionException e) {
328                 Throwable t = e.getCause();
329                 if (t instanceof Exception) {
330                     throw (Exception) t;
331                 } else {
332                     throw new RuntimeException(t);
333                 }
334             }
335             if (response.getStatus() >= MULTIPLE_CHOICES) {
336                 throw new JettyException(response.getStatus());
337             }
338         }
339     }
340 
341     @Override
342     protected void implClose() {
343         // noop
344     }
345 
346     /**
347      * Visible for testing.
348      */
349     static final String JETTY_INSTANCE_KEY_PREFIX = JettyTransporterFactory.class.getName() + ".jetty.";
350 
351     static final Logger LOGGER = LoggerFactory.getLogger(JettyTransporter.class);
352 
353     @SuppressWarnings("checkstyle:methodlength")
354     private static HttpClient getOrCreateClient(RepositorySystemSession session, RemoteRepository repository)
355             throws NoTransporterException {
356 
357         final String instanceKey = JETTY_INSTANCE_KEY_PREFIX + repository.getId();
358 
359         try {
360             return (HttpClient) session.getData().computeIfAbsent(instanceKey, () -> {
361                 SSLContext sslContext = null;
362                 BasicAuthentication basicAuthentication = null;
363                 try {
364                     try (AuthenticationContext repoAuthContext =
365                             AuthenticationContext.forRepository(session, repository)) {
366                         if (repoAuthContext != null) {
367                             sslContext = repoAuthContext.get(AuthenticationContext.SSL_CONTEXT, SSLContext.class);
368 
369                             String username = repoAuthContext.get(AuthenticationContext.USERNAME);
370                             String password = repoAuthContext.get(AuthenticationContext.PASSWORD);
371 
372                             basicAuthentication = new BasicAuthentication(
373                                     URI.create(repository.getUrl()), Authentication.ANY_REALM, username, password);
374                         }
375                     }
376 
377                     if (sslContext == null) {
378                         sslContext = SSLContext.getDefault();
379                     }
380 
381                     int connectTimeout = ConfigUtils.getInteger(
382                             session,
383                             ConfigurationProperties.DEFAULT_CONNECT_TIMEOUT,
384                             ConfigurationProperties.CONNECT_TIMEOUT + "." + repository.getId(),
385                             ConfigurationProperties.CONNECT_TIMEOUT);
386 
387                     SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
388                     sslContextFactory.setSslContext(sslContext);
389 
390                     ClientConnector clientConnector = new ClientConnector();
391                     clientConnector.setSslContextFactory(sslContextFactory);
392 
393                     HTTP2Client http2Client = new HTTP2Client(clientConnector);
394                     ClientConnectionFactoryOverHTTP2.HTTP2 http2 =
395                             new ClientConnectionFactoryOverHTTP2.HTTP2(http2Client);
396 
397                     HttpClientTransportDynamic transport;
398                     if ("https".equalsIgnoreCase(repository.getProtocol())) {
399                         transport = new HttpClientTransportDynamic(
400                                 clientConnector, http2, HttpClientConnectionFactory.HTTP11); // HTTPS, prefer H2
401                     } else {
402                         transport = new HttpClientTransportDynamic(
403                                 clientConnector,
404                                 HttpClientConnectionFactory.HTTP11,
405                                 http2); // plaintext HTTP, H2 cannot be used
406                     }
407 
408                     HttpClient httpClient = new HttpClient(transport);
409                     httpClient.setConnectTimeout(connectTimeout);
410                     httpClient.setFollowRedirects(true);
411                     httpClient.setMaxRedirects(2);
412 
413                     httpClient.setUserAgentField(null); // we manage it
414 
415                     if (basicAuthentication != null) {
416                         httpClient.getAuthenticationStore().addAuthentication(basicAuthentication);
417                     }
418 
419                     if (repository.getProxy() != null) {
420                         HttpProxy proxy = new HttpProxy(
421                                 repository.getProxy().getHost(),
422                                 repository.getProxy().getPort());
423 
424                         httpClient.getProxyConfiguration().addProxy(proxy);
425                         try (AuthenticationContext proxyAuthContext =
426                                 AuthenticationContext.forProxy(session, repository)) {
427                             if (proxyAuthContext != null) {
428                                 String username = proxyAuthContext.get(AuthenticationContext.USERNAME);
429                                 String password = proxyAuthContext.get(AuthenticationContext.PASSWORD);
430 
431                                 BasicAuthentication proxyAuthentication = new BasicAuthentication(
432                                         proxy.getURI(), Authentication.ANY_REALM, username, password);
433 
434                                 httpClient.getAuthenticationStore().addAuthentication(proxyAuthentication);
435                             }
436                         }
437                     }
438                     if (!session.addOnSessionEndedHandler(() -> {
439                         try {
440                             httpClient.stop();
441                         } catch (Exception e) {
442                             throw new RuntimeException(e);
443                         }
444                     })) {
445                         LOGGER.warn(
446                                 "Using Resolver 2 feature without Resolver 2 session handling, you may leak resources.");
447                     }
448                     httpClient.start();
449                     return httpClient;
450                 } catch (Exception e) {
451                     throw new WrapperEx(e);
452                 }
453             });
454         } catch (WrapperEx e) {
455             throw new NoTransporterException(repository, e.getCause());
456         }
457     }
458 
459     private static final class WrapperEx extends RuntimeException {
460         private WrapperEx(Throwable cause) {
461             super(cause);
462         }
463     }
464 }