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