Downloader.java

/*
 * This file is part of dependency-check-utils.
 *
 * Licensed 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.
 *
 * Copyright (c) 2024 Hans Aikema. All Rights Reserved.
 */
package org.owasp.dependencycheck.utils;

import org.apache.hc.client5.http.HttpResponseException;
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.client5.http.impl.auth.SystemDefaultCredentialsProvider;
import org.apache.hc.client5.http.impl.classic.BasicHttpClientResponseHandler;
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.io.PoolingHttpClientConnectionManager;
import org.apache.hc.client5.http.protocol.HttpClientContext;
import org.apache.hc.core5.http.ClassicHttpResponse;
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.HttpException;
import org.apache.hc.core5.http.HttpHost;
import org.apache.hc.core5.http.Method;
import org.apache.hc.core5.http.io.HttpClientResponseHandler;
import org.apache.hc.core5.http.io.entity.BasicHttpEntity;
import org.apache.hc.core5.http.io.entity.StringEntity;
import org.apache.hc.core5.http.message.BasicClassicHttpRequest;
import org.apache.hc.core5.http.message.BasicClassicHttpResponse;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.net.ssl.SSLHandshakeException;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.MalformedURLException;
import java.net.Proxy;
import java.net.ProxySelector;
import java.net.SocketAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;

import static java.lang.String.format;

/**
 * A Utility class to centralize download logic like HTTP(S) proxy configuration and proxy- and server credential handling.
 * @author Jeremy Long, Hans Aikema
 */
public final class Downloader {

    /**
     * The builder to use for a HTTP Client that uses the configured proxy-settings
     */
    private final HttpClientBuilder httpClientBuilder;

    /**
     * The builder to use for a HTTP Client that explicitly opts out of proxy-usage
     */
    private final HttpClientBuilder httpClientBuilderExplicitNoproxy;


    /**
     * The settings
     */
    private Settings settings;

    /**
     * The Logger for use throughout the class.
     */
    private static final Logger LOGGER = LoggerFactory.getLogger(Downloader.class);

    /**
     * The singleton instance of the downloader
     */
    private static final Downloader INSTANCE = new Downloader();

    private Downloader() {
        // Singleton class
        final PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
        //TODO: ensure proper closure and eviction policy
        httpClientBuilder = HttpClientBuilder.create()
                .useSystemProperties()
                .setConnectionManager(connectionManager)
                .setConnectionManagerShared(true);
        httpClientBuilderExplicitNoproxy = HttpClientBuilder.create()
                .useSystemProperties()
                .setConnectionManager(connectionManager)
                .setConnectionManagerShared(true)
                .setProxySelector(new ProxySelector() {
                    @Override
                    public List<Proxy> select(URI uri) {
                        return Collections.singletonList(Proxy.NO_PROXY);
                    }

                    @Override
                    public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {

                    }
                });
    }

    /**
     * The singleton instance for downloading file resources.
     *
     * @return The singleton instance managing download credentials and proxy configuration
     */
    public static Downloader getInstance() {
        return INSTANCE;
    }

