#!/usr/bin/env bash

# Verify a checksum manifest of a folder or a file.
#
# Copyright (c) 2003-2026 by Reto Kromer <https://reto.ch/>
#
# This Bash script is released under a 3-Clause BSD License and is provided
# "as is" without warranty or support of any kind.


# initialise constants
VERSION='2026-01-25'
SCRIPT_NAME="$(basename "$0")"
CONFIG_FILE="${HOME}/.config/AVpres/Bash_AVpres/${SCRIPT_NAME}.txt"
RED='\033[1;31m'
BLUE='\033[1;34m'
NC='\033[0m'

# load configuration file if any and initialise default values
[[ -f "${CONFIG_FILE}" ]] && . "${CONFIG_FILE}"
default_algorithm="${default_algorithm:-xxh128}"
crc32="${crc32:-$(which crc32)}"
md5="${md5:-$(which md5sum)}"
sha1="${sha1:-$(which sha1sum)}"
sha256="${sha256:-$(which sha256sum)}"
sha512="${sha512:-$(which sha512sum)}"
xxh32="${xxh32:-$(which xxhsum) -H32}"
xxh64="${xxh64:-$(which xxhsum) -H64}"
xxh128="${xxh128:-$(which xxhsum) -H128}"
verbosity="${verbosity:-count}"
suffix="${suffix:-_${default_algorithm}}"
extension="${extension:-txt}"
if (( "${#exclusion[@]}" == 0 )); then
  exclusion=('.DS_Store' 'desktop.ini')
elif [[ "${exclusion}" == '' ]]; then
  unset exclusion
fi

# initialise other default values
algorithm_regex='^(crc32|md5|sha1|sha256|sha512|xxh32|xxh64|xxh128)$'
verbosity_regex='^(path|filename|count|off)$'

# initialise variables
unset algorithm
unset input_path
unset input_manifest


# get date-and-time stamp
date_time() {
  TZ='UTC' date +'[%F %T %Z]'
}


# start log file
[[ -d '/tmp/AVpres' ]] || mkdir -p '/tmp/AVpres'
LOG_FILE="$(mktemp "/tmp/AVpres/${SCRIPT_NAME}.XXXXXXXXXX")"
TMP_MANIFEST="$(mktemp -q "/tmp/AVpres/tmp_${SCRIPT_NAME}.XXXXXXXXXX")"
echo "$(date_time) ${SCRIPT_NAME} ${VERSION}" > "${LOG_FILE}"
echo "$(date_time) $0 $*" >> "${LOG_FILE}"
echo "$(date_time) START" >> "${LOG_FILE}"

# check that Bash 3.2 or later is running
bash_version="$(bash -c 'echo ${BASH_VERSION}')"
echo "$(date_time) running bash version = '${bash_version}'" >> "${LOG_FILE}"
if ! printf '%s\n%s\n' "${bash_version}" "3.2" | sort -rVC; then
  echo -e "${BLUE}Warning: This 'bash' binary is very old."
  echo -e "We strongly recommend that you use the current version 5.3.${NC}"
fi


# print an error message and exit with status 1
abort() {
  echo -e "${RED}${1:-An unknown error occurred.\a}${NC}"
  echo "$(date_time) ${1:-An unknown error occurred.}" >> "${LOG_FILE}"
  rm "${TMP_MANIFEST}"
  echo "$(date_time) END" >> "${LOG_FILE}"
  exit 1
}


# print a minimal help message and exit with status 1
print_prompt() {
  echo "$(date_time) print prompt" >> "${LOG_FILE}"
  cat << EOF
Help:
  ${SCRIPT_NAME} -h
EOF
  rm "${TMP_MANIFEST}"
  echo "$(date_time) END" >> "${LOG_FILE}"
  exit 1
}


# print the help message and exit with status 0
print_help() {
  local tmp
  if [[ -f "${CONFIG_FILE}" ]]; then
    tmp="local configuration file found and loaded"
  else
    tmp="no local configuration file found on this computer"
  fi

  echo "$(date_time) print help" >> "${LOG_FILE}"
  cat << EOF

Usage:
  ${SCRIPT_NAME} -i <input_path> [-m <input_manifest>]
  ${SCRIPT_NAME} -h | -x
Options:
  -i  input folder or file
  -m  manifest file (default is '<input_path>${suffix}.${extension}')
  -h  this help
  -x  advanced options with their default arguments
      (${tmp})
Dependency:
  xxhsum (default), md5sum, sha1sum, sha256sum, sha512sum or crc32
See also:
  man ${SCRIPT_NAME}
  https://avpres.net/Bash_AVpres/
About:
  Abstract: Verify a checksum manifest of a folder or file
  Version:  ${VERSION}

EOF
  rm "${TMP_MANIFEST}"
  echo "$(date_time) END" >> "${LOG_FILE}"
  exit 0
}


