001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *   http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing,
013 * software distributed under the License is distributed on an
014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015 * KIND, either express or implied.  See the License for the
016 * specific language governing permissions and limitations
017 * under the License.
018 */
019package org.eclipse.aether.transport.url;
020
021import java.io.IOException;
022import java.io.InputStream;
023import java.net.HttpURLConnection;
024import java.net.InetSocketAddress;
025import java.net.Proxy;
026import java.net.URI;
027import java.net.URISyntaxException;
028import java.nio.charset.Charset;
029import java.nio.file.Path;
030import java.util.ArrayList;
031import java.util.Base64;
032import java.util.Collections;
033import java.util.Map;
034import java.util.function.Consumer;
035import java.util.function.Function;
036import java.util.zip.GZIPInputStream;
037import java.util.zip.InflaterInputStream;
038
039import org.eclipse.aether.Keys;
040import org.eclipse.aether.RepositorySystemSession;
041import org.eclipse.aether.repository.AuthenticationContext;
042import org.eclipse.aether.repository.RemoteRepository;
043import org.eclipse.aether.spi.connector.transport.AbstractTransporter;
044import org.eclipse.aether.spi.connector.transport.GetTask;
045import org.eclipse.aether.spi.connector.transport.PeekTask;
046import org.eclipse.aether.spi.connector.transport.PutTask;
047import org.eclipse.aether.spi.connector.transport.http.ChecksumExtractor;
048import org.eclipse.aether.spi.connector.transport.http.HttpConstants;
049import org.eclipse.aether.spi.connector.transport.http.HttpTransporter;
050import org.eclipse.aether.spi.connector.transport.http.HttpTransporterException;
051import org.eclipse.aether.spi.io.PathProcessor;
052import org.eclipse.aether.transfer.NoTransporterException;
053import org.eclipse.aether.util.ConfigUtils;
054import org.eclipse.aether.util.connector.transport.http.HttpTransporterUtils;
055
056/**
057 * A special, "read only" and limited capability transport usable for bootstrapping. It provides HTTP with minimal
058 * support (only basic auth, only GET/HEAD). It is implemented using {@link java.net.HttpURLConnection} class.
059 *
060 * @since 2.0.21
061 */
062public class UrlTransporter extends AbstractTransporter implements HttpTransporter {
063
064    private static final String METHOD_GET = "GET";
065    private static final String METHOD_HEAD = "HEAD";
066    private static final String HEADER_LOCATION = "Location";
067    private static final String HEADER_AUTHORIZATION = "Authorization";
068    private static final String HEADER_PROXY_AUTHORIZATION = "Proxy-Authorization";
069    private static final String AUTH_SCHEME_BASIC = "Basic";
070    private static final int HTTP_STATUS_TEMPORARY_REDIRECT = 307;
071    private static final int HTTP_STATUS_PERMANENT_REDIRECT = 308;
072
073    private enum RedirectMode {
074        /**
075         * No redirects allowed.
076         */
077        NONE,
078        /**
079         * Redirects only within same authority.
080         */
081        SAME_AUTHORITY,
082        /**
083         * Any redirect is followed.
084         */
085        ANY
086    }
087
088    private final ChecksumExtractor checksumExtractor;
089    private final PathProcessor pathProcessor;
090    private final URI baseUri;
091    private final Map<String, String> headers;
092    private final String userAgent;
093    private final int connectTimeout;
094    private final int requestTimeout;
095    private final boolean preemptiveAuth;
096    private final Charset authEncoding;
097    private final String auth;
098    private final Proxy proxy;
099    private final String proxyAuth;
100    private final RedirectMode redirectMode;
101    private final boolean redirectAllowDowngrade;
102    private final int maxRedirects;
103    private final boolean closeConnection;
104
105    private final Object authKey;
106    private final Object proxyAuthKey;
107    private final Function<Object, Boolean> cacheGetter;
108    private final Consumer<Object> cacheSetter;
109
110    @FunctionalInterface
111    private interface IOSupplier<T> {
112        T get() throws IOException;
113    }
114
115    public UrlTransporter(
116            RemoteRepository repository,
117            RepositorySystemSession session,
118            ChecksumExtractor checksumExtractor,
119            PathProcessor pathProcessor)
120            throws NoTransporterException {
121        this.checksumExtractor = checksumExtractor;
122        this.pathProcessor = pathProcessor;
123        try {
124            this.baseUri = HttpTransporterUtils.getBaseUri(repository);
125        } catch (URISyntaxException e) {
126            throw new NoTransporterException(repository, e.getMessage(), e);
127        }
128
129        this.headers = HttpTransporterUtils.getHttpHeaders(session, repository);
130        this.userAgent = HttpTransporterUtils.getUserAgent(session, repository);
131        this.connectTimeout = HttpTransporterUtils.getHttpConnectTimeout(session, repository);
132        this.requestTimeout = HttpTransporterUtils.getHttpRequestTimeout(session, repository);
133        String authString = null;
134        try (AuthenticationContext repoAuthContext = AuthenticationContext.forRepository(session, repository)) {
135            if (repoAuthContext != null) {
136                String username = repoAuthContext.get(AuthenticationContext.USERNAME);
137                String password = repoAuthContext.get(AuthenticationContext.PASSWORD);
138                if (username != null && password != null) {
139                    authString = username + ":" + password;
140                }
141            }
142        }
143        this.authEncoding = HttpTransporterUtils.getHttpCredentialsEncoding(session, repository);
144        this.auth = authString;
145        this.preemptiveAuth = this.auth != null && HttpTransporterUtils.isHttpPreemptiveAuth(session, repository);
146
147        org.eclipse.aether.repository.Proxy repoProxy = repository.getProxy();
148        this.proxy = repoProxy != null
149                ? new Proxy(Proxy.Type.HTTP, new InetSocketAddress(repoProxy.getHost(), repoProxy.getPort()))
150                : Proxy.NO_PROXY;
151        String proxyAuthString = null;
152        try (AuthenticationContext proxyAuthContext = AuthenticationContext.forProxy(session, repository)) {
153            if (proxyAuthContext != null) {
154                String username = proxyAuthContext.get(AuthenticationContext.USERNAME);
155                String password = proxyAuthContext.get(AuthenticationContext.PASSWORD);
156                if (username != null && password != null) {
157                    proxyAuthString = username + ":" + password;
158                }
159            }
160        }
161        this.proxyAuth = proxyAuthString;
162
163        this.redirectMode = RedirectMode.valueOf(ConfigUtils.getString(
164                session,
165                UrlTransporterConfigurationKeys.DEFAULT_REDIRECT_MODE,
166                UrlTransporterConfigurationKeys.CONFIG_PROP_REDIRECT_MODE + "." + repository.getId(),
167                UrlTransporterConfigurationKeys.CONFIG_PROP_REDIRECT_MODE));
168        this.redirectAllowDowngrade = ConfigUtils.getBoolean(
169                session,
170                UrlTransporterConfigurationKeys.DEFAULT_REDIRECT_ALLOW_DOWNGRADE,
171                UrlTransporterConfigurationKeys.CONFIG_PROP_REDIRECT_ALLOW_DOWNGRADE + "." + repository.getId(),
172                UrlTransporterConfigurationKeys.CONFIG_PROP_REDIRECT_ALLOW_DOWNGRADE);
173        this.maxRedirects = ConfigUtils.getInteger(
174                session,
175                UrlTransporterConfigurationKeys.DEFAULT_MAX_REDIRECT_COUNT,
176                UrlTransporterConfigurationKeys.CONFIG_PROP_MAX_REDIRECT_COUNT + "." + repository.getId(),
177                UrlTransporterConfigurationKeys.CONFIG_PROP_MAX_REDIRECT_COUNT);
178        this.closeConnection = ConfigUtils.getBoolean(
179                session,
180                UrlTransporterConfigurationKeys.DEFAULT_CLOSE_CONNECTION,
181                UrlTransporterConfigurationKeys.CONFIG_PROP_CLOSE_CONNECTION + "." + repository.getId(),
182                UrlTransporterConfigurationKeys.CONFIG_PROP_CLOSE_CONNECTION);
183
184        this.authKey = Keys.of(UrlTransporter.class, repository, "auth");
185        this.proxyAuthKey = Keys.of(UrlTransporter.class, repository, "proxyAuth");
186        if (session.getCache() != null) {
187            this.cacheGetter = k -> {
188                Boolean ret = (Boolean) session.getCache().get(session, k);
189                if (ret == null) {
190                    return false;
191                } else {
192                    return ret;
193                }
194            };
195            this.cacheSetter = k -> session.getCache().put(session, k, Boolean.TRUE);
196        } else {
197            this.cacheGetter = k -> false;
198            this.cacheSetter = k -> {};
199        }
200    }
201
202    @Override
203    protected void implPeek(PeekTask task) throws Exception {
204        HttpURLConnection con = perform(METHOD_HEAD, baseUri.resolve(task.getLocation()), null);
205        try {
206            int responseCode = con.getResponseCode();
207            if (HttpURLConnection.HTTP_OK != responseCode) {
208                throw new HttpTransporterException(responseCode);
209            }
210        } finally {
211            con.disconnect();
212        }
213    }
214
215    @Override
216    protected void implGet(GetTask task) throws Exception {
217        HttpURLConnection con = perform(METHOD_GET, baseUri.resolve(task.getLocation()), task);
218        try {
219            int responseCode = con.getResponseCode();
220            if (HttpURLConnection.HTTP_OK != responseCode) {
221                throw new HttpTransporterException(responseCode);
222            }
223            IOSupplier<InputStream> inputStreamSupplier = () -> {
224                String contentEncoding = con.getHeaderField("Content-Encoding");
225                if (contentEncoding != null) {
226                    if ("gzip".equalsIgnoreCase(contentEncoding)) {
227                        return new GZIPInputStream(con.getInputStream());
228                    } else if ("deflate".equalsIgnoreCase(contentEncoding)) {
229                        return new InflaterInputStream(con.getInputStream());
230                    }
231                }
232                return con.getInputStream();
233            };
234            final Path dataFile = task.getDataPath();
235            if (dataFile == null) {
236                try (InputStream is = inputStreamSupplier.get()) {
237                    utilGet(task, is, true, con.getContentLengthLong(), false);
238                }
239            } else {
240                try (PathProcessor.CollocatedTempFile tempFile = pathProcessor.newTempFile(dataFile)) {
241                    task.setDataPath(tempFile.getPath(), false);
242                    try (InputStream is = inputStreamSupplier.get()) {
243                        utilGet(task, is, true, con.getContentLengthLong(), false);
244                    }
245                    tempFile.move();
246                } finally {
247                    task.setDataPath(dataFile);
248                }
249            }
250            if (task.getDataPath() != null) {
251                long lastModified = con.getLastModified();
252                if (lastModified != 0) {
253                    pathProcessor.setLastModified(
254                            task.getDataPath(), HttpTransporterUtils.clampRemoteLastModified(lastModified));
255                }
256            }
257        } finally {
258            con.disconnect();
259        }
260    }
261
262    @Override
263    protected void implPut(PutTask task) {
264        throw new UnsupportedOperationException("PUT method unsupported");
265    }
266
267    @Override
268    protected void implClose() {
269        // nothing
270    }
271
272    private HttpURLConnection perform(String method, URI target, GetTask task) throws IOException {
273        String currAuth = preemptiveAuth ? auth : null;
274        String currProxyAuth = null;
275        if (cacheGetter.apply(authKey)) {
276            currAuth = this.auth;
277        }
278        if (cacheGetter.apply(proxyAuthKey)) {
279            currProxyAuth = this.proxyAuth;
280        }
281        return perform(method, new ArrayList<>(Collections.singletonList(target)), currAuth, currProxyAuth, task);
282    }
283
284    private HttpURLConnection perform(
285            String method, ArrayList<URI> target, String currAuth, String currProxyAuth, GetTask task)
286            throws IOException {
287        if (target.size() - 1 > maxRedirects) {
288            throw new IOException("Too many redirects");
289        }
290        HttpURLConnection con = (HttpURLConnection) target.get(0).toURL().openConnection(proxy);
291        con.setConnectTimeout(connectTimeout);
292        con.setReadTimeout(requestTimeout);
293        con.setRequestMethod(method);
294        con.setUseCaches(false);
295        con.setInstanceFollowRedirects(false);
296        con.setRequestProperty(HttpConstants.ACCEPT_ENCODING, "gzip,deflate");
297        con.setRequestProperty(HttpConstants.CACHE_CONTROL, "no-cache, no-store");
298        con.setRequestProperty("Pragma", "no-cache");
299        con.setRequestProperty(HttpConstants.USER_AGENT, userAgent);
300        headers.forEach(con::setRequestProperty);
301        if (closeConnection) {
302            con.setRequestProperty("Connection", "close");
303        }
304        if (currAuth != null) {
305            con.setRequestProperty(HEADER_AUTHORIZATION, basicAuthorization(currAuth));
306        }
307        if (currProxyAuth != null) {
308            con.setRequestProperty(HEADER_PROXY_AUTHORIZATION, basicAuthorization(currProxyAuth));
309        }
310        int responseCode = con.getResponseCode();
311        if (responseCode == HttpURLConnection.HTTP_OK) {
312            if (task != null) {
313                Map<String, String> checksums = checksumExtractor.extractChecksums(con::getHeaderField);
314                if (checksums != null && !checksums.isEmpty()) {
315                    checksums.forEach(task::setChecksum);
316                }
317            }
318        } else if (responseCode == HttpURLConnection.HTTP_MOVED_PERM
319                || responseCode == HttpURLConnection.HTTP_MOVED_TEMP
320                || responseCode == HttpURLConnection.HTTP_SEE_OTHER
321                || responseCode == HTTP_STATUS_TEMPORARY_REDIRECT
322                || responseCode == HTTP_STATUS_PERMANENT_REDIRECT) {
323            if (redirectMode == RedirectMode.NONE) {
324                con.disconnect();
325                throw new IOException("Refusing to follow redirects");
326            }
327            final String location = con.getHeaderField(HEADER_LOCATION);
328            if (location == null) {
329                con.disconnect();
330                throw new IOException("Redirect response missing Location header");
331            }
332            final URI currentUri;
333            final URI redirectUri;
334            try {
335                currentUri = URI.create(con.getURL().toString());
336                redirectUri = currentUri.resolve(location);
337            } catch (IllegalArgumentException e) {
338                con.disconnect();
339                throw new IOException("Redirect response has invalid Location header: " + location, e);
340            }
341            // ensure we are HTTP or HTTPS after redirect
342            if (!"http".equalsIgnoreCase(redirectUri.getScheme())
343                    && !"https".equalsIgnoreCase(redirectUri.getScheme())) {
344                con.disconnect();
345                throw new IOException("Unsupported redirect protocol: " + redirectUri.getScheme());
346            }
347            String currentAuthority = currentUri.getAuthority();
348            String redirectAuthority = redirectUri.getAuthority();
349            if (currentAuthority == null || !currentAuthority.equalsIgnoreCase(redirectAuthority)) {
350                if (redirectMode == RedirectMode.SAME_AUTHORITY) {
351                    con.disconnect();
352                    throw new IOException("Refusing to follow redirect to different authority: " + redirectAuthority);
353                } else {
354                    // reset auth if authority differs after redirect
355                    currAuth = null;
356                }
357            }
358            if ("https".equalsIgnoreCase(currentUri.getScheme())
359                    && "http".equalsIgnoreCase(redirectUri.getScheme())
360                    && !redirectAllowDowngrade) {
361                // forbid HTTPS -> HTTP downgrade during redirect
362                con.disconnect();
363                throw new IOException("Refusing to downgrade from HTTPS to HTTP protocol");
364            }
365            target.add(0, redirectUri);
366            con.disconnect();
367            return perform(method, target, currAuth, currProxyAuth, task);
368        } else if (responseCode == HttpURLConnection.HTTP_UNAUTHORIZED && currAuth == null && this.auth != null) {
369            con.disconnect();
370            return perform(method, target, this.auth, currProxyAuth, task);
371        } else if (responseCode == HttpURLConnection.HTTP_PROXY_AUTH
372                && currProxyAuth == null
373                && this.proxyAuth != null) {
374            con.disconnect();
375            return perform(method, target, currAuth, this.proxyAuth, task);
376        }
377        if (currAuth != null) {
378            cacheSetter.accept(authKey);
379        }
380        if (currProxyAuth != null) {
381            cacheSetter.accept(proxyAuthKey);
382        }
383        return con;
384    }
385
386    private String basicAuthorization(String credentials) {
387        return AUTH_SCHEME_BASIC + " " + Base64.getEncoder().encodeToString(credentials.getBytes(authEncoding));
388    }
389}