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.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
70
71
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
278 value = response.getHeaders().get("x-checksum-sha1");
279 if (value != null) {
280 result.put("SHA-1", value);
281 }
282
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
291 value = response.getHeaders().get("x-goog-meta-checksum-sha1");
292 if (value != null) {
293 result.put("SHA-1", value);
294 }
295
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
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
344 }
345
346
347
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);
401 } else {
402 transport = new HttpClientTransportDynamic(
403 clientConnector,
404 HttpClientConnectionFactory.HTTP11,
405 http2);
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);
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 }