1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 package org.eclipse.aether.transport.jetty;
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.net.URI;
27 import java.net.URISyntaxException;
28 import java.nio.file.Files;
29 import java.nio.file.StandardCopyOption;
30 import java.nio.file.attribute.FileTime;
31 import java.security.cert.X509Certificate;
32 import java.time.format.DateTimeParseException;
33 import java.util.Collections;
34 import java.util.HashMap;
35 import java.util.Map;
36 import java.util.concurrent.ExecutionException;
37 import java.util.concurrent.TimeUnit;
38 import java.util.concurrent.atomic.AtomicBoolean;
39 import java.util.concurrent.atomic.AtomicReference;
40 import java.util.regex.Matcher;
41 import java.util.regex.Pattern;
42
43 import org.eclipse.aether.ConfigurationProperties;
44 import org.eclipse.aether.RepositorySystemSession;
45 import org.eclipse.aether.repository.AuthenticationContext;
46 import org.eclipse.aether.repository.RemoteRepository;
47 import org.eclipse.aether.spi.connector.transport.AbstractTransporter;
48 import org.eclipse.aether.spi.connector.transport.GetTask;
49 import org.eclipse.aether.spi.connector.transport.PeekTask;
50 import org.eclipse.aether.spi.connector.transport.PutTask;
51 import org.eclipse.aether.spi.connector.transport.TransportTask;
52 import org.eclipse.aether.spi.connector.transport.http.HttpTransporter;
53 import org.eclipse.aether.spi.connector.transport.http.HttpTransporterException;
54 import org.eclipse.aether.transfer.NoTransporterException;
55 import org.eclipse.aether.transfer.TransferCancelledException;
56 import org.eclipse.aether.util.ConfigUtils;
57 import org.eclipse.aether.util.FileUtils;
58 import org.eclipse.jetty.client.HttpClient;
59 import org.eclipse.jetty.client.HttpProxy;
60 import org.eclipse.jetty.client.api.Authentication;
61 import org.eclipse.jetty.client.api.Request;
62 import org.eclipse.jetty.client.api.Response;
63 import org.eclipse.jetty.client.dynamic.HttpClientTransportDynamic;
64 import org.eclipse.jetty.client.http.HttpClientConnectionFactory;
65 import org.eclipse.jetty.client.util.BasicAuthentication;
66 import org.eclipse.jetty.client.util.InputStreamResponseListener;
67 import org.eclipse.jetty.http.HttpHeader;
68 import org.eclipse.jetty.http2.client.HTTP2Client;
69 import org.eclipse.jetty.http2.client.http.ClientConnectionFactoryOverHTTP2;
70 import org.eclipse.jetty.io.ClientConnector;
71 import org.eclipse.jetty.util.ssl.SslContextFactory;
72 import org.slf4j.Logger;
73 import org.slf4j.LoggerFactory;
74
75
76
77
78
79
80 final class JettyTransporter extends AbstractTransporter implements HttpTransporter {
81 private static final int MULTIPLE_CHOICES = 300;
82
83 private static final int NOT_FOUND = 404;
84
85 private static final int PRECONDITION_FAILED = 412;
86
87 private static final long MODIFICATION_THRESHOLD = 60L * 1000L;
88
89 private static final String ACCEPT_ENCODING = "Accept-Encoding";
90
91 private static final String CONTENT_LENGTH = "Content-Length";
92
93 private static final String CONTENT_RANGE = "Content-Range";
94
95 private static final String LAST_MODIFIED = "Last-Modified";
96
97 private static final String IF_UNMODIFIED_SINCE = "If-Unmodified-Since";
98
99 private static final String RANGE = "Range";
100
101 private static final String USER_AGENT = "User-Agent";
102
103 private static final Pattern CONTENT_RANGE_PATTERN =
104 Pattern.compile("\\s*bytes\\s+([0-9]+)\\s*-\\s*([0-9]+)\\s*/.*");
105
106 private final URI baseUri;
107
108 private final HttpClient client;
109
110 private final int requestTimeout;
111
112 private final Map<String, String> headers;
113
114 private final boolean preemptiveAuth;
115
116 private final boolean preemptivePutAuth;
117
118 private final BasicAuthentication.BasicResult basicServerAuthenticationResult;
119
120 private final BasicAuthentication.BasicResult basicProxyAuthenticationResult;
121
122 JettyTransporter(RepositorySystemSession session, RemoteRepository repository) throws NoTransporterException {
123 try {
124 URI uri = new URI(repository.getUrl()).parseServerAuthority();
125 if (uri.isOpaque()) {
126 throw new URISyntaxException(repository.getUrl(), "URL must not be opaque");
127 }
128 if (uri.getRawFragment() != null || uri.getRawQuery() != null) {
129 throw new URISyntaxException(repository.getUrl(), "URL must not have fragment or query");
130 }
131 String path = uri.getPath();
132 if (path == null) {
133 path = "/";
134 }
135 if (!path.startsWith("/")) {
136 path = "/" + path;
137 }
138 if (!path.endsWith("/")) {
139 path = path + "/";
140 }
141 this.baseUri = URI.create(uri.getScheme() + "://" + uri.getRawAuthority() + path);
142 } catch (URISyntaxException e) {
143 throw new NoTransporterException(repository, e.getMessage(), e);
144 }
145
146 HashMap<String, String> headers = new HashMap<>();
147 String userAgent = ConfigUtils.getString(
148 session, ConfigurationProperties.DEFAULT_USER_AGENT, ConfigurationProperties.USER_AGENT);
149 if (userAgent != null) {
150 headers.put(USER_AGENT, userAgent);
151 }
152 @SuppressWarnings("unchecked")
153 Map<Object, Object> configuredHeaders = (Map<Object, Object>) ConfigUtils.getMap(
154 session,
155 Collections.emptyMap(),
156 ConfigurationProperties.HTTP_HEADERS + "." + repository.getId(),
157 ConfigurationProperties.HTTP_HEADERS);
158 if (configuredHeaders != null) {
159 configuredHeaders.forEach((k, v) -> headers.put(String.valueOf(k), v != null ? String.valueOf(v) : null));
160 }
161
162 this.headers = headers;
163
164 this.requestTimeout = ConfigUtils.getInteger(
165 session,
166 ConfigurationProperties.DEFAULT_REQUEST_TIMEOUT,
167 ConfigurationProperties.REQUEST_TIMEOUT + "." + repository.getId(),
168 ConfigurationProperties.REQUEST_TIMEOUT);
169 this.preemptiveAuth = ConfigUtils.getBoolean(
170 session,
171 ConfigurationProperties.DEFAULT_HTTP_PREEMPTIVE_AUTH,
172 ConfigurationProperties.HTTP_PREEMPTIVE_AUTH + "." + repository.getId(),
173 ConfigurationProperties.HTTP_PREEMPTIVE_AUTH);
174 this.preemptivePutAuth = ConfigUtils.getBoolean(
175 session,
176 ConfigurationProperties.DEFAULT_HTTP_PREEMPTIVE_PUT_AUTH,
177 ConfigurationProperties.HTTP_PREEMPTIVE_PUT_AUTH + "." + repository.getId(),
178 ConfigurationProperties.HTTP_PREEMPTIVE_PUT_AUTH);
179
180 this.client = getOrCreateClient(session, repository);
181
182 final String instanceKey = JETTY_INSTANCE_KEY_PREFIX + repository.getId();
183 this.basicServerAuthenticationResult =
184 (BasicAuthentication.BasicResult) session.getData().get(instanceKey + ".serverAuth");
185 this.basicProxyAuthenticationResult =
186 (BasicAuthentication.BasicResult) session.getData().get(instanceKey + ".proxyAuth");
187 }
188
189 private URI resolve(TransportTask task) {
190 return baseUri.resolve(task.getLocation());
191 }
192
193 @Override
194 public int classify(Throwable error) {
195 if (error instanceof HttpTransporterException
196 && ((HttpTransporterException) error).getStatusCode() == NOT_FOUND) {
197 return ERROR_NOT_FOUND;
198 }
199 return ERROR_OTHER;
200 }
201
202 @Override
203 protected void implPeek(PeekTask task) throws Exception {
204 Request request = client.newRequest(resolve(task))
205 .timeout(requestTimeout, TimeUnit.MILLISECONDS)
206 .method("HEAD");
207 request.headers(m -> headers.forEach(m::add));
208 if (preemptiveAuth) {
209 if (basicServerAuthenticationResult != null) {
210 basicServerAuthenticationResult.apply(request);
211 }
212 if (basicProxyAuthenticationResult != null) {
213 basicProxyAuthenticationResult.apply(request);
214 }
215 }
216 Response response = request.send();
217 if (response.getStatus() >= MULTIPLE_CHOICES) {
218 throw new HttpTransporterException(response.getStatus());
219 }
220 }
221
222 @Override
223 protected void implGet(GetTask task) throws Exception {
224 boolean resume = task.getResumeOffset() > 0L && task.getDataFile() != null;
225 Response response;
226 InputStreamResponseListener listener;
227
228 while (true) {
229 Request request = client.newRequest(resolve(task))
230 .timeout(requestTimeout, TimeUnit.MILLISECONDS)
231 .method("GET");
232 request.headers(m -> headers.forEach(m::add));
233 if (preemptiveAuth) {
234 if (basicServerAuthenticationResult != null) {
235 basicServerAuthenticationResult.apply(request);
236 }
237 if (basicProxyAuthenticationResult != null) {
238 basicProxyAuthenticationResult.apply(request);
239 }
240 }
241
242 if (resume) {
243 long resumeOffset = task.getResumeOffset();
244 request.headers(h -> {
245 h.add(RANGE, "bytes=" + resumeOffset + '-');
246 h.addDateField(IF_UNMODIFIED_SINCE, task.getDataFile().lastModified() - MODIFICATION_THRESHOLD);
247 h.remove(HttpHeader.ACCEPT_ENCODING);
248 h.add(ACCEPT_ENCODING, "identity");
249 });
250 }
251
252 listener = new InputStreamResponseListener();
253 request.send(listener);
254 try {
255 response = listener.get(requestTimeout, TimeUnit.MILLISECONDS);
256 } catch (ExecutionException e) {
257 Throwable t = e.getCause();
258 if (t instanceof Exception) {
259 throw (Exception) t;
260 } else {
261 throw new RuntimeException(t);
262 }
263 }
264 if (response.getStatus() >= MULTIPLE_CHOICES) {
265 if (resume && response.getStatus() == PRECONDITION_FAILED) {
266 resume = false;
267 continue;
268 }
269 throw new HttpTransporterException(response.getStatus());
270 }
271 break;
272 }
273
274 long offset = 0L, length = response.getHeaders().getLongField(CONTENT_LENGTH);
275 if (resume) {
276 String range = response.getHeaders().get(CONTENT_RANGE);
277 if (range != null) {
278 Matcher m = CONTENT_RANGE_PATTERN.matcher(range);
279 if (!m.matches()) {
280 throw new IOException("Invalid Content-Range header for partial download: " + range);
281 }
282 offset = Long.parseLong(m.group(1));
283 length = Long.parseLong(m.group(2)) + 1L;
284 if (offset < 0L || offset >= length || (offset > 0L && offset != task.getResumeOffset())) {
285 throw new IOException("Invalid Content-Range header for partial download from offset "
286 + task.getResumeOffset() + ": " + range);
287 }
288 }
289 }
290
291 final boolean downloadResumed = offset > 0L;
292 final File dataFile = task.getDataFile();
293 if (dataFile == null) {
294 try (InputStream is = listener.getInputStream()) {
295 utilGet(task, is, true, length, downloadResumed);
296 }
297 } else {
298 try (FileUtils.CollocatedTempFile tempFile = FileUtils.newTempFile(dataFile.toPath())) {
299 task.setDataFile(tempFile.getPath().toFile(), downloadResumed);
300 if (downloadResumed && Files.isRegularFile(dataFile.toPath())) {
301 try (InputStream inputStream = Files.newInputStream(dataFile.toPath())) {
302 Files.copy(inputStream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING);
303 }
304 }
305 try (InputStream is = listener.getInputStream()) {
306 utilGet(task, is, true, length, downloadResumed);
307 }
308 tempFile.move();
309 } finally {
310 task.setDataFile(dataFile);
311 }
312 }
313 if (task.getDataFile() != null && response.getHeaders().getDateField(LAST_MODIFIED) != -1) {
314 long lastModified =
315 response.getHeaders().getDateField(LAST_MODIFIED);
316 if (lastModified != -1) {
317 try {
318 Files.setLastModifiedTime(task.getDataFile().toPath(), FileTime.fromMillis(lastModified));
319 } catch (DateTimeParseException e) {
320
321 }
322 }
323 }
324 Map<String, String> checksums = extractXChecksums(response);
325 if (checksums != null) {
326 checksums.forEach(task::setChecksum);
327 return;
328 }
329 checksums = extractNexus2Checksums(response);
330 if (checksums != null) {
331 checksums.forEach(task::setChecksum);
332 }
333 }
334
335 private static Map<String, String> extractXChecksums(Response response) {
336 String value;
337 HashMap<String, String> result = new HashMap<>();
338
339 value = response.getHeaders().get("x-checksum-sha1");
340 if (value != null) {
341 result.put("SHA-1", value);
342 }
343
344 value = response.getHeaders().get("x-checksum-md5");
345 if (value != null) {
346 result.put("MD5", value);
347 }
348 if (!result.isEmpty()) {
349 return result;
350 }
351
352 value = response.getHeaders().get("x-goog-meta-checksum-sha1");
353 if (value != null) {
354 result.put("SHA-1", value);
355 }
356
357 value = response.getHeaders().get("x-goog-meta-checksum-md5");
358 if (value != null) {
359 result.put("MD5", value);
360 }
361
362 return result.isEmpty() ? null : result;
363 }
364
365 private static Map<String, String> extractNexus2Checksums(Response response) {
366
367 String etag = response.getHeaders().get("ETag");
368 if (etag != null) {
369 int start = etag.indexOf("SHA1{"), end = etag.indexOf("}", start + 5);
370 if (start >= 0 && end > start) {
371 return Collections.singletonMap("SHA-1", etag.substring(start + 5, end));
372 }
373 }
374 return null;
375 }
376
377 @Override
378 protected void implPut(PutTask task) throws Exception {
379 Request request = client.newRequest(resolve(task)).method("PUT").timeout(requestTimeout, TimeUnit.MILLISECONDS);
380 request.headers(m -> headers.forEach(m::add));
381 if (preemptiveAuth || preemptivePutAuth) {
382 if (basicServerAuthenticationResult != null) {
383 basicServerAuthenticationResult.apply(request);
384 }
385 if (basicProxyAuthenticationResult != null) {
386 basicProxyAuthenticationResult.apply(request);
387 }
388 }
389 request.body(new PutTaskRequestContent(task));
390 AtomicBoolean started = new AtomicBoolean(false);
391 Response response;
392 try {
393 response = request.onRequestCommit(r -> {
394 if (task.getDataLength() == 0) {
395 if (started.compareAndSet(false, true)) {
396 try {
397 task.getListener().transportStarted(0, task.getDataLength());
398 } catch (TransferCancelledException e) {
399 r.abort(e);
400 }
401 }
402 }
403 })
404 .onRequestContent((r, b) -> {
405 if (started.compareAndSet(false, true)) {
406 try {
407 task.getListener().transportStarted(0, task.getDataLength());
408 } catch (TransferCancelledException e) {
409 r.abort(e);
410 return;
411 }
412 }
413 try {
414 task.getListener().transportProgressed(b);
415 } catch (TransferCancelledException e) {
416 r.abort(e);
417 }
418 })
419 .send();
420 } catch (ExecutionException e) {
421 Throwable t = e.getCause();
422 if (t instanceof IOException) {
423 IOException ioex = (IOException) t;
424 if (ioex.getCause() instanceof TransferCancelledException) {
425 throw (TransferCancelledException) ioex.getCause();
426 } else {
427 throw ioex;
428 }
429 } else if (t instanceof Exception) {
430 throw (Exception) t;
431 } else {
432 throw new RuntimeException(t);
433 }
434 }
435 if (response.getStatus() >= MULTIPLE_CHOICES) {
436 throw new HttpTransporterException(response.getStatus());
437 }
438 }
439
440 @Override
441 protected void implClose() {
442
443 }
444
445
446
447
448 static final String JETTY_INSTANCE_KEY_PREFIX = JettyTransporterFactory.class.getName() + ".jetty.";
449
450 static final Logger LOGGER = LoggerFactory.getLogger(JettyTransporter.class);
451
452 @SuppressWarnings("checkstyle:methodlength")
453 private HttpClient getOrCreateClient(RepositorySystemSession session, RemoteRepository repository)
454 throws NoTransporterException {
455
456 final String instanceKey = JETTY_INSTANCE_KEY_PREFIX + repository.getId();
457
458 final String httpsSecurityMode = ConfigUtils.getString(
459 session,
460 ConfigurationProperties.HTTPS_SECURITY_MODE_DEFAULT,
461 ConfigurationProperties.HTTPS_SECURITY_MODE + "." + repository.getId(),
462 ConfigurationProperties.HTTPS_SECURITY_MODE);
463
464 if (!ConfigurationProperties.HTTPS_SECURITY_MODE_DEFAULT.equals(httpsSecurityMode)
465 && !ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE.equals(httpsSecurityMode)) {
466 throw new IllegalArgumentException("Unsupported '" + httpsSecurityMode + "' HTTPS security mode.");
467 }
468 final boolean insecure = ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE.equals(httpsSecurityMode);
469
470 try {
471 AtomicReference<BasicAuthentication.BasicResult> serverAuth = new AtomicReference<>(null);
472 AtomicReference<BasicAuthentication.BasicResult> proxyAuth = new AtomicReference<>(null);
473 HttpClient client = (HttpClient) session.getData().computeIfAbsent(instanceKey, () -> {
474 SSLContext sslContext = null;
475 BasicAuthentication basicAuthentication = null;
476 try {
477 try (AuthenticationContext repoAuthContext =
478 AuthenticationContext.forRepository(session, repository)) {
479 if (repoAuthContext != null) {
480 sslContext = repoAuthContext.get(AuthenticationContext.SSL_CONTEXT, SSLContext.class);
481
482 String username = repoAuthContext.get(AuthenticationContext.USERNAME);
483 String password = repoAuthContext.get(AuthenticationContext.PASSWORD);
484
485 URI uri = URI.create(repository.getUrl());
486 basicAuthentication =
487 new BasicAuthentication(uri, Authentication.ANY_REALM, username, password);
488 if (preemptiveAuth || preemptivePutAuth) {
489 serverAuth.set(new BasicAuthentication.BasicResult(
490 uri, HttpHeader.AUTHORIZATION, username, password));
491 }
492 }
493 }
494
495 if (sslContext == null) {
496 if (insecure) {
497 sslContext = SSLContext.getInstance("TLS");
498 X509TrustManager tm = new X509TrustManager() {
499 @Override
500 public void checkClientTrusted(X509Certificate[] chain, String authType) {}
501
502 @Override
503 public void checkServerTrusted(X509Certificate[] chain, String authType) {}
504
505 @Override
506 public X509Certificate[] getAcceptedIssuers() {
507 return new X509Certificate[0];
508 }
509 };
510 sslContext.init(null, new X509TrustManager[] {tm}, null);
511 } else {
512 sslContext = SSLContext.getDefault();
513 }
514 }
515
516 int connectTimeout = ConfigUtils.getInteger(
517 session,
518 ConfigurationProperties.DEFAULT_CONNECT_TIMEOUT,
519 ConfigurationProperties.CONNECT_TIMEOUT + "." + repository.getId(),
520 ConfigurationProperties.CONNECT_TIMEOUT);
521
522 SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
523 sslContextFactory.setSslContext(sslContext);
524 if (insecure) {
525 sslContextFactory.setEndpointIdentificationAlgorithm(null);
526 sslContextFactory.setHostnameVerifier((name, context) -> true);
527 }
528
529 ClientConnector clientConnector = new ClientConnector();
530 clientConnector.setSslContextFactory(sslContextFactory);
531
532 HTTP2Client http2Client = new HTTP2Client(clientConnector);
533 ClientConnectionFactoryOverHTTP2.HTTP2 http2 =
534 new ClientConnectionFactoryOverHTTP2.HTTP2(http2Client);
535
536 HttpClientTransportDynamic transport;
537 if ("https".equalsIgnoreCase(repository.getProtocol())) {
538 transport = new HttpClientTransportDynamic(
539 clientConnector, http2, HttpClientConnectionFactory.HTTP11);
540 } else {
541 transport = new HttpClientTransportDynamic(
542 clientConnector,
543 HttpClientConnectionFactory.HTTP11,
544 http2);
545 }
546
547 HttpClient httpClient = new HttpClient(transport);
548 httpClient.setConnectTimeout(connectTimeout);
549 httpClient.setFollowRedirects(true);
550 httpClient.setMaxRedirects(2);
551
552 httpClient.setUserAgentField(null);
553
554 if (basicAuthentication != null) {
555 httpClient.getAuthenticationStore().addAuthentication(basicAuthentication);
556 }
557
558 if (repository.getProxy() != null) {
559 HttpProxy proxy = new HttpProxy(
560 repository.getProxy().getHost(),
561 repository.getProxy().getPort());
562
563 httpClient.getProxyConfiguration().addProxy(proxy);
564 try (AuthenticationContext proxyAuthContext =
565 AuthenticationContext.forProxy(session, repository)) {
566 if (proxyAuthContext != null) {
567 String username = proxyAuthContext.get(AuthenticationContext.USERNAME);
568 String password = proxyAuthContext.get(AuthenticationContext.PASSWORD);
569
570 BasicAuthentication proxyAuthentication = new BasicAuthentication(
571 proxy.getURI(), Authentication.ANY_REALM, username, password);
572
573 httpClient.getAuthenticationStore().addAuthentication(proxyAuthentication);
574 if (preemptiveAuth || preemptivePutAuth) {
575 proxyAuth.set(new BasicAuthentication.BasicResult(
576 proxy.getURI(), HttpHeader.PROXY_AUTHORIZATION, username, password));
577 }
578 }
579 }
580 }
581 if (!session.addOnSessionEndedHandler(() -> {
582 try {
583 httpClient.stop();
584 } catch (Exception e) {
585 throw new RuntimeException(e);
586 }
587 })) {
588 LOGGER.warn(
589 "Using Resolver 2 feature without Resolver 2 session handling, you may leak resources.");
590 }
591 httpClient.start();
592 return httpClient;
593 } catch (Exception e) {
594 throw new WrapperEx(e);
595 }
596 });
597 if (serverAuth.get() != null) {
598 session.getData().set(instanceKey + ".serverAuth", serverAuth.get());
599 }
600 if (proxyAuth.get() != null) {
601 session.getData().set(instanceKey + ".proxyAuth", proxyAuth.get());
602 }
603 return client;
604 } catch (WrapperEx e) {
605 throw new NoTransporterException(repository, e.getCause());
606 }
607 }
608
609 private static final class WrapperEx extends RuntimeException {
610 private WrapperEx(Throwable cause) {
611 super(cause);
612 }
613 }
614 }