# This file will be sourced by the shell bash.
#
# Filename: integrity-database.bash
# Version: 1.0-beta-5
# Release date: 2017-08-25
# Intended compatibility: WSUS Offline Update Version 11.0.1 and newer
#
# Copyright (C) 2016-2017 Hartmut Buhrmester
#                         <zo3xaiD8-eiK1iawa@t-online.de>
#
# License
#
#     This file is free software: you can redistribute it and/or modify
#     it under the terms of the GNU General Public License as published
#     by the Free Software Foundation, either version 3 of the License,
#     or (at your option) any later version.
#
#     This file is distributed in the hope that it will be useful,
#     but WITHOUT ANY WARRANTY; without even the implied warranty of
#     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
#     General Public License for more details.
#
#     You should have received a copy of the GNU General
#     Public License along with this program.  If not, see
#     <http://www.gnu.org/licenses/>.
#
# Description
#
#     This file uses hashdeep to maintain an integrity database.
#
#     It also allows an easy check for the integrity of downloaded
#     files: for all security updates, the SHA-1 checksum is
#     embedded into the filename. For example, if the filename is
#     "ndp35sp1-kb958484-x64_e69006433c1006c53da651914dc8162bbdd80d41.exe",
#     then "e69006433c1006c53da651914dc8162bbdd80d41" is the SHA-1 hash,
#     consisting of 40 hexadecimal digits. hashdeep calculates the
#     MD5, SHA-1 and SHA-256 checksums itself, and then the calculated
#     checksums can be compared to those in the filenames.


function verify_integrity_database ()
{
    local hashed_dir="$1"
    local hashes_file="$2"
    # Delete the element "client/" from the pathname, because hashdeep
    # will be called from wsusoffline/client/md/. These directory changes
    # could probably be omitted by using the bare option (-b) in hashdeep,
    # which simply strips any leading directory information. This would
    # not cause problems, since every hashdeep file corresponds to one
    # download directory only. Only for wddefs and msse, there are two
    # subdirectories.
    local hashed_dir_truncated="${hashed_dir/'client/'/}"
    local hashes_file_basename="${hashes_file##*/}"
    local hashdeep_output=""
    local initial_errors="${runtime_errors}"
    mkdir -p "../client/md"

    # Preconditions
    #
    # If the verification of the integrity database is disabled by
    # preferences settings, then this function returns 0 and any existing
    # checksum files will be deleted (since they will be invalid after
    # new downloads).
    if [[ "$use_integrity_database" == "disabled" ]]; then
        log_info_message "Verification of integrity database is disabled in preferences.bash"
        rm -f "${hashes_file}"
        return 0
    fi

    log_info_message "Verifying the integrity of existing files in the directory ${hashed_dir} ..."

    # When the script is first run, neither the hashed directory nor
    # the hashes file exists yet. This is not an error; it only means,
    # that the download task must be run once to create the file.
    if ! require_directory "${hashed_dir}"; then
        log_info_message "The download directory ${hashed_dir} does not exist yet. This is normal during the first run of the script."
        return 0
    fi
    if ! require_non_empty_file "${hashes_file}"; then
        log_info_message "The checksum file ${hashes_file} does not exist yet. This is normal during the first run of the script."
        return 0
    fi

    # Create a copy of the hashes file in Linux format
    cat_dos "${hashes_file}" | tr '\\' '/' > "${temp_dir}/${hashes_file_basename}"

    pushd "../client/md" > /dev/null
    if [[ "${hashed_dir_truncated}" == "../dotnet" ]]; then
        if ! hashdeep_output="$(hashdeep -a -vv -k "${temp_dir}/${hashes_file_basename}" -l ../dotnet/*.exe 2>&1)"; then
            runtime_errors="$(( runtime_errors + 1 ))"
        fi
    else
        if ! hashdeep_output="$(hashdeep -a -vv -k "${temp_dir}/${hashes_file_basename}" -l -r "$hashed_dir_truncated" 2>&1)"; then
            runtime_errors="$(( runtime_errors + 1 ))"
        fi
    fi
    popd > /dev/null

    if (( runtime_errors == initial_errors )); then
        log_info_message "$hashdeep_output"
        log_info_message "Verified the integrity of existing files in the directory ${hashed_dir}"
    else
        log_error_message "$hashdeep_output"
        log_error_message "The directory ${hashed_dir} has changed since last creating the integrity database"
        rm "${hashes_file}"
    fi

    # Integrity database verification errors are only reported and
    # written to the logfile. The script may just go on, because the
    # hashdeep files will be rebuilt anyway.
    return 0
}


