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