1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 package org.eclipse.aether.transport.jdk;
20
21 import javax.net.ssl.SSLContext;
22 import javax.net.ssl.SSLEngine;
23 import javax.net.ssl.SSLParameters;
24 import javax.net.ssl.X509ExtendedTrustManager;
25 import javax.net.ssl.X509TrustManager;
26
27 import java.io.BufferedInputStream;
28 import java.io.IOException;
29 import java.io.InputStream;
30 import java.lang.reflect.InvocationTargetException;
31 import java.lang.reflect.Method;
32 import java.net.Authenticator;
33 import java.net.ConnectException;
34 import java.net.InetAddress;
35 import java.net.InetSocketAddress;
36 import java.net.PasswordAuthentication;
37 import java.net.ProxySelector;
38 import java.net.Socket;
39 import java.net.URI;
40 import java.net.URISyntaxException;
41 import java.net.UnknownHostException;
42 import java.net.http.HttpClient;
43 import java.net.http.HttpRequest;
44 import java.net.http.HttpResponse;
45 import java.nio.file.Files;
46 import java.nio.file.Path;
47 import java.nio.file.StandardCopyOption;
48 import java.security.cert.X509Certificate;
49 import java.time.Duration;
50 import java.time.Instant;
51 import java.time.ZoneId;
52 import java.time.ZonedDateTime;
53 import java.time.format.DateTimeFormatter;
54 import java.time.format.DateTimeParseException;
55 import java.util.Collections;
56 import java.util.HashMap;
57 import java.util.Locale;
58 import java.util.Map;
59 import java.util.concurrent.Semaphore;
60 import java.util.function.Function;
61 import java.util.function.Supplier;
62 import java.util.regex.Matcher;
63
64 import org.eclipse.aether.ConfigurationProperties;
65 import org.eclipse.aether.RepositorySystemSession;
66 import org.eclipse.aether.repository.AuthenticationContext;
67 import org.eclipse.aether.repository.RemoteRepository;
68 import org.eclipse.aether.spi.connector.transport.AbstractTransporter;
69 import org.eclipse.aether.spi.connector.transport.GetTask;
70 import org.eclipse.aether.spi.connector.transport.PeekTask;
71 import org.eclipse.aether.spi.connector.transport.PutTask;
72 import org.eclipse.aether.spi.connector.transport.TransportTask;
73 import org.eclipse.aether.spi.connector.transport.http.ChecksumExtractor;
74 import org.eclipse.aether.spi.connector.transport.http.HttpTransporter;
75 import org.eclipse.aether.spi.connector.transport.http.HttpTransporterException;
76 import org.eclipse.aether.spi.io.PathProcessor;
77 import org.eclipse.aether.transfer.NoTransporterException;
78 import org.eclipse.aether.util.ConfigUtils;
79 import org.eclipse.aether.util.FileUtils;
80 import org.slf4j.Logger;
81 import org.slf4j.LoggerFactory;
82
83 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.ACCEPT_ENCODING;
84 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CACHE_CONTROL;
85 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_LENGTH;
86 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_RANGE;
87 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_RANGE_PATTERN;
88 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.IF_UNMODIFIED_SINCE;
89 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.LAST_MODIFIED;
90 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.MULTIPLE_CHOICES;
91 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.NOT_FOUND;
92 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.PRECONDITION_FAILED;
93 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.RANGE;
94 import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.USER_AGENT;
95 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.CONFIG_PROP_HTTP_VERSION;
96 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.CONFIG_PROP_MAX_CONCURRENT_REQUESTS;
97 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.DEFAULT_HTTP_VERSION;
98 import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.DEFAULT_MAX_CONCURRENT_REQUESTS;
99
100
101
102
103
104
105
106
107
108
109
110
111
112 final class JdkTransporter extends AbstractTransporter implements HttpTransporter {
113 private static final Logger LOGGER = LoggerFactory.getLogger(JdkTransporter.class);
114
115 private static final DateTimeFormatter RFC7231 = DateTimeFormatter.ofPattern(
116 "EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH)
117 .withZone(ZoneId.of("GMT"));
118
119 private static final long MODIFICATION_THRESHOLD = 60L * 1000L;
120
121 private final ChecksumExtractor checksumExtractor;
122
123 private final PathProcessor pathProcessor;
124
125 private final URI baseUri;
126
127 private final HttpClient client;
128
129 private final Map<String, String> headers;
130
131 private final int connectTimeout;
132
133 private final int requestTimeout;
134
135 private final Boolean expectContinue;
136
137 private final Semaphore maxConcurrentRequests;
138
139 JdkTransporter(
140 RepositorySystemSession session,
141 RemoteRepository repository,
142 int javaVersion,
143 ChecksumExtractor checksumExtractor,
144 PathProcessor pathProcessor)
145 throws NoTransporterException {
146 this.checksumExtractor = checksumExtractor;
147 this.pathProcessor = pathProcessor;
148 try {
149 URI uri = new URI(repository.getUrl()).parseServerAuthority();
150 if (uri.isOpaque()) {
151 throw new URISyntaxException(repository.getUrl(), "URL must not be opaque");
152 }
153 if (uri.getRawFragment() != null || uri.getRawQuery() != null) {
154 throw new URISyntaxException(repository.getUrl(), "URL must not have fragment or query");
155 }
156 String path = uri.getPath();
157 if (path == null) {
158 path = "/";
159 }
160 if (!path.startsWith("/")) {
161 path = "/" + path;
162 }
163 if (!path.endsWith("/")) {
164 path = path + "/";
165 }
166 this.baseUri = URI.create(uri.getScheme() + "://" + uri.getRawAuthority() + path);
167 } catch (URISyntaxException e) {
168 throw new NoTransporterException(repository, e.getMessage(), e);
169 }
170
171 HashMap<String, String> headers = new HashMap<>();
172 String userAgent = ConfigUtils.getString(
173 session, ConfigurationProperties.DEFAULT_USER_AGENT, ConfigurationProperties.USER_AGENT);
174 if (userAgent != null) {
175 headers.put(USER_AGENT, userAgent);
176 }
177 @SuppressWarnings("unchecked")
178 Map<Object, Object> configuredHeaders = (Map<Object, Object>) ConfigUtils.getMap(
179 session,
180 Collections.emptyMap(),
181 ConfigurationProperties.HTTP_HEADERS + "." + repository.getId(),
182 ConfigurationProperties.HTTP_HEADERS);
183 if (configuredHeaders != null) {
184 configuredHeaders.forEach((k, v) -> headers.put(String.valueOf(k), v != null ? String.valueOf(v) : null));
185 }
186 headers.put(CACHE_CONTROL, "no-cache, no-store");
187
188 this.connectTimeout = ConfigUtils.getInteger(
189 session,
190 ConfigurationProperties.DEFAULT_CONNECT_TIMEOUT,
191 ConfigurationProperties.CONNECT_TIMEOUT + "." + repository.getId(),
192 ConfigurationProperties.CONNECT_TIMEOUT);
193 this.requestTimeout = ConfigUtils.getInteger(
194 session,
195 ConfigurationProperties.DEFAULT_REQUEST_TIMEOUT,
196 ConfigurationProperties.REQUEST_TIMEOUT + "." + repository.getId(),
197 ConfigurationProperties.REQUEST_TIMEOUT);
198 String expectContinueConf = ConfigUtils.getString(
199 session,
200 null,
201 ConfigurationProperties.HTTP_EXPECT_CONTINUE + "." + repository.getId(),
202 ConfigurationProperties.HTTP_EXPECT_CONTINUE);
203 if (javaVersion > 19) {
204 this.expectContinue = expectContinueConf == null ? null : Boolean.parseBoolean(expectContinueConf);
205 } else {
206 this.expectContinue = null;
207 if (expectContinueConf != null) {
208 LOGGER.warn(
209 "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",
210 javaVersion);
211 }
212 }
213 final String httpsSecurityMode = ConfigUtils.getString(
214 session,
215 ConfigurationProperties.HTTPS_SECURITY_MODE_DEFAULT,
216 ConfigurationProperties.HTTPS_SECURITY_MODE + "." + repository.getId(),
217 ConfigurationProperties.HTTPS_SECURITY_MODE);
218
219 if (!ConfigurationProperties.HTTPS_SECURITY_MODE_DEFAULT.equals(httpsSecurityMode)
220 && !ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE.equals(httpsSecurityMode)) {
221 throw new IllegalArgumentException("Unsupported '" + httpsSecurityMode + "' HTTPS security mode.");
222 }
223 final boolean insecure = ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE.equals(httpsSecurityMode);
224
225 this.maxConcurrentRequests = new Semaphore(ConfigUtils.getInteger(
226 session,
227 DEFAULT_MAX_CONCURRENT_REQUESTS,
228 CONFIG_PROP_MAX_CONCURRENT_REQUESTS + "." + repository.getId(),
229 CONFIG_PROP_MAX_CONCURRENT_REQUESTS));
230
231 this.headers = headers;
232 this.client = createClient(session, repository, insecure);
233 }
234
235 private URI resolve(TransportTask task) {
236 return baseUri.resolve(task.getLocation());
237 }
238
239 private ConnectException enhance(ConnectException connectException) {
240 ConnectException result = new ConnectException("Connection to " + baseUri.toASCIIString() + " refused");
241 result.initCause(connectException);
242 return result;
243 }
244
245 @Override
246 public int classify(Throwable error) {
247 if (error instanceof HttpTransporterException
248 && ((HttpTransporterException) error).getStatusCode() == NOT_FOUND) {
249 return ERROR_NOT_FOUND;
250 }
251 return ERROR_OTHER;
252 }
253
254 @Override
255 protected void implPeek(PeekTask task) throws Exception {
256 HttpRequest.Builder request =
257 HttpRequest.newBuilder().uri(resolve(task)).method("HEAD", HttpRequest.BodyPublishers.noBody());
258 headers.forEach(request::setHeader);
259 try {
260 HttpResponse<Void> response = send(request.build(), HttpResponse.BodyHandlers.discarding());
261 if (response.statusCode() >= MULTIPLE_CHOICES) {
262 throw new HttpTransporterException(response.statusCode());
263 }
264 } catch (ConnectException e) {
265 throw enhance(e);
266 }
267 }
268
269 @Override
270 protected void implGet(GetTask task) throws Exception {
271 boolean resume = task.getResumeOffset() > 0L && task.getDataPath() != null;
272 HttpResponse<InputStream> response = null;
273
274 try {
275 while (true) {
276 HttpRequest.Builder request =
277 HttpRequest.newBuilder().uri(resolve(task)).GET();
278 headers.forEach(request::setHeader);
279
280 if (resume) {
281 long resumeOffset = task.getResumeOffset();
282 long lastModified = pathProcessor.lastModified(task.getDataPath(), 0L);
283 request.header(RANGE, "bytes=" + resumeOffset + '-');
284 request.header(
285 IF_UNMODIFIED_SINCE,
286 RFC7231.format(Instant.ofEpochMilli(lastModified - MODIFICATION_THRESHOLD)));
287 request.header(ACCEPT_ENCODING, "identity");
288 }
289
290 try {
291 response = send(request.build(), HttpResponse.BodyHandlers.ofInputStream());
292 if (response.statusCode() >= MULTIPLE_CHOICES) {
293 if (resume && response.statusCode() == PRECONDITION_FAILED) {
294 closeBody(response);
295 resume = false;
296 continue;
297 }
298 try {
299 JdkRFC9457Reporter.INSTANCE.generateException(response, (statusCode, reasonPhrase) -> {
300 throw new HttpTransporterException(statusCode);
301 });
302 } finally {
303 closeBody(response);
304 }
305 }
306 } catch (ConnectException e) {
307 closeBody(response);
308 throw enhance(e);
309 }
310 break;
311 }
312
313 long offset = 0L,
314 length = response.headers().firstValueAsLong(CONTENT_LENGTH).orElse(-1L);
315 if (resume) {
316 String range = response.headers().firstValue(CONTENT_RANGE).orElse(null);
317 if (range != null) {
318 Matcher m = CONTENT_RANGE_PATTERN.matcher(range);
319 if (!m.matches()) {
320 throw new IOException("Invalid Content-Range header for partial download: " + range);
321 }
322 offset = Long.parseLong(m.group(1));
323 length = Long.parseLong(m.group(2)) + 1L;
324 if (offset < 0L || offset >= length || (offset > 0L && offset != task.getResumeOffset())) {
325 throw new IOException("Invalid Content-Range header for partial download from offset "
326 + task.getResumeOffset() + ": " + range);
327 }
328 }
329 }
330
331 final boolean downloadResumed = offset > 0L;
332 final Path dataFile = task.getDataPath();
333 if (dataFile == null) {
334 try (InputStream is = response.body()) {
335 utilGet(task, is, true, length, downloadResumed);
336 }
337 } else {
338 try (FileUtils.CollocatedTempFile tempFile = FileUtils.newTempFile(dataFile)) {
339 task.setDataPath(tempFile.getPath(), downloadResumed);
340 if (downloadResumed && Files.isRegularFile(dataFile)) {
341 try (InputStream inputStream = new BufferedInputStream(Files.newInputStream(dataFile))) {
342 Files.copy(inputStream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING);
343 }
344 }
345 try (InputStream is = response.body()) {
346 utilGet(task, is, true, length, downloadResumed);
347 }
348 tempFile.move();
349 } finally {
350 task.setDataPath(dataFile);
351 }
352 }
353 if (task.getDataPath() != null) {
354 String lastModifiedHeader = response.headers()
355 .firstValue(LAST_MODIFIED)
356 .orElse(null);
357 if (lastModifiedHeader != null) {
358 try {
359 pathProcessor.setLastModified(
360 task.getDataPath(),
361 ZonedDateTime.parse(lastModifiedHeader, RFC7231)
362 .toInstant()
363 .toEpochMilli());
364 } catch (DateTimeParseException e) {
365
366 }
367 }
368 }
369 Map<String, String> checksums = checksumExtractor.extractChecksums(headerGetter(response));
370 if (checksums != null && !checksums.isEmpty()) {
371 checksums.forEach(task::setChecksum);
372 }
373 } finally {
374 closeBody(response);
375 }
376 }
377
378 private static Function<String, String> headerGetter(HttpResponse<?> response) {
379 return s -> response.headers().firstValue(s).orElse(null);
380 }
381
382 private void closeBody(HttpResponse<InputStream> streamHttpResponse) throws IOException {
383 if (streamHttpResponse != null) {
384 InputStream body = streamHttpResponse.body();
385 if (body != null) {
386 body.close();
387 }
388 }
389 }
390
391 @Override
392 protected void implPut(PutTask task) throws Exception {
393 HttpRequest.Builder request = HttpRequest.newBuilder().uri(resolve(task));
394 if (expectContinue != null) {
395 request = request.expectContinue(expectContinue);
396 }
397 headers.forEach(request::setHeader);
398 try (FileUtils.TempFile tempFile = FileUtils.newTempFile()) {
399 utilPut(task, Files.newOutputStream(tempFile.getPath()), true);
400 request.PUT(HttpRequest.BodyPublishers.ofFile(tempFile.getPath()));
401
402 try {
403 HttpResponse<Void> response = send(request.build(), HttpResponse.BodyHandlers.discarding());
404 if (response.statusCode() >= MULTIPLE_CHOICES) {
405 throw new HttpTransporterException(response.statusCode());
406 }
407 } catch (ConnectException e) {
408 throw enhance(e);
409 }
410 }
411 }
412
413 private <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler)
414 throws Exception {
415 maxConcurrentRequests.acquire();
416 try {
417 return client.send(request, responseBodyHandler);
418 } finally {
419 maxConcurrentRequests.release();
420 }
421 }
422
423 @Override
424 protected void implClose() {
425 if (client != null) {
426 JdkTransporterCloser.closer(client).run();
427 }
428 }
429
430 private HttpClient createClient(RepositorySystemSession session, RemoteRepository repository, boolean insecure)
431 throws RuntimeException {
432
433 HashMap<Authenticator.RequestorType, PasswordAuthentication> authentications = new HashMap<>();
434 SSLContext sslContext = null;
435 try (AuthenticationContext repoAuthContext = AuthenticationContext.forRepository(session, repository)) {
436 if (repoAuthContext != null) {
437 sslContext = repoAuthContext.get(AuthenticationContext.SSL_CONTEXT, SSLContext.class);
438
439 String username = repoAuthContext.get(AuthenticationContext.USERNAME);
440 String password = repoAuthContext.get(AuthenticationContext.PASSWORD);
441
442 authentications.put(
443 Authenticator.RequestorType.SERVER,
444 new PasswordAuthentication(username, password.toCharArray()));
445 }
446 }
447
448 if (sslContext == null) {
449 try {
450 if (insecure) {
451 sslContext = SSLContext.getInstance("TLS");
452 X509ExtendedTrustManager tm = new X509ExtendedTrustManager() {
453 @Override
454 public void checkClientTrusted(X509Certificate[] chain, String authType) {}
455
456 @Override
457 public void checkServerTrusted(X509Certificate[] chain, String authType) {}
458
459 @Override
460 public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) {}
461
462 @Override
463 public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) {}
464
465 @Override
466 public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {}
467
468 @Override
469 public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {}
470
471 @Override
472 public X509Certificate[] getAcceptedIssuers() {
473 return null;
474 }
475 };
476 sslContext.init(null, new X509TrustManager[] {tm}, null);
477 } else {
478 sslContext = SSLContext.getDefault();
479 }
480 } catch (Exception e) {
481 if (e instanceof RuntimeException) {
482 throw (RuntimeException) e;
483 } else {
484 throw new IllegalStateException("SSL Context setup failure", e);
485 }
486 }
487 }
488
489 HttpClient.Builder builder = HttpClient.newBuilder()
490 .version(HttpClient.Version.valueOf(ConfigUtils.getString(
491 session,
492 DEFAULT_HTTP_VERSION,
493 CONFIG_PROP_HTTP_VERSION + "." + repository.getId(),
494 CONFIG_PROP_HTTP_VERSION)))
495 .followRedirects(HttpClient.Redirect.NORMAL)
496 .connectTimeout(Duration.ofMillis(connectTimeout))
497 .sslContext(sslContext);
498
499 if (insecure) {
500 SSLParameters sslParameters = sslContext.getDefaultSSLParameters();
501 sslParameters.setEndpointIdentificationAlgorithm(null);
502 builder.sslParameters(sslParameters);
503 }
504
505 setLocalAddress(builder, () -> getHttpLocalAddress(session, repository));
506
507 if (repository.getProxy() != null) {
508 ProxySelector proxy = ProxySelector.of(new InetSocketAddress(
509 repository.getProxy().getHost(), repository.getProxy().getPort()));
510
511 builder.proxy(proxy);
512 try (AuthenticationContext proxyAuthContext = AuthenticationContext.forProxy(session, repository)) {
513 if (proxyAuthContext != null) {
514 String username = proxyAuthContext.get(AuthenticationContext.USERNAME);
515 String password = proxyAuthContext.get(AuthenticationContext.PASSWORD);
516
517 authentications.put(
518 Authenticator.RequestorType.PROXY,
519 new PasswordAuthentication(username, password.toCharArray()));
520 }
521 }
522 }
523
524 if (!authentications.isEmpty()) {
525 builder.authenticator(new Authenticator() {
526 @Override
527 protected PasswordAuthentication getPasswordAuthentication() {
528 return authentications.get(getRequestorType());
529 }
530 });
531 }
532
533 return builder.build();
534 }
535
536 private static InetAddress getHttpLocalAddress(RepositorySystemSession session, RemoteRepository repository) {
537 String bindAddress = ConfigUtils.getString(
538 session,
539 null,
540 ConfigurationProperties.HTTP_LOCAL_ADDRESS + "." + repository.getId(),
541 ConfigurationProperties.HTTP_LOCAL_ADDRESS);
542 if (bindAddress == null) {
543 return null;
544 }
545 try {
546 return InetAddress.getByName(bindAddress);
547 } catch (UnknownHostException uhe) {
548 throw new IllegalArgumentException(
549 "Given bind address (" + bindAddress + ") cannot be resolved for remote repository " + repository,
550 uhe);
551 }
552 }
553
554 private static void setLocalAddress(HttpClient.Builder builder, Supplier<InetAddress> addressSupplier) {
555 try {
556 final InetAddress address = addressSupplier.get();
557 if (address == null) {
558 return;
559 }
560
561 final Method mtd = builder.getClass().getDeclaredMethod("localAddress", InetAddress.class);
562 if (!mtd.canAccess(builder)) {
563 mtd.setAccessible(true);
564 }
565 mtd.invoke(builder, address);
566 } catch (final NoSuchMethodException nsme) {
567
568 } catch (InvocationTargetException e) {
569 throw new IllegalStateException(e.getTargetException());
570 } catch (IllegalAccessException e) {
571 throw new IllegalStateException(e);
572 }
573 }
574 }