0
\$\begingroup\$

This question is the second follow-up of: Shell POSIX OpenSSL file decryption script

The first follow-up was: Shell POSIX OpenSSL file decryption script follow-up #1

Both of which have proven very useful and sped up my learning curve of shell scripting.

You should probably look at them, but as I made so many changes, I feel it is not necessary.

All reviews are welcome, feel free to also comment in case of minor points.


#!/bin/sh

###############################################################################
##                OpenSSL file decryption POSIX shell script                 ##
##                             revision: 0.9                                 ##
##                        GitHub: https://git.io/fxslm                       ##
###############################################################################

# shellcheck disable=SC2016
# disable shellcheck information SC2016 globally for the script
# link to wiki: https://github.com/koalaman/shellcheck/wiki/SC2016
# reason: the script's main parts use constructs like that

# treat unset variables as an error when substituting
set -o nounset

# pipe will be considered successful only if all the commands involved are executed without errors
# ERROR: Illegal option -o pipefail. This likely works in Bash and alike only.
#set -o pipefail

#------------------------------------------------------------------------------

print_error_and_exit()
# expected arguments:
# $1 = exit code
# $2 = error origin (usually function name)
# $3 = error message
{
    # redirect all output of this function to standard error stream
    exec 1>&2

    # check if exactly 3 arguments have been passed
    # if not, print out an internal error without colors
    if [ "${#}" -ne 3 ]
    then
        printf 'print_error_and_exit internal error\n\n\tWrong number of arguments has been passed: %b!\n\tExpected the following 3:\n\t\t$1 - exit code\n\t\t$2 - error origin\n\t\t$3 - error message\n\nexit code = 1\n' "${#}"
        exit 1
    fi

    # check if the first argument is a number
    # if not, print out an internal error without colors
    if ! [ "${1}" -eq "${1}" ] 2> /dev/null
    then
        printf 'print_error_and_exit internal error\n\n\tThe first argument is not a number: %b!\n\tExpected an exit code from the script.\n\nexit code = 1\n' "${1}"
        exit 1
    fi

    # check if we have color support
    if command -v tput > /dev/null 2>&1 && tput setaf 1 > /dev/null 2>&1
    then
        # color definitions
        readonly bold=$(tput bold)
        readonly red=$(tput setaf 1)
        readonly yellow=$(tput setaf 3)
        readonly nocolor=$(tput sgr0)

        # here we do have color support, so we highlight the error origin and the exit code
        printf '%b%b\n\n\t%b%b%b\n\nexit code = %b%b\n' \
            "${bold}${yellow}" "${2}" "${nocolor}" \
            "${3}" \
            "${bold}${red}" "${1}" "${nocolor}"

        exit "${1}"
    else
        # here we do not have color support
        printf '%b\n\n\t%b\n\nexit code = %b\n' \
            "${2}" "${3}" "${1}"

        exit "${1}"
    fi
}

#------------------------------------------------------------------------------

# in this function, the SC2120 warning is irrelevant and safe to ignore
# link to wiki: https://github.com/koalaman/shellcheck/wiki/SC2120
# shellcheck disable=SC2120
am_i_root()
# expected arguments: none
{
    # check if no argument has been passed
    [ "${#}" -eq 0 ] || print_error_and_exit 1 "am_i_root" "Some arguments have been passed to the function!\\n\\tNo arguments expected.\\n\\tPassed: ${*}"

    # check if the user is root
    # this will return an exit code of the command itself directly
    [ "$(id -u)" -eq 0 ]
}

# check if the user had by any chance run the script with root privileges
# if you need to run it as root, feel free to comment out the line below
# in this function call, the SC2119 information is irrelevant and safe to ignore
# link to wiki: https://github.com/koalaman/shellcheck/wiki/SC2119
# shellcheck disable=SC2119
am_i_root && print_error_and_exit 1 "am_i_root" "Running this script with root privileges is discouraged!\\n\\tQuiting to shell."

#------------------------------------------------------------------------------

check_for_prerequisite()
# expected arguments:
# $1 = command / program name
{
    # check if exactly one argument has been passed
    [ "${#}" -eq 1 ] || print_error_and_exit 1 "check_for_prerequisite" "Exactly one argument has not been passed to the function!\\n\\tOne command to test expected.\\n\\tPassed: ${*}"

    # check if the argument is a program which is installed
    command -v "${1}" > /dev/null 2>&1 || print_error_and_exit 1 "check_for_prerequisite" "This script requires '${1}' but it is not installed or available on this system!\\n\\tPlease install the corresponding package manually."
}

check_for_prerequisite 'openssl'
check_for_prerequisite 'pv'
check_for_prerequisite 'file'
check_for_prerequisite 'grep'

#------------------------------------------------------------------------------

