diff --git a/maven-resolver-transport-apache/pom.xml b/maven-resolver-transport-apache/pom.xml index 64fe59c66..e4dd1e0ef 100644 --- a/maven-resolver-transport-apache/pom.xml +++ b/maven-resolver-transport-apache/pom.xml @@ -28,7 +28,7 @@ maven-resolver-transport-apache - Maven Artifact Resolver Transport Apache + Maven Artifact Resolver Transport Apache 4.x A transport implementation for repositories using http:// and https:// URLs. diff --git a/maven-resolver-transport-apache5x/pom.xml b/maven-resolver-transport-apache5x/pom.xml new file mode 100644 index 000000000..01e6db7f2 --- /dev/null +++ b/maven-resolver-transport-apache5x/pom.xml @@ -0,0 +1,110 @@ + + + + 4.0.0 + + + org.apache.maven.resolver + maven-resolver + 2.0.10-SNAPSHOT + + + maven-resolver-transport-apache5x + + Maven Artifact Resolver Transport Apache 5.x + A transport implementation for repositories using http:// and https:// URLs. + + + + org.apache.maven.resolver + maven-resolver-api + + + org.apache.maven.resolver + maven-resolver-spi + + + org.apache.maven.resolver + maven-resolver-util + + + org.apache.httpcomponents.client5 + httpclient5 + 5.5 + + + + commons-codec + commons-codec + + + + + org.apache.httpcomponents.core5 + httpcore5 + 5.3.4 + + + org.apache.httpcomponents.core5 + httpcore5-h2 + 5.3.4 + + + org.slf4j + slf4j-api + + + javax.inject + javax.inject + provided + true + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.slf4j + slf4j-simple + test + + + org.apache.maven.resolver + maven-resolver-test-util + test + + + org.apache.maven.resolver + maven-resolver-test-http + test + + + + + + + org.eclipse.sisu + sisu-maven-plugin + + + + diff --git a/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/ApacheRFC9457Reporter.java b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/ApacheRFC9457Reporter.java new file mode 100644 index 000000000..40eceff4d --- /dev/null +++ b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/ApacheRFC9457Reporter.java @@ -0,0 +1,70 @@ +/* + * 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.apache5x; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.apache.hc.client5.http.HttpResponseException; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.ParseException; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.eclipse.aether.spi.connector.transport.http.RFC9457.RFC9457Reporter; + +public class ApacheRFC9457Reporter extends RFC9457Reporter { + public static final ApacheRFC9457Reporter INSTANCE = new ApacheRFC9457Reporter(); + + private ApacheRFC9457Reporter() {} + + @Override + protected boolean isRFC9457Message(final ClassicHttpResponse response) { + Header[] headers = response.getHeaders(HttpHeaders.CONTENT_TYPE); + if (headers.length > 0) { + String contentType = headers[0].getValue(); + return hasRFC9457ContentType(contentType); + } + return false; + } + + @Override + protected int getStatusCode(final ClassicHttpResponse response) { + return response.getCode(); + } + + @Override + protected String getReasonPhrase(final ClassicHttpResponse response) { + String reasonPhrase = response.getReasonPhrase(); + if (reasonPhrase == null || reasonPhrase.isEmpty()) { + return ""; + } + int statusCode = getStatusCode(response); + return reasonPhrase + " (" + statusCode + ")"; + } + + @Override + protected String getBody(final ClassicHttpResponse response) throws IOException { + try { + return EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + } catch (ParseException e) { + throw new IOException(e); + } + } +} diff --git a/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/ApacheTransporter.java b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/ApacheTransporter.java new file mode 100644 index 000000000..8be790692 --- /dev/null +++ b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/ApacheTransporter.java @@ -0,0 +1,800 @@ +/* + * 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.apache5x; + +import javax.net.ssl.SSLException; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.net.ConnectException; +import java.net.InetAddress; +import java.net.NoRouteToHostException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.regex.Matcher; + +import org.apache.hc.client5.http.HttpRequestRetryStrategy; +import org.apache.hc.client5.http.HttpResponseException; +import org.apache.hc.client5.http.auth.AuthSchemeFactory; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.CredentialsStore; +import org.apache.hc.client5.http.auth.StandardAuthScheme; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpHead; +import org.apache.hc.client5.http.classic.methods.HttpOptions; +import org.apache.hc.client5.http.classic.methods.HttpPut; +import org.apache.hc.client5.http.classic.methods.HttpUriRequest; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.cookie.StandardCookieSpec; +import org.apache.hc.client5.http.impl.DefaultClientConnectionReuseStrategy; +import org.apache.hc.client5.http.impl.DefaultHttpRequestRetryStrategy; +import org.apache.hc.client5.http.impl.DefaultSchemePortResolver; +import org.apache.hc.client5.http.impl.LaxRedirectStrategy; +import org.apache.hc.client5.http.impl.auth.BasicScheme; +import org.apache.hc.client5.http.impl.auth.BasicSchemeFactory; +import org.apache.hc.client5.http.impl.auth.DigestSchemeFactory; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.routing.DefaultProxyRoutePlanner; +import org.apache.hc.client5.http.impl.routing.DefaultRoutePlanner; +import org.apache.hc.client5.http.routing.HttpRoutePlanner; +import org.apache.hc.client5.http.utils.DateUtils; +import org.apache.hc.client5.http.utils.URIUtils; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ConnectionClosedException; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.config.Registry; +import org.apache.hc.core5.http.config.RegistryBuilder; +import org.apache.hc.core5.http.io.SocketConfig; +import org.apache.hc.core5.http.io.entity.AbstractHttpEntity; +import org.apache.hc.core5.http.io.entity.ByteArrayEntity; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.TimeValue; +import org.eclipse.aether.ConfigurationProperties; +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.repository.AuthenticationContext; +import org.eclipse.aether.repository.Proxy; +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.spi.connector.transport.http.ChecksumExtractor; +import org.eclipse.aether.spi.connector.transport.http.HttpTransporter; +import org.eclipse.aether.spi.connector.transport.http.HttpTransporterException; +import org.eclipse.aether.spi.io.PathProcessor; +import org.eclipse.aether.transfer.NoTransporterException; +import org.eclipse.aether.transfer.TransferCancelledException; +import org.eclipse.aether.util.ConfigUtils; +import org.eclipse.aether.util.FileUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_RANGE_PATTERN; +import static org.eclipse.aether.transport.apache5x.ApacheTransporterConfigurationKeys.CONFIG_PROP_FOLLOW_REDIRECTS; +import static org.eclipse.aether.transport.apache5x.ApacheTransporterConfigurationKeys.CONFIG_PROP_HTTP_RETRY_HANDLER_NAME; +import static org.eclipse.aether.transport.apache5x.ApacheTransporterConfigurationKeys.CONFIG_PROP_MAX_REDIRECTS; +import static org.eclipse.aether.transport.apache5x.ApacheTransporterConfigurationKeys.CONFIG_PROP_USE_SYSTEM_PROPERTIES; +import static org.eclipse.aether.transport.apache5x.ApacheTransporterConfigurationKeys.DEFAULT_FOLLOW_REDIRECTS; +import static org.eclipse.aether.transport.apache5x.ApacheTransporterConfigurationKeys.DEFAULT_MAX_REDIRECTS; +import static org.eclipse.aether.transport.apache5x.ApacheTransporterConfigurationKeys.DEFAULT_USE_SYSTEM_PROPERTIES; +import static org.eclipse.aether.transport.apache5x.ApacheTransporterConfigurationKeys.HTTP_RETRY_HANDLER_NAME_STANDARD; + +/** + * A transporter for HTTP/HTTPS. + */ +final class ApacheTransporter extends AbstractTransporter implements HttpTransporter { + private static final Logger LOGGER = LoggerFactory.getLogger(ApacheTransporter.class); + + private final ChecksumExtractor checksumExtractor; + + private final PathProcessor pathProcessor; + + private final AuthenticationContext repoAuthContext; + + private final AuthenticationContext proxyAuthContext; + + private final URI baseUri; + + private final HttpHost server; + + private final HttpHost proxy; + + private final CloseableHttpClient client; + + private final Map headers; + + private final LocalState state; + + private final boolean preemptiveAuth; + + private final boolean preemptivePutAuth; + + private final boolean supportWebDav; + + @SuppressWarnings("checkstyle:methodlength") + ApacheTransporter( + RemoteRepository repository, + RepositorySystemSession session, + ChecksumExtractor checksumExtractor, + PathProcessor pathProcessor) + throws NoTransporterException { + this.checksumExtractor = checksumExtractor; + this.pathProcessor = pathProcessor; + try { + this.baseUri = new URI(repository.getUrl()).parseServerAuthority(); + if (baseUri.isOpaque()) { + throw new URISyntaxException(repository.getUrl(), "URL must not be opaque"); + } + this.server = URIUtils.extractHost(baseUri); + if (server == null) { + throw new URISyntaxException(repository.getUrl(), "URL lacks host name"); + } + } catch (URISyntaxException e) { + throw new NoTransporterException(repository, e.getMessage(), e); + } + this.proxy = toHost(repository.getProxy()); + + this.repoAuthContext = AuthenticationContext.forRepository(session, repository); + this.proxyAuthContext = AuthenticationContext.forProxy(session, repository); + + String httpsSecurityMode = ConfigUtils.getString( + session, + ConfigurationProperties.HTTPS_SECURITY_MODE_DEFAULT, + ConfigurationProperties.HTTPS_SECURITY_MODE + "." + repository.getId(), + ConfigurationProperties.HTTPS_SECURITY_MODE); + final int connectionMaxTtlSeconds = ConfigUtils.getInteger( + session, + ConfigurationProperties.DEFAULT_HTTP_CONNECTION_MAX_TTL, + ConfigurationProperties.HTTP_CONNECTION_MAX_TTL + "." + repository.getId(), + ConfigurationProperties.HTTP_CONNECTION_MAX_TTL); + final int maxConnectionsPerRoute = ConfigUtils.getInteger( + session, + ConfigurationProperties.DEFAULT_HTTP_MAX_CONNECTIONS_PER_ROUTE, + ConfigurationProperties.HTTP_MAX_CONNECTIONS_PER_ROUTE + "." + repository.getId(), + ConfigurationProperties.HTTP_MAX_CONNECTIONS_PER_ROUTE); + int connectTimeout = ConfigUtils.getInteger( + session, + ConfigurationProperties.DEFAULT_CONNECT_TIMEOUT, + ConfigurationProperties.CONNECT_TIMEOUT + "." + repository.getId(), + ConfigurationProperties.CONNECT_TIMEOUT); + int requestTimeout = ConfigUtils.getInteger( + session, + ConfigurationProperties.DEFAULT_REQUEST_TIMEOUT, + ConfigurationProperties.REQUEST_TIMEOUT + "." + repository.getId(), + ConfigurationProperties.REQUEST_TIMEOUT); + + SocketConfig socketConfig = + // the time to establish connection (low level) + SocketConfig.custom() + .setSoTimeout(requestTimeout, TimeUnit.MILLISECONDS) + .build(); + ConnectionConfig connectionConfig = ConnectionConfig.custom() + .setTimeToLive(connectionMaxTtlSeconds, TimeUnit.SECONDS) + .setConnectTimeout(connectTimeout, TimeUnit.MILLISECONDS) + .setSocketTimeout(requestTimeout, TimeUnit.MILLISECONDS) + .build(); + + this.state = new LocalState( + session, + repository, + new ConnMgrConfig( + session, + repoAuthContext, + httpsSecurityMode, + maxConnectionsPerRoute, + socketConfig, + connectionConfig)); + + this.headers = ConfigUtils.getMap( + session, + Collections.emptyMap(), + ConfigurationProperties.HTTP_HEADERS + "." + repository.getId(), + ConfigurationProperties.HTTP_HEADERS); + + this.preemptiveAuth = ConfigUtils.getBoolean( + session, + ConfigurationProperties.DEFAULT_HTTP_PREEMPTIVE_AUTH, + ConfigurationProperties.HTTP_PREEMPTIVE_AUTH + "." + repository.getId(), + ConfigurationProperties.HTTP_PREEMPTIVE_AUTH); + this.preemptivePutAuth = ConfigUtils.getBoolean( + session, + ConfigurationProperties.DEFAULT_HTTP_PREEMPTIVE_PUT_AUTH, + ConfigurationProperties.HTTP_PREEMPTIVE_PUT_AUTH + "." + repository.getId(), + ConfigurationProperties.HTTP_PREEMPTIVE_PUT_AUTH); + this.supportWebDav = ConfigUtils.getBoolean( + session, + ConfigurationProperties.DEFAULT_HTTP_SUPPORT_WEBDAV, + ConfigurationProperties.HTTP_SUPPORT_WEBDAV + "." + repository.getId(), + ConfigurationProperties.HTTP_SUPPORT_WEBDAV); + String credentialEncoding = ConfigUtils.getString( + session, + ConfigurationProperties.DEFAULT_HTTP_CREDENTIAL_ENCODING, + ConfigurationProperties.HTTP_CREDENTIAL_ENCODING + "." + repository.getId(), + ConfigurationProperties.HTTP_CREDENTIAL_ENCODING); + int retryCount = ConfigUtils.getInteger( + session, + ConfigurationProperties.DEFAULT_HTTP_RETRY_HANDLER_COUNT, + ConfigurationProperties.HTTP_RETRY_HANDLER_COUNT + "." + repository.getId(), + ConfigurationProperties.HTTP_RETRY_HANDLER_COUNT); + long retryInterval = ConfigUtils.getLong( + session, + ConfigurationProperties.DEFAULT_HTTP_RETRY_HANDLER_INTERVAL, + ConfigurationProperties.HTTP_RETRY_HANDLER_INTERVAL + "." + repository.getId(), + ConfigurationProperties.HTTP_RETRY_HANDLER_INTERVAL); + String serviceUnavailableCodesString = ConfigUtils.getString( + session, + ConfigurationProperties.DEFAULT_HTTP_RETRY_HANDLER_SERVICE_UNAVAILABLE, + ConfigurationProperties.HTTP_RETRY_HANDLER_SERVICE_UNAVAILABLE + "." + repository.getId(), + ConfigurationProperties.HTTP_RETRY_HANDLER_SERVICE_UNAVAILABLE); + String retryHandlerName = ConfigUtils.getString( + session, + HTTP_RETRY_HANDLER_NAME_STANDARD, + CONFIG_PROP_HTTP_RETRY_HANDLER_NAME + "." + repository.getId(), + CONFIG_PROP_HTTP_RETRY_HANDLER_NAME); + int maxRedirects = ConfigUtils.getInteger( + session, + DEFAULT_MAX_REDIRECTS, + CONFIG_PROP_MAX_REDIRECTS + "." + repository.getId(), + CONFIG_PROP_MAX_REDIRECTS); + boolean followRedirects = ConfigUtils.getBoolean( + session, + DEFAULT_FOLLOW_REDIRECTS, + CONFIG_PROP_FOLLOW_REDIRECTS + "." + repository.getId(), + CONFIG_PROP_FOLLOW_REDIRECTS); + String userAgent = ConfigUtils.getString( + session, ConfigurationProperties.DEFAULT_USER_AGENT, ConfigurationProperties.USER_AGENT); + + Charset credentialsCharset = Charset.forName(credentialEncoding); + Registry authSchemeRegistry = RegistryBuilder.create() + .register(StandardAuthScheme.BASIC, new BasicSchemeFactory(credentialsCharset)) + .register(StandardAuthScheme.DIGEST, new DigestSchemeFactory(credentialsCharset)) + .build(); + + RequestConfig requestConfig = RequestConfig.custom() + .setMaxRedirects(maxRedirects) + .setRedirectsEnabled(followRedirects) + .setConnectionRequestTimeout(connectTimeout, TimeUnit.MILLISECONDS) + .setCookieSpec(StandardCookieSpec.STRICT) + .build(); + + Set serviceUnavailableCodes = new HashSet<>(); + try { + for (String code : ConfigUtils.parseCommaSeparatedUniqueNames(serviceUnavailableCodesString)) { + serviceUnavailableCodes.add(Integer.parseInt(code)); + } + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "Illegal HTTP codes for " + ConfigurationProperties.HTTP_RETRY_HANDLER_SERVICE_UNAVAILABLE + + " (list of integers): " + serviceUnavailableCodesString); + } + + HttpRequestRetryStrategy retryHandler; + if (HTTP_RETRY_HANDLER_NAME_STANDARD.equals(retryHandlerName)) { + retryHandler = new ResolverServiceUnavailableRetryStrategy( + retryCount, + TimeValue.ofMilliseconds(retryInterval), + Arrays.asList( + InterruptedIOException.class, + UnknownHostException.class, + ConnectException.class, + ConnectionClosedException.class, + NoRouteToHostException.class, + SSLException.class), + serviceUnavailableCodes); + } else { + // TODO: no equivalent + throw new IllegalArgumentException( + "Unsupported parameter " + CONFIG_PROP_HTTP_RETRY_HANDLER_NAME + " value: " + retryHandlerName); + } + + HttpRoutePlanner routePlanner = null; + InetAddress localAddress = getHttpLocalAddress(session, repository); + if (localAddress != null) { + if (proxy != null) { + routePlanner = new DefaultProxyRoutePlanner(proxy, DefaultSchemePortResolver.INSTANCE) { + @Override + protected InetAddress determineLocalAddress(HttpHost firstHop, HttpContext context) { + return localAddress; + } + }; + } else { + routePlanner = new DefaultRoutePlanner(DefaultSchemePortResolver.INSTANCE) { + @Override + protected InetAddress determineLocalAddress(HttpHost firstHop, HttpContext context) { + return localAddress; + } + }; + } + } + + HttpClientBuilder builder = HttpClientBuilder.create() + .setUserAgent(userAgent) + .setRedirectStrategy(LaxRedirectStrategy.INSTANCE) + .setDefaultRequestConfig(requestConfig) + .setRetryStrategy(retryHandler) + .setRoutePlanner(routePlanner) + .setDefaultAuthSchemeRegistry(authSchemeRegistry) + .setConnectionManager(state.getConnectionManager()) + .setConnectionManagerShared(true) + .setDefaultCredentialsProvider(toCredentialsProvider(server, repoAuthContext, proxy, proxyAuthContext)) + .setProxy(proxy); + final boolean useSystemProperties = ConfigUtils.getBoolean( + session, + DEFAULT_USE_SYSTEM_PROPERTIES, + CONFIG_PROP_USE_SYSTEM_PROPERTIES + "." + repository.getId(), + CONFIG_PROP_USE_SYSTEM_PROPERTIES); + if (useSystemProperties) { + LOGGER.warn( + "Transport used Apache HttpClient is instructed to use system properties: this may yield in unwanted side-effects!"); + LOGGER.warn("Please use documented means to configure resolver transport."); + builder.useSystemProperties(); + } + + final String expectContinue = ConfigUtils.getString( + session, + null, + ConfigurationProperties.HTTP_EXPECT_CONTINUE + "." + repository.getId(), + ConfigurationProperties.HTTP_EXPECT_CONTINUE); + if (expectContinue != null) { + state.setExpectContinue(Boolean.parseBoolean(expectContinue)); + } + + final boolean reuseConnections = ConfigUtils.getBoolean( + session, + ConfigurationProperties.DEFAULT_HTTP_REUSE_CONNECTIONS, + ConfigurationProperties.HTTP_REUSE_CONNECTIONS + "." + repository.getId(), + ConfigurationProperties.HTTP_REUSE_CONNECTIONS); + if (!reuseConnections) { + builder.setConnectionReuseStrategy((request, response, context) -> false); + } else { + builder.setConnectionReuseStrategy(DefaultClientConnectionReuseStrategy.INSTANCE); + } + + this.client = builder.build(); + } + + /** + * Returns non-null {@link InetAddress} if set in configuration, {@code null} otherwise. + */ + private InetAddress getHttpLocalAddress(RepositorySystemSession session, RemoteRepository repository) { + String bindAddress = ConfigUtils.getString( + session, + null, + ConfigurationProperties.HTTP_LOCAL_ADDRESS + "." + repository.getId(), + ConfigurationProperties.HTTP_LOCAL_ADDRESS); + if (bindAddress == null) { + return null; + } + try { + return InetAddress.getByName(bindAddress); + } catch (UnknownHostException uhe) { + throw new IllegalArgumentException( + "Given bind address (" + bindAddress + ") cannot be resolved for remote repository " + repository, + uhe); + } + } + + private static HttpHost toHost(Proxy proxy) { + HttpHost host = null; + if (proxy != null) { + host = new HttpHost(proxy.getHost(), proxy.getPort()); + } + return host; + } + + private static CredentialsStore toCredentialsProvider( + HttpHost server, AuthenticationContext serverAuthCtx, HttpHost proxy, AuthenticationContext proxyAuthCtx) { + CredentialsStore provider = toCredentialsProvider(server.getHostName(), -1, serverAuthCtx); + if (proxy != null) { + CredentialsStore p = toCredentialsProvider(proxy.getHostName(), proxy.getPort(), proxyAuthCtx); + provider = new DemuxCredentialsProvider(provider, p, proxy); + } + return provider; + } + + private static CredentialsStore toCredentialsProvider(String host, int port, AuthenticationContext ctx) { + DeferredCredentialsProvider provider = new DeferredCredentialsProvider(); + if (ctx != null) { + AuthScope basicScope = new AuthScope(host, port); + provider.setCredentials(basicScope, new DeferredCredentialsProvider.BasicFactory(ctx)); + } + return provider; + } + + LocalState getState() { + return state; + } + + private URI resolve(TransportTask task) { + return UriUtils.resolve(baseUri, task.getLocation()); + } + + @Override + public int classify(Throwable error) { + if (error instanceof HttpTransporterException + && ((HttpTransporterException) error).getStatusCode() == HttpStatus.SC_NOT_FOUND) { + return ERROR_NOT_FOUND; + } + return ERROR_OTHER; + } + + @Override + protected void implPeek(PeekTask task) throws Exception { + HttpHead request = commonHeaders(new HttpHead(resolve(task))); + try { + execute(request, null); + } catch (HttpResponseException e) { + throw new HttpTransporterException(e.getStatusCode()); + } + } + + @Override + protected void implGet(GetTask task) throws Exception { + boolean resume = true; + + EntityGetter getter = new EntityGetter(task); + HttpGet request = commonHeaders(new HttpGet(resolve(task))); + while (true) { + try { + if (resume) { + resume(request, task); + } + execute(request, getter); + break; + } catch (HttpResponseException e) { + if (resume + && e.getStatusCode() == HttpStatus.SC_PRECONDITION_FAILED + && request.containsHeader(HttpHeaders.RANGE)) { + request = commonHeaders(new HttpGet(resolve(task))); + resume = false; + continue; + } + throw new HttpTransporterException(e.getStatusCode()); + } + } + } + + @Override + protected void implPut(PutTask task) throws Exception { + PutTaskEntity entity = new PutTaskEntity(task); + HttpPut request = commonHeaders(entity(new HttpPut(resolve(task)), entity)); + try { + execute(request, null); + } catch (HttpResponseException e) { + if (e.getStatusCode() == HttpStatus.SC_EXPECTATION_FAILED && request.containsHeader(HttpHeaders.EXPECT)) { + state.setExpectContinue(false); + request = commonHeaders(entity(new HttpPut(request.getUri()), entity)); + execute(request, null); + return; + } + throw new HttpTransporterException(e.getStatusCode()); + } + } + + private void execute(HttpUriRequest request, EntityGetter getter) throws Exception { + try { + SharingHttpContext context = new SharingHttpContext(state); + prepare(request, context); + try (ClassicHttpResponse response = client.execute(server, request, context)) { + try { + context.close(); + handleStatus(response); + if (getter != null) { + getter.handle(response); + } + } finally { + EntityUtils.consumeQuietly(response.getEntity()); + } + } + } catch (IOException e) { + if (e.getCause() instanceof TransferCancelledException) { + throw (Exception) e.getCause(); + } + throw e; + } + } + + private void prepare(HttpUriRequest request, SharingHttpContext context) throws Exception { + final boolean put = HttpPut.METHOD_NAME.equalsIgnoreCase(request.getMethod()); + if (preemptiveAuth || (preemptivePutAuth && put)) { + context.getAuthCache().put(server, new BasicScheme()); + } + if (supportWebDav) { + if (state.getWebDav() == null && (put || isPayloadPresent(request))) { + HttpOptions req = commonHeaders(new HttpOptions(request.getUri())); + try (ClassicHttpResponse response = client.execute(server, req, context)) { + state.setWebDav(response.containsHeader(HttpHeaders.DAV)); + EntityUtils.consumeQuietly(response.getEntity()); + } catch (IOException e) { + LOGGER.debug("Failed to prepare HTTP context", e); + } + } + if (put && Boolean.TRUE.equals(state.getWebDav())) { + mkdirs(request.getUri(), context); + } + } + } + + private void mkdirs(URI uri, SharingHttpContext context) throws Exception { + List dirs = UriUtils.getDirectories(baseUri, uri); + int index = 0; + for (; index < dirs.size(); index++) { + try (ClassicHttpResponse response = + client.execute(server, commonHeaders(new HttpMkCol(dirs.get(index))), context)) { + try { + int status = response.getCode(); + if (status < 300 || status == HttpStatus.SC_METHOD_NOT_ALLOWED) { + break; + } else if (status == HttpStatus.SC_CONFLICT) { + continue; + } + handleStatus(response); + } finally { + EntityUtils.consumeQuietly(response.getEntity()); + } + } catch (IOException e) { + LOGGER.debug("Failed to create parent directory {}", dirs.get(index), e); + return; + } + } + for (index--; index >= 0; index--) { + try (ClassicHttpResponse response = + client.execute(server, commonHeaders(new HttpMkCol(dirs.get(index))), context)) { + try { + handleStatus(response); + } finally { + EntityUtils.consumeQuietly(response.getEntity()); + } + } catch (IOException e) { + LOGGER.debug("Failed to create parent directory {}", dirs.get(index), e); + return; + } + } + } + + private T entity(T request, HttpEntity entity) { + request.setEntity(entity); + return request; + } + + private boolean isPayloadPresent(HttpUriRequest request) { + HttpEntity entity = request.getEntity(); + return entity != null && entity.getContentLength() != 0; + } + + private T commonHeaders(T request) { + request.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-store"); + request.setHeader(HttpHeaders.PRAGMA, "no-cache"); + + if (state.isExpectContinue() && isPayloadPresent(request)) { + request.setHeader(HttpHeaders.EXPECT, "100-continue"); + } + + for (Map.Entry entry : headers.entrySet()) { + if (!(entry.getKey() instanceof String)) { + continue; + } + if (entry.getValue() instanceof String) { + request.setHeader(entry.getKey().toString(), entry.getValue().toString()); + } else { + request.removeHeaders(entry.getKey().toString()); + } + } + + if (!state.isExpectContinue()) { + request.removeHeaders(HttpHeaders.EXPECT); + } + + return request; + } + + private void resume(T request, GetTask task) throws IOException { + long resumeOffset = task.getResumeOffset(); + if (resumeOffset > 0L && task.getDataPath() != null) { + long lastModified = Files.getLastModifiedTime(task.getDataPath()).toMillis(); + request.setHeader(HttpHeaders.RANGE, "bytes=" + resumeOffset + '-'); + request.setHeader( + HttpHeaders.IF_UNMODIFIED_SINCE, + DateUtils.formatStandardDate(Instant.ofEpochMilli(lastModified - 60L * 1000L))); + request.setHeader(HttpHeaders.ACCEPT_ENCODING, "identity"); + } + } + + private void handleStatus(ClassicHttpResponse response) throws Exception { + int status = response.getCode(); + if (status >= 300) { + ApacheRFC9457Reporter.INSTANCE.generateException(response, (statusCode, reasonPhrase) -> { + throw new HttpResponseException(statusCode, reasonPhrase + " (" + statusCode + ")"); + }); + } + } + + @Override + protected void implClose() { + try { + client.close(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + AuthenticationContext.close(repoAuthContext); + AuthenticationContext.close(proxyAuthContext); + state.close(); + } + + private class EntityGetter { + + private final GetTask task; + + EntityGetter(GetTask task) { + this.task = task; + } + + public void handle(ClassicHttpResponse response) throws IOException, TransferCancelledException { + HttpEntity entity = response.getEntity(); + if (entity == null) { + entity = new ByteArrayEntity(new byte[0], ContentType.DEFAULT_BINARY); + } + + long offset = 0L, length = entity.getContentLength(); + Header rangeHeader = response.getFirstHeader(HttpHeaders.CONTENT_RANGE); + String range = rangeHeader != null ? rangeHeader.getValue() : 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 resume = offset > 0L; + final Path dataFile = task.getDataPath(); + if (dataFile == null) { + try (InputStream is = entity.getContent()) { + utilGet(task, is, true, length, resume); + extractChecksums(response); + } + } else { + try (FileUtils.CollocatedTempFile tempFile = FileUtils.newTempFile(dataFile)) { + task.setDataPath(tempFile.getPath(), resume); + if (resume && Files.isRegularFile(dataFile)) { + try (InputStream inputStream = Files.newInputStream(dataFile)) { + Files.copy(inputStream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING); + } + } + try (InputStream is = entity.getContent()) { + utilGet(task, is, true, length, resume); + } + tempFile.move(); + } finally { + task.setDataPath(dataFile); + } + } + if (task.getDataPath() != null) { + Header lastModifiedHeader = + response.getFirstHeader(HttpHeaders.LAST_MODIFIED); // note: Wagon also does first not last + if (lastModifiedHeader != null) { + Instant lastModified = DateUtils.parseStandardDate(lastModifiedHeader.getValue()); + if (lastModified != null) { + pathProcessor.setLastModified(task.getDataPath(), lastModified.toEpochMilli()); + } + } + } + extractChecksums(response); + } + + private void extractChecksums(ClassicHttpResponse response) { + Map checksums = checksumExtractor.extractChecksums(headerGetter(response)); + if (checksums != null && !checksums.isEmpty()) { + checksums.forEach(task::setChecksum); + } + } + } + + private static Function headerGetter(ClassicHttpResponse closeableHttpResponse) { + return s -> { + Header header = closeableHttpResponse.getFirstHeader(s); + return header != null ? header.getValue() : null; + }; + } + + private class PutTaskEntity extends AbstractHttpEntity { + + private final PutTask task; + + PutTaskEntity(PutTask task) { + super(ContentType.DEFAULT_BINARY, null, false); + this.task = task; + } + + @Override + public boolean isRepeatable() { + return true; + } + + @Override + public boolean isStreaming() { + return false; + } + + @Override + public long getContentLength() { + return task.getDataLength(); + } + + @Override + public InputStream getContent() throws IOException { + return task.newInputStream(); + } + + @Override + public void writeTo(OutputStream os) throws IOException { + try { + utilPut(task, os, false); + } catch (TransferCancelledException e) { + throw (IOException) new InterruptedIOException().initCause(e); + } + } + + @Override + public void close() throws IOException {} + } + + private static class ResolverServiceUnavailableRetryStrategy extends DefaultHttpRequestRetryStrategy { + private ResolverServiceUnavailableRetryStrategy( + int maxRetries, + TimeValue defaultRetryInterval, + Collection> clazzes, + Collection codes) { + super(maxRetries, defaultRetryInterval, clazzes, codes); + } + } +} diff --git a/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/ApacheTransporterConfigurationKeys.java b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/ApacheTransporterConfigurationKeys.java new file mode 100644 index 000000000..775525195 --- /dev/null +++ b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/ApacheTransporterConfigurationKeys.java @@ -0,0 +1,120 @@ +/* + * 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.apache5x; + +import org.eclipse.aether.ConfigurationProperties; +import org.eclipse.aether.RepositorySystemSession; + +/** + * Configuration for Apache Transport. + * + * @since 2.0.10 + */ +public final class ApacheTransporterConfigurationKeys { + private ApacheTransporterConfigurationKeys() {} + + static final String CONFIG_PROPS_PREFIX = + ConfigurationProperties.PREFIX_TRANSPORT + ApacheTransporterFactory.NAME + "."; + + /** + * If enabled, underlying Apache HttpClient will use system properties as well to configure itself (typically + * used to set up HTTP Proxy via Java system properties). See HttpClientBuilder for used properties. This mode + * is not recommended, better use documented ways of configuration instead. + * + * @configurationSource {@link RepositorySystemSession#getConfigProperties()} + * @configurationType {@link java.lang.Boolean} + * @configurationDefaultValue {@link #DEFAULT_USE_SYSTEM_PROPERTIES} + * @configurationRepoIdSuffix Yes + */ + public static final String CONFIG_PROP_USE_SYSTEM_PROPERTIES = CONFIG_PROPS_PREFIX + "useSystemProperties"; + + public static final boolean DEFAULT_USE_SYSTEM_PROPERTIES = false; + + /** + * The name of retryHandler, supported values are “standard”, that obeys RFC-2616, regarding idempotent methods, + * and “default” that considers requests w/o payload as idempotent. + * + * @configurationSource {@link RepositorySystemSession#getConfigProperties()} + * @configurationType {@link java.lang.String} + * @configurationDefaultValue {@link #HTTP_RETRY_HANDLER_NAME_STANDARD} + * @configurationRepoIdSuffix Yes + */ + public static final String CONFIG_PROP_HTTP_RETRY_HANDLER_NAME = CONFIG_PROPS_PREFIX + "retryHandler.name"; + + public static final String HTTP_RETRY_HANDLER_NAME_STANDARD = "standard"; + + public static final String HTTP_RETRY_HANDLER_NAME_DEFAULT = "default"; + + /** + * Set to true if it is acceptable to retry non-idempotent requests, that have been sent. + * + * @configurationSource {@link RepositorySystemSession#getConfigProperties()} + * @configurationType {@link java.lang.Boolean} + * @configurationDefaultValue {@link #DEFAULT_HTTP_RETRY_HANDLER_REQUEST_SENT_ENABLED} + * @configurationRepoIdSuffix Yes + */ + public static final String CONFIG_PROP_HTTP_RETRY_HANDLER_REQUEST_SENT_ENABLED = + CONFIG_PROPS_PREFIX + "retryHandler.requestSentEnabled"; + + public static final boolean DEFAULT_HTTP_RETRY_HANDLER_REQUEST_SENT_ENABLED = false; + + /** + * Comma-separated list of + * Cipher + * Suites which are enabled for HTTPS connections. + * + * @configurationSource {@link RepositorySystemSession#getConfigProperties()} + * @configurationType {@link java.lang.String} + */ + public static final String CONFIG_PROP_CIPHER_SUITES = CONFIG_PROPS_PREFIX + "https.cipherSuites"; + + /** + * Comma-separated list of + * Protocols + * which are enabled for HTTPS connections. + * + * @configurationSource {@link RepositorySystemSession#getConfigProperties()} + * @configurationType {@link java.lang.String} + */ + public static final String CONFIG_PROP_PROTOCOLS = CONFIG_PROPS_PREFIX + "https.protocols"; + + /** + * If enabled, Apache HttpClient will follow HTTP redirects. + * + * @configurationSource {@link RepositorySystemSession#getConfigProperties()} + * @configurationType {@link Boolean} + * @configurationDefaultValue {@link #DEFAULT_FOLLOW_REDIRECTS} + * @configurationRepoIdSuffix Yes + */ + public static final String CONFIG_PROP_FOLLOW_REDIRECTS = CONFIG_PROPS_PREFIX + "followRedirects"; + + public static final boolean DEFAULT_FOLLOW_REDIRECTS = true; + + /** + * The max redirect count to follow. + * + * @configurationSource {@link RepositorySystemSession#getConfigProperties()} + * @configurationType {@link java.lang.Integer} + * @configurationDefaultValue {@link #DEFAULT_MAX_REDIRECTS} + * @configurationRepoIdSuffix Yes + */ + public static final String CONFIG_PROP_MAX_REDIRECTS = CONFIG_PROPS_PREFIX + "maxRedirects"; + + public static final int DEFAULT_MAX_REDIRECTS = 5; +} diff --git a/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/ApacheTransporterFactory.java b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/ApacheTransporterFactory.java new file mode 100644 index 000000000..ed10c7fdb --- /dev/null +++ b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/ApacheTransporterFactory.java @@ -0,0 +1,84 @@ +/* + * 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.apache5x; + +import javax.inject.Inject; +import javax.inject.Named; + +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.spi.connector.transport.http.ChecksumExtractor; +import org.eclipse.aether.spi.connector.transport.http.HttpTransporter; +import org.eclipse.aether.spi.connector.transport.http.HttpTransporterFactory; +import org.eclipse.aether.spi.io.PathProcessor; +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. The provided transporters + * support uploads to WebDAV servers and resumable downloads. + * + * @since 2.0.10 + */ +@Named(ApacheTransporterFactory.NAME) +public final class ApacheTransporterFactory implements HttpTransporterFactory { + public static final String NAME = "apache5x"; + + private float priority = 6.0f; + + private final ChecksumExtractor checksumExtractor; + + private final PathProcessor pathProcessor; + + @Inject + public ApacheTransporterFactory(ChecksumExtractor checksumExtractor, PathProcessor pathProcessor) { + this.checksumExtractor = requireNonNull(checksumExtractor, "checksumExtractor"); + this.pathProcessor = requireNonNull(pathProcessor, "pathProcessor"); + } + + @Override + public float getPriority() { + return priority; + } + + /** + * Sets the priority of this component. + * + * @param priority The priority. + * @return This component for chaining, never {@code null}. + */ + public ApacheTransporterFactory setPriority(float priority) { + this.priority = priority; + return this; + } + + @Override + public HttpTransporter 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 ApacheTransporter(repository, session, checksumExtractor, pathProcessor); + } +} diff --git a/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/AuthSchemePool.java b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/AuthSchemePool.java new file mode 100644 index 000000000..15d07ebdd --- /dev/null +++ b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/AuthSchemePool.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.apache5x; + +import java.util.LinkedList; + +import org.apache.hc.client5.http.auth.AuthScheme; +import org.apache.hc.client5.http.auth.StandardAuthScheme; +import org.apache.hc.client5.http.impl.auth.BasicScheme; + +/** + * Pool of (equivalent) auth schemes for a single host. + */ +final class AuthSchemePool { + + private final LinkedList authSchemes; + + private String schemeName; + + AuthSchemePool() { + authSchemes = new LinkedList<>(); + } + + public synchronized AuthScheme get() { + AuthScheme authScheme = null; + if (!authSchemes.isEmpty()) { + authScheme = authSchemes.removeLast(); + } else if (StandardAuthScheme.BASIC.equalsIgnoreCase(schemeName)) { + authScheme = new BasicScheme(); + } + return authScheme; + } + + public synchronized void put(AuthScheme authScheme) { + if (authScheme == null) { + return; + } + if (!authScheme.getName().equals(schemeName)) { + schemeName = authScheme.getName(); + authSchemes.clear(); + } + authSchemes.add(authScheme); + } +} diff --git a/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/ConnMgrConfig.java b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/ConnMgrConfig.java new file mode 100644 index 000000000..23b067a5d --- /dev/null +++ b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/ConnMgrConfig.java @@ -0,0 +1,142 @@ +/* + * 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.apache5x; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLContext; + +import java.util.Arrays; +import java.util.Objects; + +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.core5.http.io.SocketConfig; +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.repository.AuthenticationContext; +import org.eclipse.aether.util.ConfigUtils; + +/** + * Connection manager config: among other SSL-related configuration and cache key for connection pools (whose scheme + * registries are derived from this config). + */ +final class ConnMgrConfig { + + /** + * Comma-separated list of Cipher Suites which are enabled for HTTPS connections. + */ + private static final String CIPHER_SUITES = "https.cipherSuites"; + + /** + * Comma-separated list of Protocols which are enabled for HTTPS connections. + */ + private static final String PROTOCOLS = "https.protocols"; + + final SSLContext context; + + final HostnameVerifier verifier; + + final String[] cipherSuites; + + final String[] protocols; + + final String httpsSecurityMode; + + final int maxConnectionsPerRoute; + + final SocketConfig socketConfig; + + final ConnectionConfig connectionConfig; + + ConnMgrConfig( + RepositorySystemSession session, + AuthenticationContext authContext, + String httpsSecurityMode, + int maxConnectionsPerRoute, + SocketConfig socketConfig, + ConnectionConfig connectionConfig) { + context = (authContext != null) ? authContext.get(AuthenticationContext.SSL_CONTEXT, SSLContext.class) : null; + verifier = (authContext != null) + ? authContext.get(AuthenticationContext.SSL_HOSTNAME_VERIFIER, HostnameVerifier.class) + : null; + + cipherSuites = split(getCipherSuites(session)); + protocols = split(getProtocols(session)); + this.httpsSecurityMode = httpsSecurityMode; + this.maxConnectionsPerRoute = maxConnectionsPerRoute; + this.socketConfig = socketConfig; + this.connectionConfig = connectionConfig; + } + + private static String getCipherSuites(RepositorySystemSession session) { + String value = ConfigUtils.getString( + session, null, ApacheTransporterConfigurationKeys.CONFIG_PROP_CIPHER_SUITES, CIPHER_SUITES); + if (value == null) { + value = System.getProperty(CIPHER_SUITES); + } + return value; + } + + private static String getProtocols(RepositorySystemSession session) { + String value = ConfigUtils.getString( + session, null, ApacheTransporterConfigurationKeys.CONFIG_PROP_PROTOCOLS, PROTOCOLS); + if (value == null) { + value = System.getProperty(PROTOCOLS); + } + return value; + } + + private static String[] split(String value) { + if (value == null || value.isEmpty()) { + return null; + } + return value.split(",+"); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || !getClass().equals(obj.getClass())) { + return false; + } + ConnMgrConfig that = (ConnMgrConfig) obj; + return Objects.equals(context, that.context) + && Objects.equals(verifier, that.verifier) + && Arrays.equals(cipherSuites, that.cipherSuites) + && Arrays.equals(protocols, that.protocols) + && Objects.equals(httpsSecurityMode, that.httpsSecurityMode) + && maxConnectionsPerRoute == that.maxConnectionsPerRoute; + } + + @Override + public int hashCode() { + int hash = 17; + hash = hash * 31 + hash(context); + hash = hash * 31 + hash(verifier); + hash = hash * 31 + Arrays.hashCode(cipherSuites); + hash = hash * 31 + Arrays.hashCode(protocols); + hash = hash * 31 + hash(httpsSecurityMode); + hash = hash * 31 + hash(maxConnectionsPerRoute); + return hash; + } + + private static int hash(Object obj) { + return obj != null ? obj.hashCode() : 0; + } +} diff --git a/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/DeferredCredentialsProvider.java b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/DeferredCredentialsProvider.java new file mode 100644 index 000000000..ea51e28ae --- /dev/null +++ b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/DeferredCredentialsProvider.java @@ -0,0 +1,100 @@ +/* + * 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.apache5x; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.Credentials; +import org.apache.hc.client5.http.auth.CredentialsStore; +import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; +import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.eclipse.aether.repository.AuthenticationContext; + +/** + * Credentials provider that defers calls into the auth context until authentication is actually requested. + */ +final class DeferredCredentialsProvider implements CredentialsStore { + + private final CredentialsStore delegate; + + private final Map factories; + + DeferredCredentialsProvider() { + delegate = new BasicCredentialsProvider(); + factories = new HashMap<>(); + } + + public void setCredentials(AuthScope authScope, Factory factory) { + factories.put(authScope, factory); + } + + @Override + public void setCredentials(AuthScope authScope, Credentials credentials) { + delegate.setCredentials(authScope, credentials); + } + + @Override + public Credentials getCredentials(AuthScope authScope, HttpContext context) { + synchronized (factories) { + for (Iterator> it = + factories.entrySet().iterator(); + it.hasNext(); ) { + Map.Entry entry = it.next(); + if (authScope.match(entry.getKey()) >= 0) { + it.remove(); + delegate.setCredentials(entry.getKey(), entry.getValue().newCredentials()); + } + } + } + return delegate.getCredentials(authScope, context); + } + + @Override + public void clear() { + delegate.clear(); + } + + interface Factory { + + Credentials newCredentials(); + } + + static class BasicFactory implements Factory { + + private final AuthenticationContext authContext; + + BasicFactory(AuthenticationContext authContext) { + this.authContext = authContext; + } + + @Override + public Credentials newCredentials() { + String username = authContext.get(AuthenticationContext.USERNAME); + if (username == null) { + return null; + } + String password = authContext.get(AuthenticationContext.PASSWORD); + return new UsernamePasswordCredentials(username, password.toCharArray()); + } + } +} diff --git a/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/DemuxCredentialsProvider.java b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/DemuxCredentialsProvider.java new file mode 100644 index 000000000..97d9aa909 --- /dev/null +++ b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/DemuxCredentialsProvider.java @@ -0,0 +1,70 @@ +/* + * 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.apache5x; + +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.Credentials; +import org.apache.hc.client5.http.auth.CredentialsStore; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.protocol.HttpContext; + +/** + * Credentials provider that helps to isolate server from proxy credentials. Apache HttpClient uses a single provider + * for both server and proxy auth, using the auth scope (host, port, etc.) to select the proper credentials. With regard + * to redirects, we use an auth scope for server credentials that's not specific enough to not be mistaken for proxy + * auth. This provider helps to maintain the proper isolation. + */ +final class DemuxCredentialsProvider implements CredentialsStore { + + private final CredentialsStore serverCredentialsProvider; + + private final CredentialsStore proxyCredentialsProvider; + + private final HttpHost proxy; + + DemuxCredentialsProvider( + CredentialsStore serverCredentialsProvider, CredentialsStore proxyCredentialsProvider, HttpHost proxy) { + this.serverCredentialsProvider = serverCredentialsProvider; + this.proxyCredentialsProvider = proxyCredentialsProvider; + this.proxy = proxy; + } + + private CredentialsStore getDelegate(AuthScope authScope) { + if (proxy.getPort() == authScope.getPort() && proxy.getHostName().equalsIgnoreCase(authScope.getHost())) { + return proxyCredentialsProvider; + } + return serverCredentialsProvider; + } + + @Override + public Credentials getCredentials(AuthScope authScope, HttpContext context) { + return getDelegate(authScope).getCredentials(authScope, context); + } + + @Override + public void setCredentials(AuthScope authScope, Credentials credentials) { + getDelegate(authScope).setCredentials(authScope, credentials); + } + + @Override + public void clear() { + serverCredentialsProvider.clear(); + proxyCredentialsProvider.clear(); + } +} diff --git a/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/GlobalState.java b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/GlobalState.java new file mode 100644 index 000000000..3b3833655 --- /dev/null +++ b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/GlobalState.java @@ -0,0 +1,229 @@ +/* + * 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.apache5x; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLSocketFactory; + +import java.io.Closeable; +import java.util.Arrays; +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.impl.DefaultSchemePortResolver; +import org.apache.hc.client5.http.impl.io.ManagedHttpClientConnectionFactory; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.client5.http.ssl.HttpsSupport; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.io.SocketConfig; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.ssl.SSLContextBuilder; +import org.apache.hc.core5.ssl.SSLInitializationException; +import org.eclipse.aether.ConfigurationProperties; +import org.eclipse.aether.RepositoryCache; +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.util.ConfigUtils; + +/** + * Container for HTTP-related state that can be shared across incarnations of the transporter to optimize the + * communication with servers. + */ +final class GlobalState implements Closeable { + + static class CompoundKey { + + private final Object[] keys; + + CompoundKey(Object... keys) { + this.keys = keys; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || !getClass().equals(obj.getClass())) { + return false; + } + CompoundKey that = (CompoundKey) obj; + return Arrays.equals(keys, that.keys); + } + + @Override + public int hashCode() { + int hash = 17; + hash = hash * 31 + Arrays.hashCode(keys); + return hash; + } + + @Override + public String toString() { + return Arrays.toString(keys); + } + } + + private static final String KEY = GlobalState.class.getName(); + + private static final String CONFIG_PROP_CACHE_STATE = + ApacheTransporterConfigurationKeys.CONFIG_PROPS_PREFIX + "cacheState"; + + private final ConcurrentMap connectionManagers; + + private final ConcurrentMap userTokens; + + private final ConcurrentMap authSchemePools; + + private final ConcurrentMap expectContinues; + + public static GlobalState get(RepositorySystemSession session) { + GlobalState cache; + RepositoryCache repoCache = session.getCache(); + if (repoCache == null || !ConfigUtils.getBoolean(session, true, CONFIG_PROP_CACHE_STATE)) { + cache = null; + } else { + Object tmp = repoCache.get(session, KEY); + if (tmp instanceof GlobalState) { + cache = (GlobalState) tmp; + } else { + synchronized (GlobalState.class) { + tmp = repoCache.get(session, KEY); + if (tmp instanceof GlobalState) { + cache = (GlobalState) tmp; + } else { + cache = new GlobalState(); + repoCache.put(session, KEY, cache); + } + } + } + } + return cache; + } + + private GlobalState() { + connectionManagers = new ConcurrentHashMap<>(); + userTokens = new ConcurrentHashMap<>(); + authSchemePools = new ConcurrentHashMap<>(); + expectContinues = new ConcurrentHashMap<>(); + } + + @Override + public void close() { + for (Iterator> it = + connectionManagers.entrySet().iterator(); + it.hasNext(); ) { + HttpClientConnectionManager connMgr = it.next().getValue(); + it.remove(); + connMgr.close(CloseMode.GRACEFUL); + } + } + + public HttpClientConnectionManager getConnectionManager(ConnMgrConfig config) { + return connectionManagers.computeIfAbsent(config, GlobalState::newConnectionManager); + } + + public static HttpClientConnectionManager newConnectionManager(ConnMgrConfig connMgrConfig) { + int maxConnectionsPerRoute = ConfigurationProperties.DEFAULT_HTTP_MAX_CONNECTIONS_PER_ROUTE; + SocketConfig socketConfig = null; + ConnectionConfig connectionConfig = null; + SSLConnectionSocketFactory sslConnectionSocketFactory = null; + if (connMgrConfig == null) { + sslConnectionSocketFactory = SSLConnectionSocketFactory.getSystemSocketFactory(); + } else { + // config present: use provided, if any, or create (depending on httpsSecurityMode) + maxConnectionsPerRoute = connMgrConfig.maxConnectionsPerRoute; + socketConfig = connMgrConfig.socketConfig; + connectionConfig = connMgrConfig.connectionConfig; + SSLSocketFactory sslSocketFactory = + connMgrConfig.context != null ? connMgrConfig.context.getSocketFactory() : null; + HostnameVerifier hostnameVerifier = connMgrConfig.verifier; + if (ConfigurationProperties.HTTPS_SECURITY_MODE_DEFAULT.equals(connMgrConfig.httpsSecurityMode)) { + if (sslSocketFactory == null) { + sslSocketFactory = (SSLSocketFactory) SSLSocketFactory.getDefault(); + } + if (hostnameVerifier == null) { + hostnameVerifier = HttpsSupport.getDefaultHostnameVerifier(); + } + } else if (ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE.equals(connMgrConfig.httpsSecurityMode)) { + if (sslSocketFactory == null) { + try { + sslSocketFactory = new SSLContextBuilder() + .loadTrustMaterial(null, (chain, auth) -> true) + .build() + .getSocketFactory(); + } catch (Exception e) { + throw new SSLInitializationException( + "Could not configure '" + connMgrConfig.httpsSecurityMode + "' HTTPS security mode", e); + } + } + if (hostnameVerifier == null) { + hostnameVerifier = NoopHostnameVerifier.INSTANCE; + } + } else { + throw new IllegalArgumentException( + "Unsupported '" + connMgrConfig.httpsSecurityMode + "' HTTPS security mode."); + } + + sslConnectionSocketFactory = new SSLConnectionSocketFactory( + sslSocketFactory, connMgrConfig.protocols, connMgrConfig.cipherSuites, hostnameVerifier); + } + + return PoolingHttpClientConnectionManagerBuilder.create() + .setDnsResolver(SystemDefaultDnsResolver.INSTANCE) + .setSchemePortResolver(DefaultSchemePortResolver.INSTANCE) + .setConnectionFactory(ManagedHttpClientConnectionFactory.INSTANCE) + .setSSLSocketFactory(sslConnectionSocketFactory) + .setDefaultSocketConfig(socketConfig != null ? socketConfig : SocketConfig.DEFAULT) + .setDefaultConnectionConfig(connectionConfig != null ? connectionConfig : ConnectionConfig.DEFAULT) + .setMaxConnTotal(maxConnectionsPerRoute * 2) + .setMaxConnPerRoute(maxConnectionsPerRoute) + .build(); + } + + public Object getUserToken(CompoundKey key) { + return userTokens.get(key); + } + + public void setUserToken(CompoundKey key, Object userToken) { + if (userToken != null) { + userTokens.put(key, userToken); + } else { + userTokens.remove(key); + } + } + + public ConcurrentMap getAuthSchemePools() { + return authSchemePools; + } + + public Boolean getExpectContinue(CompoundKey key) { + return expectContinues.get(key); + } + + public void setExpectContinue(CompoundKey key, boolean enabled) { + expectContinues.put(key, enabled); + } +} diff --git a/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/HttpMkCol.java b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/HttpMkCol.java new file mode 100644 index 000000000..2fe5283c5 --- /dev/null +++ b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/HttpMkCol.java @@ -0,0 +1,32 @@ +/* + * 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.apache5x; + +import java.net.URI; + +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; + +/** + * WebDAV MKCOL request to create parent directories. + */ +final class HttpMkCol extends HttpUriRequestBase { + HttpMkCol(URI uri) { + super("MKCOL", uri); + } +} diff --git a/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/LocalState.java b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/LocalState.java new file mode 100644 index 000000000..078a55fb9 --- /dev/null +++ b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/LocalState.java @@ -0,0 +1,137 @@ +/* + * 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.apache5x; + +import java.io.Closeable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.apache.hc.client5.http.auth.AuthScheme; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.io.CloseMode; +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.repository.RemoteRepository; + +/** + * Container for HTTP-related state that can be shared across invocations of the transporter to optimize the + * communication with server. + */ +final class LocalState implements Closeable { + private final GlobalState global; + + private final HttpClientConnectionManager connMgr; + + private final GlobalState.CompoundKey userTokenKey; + + private volatile Object userToken; + + private final GlobalState.CompoundKey expectContinueKey; + + private volatile Boolean expectContinue; + + private volatile Boolean webDav; + + private final ConcurrentMap authSchemePools; + + LocalState(RepositorySystemSession session, RemoteRepository repo, ConnMgrConfig connMgrConfig) { + global = GlobalState.get(session); + userToken = this; + if (global == null) { + connMgr = GlobalState.newConnectionManager(connMgrConfig); + userTokenKey = null; + expectContinueKey = null; + authSchemePools = new ConcurrentHashMap<>(); + } else { + connMgr = global.getConnectionManager(connMgrConfig); + userTokenKey = + new GlobalState.CompoundKey(repo.getId(), repo.getUrl(), repo.getAuthentication(), repo.getProxy()); + expectContinueKey = new GlobalState.CompoundKey(repo.getUrl(), repo.getProxy()); + authSchemePools = global.getAuthSchemePools(); + } + } + + public HttpClientConnectionManager getConnectionManager() { + return connMgr; + } + + public Object getUserToken() { + if (userToken == this) { + userToken = (global != null) ? global.getUserToken(userTokenKey) : null; + } + return userToken; + } + + public void setUserToken(Object userToken) { + this.userToken = userToken; + if (global != null) { + global.setUserToken(userTokenKey, userToken); + } + } + + public boolean isExpectContinue() { + if (expectContinue == null) { + expectContinue = + !Boolean.FALSE.equals((global != null) ? global.getExpectContinue(expectContinueKey) : null); + } + return expectContinue; + } + + public void setExpectContinue(boolean enabled) { + expectContinue = enabled; + if (global != null) { + global.setExpectContinue(expectContinueKey, enabled); + } + } + + public Boolean getWebDav() { + return webDav; + } + + public void setWebDav(boolean webDav) { + this.webDav = webDav; + } + + public AuthScheme getAuthScheme(HttpHost host) { + AuthSchemePool pool = authSchemePools.get(host); + if (pool != null) { + return pool.get(); + } + return null; + } + + public void setAuthScheme(HttpHost host, AuthScheme authScheme) { + AuthSchemePool pool = authSchemePools.get(host); + if (pool == null) { + AuthSchemePool p = new AuthSchemePool(); + pool = authSchemePools.putIfAbsent(host, p); + if (pool == null) { + pool = p; + } + } + pool.put(authScheme); + } + + @Override + public void close() { + if (global == null) { + connMgr.close(CloseMode.GRACEFUL); + } + } +} diff --git a/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/SharingAuthCache.java b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/SharingAuthCache.java new file mode 100644 index 000000000..5a191fde7 --- /dev/null +++ b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/SharingAuthCache.java @@ -0,0 +1,92 @@ +/* + * 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.apache5x; + +import java.util.HashMap; +import java.util.Map; + +import org.apache.hc.client5.http.auth.AuthCache; +import org.apache.hc.client5.http.auth.AuthScheme; +import org.apache.hc.core5.http.HttpHost; + +/** + * Auth scheme cache that upon clearing releases all cached schemes into a pool for future reuse by other requests, + * thereby reducing challenge-response roundtrips. + */ +final class SharingAuthCache implements AuthCache { + + private final LocalState state; + + private final Map authSchemes; + + SharingAuthCache(LocalState state) { + this.state = state; + authSchemes = new HashMap<>(); + } + + private static HttpHost toKey(HttpHost host) { + if (host.getPort() <= 0) { + int port = host.getSchemeName().equalsIgnoreCase("https") ? 443 : 80; + return new HttpHost(host.getSchemeName(), host.getHostName(), port); + } + return host; + } + + @Override + public AuthScheme get(HttpHost host) { + host = toKey(host); + AuthScheme authScheme = authSchemes.get(host); + if (authScheme == null) { + authScheme = state.getAuthScheme(host); + authSchemes.put(host, authScheme); + } + return authScheme; + } + + @Override + public void put(HttpHost host, AuthScheme authScheme) { + if (authScheme != null) { + authSchemes.put(toKey(host), authScheme); + } else { + remove(host); + } + } + + @Override + public void remove(HttpHost host) { + authSchemes.remove(toKey(host)); + } + + @Override + public void clear() { + share(); + authSchemes.clear(); + } + + private void share() { + for (Map.Entry entry : authSchemes.entrySet()) { + state.setAuthScheme(entry.getKey(), entry.getValue()); + } + } + + @Override + public String toString() { + return authSchemes.toString(); + } +} diff --git a/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/SharingHttpContext.java b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/SharingHttpContext.java new file mode 100644 index 000000000..07f9f8e15 --- /dev/null +++ b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/SharingHttpContext.java @@ -0,0 +1,57 @@ +/* + * 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.apache5x; + +import java.io.Closeable; + +import org.apache.hc.client5.http.protocol.HttpClientContext; + +/** + * HTTP context that shares certain attributes among requests to optimize the communication with the server. + * + * @see Stateful HTTP + * connections + */ +final class SharingHttpContext extends HttpClientContext implements Closeable { + + private final LocalState state; + + private final SharingAuthCache authCache; + + SharingHttpContext(LocalState state) { + this.state = state; + authCache = new SharingAuthCache(state); + super.setAuthCache(authCache); + } + + @Override + public Object getUserToken() { + return state.getUserToken(); + } + + @Override + public void setUserToken(Object userToken) { + state.setUserToken(userToken); + } + + @Override + public void close() { + authCache.clear(); + } +} diff --git a/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/UriUtils.java b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/UriUtils.java new file mode 100644 index 000000000..deb4a3220 --- /dev/null +++ b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/UriUtils.java @@ -0,0 +1,69 @@ +/* + * 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.apache5x; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.hc.client5.http.utils.URIUtils; + +/** + * Helps to deal with URIs. + */ +final class UriUtils { + + public static URI resolve(URI base, URI ref) { + String path = ref.getRawPath(); + if (path != null && !path.isEmpty()) { + path = base.getRawPath(); + if (path == null || !path.endsWith("/")) { + try { + base = new URI(base.getScheme(), base.getAuthority(), base.getPath() + '/', null, null); + } catch (URISyntaxException e) { + throw new IllegalStateException(e); + } + } + } + return URIUtils.resolve(base, ref); + } + + public static List getDirectories(URI base, URI uri) { + List dirs = new ArrayList<>(); + for (URI dir = uri.resolve("."); !isBase(base, dir); dir = dir.resolve("..")) { + dirs.add(dir); + } + return dirs; + } + + private static boolean isBase(URI base, URI uri) { + String path = uri.getRawPath(); + if (path == null || "/".equals(path)) { + return true; + } + if (base != null) { + URI rel = base.relativize(uri); + if (rel.getRawPath() == null || rel.getRawPath().isEmpty() || rel.equals(uri)) { + return true; + } + } + return false; + } +} diff --git a/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/package-info.java b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/package-info.java new file mode 100644 index 000000000..7e2b55f1d --- /dev/null +++ b/maven-resolver-transport-apache5x/src/main/java/org/eclipse/aether/transport/apache5x/package-info.java @@ -0,0 +1,24 @@ +// 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 current implementation is backed by + * Apache HttpClient 5.5.x. + */ +package org.eclipse.aether.transport.apache5x; diff --git a/maven-resolver-transport-apache5x/src/site/site.xml b/maven-resolver-transport-apache5x/src/site/site.xml new file mode 100644 index 000000000..a75986c32 --- /dev/null +++ b/maven-resolver-transport-apache5x/src/site/site.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/maven-resolver-transport-apache5x/src/test/java/org/eclipse/aether/transport/apache5x/ApacheTransporterTest.java b/maven-resolver-transport-apache5x/src/test/java/org/eclipse/aether/transport/apache5x/ApacheTransporterTest.java new file mode 100644 index 000000000..f6ecc4a50 --- /dev/null +++ b/maven-resolver-transport-apache5x/src/test/java/org/eclipse/aether/transport/apache5x/ApacheTransporterTest.java @@ -0,0 +1,132 @@ +/* + * 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.apache5x; + +import java.io.File; +import java.net.URI; +import java.nio.charset.StandardCharsets; + +import org.apache.hc.core5.pool.ConnPoolControl; +import org.apache.hc.core5.pool.PoolStats; +import org.eclipse.aether.ConfigurationProperties; +import org.eclipse.aether.DefaultRepositoryCache; +import org.eclipse.aether.internal.test.util.TestFileUtils; +import org.eclipse.aether.internal.test.util.TestPathProcessor; +import org.eclipse.aether.internal.test.util.http.HttpTransporterTest; +import org.eclipse.aether.internal.test.util.http.RecordingTransportListener; +import org.eclipse.aether.spi.connector.transport.GetTask; +import org.eclipse.aether.spi.connector.transport.PutTask; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Apache Transporter UT. + * It does support WebDAV. + */ +class ApacheTransporterTest extends HttpTransporterTest { + + public ApacheTransporterTest() { + super(() -> new ApacheTransporterFactory(standardChecksumExtractor(), new TestPathProcessor())); + } + + @Override + @Disabled + @Test + protected void testGet_HTTPS_HTTP2Only_Insecure_SecurityMode() throws Exception {} + + @Test + void testGet_WebDav() throws Exception { + httpServer.setWebDav(true); + RecordingTransportListener listener = new RecordingTransportListener(); + GetTask task = new GetTask(URI.create("repo/dir/file.txt")).setListener(listener); + ((ApacheTransporter) transporter).getState().setWebDav(true); + transporter.get(task); + assertEquals("test", task.getDataString()); + assertEquals(0L, listener.getDataOffset()); + assertEquals(4L, listener.getDataLength()); + assertEquals(1, listener.getStartedCount()); + assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount()); + assertEquals(task.getDataString(), new String(listener.getBaos().toByteArray(), StandardCharsets.UTF_8)); + assertEquals( + 1, httpServer.getLogEntries().size(), httpServer.getLogEntries().toString()); + } + + @Test + void testPut_WebDav() throws Exception { + httpServer.setWebDav(true); + session.setConfigProperty(ConfigurationProperties.HTTP_SUPPORT_WEBDAV, true); + newTransporter(httpServer.getHttpUrl()); + + RecordingTransportListener listener = new RecordingTransportListener(); + PutTask task = new PutTask(URI.create("repo/dir1/dir2/file.txt")) + .setListener(listener) + .setDataString("upload"); + transporter.put(task); + assertEquals(0L, listener.getDataOffset()); + assertEquals(6L, listener.getDataLength()); + assertEquals(1, listener.getStartedCount()); + assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount()); + assertEquals("upload", TestFileUtils.readString(new File(repoDir, "dir1/dir2/file.txt"))); + + assertEquals(5, httpServer.getLogEntries().size()); + assertEquals("OPTIONS", httpServer.getLogEntries().get(0).getMethod()); + assertEquals("MKCOL", httpServer.getLogEntries().get(1).getMethod()); + assertEquals("/repo/dir1/dir2/", httpServer.getLogEntries().get(1).getPath()); + assertEquals("MKCOL", httpServer.getLogEntries().get(2).getMethod()); + assertEquals("/repo/dir1/", httpServer.getLogEntries().get(2).getPath()); + assertEquals("MKCOL", httpServer.getLogEntries().get(3).getMethod()); + assertEquals("/repo/dir1/dir2/", httpServer.getLogEntries().get(3).getPath()); + assertEquals("PUT", httpServer.getLogEntries().get(4).getMethod()); + } + + @Test + void testConnectionReuse() throws Exception { + httpServer.addSslConnector(); + session.setCache(new DefaultRepositoryCache()); + for (int i = 0; i < 3; i++) { + newTransporter(httpServer.getHttpsUrl()); + GetTask task = new GetTask(URI.create("repo/file.txt")); + transporter.get(task); + assertEquals("test", task.getDataString()); + } + PoolStats stats = ((ConnPoolControl) + ((ApacheTransporter) transporter).getState().getConnectionManager()) + .getTotalStats(); + assertEquals(1, stats.getAvailable(), stats.toString()); + } + + @Test + void testConnectionNoReuse() throws Exception { + httpServer.addSslConnector(); + session.setCache(new DefaultRepositoryCache()); + session.setConfigProperty(ConfigurationProperties.HTTP_REUSE_CONNECTIONS, false); + for (int i = 0; i < 3; i++) { + newTransporter(httpServer.getHttpsUrl()); + GetTask task = new GetTask(URI.create("repo/file.txt")); + transporter.get(task); + assertEquals("test", task.getDataString()); + } + PoolStats stats = ((ConnPoolControl) + ((ApacheTransporter) transporter).getState().getConnectionManager()) + .getTotalStats(); + assertEquals(0, stats.getAvailable(), stats.toString()); + } +} diff --git a/maven-resolver-transport-apache5x/src/test/java/org/eclipse/aether/transport/apache5x/UriUtilsTest.java b/maven-resolver-transport-apache5x/src/test/java/org/eclipse/aether/transport/apache5x/UriUtilsTest.java new file mode 100644 index 000000000..9ee5a2731 --- /dev/null +++ b/maven-resolver-transport-apache5x/src/test/java/org/eclipse/aether/transport/apache5x/UriUtilsTest.java @@ -0,0 +1,123 @@ +/* + * 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.apache5x; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class UriUtilsTest { + + private String resolve(URI base, String ref) { + return UriUtils.resolve(base, URI.create(ref)).toString(); + } + + @Test + void testResolve_BaseEmptyPath() { + URI base = URI.create("http://host"); + assertEquals("http://host/file.jar", resolve(base, "file.jar")); + assertEquals("http://host/dir/file.jar", resolve(base, "dir/file.jar")); + assertEquals("http://host?arg=val", resolve(base, "?arg=val")); + assertEquals("http://host/file?arg=val", resolve(base, "file?arg=val")); + assertEquals("http://host/dir/file?arg=val", resolve(base, "dir/file?arg=val")); + } + + @Test + void testResolve_BaseRootPath() { + URI base = URI.create("http://host/"); + assertEquals("http://host/file.jar", resolve(base, "file.jar")); + assertEquals("http://host/dir/file.jar", resolve(base, "dir/file.jar")); + assertEquals("http://host/?arg=val", resolve(base, "?arg=val")); + assertEquals("http://host/file?arg=val", resolve(base, "file?arg=val")); + assertEquals("http://host/dir/file?arg=val", resolve(base, "dir/file?arg=val")); + } + + @Test + void testResolve_BasePathTrailingSlash() { + URI base = URI.create("http://host/sub/dir/"); + assertEquals("http://host/sub/dir/file.jar", resolve(base, "file.jar")); + assertEquals("http://host/sub/dir/dir/file.jar", resolve(base, "dir/file.jar")); + assertEquals("http://host/sub/dir/?arg=val", resolve(base, "?arg=val")); + assertEquals("http://host/sub/dir/file?arg=val", resolve(base, "file?arg=val")); + assertEquals("http://host/sub/dir/dir/file?arg=val", resolve(base, "dir/file?arg=val")); + } + + @Test + void testResolve_BasePathNoTrailingSlash() { + URI base = URI.create("http://host/sub/d%20r"); + assertEquals("http://host/sub/d%20r/file.jar", resolve(base, "file.jar")); + assertEquals("http://host/sub/d%20r/dir/file.jar", resolve(base, "dir/file.jar")); + assertEquals("http://host/sub/d%20r?arg=val", resolve(base, "?arg=val")); + assertEquals("http://host/sub/d%20r/file?arg=val", resolve(base, "file?arg=val")); + assertEquals("http://host/sub/d%20r/dir/file?arg=val", resolve(base, "dir/file?arg=val")); + } + + private List getDirs(String base, String uri) { + return UriUtils.getDirectories((base != null) ? URI.create(base) : null, URI.create(uri)); + } + + private void assertUris(List actual, String... expected) { + List uris = new ArrayList<>(actual.size()); + for (URI uri : actual) { + uris.add(uri.toString()); + } + assertEquals(Arrays.asList(expected), uris); + } + + @Test + void testGetDirectories_NoBase() { + List parents = getDirs(null, "http://host/repo/sub/dir/file.jar"); + assertUris(parents, "http://host/repo/sub/dir/", "http://host/repo/sub/", "http://host/repo/"); + + parents = getDirs(null, "http://host/repo/sub/dir/?file.jar"); + assertUris(parents, "http://host/repo/sub/dir/", "http://host/repo/sub/", "http://host/repo/"); + + parents = getDirs(null, "http://host/"); + assertUris(parents); + } + + @Test + void testGetDirectories_ExplicitBaseTrailingSlash() { + List parents = getDirs("http://host/repo/", "http://host/repo/sub/dir/file.jar"); + assertUris(parents, "http://host/repo/sub/dir/", "http://host/repo/sub/"); + + parents = getDirs("http://host/repo/", "http://host/repo/sub/dir/?file.jar"); + assertUris(parents, "http://host/repo/sub/dir/", "http://host/repo/sub/"); + + parents = getDirs("http://host/repo/", "http://host/"); + assertUris(parents); + } + + @Test + void testGetDirectories_ExplicitBaseNoTrailingSlash() { + List parents = getDirs("http://host/repo", "http://host/repo/sub/dir/file.jar"); + assertUris(parents, "http://host/repo/sub/dir/", "http://host/repo/sub/"); + + parents = getDirs("http://host/repo", "http://host/repo/sub/dir/?file.jar"); + assertUris(parents, "http://host/repo/sub/dir/", "http://host/repo/sub/"); + + parents = getDirs("http://host/repo", "http://host/"); + assertUris(parents); + } +} diff --git a/pom.xml b/pom.xml index e8f9845bd..f2b36a7a7 100644 --- a/pom.xml +++ b/pom.xml @@ -61,6 +61,7 @@ maven-resolver-transport-jetty maven-resolver-transport-jdk-parent maven-resolver-transport-apache + maven-resolver-transport-apache5x maven-resolver-transport-wagon maven-resolver-transport-minio maven-resolver-generator-gnupg @@ -191,6 +192,11 @@ maven-resolver-transport-apache ${project.version} + + org.apache.maven.resolver + maven-resolver-transport-apache5x + ${project.version} + org.apache.maven.resolver maven-resolver-transport-wagon diff --git a/src/site/markdown/configuration.md b/src/site/markdown/configuration.md index 387e0c48e..1dbda89be 100644 --- a/src/site/markdown/configuration.md +++ b/src/site/markdown/configuration.md @@ -120,6 +120,13 @@ To modify this file, edit the template and regenerate. | `"aether.transport.apache.retryHandler.name"` | `String` | The name of retryHandler, supported values are “standard”, that obeys RFC-2616, regarding idempotent methods, and “default” that considers requests w/o payload as idempotent. | `"standard"` | 2.0.0 | Yes | Session Configuration | | `"aether.transport.apache.retryHandler.requestSentEnabled"` | `Boolean` | Set to true if it is acceptable to retry non-idempotent requests, that have been sent. | `false` | 2.0.0 | Yes | Session Configuration | | `"aether.transport.apache.useSystemProperties"` | `Boolean` | If enabled, underlying Apache HttpClient will use system properties as well to configure itself (typically used to set up HTTP Proxy via Java system properties). See HttpClientBuilder for used properties. This mode is not recommended, better use documented ways of configuration instead. | `false` | 2.0.0 | Yes | Session Configuration | +| `"aether.transport.apache5x.followRedirects"` | `Boolean` | If enabled, Apache HttpClient will follow HTTP redirects. | `true` | 2.0.10 | Yes | Session Configuration | +| `"aether.transport.apache5x.https.cipherSuites"` | `String` | Comma-separated list of Cipher Suites which are enabled for HTTPS connections. | - | 2.0.10 | No | Session Configuration | +| `"aether.transport.apache5x.https.protocols"` | `String` | Comma-separated list of Protocols which are enabled for HTTPS connections. | - | 2.0.10 | No | Session Configuration | +| `"aether.transport.apache5x.maxRedirects"` | `Integer` | The max redirect count to follow. | `5` | 2.0.10 | Yes | Session Configuration | +| `"aether.transport.apache5x.retryHandler.name"` | `String` | The name of retryHandler, supported values are “standard”, that obeys RFC-2616, regarding idempotent methods, and “default” that considers requests w/o payload as idempotent. | `"standard"` | 2.0.10 | Yes | Session Configuration | +| `"aether.transport.apache5x.retryHandler.requestSentEnabled"` | `Boolean` | Set to true if it is acceptable to retry non-idempotent requests, that have been sent. | `false` | 2.0.10 | Yes | Session Configuration | +| `"aether.transport.apache5x.useSystemProperties"` | `Boolean` | If enabled, underlying Apache HttpClient will use system properties as well to configure itself (typically used to set up HTTP Proxy via Java system properties). See HttpClientBuilder for used properties. This mode is not recommended, better use documented ways of configuration instead. | `false` | 2.0.10 | Yes | Session Configuration | | `"aether.transport.classpath.loader"` | `ClassLoader` | The key in the repository session's RepositorySystemSession#getConfigProperties() configurationproperties used to store a ClassLoader from which resources should be retrieved. If unspecified, the Thread#getContextClassLoader() context class loader of the current thread will be used. | - | | No | Session Configuration | | `"aether.transport.http.connectTimeout"` | `Integer` | The maximum amount of time (in milliseconds) to wait for a successful connection to a remote server. Non-positive values indicate no timeout. | `30000` | | Yes | Session Configuration | | `"aether.transport.http.connectionMaxTtl"` | `Integer` | Total time to live in seconds for an HTTP connection, after that time, the connection will be dropped (no matter for how long it was idle). | `300` | 1.9.8 | Yes | Session Configuration |