Hasher.java

/*
 * Copyright (C) 2023 DANS - Data Archiving and Networked Services (info@dans.knaw.nl)
 *
 * 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.
 */
package nl.knaw.dans.bagit.hash;

import nl.knaw.dans.bagit.domain.FetchItem;
import nl.knaw.dans.bagit.domain.Manifest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import java.net.URL;
import java.net.URLConnection;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Collection;
import java.util.Collections;
import java.util.Formatter;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.ResourceBundle;

/**
 * Convenience class for generating a HEX formatted string of the checksum hash.
 */
public final class Hasher {
    private static final Logger logger = LoggerFactory.getLogger(Hasher.class);
    private static final int _64_KB = 1024 * 64;
    private static final int CHUNK_SIZE = _64_KB;
    private static final ResourceBundle messages = ResourceBundle.getBundle("MessageBundle");

    public static final String CHUNK_SIZE_PROP = "nl.knaw.dans.bagit.hash.chunkSize";
    public static final String MAX_RETRIES_PROP = "nl.knaw.dans.bagit.hash.maxRetries";
    public static final String RETRY_SLEEP_MS_PROP = "nl.knaw.dans.bagit.hash.retrySleepMs";
    public static final String MAX_REDIRECTS_PROP = "nl.knaw.dans.bagit.hash.maxRedirects";
    public static final String FALL_BACK_TO_FULL_STREAM_ON_RANGE_FAIL_PROP = "nl.knaw.dans.bagit.hash.fallBackToFullStreamOnRangeFail";

    private static final int DEFAULT_CHUNK_SIZE = 1024 * 1024 * 128; // 128 MB
    private static final int DEFAULT_MAX_RETRIES = 5;
    private static final int DEFAULT_RETRY_SLEEP_MS = 5000;
    private static final int DEFAULT_MAX_REDIRECTS = 20;
    private static final boolean DEFAULT_FALL_BACK_TO_FULL_STREAM_ON_RANGE_FAIL = false;

    private Hasher() {
        //intentionally left empty
    }

    /**
     * Create a HEX formatted string checksum hash of the file
     *
     * @param path          the {@link Path} (file) to hash
     * @param messageDigest the {@link MessageDigest} object representing the hashing algorithm
     * @return the hash as a hex formated string
     * @throws IOException if there is a problem reading the file
     */
    public static String hash(final Path path, final MessageDigest messageDigest) throws IOException {
        updateMessageDigests(path, Collections.singletonList(messageDigest));

        return formatMessageDigest(messageDigest);
    }

    /**
     * Create a HEX formatted string checksum hash of the data from the URL
     *
     * @param url           the {@link URL} to hash
     * @param messageDigest the {@link MessageDigest} object representing the hashing algorithm
     * @return the hash as a hex-formatted string
     * @throws IOException if there is a problem reading from the URL
     */
    public static String hash(final URL url, final MessageDigest messageDigest) throws IOException {
        return hash(url, messageDigest, null);
    }

    /**
     * Create a HEX-formatted string checksum hash of the data from the {@link FetchItem}
     *
     * @param item          the {@link FetchItem} to hash
     * @param messageDigest the {@link MessageDigest} object representing the hashing algorithm
     * @param extraHeaders  optional extra headers to send with the request
     * @return the hash as a hex-formatted string
     * @throws IOException if there is a problem reading from the URL
     */
    public static String hash(final FetchItem item, final MessageDigest messageDigest, final Map<String, String> extraHeaders) throws IOException {
        return hash(item, messageDigest, extraHeaders, HashOptions.systemProperties());
    }

