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