diff --git a/maven-resolver-demos/maven-resolver-demo-snippets/pom.xml b/maven-resolver-demos/maven-resolver-demo-snippets/pom.xml index 9f48c0327..8f7f22c0c 100644 --- a/maven-resolver-demos/maven-resolver-demo-snippets/pom.xml +++ b/maven-resolver-demos/maven-resolver-demo-snippets/pom.xml @@ -34,6 +34,8 @@ org.apache.maven.resolver.demo.snippets + + 11 @@ -69,6 +71,10 @@ org.apache.maven.resolver maven-resolver-transport-jdk + + org.apache.maven.resolver + maven-resolver-transport-jetty + org.apache.maven.resolver maven-resolver-supplier diff --git a/maven-resolver-demos/maven-resolver-demo-snippets/src/main/java/org/apache/maven/resolver/examples/supplier/SupplierRepositorySystemFactory.java b/maven-resolver-demos/maven-resolver-demo-snippets/src/main/java/org/apache/maven/resolver/examples/supplier/SupplierRepositorySystemFactory.java index 55c02e4d2..56df8a19a 100644 --- a/maven-resolver-demos/maven-resolver-demo-snippets/src/main/java/org/apache/maven/resolver/examples/supplier/SupplierRepositorySystemFactory.java +++ b/maven-resolver-demos/maven-resolver-demo-snippets/src/main/java/org/apache/maven/resolver/examples/supplier/SupplierRepositorySystemFactory.java @@ -25,6 +25,7 @@ import org.eclipse.aether.supplier.RepositorySystemSupplier; import org.eclipse.aether.transport.http.ChecksumExtractor; import org.eclipse.aether.transport.jdk.JdkTransporterFactory; +import org.eclipse.aether.transport.jetty.JettyTransporterFactory; /** * A factory for repository system instances that employs Maven Artifact Resolver's provided supplier. @@ -37,6 +38,7 @@ protected Map getTransporterFactories( Map extractors) { Map result = super.getTransporterFactories(extractors); result.put(JdkTransporterFactory.NAME, new JdkTransporterFactory()); + result.put(JettyTransporterFactory.NAME, new JettyTransporterFactory()); return result; } }.get(); diff --git a/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk-11/pom.xml b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk-11/pom.xml new file mode 100644 index 000000000..b8f544d8c --- /dev/null +++ b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk-11/pom.xml @@ -0,0 +1,85 @@ + + + + 4.0.0 + + + org.apache.maven.resolver + maven-resolver-transport-jdk-parent + 2.0.0-SNAPSHOT + + + maven-resolver-transport-jdk-11 + jar + + Maven Artifact Resolver Transport JDK (11) + Maven Artifact Transport JDK Java 11+. + + + org.apache.maven.resolver.transport.jdk + ${Automatic-Module-Name} + + 11 + + + + + org.apache.maven.resolver + maven-resolver-api + + + org.apache.maven.resolver + maven-resolver-spi + + + org.apache.maven.resolver + maven-resolver-util + + + javax.inject + javax.inject + provided + true + + + + + + + org.eclipse.sisu + sisu-maven-plugin + + + biz.aQute.bnd + bnd-maven-plugin + + + org.apache.maven.plugins + maven-jar-plugin + + + ${project.build.outputDirectory}/META-INF/MANIFEST.MF + + + + + + + diff --git a/maven-resolver-transport-jdk/src/main/java11/org/eclipse/aether/transport/jdk/JdkHttpException.java b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk-11/src/main/java/org/eclipse/aether/transport/jdk/JdkHttpException.java similarity index 100% rename from maven-resolver-transport-jdk/src/main/java11/org/eclipse/aether/transport/jdk/JdkHttpException.java rename to maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk-11/src/main/java/org/eclipse/aether/transport/jdk/JdkHttpException.java diff --git a/maven-resolver-transport-jdk/src/main/java11/org/eclipse/aether/transport/jdk/JdkHttpTransporter.java b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk-11/src/main/java/org/eclipse/aether/transport/jdk/JdkHttpTransporter.java similarity index 100% rename from maven-resolver-transport-jdk/src/main/java11/org/eclipse/aether/transport/jdk/JdkHttpTransporter.java rename to maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk-11/src/main/java/org/eclipse/aether/transport/jdk/JdkHttpTransporter.java diff --git a/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk-11/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk-11/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java new file mode 100644 index 000000000..ef076f038 --- /dev/null +++ b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk-11/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.eclipse.aether.transport.jdk; + +import javax.inject.Named; + +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.spi.connector.transport.Transporter; +import org.eclipse.aether.spi.connector.transport.TransporterFactory; +import org.eclipse.aether.transfer.NoTransporterException; + +import static java.util.Objects.requireNonNull; + +/** + * JDK Transport factory. + * + * @since TBD + */ +@Named(JdkTransporterFactory.NAME) +public final class JdkTransporterFactory implements TransporterFactory { + public static final String NAME = "jdk"; + + private float priority = 10.0f; + + @Override + public float getPriority() { + return priority; + } + + public JdkTransporterFactory setPriority(float priority) { + this.priority = priority; + return this; + } + + @Override + public Transporter newInstance(RepositorySystemSession session, RemoteRepository repository) + throws NoTransporterException { + requireNonNull(session, "session cannot be null"); + requireNonNull(repository, "repository cannot be null"); + + if (!"http".equalsIgnoreCase(repository.getProtocol()) && !"https".equalsIgnoreCase(repository.getProtocol())) { + throw new NoTransporterException(repository, "Only HTTP/HTTPS is supported"); + } + + return new JdkHttpTransporter(session, repository); + } +} diff --git a/maven-resolver-transport-jdk/src/main/java11/org/eclipse/aether/transport/jdk/package-info.java b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk-11/src/main/java/org/eclipse/aether/transport/jdk/package-info.java similarity index 100% rename from maven-resolver-transport-jdk/src/main/java11/org/eclipse/aether/transport/jdk/package-info.java rename to maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk-11/src/main/java/org/eclipse/aether/transport/jdk/package-info.java diff --git a/maven-resolver-transport-jdk/pom.xml b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk-8/pom.xml similarity index 77% rename from maven-resolver-transport-jdk/pom.xml rename to maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk-8/pom.xml index c0705e1ae..ff0847481 100644 --- a/maven-resolver-transport-jdk/pom.xml +++ b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk-8/pom.xml @@ -22,14 +22,14 @@ org.apache.maven.resolver - maven-resolver + maven-resolver-transport-jdk-parent 2.0.0-SNAPSHOT - maven-resolver-transport-jdk + maven-resolver-transport-jdk-8 jar - Maven Artifact Resolver Transport JDK + Maven Artifact Resolver Transport JDK (8) Maven Artifact Transport JDK Java 11+. @@ -60,22 +60,22 @@ + + org.eclipse.sisu + sisu-maven-plugin + + + biz.aQute.bnd + bnd-maven-plugin + org.apache.maven.plugins - maven-compiler-plugin - - - java11 - - compile - - - 11 - true - src/main/java11 - - - + maven-jar-plugin + + + ${project.build.outputDirectory}/META-INF/MANIFEST.MF + + diff --git a/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk-8/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk-8/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java new file mode 100644 index 000000000..80dd1d656 --- /dev/null +++ b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk-8/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.eclipse.aether.transport.jdk; + +import javax.inject.Named; + +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.spi.connector.transport.Transporter; +import org.eclipse.aether.spi.connector.transport.TransporterFactory; +import org.eclipse.aether.transfer.NoTransporterException; + +import static java.util.Objects.requireNonNull; + +/** + * JDK Transport factory: on Java 8 is no-op. + * + * @since TBD + */ +@Named(JdkTransporterFactory.NAME) +public final class JdkTransporterFactory implements TransporterFactory { + public static final String NAME = "jdk"; + + private float priority = Float.MIN_VALUE; + + @Override + public float getPriority() { + return priority; + } + + public JdkTransporterFactory setPriority(float priority) { + this.priority = priority; + return this; + } + + @Override + public Transporter newInstance(RepositorySystemSession session, RemoteRepository repository) + throws NoTransporterException { + requireNonNull(session, "session cannot be null"); + requireNonNull(repository, "repository cannot be null"); + + throw new NoTransporterException(repository, "JDK Transport needs Java11+"); + } +} diff --git a/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk/pom.xml b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk/pom.xml new file mode 100644 index 000000000..f6b32f9a7 --- /dev/null +++ b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk/pom.xml @@ -0,0 +1,128 @@ + + + + 4.0.0 + + + org.apache.maven.resolver + maven-resolver-transport-jdk-parent + 2.0.0-SNAPSHOT + + + maven-resolver-transport-jdk + jar + + Maven Artifact Resolver Transport JDK (mr) + Maven Artifact Transport JDK Java 11+. + + + org.apache.maven.resolver.transport.jdk + ${Automatic-Module-Name} + + + + + org.apache.maven.resolver + maven-resolver-api + + + org.apache.maven.resolver + maven-resolver-spi + + + org.apache.maven.resolver + maven-resolver-util + + + javax.inject + javax.inject + provided + true + + + + + + + false + ${project.build.directory}/generated-resources + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + java8 + + unpack + + generate-resources + + + + org.apache.maven.resolver + maven-resolver-transport-jdk-8 + ${project.version} + jar + ${project.build.directory}/generated-resources + META-INF/maven/** + + + + + + java11 + + unpack + + generate-resources + + + + org.apache.maven.resolver + maven-resolver-transport-jdk-11 + ${project.version} + jar + ${project.build.directory}/generated-resources/META-INF/versions/11 + **/*.class + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + ${project.build.outputDirectory}/META-INF/MANIFEST.MF + + true + + + + + + + + diff --git a/maven-resolver-transport-jdk-parent/pom.xml b/maven-resolver-transport-jdk-parent/pom.xml new file mode 100644 index 000000000..d25f5805d --- /dev/null +++ b/maven-resolver-transport-jdk-parent/pom.xml @@ -0,0 +1,41 @@ + + + + 4.0.0 + + + org.apache.maven.resolver + maven-resolver + 2.0.0-SNAPSHOT + + + maven-resolver-transport-jdk-parent + pom + + Maven Artifact Resolver Transport JDK (parent) + Maven Artifact Transport JDK Java 11+. + + + maven-resolver-transport-jdk-8 + maven-resolver-transport-jdk-11 + maven-resolver-transport-jdk + + + diff --git a/maven-resolver-transport-jdk/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java b/maven-resolver-transport-jdk-parent/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java similarity index 100% rename from maven-resolver-transport-jdk/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java rename to maven-resolver-transport-jdk-parent/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java diff --git a/maven-resolver-transport-jdk-parent/src/main/java11/org/eclipse/aether/transport/jdk/JdkHttpException.java b/maven-resolver-transport-jdk-parent/src/main/java11/org/eclipse/aether/transport/jdk/JdkHttpException.java new file mode 100644 index 000000000..6aaaead40 --- /dev/null +++ b/maven-resolver-transport-jdk-parent/src/main/java11/org/eclipse/aether/transport/jdk/JdkHttpException.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.eclipse.aether.transport.jdk; + +/** + * Exception thrown by {@link JdkHttpTransporter} in case of errors. + * + * @since TBD + */ +final class JdkHttpException extends Exception { + private final int statusCode; + + JdkHttpException(int statusCode) { + super("HTTP Status: " + statusCode); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } +} diff --git a/maven-resolver-transport-jdk-parent/src/main/java11/org/eclipse/aether/transport/jdk/JdkHttpTransporter.java b/maven-resolver-transport-jdk-parent/src/main/java11/org/eclipse/aether/transport/jdk/JdkHttpTransporter.java new file mode 100644 index 000000000..2200aa870 --- /dev/null +++ b/maven-resolver-transport-jdk-parent/src/main/java11/org/eclipse/aether/transport/jdk/JdkHttpTransporter.java @@ -0,0 +1,435 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.eclipse.aether.transport.jdk; + +import javax.net.ssl.SSLContext; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.Authenticator; +import java.net.InetSocketAddress; +import java.net.PasswordAuthentication; +import java.net.ProxySelector; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.FileTime; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.aether.ConfigurationProperties; +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.repository.AuthenticationContext; +import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.spi.connector.transport.AbstractTransporter; +import org.eclipse.aether.spi.connector.transport.GetTask; +import org.eclipse.aether.spi.connector.transport.PeekTask; +import org.eclipse.aether.spi.connector.transport.PutTask; +import org.eclipse.aether.spi.connector.transport.TransportTask; +import org.eclipse.aether.transfer.NoTransporterException; +import org.eclipse.aether.util.ConfigUtils; +import org.eclipse.aether.util.FileUtils; + +/** + * JDK Transport using {@link HttpClient}. + * + * @since TBD + */ +final class JdkHttpTransporter extends AbstractTransporter { + private static final int MULTIPLE_CHOICES = 300; + + private static final int NOT_FOUND = 404; + + private static final int PRECONDITION_FAILED = 412; + + private static final long MODIFICATION_THRESHOLD = 60L * 1000L; + + private static final String ACCEPT_ENCODING = "Accept-Encoding"; + + private static final String CACHE_CONTROL = "Cache-Control"; + + private static final String CONTENT_LENGTH = "Content-Length"; + + private static final String CONTENT_RANGE = "Content-Range"; + + private static final String IF_UNMODIFIED_SINCE = "If-Unmodified-Since"; + + private static final String RANGE = "Range"; + + private static final String USER_AGENT = "User-Agent"; + + private static final String LAST_MODIFIED = "Last-Modified"; + + private static final Pattern CONTENT_RANGE_PATTERN = + Pattern.compile("\\s*bytes\\s+([0-9]+)\\s*-\\s*([0-9]+)\\s*/.*"); + + private final URI baseUri; + + private final HttpClient client; + + private final Map headers; + + private final int requestTimeout; + + JdkHttpTransporter(RepositorySystemSession session, RemoteRepository repository) throws NoTransporterException { + try { + URI uri = new URI(repository.getUrl()).parseServerAuthority(); + if (uri.isOpaque()) { + throw new URISyntaxException(repository.getUrl(), "URL must not be opaque"); + } + if (uri.getRawFragment() != null || uri.getRawQuery() != null) { + throw new URISyntaxException(repository.getUrl(), "URL must not have fragment or query"); + } + String path = uri.getPath(); + if (path == null) { + path = "/"; + } + if (!path.startsWith("/")) { + path = "/" + path; + } + if (!path.endsWith("/")) { + path = path + "/"; + } + this.baseUri = URI.create(uri.getScheme() + "://" + uri.getRawAuthority() + path); + } catch (URISyntaxException e) { + throw new NoTransporterException(repository, e.getMessage(), e); + } + + HashMap headers = new HashMap<>(); + String userAgent = ConfigUtils.getString( + session, ConfigurationProperties.DEFAULT_USER_AGENT, ConfigurationProperties.USER_AGENT); + if (userAgent != null) { + headers.put(USER_AGENT, userAgent); + } + @SuppressWarnings("unchecked") + Map configuredHeaders = (Map) ConfigUtils.getMap( + session, + Collections.emptyMap(), + ConfigurationProperties.HTTP_HEADERS + "." + repository.getId(), + ConfigurationProperties.HTTP_HEADERS); + if (configuredHeaders != null) { + configuredHeaders.forEach((k, v) -> headers.put(String.valueOf(k), v != null ? String.valueOf(v) : null)); + } + headers.put(CACHE_CONTROL, "no-cache, no-store"); + + this.requestTimeout = ConfigUtils.getInteger( + session, + ConfigurationProperties.DEFAULT_REQUEST_TIMEOUT, + ConfigurationProperties.REQUEST_TIMEOUT + "." + repository.getId(), + ConfigurationProperties.REQUEST_TIMEOUT); + + this.headers = headers; + this.client = getOrCreateClient(session, repository); + } + + private URI resolve(TransportTask task) { + return baseUri.resolve(task.getLocation()); + } + + @Override + public int classify(Throwable error) { + if (error instanceof JdkHttpException && ((JdkHttpException) error).getStatusCode() == NOT_FOUND) { + return ERROR_NOT_FOUND; + } + return ERROR_OTHER; + } + + @Override + protected void implPeek(PeekTask task) throws Exception { + HttpRequest.Builder request = HttpRequest.newBuilder() + .uri(resolve(task)) + .timeout(Duration.ofMillis(requestTimeout)) + .method("HEAD", HttpRequest.BodyPublishers.noBody()); + headers.forEach(request::setHeader); + HttpResponse response = client.send(request.build(), HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() >= MULTIPLE_CHOICES) { + throw new JdkHttpException(response.statusCode()); + } + } + + @Override + protected void implGet(GetTask task) throws Exception { + boolean resume = task.getResumeOffset() > 0L && task.getDataFile() != null; + HttpResponse response; + + while (true) { + HttpRequest.Builder request = HttpRequest.newBuilder() + .uri(resolve(task)) + .timeout(Duration.ofMillis(requestTimeout)) + .method("GET", HttpRequest.BodyPublishers.noBody()); + headers.forEach(request::setHeader); + + if (resume) { + long resumeOffset = task.getResumeOffset(); + request.header(RANGE, "bytes=" + resumeOffset + '-'); + request.header( + IF_UNMODIFIED_SINCE, + RFC7231.format( + Instant.ofEpochMilli(task.getDataFile().lastModified() - MODIFICATION_THRESHOLD))); + request.header(ACCEPT_ENCODING, "identity"); + } + + response = client.send(request.build(), HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() >= MULTIPLE_CHOICES) { + if (resume && response.statusCode() == PRECONDITION_FAILED) { + resume = false; + continue; + } + throw new JdkHttpException(response.statusCode()); + } + break; + } + + long offset = 0L, + length = response.headers().firstValueAsLong(CONTENT_LENGTH).orElse(-1L); + if (resume) { + String range = response.headers().firstValue(CONTENT_RANGE).orElse(null); + if (range != null) { + Matcher m = CONTENT_RANGE_PATTERN.matcher(range); + if (!m.matches()) { + throw new IOException("Invalid Content-Range header for partial download: " + range); + } + offset = Long.parseLong(m.group(1)); + length = Long.parseLong(m.group(2)) + 1L; + if (offset < 0L || offset >= length || (offset > 0L && offset != task.getResumeOffset())) { + throw new IOException("Invalid Content-Range header for partial download from offset " + + task.getResumeOffset() + ": " + range); + } + } + } + + final boolean downloadResumed = offset > 0L; + final File dataFile = task.getDataFile(); + if (dataFile == null) { + try (InputStream is = response.body()) { + utilGet(task, is, true, length, downloadResumed); + } + } else { + try (FileUtils.CollocatedTempFile tempFile = FileUtils.newTempFile(dataFile.toPath())) { + task.setDataFile(tempFile.getPath().toFile(), downloadResumed); + if (downloadResumed && Files.isRegularFile(dataFile.toPath())) { + try (InputStream inputStream = Files.newInputStream(dataFile.toPath())) { + Files.copy(inputStream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING); + } + } + try (InputStream is = response.body()) { + utilGet(task, is, true, length, downloadResumed); + } + tempFile.move(); + } finally { + task.setDataFile(dataFile); + } + } + if (task.getDataFile() != null) { + String lastModifiedHeader = + response.headers().firstValue(LAST_MODIFIED).orElse(null); // note: Wagon also does first not last + if (lastModifiedHeader != null) { + try { + Files.setLastModifiedTime( + task.getDataFile().toPath(), + FileTime.fromMillis(ZonedDateTime.parse(lastModifiedHeader, RFC7231) + .toInstant() + .toEpochMilli())); + } catch (DateTimeParseException e) { + // fall through + } + } + } + Map checksums = extractXChecksums(response); + if (checksums != null) { + checksums.forEach(task::setChecksum); + return; + } + checksums = extractNexus2Checksums(response); + if (checksums != null) { + checksums.forEach(task::setChecksum); + } + } + + @Override + protected void implPut(PutTask task) throws Exception { + HttpRequest.Builder request = + HttpRequest.newBuilder().uri(resolve(task)).timeout(Duration.ofMillis(requestTimeout)); + headers.forEach(request::setHeader); + try (FileUtils.TempFile tempFile = FileUtils.newTempFile()) { + utilPut(task, Files.newOutputStream(tempFile.getPath()), true); + request.method("PUT", HttpRequest.BodyPublishers.ofFile(tempFile.getPath())); + + HttpResponse response = client.send(request.build(), HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() >= MULTIPLE_CHOICES) { + throw new JdkHttpException(response.statusCode()); + } + } + } + + @Override + protected void implClose() { + // nop + } + + private static Map extractXChecksums(HttpResponse response) { + String value; + HashMap result = new HashMap<>(); + // Central style: x-checksum-sha1: c74edb60ca2a0b57ef88d9a7da28f591e3d4ce7b + value = response.headers().firstValue("x-checksum-sha1").orElse(null); + if (value != null) { + result.put("SHA-1", value); + } + // Central style: x-checksum-md5: 9ad0d8e3482767c122e85f83567b8ce6 + value = response.headers().firstValue("x-checksum-md5").orElse(null); + if (value != null) { + result.put("MD5", value); + } + if (!result.isEmpty()) { + return result; + } + // Google style: x-goog-meta-checksum-sha1: c74edb60ca2a0b57ef88d9a7da28f591e3d4ce7b + value = response.headers().firstValue("x-goog-meta-checksum-sha1").orElse(null); + if (value != null) { + result.put("SHA-1", value); + } + // Central style: x-goog-meta-checksum-sha1: 9ad0d8e3482767c122e85f83567b8ce6 + value = response.headers().firstValue("x-goog-meta-checksum-md5").orElse(null); + if (value != null) { + result.put("MD5", value); + } + + return result.isEmpty() ? null : result; + } + + private static Map extractNexus2Checksums(HttpResponse response) { + // Nexus-style, ETag: "{SHA1{d40d68ba1f88d8e9b0040f175a6ff41928abd5e7}}" + String etag = response.headers().firstValue("ETag").orElse(null); + if (etag != null) { + int start = etag.indexOf("SHA1{"), end = etag.indexOf("}", start + 5); + if (start >= 0 && end > start) { + return Collections.singletonMap("SHA-1", etag.substring(start + 5, end)); + } + } + return null; + } + + private static final DateTimeFormatter RFC7231 = DateTimeFormatter.ofPattern( + "EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH) + .withZone(ZoneId.of("GMT")); + + /** + * Visible for testing. + */ + static final String HTTP_INSTANCE_KEY_PREFIX = JdkTransporterFactory.class.getName() + ".http."; + + private static HttpClient getOrCreateClient(RepositorySystemSession session, RemoteRepository repository) + throws NoTransporterException { + final String instanceKey = HTTP_INSTANCE_KEY_PREFIX + repository.getId(); + + try { + return (HttpClient) session.getData().computeIfAbsent(instanceKey, () -> { + HashMap authentications = new HashMap<>(); + SSLContext sslContext = null; + try { + try (AuthenticationContext repoAuthContext = + AuthenticationContext.forRepository(session, repository)) { + if (repoAuthContext != null) { + sslContext = repoAuthContext.get(AuthenticationContext.SSL_CONTEXT, SSLContext.class); + + String username = repoAuthContext.get(AuthenticationContext.USERNAME); + String password = repoAuthContext.get(AuthenticationContext.PASSWORD); + + authentications.put( + Authenticator.RequestorType.SERVER, + new PasswordAuthentication(username, password.toCharArray())); + } + } + + if (sslContext == null) { + sslContext = SSLContext.getDefault(); + } + + int connectTimeout = ConfigUtils.getInteger( + session, + ConfigurationProperties.DEFAULT_CONNECT_TIMEOUT, + ConfigurationProperties.CONNECT_TIMEOUT + "." + repository.getId(), + ConfigurationProperties.CONNECT_TIMEOUT); + + HttpClient.Builder builder = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofMillis(connectTimeout)) + .sslContext(sslContext); + + if (repository.getProxy() != null) { + ProxySelector proxy = ProxySelector.of(new InetSocketAddress( + repository.getProxy().getHost(), + repository.getProxy().getPort())); + + builder.proxy(proxy); + try (AuthenticationContext proxyAuthContext = + AuthenticationContext.forProxy(session, repository)) { + if (proxyAuthContext != null) { + String username = proxyAuthContext.get(AuthenticationContext.USERNAME); + String password = proxyAuthContext.get(AuthenticationContext.PASSWORD); + + authentications.put( + Authenticator.RequestorType.PROXY, + new PasswordAuthentication(username, password.toCharArray())); + } + } + } + + if (!authentications.isEmpty()) { + builder.authenticator(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return authentications.get(getRequestorType()); + } + }); + } + + return builder.build(); + } catch (NoSuchAlgorithmException e) { + throw new WrapperEx(e); + } + }); + } catch (WrapperEx e) { + throw new NoTransporterException(repository, e.getCause()); + } + } + + private static final class WrapperEx extends RuntimeException { + private WrapperEx(Throwable cause) { + super(cause); + } + } +} diff --git a/maven-resolver-transport-jdk/src/main/java11/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java b/maven-resolver-transport-jdk-parent/src/main/java11/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java similarity index 100% rename from maven-resolver-transport-jdk/src/main/java11/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java rename to maven-resolver-transport-jdk-parent/src/main/java11/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java diff --git a/maven-resolver-transport-jdk-parent/src/main/java11/org/eclipse/aether/transport/jdk/package-info.java b/maven-resolver-transport-jdk-parent/src/main/java11/org/eclipse/aether/transport/jdk/package-info.java new file mode 100644 index 000000000..8ef1b81a9 --- /dev/null +++ b/maven-resolver-transport-jdk-parent/src/main/java11/org/eclipse/aether/transport/jdk/package-info.java @@ -0,0 +1,26 @@ +// CHECKSTYLE_OFF: RegexpHeader +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/** + * Support for downloads/uploads via the HTTP and HTTPS protocols. The implementation is backed by + * Java 11 {@link java.net.http.HttpClient}. + * + * @since TBD + */ +package org.eclipse.aether.transport.jdk; diff --git a/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-11/pom.xml b/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-11/pom.xml new file mode 100644 index 000000000..c407686f2 --- /dev/null +++ b/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-11/pom.xml @@ -0,0 +1,101 @@ + + + + 4.0.0 + + + org.apache.maven.resolver + maven-resolver-transport-jetty-parent + 2.0.0-SNAPSHOT + + + maven-resolver-transport-jetty-11 + jar + + Maven Artifact Resolver Transport Jetty (11) + Maven Artifact Transport Jetty. + + + org.apache.maven.resolver.transport.jetty + ${Automatic-Module-Name} + + 11 + + + + + org.apache.maven.resolver + maven-resolver-api + + + org.apache.maven.resolver + maven-resolver-spi + + + org.apache.maven.resolver + maven-resolver-util + + + javax.inject + javax.inject + provided + true + + + + org.eclipse.jetty + jetty-client + ${jettyVersion} + + + org.eclipse.jetty.http2 + http2-client + ${jettyVersion} + + + org.eclipse.jetty.http2 + http2-http-client-transport + ${jettyVersion} + + + + + + + org.eclipse.sisu + sisu-maven-plugin + + + biz.aQute.bnd + bnd-maven-plugin + + + org.apache.maven.plugins + maven-jar-plugin + + + ${project.build.outputDirectory}/META-INF/MANIFEST.MF + + + + + + + diff --git a/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-11/src/main/java/org/eclipse/aether/transport/jetty/JettyException.java b/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-11/src/main/java/org/eclipse/aether/transport/jetty/JettyException.java new file mode 100644 index 000000000..3cd907bf2 --- /dev/null +++ b/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-11/src/main/java/org/eclipse/aether/transport/jetty/JettyException.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.eclipse.aether.transport.jetty; + +/** + * Exception thrown by {@link JettyTransporter} in case of errors. + * + * @since TBD + */ +final class JettyException extends Exception { + private final int statusCode; + + JettyException(int statusCode) { + super("HTTP Status: " + statusCode); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } +} diff --git a/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-11/src/main/java/org/eclipse/aether/transport/jetty/JettyTransporter.java b/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-11/src/main/java/org/eclipse/aether/transport/jetty/JettyTransporter.java new file mode 100644 index 000000000..56f8d93eb --- /dev/null +++ b/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-11/src/main/java/org/eclipse/aether/transport/jetty/JettyTransporter.java @@ -0,0 +1,450 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.eclipse.aether.transport.jetty; + +import javax.net.ssl.SSLContext; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.aether.ConfigurationProperties; +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.repository.AuthenticationContext; +import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.spi.connector.transport.AbstractTransporter; +import org.eclipse.aether.spi.connector.transport.GetTask; +import org.eclipse.aether.spi.connector.transport.PeekTask; +import org.eclipse.aether.spi.connector.transport.PutTask; +import org.eclipse.aether.spi.connector.transport.TransportTask; +import org.eclipse.aether.transfer.NoTransporterException; +import org.eclipse.aether.util.ConfigUtils; +import org.eclipse.aether.util.FileUtils; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.HttpProxy; +import org.eclipse.jetty.client.api.Authentication; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.api.Response; +import org.eclipse.jetty.client.dynamic.HttpClientTransportDynamic; +import org.eclipse.jetty.client.http.HttpClientConnectionFactory; +import org.eclipse.jetty.client.util.BasicAuthentication; +import org.eclipse.jetty.client.util.InputStreamResponseListener; +import org.eclipse.jetty.client.util.PathRequestContent; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http2.client.HTTP2Client; +import org.eclipse.jetty.http2.client.http.ClientConnectionFactoryOverHTTP2; +import org.eclipse.jetty.io.ClientConnector; +import org.eclipse.jetty.util.ssl.SslContextFactory; + +/** + * A transporter for HTTP/HTTPS. + * + * @since TBD + */ +final class JettyTransporter extends AbstractTransporter { + private static final int MULTIPLE_CHOICES = 300; + + private static final int NOT_FOUND = 404; + + private static final int PRECONDITION_FAILED = 412; + + private static final long MODIFICATION_THRESHOLD = 60L * 1000L; + + private static final String ACCEPT_ENCODING = "Accept-Encoding"; + + private static final String CONTENT_LENGTH = "Content-Length"; + + private static final String CONTENT_RANGE = "Content-Range"; + + private static final String IF_UNMODIFIED_SINCE = "If-Unmodified-Since"; + + private static final String RANGE = "Range"; + + private static final String USER_AGENT = "User-Agent"; + + private static final Pattern CONTENT_RANGE_PATTERN = + Pattern.compile("\\s*bytes\\s+([0-9]+)\\s*-\\s*([0-9]+)\\s*/.*"); + + private final URI baseUri; + + private final HttpClient client; + + private final int requestTimeout; + + private final Map headers; + + JettyTransporter(RepositorySystemSession session, RemoteRepository repository) throws NoTransporterException { + try { + URI uri = new URI(repository.getUrl()).parseServerAuthority(); + if (uri.isOpaque()) { + throw new URISyntaxException(repository.getUrl(), "URL must not be opaque"); + } + if (uri.getRawFragment() != null || uri.getRawQuery() != null) { + throw new URISyntaxException(repository.getUrl(), "URL must not have fragment or query"); + } + String path = uri.getPath(); + if (path == null) { + path = "/"; + } + if (!path.startsWith("/")) { + path = "/" + path; + } + if (!path.endsWith("/")) { + path = path + "/"; + } + this.baseUri = URI.create(uri.getScheme() + "://" + uri.getRawAuthority() + path); + } catch (URISyntaxException e) { + throw new NoTransporterException(repository, e.getMessage(), e); + } + + HashMap headers = new HashMap<>(); + String userAgent = ConfigUtils.getString( + session, ConfigurationProperties.DEFAULT_USER_AGENT, ConfigurationProperties.USER_AGENT); + if (userAgent != null) { + headers.put(USER_AGENT, userAgent); + } + @SuppressWarnings("unchecked") + Map configuredHeaders = (Map) ConfigUtils.getMap( + session, + Collections.emptyMap(), + ConfigurationProperties.HTTP_HEADERS + "." + repository.getId(), + ConfigurationProperties.HTTP_HEADERS); + if (configuredHeaders != null) { + configuredHeaders.forEach((k, v) -> headers.put(String.valueOf(k), v != null ? String.valueOf(v) : null)); + } + + this.headers = headers; + + this.requestTimeout = ConfigUtils.getInteger( + session, + ConfigurationProperties.DEFAULT_REQUEST_TIMEOUT, + ConfigurationProperties.REQUEST_TIMEOUT + "." + repository.getId(), + ConfigurationProperties.REQUEST_TIMEOUT); + + this.client = getOrCreateClient(session, repository); + } + + private URI resolve(TransportTask task) { + return baseUri.resolve(task.getLocation()); + } + + @Override + public int classify(Throwable error) { + if (error instanceof JettyException && ((JettyException) error).getStatusCode() == NOT_FOUND) { + return ERROR_NOT_FOUND; + } + return ERROR_OTHER; + } + + @Override + protected void implPeek(PeekTask task) throws Exception { + Request request = client.newRequest(resolve(task)) + .timeout(requestTimeout, TimeUnit.MILLISECONDS) + .method("HEAD"); + request.headers(m -> headers.forEach(m::add)); + Response response = request.send(); + if (response.getStatus() >= MULTIPLE_CHOICES) { + throw new JettyException(response.getStatus()); + } + } + + @Override + protected void implGet(GetTask task) throws Exception { + boolean resume = task.getResumeOffset() > 0L && task.getDataFile() != null; + Response response; + InputStreamResponseListener listener; + + while (true) { + Request request = client.newRequest(resolve(task)) + .timeout(requestTimeout, TimeUnit.MILLISECONDS) + .method("GET"); + request.headers(m -> headers.forEach(m::add)); + + if (resume) { + long resumeOffset = task.getResumeOffset(); + request.headers(h -> { + h.add(RANGE, "bytes=" + resumeOffset + '-'); + h.addDateField(IF_UNMODIFIED_SINCE, task.getDataFile().lastModified() - MODIFICATION_THRESHOLD); + h.remove(HttpHeader.ACCEPT_ENCODING); + h.add(ACCEPT_ENCODING, "identity"); + }); + } + + listener = new InputStreamResponseListener(); + request.send(listener); + try { + response = listener.get(requestTimeout, TimeUnit.MILLISECONDS); + } catch (ExecutionException e) { + Throwable t = e.getCause(); + if (t instanceof Exception) { + throw (Exception) t; + } else { + throw new RuntimeException(t); + } + } + if (response.getStatus() >= MULTIPLE_CHOICES) { + if (resume && response.getStatus() == PRECONDITION_FAILED) { + resume = false; + continue; + } + throw new JettyException(response.getStatus()); + } + break; + } + + long offset = 0L, length = response.getHeaders().getLongField(CONTENT_LENGTH); + if (resume) { + String range = response.getHeaders().get(CONTENT_RANGE); + if (range != null) { + Matcher m = CONTENT_RANGE_PATTERN.matcher(range); + if (!m.matches()) { + throw new IOException("Invalid Content-Range header for partial download: " + range); + } + offset = Long.parseLong(m.group(1)); + length = Long.parseLong(m.group(2)) + 1L; + if (offset < 0L || offset >= length || (offset > 0L && offset != task.getResumeOffset())) { + throw new IOException("Invalid Content-Range header for partial download from offset " + + task.getResumeOffset() + ": " + range); + } + } + } + + final boolean downloadResumed = offset > 0L; + final File dataFile = task.getDataFile(); + if (dataFile == null) { + try (InputStream is = listener.getInputStream()) { + utilGet(task, is, true, length, downloadResumed); + } + } else { + try (FileUtils.CollocatedTempFile tempFile = FileUtils.newTempFile(dataFile.toPath())) { + task.setDataFile(tempFile.getPath().toFile(), downloadResumed); + if (downloadResumed && Files.isRegularFile(dataFile.toPath())) { + try (InputStream inputStream = Files.newInputStream(dataFile.toPath())) { + Files.copy(inputStream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING); + } + } + try (InputStream is = listener.getInputStream()) { + utilGet(task, is, true, length, downloadResumed); + } + tempFile.move(); + } finally { + task.setDataFile(dataFile); + } + } + Map checksums = extractXChecksums(response); + if (checksums != null) { + checksums.forEach(task::setChecksum); + return; + } + checksums = extractNexus2Checksums(response); + if (checksums != null) { + checksums.forEach(task::setChecksum); + } + } + + private static Map extractXChecksums(Response response) { + String value; + HashMap result = new HashMap<>(); + // Central style: x-checksum-sha1: c74edb60ca2a0b57ef88d9a7da28f591e3d4ce7b + value = response.getHeaders().get("x-checksum-sha1"); + if (value != null) { + result.put("SHA-1", value); + } + // Central style: x-checksum-md5: 9ad0d8e3482767c122e85f83567b8ce6 + value = response.getHeaders().get("x-checksum-md5"); + if (value != null) { + result.put("MD5", value); + } + if (!result.isEmpty()) { + return result; + } + // Google style: x-goog-meta-checksum-sha1: c74edb60ca2a0b57ef88d9a7da28f591e3d4ce7b + value = response.getHeaders().get("x-goog-meta-checksum-sha1"); + if (value != null) { + result.put("SHA-1", value); + } + // Central style: x-goog-meta-checksum-sha1: 9ad0d8e3482767c122e85f83567b8ce6 + value = response.getHeaders().get("x-goog-meta-checksum-md5"); + if (value != null) { + result.put("MD5", value); + } + + return result.isEmpty() ? null : result; + } + + private static Map extractNexus2Checksums(Response response) { + // Nexus-style, ETag: "{SHA1{d40d68ba1f88d8e9b0040f175a6ff41928abd5e7}}" + String etag = response.getHeaders().get("ETag"); + if (etag != null) { + int start = etag.indexOf("SHA1{"), end = etag.indexOf("}", start + 5); + if (start >= 0 && end > start) { + return Collections.singletonMap("SHA-1", etag.substring(start + 5, end)); + } + } + return null; + } + + @Override + protected void implPut(PutTask task) throws Exception { + Request request = client.newRequest(resolve(task)).method("PUT").timeout(requestTimeout, TimeUnit.MILLISECONDS); + request.headers(m -> headers.forEach(m::add)); + try (FileUtils.TempFile tempFile = FileUtils.newTempFile()) { + utilPut(task, Files.newOutputStream(tempFile.getPath()), true); + request.body(new PathRequestContent(tempFile.getPath())); + + Response response; + try { + response = request.send(); + } catch (ExecutionException e) { + Throwable t = e.getCause(); + if (t instanceof Exception) { + throw (Exception) t; + } else { + throw new RuntimeException(t); + } + } + if (response.getStatus() >= MULTIPLE_CHOICES) { + throw new JettyException(response.getStatus()); + } + } + } + + @Override + protected void implClose() { + // noop + } + + /** + * Visible for testing. + */ + static final String JETTY_INSTANCE_KEY_PREFIX = JettyTransporterFactory.class.getName() + ".jetty."; + + @SuppressWarnings("checkstyle:methodlength") + private static HttpClient getOrCreateClient(RepositorySystemSession session, RemoteRepository repository) + throws NoTransporterException { + + final String instanceKey = JETTY_INSTANCE_KEY_PREFIX + repository.getId(); + + try { + return (HttpClient) session.getData().computeIfAbsent(instanceKey, () -> { + SSLContext sslContext = null; + BasicAuthentication basicAuthentication = null; + try { + try (AuthenticationContext repoAuthContext = + AuthenticationContext.forRepository(session, repository)) { + if (repoAuthContext != null) { + sslContext = repoAuthContext.get(AuthenticationContext.SSL_CONTEXT, SSLContext.class); + + String username = repoAuthContext.get(AuthenticationContext.USERNAME); + String password = repoAuthContext.get(AuthenticationContext.PASSWORD); + + basicAuthentication = new BasicAuthentication( + URI.create(repository.getUrl()), Authentication.ANY_REALM, username, password); + } + } + + if (sslContext == null) { + sslContext = SSLContext.getDefault(); + } + + int connectTimeout = ConfigUtils.getInteger( + session, + ConfigurationProperties.DEFAULT_CONNECT_TIMEOUT, + ConfigurationProperties.CONNECT_TIMEOUT + "." + repository.getId(), + ConfigurationProperties.CONNECT_TIMEOUT); + + SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(); + sslContextFactory.setSslContext(sslContext); + + ClientConnector clientConnector = new ClientConnector(); + clientConnector.setSslContextFactory(sslContextFactory); + + HTTP2Client http2Client = new HTTP2Client(clientConnector); + ClientConnectionFactoryOverHTTP2.HTTP2 http2 = + new ClientConnectionFactoryOverHTTP2.HTTP2(http2Client); + + HttpClientTransportDynamic transport; + if ("https".equalsIgnoreCase(repository.getProtocol())) { + transport = new HttpClientTransportDynamic( + clientConnector, http2, HttpClientConnectionFactory.HTTP11); // HTTPS, prefer H2 + } else { + transport = new HttpClientTransportDynamic( + clientConnector, + HttpClientConnectionFactory.HTTP11, + http2); // plaintext HTTP, H2 cannot be used + } + + HttpClient httpClient = new HttpClient(transport); + httpClient.setConnectTimeout(connectTimeout); + httpClient.setFollowRedirects(true); + httpClient.setMaxRedirects(2); + + httpClient.setUserAgentField(null); // we manage it + + if (basicAuthentication != null) { + httpClient.getAuthenticationStore().addAuthentication(basicAuthentication); + } + + if (repository.getProxy() != null) { + HttpProxy proxy = new HttpProxy( + repository.getProxy().getHost(), + repository.getProxy().getPort()); + + httpClient.getProxyConfiguration().addProxy(proxy); + try (AuthenticationContext proxyAuthContext = + AuthenticationContext.forProxy(session, repository)) { + if (proxyAuthContext != null) { + String username = proxyAuthContext.get(AuthenticationContext.USERNAME); + String password = proxyAuthContext.get(AuthenticationContext.PASSWORD); + + BasicAuthentication proxyAuthentication = new BasicAuthentication( + proxy.getURI(), Authentication.ANY_REALM, username, password); + + httpClient.getAuthenticationStore().addAuthentication(proxyAuthentication); + } + } + } + httpClient.start(); + return httpClient; + } catch (Exception e) { + throw new WrapperEx(e); + } + }); + } catch (WrapperEx e) { + throw new NoTransporterException(repository, e.getCause()); + } + } + + private static final class WrapperEx extends RuntimeException { + private WrapperEx(Throwable cause) { + super(cause); + } + } +} diff --git a/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-11/src/main/java/org/eclipse/aether/transport/jetty/JettyTransporterFactory.java b/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-11/src/main/java/org/eclipse/aether/transport/jetty/JettyTransporterFactory.java new file mode 100644 index 000000000..9d50d60d4 --- /dev/null +++ b/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-11/src/main/java/org/eclipse/aether/transport/jetty/JettyTransporterFactory.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.eclipse.aether.transport.jetty; + +import javax.inject.Named; + +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.spi.connector.transport.Transporter; +import org.eclipse.aether.spi.connector.transport.TransporterFactory; +import org.eclipse.aether.transfer.NoTransporterException; + +import static java.util.Objects.requireNonNull; + +/** + * A transporter factory for repositories using the {@code http:} or {@code https:} protocol. + * + * @since TBD + */ +@Named(JettyTransporterFactory.NAME) +public final class JettyTransporterFactory implements TransporterFactory { + public static final String NAME = "jetty"; + + private float priority = 15.0f; + + @Override + public float getPriority() { + return priority; + } + + public JettyTransporterFactory setPriority(float priority) { + this.priority = priority; + return this; + } + + @Override + public Transporter newInstance(RepositorySystemSession session, RemoteRepository repository) + throws NoTransporterException { + requireNonNull(session, "session cannot be null"); + requireNonNull(repository, "repository cannot be null"); + + if (!"http".equalsIgnoreCase(repository.getProtocol()) && !"https".equalsIgnoreCase(repository.getProtocol())) { + throw new NoTransporterException(repository); + } + + return new JettyTransporter(session, repository); + } +} diff --git a/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-11/src/main/java/org/eclipse/aether/transport/jetty/package-info.java b/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-11/src/main/java/org/eclipse/aether/transport/jetty/package-info.java new file mode 100644 index 000000000..367c20573 --- /dev/null +++ b/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-11/src/main/java/org/eclipse/aether/transport/jetty/package-info.java @@ -0,0 +1,26 @@ +// CHECKSTYLE_OFF: RegexpHeader +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/** + * Support for downloads/uploads via the HTTP and HTTPS protocols. The implementation is backed by + * Eclipse Jetty. + * + * @since TBD + */ +package org.eclipse.aether.transport.jetty; diff --git a/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-8/pom.xml b/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-8/pom.xml new file mode 100644 index 000000000..9620f97ad --- /dev/null +++ b/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-8/pom.xml @@ -0,0 +1,83 @@ + + + + 4.0.0 + + + org.apache.maven.resolver + maven-resolver-transport-jetty-parent + 2.0.0-SNAPSHOT + + + maven-resolver-transport-jetty-8 + jar + + Maven Artifact Resolver Transport Jetty (8) + Maven Artifact Transport Jetty. + + + org.apache.maven.resolver.transport.jetty + ${Automatic-Module-Name} + + + + + org.apache.maven.resolver + maven-resolver-api + + + org.apache.maven.resolver + maven-resolver-spi + + + org.apache.maven.resolver + maven-resolver-util + + + javax.inject + javax.inject + provided + true + + + + + + + org.eclipse.sisu + sisu-maven-plugin + + + biz.aQute.bnd + bnd-maven-plugin + + + org.apache.maven.plugins + maven-jar-plugin + + + ${project.build.outputDirectory}/META-INF/MANIFEST.MF + + + + + + + diff --git a/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-8/src/main/java/org/eclipse/aether/transport/jetty/JettyTransporterFactory.java b/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-8/src/main/java/org/eclipse/aether/transport/jetty/JettyTransporterFactory.java new file mode 100644 index 000000000..bbb7f3988 --- /dev/null +++ b/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty-8/src/main/java/org/eclipse/aether/transport/jetty/JettyTransporterFactory.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.eclipse.aether.transport.jetty; + +import javax.inject.Named; + +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.spi.connector.transport.Transporter; +import org.eclipse.aether.spi.connector.transport.TransporterFactory; +import org.eclipse.aether.transfer.NoTransporterException; + +import static java.util.Objects.requireNonNull; + +/** + * Jetty Transport factory: on Java 8 is no-op. + * + * @since TBD + */ +@Named(JettyTransporterFactory.NAME) +public final class JettyTransporterFactory implements TransporterFactory { + public static final String NAME = "jetty"; + + private float priority = Float.MIN_VALUE; + + @Override + public float getPriority() { + return priority; + } + + public JettyTransporterFactory setPriority(float priority) { + this.priority = priority; + return this; + } + + @Override + public Transporter newInstance(RepositorySystemSession session, RemoteRepository repository) + throws NoTransporterException { + requireNonNull(session, "session cannot be null"); + requireNonNull(repository, "repository cannot be null"); + + throw new NoTransporterException(repository, "Jetty Transport needs Java11+"); + } +} diff --git a/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty/pom.xml b/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty/pom.xml new file mode 100644 index 000000000..fd01a6eb6 --- /dev/null +++ b/maven-resolver-transport-jetty-parent/maven-resolver-transport-jetty/pom.xml @@ -0,0 +1,147 @@ + + + + 4.0.0 + + + org.apache.maven.resolver + maven-resolver-transport-jetty-parent + 2.0.0-SNAPSHOT + + + maven-resolver-transport-jetty + jar + + Maven Artifact Resolver Transport Jetty (mr) + Maven Artifact Transport Jetty. + + + org.apache.maven.resolver.transport.jetty + ${Automatic-Module-Name} + + + 11 + + + + + org.apache.maven.resolver + maven-resolver-api + + + org.apache.maven.resolver + maven-resolver-spi + + + org.apache.maven.resolver + maven-resolver-util + + + javax.inject + javax.inject + provided + true + + + + org.eclipse.jetty + jetty-client + ${jettyVersion} + + + org.eclipse.jetty.http2 + http2-client + ${jettyVersion} + + + org.eclipse.jetty.http2 + http2-http-client-transport + ${jettyVersion} + + + + + + + false + ${project.build.directory}/generated-resources + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + java8 + + unpack + + generate-resources + + + + org.apache.maven.resolver + maven-resolver-transport-jetty-8 + ${project.version} + jar + ${project.build.directory}/generated-resources + META-INF/maven/** + + + + + + java11 + + unpack + + generate-resources + + + + org.apache.maven.resolver + maven-resolver-transport-jetty-11 + ${project.version} + jar + ${project.build.directory}/generated-resources/META-INF/versions/11 + **/*.class + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + ${project.build.outputDirectory}/META-INF/MANIFEST.MF + + true + + + + + + + + diff --git a/maven-resolver-transport-jetty-parent/pom.xml b/maven-resolver-transport-jetty-parent/pom.xml new file mode 100644 index 000000000..6d9f243ec --- /dev/null +++ b/maven-resolver-transport-jetty-parent/pom.xml @@ -0,0 +1,45 @@ + + + + 4.0.0 + + + org.apache.maven.resolver + maven-resolver + 2.0.0-SNAPSHOT + + + maven-resolver-transport-jetty-parent + pom + + Maven Artifact Resolver Transport Jetty (parent) + Maven Artifact Transport Jetty. + + + maven-resolver-transport-jetty-8 + maven-resolver-transport-jetty-11 + maven-resolver-transport-jetty + + + + 10.0.17 + + + diff --git a/maven-resolver-transport-jetty-parent/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java b/maven-resolver-transport-jetty-parent/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java new file mode 100644 index 000000000..aa015d2b2 --- /dev/null +++ b/maven-resolver-transport-jetty-parent/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.eclipse.aether.transport.jdk; + +import javax.inject.Named; + +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.spi.connector.transport.Transporter; +import org.eclipse.aether.spi.connector.transport.TransporterFactory; +import org.eclipse.aether.transfer.NoTransporterException; + +import static java.util.Objects.requireNonNull; + +/** + * JDK Transport factory: on Java 8 is no-op. + * + * @since TBD + */ +@Named(JdkTransporterFactory.NAME) +public final class JdkTransporterFactory implements TransporterFactory { + public static final String NAME = "jdk"; + + private float priority = 10.0f; + + @Override + public float getPriority() { + return priority; + } + + public JdkTransporterFactory setPriority(float priority) { + this.priority = priority; + return this; + } + + @Override + public Transporter newInstance(RepositorySystemSession session, RemoteRepository repository) + throws NoTransporterException { + requireNonNull(session, "session cannot be null"); + requireNonNull(repository, "repository cannot be null"); + + throw new NoTransporterException(repository, "JDK Transport needs Java11+"); + } +} diff --git a/maven-resolver-transport-jetty-parent/src/main/java11/org/eclipse/aether/transport/jdk/JdkHttpException.java b/maven-resolver-transport-jetty-parent/src/main/java11/org/eclipse/aether/transport/jdk/JdkHttpException.java new file mode 100644 index 000000000..6aaaead40 --- /dev/null +++ b/maven-resolver-transport-jetty-parent/src/main/java11/org/eclipse/aether/transport/jdk/JdkHttpException.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.eclipse.aether.transport.jdk; + +/** + * Exception thrown by {@link JdkHttpTransporter} in case of errors. + * + * @since TBD + */ +final class JdkHttpException extends Exception { + private final int statusCode; + + JdkHttpException(int statusCode) { + super("HTTP Status: " + statusCode); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } +} diff --git a/maven-resolver-transport-jetty-parent/src/main/java11/org/eclipse/aether/transport/jdk/JdkHttpTransporter.java b/maven-resolver-transport-jetty-parent/src/main/java11/org/eclipse/aether/transport/jdk/JdkHttpTransporter.java new file mode 100644 index 000000000..2200aa870 --- /dev/null +++ b/maven-resolver-transport-jetty-parent/src/main/java11/org/eclipse/aether/transport/jdk/JdkHttpTransporter.java @@ -0,0 +1,435 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.eclipse.aether.transport.jdk; + +import javax.net.ssl.SSLContext; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.Authenticator; +import java.net.InetSocketAddress; +import java.net.PasswordAuthentication; +import java.net.ProxySelector; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.FileTime; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.aether.ConfigurationProperties; +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.repository.AuthenticationContext; +import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.spi.connector.transport.AbstractTransporter; +import org.eclipse.aether.spi.connector.transport.GetTask; +import org.eclipse.aether.spi.connector.transport.PeekTask; +import org.eclipse.aether.spi.connector.transport.PutTask; +import org.eclipse.aether.spi.connector.transport.TransportTask; +import org.eclipse.aether.transfer.NoTransporterException; +import org.eclipse.aether.util.ConfigUtils; +import org.eclipse.aether.util.FileUtils; + +/** + * JDK Transport using {@link HttpClient}. + * + * @since TBD + */ +final class JdkHttpTransporter extends AbstractTransporter { + private static final int MULTIPLE_CHOICES = 300; + + private static final int NOT_FOUND = 404; + + private static final int PRECONDITION_FAILED = 412; + + private static final long MODIFICATION_THRESHOLD = 60L * 1000L; + + private static final String ACCEPT_ENCODING = "Accept-Encoding"; + + private static final String CACHE_CONTROL = "Cache-Control"; + + private static final String CONTENT_LENGTH = "Content-Length"; + + private static final String CONTENT_RANGE = "Content-Range"; + + private static final String IF_UNMODIFIED_SINCE = "If-Unmodified-Since"; + + private static final String RANGE = "Range"; + + private static final String USER_AGENT = "User-Agent"; + + private static final String LAST_MODIFIED = "Last-Modified"; + + private static final Pattern CONTENT_RANGE_PATTERN = + Pattern.compile("\\s*bytes\\s+([0-9]+)\\s*-\\s*([0-9]+)\\s*/.*"); + + private final URI baseUri; + + private final HttpClient client; + + private final Map headers; + + private final int requestTimeout; + + JdkHttpTransporter(RepositorySystemSession session, RemoteRepository repository) throws NoTransporterException { + try { + URI uri = new URI(repository.getUrl()).parseServerAuthority(); + if (uri.isOpaque()) { + throw new URISyntaxException(repository.getUrl(), "URL must not be opaque"); + } + if (uri.getRawFragment() != null || uri.getRawQuery() != null) { + throw new URISyntaxException(repository.getUrl(), "URL must not have fragment or query"); + } + String path = uri.getPath(); + if (path == null) { + path = "/"; + } + if (!path.startsWith("/")) { + path = "/" + path; + } + if (!path.endsWith("/")) { + path = path + "/"; + } + this.baseUri = URI.create(uri.getScheme() + "://" + uri.getRawAuthority() + path); + } catch (URISyntaxException e) { + throw new NoTransporterException(repository, e.getMessage(), e); + } + + HashMap headers = new HashMap<>(); + String userAgent = ConfigUtils.getString( + session, ConfigurationProperties.DEFAULT_USER_AGENT, ConfigurationProperties.USER_AGENT); + if (userAgent != null) { + headers.put(USER_AGENT, userAgent); + } + @SuppressWarnings("unchecked") + Map configuredHeaders = (Map) ConfigUtils.getMap( + session, + Collections.emptyMap(), + ConfigurationProperties.HTTP_HEADERS + "." + repository.getId(), + ConfigurationProperties.HTTP_HEADERS); + if (configuredHeaders != null) { + configuredHeaders.forEach((k, v) -> headers.put(String.valueOf(k), v != null ? String.valueOf(v) : null)); + } + headers.put(CACHE_CONTROL, "no-cache, no-store"); + + this.requestTimeout = ConfigUtils.getInteger( + session, + ConfigurationProperties.DEFAULT_REQUEST_TIMEOUT, + ConfigurationProperties.REQUEST_TIMEOUT + "." + repository.getId(), + ConfigurationProperties.REQUEST_TIMEOUT); + + this.headers = headers; + this.client = getOrCreateClient(session, repository); + } + + private URI resolve(TransportTask task) { + return baseUri.resolve(task.getLocation()); + } + + @Override + public int classify(Throwable error) { + if (error instanceof JdkHttpException && ((JdkHttpException) error).getStatusCode() == NOT_FOUND) { + return ERROR_NOT_FOUND; + } + return ERROR_OTHER; + } + + @Override + protected void implPeek(PeekTask task) throws Exception { + HttpRequest.Builder request = HttpRequest.newBuilder() + .uri(resolve(task)) + .timeout(Duration.ofMillis(requestTimeout)) + .method("HEAD", HttpRequest.BodyPublishers.noBody()); + headers.forEach(request::setHeader); + HttpResponse response = client.send(request.build(), HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() >= MULTIPLE_CHOICES) { + throw new JdkHttpException(response.statusCode()); + } + } + + @Override + protected void implGet(GetTask task) throws Exception { + boolean resume = task.getResumeOffset() > 0L && task.getDataFile() != null; + HttpResponse response; + + while (true) { + HttpRequest.Builder request = HttpRequest.newBuilder() + .uri(resolve(task)) + .timeout(Duration.ofMillis(requestTimeout)) + .method("GET", HttpRequest.BodyPublishers.noBody()); + headers.forEach(request::setHeader); + + if (resume) { + long resumeOffset = task.getResumeOffset(); + request.header(RANGE, "bytes=" + resumeOffset + '-'); + request.header( + IF_UNMODIFIED_SINCE, + RFC7231.format( + Instant.ofEpochMilli(task.getDataFile().lastModified() - MODIFICATION_THRESHOLD))); + request.header(ACCEPT_ENCODING, "identity"); + } + + response = client.send(request.build(), HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() >= MULTIPLE_CHOICES) { + if (resume && response.statusCode() == PRECONDITION_FAILED) { + resume = false; + continue; + } + throw new JdkHttpException(response.statusCode()); + } + break; + } + + long offset = 0L, + length = response.headers().firstValueAsLong(CONTENT_LENGTH).orElse(-1L); + if (resume) { + String range = response.headers().firstValue(CONTENT_RANGE).orElse(null); + if (range != null) { + Matcher m = CONTENT_RANGE_PATTERN.matcher(range); + if (!m.matches()) { + throw new IOException("Invalid Content-Range header for partial download: " + range); + } + offset = Long.parseLong(m.group(1)); + length = Long.parseLong(m.group(2)) + 1L; + if (offset < 0L || offset >= length || (offset > 0L && offset != task.getResumeOffset())) { + throw new IOException("Invalid Content-Range header for partial download from offset " + + task.getResumeOffset() + ": " + range); + } + } + } + + final boolean downloadResumed = offset > 0L; + final File dataFile = task.getDataFile(); + if (dataFile == null) { + try (InputStream is = response.body()) { + utilGet(task, is, true, length, downloadResumed); + } + } else { + try (FileUtils.CollocatedTempFile tempFile = FileUtils.newTempFile(dataFile.toPath())) { + task.setDataFile(tempFile.getPath().toFile(), downloadResumed); + if (downloadResumed && Files.isRegularFile(dataFile.toPath())) { + try (InputStream inputStream = Files.newInputStream(dataFile.toPath())) { + Files.copy(inputStream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING); + } + } + try (InputStream is = response.body()) { + utilGet(task, is, true, length, downloadResumed); + } + tempFile.move(); + } finally { + task.setDataFile(dataFile); + } + } + if (task.getDataFile() != null) { + String lastModifiedHeader = + response.headers().firstValue(LAST_MODIFIED).orElse(null); // note: Wagon also does first not last + if (lastModifiedHeader != null) { + try { + Files.setLastModifiedTime( + task.getDataFile().toPath(), + FileTime.fromMillis(ZonedDateTime.parse(lastModifiedHeader, RFC7231) + .toInstant() + .toEpochMilli())); + } catch (DateTimeParseException e) { + // fall through + } + } + } + Map checksums = extractXChecksums(response); + if (checksums != null) { + checksums.forEach(task::setChecksum); + return; + } + checksums = extractNexus2Checksums(response); + if (checksums != null) { + checksums.forEach(task::setChecksum); + } + } + + @Override + protected void implPut(PutTask task) throws Exception { + HttpRequest.Builder request = + HttpRequest.newBuilder().uri(resolve(task)).timeout(Duration.ofMillis(requestTimeout)); + headers.forEach(request::setHeader); + try (FileUtils.TempFile tempFile = FileUtils.newTempFile()) { + utilPut(task, Files.newOutputStream(tempFile.getPath()), true); + request.method("PUT", HttpRequest.BodyPublishers.ofFile(tempFile.getPath())); + + HttpResponse response = client.send(request.build(), HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() >= MULTIPLE_CHOICES) { + throw new JdkHttpException(response.statusCode()); + } + } + } + + @Override + protected void implClose() { + // nop + } + + private static Map extractXChecksums(HttpResponse response) { + String value; + HashMap result = new HashMap<>(); + // Central style: x-checksum-sha1: c74edb60ca2a0b57ef88d9a7da28f591e3d4ce7b + value = response.headers().firstValue("x-checksum-sha1").orElse(null); + if (value != null) { + result.put("SHA-1", value); + } + // Central style: x-checksum-md5: 9ad0d8e3482767c122e85f83567b8ce6 + value = response.headers().firstValue("x-checksum-md5").orElse(null); + if (value != null) { + result.put("MD5", value); + } + if (!result.isEmpty()) { + return result; + } + // Google style: x-goog-meta-checksum-sha1: c74edb60ca2a0b57ef88d9a7da28f591e3d4ce7b + value = response.headers().firstValue("x-goog-meta-checksum-sha1").orElse(null); + if (value != null) { + result.put("SHA-1", value); + } + // Central style: x-goog-meta-checksum-sha1: 9ad0d8e3482767c122e85f83567b8ce6 + value = response.headers().firstValue("x-goog-meta-checksum-md5").orElse(null); + if (value != null) { + result.put("MD5", value); + } + + return result.isEmpty() ? null : result; + } + + private static Map extractNexus2Checksums(HttpResponse response) { + // Nexus-style, ETag: "{SHA1{d40d68ba1f88d8e9b0040f175a6ff41928abd5e7}}" + String etag = response.headers().firstValue("ETag").orElse(null); + if (etag != null) { + int start = etag.indexOf("SHA1{"), end = etag.indexOf("}", start + 5); + if (start >= 0 && end > start) { + return Collections.singletonMap("SHA-1", etag.substring(start + 5, end)); + } + } + return null; + } + + private static final DateTimeFormatter RFC7231 = DateTimeFormatter.ofPattern( + "EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH) + .withZone(ZoneId.of("GMT")); + + /** + * Visible for testing. + */ + static final String HTTP_INSTANCE_KEY_PREFIX = JdkTransporterFactory.class.getName() + ".http."; + + private static HttpClient getOrCreateClient(RepositorySystemSession session, RemoteRepository repository) + throws NoTransporterException { + final String instanceKey = HTTP_INSTANCE_KEY_PREFIX + repository.getId(); + + try { + return (HttpClient) session.getData().computeIfAbsent(instanceKey, () -> { + HashMap authentications = new HashMap<>(); + SSLContext sslContext = null; + try { + try (AuthenticationContext repoAuthContext = + AuthenticationContext.forRepository(session, repository)) { + if (repoAuthContext != null) { + sslContext = repoAuthContext.get(AuthenticationContext.SSL_CONTEXT, SSLContext.class); + + String username = repoAuthContext.get(AuthenticationContext.USERNAME); + String password = repoAuthContext.get(AuthenticationContext.PASSWORD); + + authentications.put( + Authenticator.RequestorType.SERVER, + new PasswordAuthentication(username, password.toCharArray())); + } + } + + if (sslContext == null) { + sslContext = SSLContext.getDefault(); + } + + int connectTimeout = ConfigUtils.getInteger( + session, + ConfigurationProperties.DEFAULT_CONNECT_TIMEOUT, + ConfigurationProperties.CONNECT_TIMEOUT + "." + repository.getId(), + ConfigurationProperties.CONNECT_TIMEOUT); + + HttpClient.Builder builder = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofMillis(connectTimeout)) + .sslContext(sslContext); + + if (repository.getProxy() != null) { + ProxySelector proxy = ProxySelector.of(new InetSocketAddress( + repository.getProxy().getHost(), + repository.getProxy().getPort())); + + builder.proxy(proxy); + try (AuthenticationContext proxyAuthContext = + AuthenticationContext.forProxy(session, repository)) { + if (proxyAuthContext != null) { + String username = proxyAuthContext.get(AuthenticationContext.USERNAME); + String password = proxyAuthContext.get(AuthenticationContext.PASSWORD); + + authentications.put( + Authenticator.RequestorType.PROXY, + new PasswordAuthentication(username, password.toCharArray())); + } + } + } + + if (!authentications.isEmpty()) { + builder.authenticator(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return authentications.get(getRequestorType()); + } + }); + } + + return builder.build(); + } catch (NoSuchAlgorithmException e) { + throw new WrapperEx(e); + } + }); + } catch (WrapperEx e) { + throw new NoTransporterException(repository, e.getCause()); + } + } + + private static final class WrapperEx extends RuntimeException { + private WrapperEx(Throwable cause) { + super(cause); + } + } +} diff --git a/maven-resolver-transport-jetty-parent/src/main/java11/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java b/maven-resolver-transport-jetty-parent/src/main/java11/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java new file mode 100644 index 000000000..ecb327491 --- /dev/null +++ b/maven-resolver-transport-jetty-parent/src/main/java11/org/eclipse/aether/transport/jdk/JdkTransporterFactory.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.eclipse.aether.transport.jdk; + +import javax.inject.Named; + +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.spi.connector.transport.Transporter; +import org.eclipse.aether.spi.connector.transport.TransporterFactory; +import org.eclipse.aether.transfer.NoTransporterException; + +import static java.util.Objects.requireNonNull; + +/** + * JDK Transport factory. + * + * @since TBD + */ +@Named( JdkTransporterFactory.NAME) +public final class JdkTransporterFactory implements TransporterFactory { + public static final String NAME = "jdk"; + + private float priority = 10.0f; + + @Override + public float getPriority() { + return priority; + } + + public JdkTransporterFactory setPriority( float priority) { + this.priority = priority; + return this; + } + + @Override + public Transporter newInstance(RepositorySystemSession session, RemoteRepository repository) + throws NoTransporterException { + requireNonNull(session, "session cannot be null"); + requireNonNull(repository, "repository cannot be null"); + + if (!"http".equalsIgnoreCase(repository.getProtocol()) && !"https".equalsIgnoreCase(repository.getProtocol())) { + throw new NoTransporterException(repository, "Only HTTP/HTTPS is supported"); + } + + return new JdkHttpTransporter(session, repository); + } +} diff --git a/maven-resolver-transport-jetty-parent/src/main/java11/org/eclipse/aether/transport/jdk/package-info.java b/maven-resolver-transport-jetty-parent/src/main/java11/org/eclipse/aether/transport/jdk/package-info.java new file mode 100644 index 000000000..8ef1b81a9 --- /dev/null +++ b/maven-resolver-transport-jetty-parent/src/main/java11/org/eclipse/aether/transport/jdk/package-info.java @@ -0,0 +1,26 @@ +// CHECKSTYLE_OFF: RegexpHeader +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/** + * Support for downloads/uploads via the HTTP and HTTPS protocols. The implementation is backed by + * Java 11 {@link java.net.http.HttpClient}. + * + * @since TBD + */ +package org.eclipse.aether.transport.jdk; diff --git a/pom.xml b/pom.xml index c895375a9..a92cab22d 100644 --- a/pom.xml +++ b/pom.xml @@ -56,7 +56,8 @@ maven-resolver-connector-basic maven-resolver-transport-classpath maven-resolver-transport-file - maven-resolver-transport-jdk + maven-resolver-transport-jetty-parent + maven-resolver-transport-jdk-parent maven-resolver-transport-http maven-resolver-transport-wagon maven-resolver-supplier @@ -143,6 +144,11 @@ maven-resolver-transport-file ${project.version} + + org.apache.maven.resolver + maven-resolver-transport-jetty + ${project.version} + org.apache.maven.resolver maven-resolver-transport-jdk