function create_integrity_database ()
{
    local hashed_dir="$1"
    local hashes_file="$2"
    # Delete the element "client/" from the pathname, because hashdeep
    # will be called from wsusoffline/client/md/.
    local hashed_dir_truncated="${hashed_dir/'client/'/}"
    local hashes_file_basename="${hashes_file##*/}"
    local filecount=0
    local hashdeep_error_output=""
    local initial_errors="${runtime_errors}"
    mkdir -p "../client/md"
    rm -f "${hashes_file}"

    # Preconditions
    if [[ "$use_integrity_database" == "disabled" ]]; then
        log_info_message "Creation of integrity database is disabled in preferences.bash"
        return 0
    fi
    if ! require_directory "${hashed_dir}"; then
        log_warning_message "Aborted creation of integrity database, because the directory \"${hashed_dir}\" does not exist"
        return 0
    fi

    # Count files to prevent errors and the creation of empty hashdeep
    # files
    if [[ "${hashed_dir}" == "../client/dotnet" ]]; then
        filecount="$(find "${hashed_dir}" -maxdepth 1 -type f | wc -l)"
    else
        filecount="$(find "${hashed_dir}" -type f | wc -l)"
    fi
    if (( filecount == 0 )); then
        log_warning_message "Aborted creation of integrity database, because the directory \"${hashed_dir}\" is empty"
        return 0
    fi

    log_info_message "Creating integrity database for directory ${hashed_dir} ..."

    # The two subdirectories dotnet/x86-glb and dotnet/x64-glb are not
    # included in the hashes file. Therefore, the recursive option for
    # hashdeep can not be used for the dotnet directory.
    #
    # Note: standard output is written to the hashes file, but error
    # output can be caught in a variable for reference.

    pushd "../client/md" > /dev/null
    if [[ "${hashed_dir}" == "../client/dotnet" ]]; then
        if ! hashdeep_error_output="$( { hashdeep -c md5,sha1,sha256 -l ../dotnet/*.exe | tr '/' '\\' > "$hashes_file_basename"; } 2>&1 )"; then
            runtime_errors="$(( runtime_errors + 1 ))"
        fi
    else
        if ! hashdeep_error_output="$( { hashdeep -c md5,sha1,sha256 -l -r "$hashed_dir_truncated" | tr '/' '\\' > "$hashes_file_basename"; } 2>&1 )"; then
            runtime_errors="$(( runtime_errors + 1 ))"
        fi
    fi
    popd > /dev/null

    if (( runtime_errors == initial_errors )); then
        if ensure_non_empty_file "${hashes_file}"; then
            log_info_message "Created file ${hashes_file##*/}"
        else
            log_warning_message "File ${hashes_file##*/} was empty"
        fi
    else
        log_error_message "$hashdeep_error_output"
        log_error_message "Creation of hashes file failed"
        rm -f "${hashes_file}"
    fi
    return 0
}


function verify_embedded_checksums ()
{
    local hashed_dir="$1"
    local hashes_file="$2"
    # Delete the element "client/" from the pathname
    local hashed_dir_truncated="${hashed_dir/'client/'/}"
    local hashes_file_basename="${hashes_file##*/}"
    local file_size=""          # field 1 in the CSV-formatted hashes file
    local md5_calculated=""     # field 2
    local sha1_calculated=""    # field 3
    local sha256_calculated=""  # field 4
    local file_path=""          # field 5
    local sha1_embedded=""      # checksum embedded in the filename
    local initial_errors="${runtime_errors}"
    mkdir -p "../client/md"

    # Preconditions
    if [[ "$use_integrity_database" == "disabled" ]]; then
        log_info_message "Verification of embedded file checksums is disabled in preferences.bash"
        rm -f "${hashes_file}"
        return 0
    fi

    require_directory "${hashed_dir}" || return 0
    require_non_empty_file "${hashes_file}" || return 0

    log_info_message "Verifying embedded SHA1 hashes for directory ${hashed_dir} ..."

    # Skip the comments and read the hashes file starting at line 6.
    # Extract only records with embedded SHA-1 hashes (a string of
    # 40 hexadecimal digits).
    tail -n +6 "${hashes_file}" |
        tr '\\' '/' |
        grep -E '_[[:xdigit:]]{40}[.][[:alpha:]]{3}' \
            > "${temp_dir}/sha-1-${hashes_file_basename}" || true

    while IFS=',' read -r file_size md5_calculated sha1_calculated \
                       sha256_calculated file_path
    do
        # GNU grep can conveniently use:
        #
        # grep -E --only-matching '[[:xdigit:]]{40}'
        #
        # to extract the SHA-1 hash, but this was replaced with sed
        # for compatibility.
        sha1_embedded="$(sed 's/.*_\([[:xdigit:]]\{40\}\).*/\1/g' <<< "${file_path}" || true)"
        if [[ "${sha1_calculated}" != "${sha1_embedded}" ]]; then
            pushd "../client/md" > /dev/null
            trash_file "${file_path}"
            popd > /dev/null

            runtime_errors="$(( runtime_errors + 1 ))"
            log_error_message "Deleted file ${file_path##*/} due to mismatching SHA-1 message digest."

            # Rewrite the hashes file without the deleted file
            mv "${hashes_file}" "${hashes_file}.bak"
            grep -F -v "${sha1_calculated}" "${hashes_file}.bak" > "${hashes_file}" || true
            rm "${hashes_file}.bak"
        fi
    done < "${temp_dir}/sha-1-${hashes_file_basename}"

    if (( runtime_errors == initial_errors )); then
        log_info_message "Verified embedded SHA1 hashes"
    else
        log_error_message "Verification of embedded SHA1 hashes detected $(( runtime_errors - initial_errors )) errors"
    fi
    return 0
}

return 0