# print advanced options with their default arguments and exit with status 0
print_options() {
  local each
  local tmp

  echo "$(date_time) print parameters" >> "${LOG_FILE}"
  for each in "${exclusion[@]}"; do
    tmp="$tmp '${each}'"
  done
  if [[ -f "${CONFIG_FILE}" ]]; then
    cat << EOF

Local configuration file
  '${CONFIG_FILE}'
found and loaded.
EOF
  else
    cat << EOF

No local configuration file for '${SCRIPT_NAME}' found on this computer.
EOF
  fi
  cat << EOF

Hash checksum default parameters:
  --algorithm='${default_algorithm}'
  --crc32='${crc32}'
  --md5='${md5}'
  --sha1='${sha1}'
  --sha256='${sha256}'
  --sha512='${sha512}'
  --xxh32='${xxh32}'
  --xxh64='${xxh64}'
  --xxh128='${xxh128}'

Manifest file default parameters:
  --suffix='${suffix}'
  --extension='${extension}'

Exclusion list default parameter:
  --exclusion=(${tmp# })

EOF
  rm "${TMP_MANIFEST}"
  echo "$(date_time) END" >> "${LOG_FILE}"
  exit 0
}


# check algorithm and hash checksum command
check_algorithm() {
  local tmp_algorithm="${algorithm}"
  local tmp_suffix

  # check is a passed algorithm is valid
  if [[ "${algorithm}" ]]; then
    if [[ ! $(echo "${algorithm}" | grep -E "${algorithm_regex}") ]]; then
      abort "Error: Algorithm '${algorithm}' is not valid."
    fi
  fi

  # check if the manifest filename contains the algorithm as suffix
  tmp_suffix="${input_manifest##*_}"
  if [[ $(echo "${algorithm}" | grep -E "${algorithm_regex}") ]]; then
    :
  elif [[ ! $(echo "${tmp_suffix}" | grep -E "${algorithm_regex}") ]]; then
    algorithm="${tmp_suffix%.*}"
  fi

  # check that algorithm is valid
  if [[ ! "${algorithm}" ]]; then
    algorithm="${default_algorithm}"
  else
    algorithm="$(echo "${algorithm}" | awk '{print tolower($0)}')"
    if [[ ! $(echo "${algorithm}" | grep -E "${algorithm_regex}") ]]; then
      abort "Error: Algorithm '${tmp_algorithm}' is not valid."
    fi
  fi

  # check if hash checksum command is running
  if ! command -v ${!algorithm} &> /dev/null; then
    abort "Error: '${algorithm}' is failing."
  fi

  if [[ "${suffix}" != "_${algorithm}" \
    && "${algorithm}" != 'md5' \
    &&  "${suffix}" == '_md5' ]]
  then
    suffix="_${algorithm}"
  fi
  echo "$(date_time) ${algorithm}='${!algorithm}'" >> "${LOG_FILE}"

  return 0
}


# verify input
verify_input() {
  local in_path="${1}"

  echo "$(date_time) verify input" >> "${LOG_FILE}"
  if [[ ! "${in_path}" ]]; then
    abort "Error: No input file passed."
  elif [[ ! -d "${in_path}" && ! -f "${in_path}" ]]; then
    abort "Error: '${in_path}' is not valid."
  fi
  echo "$(date_time) input='${in_path}'" >> "${LOG_FILE}"

  return 0
}


# verify manifest and normalise path if needed
verify_manifest() {
  local in_manifest="${1}"

  echo "$(date_time) verify manifest" >> "${LOG_FILE}"
  if [[ ! "${in_manifest}" ]]; then
    if [[ -f "${input_path%.*}${suffix}.${extension}" ]]; then
      input_manifest="${input_path%.*}${suffix}.${extension}"
    elif [[ -f "${input_path%.*}_${input_path##*.}${suffix}.${extension}" ]]; then
      input_manifest="${input_path%.*}_${input_path##*.}${suffix}.${extension}"
    else
      abort "Error: No manifest file passed."
    fi
  elif [[ ! -f "${in_manifest}" ]]; then
    abort "Error: '${in_manifest}' is not a manifest file."
  fi
  if [[ "${input_manifest%/*}" == "${input_manifest}" ]]; then
    input_manifest="${PWD}/${input_manifest}"
  fi
  echo "$(date_time) manifest='${input_manifest}'" >> "${LOG_FILE}"

  return 0
}


# generate checksum manifest
make_manifest() {
  local in_file="${1}"
  local out_file="${2}"

  echo -e "${BLUE}Please wait while verifying checksum manifest...${NC}"
  echo "$(date_time) generating checksum manifest" >> "${LOG_FILE}"
  if [[ -f "${in_file}" ]]; then
    ${!algorithm} "${in_file}" | sed "s#${in_file%/*}/##" > "${out_file}"
    return 0
  fi
  if [[ -d "${in_file}" ]]; then
    for each in "${exclusion[@]}"; do
      find "${input_path}" -name "${each}" -type f -delete
    done
    cd "${in_file}" || abort "Error: 'cd ${in_file}' is failing."
  fi
  if ! find . -type f -print0 | xargs -0 ${!algorithm} | sort -k2 \
    > "${TMP_MANIFEST}"
  then
    abort "Fatal error, see '${LOG_FILE}'."
  fi

  return 0
}


# parse and process provided input
(( $# == 0 )) && print_prompt
while getopts ":i:m:-:hx" opt; do
  case "${opt}" in
    i) if [[ "${OPTARG:0:1}" == '-' ]]; then
         abort "Error: The option '-i' requires an argument."
       else
         input_path="${OPTARG}"
       fi ;;
    m) if [[ "${OPTARG:0:1}" == '-' ]]; then
         abort "Error: The option '-o' requires an argument."
       else
         input_manifest="${OPTARG}"
       fi ;;
    -) case "${OPTARG}" in
         input=?*) input_path="${OPTARG#*=}" ;;
         manifest=?*) input_manifest="${OPTARG#*=}" ;;
         verbosity=?*) verbosity="${OPTARG#*=}" ;;
         suffix=?*) suffix="${OPTARG#*=}" ;;
         extension=?*) extension="${OPTARG#*=}" ;;
         algorithm=?*) algorithm="${OPTARG#*=}" ;;
         md5=?*) md5="${OPTARG#*=}" ;;
         sha1=?*) sha1="${OPTARG#*=}" ;;
         sha256=?*) sha256="${OPTARG#*=}" ;;
         sha512=?*) sha512="${OPTARG#*=}" ;;
         xxh32=?*) xxh32="${OPTARG#*=}" ;;
         xxh64=?*) xxh64="${OPTARG#*=}" ;;
         xxh128=?*) xxh128="${OPTARG#*=}" ;;
         crc32=?*) crc32="${OPTARG#*=}" ;;
         help) print_help ;;
         options) print_options ;;
         *) abort "Error: The option '--${OPTARG}' is not valid." ;;
       esac ;;
    h) print_help ;;
    x) print_options ;;
    :) abort "Error: The option '-${OPTARG}' requires an argument." ;;
    *) abort "Error: The option '-${OPTARG}' is not valid." ;;
  esac
done

# check algorithm and hash checksum command
check_algorithm

# check that input provided is valid and normalise path if needed
if [[ "${input_path%/*}" == "${input_path}" ]]; then
  input_path="${PWD}/${input_path}"
fi
verify_input "${input_path}"
verify_manifest "${input_manifest}"

# generate checksum manifest
make_manifest "${input_path}" "${TMP_MANIFEST}"

# compare the provided and the generated checksum manifests
if ! diff -b <(cat "${input_manifest}" | sort -k2) \
  <(sed "s#\./##" < "${TMP_MANIFEST}" | sort -k2) \
  >> "${LOG_FILE}" 2>&1
then
  abort "Error: The file and the manifest don't match, see '${LOG_FILE}'."
fi
rm "${TMP_MANIFEST}"
echo -e "${BLUE}... all is okay!${NC}"

# end log file
echo "$(date_time) END" >> "${LOG_FILE}"