    /**
     * Create a HEX-formatted string checksum hash of the data from the {@link FetchItem}
     *
     * @param item          the {@link FetchItem} to hash
     * @param messageDigest the {@link MessageDigest} object representing the hashing algorithm
     * @param extraHeaders  optional extra headers to send with the request
     * @param hashOptions   optional settings for ranged HTTP requests
     * @return the hash as a hex-formatted string
     * @throws IOException if there is a problem reading from the URL
     */
    public static String hash(final FetchItem item, final MessageDigest messageDigest, final Map<String, String> extraHeaders, final HashOptions hashOptions) throws IOException {
        long totalSize = (item.length != null && item.length >= 0) ? item.length : -1;
        URL currentUrl = item.url;
        Map<String, String> currentHeaders = extraHeaders;

        if (!currentUrl.getProtocol().startsWith("http")) {
            return hashFullStream(currentUrl, messageDigest, currentHeaders, hashOptions);
        }

        HashOptions effectiveOptions = hashOptions == null ? HashOptions.systemProperties() : hashOptions;
        long chunkSize = effectiveOptions.getChunkSize();
        int maxRetries = effectiveOptions.getMaxRetries();
        int retrySleepMs = effectiveOptions.getRetrySleepMs();
        int maxRedirects = effectiveOptions.getMaxRedirects();
        int redirectCount = 0;

        long offset = 0;
        while (totalSize < 0 || offset < totalSize) {
            long end = (totalSize > 0) ? Math.min(offset + chunkSize - 1, totalSize - 1) : offset + chunkSize - 1;
            String range = "bytes=" + offset + "-" + end;

            final URL finalUrl = currentUrl;
            final Map<String, String> finalHeaders = currentHeaders;
            final long finalOffset = offset;
            final long finalTotalSize = totalSize;

            try {
                ChunkResult result = executeWithRetry(() -> {
                    HttpURLConnection conn = openRangedConnection(finalUrl, range, finalHeaders);
                    int code = conn.getResponseCode();

                    // Manual redirect following
                    if (code >= 300 && code < 400) {
                        return ChunkResult.redirect(conn.getHeaderField("Location"));
                    }

                    if (code == 206) {
                        return handlePartialContent(conn, messageDigest, finalTotalSize, range);
                    }
                    else if (code == 200) {
                        if (finalOffset > 0) {
                            throw new ProtocolException("Server returned 200 OK for range request " + range + " from " + finalUrl);
                        }
                        logger.info("Server returned 200 OK for first range request (probably range requests are not supported); downloading full stream from {}", finalUrl);
                        messageDigest.reset(); // Reset in case we got here after a retry
                        try (InputStream is = conn.getInputStream()) {
                            long totalRead = updateDigestFromStream(is, messageDigest);
                            if (finalTotalSize >= 0 && totalRead != finalTotalSize) {
                                throw new IOException("Expected to read " + finalTotalSize + " bytes but read " + totalRead + " bytes from " + finalUrl);
                            }
                        }
                        return ChunkResult.fullStream(formatMessageDigest(messageDigest));
                    }
                    else {
                        throw new IOException("Unexpected response code " + code + " for " + finalUrl);
                    }
                }, "Error fetching range " + range + " from " + currentUrl, maxRetries, retrySleepMs);

                logger.debug("Processing chunk result for range {} from {}", range, currentUrl);

                if (result.type == ChunkResultType.FULL_STREAM_SUCCESS) {
                    redirectCount = 0;
                    logger.debug("Successfully processed full stream for range {} from {}", range, currentUrl);
                    return result.hash;
                }
                else if (result.type == ChunkResultType.REDIRECT) {
                    if (++redirectCount > maxRedirects) {
                        throw new ProtocolException("Too many redirects");
                    }
                    URL nextUrl = new URL(currentUrl, result.location);
                    if (!currentUrl.getAuthority().equals(nextUrl.getAuthority()) || !currentUrl.getProtocol().equals(nextUrl.getProtocol())) {
                        currentHeaders = null;
                    }
                    currentUrl = nextUrl;
                    logger.debug("Redirected to {}, currentHeaders stripped: {}", currentUrl, (currentHeaders == null));
                    // Skip offset update and retry the current chunk with new URL
                }
                else if (result.type == ChunkResultType.SUCCESS) {
                    redirectCount = 0;
                    currentUrl = item.url;
                    currentHeaders = extraHeaders;
                    offset += result.bytesRead;
                    if (totalSize < 0 && result.totalSize > 0) {
                        totalSize = result.totalSize;
                    }
                    logger.debug("Successfully processed chunk for range {} from {}", range, currentUrl);
                    logger.debug("Read {} of {}{}", offset, totalSize > 0 ? totalSize : "Unknown", totalSize > 0 ? " (" + (offset * 100L / totalSize) + "%)" : "");
                }
            }
            catch (ProtocolException e) {
                throw e;
            }
            catch (IOException e) {
                if (effectiveOptions.isFallBackToFullStreamOnRangeFail()) {
                    logger.info("Falling back to full stream for {} after failed range requests", currentUrl);
                    messageDigest.reset();
                    return hashFullStream(currentUrl, messageDigest, currentHeaders, hashOptions);
                }
                else {
                    logger.error("Failed range requests for {}, and fallback to full stream is disabled", currentUrl);
                    throw e;
                }
            }
        }

        return formatMessageDigest(messageDigest);
    }