is_number()
# expected arguments:
# $1 = variable or literal
{
    # check if exactly one argument has been passed
    [ "${#}" -eq 1 ] || print_error_and_exit 1 "is_number" "Exactly one argument has not been passed to the function!\\n\\tOne variable or literal to test expected.\\n\\tPassed: ${*}"

    # check if the argument is an integer number
    # this will return an exit code of the command itself directly
    [ "${1}" -eq "${1}" ] 2> /dev/null
}

#------------------------------------------------------------------------------

print_usage_and_exit()
{
    # check if exactly one argument has been passed
    [ "${#}" -eq 1 ] || print_error_and_exit 1 "print_usage_and_exit" "Exactly one argument has not been passed to the function!\\n\\tPassed: ${*}"

    # check if the argument is a number
    is_number "${1}" || print_error_and_exit 1 "print_usage_and_exit" "The argument is not a number!\\n\\Expected an exit code from the script.\\n\\tPassed: ${1}"

    # in case of non-zero exit code given, redirect all output to stderr
    [ "${1}" -ne 0 ] && exec 1>&2

    echo "Usage: ${0} [-o directory] file"
    echo
    echo "       -o directory: Write the output file into the given directory;"
    echo "                     Optional and must be given before the file."
    echo
    echo "       file: Regular file to decrypt."

    exit "${1}"
}

#------------------------------------------------------------------------------

given_output_directory=
while getopts ":ho:" option
do
    case "${option}" in
        o)
            given_output_directory="${OPTARG}"
            ;;
        h)
            print_usage_and_exit 0
            ;;
        *)
            print_usage_and_exit 1
            ;;
    esac
done
shift $(( OPTIND - 1 ))

#------------------------------------------------------------------------------

[ "${#}" -eq 0 ] && print_usage_and_exit 1

#------------------------------------------------------------------------------

[ "${#}" -gt 1 ] && print_error_and_exit 1 '[ "${#}" -gt 1 ]' "You have passed ${#} arguments to the script!\\n\\tOnly one file expected.\\n\\tPassed: ${*}"

#------------------------------------------------------------------------------

[ -f "${1}" ] || print_error_and_exit 1 '[ -f "${1}" ]' "The given argument is not an existing regular file!\\n\\tPassed: ${1}"

#------------------------------------------------------------------------------

input_file="${1}"
[ -r "${input_file}" ] || print_error_and_exit 1 '[ -r "${input_file}" ]' "Input file is not readable by you!\\n\\tPassed: ${input_file}"

#------------------------------------------------------------------------------

is_file_encrypted_using_openssl()
{
    # check if exactly one argument has been passed
    [ "${#}" -eq 1 ] || print_error_and_exit 1 "is_file_encrypted_using_openssl" "Exactly one argument has not been passed to the function!\\n\\tPassed: ${*}"

    # check if the argument is a file
    [ -f "${1}" ] || print_error_and_exit 1 "is_file_encrypted_using_openssl" "The provided argument is not a regular file!\\n\\tPassed: ${1}"

    # check if the provided file has been encrypted using openssl
    # this will return an exit code of the command itself directly
    file "${1}" | grep --ignore-case 'openssl' > /dev/null 2>&1
}

is_file_encrypted_using_openssl "${input_file}" || print_error_and_exit 1 "is_file_encrypted_using_openssl" "Input file does not seem to have been encrypted using OpenSSL!\\n\\tPassed: ${input_file}"

#------------------------------------------------------------------------------

# parameter substitution with - modifier will cause the output_directory
# variable to to get dirname ... in case given_output_directory is empty
output_directory="${given_output_directory:-$(dirname "${input_file}")}"

[ -d "${output_directory}" ] || print_error_and_exit 1 '[ -d "${output_directory}" ]' "Destination:\\n\\t\\t${output_directory}\\n\\tis not a directory!"

[ -w "${output_directory}" ] || print_error_and_exit 1 '[ -w "${output_directory}" ]' "Destination directory:\\n\\t\\t${output_directory}\\n\\tis not writable by you!"

#------------------------------------------------------------------------------

filename_extracted_from_path=$(basename "${input_file}")
filename_without_enc_extension="${filename_extracted_from_path%.enc}"

if [ "${filename_extracted_from_path}" = "${filename_without_enc_extension}" ]
then
    # the file has a different than .enc extension or no extension at all
    # what we do now, is that we append .dec extention to the file name
    output_file="${output_directory}/${filename_extracted_from_path}.dec"
else
    # the file has the .enc extension
    # what we do now, is that we use the file name without .enc extension
    output_file="${output_directory}/${filename_without_enc_extension}"
fi

#------------------------------------------------------------------------------

