diff --git a/Makefile b/Makefile index 0fe5868..bfedf1e 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,6 @@ default: build check-dependencies: command -v bash &>/dev/null || (echo "ERROR, please install bash" >&2; exit 1) command -v bats &>/dev/null || (echo "ERROR, please install bats" >&2; exit 1) - command -v find &>/dev/null || (echo "ERROR, please install find" >&2; exit 1) command -v shellcheck &>/dev/null || (echo "ERROR, please install shellcheck" >&2; exit 1) command -v shfmt &>/dev/null || (echo "ERROR, please install shfmt" >&2; exit 1) command -v jq &>/dev/null || (echo "ERROR, please install jq" >&2; exit 1) diff --git a/README.md b/README.md index 33500fc..2f6c07d 100644 --- a/README.md +++ b/README.md @@ -47,30 +47,18 @@ See [below](#documentation-overview) for `shellmock`'s documentation. The following tools are needed to use `shellmock`: - `base32` -- `base64` -- `basename` - `bash` (at least version 4.4) - `cat` - `chmod` -- `env` -- `find` - `flock` -- `gawk` -- `grep` - `mkdir` - `mktemp` -- `ps` - `rm` -- `sed` -- `sort` -- `touch` -- `tr` -- `xargs` On Debian-based systems, they can be installed via: ```bash -sudo apt install -yqq bash coreutils findutils gawk grep procps sed util-linux +sudo apt install -yqq bash coreutils util-linux ``` You also need the [bats-core] testing framework that diff --git a/bin/mock_exe.sh b/bin/mock_exe.sh index 6b38a68..9676b0c 100755 --- a/bin/mock_exe.sh +++ b/bin/mock_exe.sh @@ -39,8 +39,20 @@ env_var_check() { get_and_ensure_outdir() { local cmd_b32="$1" + + local max_num_calls=${SHELLMOCK_MAX_CALLS_PER_MOCK:-1000} + if ! [[ ${max_num_calls} =~ ^[0-9][0-9]*$ ]]; then + echo >&2 "SHELLMOCK_MAX_CALLS_PER_MOCK must be a number." + _kill_parent "${PPID}" + return 1 + fi + local tmp + tmp=$((max_num_calls - 1)) + local max_digits=${#tmp} + # Ensure no two calls overwrite each other in a thread-safe way. - local count=0 + local count + count=$(printf "%0${max_digits}d" "0") local outdir="${__SHELLMOCK_OUTPUT}/${cmd_b32}/${count}" while ! ( # Increment the counter until we find one that has not been used before. @@ -48,9 +60,18 @@ get_and_ensure_outdir() { [[ -d ${outdir} ]] && exit 1 mkdir -p "${outdir}" ) 9> "${__SHELLMOCK_OUTPUT}/lockfile_${cmd_b32}_${count}"; do - count=$((count + 1)) + count=$(printf "%0${max_digits}d" "$((count + 1))") outdir="${__SHELLMOCK_OUTPUT}/${cmd_b32}/${count}" done + + if [[ ${count} -ge ${max_num_calls} ]]; then + echo >&2 "The maximum number of calls per mock is ${max_num_calls}." \ + "Consider increasing SHELLMOCK_MAX_CALLS_PER_MOCK, which is currently" \ + "set to '${max_num_calls}'." + _kill_parent "${PPID}" + return 1 + fi + echo "${outdir}" } @@ -114,7 +135,7 @@ _match_spec() { local spec while read -r spec; do local id val - id="$(gawk -F: '{print $1}' <<< "${spec}")" + read -r -d ':' id <<< "${spec}" val="${spec##"${id}":}" if [[ ${spec} =~ ^any: ]]; then @@ -138,7 +159,7 @@ _match_spec() { errecho "Internal error, incorrect spec ${spec}" return 1 fi - done < <(base64 --decode <<< "${full_spec}") && wait $! || return 1 + done < <(base32 --decode <<< "${full_spec}") && wait $! || return 1 } # Check whether the given process is a bats process. A bats process is a bash @@ -172,10 +193,9 @@ _kill_parent() { return 0 fi - errecho "Killing parent process with information:" - # In case the `ps` command fails (e.g. because we mock it), don't fail this - # mock. - errecho "$(ps -p "${parent}" -lF || :)" + local cmd_w_args + mapfile -t -d $'\0' cmd_w_args < "/proc/${parent}/cmdline" + errecho "Killing parent process: ${cmd_w_args[*]@Q}" kill "${parent}" } @@ -187,19 +207,20 @@ find_matching_argspec() { local cmd_b32="${3}" shift 3 - local env_var + local env_var var while read -r env_var; do if _match_spec "${!env_var}" "$@"; then - echo "${env_var##MOCK_ARGSPEC_BASE64_}" + echo "${env_var##MOCK_ARGSPEC_BASE32_}" echo "${env_var}" > "${outdir}/argspec" return 0 fi done < <( - env | sed 's/=.*$//' \ - | { - grep -x "MOCK_ARGSPEC_BASE64_${cmd_b32}_[0-9][0-9]*" || : - } | sort -u + for var in "${!MOCK_ARGSPEC_BASE32_@}"; do + if [[ ${var} == "MOCK_ARGSPEC_BASE32_${cmd_b32}_"* ]]; then + echo "${var}" + fi + done ) && wait $! || return 1 errecho "SHELLMOCK: unexpected call '${cmd} $*'" @@ -209,11 +230,11 @@ find_matching_argspec() { provide_output() { local cmd_spec="$1" - # Base64 encoding is an easy way to be able to store arbitrary data in + # Base32 encoding is an easy way to be able to store arbitrary data in # environment variables. - output_base64="MOCK_OUTPUT_BASE64_${cmd_spec}" - if [[ -n ${!output_base64-} ]]; then - base64 --decode <<< "${!output_base64}" + output_base32="MOCK_OUTPUT_BASE32_${cmd_spec}" + if [[ -n ${!output_base32-} ]]; then + base32 --decode <<< "${!output_base32}" fi } @@ -289,8 +310,9 @@ main() { # Determine our name. This assumes that the first value in argv is the name of # the command. This is almost always so. local cmd cmd_b32 args - cmd="$(basename "$0")" - cmd_b32="$(base32 -w0 <<< "${cmd}" | tr "=" "_")" + cmd="${0##*/}" + cmd_b32="$(base32 -w0 <<< "${cmd}")" + cmd_b32="${cmd_b32//=/_}" local outdir outdir="$(get_and_ensure_outdir "${cmd_b32}")" declare -g STDERR="${outdir}/stderr" diff --git a/docs/build.md b/docs/build.md index b39aa26..57bfb48 100644 --- a/docs/build.md +++ b/docs/build.md @@ -19,5 +19,5 @@ # Build the Deployable Simply run the script `./generate_deployable.sh` at the top level of this -repository to generate `shellmoch.bash`. -It uses only the standard Unix tools `bash`, `cat`, and `gawk` to generate it. +repository to generate `shellmock.bash`. +It uses only the standard Unix tools `bash` and `cat` to generate it. diff --git a/docs/usage.md b/docs/usage.md index cf3b93f..d3e3e97 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -618,7 +618,7 @@ func2() { local ls=ls func1 # Output all files in a directory using `cat` via their full paths. This calls - # `cat` and `basename` via `xargs`. + # `cat` and `readlink` via `xargs`. # shellmock: uses-command=readlink,cat find . -type f | xargs readlink -f | xargs cat # shellmock: uses-command=ls # Calling "ls" with its name in a variable. diff --git a/generate_deployable.sh b/generate_deployable.sh index b6daf01..9cef42c 100755 --- a/generate_deployable.sh +++ b/generate_deployable.sh @@ -25,7 +25,12 @@ __SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/" &> /dev/null && pwd)" deployable() { # Output header including the licence file. echo '#!/bin/bash' - sed 's/^/# /' LICENSE + ( + IFS= + while read -r line; do + echo "# ${line}" + done < LICENSE + ) cat << 'EOF' # This file is auto-generated. It is the main deployable of shellmock. To make @@ -46,11 +51,21 @@ __shellmock__help() { This is shellmock, a tool to mock executables called within shell scripts. ENDOFFILE - gawk \ - -v start='' \ - -v end='' \ - 'BEGIN{act=0} {if($0==end){act=0}; if(act==1){print}; if($0==start){act=1;};}' \ - ./docs/usage.md + ( + IFS= + do_print=0 + while read -r line; do + if [[ ${line} == '' ]]; then + do_print=0 + fi + if [[ ${do_print} -eq 1 ]]; then + echo "${line}" + fi + if [[ ${line} == '' ]]; then + do_print=1 + fi + done < ./docs/usage.md + ) cat << 'ENDOFFILE' EOF diff --git a/lib/mock_management.bash b/lib/mock_management.bash index f8ba971..56fac48 100644 --- a/lib/mock_management.bash +++ b/lib/mock_management.bash @@ -48,7 +48,8 @@ __shellmock__unmock() { local cmd="$1" local cmd_b32 - cmd_b32=$(base32 -w0 <<< "${cmd}" | tr "=" "_") + cmd_b32=$(base32 -w0 <<< "${cmd}") + cmd_b32=${cmd_b32//=/_} # Restore the function if we are mocking one. local store="${__SHELLMOCK_FUNCSTORE}/${cmd}" @@ -65,8 +66,15 @@ __shellmock__unmock() { while read -r env_var; do unset "${env_var}" done < <( - env | sed 's/=.*$//' \ - | { grep -xE "^MOCK_(RC|ARGSPEC_BASE64)_${cmd_b32}_[0-9][0-9]*" || :; } + local var + for var in "${!MOCK_RC_@}" "${!MOCK_ARGSPEC_BASE32_@}"; do + if + [[ ${var} == "MOCK_RC_${cmd_b32}_"* ]] \ + || [[ ${var} == "MOCK_ARGSPEC_BASE32_${cmd_b32}_"* ]] + then + echo "${var}" + fi + done ) && wait $! || return 1 if [[ -f "${__SHELLMOCK_MOCKBIN}/${cmd}" ]]; then @@ -83,7 +91,7 @@ __shellmock_assert_no_duplicate_argspecs() { local args=("$@") declare -A arg_idx_count=() - declare -A duplicate_arg_indices=() + declare -a duplicate_arg_indices=() local count for arg in "${args[@]}"; do idx=${arg%%:*} @@ -93,15 +101,13 @@ __shellmock_assert_no_duplicate_argspecs() { fi count=${arg_idx_count["${idx}"]-0} arg_idx_count["${idx}"]=$((count + 1)) - if [[ ${count} -gt 0 ]]; then - duplicate_arg_indices["${idx}"]=1 + if [[ ${count} -eq 1 ]]; then + duplicate_arg_indices+=("${idx}") fi done if [[ ${#duplicate_arg_indices[@]} -gt 0 ]]; then - local dups - dups=$(printf '%s\n' "${!duplicate_arg_indices[@]}" | sort -n | tr '\n' ' ') echo >&2 "Multiple arguments specified for the following indices, cannot" \ - "continue: ${dups}" + "continue: ${duplicate_arg_indices[*]}" return 1 fi } @@ -115,7 +121,8 @@ __shellmock__config() { # Fake output is read from stdin. local cmd="$1" local cmd_b32 - cmd_b32=$(base32 -w0 <<< "${cmd}" | tr "=" "_") + cmd_b32=$(base32 -w0 <<< "${cmd}") + cmd_b32=${cmd_b32//=/_} local rc="$2" shift 2 @@ -136,7 +143,7 @@ __shellmock__config() { local has_err=0 local regex='^(regex-[0-9][0-9]*|regex-any|i|[0-9][0-9]*|any):' for arg in "$@"; do - if ! grep -qE "${regex}" <<< "${arg}"; then + if ! [[ ${arg} =~ ${regex} ]]; then echo >&2 "Incorrect format of argspec: ${arg}" has_err=1 fi @@ -179,32 +186,50 @@ __shellmock__config() { return 1 fi + local max_num_configs=${SHELLMOCK_MAX_CONFIGS_PER_MOCK:-100} + if ! [[ ${max_num_configs} =~ ^[0-9][0-9]*$ ]]; then + echo >&2 "SHELLMOCK_MAX_CONFIGS_PER_MOCK must be a number." + return 1 + fi + local tmp + tmp=$((max_num_configs - 1)) + local max_digits=${#tmp} + # Handle fake exit code. Use the exit code as a proxy to determine which count # to use next because all mock configurations have to set the exit code but # not all of them have to provide arg specs or output. - local count=0 + local count + count=$(printf "%0${max_digits}d" "0") local env_var_val="${rc}" local env_var_name="MOCK_RC_${cmd_b32}_${count}" while [[ -n ${!env_var_name-} ]]; do - count=$((count + 1)) + count=$(printf "%0${max_digits}d" "$((count + 1))") env_var_name="MOCK_RC_${cmd_b32}_${count}" done + + if [[ ${count} -ge ${max_num_configs} ]]; then + echo >&2 "The maximum number of configs per mock is ${max_num_configs}." \ + "Consider increasing SHELLMOCK_MAX_CONFIGS_PER_MOCK, which is currently" \ + "set to '${max_num_configs}'." + return 1 + fi + declare -gx "${env_var_name}=${env_var_val}" # Handle arg specs. env_var_val=$(for arg in "${args[@]}"; do echo "${arg}" - done | base64 -w0) - env_var_name="MOCK_ARGSPEC_BASE64_${cmd_b32}_${count}" + done | base32 -w0) + env_var_name="MOCK_ARGSPEC_BASE32_${cmd_b32}_${count}" declare -gx "${env_var_name}=${env_var_val}" # Handle fake output. Read from stdin but only if stdin is not a terminal. if ! [[ -t 0 ]]; then - env_var_val="$(base64 -w0)" + env_var_val="$(base32 -w0)" else env_var_val= fi - env_var_name="MOCK_OUTPUT_BASE64_${cmd_b32}_${count}" + env_var_name="MOCK_OUTPUT_BASE32_${cmd_b32}_${count}" declare -gx "${env_var_name}=${env_var_val}" # Handle hook. @@ -222,7 +247,8 @@ __shellmock__assert() { local assert_type="$1" local cmd="$2" local cmd_b32 - cmd_b32=$(base32 -w0 <<< "${cmd}" | tr "=" "_") + cmd_b32=$(base32 -w0 <<< "${cmd}") + cmd_b32=${cmd_b32//=/_} # Ensure we only assert on existing mocks. if ! [[ -x "${__SHELLMOCK_MOCKBIN}/${cmd}" ]]; then @@ -230,7 +256,8 @@ __shellmock__assert() { return 1 fi - touch "${__SHELLMOCK_EXPECTATIONS_DIR}/${cmd}" + # Create new empty file. + : > "${__SHELLMOCK_EXPECTATIONS_DIR}/${cmd}" case "${assert_type}" in # Make sure that no calls were issued to the mock that we did not expect. By @@ -253,7 +280,13 @@ __shellmock__assert() { has_err=1 fi done < <( - find "${__SHELLMOCK_OUTPUT}/${cmd_b32}" -mindepth 2 -type f -name stderr + shopt -s globstar + local file + for file in "${__SHELLMOCK_OUTPUT}/${cmd_b32}/"**"/stderr"; do + if [[ -f ${file} ]]; then + echo "${file}" + fi + done ) && wait $! || return 1 if [[ ${has_err} -ne 0 ]]; then echo >&2 "SHELLMOCK: got at least one unexpected call for mock ${cmd}." @@ -267,20 +300,25 @@ __shellmock__assert() { call-correspondence) declare -a actual_argspecs mapfile -t actual_argspecs < <( + local file if [[ -d "${__SHELLMOCK_OUTPUT}/${cmd_b32}" ]]; then - # shellmock: uses-command=cat - find "${__SHELLMOCK_OUTPUT}/${cmd_b32}" -mindepth 2 -type f \ - -name argspec -print0 | xargs -r -0 cat | sort -u + shopt -s globstar + for file in "${__SHELLMOCK_OUTPUT}/${cmd_b32}/"**"/argspec"; do + if [[ -f ${file} ]]; then + cat "${file}" + fi + done fi ) && wait $! || return 1 declare -a expected_argspecs mapfile -t expected_argspecs < <( - # Ignore grep's exit code, which is relevant with the "pipefail" option. - # The case of no matches is OK here. - env | sed 's/=.*$//' \ - | { grep -x "MOCK_ARGSPEC_BASE64_${cmd_b32}_[0-9][0-9]*" || :; } \ - | sort -u + local var + for var in "${!MOCK_ARGSPEC_BASE32_@}"; do + if [[ ${var} == "MOCK_ARGSPEC_BASE32_${cmd_b32}_"* ]]; then + echo "${var}" + fi + done ) && wait $! || return 1 local has_err=0 @@ -288,7 +326,7 @@ __shellmock__assert() { if ! [[ " ${actual_argspecs[*]} " == *"${argspec}"* ]]; then has_err=1 echo >&2 "SHELLMOCK: cannot find call for mock ${cmd} and argspec:" \ - "$(base64 --decode <<< "${!argspec}")" + "$(base32 --decode <<< "${!argspec}")" fi done if [[ ${has_err} -ne 0 ]]; then @@ -347,7 +385,8 @@ __shellmock__calls() { local cmd="$1" local format="${2-"--plain"}" local cmd_b32 - cmd_b32=$(base32 -w0 <<< "${cmd}" | tr "=" "_") + cmd_b32=$(base32 -w0 <<< "${cmd}") + cmd_b32=${cmd_b32//=/_} local cmd_quoted cmd_quoted="$(printf "%q" "${cmd}")" @@ -361,8 +400,12 @@ __shellmock__calls() { local call_ids readarray -d $'\n' -t call_ids < <( - find "${__SHELLMOCK_OUTPUT}/${cmd_b32}" -mindepth 1 -maxdepth 1 -type d \ - | sort -n + local dir + for dir in "${__SHELLMOCK_OUTPUT}/${cmd_b32}/"*; do + if [[ -d ${dir} ]]; then + echo "${dir}" + fi + done ) && wait $! || return 1 for call_idx in "${!call_ids[@]}"; do diff --git a/lib/shellmock.bash b/lib/shellmock.bash index 1f908b9..483459f 100644 --- a/lib/shellmock.bash +++ b/lib/shellmock.bash @@ -165,8 +165,14 @@ __shellmock_internal_trap() { then local defined_cmds readarray -d $'\n' -t defined_cmds < <( - # shellmock: uses-command=basename - find "${__SHELLMOCK_MOCKBIN}" -type f -print0 | xargs -r -0 -I{} basename {} + # Recursively get the name of the mock binary commands. + shopt -s globstar + local file + for file in "${__SHELLMOCK_MOCKBIN}"/**; do + if [[ -f ${file} ]]; then + echo "${file##*/}" + fi + done ) && wait $! local cmd has_err=0 diff --git a/tests/extended.bats b/tests/extended.bats index 819a5c0..74e7d6d 100644 --- a/tests/extended.bats +++ b/tests/extended.bats @@ -158,25 +158,13 @@ EOF exes=( base32 - base64 - basename cat chmod - env - find flock - gawk go - grep mkdir mktemp - ps rm - sed - sort - touch - tr - xargs ) [[ ${output} == $(_join $'\n' "${exes[@]}") ]] } diff --git a/tests/main.bats b/tests/main.bats index 5abfb6e..62ecb91 100644 --- a/tests/main.bats +++ b/tests/main.bats @@ -473,7 +473,7 @@ EOF run ! shellmock config my_exe 0 2:two 1:one i:three regex-1:another-one local expected="Multiple arguments specified for the following \ -indices, cannot continue: 1 2 " +indices, cannot continue: 2 1" [[ ${output} == "${expected}" ]] } @@ -599,7 +599,7 @@ indices, cannot continue: 1 2 " ls=$(command -v ls) shellmock new ls mkdir -p "${BATS_TEST_TMPDIR}/dir" - touch "${BATS_TEST_TMPDIR}/dir/file" + : > "${BATS_TEST_TMPDIR}/dir/file" shellmock config ls forward 1:"${BATS_TEST_TMPDIR}/dir" # Making the linter happy. diff --git a/tests/misc.bats b/tests/misc.bats index f850f6a..45ca804 100644 --- a/tests/misc.bats +++ b/tests/misc.bats @@ -134,3 +134,28 @@ setup() { shellmock new some_executable shellmock assert expectations some_executable } + +@test "disallow calling more often than specified" { + export SHELLMOCK_MAX_CALLS_PER_MOCK=3 + shellmock new some_executable + shellmock config some_executable 0 + # The first 3 calls work out. + some_executable + some_executable + some_executable + # The next call fails. + run ! some_executable + + shellmock assert expectations some_executable +} + +@test "disallow configuring more often than specified" { + export SHELLMOCK_MAX_CONFIGS_PER_MOCK=3 + shellmock new some_executable + # The first 3 configs can be set. + shellmock config some_executable 0 1:arg1 + shellmock config some_executable 0 1:arg2 + shellmock config some_executable 0 1:arg3 + # The next one fails. + run ! shellmock config some_executable 0 1:arg4 +}