    private static String hashFullStream(final URL url, final MessageDigest messageDigest, final Map<String, String> extraHeaders, final HashOptions hashOptions) throws IOException {
        HashOptions effectiveOptions = hashOptions == null ? HashOptions.systemProperties() : hashOptions;
        int maxRetries = effectiveOptions.getMaxRetries();
        int retrySleepMs = effectiveOptions.getRetrySleepMs();
        int maxRedirects = effectiveOptions.getMaxRedirects();

        for (int attempt = 0; attempt < maxRetries; attempt++) {
            URL currentUrl = url;
            Map<String, String> currentHeaders = extraHeaders;
            try {
                int redirectCount = 0;
                while (true) {
                    URLConnection conn = currentUrl.openConnection();
                    if (conn instanceof HttpURLConnection httpConn) {
                        httpConn.setInstanceFollowRedirects(false);
                        if (currentHeaders != null) {
                            for (Entry<String, String> entry : currentHeaders.entrySet()) {
                                httpConn.setRequestProperty(entry.getKey(), entry.getValue());
                            }
                        }
                        int code = httpConn.getResponseCode();
                        if (code >= 300 && code < 400) {
                            if (++redirectCount > maxRedirects) {
                                throw new ProtocolException("Too many redirects");
                            }
                            String location = httpConn.getHeaderField("Location");
                            URL nextUrl = new URL(currentUrl, location);
                            if (!currentUrl.getAuthority().equals(nextUrl.getAuthority()) || !currentUrl.getProtocol().equals(nextUrl.getProtocol())) {
                                currentHeaders = null;
                            }
                            currentUrl = nextUrl;
                            continue;
                        }
                        if (code != 200) {
                            throw new IOException("Unexpected response code " + code + " for " + currentUrl);
                        }
                    }
                    try (final InputStream is = conn.getInputStream()) {
                        updateDigestFromStream(is, messageDigest);
                    }
                    break;
                }
                return formatMessageDigest(messageDigest);
            }
            catch (ProtocolException e) {
                throw e;
            }
            catch (IOException e) {
                logger.warn("Error fetching full stream from {} (attempt {}/{}): {}", url, attempt + 1, maxRetries, e.getMessage());
                messageDigest.reset();
                if (attempt < maxRetries - 1) {
                    try {
                        Thread.sleep(retrySleepMs);
                    }
                    catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                        throw new IOException("Interrupted during retry sleep", ie);
                    }
                }
                else {
                    throw e;
                }
            }
        }
        throw new IOException("Max retries exceeded");
    }

    /**
     * Create a HEX formatted string checksum hash of the data from the URL
     *
     * @param url           the {@link URL} to hash
     * @param messageDigest the {@link MessageDigest} object representing the hashing algorithm
     * @param extraHeaders  optional extra headers to send with the request
     * @return the hash as a hex formatted string
     * @throws IOException if there is a problem reading from the URL
     */
    public static String hash(final URL url, final MessageDigest messageDigest, final Map<String, String> extraHeaders) throws IOException {
        return hash(new FetchItem(url, -1L, null), messageDigest, extraHeaders);
    }

    /**
     * Create a HEX formatted string checksum hash of the data from the URL
     *
     * @param url           the {@link URL} to hash
     * @param messageDigest the {@link MessageDigest} object representing the hashing algorithm
     * @param extraHeaders  optional extra headers to send with the request
     * @param hashOptions   optional settings for ranged HTTP requests
     * @return the hash as a hex formatted string
     * @throws IOException if there is a problem reading from the URL
     */
    public static String hash(final URL url, final MessageDigest messageDigest, final Map<String, String> extraHeaders, final HashOptions hashOptions) throws IOException {
        return hash(new FetchItem(url, -1L, null), messageDigest, extraHeaders, hashOptions);
    }

    private static ChunkResult handlePartialContent(HttpURLConnection conn, MessageDigest messageDigest, long currentTotalSize, String range) throws IOException {
        long totalSize = currentTotalSize;
        if (totalSize < 0) {
            String contentRange = conn.getHeaderField("Content-Range");
            if (contentRange != null && contentRange.contains("/")) {
                try {
                    totalSize = Long.parseLong(contentRange.substring(contentRange.lastIndexOf("/") + 1));
                }
                catch (NumberFormatException e) {
                    logger.warn("Could not parse Content-Range: {}", contentRange);
                }
            }
        }
        try (InputStream is = conn.getInputStream()) {
            byte[] bytes = is.readAllBytes();
            if (bytes.length == 0) {
                // N.B. getting the range from the connection at this point may throw an exception, so we use the range we already have.
                throw new IOException("Received empty response for range request " + range + " from " + conn.getURL());
            }
            messageDigest.update(bytes);
            logger.debug("Updated message digest with range {}", range);
            return ChunkResult.success(bytes.length, totalSize);
        }
    }

    private static ChunkResult executeWithRetry(RetryableOperation<ChunkResult> operation, String description, int maxRetries, int retrySleepMs) throws IOException {
        for (int attempt = 0; attempt < maxRetries; attempt++) {
            try {
                return operation.execute();
            }
            catch (ProtocolException e) {
                throw e;
            }
            catch (IOException e) {
                logger.warn("{} (attempt {}/{}): {}", description, attempt + 1, maxRetries, e.getMessage());
                if (attempt < maxRetries - 1) {
                    try {
                        Thread.sleep(retrySleepMs);
                    }
                    catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                        throw new IOException("Interrupted during retry sleep", ie);
                    }
                }
                else {
                    throw e;
                }
            }
        }
        throw new IOException("Max retries exceeded");
    }

    @FunctionalInterface
    private interface RetryableOperation<T> {
        T execute() throws IOException;
    }

    private enum ChunkResultType {
        SUCCESS, REDIRECT, FULL_STREAM_SUCCESS
    }

    /**
     * Represents the result of a chunk operation when processing data for hashing. A chunk operation can have various results, such as a successful read, a redirection to another location, or
     * successful processing of a complete stream.
     */
    private static class ChunkResult {
        final ChunkResultType type;
        final long bytesRead;
        final long totalSize;
        final String location;
        final String hash;

        private ChunkResult(ChunkResultType type, long bytesRead, long totalSize, String location, String hash) {
            this.type = type;
            this.bytesRead = bytesRead;
            this.totalSize = totalSize;
            this.location = location;
            this.hash = hash;
        }

        static ChunkResult success(long bytesRead, long totalSize) {
            return new ChunkResult(ChunkResultType.SUCCESS, bytesRead, totalSize, null, null);
        }

        static ChunkResult redirect(String location) {
            return new ChunkResult(ChunkResultType.REDIRECT, 0, -1, location, null);
        }

        static ChunkResult fullStream(String hash) {
            return new ChunkResult(ChunkResultType.FULL_STREAM_SUCCESS, 0, -1, null, hash);
        }
    }

    /**
     * Opens a HttpURLConnection for the given range.
     */
    private static HttpURLConnection openRangedConnection(URL url, String range, final Map<String, String> extraHeaders) throws IOException {
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        if (extraHeaders != null) {
            for (Entry<String, String> entry : extraHeaders.entrySet()) {
                conn.setRequestProperty(entry.getKey(), entry.getValue());
            }
        }
        conn.setInstanceFollowRedirects(false);
        if (range != null) {
            conn.setRequestProperty("Range", range);
        }
        return conn;
    }

    /**
     * Reads from the InputStream and updates the MessageDigest. Returns the number of bytes read.
     */
    private static long updateDigestFromStream(InputStream is, MessageDigest messageDigest) throws IOException {
        byte[] buffer = new byte[CHUNK_SIZE];
        long totalRead = 0;
        int read = is.read(buffer);
        while (read != -1) {
            messageDigest.update(buffer, 0, read);
            totalRead += read;
            read = is.read(buffer);
        }
        return totalRead;
    }

    /**
     * Update the Manifests with the file's hash
     *
     * @param path                       the {@link Path} (file) to hash
     * @param manifestToMessageDigestMap the map between {@link Manifest} and {@link MessageDigest}
     * @throws IOException if there is a problem reading the file
     */
    public static void hash(final Path path, final Map<Manifest, MessageDigest> manifestToMessageDigestMap) throws IOException {
        updateMessageDigests(path, manifestToMessageDigestMap.values());
        addMessageDigestHashToManifest(path, manifestToMessageDigestMap);
    }

    static void updateMessageDigests(final Path path, final Collection<MessageDigest> messageDigests) throws IOException {
        try (final InputStream is = new BufferedInputStream(Files.newInputStream(path, StandardOpenOption.READ))) {
            final byte[] buffer = new byte[CHUNK_SIZE];
            int read = is.read(buffer);

            while (read != -1) {
                for (final MessageDigest messageDigest : messageDigests) {
                    messageDigest.update(buffer, 0, read);
                }
                read = is.read(buffer);
            }
        }
    }

    private static void addMessageDigestHashToManifest(final Path path, final Map<Manifest, MessageDigest> manifestToMessageDigestMap) {
        for (final Entry<Manifest, MessageDigest> entry : manifestToMessageDigestMap.entrySet()) {
            final String hash = formatMessageDigest(entry.getValue());
            logger.debug(messages.getString("adding_checksum"), path, hash);
            entry.getKey().getFileToChecksumMap().put(path, hash);
        }
    }

    //Convert the byte to hex format
    private static String formatMessageDigest(final MessageDigest messageDigest) {
        try (final Formatter formatter = new Formatter()) {
            for (final byte b : messageDigest.digest()) {
                formatter.format("%02x", b);
            }

            return formatter.toString();
        }
    }

    /**
     * create a mapping between {@link Manifest} and {@link MessageDigest} for each each supplied {@link SupportedAlgorithm}
     *
     * @param algorithms the {@link SupportedAlgorithm} that you which to map to {@link MessageDigest}
     * @return mapping between {@link Manifest} and {@link MessageDigest}
     * @throws NoSuchAlgorithmException if {@link MessageDigest} doesn't support the algorithm
     */
    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
    public static Map<Manifest, MessageDigest> createManifestToMessageDigestMap(final Collection<SupportedAlgorithm> algorithms) throws NoSuchAlgorithmException {
        final Map<Manifest, MessageDigest> map = new HashMap<>();

        for (final SupportedAlgorithm algorithm : algorithms) {
            final MessageDigest messageDigest = MessageDigest.getInstance(algorithm.getMessageDigestName());
            final Manifest manifest = new Manifest(algorithm);
            map.put(manifest, messageDigest);
        }

        return map;
    }

    /**
     * Represents configuration options for hashing operations. This class is immutable and provides various configurable parameters such as chunk size, retry behavior, and redirection limits.
     */
    public static final class HashOptions {
        private final int chunkSize;
        private final int maxRetries;
        private final int retrySleepMs;
        private final int maxRedirects;
        private final boolean fallBackToFullStreamOnRangeFail;

        /**
         * Constructs an instance of {@code HashOptions} with specific configuration settings for chunk size, retry behavior, and redirection limits.
         *
         * @param chunkSize    the size of data chunks in bytes; must be greater than 0
         * @param maxRetries   the maximum number of retry attempts; must be greater than 0
         * @param retrySleepMs the time in milliseconds to sleep between retries
         * @param maxRedirects the maximum number of redirects allowed; must be greater than or equal to 0
         * @throws IllegalArgumentException if any of the provided values are invalid (e.g., negative or zero where not allowed)
         */
        public HashOptions(final int chunkSize, final int maxRetries, final int retrySleepMs, final int maxRedirects, boolean fallBackToFullStreamOnRangeFail) {
            if (chunkSize <= 0) {
                throw new IllegalArgumentException("chunkSize must be greater than 0");
            }
            if (maxRetries <= 0) {
                throw new IllegalArgumentException("maxRetries must be greater than 0");
            }
            if (maxRedirects < 0) {
                throw new IllegalArgumentException("maxRedirects must be greater than or equal to 0");
            }
            this.chunkSize = chunkSize;
            this.maxRetries = maxRetries;
            this.retrySleepMs = retrySleepMs;
            this.maxRedirects = maxRedirects;
            this.fallBackToFullStreamOnRangeFail = fallBackToFullStreamOnRangeFail;
        }

        /**
         * Creates an instance of {@code HashOptions} using system properties to determine the configuration values for chunk size, maximum retries, retry sleep time, and maximum redirects. Defaults
         * are used if the respective system properties are not set.
         *
         * The following system properties are used: - {@code CHUNK_SIZE_PROP}: Configures the chunk size in bytes (default: {@code DEFAULT_CHUNK_SIZE}). - {@code MAX_RETRIES_PROP}: Configures the
         * maximum number of retry attempts (default: {@code DEFAULT_MAX_RETRIES}). - {@code RETRY_SLEEP_MS_PROP}: Configures the sleep time in milliseconds between retries (default:
         * {@code DEFAULT_RETRY_SLEEP_MS}). - {@code MAX_REDIRECTS_PROP}: Configures the maximum allowable redirects (default: {@code DEFAULT_MAX_REDIRECTS}).
         *
         * @return a {@code HashOptions} instance populated with configuration values derived from system properties or their default values.
         * @throws IllegalArgumentException if any of the retrieved values are invalid (e.g., negative or zero where not allowed)
         */
        public static HashOptions systemProperties() {
            return new HashOptions(
                Integer.getInteger(CHUNK_SIZE_PROP, DEFAULT_CHUNK_SIZE),
                Integer.getInteger(MAX_RETRIES_PROP, DEFAULT_MAX_RETRIES),
                Integer.getInteger(RETRY_SLEEP_MS_PROP, DEFAULT_RETRY_SLEEP_MS),
                Integer.getInteger(MAX_REDIRECTS_PROP, DEFAULT_MAX_REDIRECTS),
                Boolean.parseBoolean(System.getProperty(FALL_BACK_TO_FULL_STREAM_ON_RANGE_FAIL_PROP, String.valueOf(DEFAULT_FALL_BACK_TO_FULL_STREAM_ON_RANGE_FAIL)))
            );
        }

        /**
         * Returns a new {@code HashOptions} instance with the specified overrides for configuration parameters. If a parameter is {@code null}, the existing value from the current {@code HashOptions}
         * instance is used.
         *
         * @param chunkSize    the size of data chunks in bytes; if {@code null}, the existing chunk size is retained
         * @param maxRetries   the maximum number of retry attempts; if {@code null}, the existing max retries value is retained
         * @param retrySleepMs the time in milliseconds to sleep between retries; if {@code null}, the existing retry sleep time is retained
         * @param maxRedirects the maximum number of redirects allowed; if {@code null}, the existing max redirects value is retained
         * @return a new {@code HashOptions} instance with the specified values or the existing values if overrides are {@code null}
         * @throws IllegalArgumentException if any of the provided values are invalid (e.g., negative or zero where not allowed)
         */
        public HashOptions withOverrides(final Integer chunkSize, final Integer maxRetries, final Integer retrySleepMs, final Integer maxRedirects) {
            return withOverrides(chunkSize, maxRetries, retrySleepMs, maxRedirects, null);
        }

        /**
         * Returns a new {@code HashOptions} instance with the specified overrides for configuration parameters. If a parameter is {@code null}, the existing value from the current {@code HashOptions}
         * instance is used.
         *
         * @param chunkSize    the size of data chunks in bytes; if {@code null}, the existing chunk size is retained
         * @param maxRetries   the maximum number of retry attempts; if {@code null}, the existing max retries value is retained
         * @param retrySleepMs the time in milliseconds to sleep between retries; if {@code null}, the existing retry sleep time is retained
         * @param maxRedirects the maximum number of redirects allowed; if {@code null}, the existing max redirects value is retained
         * @param fallBackToFullStreamOnRangeFail whether to fall back to full stream; if {@code null}, the existing value is retained
         * @return a new {@code HashOptions} instance with the specified values or the existing values if overrides are {@code null}
         * @throws IllegalArgumentException if any of the provided values are invalid (e.g., negative or zero where not allowed)
         */
        public HashOptions withOverrides(final Integer chunkSize, final Integer maxRetries, final Integer retrySleepMs, final Integer maxRedirects, final Boolean fallBackToFullStreamOnRangeFail) {
            return new HashOptions(
                chunkSize == null ? this.chunkSize : chunkSize,
                maxRetries == null ? this.maxRetries : maxRetries,
                retrySleepMs == null ? this.retrySleepMs : retrySleepMs,
                maxRedirects == null ? this.maxRedirects : maxRedirects,
                fallBackToFullStreamOnRangeFail == null ? this.fallBackToFullStreamOnRangeFail : fallBackToFullStreamOnRangeFail
            );
        }

        public int getChunkSize() {
            return chunkSize;
        }

        public int getMaxRetries() {
            return maxRetries;
        }

        public int getRetrySleepMs() {
            return retrySleepMs;
        }

        public int getMaxRedirects() {
            return maxRedirects;
        }

        public boolean isFallBackToFullStreamOnRangeFail() {
            return fallBackToFullStreamOnRangeFail;
        }
    }
}