# -e FILE: True if file exists. Any type of file!
[ -e "${output_file}" ] && print_error_and_exit 1 '[ -e "${output_file}" ]' "Destination file:\\n\\t\\t${output_file}\\n\\talready exists!"

#------------------------------------------------------------------------------

# here comes the core part - decryption of the given file
if ! pv --wait "${input_file}" | openssl enc -aes-256-cbc -md sha256 -salt -out "${output_file}" -d 2> /dev/null
then
    [ -f "${output_file}" ] && rm "${output_file}"

    echo

    print_error_and_exit 1 'pv --wait "${input_file}" | openssl enc -aes-256-cbc -md sha256 -salt -out "${output_file}" -d' "Decryption failed!"
else
    echo

    echo "Decryption successful."

    # scripts exit with 0 exit code by default, in case of success,
    # so this just more explicit for readers
    exit 0
fi

The project is uploaded on GitHub: https://git.io/fxslm

But not this revision. I will learn from answers and make the first release afterwards.


EDIT1

I now realized I should check for free space, so I added this:

file_size=$(du --bytes "${input_file}" | awk '{ print $1 }')
free_space=$(df "${output_directory}" | tail --lines=1 | awk '{ print $4 }')

[ "${free_space}" -gt "${file_size}" ] || print_error_and_exit 1 '[ "${free_space}" -gt "${file_size}" ]' "There is not enough free space in the destination directory!\\n\\t\\tFile size: ${file_size}\\n\\t\\tFree space: ${free_space}"

EDIT2

I have fixed an issue with getting free space in bytes by adding -B1 to df:

free_space=$(df -B1 "${output_directory}" | tail --lines=1 | awk '{ print $4 }')
\$\endgroup\$
2
\$\begingroup\$

POSIX

The script is a strange mixture of POSIX and non-POSIX. On the one hand, openssl definitely isn't POSIX, but it's essential since that's what the entire script is about. On the other hand, many of the options used here for POSIX utilities aren't standard, and pv isn't essential to the operation of the script. (Is it useful here? Yes. Is it necessary here? No.) So, if you're going to be POSIX-compliant+openssl in any meaningful sense:

  • Use -i instead of --ignore-case with grep. There's the -q option for suppressing output as well.
  • Take the default 512-byte block output of du and df and multiple by 512 in awk, or use -k for 1024-byte blocks and multiply by 1024, instead of using --byte.
    • At any rate, byte-level free-space checks aren't that useful, since ultimately even a single-byte file will take up an entire sector. 512-byte checks should be enough, but since GNU df and du unfortunately stick to 1024-byte blocks even with -P, use -kP (1024 bytes with POSIX output) for predictability.
  • If pv is used solely for progress, use it if available, but fallback to cat if isn't.

Diagnostics

The script has a lot of error messages, but has a grave problem: You discard openssl's error and then print your own useless error message. "Decryption failed!"? And how is the user supposed to know why it failed without openssl's error output? You should retain openssl output and print if an error occurred (I'd print everything from openssl in any case, but if you really must hide it, hide it only on success):

if ! openssl_out="$(pv --wait "${input_file}" | openssl enc -aes-256-cbc -md sha256 -salt -out "${output_file}" -d 2>&1)"
then
    [ -f "${output_file}" ] && rm "${output_file}"
    print_error_and_exit 1 'pv --wait "${input_file}" | openssl enc -aes-256-cbc -md sha256 -salt -out "${output_file}" -d' "Decryption failed!: ${openssl_out}"
    ...

Also, input_file and output_file (and various other variables in other such error messages) won't be expanded for the output message. This reduces the utility of these labels, since the user won't see what was actually run.


# scripts exit with 0 exit code by default, in case of success,
# so this just more explicit for readers
exit 0

No, they don't. They exit with the exit status of the last command executed, which in that case would have been the echo "Decryption successful.", and if echo had failed for some reason, then there'd be bigger problems. exit 0 as the last command in a shell script is virtually useless unless the previous command is expected to return a non-zero exit code.


check_for_prerequisite could loop over arguments. There's no reason for it to accept just a single argument.


if command -v tput > /dev/null 2>&1 && tput setaf 1 > /dev/null 2>&1
then
    # color definitions
    readonly bold=$(tput bold)
    ...

    exit "${1}"
else
    # here we do not have color support
    printf '%b\n\n\t%b\n\nexit code = %b\n' \
        "${2}" "${3}" "${1}"

    exit "${1}"
fi

Move the exit "${1}" outside the if block. You're going to exit either way.

Why print the exit code? It's easily accessible to the user anyway.


filename_extracted_from_path=$(basename "${input_file}")
filename_without_enc_extension="${filename_extracted_from_path%.enc}"

You don't need to quote variable assignments, but if you are going to quote them, be consistent and quote the command substitution as well.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.