    /**
     * Initialize the Downloader from the settings.
     * Extracts the configured proxy- and credential information from the settings and system properties and
     * caches those for future use by the Downloader.
     *
     * @param settings The settings to configure from
     * @throws InvalidSettingException When improper configurations are found.
     */
    public void configure(Settings settings) throws InvalidSettingException {
        this.settings = settings;

        final SystemDefaultCredentialsProvider credentialsProvider = new SystemDefaultCredentialsProvider();
        if (settings.getString(Settings.KEYS.PROXY_SERVER) != null) {
            // Legacy proxy configuration present
            // So don't rely on the system properties for proxy; use the legacy settings configuration
            final String proxyHost = settings.getString(Settings.KEYS.PROXY_SERVER);
            final int proxyPort = settings.getInt(Settings.KEYS.PROXY_PORT, -1);
            final String nonProxyHosts = settings.getString(Settings.KEYS.PROXY_NON_PROXY_HOSTS);
            if (nonProxyHosts != null && !nonProxyHosts.isEmpty()) {
                final ProxySelector selector = new SelectiveProxySelector(
                        new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)),
                        nonProxyHosts.split("\\|")
                );
                httpClientBuilder.setProxySelector(selector);
            } else {
                httpClientBuilder.setProxy(new HttpHost(proxyHost, proxyPort));
            }
            if (settings.getString(Settings.KEYS.PROXY_USERNAME) != null) {
                final String proxyuser = settings.getString(Settings.KEYS.PROXY_USERNAME);
                final char[] proxypass = settings.getString(Settings.KEYS.PROXY_PASSWORD).toCharArray();
                credentialsProvider.setCredentials(
                        new AuthScope(null, proxyHost, proxyPort, null, null),
                        new UsernamePasswordCredentials(proxyuser, proxypass)
                );
            }
        }
        tryAddRetireJSCredentials(settings, credentialsProvider);
        tryAddHostedSuppressionCredentials(settings, credentialsProvider);
        tryAddKEVCredentials(settings, credentialsProvider);
        tryAddNexusAnalyzerCredentials(settings, credentialsProvider);
        tryAddNVDApiDatafeed(settings, credentialsProvider);
        httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider);
        httpClientBuilderExplicitNoproxy.setDefaultCredentialsProvider(credentialsProvider);
    }

    private void tryAddRetireJSCredentials(Settings settings, CredentialsStore credentialsStore) throws InvalidSettingException {
        if (settings.getString(Settings.KEYS.ANALYZER_RETIREJS_REPO_JS_PASSWORD) != null) {
            addUserPasswordCreds(settings, credentialsStore,
                    Settings.KEYS.ANALYZER_RETIREJS_REPO_JS_USER,
                    Settings.KEYS.ANALYZER_RETIREJS_REPO_JS_URL,
                    Settings.KEYS.ANALYZER_RETIREJS_REPO_JS_PASSWORD,
                    "RetireJS repo.js");
        }
    }

    private void tryAddHostedSuppressionCredentials(Settings settings, CredentialsStore credentialsStore) throws InvalidSettingException {
        if (settings.getString(Settings.KEYS.HOSTED_SUPPRESSIONS_PASSWORD) != null) {
            addUserPasswordCreds(settings, credentialsStore,
                    Settings.KEYS.HOSTED_SUPPRESSIONS_USER,
                    Settings.KEYS.HOSTED_SUPPRESSIONS_URL,
                    Settings.KEYS.HOSTED_SUPPRESSIONS_PASSWORD,
                    "Hosted suppressions");
        }
    }

    private void tryAddKEVCredentials(Settings settings, CredentialsStore credentialsStore) throws InvalidSettingException {
        if (settings.getString(Settings.KEYS.KEV_PASSWORD) != null) {
            addUserPasswordCreds(settings, credentialsStore,
                    Settings.KEYS.KEV_USER,
                    Settings.KEYS.KEV_URL,
                    Settings.KEYS.KEV_PASSWORD,
                    "Known Exploited Vulnerabilities");
        }
    }

    private void tryAddNexusAnalyzerCredentials(Settings settings, CredentialsStore credentialsStore) throws InvalidSettingException {
        if (settings.getString(Settings.KEYS.ANALYZER_NEXUS_PASSWORD) != null) {
            addUserPasswordCreds(settings, credentialsStore,
                    Settings.KEYS.ANALYZER_NEXUS_USER,
                    Settings.KEYS.ANALYZER_NEXUS_URL,
                    Settings.KEYS.ANALYZER_NEXUS_PASSWORD,
                    "Nexus Analyzer");
        }
    }

    private void tryAddNVDApiDatafeed(Settings settings, CredentialsStore credentialsStore) throws InvalidSettingException {
        if (settings.getString(Settings.KEYS.NVD_API_DATAFEED_PASSWORD) != null) {
            addUserPasswordCreds(settings, credentialsStore,
                    Settings.KEYS.NVD_API_DATAFEED_USER,
                    Settings.KEYS.NVD_API_DATAFEED_URL,
                    Settings.KEYS.NVD_API_DATAFEED_PASSWORD,
                    "NVD API Datafeed");
        }
    }

    /**
     * Add user/password credentials for the host/port of the URL, all configured in the settings, to the credential-store.
     *
     * @param settings The settings to retrieve the values from
     * @param store The credentialStore
     * @param userKey The key for a configured username credential part
     * @param passwordKey The key for a configured password credential part
     * @param urlKey The key for a configured url for which the credentials hold
     * @param desc A descriptive text for use in error messages for this credential
     * @throws InvalidSettingException When the password is empty or one of the other keys are not found in the settings.
     */
    private void addUserPasswordCreds(Settings settings, CredentialsStore store, String userKey, String urlKey, String passwordKey, String desc)
            throws InvalidSettingException {
        final String theUser = settings.getString(userKey);
        final String theURL = settings.getString(urlKey);
        final char[] thePass = settings.getString(passwordKey, "").toCharArray();
        if (theUser == null || theURL == null || thePass.length == 0) {
            throw new InvalidSettingException(desc + " URL and username are required when setting " + desc + " password");
        }
        try {
            final URL parsedURL = new URL(theURL);
            addCredentials(store, desc, parsedURL, theUser, thePass);
        } catch (MalformedURLException e) {
            throw new InvalidSettingException(desc + " URL must be a valid URL", e);
        }
    }

    private static void addCredentials(CredentialsStore credentialsStore, String messageScope, URL parsedURL, String theUser, char[] thePass)
            throws InvalidSettingException {
        final String theProtocol = parsedURL.getProtocol();
        if ("file".equals(theProtocol)) {
            LOGGER.warn("Credentials are not supported for file-protocol, double-check your configuration options for {}.", messageScope);
            return;
        } else if ("http".equals(theProtocol)) {
            LOGGER.warn("Insecure configuration: Basic Credentials are configured to be used over a plain http connection for {}. "
                    + "Consider migrating to https to guard the credentials.", messageScope);
        } else if (!"https".equals(theProtocol)) {
            throw new InvalidSettingException("Unsupported protocol in the " + messageScope
                    + " URL; only file, http and https are supported");
        }
        final String theHost = parsedURL.getHost();
        final int thePort = parsedURL.getPort();
        final Credentials creds = new UsernamePasswordCredentials(theUser, thePass);
        final AuthScope scope = new AuthScope(theProtocol, theHost, thePort, null, null);
        credentialsStore.setCredentials(scope, creds);
    }

    /**
     * Retrieves a file from a given URL and saves it to the outputPath.
     *
     * @param url        the URL of the file to download
     * @param outputPath the path to the save the file to
     * @throws DownloadFailedException       is thrown if there is an error downloading the file
     * @throws URLConnectionFailureException is thrown when certificate-chain trust errors occur downloading the file
     * @throws TooManyRequestsException      thrown when a 429 is received
     * @throws ResourceNotFoundException     thrown when a 404 is received
     */
    public void fetchFile(URL url, File outputPath)
            throws DownloadFailedException, TooManyRequestsException, ResourceNotFoundException, URLConnectionFailureException {
        fetchFile(url, outputPath, true);
    }

    /**
     * Retrieves a file from a given URL and saves it to the outputPath.
     *
     * @param url        the URL of the file to download
     * @param outputPath the path to the save the file to
     * @param useProxy   whether to use the configured proxy when downloading
     *                   files
     * @throws DownloadFailedException       is thrown if there is an error downloading the file
     * @throws URLConnectionFailureException is thrown when certificate-chain trust errors occur downloading the file
     * @throws TooManyRequestsException      thrown when a 429 is received
     * @throws ResourceNotFoundException     thrown when a 404 is received
     */
    public void fetchFile(URL url, File outputPath, boolean useProxy) throws DownloadFailedException,
            TooManyRequestsException, ResourceNotFoundException, URLConnectionFailureException {
        try {
            if ("file".equals(url.getProtocol())) {
                final Path p = Paths.get(url.toURI());
                Files.copy(p, outputPath.toPath(), StandardCopyOption.REPLACE_EXISTING);
            } else {
                final BasicClassicHttpRequest req;
                req = new BasicClassicHttpRequest(Method.GET, url.toURI());
                try (CloseableHttpClient hc = useProxy ? httpClientBuilder.build() : httpClientBuilderExplicitNoproxy.build()) {
                    final SaveToFileResponseHandler responseHandler = new SaveToFileResponseHandler(outputPath);
                    hc.execute(req, responseHandler);
                }
            }
        } catch (HttpResponseException hre) {
            wrapAndThrowHttpResponseException(url.toString(), hre);
        } catch (SSLHandshakeException ex) {
            if (ex.getMessage().contains("unable to find valid certification path to requested target")) {
                final String msg = String.format("Unable to connect to '%s' - the Java trust store does not contain a trusted root for the cert. "
                        + "Please see https://github.com/jeremylong/InstallCert for one method of updating the trusted certificates.", url);
                throw new URLConnectionFailureException(msg, ex);
            }
            final String msg = format("Download failed, unable to copy '%s' to '%s'; %s", url, outputPath.getAbsolutePath(), ex.getMessage());
            throw new DownloadFailedException(msg, ex);
        } catch (RuntimeException | URISyntaxException | IOException ex) {
            final String msg = format("Download failed, unable to copy '%s' to '%s'; %s", url, outputPath.getAbsolutePath(), ex.getMessage());
            throw new DownloadFailedException(msg, ex);
        }
    }

    private static void wrapAndThrowHttpResponseException(String url, HttpResponseException hre)
            throws ResourceNotFoundException, TooManyRequestsException, DownloadFailedException {
        final String messageFormat = "%s - Server status: %d - Server reason: %s";
        switch (hre.getStatusCode()) {
            case 404:
                throw new ResourceNotFoundException(String.format(messageFormat, url, hre.getStatusCode(), hre.getReasonPhrase()), hre);
            case 429:
                throw new TooManyRequestsException(String.format(messageFormat, url, hre.getStatusCode(), hre.getReasonPhrase()), hre);
            default:
                throw new DownloadFailedException(String.format(messageFormat, url, hre.getStatusCode(), hre.getReasonPhrase()), hre);
        }
    }

    /**
     * Retrieves a file from a given URL using an ad-hoc created CredentialsProvider if needed
     * and saves it to the outputPath.
     *
     * @param url         the URL of the file to download
     * @param outputPath  the path to the save the file to
     * @param useProxy    whether to use the configured proxy when downloading
     *                    files
     * @param userKey     the settings key for the username to be used
     * @param passwordKey the settings key for the password to be used
     * @throws DownloadFailedException       is thrown if there is an error downloading the file
     * @throws URLConnectionFailureException is thrown when certificate-chain trust errors occur downloading the file
     * @throws TooManyRequestsException      thrown when a 429 is received
     * @throws ResourceNotFoundException     thrown when a 404 is received
     * @implNote This method should only be used in cases where the target host cannot be determined beforehand from settings, so that ad-hoc
     * Credentials needs to be constructed for the target URL when the user/password keys point to configured credentials. The method delegates to
     * {@link #fetchFile(URL, File, boolean)} when credentials are not configured for the given keys or the resource points to a file.
     */
    public void fetchFile(URL url, File outputPath, boolean useProxy, String userKey, String passwordKey) throws DownloadFailedException,
            TooManyRequestsException, ResourceNotFoundException, URLConnectionFailureException {
        if ("file".equals(url.getProtocol())
                || userKey == null || settings.getString(userKey) == null
                || passwordKey == null || settings.getString(passwordKey) == null
        ) {
            // no credentials configured, so use the default fetchFile
            fetchFile(url, outputPath, useProxy);
            return;
        }
        final String theProtocol = url.getProtocol();
        if (!("http".equals(theProtocol) || "https".equals(theProtocol))) {
            throw new DownloadFailedException("Unsupported protocol in the URL; only file, http and https are supported");
        }
        try {
            final HttpClientContext context = HttpClientContext.create();
            final BasicCredentialsProvider localCredentials = new BasicCredentialsProvider();
            addCredentials(localCredentials, url.toString(), url, settings.getString(userKey), settings.getString(passwordKey).toCharArray());
            context.setCredentialsProvider(localCredentials);
            try (CloseableHttpClient hc = useProxy ? httpClientBuilder.build() : httpClientBuilderExplicitNoproxy.build()) {
                final BasicClassicHttpRequest req = new BasicClassicHttpRequest(Method.GET, url.toURI());
                final SaveToFileResponseHandler responseHandler = new SaveToFileResponseHandler(outputPath);
                hc.execute(req, context, responseHandler);
            }
        } catch (HttpResponseException hre) {
            wrapAndThrowHttpResponseException(url.toString(), hre);
        } catch (SSLHandshakeException ex) {
            if (ex.getMessage().contains("unable to find valid certification path to requested target")) {
                final String msg = String.format("Unable to connect to '%s' - the Java trust store does not contain a trusted root for the cert. "
                        + "Please see https://github.com/jeremylong/InstallCert for one method of updating the trusted certificates.", url);
                throw new URLConnectionFailureException(msg, ex);
            }
            final String msg = format("Download failed, unable to copy '%s' to '%s'; %s", url, outputPath.getAbsolutePath(), ex.getMessage());
            throw new DownloadFailedException(msg, ex);
        } catch (RuntimeException | URISyntaxException | IOException ex) {
            final String msg = format("Download failed, unable to copy '%s' to '%s'; %s", url, outputPath.getAbsolutePath(), ex.getMessage());
            throw new DownloadFailedException(msg, ex);
        }
    }

    /**
     * Posts a payload to the URL and returns the response as a string.
     *
     * @param url         the URL to POST to
     * @param payload     the Payload to post
     * @param payloadType the string describing the payload's mime-type
     * @param hdr         Additional headers to add to the HTTP request
     * @return the content of the response
     * @throws DownloadFailedException       is thrown if there is an error downloading the file
     * @throws URLConnectionFailureException is thrown when certificate-chain trust errors occur downloading the file
     * @throws TooManyRequestsException      thrown when a 429 is received
     * @throws ResourceNotFoundException     thrown when a 404 is received
     */
    public String postBasedFetchContent(URI url, String payload, ContentType payloadType, List<Header> hdr)
            throws DownloadFailedException, TooManyRequestsException, ResourceNotFoundException, URLConnectionFailureException {
        try {
            if (url.getScheme() == null || !url.getScheme().toLowerCase(Locale.ROOT).matches("^https?")) {
                throw new IllegalArgumentException("Unsupported protocol in the URL; only http and https are supported");
            } else {
                final BasicClassicHttpRequest req;
                req = new BasicClassicHttpRequest(Method.POST, url);
                req.setEntity(new StringEntity(payload, payloadType));
                for (Header h : hdr) {
                    req.addHeader(h);
                }
                final String result;
                try (CloseableHttpClient hc = httpClientBuilder.build()) {
                    result = hc.execute(req, new BasicHttpClientResponseHandler());
                }
                return result;
            }
        } catch (HttpResponseException hre) {
            wrapAndThrowHttpResponseException(url.toString(), hre);
            throw new InternalError("wrapAndThrowHttpResponseException will always throw an exception but Java compiler fails to spot it");
        } catch (SSLHandshakeException ex) {
            if (ex.getMessage().contains("unable to find valid certification path to requested target")) {
                final String msg = String.format("Unable to connect to '%s' - the Java trust store does not contain a trusted root for the cert. "
                        + "Please see https://github.com/jeremylong/InstallCert for one method of updating the trusted certificates.", url);
                throw new URLConnectionFailureException(msg, ex);
            }
            final String msg = format("Download failed, error downloading '%s'; %s", url, ex.getMessage());
            throw new DownloadFailedException(msg, ex);
        } catch (IOException | RuntimeException ex) {
            final String msg = format("Download failed, error downloading '%s'; %s", url, ex.getMessage());
            throw new DownloadFailedException(msg, ex);
        }
    }

    /**
     * Retrieves a file from a given URL and returns the contents.
     *
     * @param url     the URL of the file to download
     * @param charset The characterset to use to interpret the binary content of the file
     * @return the content of the file
     * @throws DownloadFailedException   is thrown if there is an error
     *                                   downloading the file
     * @throws TooManyRequestsException  thrown when a 429 is received
     * @throws ResourceNotFoundException thrown when a 404 is received
     */
    public String fetchContent(URL url, Charset charset) throws DownloadFailedException, TooManyRequestsException, ResourceNotFoundException {
        return fetchContent(url, true, charset);
    }

    /**
     * Retrieves a file from a given URL and returns the contents.
     *
     * @param url      the URL of the file to download
     * @param useProxy whether to use the configured proxy when downloading
     *                 files
     * @param charset  The characterset to use to interpret the binary content of the file
     * @return the content of the file
     * @throws DownloadFailedException   is thrown if there is an error
     *                                   downloading the file
     * @throws TooManyRequestsException  thrown when a 429 is received
     * @throws ResourceNotFoundException thrown when a 404 is received
     */
    public String fetchContent(URL url, boolean useProxy, Charset charset)
            throws DownloadFailedException, TooManyRequestsException, ResourceNotFoundException {
        try {
            final String result;
            if ("file".equals(url.getProtocol())) {
                final Path p = Paths.get(url.toURI());
                result = Files.readString(p, charset);
            } else {
                final BasicClassicHttpRequest req;
                req = new BasicClassicHttpRequest(Method.GET, url.toURI());
                try (CloseableHttpClient hc = useProxy ? httpClientBuilder.build() : httpClientBuilderExplicitNoproxy.build()) {
                    final ExplicitEncodingToStringResponseHandler responseHandler = new ExplicitEncodingToStringResponseHandler(charset);
                    result = hc.execute(req, responseHandler);
                }
            }
            return result;
        } catch (HttpResponseException hre) {
            wrapAndThrowHttpResponseException(url.toString(), hre);
            throw new InternalError("wrapAndThrowHttpResponseException will always throw an exception but Java compiler fails to spot it");
        } catch (RuntimeException | URISyntaxException | IOException ex) {
            final String msg = format("Download failed, error downloading '%s'; %s", url, ex.getMessage());
            throw new DownloadFailedException(msg, ex);
        }
    }

    /**
     * Download a resource from the given URL and have its content handled by the given ResponseHandler.
     *
     * @param url             The url of the resource
     * @param handler   The responsehandler to handle the response
     * @param <T>             The return-type for the responseHandler
     * @return The response handler result
     * @throws IOException               on I/O Exceptions
     * @throws TooManyRequestsException  When HTTP status 429 is encountered
     * @throws ResourceNotFoundException When HTTP status 404 is encountered
     */
    public <T> T fetchAndHandle(@NotNull URL url, @NotNull HttpClientResponseHandler<T> handler)
            throws IOException, TooManyRequestsException, ResourceNotFoundException {
        return fetchAndHandle(url, handler, Collections.emptyList(), true);
    }

    /**
     * Download a resource from the given URL and have its content handled by the given ResponseHandler.
     *
     * @param url               The url of the resource
     * @param handler   The responsehandler to handle the response
     * @param hdr Additional headers to add to the HTTP request
     * @param <T>               The return-type for the responseHandler
     * @return The response handler result
     * @throws IOException               on I/O Exceptions
     * @throws TooManyRequestsException  When HTTP status 429 is encountered
     * @throws ResourceNotFoundException When HTTP status 404 is encountered
     */
    public <T> T fetchAndHandle(@NotNull URL url, @NotNull HttpClientResponseHandler<T> handler, @NotNull List<Header> hdr)
            throws IOException, TooManyRequestsException, ResourceNotFoundException {
        return fetchAndHandle(url, handler, hdr, true);
    }

    /**
     * Download a resource from the given URL and have its content handled by the given ResponseHandler.
     *
     * @param url               The url of the resource
     * @param handler   The responsehandler to handle the response
     * @param hdr Additional headers to add to the HTTP request
     * @param useProxy          Whether to use the configured proxy for the connection
     * @param <T>               The return-type for the responseHandler
     * @return The response handler result
     * @throws IOException               on I/O Exceptions
     * @throws TooManyRequestsException  When HTTP status 429 is encountered
     * @throws ResourceNotFoundException When HTTP status 404 is encountered
     */
    public <T> T fetchAndHandle(@NotNull URL url, @NotNull HttpClientResponseHandler<T> handler, @NotNull List<Header> hdr, boolean useProxy)
            throws IOException, TooManyRequestsException, ResourceNotFoundException {
        try {
            final T data;
            if ("file".equals(url.getProtocol())) {
                final Path p = Paths.get(url.toURI());
                try (InputStream is = Files.newInputStream(p)) {
                    final HttpEntity dummyEntity = new BasicHttpEntity(is, ContentType.APPLICATION_JSON);
                    final ClassicHttpResponse dummyResponse = new BasicClassicHttpResponse(200);
                    dummyResponse.setEntity(dummyEntity);
                    data = handler.handleResponse(dummyResponse);
                } catch (HttpException e) {
                    throw new IllegalStateException("HttpException encountered without HTTP traffic", e);
                }
            } else {
                final String theProtocol = url.getProtocol();
                if (!("http".equals(theProtocol) || "https".equals(theProtocol))) {
                    throw new DownloadFailedException("Unsupported protocol in the URL; only file, http and https are supported");
                }
                try (CloseableHttpClient hc = useProxy ? httpClientBuilder.build() : httpClientBuilderExplicitNoproxy.build()) {
                    final BasicClassicHttpRequest req = new BasicClassicHttpRequest(Method.GET, url.toURI());
                    for (Header h : hdr) {
                        req.addHeader(h);
                    }
                    data = hc.execute(req, handler);
                }
            }
            return data;
        } catch (HttpResponseException hre) {
            final String messageFormat = "%s - Server status: %d - Server reason: %s";
            switch (hre.getStatusCode()) {
                case 404:
                    throw new ResourceNotFoundException(String.format(messageFormat, url, hre.getStatusCode(), hre.getReasonPhrase()));
                case 429:
                    throw new TooManyRequestsException(String.format(messageFormat, url, hre.getStatusCode(), hre.getReasonPhrase()));
                default:
                    throw new IOException(String.format(messageFormat, url, hre.getStatusCode(), hre.getReasonPhrase()));
            }
        } catch (RuntimeException | URISyntaxException ex) {
            final String msg = format("Download failed, unable to retrieve and parse '%s'; %s", url, ex.getMessage());
            throw new IOException(msg, ex);
        }
    }

    private static class SelectiveProxySelector extends ProxySelector {

        /**
         * The suffix-match entries from the nonProxyHosts (those starting with a {@code *}).
         */
        private final List<String> suffixMatch = new ArrayList<>();
        /**
         * The full host entries from the nonProxyHosts (those <em>not</em> starting with a {@code *}).
         */
        private final List<String> fullmatch = new ArrayList<>();
        /**
         * The proxy use when no proxy-exception is found.
         */
        private final Proxy configuredProxy;

        SelectiveProxySelector(Proxy httpHost, String[] nonProxyHostsPatterns) {
            for (String nonProxyHostPattern : nonProxyHostsPatterns) {
                if (nonProxyHostPattern.startsWith("*")) {
                    suffixMatch.add(nonProxyHostPattern.substring(1));
                } else {
                    fullmatch.add(nonProxyHostPattern);
                }
            }
            this.configuredProxy = httpHost;
        }

        @Override
        public List<Proxy> select(URI uri) {
            final String theHost = uri.getHost();
            if (fullmatch.contains(theHost)) {
                return Collections.singletonList(Proxy.NO_PROXY);
            } else {
                for (String suffix : suffixMatch) {
                    if (theHost.endsWith(suffix)) {
                        return Collections.singletonList(Proxy.NO_PROXY);
                    }
                }
            }
            return List.of(configuredProxy);
        }

        @Override
        public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
            // nothing to be done for this single proxy proxy-selector
        }
    }
}