diff --git a/.github/actions/build-and-tag-locally/action.yml b/.github/actions/build-and-tag-locally/action.yml index 70c2a052f..f67023fd5 100644 --- a/.github/actions/build-and-tag-locally/action.yml +++ b/.github/actions/build-and-tag-locally/action.yml @@ -204,6 +204,26 @@ runs: exit 1 fi + - name: Test the entrypoint + id: test_entrypoint + if: ${{ contains(fromJSON('["amd64", "i386"]'), steps.platform.outputs.display_name) }} + shell: bash + run: > + cd test && env + PLATFORM=${{ steps.platform.outputs.display_name }} + REDIS_IMG=${{ github.sha }}:${{ steps.platform.outputs.display_name }} + ./run-entrypoint-tests.sh + -- --output-junit-xml=report-entrypoint.xml + + - name: Test Report + uses: dorny/test-reporter@v2 + # run this step even if previous step failed, but not if it was skipped + if: ${{ !cancelled() && steps.test_entrypoint.conclusion != 'skipped' }} + with: + name: Entrypoint Tests + path: test/report-entrypoint.xml + reporter: java-junit + - name: Push image uses: docker/build-push-action@v6 if: ${{ inputs.publish_image == 'true' && contains(fromJSON('["amd64"]'), steps.platform.outputs.display_name) }} @@ -212,4 +232,4 @@ runs: push: true tags: ${{ inputs.registry_repository }}:${{ github.sha }}-${{ inputs.distribution }} cache-from: type=gha - cache-to: type=gha,mode=max \ No newline at end of file + cache-to: type=gha,mode=max diff --git a/alpine/docker-entrypoint.sh b/alpine/docker-entrypoint.sh index ab5befbf8..476913d93 100755 --- a/alpine/docker-entrypoint.sh +++ b/alpine/docker-entrypoint.sh @@ -1,8 +1,93 @@ #!/bin/sh set -e +SETPRIV="/usr/bin/setpriv --reuid redis --regid redis --clear-groups" +IS_REDIS_SENTINEL="" +IS_REDIS_SERVER="" +CONFIG="" + +SKIP_FIX_PERMS_NOTICE="Use SKIP_FIX_PERMS=1 to skip permission changes." + +# functions has_cap() { - /usr/bin/setpriv -d | grep -q 'Capability bounding set:.*\b'$1'\b' + /usr/bin/setpriv -d | grep -q 'Capability bounding set:.*\b'"$1"'\b' +} + +check_for_sentinel() { + CMD="$1" + shift + if [ "$CMD" = '/usr/local/bin/redis-server' ]; then + for arg in "$@"; do + if [ "$arg" = "--sentinel" ]; then + return 0 + fi + done + fi + + if [ "$CMD" = '/usr/local/bin/redis-sentinel' ]; then + return 0 + fi + + return 1 +} + +# Note: Change permissions only in simple, default cases to avoid affecting +# unexpected or user-specific files. + +fix_data_dir_perms() { + # Expecting only *.rdb files and default appendonlydir; skip if others are found. + unknown_file="$(find . -mindepth 1 -maxdepth 1 \ + -not \( -name \*.rdb -or \( -type d -and -name appendonlydir \) \) \ + -print -quit)" + if [ -z "$unknown_file" ]; then + find . -print0 | fix_perms_and_owner rw + else + echo "Notice: Unknown file '$unknown_file' found in data dir. Permissions will not be modified. $SKIP_FIX_PERMS_NOTICE" + fi +} + +fix_config_perms() { + config="$1" + mode="$2" + + if [ ! -f "$config" ]; then + return 0 + fi + + confdir="$(dirname "$config")" + if [ ! -d "$confdir" ]; then + return 0 + fi + + # Expecting only the config file; skip if others are found. + pattern=$(printf "%s" "$(basename "$config")" | sed 's/[][?*]/\\&/g') + unknown_file=$(find "$confdir" -mindepth 1 -maxdepth 1 -not -name "$pattern" -print -quit) + + if [ -z "$unknown_file" ]; then + printf '%s\0%s\0' "$confdir" "$config" | fix_perms_and_owner "$mode" + else + echo "Notice: Unknown file '$unknown_file' found in '$confdir'. Permissions will not be modified. $SKIP_FIX_PERMS_NOTICE" + + fi +} + +fix_perms_and_owner() { + mode="$1" + + # shellcheck disable=SC3045 + while IFS= read -r -d '' file; do + if [ "$mode" = "rw" ] && $SETPRIV test -r "$file" -a -w "$file"; then + continue + elif [ "$mode" = "r" ] && $SETPRIV test -r "$file"; then + continue + fi + new_mode=$mode + if [ -d "$file" ]; then + new_mode=${mode}x + fi + err=$(chown redis "$file" 2>&1) || echo "Warning: cannot change owner to 'redis' for '$file': $err. $SKIP_FIX_PERMS_NOTICE" + err=$(chmod "u+$new_mode" "$file" 2>&1) || echo "Warning: cannot change mode to 'u+$new_mode' for '$file': $err. $SKIP_FIX_PERMS_NOTICE" + done } # first arg is `-f` or `--some-option` @@ -10,23 +95,43 @@ has_cap() { if [ "${1#-}" != "$1" ] || [ "${1%.conf}" != "$1" ]; then set -- redis-server "$@" fi +CMD=$(command -v "$1" 2>/dev/null || :) -CMD=$(realpath $(command -v "$1") 2>/dev/null || :) -# drop privileges only if our uid is 0 (container started without explicit --user) +if [ "$(readlink -f "$CMD")" = '/usr/local/bin/redis-server' ]; then + IS_REDIS_SERVER=1 +fi + +if check_for_sentinel "$CMD" "$@"; then + IS_REDIS_SENTINEL=1 +fi + +# if is server and its first arg is not an option then it's a config +if [ "$IS_REDIS_SERVER" ] && [ "${2#-}" = "$2" ]; then + CONFIG="$2" +fi + +# drop privileges only if +# we are starting either server or sentinel +# our uid is 0 (container started without explicit --user) # and we have capabilities required to drop privs -if has_cap setuid && has_cap setgid && \ - [ \( "$CMD" = '/usr/local/bin/redis-server' -o "$CMD" = '/usr/local/bin/redis-sentinel' \) -a "$(id -u)" = '0' ]; then - find . \! -user redis -exec chown redis '{}' + +if [ "$IS_REDIS_SERVER" ] && [ -z "$SKIP_DROP_PRIVS" ] && [ "$(id -u)" = '0' ] && has_cap setuid && has_cap setgid; then + if [ -z "$SKIP_FIX_PERMS" ]; then + # fix permissions + if [ "$IS_REDIS_SENTINEL" ]; then + fix_config_perms "$CONFIG" rw + else + fix_data_dir_perms + fix_config_perms "$CONFIG" r + fi + fi + CAPS_TO_KEEP="" if has_cap sys_resource; then # we have sys_resource capability, keep it available for redis # as redis may use it to increase open files limit CAPS_TO_KEEP=",+sys_resource" fi - exec /usr/bin/setpriv \ - --reuid redis \ - --regid redis \ - --clear-groups \ + exec $SETPRIV \ --nnp \ --inh-caps=-all$CAPS_TO_KEEP \ --ambient-caps=-all$CAPS_TO_KEEP \ @@ -42,7 +147,7 @@ if [ "$um" = '0022' ]; then umask 0077 fi -if [ "$1" = 'redis-server' ]; then +if [ "$IS_REDIS_SERVER" ] && ! [ "$IS_REDIS_SENTINEL" ]; then echo "Starting Redis Server" modules_dir="/usr/local/lib/redis/modules/" @@ -76,5 +181,4 @@ if [ "$1" = 'redis-server' ]; then fi fi - exec "$@" diff --git a/debian/docker-entrypoint.sh b/debian/docker-entrypoint.sh index ab5befbf8..d0a21fe4d 100755 --- a/debian/docker-entrypoint.sh +++ b/debian/docker-entrypoint.sh @@ -1,8 +1,93 @@ -#!/bin/sh +#!/bin/bash set -e +SETPRIV="/usr/bin/setpriv --reuid redis --regid redis --clear-groups" +IS_REDIS_SENTINEL="" +IS_REDIS_SERVER="" +CONFIG="" + +SKIP_FIX_PERMS_NOTICE="Use SKIP_FIX_PERMS=1 to skip permission changes." + +# functions has_cap() { - /usr/bin/setpriv -d | grep -q 'Capability bounding set:.*\b'$1'\b' + /usr/bin/setpriv -d | grep -q 'Capability bounding set:.*\b'"$1"'\b' +} + +check_for_sentinel() { + CMD="$1" + shift + if [ "$CMD" = '/usr/local/bin/redis-server' ]; then + for arg in "$@"; do + if [ "$arg" = "--sentinel" ]; then + return 0 + fi + done + fi + + if [ "$CMD" = '/usr/local/bin/redis-sentinel' ]; then + return 0 + fi + + return 1 +} + +# Note: Change permissions only in simple, default cases to avoid affecting +# unexpected or user-specific files. + +fix_data_dir_perms() { + # Expecting only *.rdb files and default appendonlydir; skip if others are found. + unknown_file="$(find . -mindepth 1 -maxdepth 1 \ + -not \( -name \*.rdb -or \( -type d -and -name appendonlydir \) \) \ + -print -quit)" + if [ -z "$unknown_file" ]; then + find . -print0 | fix_perms_and_owner rw + else + echo "Notice: Unknown file '$unknown_file' found in data dir. Permissions will not be modified. $SKIP_FIX_PERMS_NOTICE" + fi +} + +fix_config_perms() { + config="$1" + mode="$2" + + if [ ! -f "$config" ]; then + return 0 + fi + + confdir="$(dirname "$config")" + if [ ! -d "$confdir" ]; then + return 0 + fi + + # Expecting only the config file; skip if others are found. + pattern=$(printf "%s" "$(basename "$config")" | sed 's/[][?*]/\\&/g') + unknown_file=$(find "$confdir" -mindepth 1 -maxdepth 1 -not -name "$pattern" -print -quit) + + if [ -z "$unknown_file" ]; then + printf '%s\0%s\0' "$confdir" "$config" | fix_perms_and_owner "$mode" + else + echo "Notice: Unknown file '$unknown_file' found in '$confdir'. Permissions will not be modified. $SKIP_FIX_PERMS_NOTICE" + + fi +} + +fix_perms_and_owner() { + mode="$1" + + # shellcheck disable=SC3045 + while IFS= read -r -d '' file; do + if [ "$mode" = "rw" ] && $SETPRIV test -r "$file" -a -w "$file"; then + continue + elif [ "$mode" = "r" ] && $SETPRIV test -r "$file"; then + continue + fi + new_mode=$mode + if [ -d "$file" ]; then + new_mode=${mode}x + fi + err=$(chown redis "$file" 2>&1) || echo "Warning: cannot change owner to 'redis' for '$file': $err. $SKIP_FIX_PERMS_NOTICE" + err=$(chmod "u+$new_mode" "$file" 2>&1) || echo "Warning: cannot change mode to 'u+$new_mode' for '$file': $err. $SKIP_FIX_PERMS_NOTICE" + done } # first arg is `-f` or `--some-option` @@ -10,23 +95,43 @@ has_cap() { if [ "${1#-}" != "$1" ] || [ "${1%.conf}" != "$1" ]; then set -- redis-server "$@" fi +CMD=$(command -v "$1" 2>/dev/null || :) -CMD=$(realpath $(command -v "$1") 2>/dev/null || :) -# drop privileges only if our uid is 0 (container started without explicit --user) +if [ "$(readlink -f "$CMD")" = '/usr/local/bin/redis-server' ]; then + IS_REDIS_SERVER=1 +fi + +if check_for_sentinel "$CMD" "$@"; then + IS_REDIS_SENTINEL=1 +fi + +# if is server and its first arg is not an option then it's a config +if [ "$IS_REDIS_SERVER" ] && [ "${2#-}" = "$2" ]; then + CONFIG="$2" +fi + +# drop privileges only if +# we are starting either server or sentinel +# our uid is 0 (container started without explicit --user) # and we have capabilities required to drop privs -if has_cap setuid && has_cap setgid && \ - [ \( "$CMD" = '/usr/local/bin/redis-server' -o "$CMD" = '/usr/local/bin/redis-sentinel' \) -a "$(id -u)" = '0' ]; then - find . \! -user redis -exec chown redis '{}' + +if [ "$IS_REDIS_SERVER" ] && [ -z "$SKIP_DROP_PRIVS" ] && [ "$(id -u)" = '0' ] && has_cap setuid && has_cap setgid; then + if [ -z "$SKIP_FIX_PERMS" ]; then + # fix permissions + if [ "$IS_REDIS_SENTINEL" ]; then + fix_config_perms "$CONFIG" rw + else + fix_data_dir_perms + fix_config_perms "$CONFIG" r + fi + fi + CAPS_TO_KEEP="" if has_cap sys_resource; then # we have sys_resource capability, keep it available for redis # as redis may use it to increase open files limit CAPS_TO_KEEP=",+sys_resource" fi - exec /usr/bin/setpriv \ - --reuid redis \ - --regid redis \ - --clear-groups \ + exec $SETPRIV \ --nnp \ --inh-caps=-all$CAPS_TO_KEEP \ --ambient-caps=-all$CAPS_TO_KEEP \ @@ -42,7 +147,7 @@ if [ "$um" = '0022' ]; then umask 0077 fi -if [ "$1" = 'redis-server' ]; then +if [ "$IS_REDIS_SERVER" ] && ! [ "$IS_REDIS_SENTINEL" ]; then echo "Starting Redis Server" modules_dir="/usr/local/lib/redis/modules/" @@ -76,5 +181,4 @@ if [ "$1" = 'redis-server' ]; then fi fi - exec "$@" diff --git a/test/run-entrypoint-tests.sh b/test/run-entrypoint-tests.sh new file mode 100755 index 000000000..500fc3cec --- /dev/null +++ b/test/run-entrypoint-tests.sh @@ -0,0 +1,649 @@ +#!/bin/bash + +## +# +# These tests are designed to verify the correctness of the entrypoint behavior +# under different preconditions and arguments. As such, in some tests, it is +# expected that the Redis process may fail with errors. +# +# To run specific test use: +# +# REDIS_IMG=image ./test.sh -- specific_test_name +# +# To get verbose output use TEST_VERBOSE=1: +# +# TEST_VERBOSE=1 REDIS_IMG=image ./test.sh +# +# Uses shunit2: https://github.com/kward/shunit2 +# +# Requires sudo +# +## + +if [ -z "$REDIS_IMG" ]; then + echo "REDIS_IMG may not be empty" + exit 1 +fi +# By default create files owned by root to avoid intersecting with container user +HOST_UID=0 +HOST_GID=0 +if docker info 2>/dev/null | grep -qi rootless; then + # For rootless docker we have to use current user + HOST_UID=$(id -u) + HOST_GID=$(id -g) +fi +HOST_OWNER=$HOST_UID:$HOST_GID + +get_container_user_uid_gid_on_the_host() { + container_user="$1" + dir=$(mktemp -d -p .) + docker run --rm -v "$(pwd)/$dir":/w -w /w --entrypoint=/bin/sh "$REDIS_IMG" -c "chown $container_user ." + stat -c "%u %g" "$dir" + sudo rm -rf "$dir" +} + +# Detect how redis user and group from the container are mapped to the host ones +read -r REDIS_UID _ <<< "$(get_container_user_uid_gid_on_the_host redis:redis)" + +if [ "$REDIS_UID" == "$HOST_UID" ]; then + echo "Cannot test ownership as redis user uid is the same as current user" + exit 1 +fi + +# Helper functions # + +# creates one entry of directory structure +# used in combination with iterate_dir_structure_with +create_entry() { + dir="$1" + if [ "$type" = dir ]; then + sudo mkdir -p "$dir/$entry" + elif [ "$type" = file ]; then + sudo touch "$dir/$entry" + else + echo "Unknown type '$type' for entry '$entry'" + return 1 + fi + sudo chmod "$initial_mode" "$dir/$entry" + sudo chown "$initial_owner" "$dir/$entry" +} + +# asserts ownership and permissions for one entry from directory structure +# used in combination with iterate_dir_structure_with +assert_entry() { + dir="$1" + msg="$2" + actual_uid=$(sudo stat -c %u "$dir/$entry") + actual_mode=0$(sudo stat -c '%a' "$dir/$entry") + actual_mask=$(printf "0%03o" $(( actual_mode & expected_mode_mask ))) + assertEquals "$msg: Owner for $type '$entry'" "$expected_owner" "$actual_uid" + assertEquals "$msg: Mode mask for $type '$entry'" "$expected_mode_mask" "$actual_mask" +} + +# Iterates over directory structure assigning variables and executing command +# from the arguments for each entry. +# +# Directory structure is the following form: +# entry type initial owner -> expected uid initial mode -> expected mode mask +# . dir $HOST_OWNER -> $REDIS_UID 0555 -> 0700 +# appendonlydir dir $HOST_OWNER -> $REDIS_UID 0333 -> 0600 +# dump.rdb file $HOST_OWNER -> $REDIS_UID 0333 -> 0600 +iterate_dir_structure_with() { + awk 'NF {print $1,$2,$3,$5,$6,$8}' \ + | while read -r \ + entry \ + type \ + initial_owner \ + expected_owner \ + initial_mode \ + expected_mode_mask; \ + do + "$@" + done +} + +# Ownership and permissions test helper. +# +# This function tests the entrypoint. +# +# The idea is to test data and config files ownerhsip and permissions before and after container has started. +# +# The function creates temporary directory and uses --dir-structure (see iterate_dir_structure_with and create_entry) +# to create files and directories in this temporary dir. +# +# The temporary dir is mounted into the --mount-target inside the container. +# +# Container is started using REDIS_IMG and the function arguments as CMD. +# +# After container exits, all file permissions and ownership are checked using expected values from --dir-structure (see assert_entry) +# +# Additionally --extra-assert function is invoked if present. +# +# Arguments: +# < --mount-target DIR > +# [ --dir-structure STRING ] +# [ --extra-assert FUNCTION ] +# [ --docker-flags FLAGS ] +# +# Positional arguments: +# $docker_cmd +run_docker_and_test_ownership() { + docker_flags= + extra_assert= + dir_structure= + while [[ $# -gt 0 ]]; do + case "$1" in + --dir-structure) + dir_structure="$2" + shift 2 + ;; + --mount-target) + mount_target="$2" + shift 2 + ;; + --docker-flags) + docker_flags="$2" + shift 2 + ;; + --extra-assert) + extra_assert="$2" + shift 2 + ;; + --*) + break + ;; + *) + break + ;; + esac + done + docker_cmd="$*" + + if [ -z "$mount_target" ]; then + fail "Mount target is empty" + return 1 + fi + + dir=$(mktemp -d -p .) + + iterate_dir_structure_with create_entry "$dir" <<<"$dir_structure" + + docker_run="docker run --rm -v "$(pwd)/$dir":$mount_target $docker_flags $REDIS_IMG $docker_cmd" + if [ "$TEST_VERBOSE" ]; then + echo -e "\n#### ownership test: $docker_cmd" + echo "running $docker_run" + echo "Before:" + sudo find "$dir" -exec ls -ald {} \+ + fi + + docker_output=$($docker_run 2>&1) + + if [ "$TEST_VERBOSE" ]; then + echo "After:" + sudo find "$dir" -exec ls -ald {} \+ + echo "Docker output:" + echo "$docker_output" + fi + + iterate_dir_structure_with assert_entry "$dir" "$docker_cmd" <<<"$dir_structure" + + if [ "$extra_assert" ]; then + $extra_assert + fi + + sudo rm -rf "$dir" +} + +# running redis-server using different forms +# -v option will make redis-server to either return version or fail (if config has been provided) +# either one is OK for us +run_docker_and_test_ownership_with_common_flags_for_server() { + run_docker_and_test_ownership "${common_flags[@]}" "$@" -v + run_docker_and_test_ownership "${common_flags[@]}" redis-server "$@" -v + run_docker_and_test_ownership "${common_flags[@]}" /usr/local/bin/redis-server "$@" -v +} + +# running redis-sentinel using different forms and --dumb-option +# expecting sentinel to fail, it's ok as we are only interested in entrypoint testing here +run_docker_and_test_ownership_with_common_flags_for_sentinel() { + run_docker_and_test_ownership "${common_flags[@]}" "$@" --sentinel --dumb-option + run_docker_and_test_ownership "${common_flags[@]}" redis-sentinel "$@" --dumb-option + run_docker_and_test_ownership "${common_flags[@]}" /usr/local/bin/redis-sentinel "$@" --dumb-option + run_docker_and_test_ownership "${common_flags[@]}" redis-server "$@" --sentinel --dumb-option + run_docker_and_test_ownership "${common_flags[@]}" /usr/local/bin/redis-server "$@" --sentinel --dumb-option +} + +# start redis server or sentinel and check process uid and gid +run_redis_docker_and_check_uid_gid() { + docker_flags= + expected_cmd="redis-server" + user=redis + group=redis + file_owner= + + while [[ $# -gt 0 ]]; do + case "$1" in + --user) + user="$2" + shift 2 + ;; + --group) + group="$2" + shift 2 + ;; + --expected-cmd) + expected_cmd="$2" + shift 2 + ;; + --docker-flags) + docker_flags=$2 + shift 2 + ;; + --file-owner) + file_owner="$2" + shift 2 + ;; + --*) + fail "Unknown flag $1" + return 1 + ;; + *) + break + ;; + esac + done + + if echo "$expected_cmd" | grep -q "sentinel"; then + dir="$(readlink -f "$(mktemp -d -p .)")" + touch "$dir/sentinel.conf" + if [ "$file_owner" ]; then + sudo chown -R "$file_owner" "$dir" + fi + docker_flags="-v $dir:/etc/sentinel $docker_flags" + fi + + docker_cmd="$*" + # shellcheck disable=SC2086 + container=$(docker run $docker_flags -d "$REDIS_IMG" $docker_cmd) + ret=$? + + assertTrue "Container '$docker_flags $REDIS_IMG $docker_cmd' created" "[ $ret -eq 0 ]" + if [ $ret -gt 0 ]; then + echo "retarning" + return 1 + fi + + cmdline=$(docker exec "$container" cat /proc/1/cmdline|tr -d \\0) + assertContains "$docker_flags $docker_cmd, cmdline: $cmdline" "$cmdline" "$expected_cmd" + + redis_user_uid=$(docker exec "$container" id -u "$user") + redis_user_gid=$(docker exec "$container" id -g "$group") + + status=$(docker exec "$container" cat /proc/1/status) + process_uid=$(echo "$status" | grep Uid | cut -f2) + process_gid=$(echo "$status" | grep Gid | cut -f2) + + assertEquals "redis cmd '$docker_cmd', process uid" "$redis_user_uid" "$process_uid" + assertEquals "redis cmd '$docker_cmd', process gid" "$redis_user_gid" "$process_gid" + + docker stop "$container" >/dev/null + if [ "$dir" ]; then + sudo rm -rf "$dir" + fi +} + +run_redis_docker_and_check_modules() { + docker_cmd="$1" + # shellcheck disable=SC2086 + container=$(docker run --rm -d "$REDIS_IMG" $docker_cmd) + info=$(docker exec "$container" redis-cli info) + + [ "$PLATFORM" ] && [ "$PLATFORM" != "amd64" ] && startSkipping + assertContains "$info" "module:name=timeseries" + assertContains "$info" "module:name=search" + assertContains "$info" "module:name=bf" + assertContains "$info" "module:name=vectorset" + assertContains "$info" "module:name=ReJSON" + + docker stop "$container" >/dev/null +} + +# helper assert function to check redis output +assert_redis_output_has_no_config_perm_error() { + s="can't open config file" + assertNotContains "cmd: $docker_cmd, docker output contains '$s': " "$docker_output" "$s" +} + +assert_redis_v8() { + assertContains "$1" "Redis server v=8" +} + +# Tests # + +test_redis_version() { + ret=$(docker run --rm "$REDIS_IMG" -v|tail -n 1) + assert_redis_v8 "$ret" +} + +test_data_dir_owner_and_perms_changed_by_server_when_data_is_RO() { + dir_structure=" + . dir $HOST_OWNER -> $REDIS_UID 0555 -> 0700 + appendonlydir dir $HOST_OWNER -> $REDIS_UID 0333 -> 0600 + dump.rdb file $HOST_OWNER -> $REDIS_UID 0333 -> 0600 + " + common_flags=(--dir-structure "$dir_structure" --mount-target /data) + run_docker_and_test_ownership_with_common_flags_for_server +} + +test_data_dir_owner_and_perms_changed_by_server_when_appendonlydir_contains_files() { + dir_structure=" + . dir $HOST_OWNER -> $REDIS_UID 0555 -> 0700 + appendonlydir dir $HOST_OWNER -> $REDIS_UID 0333 -> 0600 + appendonlydir/foo.aof dir $HOST_OWNER -> $REDIS_UID 0333 -> 0600 + dump.rdb file $HOST_OWNER -> $REDIS_UID 0333 -> 0600 + " + common_flags=(--dir-structure "$dir_structure" --mount-target /data) + run_docker_and_test_ownership_with_common_flags_for_server +} + +test_data_dir_owner_and_perms_changed_by_server_when_data_is_empty_and_RO() { + dir_structure=" + . dir $HOST_OWNER -> $REDIS_UID 0555 -> 0700 + " + common_flags=(--dir-structure "$dir_structure" --mount-target /data) + run_docker_and_test_ownership_with_common_flags_for_server +} + +test_data_dir_owner_and_perms_not_changed_by_server_when_data_is_RW() { + dir_structure=" + . dir $HOST_OWNER -> $HOST_UID 0777 -> 0777 + appendonlydir dir $HOST_OWNER -> $HOST_UID 0666 -> 0666 + dump.rdb file $HOST_OWNER -> $HOST_UID 0666 -> 0666 + " + common_flags=(--dir-structure "$dir_structure" --mount-target /data) + run_docker_and_test_ownership_with_common_flags_for_server +} + +test_data_dir_owner_and_perms_not_changed_by_server_when_data_contains_unknown_file() { + dir_structure=" + . dir $HOST_OWNER -> $HOST_UID 0555 -> 0555 + appendonlydir dir $HOST_OWNER -> $HOST_UID 0444 -> 0444 + dump.rdb file $HOST_OWNER -> $HOST_UID 0444 -> 0444 + garbage.file file $HOST_OWNER -> $HOST_UID 0444 -> 0444 + " + common_flags=(--dir-structure "$dir_structure" --mount-target /data) + run_docker_and_test_ownership_with_common_flags_for_server +} + +test_data_dir_owner_and_perms_not_changed_by_server_when_data_contains_unknown_subdir() { + dir_structure=" + . dir $HOST_OWNER -> $HOST_UID 0555 -> 0555 + somedir dir $HOST_OWNER -> $HOST_UID 0444 -> 0444 + dump.rdb file $HOST_OWNER -> $HOST_UID 0444 -> 0444 + " + common_flags=(--dir-structure "$dir_structure" --mount-target /data) + run_docker_and_test_ownership_with_common_flags_for_server +} + +test_data_dir_owner_not_changed_when_sentinel() { + dir_structure=" + . dir $HOST_OWNER -> $HOST_UID 0555 -> 0555 + appendonlydir dir $HOST_OWNER -> $HOST_UID 0333 -> 0333 + dump.rdb file $HOST_OWNER -> $HOST_UID 0333 -> 0333 + " + common_flags=(--dir-structure "$dir_structure" --mount-target /data) + run_docker_and_test_ownership_with_common_flags_for_sentinel +} + + +test_config_owner_not_changed_by_server_when_config_is_readable() { + dir_structure=" + . dir $HOST_OWNER -> $HOST_UID 0555 -> 0555 + redis.conf file $HOST_OWNER -> $HOST_UID 0444 -> 0444 + " + common_flags=(--dir-structure "$dir_structure" --mount-target /etc/redis) + run_docker_and_test_ownership_with_common_flags_for_server /etc/redis/redis.conf +} + +test_only_config_file_owner_and_perms_changed_by_server_when_only_config_is_not_readable() { + dir_structure=" + . dir $HOST_OWNER -> $HOST_UID 0555 -> 0555 + redis.conf file $HOST_OWNER -> $REDIS_UID 0000 -> 0400 + " + common_flags=(--dir-structure "$dir_structure" --mount-target /etc/redis) + run_docker_and_test_ownership_with_common_flags_for_server /etc/redis/redis.conf +} + +test_config_file_and_dir_owner_and_perms_changed_by_server_when_not_readable() { + dir_structure=" + . dir $HOST_OWNER -> $REDIS_UID 0000 -> 0400 + redis.conf file $HOST_OWNER -> $REDIS_UID 0000 -> 0400 + " + common_flags=(--dir-structure "$dir_structure" --mount-target /etc/redis) + run_docker_and_test_ownership_with_common_flags_for_server /etc/redis/redis.conf +} + +test_config_owner_and_perms_not_changed_when_unknown_file_exists() { + dir_structure=" + . dir $HOST_OWNER -> $HOST_UID 0000 -> 0000 + redis.conf file $HOST_OWNER -> $HOST_UID 0000 -> 0000 + garbage.file file $HOST_OWNER -> $HOST_UID 0000 -> 0000 + " + + common_flags=(--dir-structure "$dir_structure" --mount-target /etc/redis) + run_docker_and_test_ownership_with_common_flags_for_server /etc/redis/redis.conf + run_docker_and_test_ownership_with_common_flags_for_sentinel /etc/redis/redis.conf +} + +test_config_owner_and_perms_not_changed_when_unknown_subdir_exists() { + dir_structure=" + . dir $HOST_OWNER -> $HOST_UID 0000 -> 0000 + redis.conf file $HOST_OWNER -> $HOST_UID 0000 -> 0000 + some dir $HOST_OWNER -> $HOST_UID 0000 -> 0000 + " + + common_flags=(--dir-structure "$dir_structure" --mount-target /etc/redis) + run_docker_and_test_ownership_with_common_flags_for_server /etc/redis/redis.conf + run_docker_and_test_ownership_with_common_flags_for_sentinel /etc/redis/redis.conf +} + +test_config_owner_and_perms_not_changed_by_sentinel_when_config_is_RW() { + dir_structure=" + . dir $HOST_OWNER -> $HOST_UID 0777 -> 0777 + sentinel.conf file $HOST_OWNER -> $HOST_UID 0666 -> 0666 + " + common_flags=(--dir-structure "$dir_structure" --mount-target /etc/redis/sentinel) + run_docker_and_test_ownership_with_common_flags_for_sentinel /etc/redis/sentinel/sentinel.conf +} + +test_config_file_and_dir_owner_and_perms_changed_by_sentinel_when_RO() { + dir_structure=" + . dir $HOST_OWNER -> $REDIS_UID 0555 -> 0700 + sentinel.conf file $HOST_OWNER -> $REDIS_UID 0400 -> 0600 + " + common_flags=(--dir-structure "$dir_structure" --mount-target /etc/redis/sentinel) + run_docker_and_test_ownership_with_common_flags_for_sentinel /etc/redis/sentinel/sentinel.conf +} + +test_config_dir_owner_and_perms_changed_by_sentinel_when_only_dir_is_RO() { + dir_structure=" + . dir $HOST_OWNER -> $REDIS_UID 0555 -> 0700 + sentinel.conf file $HOST_OWNER -> $HOST_UID 0666 -> 0666 + " + common_flags=(--dir-structure "$dir_structure" --mount-target /etc/redis/sentinel) + run_docker_and_test_ownership_with_common_flags_for_sentinel /etc/redis/sentinel/sentinel.conf +} + +test_config_owner_and_perms_changed_by_sentinel_when_config_is_WO() { + dir_structure=" + . dir $HOST_OWNER -> $REDIS_UID 0333 -> 0700 + sentinel.conf file $HOST_OWNER -> $REDIS_UID 0222 -> 0600 + " + common_flags=(--dir-structure "$dir_structure" --mount-target /etc/redis/sentinel) + run_docker_and_test_ownership_with_common_flags_for_sentinel /etc/redis/sentinel/sentinel.conf +} + +# test that entrypoint tries to start redis even when config is non existent dir +test_redis_start_reached_when_config_dir_does_not_exist() { + assert_has_config_error() { + # shellcheck disable=SC2317 + assertContains "$docker_output" "Fatal error, can't open config file" + # shellcheck disable=SC2317 + assertContains "$docker_output" "No such file or directory" + } + common_flags=(--mount-target /etc/somewhere --extra-assert assert_has_config_error) + run_docker_and_test_ownership_with_common_flags_for_server /etc/nowhere/redis.conf +} + +test_redis_start_reached_when_chown_on_data_dir_is_denied() { + assert_internal() { + # shellcheck disable=SC2317 + assert_redis_v8 "$docker_output" + } + dir_structure=" + . dir $HOST_OWNER -> $HOST_UID 0333 -> 0700 + dump.rdb file root:root -> 0 0222 -> 0222 + " + common_flags=(--mount-target /data + --dir-structure "$dir_structure" + --extra-assert assert_internal + --docker-flags "--cap-drop=chown" + ) + run_docker_and_test_ownership_with_common_flags_for_server +} + +test_data_dir_owner_and_perms_not_changed_by_server_when_data_is_RO_and_SKIP_FIX_PERMS_is_used() { + dir_structure=" + . dir $HOST_OWNER -> $HOST_UID 0555 -> 0555 + datum.rdb file $HOST_OWNER -> $HOST_UID 0444 -> 0444 + " + common_flags=(--mount-target /data + --dir-structure "$dir_structure" + --docker-flags "-e SKIP_FIX_PERMS=1" + ) + run_docker_and_test_ownership_with_common_flags_for_server +} + +test_config_owner_and_perms_not_changed_by_sentinel_when_config_is_RO_and_SKIP_FIX_PERMS_is_used() { + dir_structure=" + . dir $HOST_OWNER -> $HOST_UID 0555 -> 0555 + sentinel.conf file $HOST_OWNER -> $HOST_UID 0444 -> 0444 + " + common_flags=(--mount-target /etc/redis/sentinel + --dir-structure "$dir_structure" + --docker-flags "-e SKIP_FIX_PERMS=1" + ) + run_docker_and_test_ownership_with_common_flags_for_sentinel /etc/redis/sentinel/sentinel.conf +} + + + +test_redis_server_persistence_with_bind_mount() { + dir=$(mktemp -d -p .) + + # make data directory non writable + chmod 0444 "$dir" + + container=$(docker run --rm -d -v "$(pwd)/$dir":/data "$REDIS_IMG" --appendonly yes) + + result=$(echo save | docker exec -i "$container" redis-cli) + assertEquals "OK" "$result" + + # save container hash as a value + result=$(echo "SET FOO $container" | docker exec -i "$container" redis-cli) + assertEquals "OK" "$result" + + docker stop "$container" >/dev/null + + # change the owner + sudo chown -R "$HOST_OWNER" "$dir" + + container2=$(docker run --rm -d -v "$(pwd)/$dir":/data "$REDIS_IMG") + value=$(echo "GET FOO" | docker exec -i "$container2" redis-cli) + assertEquals "$container" "$value" + + docker stop "$container2" >/dev/null + + sudo rm -rf "$dir" +} + +test_redis_server_persistence_with_volume() { + docker volume rm test_redis >/dev/null 2>&1 || : + + docker volume create test_redis >/dev/null + + # change owner of the data volume + docker run --rm -v test_redis:/data --entrypoint=/bin/sh "$REDIS_IMG" -c 'chown -R 0:0 /data' + + container=$(docker run --rm -d -v test_redis:/data "$REDIS_IMG" --appendonly yes) + + result=$(echo save | docker exec -i "$container" redis-cli) + assertEquals "OK" "$result" + + # save container hash as a value + result=$(echo "SET FOO $container" | docker exec -i "$container" redis-cli) + assertEquals "OK" "$result" + + docker stop "$container" >/dev/null + + # change owner and permissions of files in data volume + docker run --rm -v test_redis:/data --entrypoint=/bin/sh "$REDIS_IMG" -c 'chown -R 0:0 /data && chmod 0000 -R /data' + + container2=$(docker run --rm -d -v test_redis:/data "$REDIS_IMG") + value=$(echo "GET FOO" | docker exec -i "$container2" redis-cli) + assertEquals "$container" "$value" + + docker stop "$container2" >/dev/null + + docker volume rm test_redis >/dev/null || : +} + +test_redis_process_uid_and_gid_are_redis() { + run_redis_docker_and_check_uid_gid "" + run_redis_docker_and_check_uid_gid redis-server + run_redis_docker_and_check_uid_gid /usr/local/bin/redis-server + + run_redis_docker_and_check_uid_gid --expected-cmd redis-sentinel redis-sentinel /etc/sentinel/sentinel.conf + run_redis_docker_and_check_uid_gid --expected-cmd redis-sentinel /usr/local/bin/redis-sentinel /etc/sentinel/sentinel.conf + run_redis_docker_and_check_uid_gid --expected-cmd "[sentinel]" /etc/sentinel/sentinel.conf --sentinel + run_redis_docker_and_check_uid_gid --expected-cmd "[sentinel]" redis-server /etc/sentinel/sentinel.conf --sentinel + run_redis_docker_and_check_uid_gid --expected-cmd "[sentinel]" /usr/local/bin/redis-server /etc/sentinel/sentinel.conf --sentinel +} + +test_redis_process_uid_and_gid_respects_docker_user_arg() { + read -r daemon_user_uid _ <<< "$(get_container_user_uid_gid_on_the_host daemon:daemon)" + + # disable persistence as directory data dir would not be writable + common_flags=(--user daemon --group daemon --docker-flags "--user daemon") + run_redis_docker_and_check_uid_gid "${common_flags[@]}" "" --save "" + run_redis_docker_and_check_uid_gid "${common_flags[@]}" redis-server --save "" + run_redis_docker_and_check_uid_gid "${common_flags[@]}" /usr/local/bin/redis-server --save "" + + run_redis_docker_and_check_uid_gid "${common_flags[@]}" --file-owner "$daemon_user_uid" --expected-cmd redis-sentinel redis-sentinel /etc/sentinel/sentinel.conf + run_redis_docker_and_check_uid_gid "${common_flags[@]}" --file-owner "$daemon_user_uid" --expected-cmd redis-sentinel /usr/local/bin/redis-sentinel /etc/sentinel/sentinel.conf + run_redis_docker_and_check_uid_gid "${common_flags[@]}" --file-owner "$daemon_user_uid" --expected-cmd "[sentinel]" /etc/sentinel/sentinel.conf --sentinel + run_redis_docker_and_check_uid_gid "${common_flags[@]}" --file-owner "$daemon_user_uid" --expected-cmd "[sentinel]" redis-server /etc/sentinel/sentinel.conf --sentinel + run_redis_docker_and_check_uid_gid "${common_flags[@]}" --file-owner "$daemon_user_uid" --expected-cmd "[sentinel]" /usr/local/bin/redis-server /etc/sentinel/sentinel.conf --sentinel +} + +test_redis_process_uid_and_gid_are_root_when_SKIP_DROP_PRIVS_is_used() { + common_flags=(--user root --group root --docker-flags "-e SKIP_DROP_PRIVS=1") + run_redis_docker_and_check_uid_gid "${common_flags[@]}" "" --save "" + run_redis_docker_and_check_uid_gid "${common_flags[@]}" redis-server --save "" + run_redis_docker_and_check_uid_gid "${common_flags[@]}" /usr/local/bin/redis-server --save "" + + run_redis_docker_and_check_uid_gid "${common_flags[@]}" --expected-cmd redis-sentinel redis-sentinel /etc/sentinel/sentinel.conf + run_redis_docker_and_check_uid_gid "${common_flags[@]}" --expected-cmd redis-sentinel /usr/local/bin/redis-sentinel /etc/sentinel/sentinel.conf + run_redis_docker_and_check_uid_gid "${common_flags[@]}" --expected-cmd "[sentinel]" /etc/sentinel/sentinel.conf --sentinel + run_redis_docker_and_check_uid_gid "${common_flags[@]}" --expected-cmd "[sentinel]" redis-server /etc/sentinel/sentinel.conf --sentinel + run_redis_docker_and_check_uid_gid "${common_flags[@]}" --expected-cmd "[sentinel]" /usr/local/bin/redis-server /etc/sentinel/sentinel.conf --sentinel +} + +test_redis_server_modules_are_loaded() { + run_redis_docker_and_check_modules + run_redis_docker_and_check_modules redis-server + run_redis_docker_and_check_modules /usr/local/bin/redis-server +} + +# shellcheck disable=SC1091 +. ./shunit2 diff --git a/test/shunit2 b/test/shunit2 new file mode 100755 index 000000000..7b7c7c199 --- /dev/null +++ b/test/shunit2 @@ -0,0 +1,1612 @@ +#! /bin/sh +# vim:et:ft=sh:sts=2:sw=2 +# +# shUnit2 -- Unit testing framework for Unix shell scripts. +# +# Copyright 2008-2021 Kate Ward. All Rights Reserved. +# Released under the Apache 2.0 license. +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Author: kate.ward@forestent.com (Kate Ward) +# https://github.com/kward/shunit2 +# +# shUnit2 is a xUnit based unit test framework for Bourne shell scripts. It is +# based on the popular JUnit unit testing framework for Java. +# +# `expr` may be antiquated, but it is the only solution in some cases. +# shellcheck disable=SC2003 +# Allow usage of legacy backticked `...` notation instead of $(...). +# shellcheck disable=SC2006 + +# Return if shunit2 already loaded. +if test -n "${SHUNIT_VERSION:-}"; then + exit 0 +fi +SHUNIT_VERSION='2.1.9pre' + +# Return values that scripts can use. +SHUNIT_TRUE=0 +SHUNIT_FALSE=1 +SHUNIT_ERROR=2 + +# Determine if `builtin` command exists. +__SHUNIT_BUILTIN='builtin' +# shellcheck disable=2039 +if ! ("${__SHUNIT_BUILTIN}" echo 123 >/dev/null 2>&1); then + __SHUNIT_BUILTIN='' +fi + +# Determine some reasonable command defaults. +__SHUNIT_CMD_ECHO_ESC='echo -e' +# shellcheck disable=SC2039,SC3037 +if ${__SHUNIT_BUILTIN} [ "`echo -e test`" = '-e test' ]; then + __SHUNIT_CMD_ECHO_ESC='echo' +fi + +# Determine if `date` supports nanosecond resolution. +__SHUNIT_CMD_DATE_SECONDS='date +%s.%N' +if ${__SHUNIT_BUILTIN} [ "`date +%N`" = '%N' ]; then + __SHUNIT_CMD_DATE_SECONDS='date +%s' +fi + +# Determine `bc` command. +__SHUNIT_CMD_BC='bc' +if ! (${__SHUNIT_CMD_BC} --help >/dev/null 2>&1); then + __SHUNIT_CMD_BC='busybox bc' +fi +if ! (${__SHUNIT_CMD_BC} --help >/dev/null 2>&1); then + __SHUNIT_CMD_BC='' +fi + +# Determine `dc` command. +__SHUNIT_CMD_DC='dc' +if ! (${__SHUNIT_CMD_DC} --help >/dev/null 2>&1); then + __SHUNIT_CMD_DC='busybox dc' +fi +if ! (${__SHUNIT_CMD_DC} --help >/dev/null 2>&1); then + __SHUNIT_CMD_DC='' +fi + +# Format float numbers to the single style from different tools. +# Args: +# num: string: float number to format +# Returns: +# string: formatted number. Empty string if error occurs. +_shunit_float_format() { + # Double-dot number is an error. + # No need to format if the number is an integer. + case "${1}" in + *.*.*) + return + ;; + *.*) + ;; + *) + echo "${1}" + return + ;; + esac + + _shunit_format_result_="$1" + + # Add leading zero if needed. + _shunit_format_result_="$(echo "${_shunit_format_result_}" \ + |command sed 's/^\./0./g')" + + # Remove trailing zeros. + _shunit_format_result_="$(echo "${_shunit_format_result_}" \ + |command sed 's/0\+$//g')" + + # Remove trailing dot. + _shunit_format_result_="$(echo "${_shunit_format_result_}" \ + |command sed 's/\.$//g')" + + # Print the result. + echo "${_shunit_format_result_}" + unset _shunit_format_result_ +} + +# Calculate numbers using bc. +# Args: +# left: string: left operand (may be float point) +# operation: string: operation (+ - * /) +# right: string: right operand (may be float point) +# Returns: +# string: result +_shunit_calc_bc() { + _shunit_output_="$(echo "$@" \ + |command ${__SHUNIT_CMD_BC:?})" + shunit_return=$? + if ${__SHUNIT_BUILTIN} [ ${shunit_return} -eq ${SHUNIT_TRUE} ]; then + _shunit_float_format "${_shunit_output_}" + shunit_return=$? + fi + + unset _shunit_output_ + return ${shunit_return} +} + +# Calculate numbers using dc. +# Args: +# left: string: left operand (may be float point) +# operation: string: operation (+ - * /) +# right: string: right operand (may be float point) +# Returns: +# string: result +_shunit_calc_dc() { + _shunit_output_="$(echo "$1" "$3" "$2" "p" \ + |command ${__SHUNIT_CMD_DC:?})" + shunit_return=$? + if ${__SHUNIT_BUILTIN} [ ${shunit_return} -eq ${SHUNIT_TRUE} ]; then + _shunit_float_format "${_shunit_output_}" + shunit_return=$? + fi + + unset _shunit_output_ + return ${shunit_return} +} + +# Calculate numbers using expr. +# Args: +# left: string: left integer number operand +# operation: string: operation (+ - * /) +# right: string: right integer number operand +# Returns: +# string: result. Empty string if error occurs. +_shunit_calc_expr() { + expr "$@" 2>/dev/null || ${__SHUNIT_BUILTIN} true +} + +# Determine what command to use for calculating numbers. +__SHUNIT_CMD_CALC='_shunit_calc_bc' +if ! ("${__SHUNIT_CMD_CALC}" 1 + 2 >/dev/null 2>&1); then + __SHUNIT_CMD_CALC=_shunit_calc_dc +fi +if ! ("${__SHUNIT_CMD_CALC}" 1 + 2 >/dev/null 2>&1); then + __SHUNIT_CMD_CALC=_shunit_calc_expr +fi + +# Commands a user can override if needed. +__SHUNIT_CMD_TPUT='tput' +SHUNIT_CMD_TPUT=${SHUNIT_CMD_TPUT:-${__SHUNIT_CMD_TPUT}} + +# Enable color output. Options are 'auto', 'always', or 'never'. +SHUNIT_COLOR=${SHUNIT_COLOR:-auto} + +# +# Internal constants. +# + +__SHUNIT_MODE_SOURCED='sourced' +__SHUNIT_MODE_STANDALONE='standalone' +__SHUNIT_PARENT=${SHUNIT_PARENT:-$0} + +# User provided test prefix to display in front of the name of the test being +# executed. Define by setting the SHUNIT_TEST_PREFIX variable. +__SHUNIT_TEST_PREFIX=${SHUNIT_TEST_PREFIX:-} + +# ANSI colors. +__SHUNIT_ANSI_NONE='\033[0m' +__SHUNIT_ANSI_RED='\033[1;31m' +__SHUNIT_ANSI_GREEN='\033[1;32m' +__SHUNIT_ANSI_YELLOW='\033[1;33m' +__SHUNIT_ANSI_CYAN='\033[1;36m' + +# +# Internal variables. +# + +# Variables. +__shunit_lineno='' # Line number of executed test. +__shunit_mode=${__SHUNIT_MODE_SOURCED} # Operating mode. +__shunit_reportGenerated=${SHUNIT_FALSE} # Is report generated. +__shunit_script='' # Filename of unittest script (standalone mode). +__shunit_skip=${SHUNIT_FALSE} # Is skipping enabled. +__shunit_suite='' # Suite of tests to execute. +__shunit_clean=${SHUNIT_FALSE} # _shunit_cleanup() was already called. +__shunit_suiteName='' # Text name of current test suite. +__shunit_xmlSuiteName='' # XML-ready text name of current test suite. + +# JUnit XML variables. +__shunit_junitXmlOutputFile='' # File to use for JUnit XML output in addition to stdout. +__shunit_junitXmlTestCases='' # Test cases info in the JUnit XML format for output +__shunit_junitXmlCurrentTestCaseErrors='' # Current test case error info in the JUnit XML format for output + +# Time variables +__shunit_startSuiteTime='' # When the suite execution was started +__shunit_endSuiteTime='' # When the suite execution ended +__shunit_startCaseTime='' # When the case execution was started +__shunit_endCaseTime='' # When the case execution ended + +# ANSI colors (populated by _shunit_configureColor()). +__shunit_ansi_none='' +__shunit_ansi_red='' +__shunit_ansi_green='' +__shunit_ansi_yellow='' +__shunit_ansi_cyan='' + +# Counts of tests. +__shunit_testSuccess=${SHUNIT_TRUE} +__shunit_testsTotal=0 +__shunit_testsPassed=0 +__shunit_testsFailed=0 + +# Counts of asserts. +__shunit_assertsTotal=0 +__shunit_assertsPassed=0 +__shunit_assertsFailed=0 +__shunit_assertsSkipped=0 +__shunit_assertsCurrentTest=0 + +# +# Internal functions. +# + +# Logging. +_shunit_warn() { + ${__SHUNIT_CMD_ECHO_ESC} "${__shunit_ansi_yellow}shunit2:WARN${__shunit_ansi_none} $*" >&2 +} +_shunit_error() { + ${__SHUNIT_CMD_ECHO_ESC} "${__shunit_ansi_red}shunit2:ERROR${__shunit_ansi_none} $*" >&2 +} +_shunit_fatal() { + ${__SHUNIT_CMD_ECHO_ESC} "${__shunit_ansi_red}shunit2:FATAL${__shunit_ansi_none} $*" >&2 + exit ${SHUNIT_ERROR} +} + +# +# Macros. +# + +# shellcheck disable=SC2016,SC2089 +_SHUNIT_LINENO_='eval __shunit_lineno=""; if ${__SHUNIT_BUILTIN} [ "${1:-}" = "--lineno" ] && ${__SHUNIT_BUILTIN} [ -n "${2:-}" ]; then __shunit_lineno="[${2}]"; shift 2; fi;' + +# +# Setup. +# + +# Specific shell checks. +if ${__SHUNIT_BUILTIN} [ -n "${ZSH_VERSION:-}" ]; then + setopt |grep "^shwordsplit$" >/dev/null + if ${__SHUNIT_BUILTIN} [ $? -ne ${SHUNIT_TRUE} ]; then + _shunit_fatal 'zsh shwordsplit option is required for proper operation' + fi + if ${__SHUNIT_BUILTIN} [ -z "${SHUNIT_PARENT:-}" ]; then + _shunit_fatal "zsh does not pass \$0 through properly. please declare \ +\"SHUNIT_PARENT=\$0\" before calling shUnit2" + fi +fi + +# Set the constants readonly. +__shunit_constants=`set |grep '^__SHUNIT_' |cut -d= -f1` +echo "${__shunit_constants}" |grep '^Binary file' >/dev/null && \ + __shunit_constants=`set |grep -a '^__SHUNIT_' |cut -d= -f1` +for __shunit_const in ${__shunit_constants}; do + if ${__SHUNIT_BUILTIN} [ -z "${ZSH_VERSION:-}" ]; then + readonly "${__shunit_const}" + else + case ${ZSH_VERSION} in + [123].*) readonly "${__shunit_const}" ;; + *) + # Declare readonly constants globally. + # shellcheck disable=SC2039,SC3045 + readonly -g "${__shunit_const}" + esac + fi +done +unset __shunit_const __shunit_constants + +#----------------------------------------------------------------------------- +# Assertion functions. +# + +# Assert that two values are equal to one another. +# +# Args: +# message: string: failure message [optional] +# expected: string: expected value +# actual: string: actual value +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +assertEquals() { + # shellcheck disable=SC2090 + ${_SHUNIT_LINENO_} + if ${__SHUNIT_BUILTIN} [ $# -lt 2 -o $# -gt 3 ]; then + _shunit_error "assertEquals() requires two or three arguments; $# given" + _shunit_assertFail + return ${SHUNIT_ERROR} + fi + if _shunit_shouldSkip; then + return ${SHUNIT_TRUE} + fi + + shunit_message_=${__shunit_lineno} + if ${__SHUNIT_BUILTIN} [ $# -eq 3 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + shunit_expected_=$1 + shunit_actual_=$2 + + shunit_return=${SHUNIT_TRUE} + if ${__SHUNIT_BUILTIN} [ "${shunit_expected_}" = "${shunit_actual_}" ]; then + _shunit_assertPass + else + failNotEquals "${shunit_message_}" "${shunit_expected_}" "${shunit_actual_}" + shunit_return=${SHUNIT_FALSE} + fi + + unset shunit_message_ shunit_expected_ shunit_actual_ + return ${shunit_return} +} +# shellcheck disable=SC2016,SC2034 +_ASSERT_EQUALS_='eval assertEquals --lineno "${LINENO:-}"' + +# Assert that two values are not equal to one another. +# +# Args: +# message: string: failure message [optional] +# expected: string: expected value +# actual: string: actual value +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +assertNotEquals() { + # shellcheck disable=SC2090 + ${_SHUNIT_LINENO_} + if ${__SHUNIT_BUILTIN} [ $# -lt 2 -o $# -gt 3 ]; then + _shunit_error "assertNotEquals() requires two or three arguments; $# given" + _shunit_assertFail + return ${SHUNIT_ERROR} + fi + if _shunit_shouldSkip; then + return ${SHUNIT_TRUE} + fi + + shunit_message_=${__shunit_lineno} + if ${__SHUNIT_BUILTIN} [ $# -eq 3 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + shunit_expected_=$1 + shunit_actual_=$2 + + shunit_return=${SHUNIT_TRUE} + if ${__SHUNIT_BUILTIN} [ "${shunit_expected_}" != "${shunit_actual_}" ]; then + _shunit_assertPass + else + failSame "${shunit_message_}" "${shunit_expected_}" "${shunit_actual_}" + shunit_return=${SHUNIT_FALSE} + fi + + unset shunit_message_ shunit_expected_ shunit_actual_ + return ${shunit_return} +} +# shellcheck disable=SC2016,SC2034 +_ASSERT_NOT_EQUALS_='eval assertNotEquals --lineno "${LINENO:-}"' + +# Assert that a container contains a content. +# +# Args: +# message: string: failure message [optional] +# container: string: container to analyze +# content: string: content to find +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +assertContains() { + # shellcheck disable=SC2090 + ${_SHUNIT_LINENO_} + if ${__SHUNIT_BUILTIN} [ $# -lt 2 -o $# -gt 3 ]; then + _shunit_error "assertContains() requires two or three arguments; $# given" + _shunit_assertFail + return ${SHUNIT_ERROR} + fi + if _shunit_shouldSkip; then + return ${SHUNIT_TRUE} + fi + + shunit_message_=${__shunit_lineno} + if ${__SHUNIT_BUILTIN} [ $# -eq 3 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + shunit_container_=$1 + shunit_content_=$2 + shunit_return=${SHUNIT_TRUE} + if echo "${shunit_container_}" |grep -F -- "${shunit_content_}" >/dev/null; then + _shunit_assertPass + else + failNotFound "${shunit_message_}" "${shunit_content_}" + shunit_return=${SHUNIT_FALSE} + fi + + unset shunit_message_ shunit_container_ shunit_content_ + return ${shunit_return} +} +# shellcheck disable=SC2016,SC2034 +_ASSERT_CONTAINS_='eval assertContains --lineno "${LINENO:-}"' + +# Assert that a container does not contain a content. +# +# Args: +# message: string: failure message [optional] +# container: string: container to analyze +# content: string: content to look for +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +assertNotContains() { + # shellcheck disable=SC2090 + ${_SHUNIT_LINENO_} + if ${__SHUNIT_BUILTIN} [ $# -lt 2 -o $# -gt 3 ]; then + _shunit_error "assertNotContains() requires two or three arguments; $# given" + _shunit_assertFail + return ${SHUNIT_ERROR} + fi + if _shunit_shouldSkip; then + return ${SHUNIT_TRUE} + fi + + shunit_message_=${__shunit_lineno} + if ${__SHUNIT_BUILTIN} [ $# -eq 3 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + shunit_container_=$1 + shunit_content_=$2 + + shunit_return=${SHUNIT_TRUE} + if echo "$shunit_container_" |grep -F -- "$shunit_content_" > /dev/null; then + failFound "${shunit_message_}" "${shunit_content_}" + shunit_return=${SHUNIT_FALSE} + else + _shunit_assertPass + fi + + unset shunit_message_ shunit_container_ shunit_content_ + return ${shunit_return} +} +# shellcheck disable=SC2016,SC2034 +_ASSERT_NOT_CONTAINS_='eval assertNotContains --lineno "${LINENO:-}"' + +# Assert that a value is null (i.e. an empty string). +# +# Args: +# message: string: failure message [optional] +# actual: string: actual value +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +assertNull() { + # shellcheck disable=SC2090 + ${_SHUNIT_LINENO_} + if ${__SHUNIT_BUILTIN} [ $# -gt 2 ]; then + # Allowing 0 arguments as $1 might actually be null. + _shunit_error "assertNull() requires one or two arguments; $# given" + _shunit_assertFail + return ${SHUNIT_ERROR} + fi + if _shunit_shouldSkip; then + return ${SHUNIT_TRUE} + fi + + shunit_message_=${__shunit_lineno} + if ${__SHUNIT_BUILTIN} [ $# -eq 2 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + + ${__SHUNIT_BUILTIN} test -z "${1:-}" + assertTrue "${shunit_message_}" $? + shunit_return=$? + + unset shunit_message_ + return ${shunit_return} +} +# shellcheck disable=SC2016,SC2034 +_ASSERT_NULL_='eval assertNull --lineno "${LINENO:-}"' + +# Assert that a value is not null (i.e. a non-empty string). +# +# Args: +# message: string: failure message [optional] +# actual: string: actual value +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +assertNotNull() { + # shellcheck disable=SC2090 + ${_SHUNIT_LINENO_} + if ${__SHUNIT_BUILTIN} [ $# -gt 2 ]; then + # Allowing 0 arguments as $1 might actually be null. + _shunit_error "assertNotNull() requires one or two arguments; $# given" + _shunit_assertFail + return ${SHUNIT_ERROR} + fi + if _shunit_shouldSkip; then + return ${SHUNIT_TRUE} + fi + + shunit_message_=${__shunit_lineno} + if ${__SHUNIT_BUILTIN} [ $# -eq 2 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + + ${__SHUNIT_BUILTIN} test -n "${1:-}" + assertTrue "${shunit_message_}" $? + shunit_return=$? + + unset shunit_message_ + return ${shunit_return} +} +# shellcheck disable=SC2016,SC2034 +_ASSERT_NOT_NULL_='eval assertNotNull --lineno "${LINENO:-}"' + +# Assert that two values are the same (i.e. equal to one another). +# +# Args: +# message: string: failure message [optional] +# expected: string: expected value +# actual: string: actual value +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +assertSame() { + # shellcheck disable=SC2090 + ${_SHUNIT_LINENO_} + if ${__SHUNIT_BUILTIN} [ $# -lt 2 -o $# -gt 3 ]; then + _shunit_error "assertSame() requires two or three arguments; $# given" + _shunit_assertFail + return ${SHUNIT_ERROR} + fi + if _shunit_shouldSkip; then + return ${SHUNIT_TRUE} + fi + + shunit_message_=${__shunit_lineno} + if ${__SHUNIT_BUILTIN} [ $# -eq 3 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + assertEquals "${shunit_message_}" "$1" "$2" + shunit_return=$? + + unset shunit_message_ + return ${shunit_return} +} +# shellcheck disable=SC2016,SC2034 +_ASSERT_SAME_='eval assertSame --lineno "${LINENO:-}"' + +# Assert that two values are not the same (i.e. not equal to one another). +# +# Args: +# message: string: failure message [optional] +# expected: string: expected value +# actual: string: actual value +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +assertNotSame() { + # shellcheck disable=SC2090 + ${_SHUNIT_LINENO_} + if ${__SHUNIT_BUILTIN} [ $# -lt 2 -o $# -gt 3 ]; then + _shunit_error "assertNotSame() requires two or three arguments; $# given" + _shunit_assertFail + return ${SHUNIT_ERROR} + fi + if _shunit_shouldSkip; then + return ${SHUNIT_TRUE} + fi + + shunit_message_=${__shunit_lineno} + if ${__SHUNIT_BUILTIN} [ $# -eq 3 ]; then + shunit_message_="${shunit_message_:-}$1" + shift + fi + assertNotEquals "${shunit_message_}" "$1" "$2" + shunit_return=$? + + unset shunit_message_ + return ${shunit_return} +} +# shellcheck disable=SC2016,SC2034 +_ASSERT_NOT_SAME_='eval assertNotSame --lineno "${LINENO:-}"' + +# Assert that a value or shell test condition is true. +# +# In shell, a value of 0 is true and a non-zero value is false. Any integer +# value passed can thereby be tested. +# +# Shell supports much more complicated tests though, and a means to support +# them was needed. As such, this function tests that conditions are true or +# false through evaluation rather than just looking for a true or false. +# +# The following test will succeed: +# assertTrue 0 +# assertTrue "[ 34 -gt 23 ]" +# The following test will fail with a message: +# assertTrue 123 +# assertTrue "test failed" "[ -r '/non/existent/file' ]" +# +# Args: +# message: string: failure message [optional] +# condition: string: integer value or shell conditional statement +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +assertTrue() { + # shellcheck disable=SC2090 + ${_SHUNIT_LINENO_} + if ${__SHUNIT_BUILTIN} [ $# -lt 1 -o $# -gt 2 ]; then + _shunit_error "assertTrue() takes one or two arguments; $# given" + _shunit_assertFail + return ${SHUNIT_ERROR} + fi + if _shunit_shouldSkip; then + return ${SHUNIT_TRUE} + fi + + shunit_message_=${__shunit_lineno} + if ${__SHUNIT_BUILTIN} [ $# -eq 2 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + shunit_condition_=$1 + + # See if condition is an integer, i.e. a return value. + shunit_return=${SHUNIT_TRUE} + if ${__SHUNIT_BUILTIN} [ -z "${shunit_condition_}" ]; then + # Null condition. + shunit_return=${SHUNIT_FALSE} + elif (expr \( "${shunit_condition_}" + '0' \) '=' "${shunit_condition_}" >/dev/null 2>&1) + then + # Possible return value. Treating 0 as true, and non-zero as false. + if ${__SHUNIT_BUILTIN} [ "${shunit_condition_}" -ne 0 ]; then + shunit_return=${SHUNIT_FALSE} + fi + else + # Hopefully... a condition. + if ! eval "${shunit_condition_}" >/dev/null 2>&1; then + shunit_return=${SHUNIT_FALSE} + fi + fi + + # Record the test. + if ${__SHUNIT_BUILTIN} [ ${shunit_return} -eq ${SHUNIT_TRUE} ]; then + _shunit_assertPass + else + _shunit_assertFail "${shunit_message_}" + fi + + unset shunit_message_ shunit_condition_ + return ${shunit_return} +} +# shellcheck disable=SC2016,SC2034 +_ASSERT_TRUE_='eval assertTrue --lineno "${LINENO:-}"' + +# Assert that a value or shell test condition is false. +# +# In shell, a value of 0 is true and a non-zero value is false. Any integer +# value passed can thereby be tested. +# +# Shell supports much more complicated tests though, and a means to support +# them was needed. As such, this function tests that conditions are true or +# false through evaluation rather than just looking for a true or false. +# +# The following test will succeed: +# assertFalse 1 +# assertFalse "[ 'apples' = 'oranges' ]" +# The following test will fail with a message: +# assertFalse 0 +# assertFalse "test failed" "[ 1 -eq 1 -a 2 -eq 2 ]" +# +# Args: +# message: string: failure message [optional] +# condition: string: integer value or shell conditional statement +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +assertFalse() { + # shellcheck disable=SC2090 + ${_SHUNIT_LINENO_} + if ${__SHUNIT_BUILTIN} [ $# -lt 1 -o $# -gt 2 ]; then + _shunit_error "assertFalse() requires one or two arguments; $# given" + _shunit_assertFail + return ${SHUNIT_ERROR} + fi + if _shunit_shouldSkip; then + return ${SHUNIT_TRUE} + fi + + shunit_message_=${__shunit_lineno} + if ${__SHUNIT_BUILTIN} [ $# -eq 2 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + shunit_condition_=$1 + + # See if condition is an integer, i.e. a return value. + shunit_return=${SHUNIT_TRUE} + if ${__SHUNIT_BUILTIN} [ -z "${shunit_condition_}" ]; then + # Null condition. + shunit_return=${SHUNIT_TRUE} + elif (expr \( "${shunit_condition_}" + '0' \) '=' "${shunit_condition_}" >/dev/null 2>&1); then + # Possible return value. Treating 0 as true, and non-zero as false. + if ${__SHUNIT_BUILTIN} [ "${shunit_condition_}" -eq 0 ]; then + shunit_return=${SHUNIT_FALSE} + fi + else + # Hopefully... a condition. + # shellcheck disable=SC2086 + if eval ${shunit_condition_} >/dev/null 2>&1; then + shunit_return=${SHUNIT_FALSE} + fi + fi + + # Record the test. + if ${__SHUNIT_BUILTIN} [ "${shunit_return}" -eq "${SHUNIT_TRUE}" ]; then + _shunit_assertPass + else + _shunit_assertFail "${shunit_message_}" + fi + + unset shunit_message_ shunit_condition_ + return "${shunit_return}" +} +# shellcheck disable=SC2016,SC2034 +_ASSERT_FALSE_='eval assertFalse --lineno "${LINENO:-}"' + +#----------------------------------------------------------------------------- +# Failure functions. +# + +# Records a test failure. +# +# Args: +# message: string: failure message [optional] +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +fail() { + # shellcheck disable=SC2090 + ${_SHUNIT_LINENO_} + if ${__SHUNIT_BUILTIN} [ $# -gt 1 ]; then + _shunit_error "fail() requires zero or one arguments; $# given" + return ${SHUNIT_ERROR} + fi + if _shunit_shouldSkip; then + return ${SHUNIT_TRUE} + fi + + shunit_message_=${__shunit_lineno} + if ${__SHUNIT_BUILTIN} [ $# -eq 1 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + + _shunit_assertFail "${shunit_message_}" + + unset shunit_message_ + return ${SHUNIT_FALSE} +} +# shellcheck disable=SC2016,SC2034 +_FAIL_='eval fail --lineno "${LINENO:-}"' + +# Records a test failure, stating two values were not equal. +# +# Args: +# message: string: failure message [optional] +# expected: string: expected value +# actual: string: actual value +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +failNotEquals() { + # shellcheck disable=SC2090 + ${_SHUNIT_LINENO_} + if ${__SHUNIT_BUILTIN} [ $# -lt 2 -o $# -gt 3 ]; then + _shunit_error "failNotEquals() requires one or two arguments; $# given" + return ${SHUNIT_ERROR} + fi + if _shunit_shouldSkip; then + return ${SHUNIT_TRUE} + fi + + shunit_message_=${__shunit_lineno} + if ${__SHUNIT_BUILTIN} [ $# -eq 3 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + shunit_expected_=$1 + shunit_actual_=$2 + + shunit_message_=${shunit_message_%% } + _shunit_assertFail "${shunit_message_:+${shunit_message_} }expected:<${shunit_expected_}> but was:<${shunit_actual_}>" + + unset shunit_message_ shunit_expected_ shunit_actual_ + return ${SHUNIT_FALSE} +} +# shellcheck disable=SC2016,SC2034 +_FAIL_NOT_EQUALS_='eval failNotEquals --lineno "${LINENO:-}"' + +# Records a test failure, stating a value was found. +# +# Args: +# message: string: failure message [optional] +# content: string: found value +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +failFound() { + # shellcheck disable=SC2090 + ${_SHUNIT_LINENO_} + if ${__SHUNIT_BUILTIN} [ $# -lt 1 -o $# -gt 2 ]; then + _shunit_error "failFound() requires one or two arguments; $# given" + return ${SHUNIT_ERROR} + fi + if _shunit_shouldSkip; then + return ${SHUNIT_TRUE} + fi + + shunit_message_=${__shunit_lineno} + if ${__SHUNIT_BUILTIN} [ $# -eq 2 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + shunit_content_=$1 + + shunit_message_=${shunit_message_%% } + _shunit_assertFail "${shunit_message_:+${shunit_message_} }found:<${shunit_content_}>" + + unset shunit_message_ shunit_content_ + return ${SHUNIT_FALSE} +} +# shellcheck disable=SC2016,SC2034 +_FAIL_FOUND_='eval failFound --lineno "${LINENO:-}"' + +# Records a test failure, stating a content was not found. +# +# Args: +# message: string: failure message [optional] +# content: string: content not found +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +failNotFound() { + # shellcheck disable=SC2090 + ${_SHUNIT_LINENO_} + if ${__SHUNIT_BUILTIN} [ $# -lt 1 -o $# -gt 2 ]; then + _shunit_error "failNotFound() requires one or two arguments; $# given" + return ${SHUNIT_ERROR} + fi + if _shunit_shouldSkip; then + return ${SHUNIT_TRUE} + fi + + shunit_message_=${__shunit_lineno} + if ${__SHUNIT_BUILTIN} [ $# -eq 2 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + shunit_content_=$1 + + shunit_message_=${shunit_message_%% } + _shunit_assertFail "${shunit_message_:+${shunit_message_} }not found:<${shunit_content_}>" + + unset shunit_message_ shunit_content_ + return ${SHUNIT_FALSE} +} +# shellcheck disable=SC2016,SC2034 +_FAIL_NOT_FOUND_='eval failNotFound --lineno "${LINENO:-}"' + +# Records a test failure, stating two values should have been the same. +# +# Args: +# message: string: failure message [optional] +# expected: string: expected value +# actual: string: actual value +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +failSame() { + # shellcheck disable=SC2090 + ${_SHUNIT_LINENO_} + if ${__SHUNIT_BUILTIN} [ $# -lt 2 -o $# -gt 3 ]; then + _shunit_error "failSame() requires two or three arguments; $# given" + return ${SHUNIT_ERROR} + fi + if _shunit_shouldSkip; then + return ${SHUNIT_TRUE} + fi + + shunit_message_=${__shunit_lineno} + if ${__SHUNIT_BUILTIN} [ $# -eq 3 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + + shunit_message_=${shunit_message_%% } + _shunit_assertFail "${shunit_message_:+${shunit_message_} }expected not same" + + unset shunit_message_ + return ${SHUNIT_FALSE} +} +# shellcheck disable=SC2016,SC2034 +_FAIL_SAME_='eval failSame --lineno "${LINENO:-}"' + +# Records a test failure, stating two values were not equal. +# +# This is functionally equivalent to calling failNotEquals(). +# +# Args: +# message: string: failure message [optional] +# expected: string: expected value +# actual: string: actual value +# Returns: +# integer: success (TRUE/FALSE/ERROR constant) +failNotSame() { + # shellcheck disable=SC2090 + ${_SHUNIT_LINENO_} + if ${__SHUNIT_BUILTIN} [ $# -lt 2 -o $# -gt 3 ]; then + _shunit_error "failNotSame() requires one or two arguments; $# given" + return ${SHUNIT_ERROR} + fi + if _shunit_shouldSkip; then + return ${SHUNIT_TRUE} + fi + + shunit_message_=${__shunit_lineno} + if ${__SHUNIT_BUILTIN} [ $# -eq 3 ]; then + shunit_message_="${shunit_message_}$1" + shift + fi + failNotEquals "${shunit_message_}" "$1" "$2" + shunit_return=$? + + unset shunit_message_ + return ${shunit_return} +} +# shellcheck disable=SC2016,SC2034 +_FAIL_NOT_SAME_='eval failNotSame --lineno "${LINENO:-}"' + +#----------------------------------------------------------------------------- +# Skipping functions. +# + +# Force remaining assert and fail functions to be "skipped". +# +# This function forces the remaining assert and fail functions to be "skipped", +# i.e. they will have no effect. Each function skipped will be recorded so that +# the total of asserts and fails will not be altered. +# +# Args: +# message: string: message to provide to user [optional] +startSkipping() { + if ${__SHUNIT_BUILTIN} [ $# -gt 0 ]; then _shunit_warn "[skipping] $*"; fi + __shunit_skip=${SHUNIT_TRUE} +} + +# Resume the normal recording behavior of assert and fail calls. +# +# Args: +# None +endSkipping() { __shunit_skip=${SHUNIT_FALSE}; } + +# Returns the state of assert and fail call skipping. +# +# Args: +# None +# Returns: +# boolean: (TRUE/FALSE constant) +isSkipping() { return ${__shunit_skip}; } + +#----------------------------------------------------------------------------- +# Suite functions. +# + +# Stub. This function should contains all unit test calls to be made. +# +# DEPRECATED (as of 2.1.0) +# +# This function can be optionally overridden by the user in their test suite. +# +# If this function exists, it will be called when shunit2 is sourced. If it +# does not exist, shunit2 will search the parent script for all functions +# beginning with the word 'test', and they will be added dynamically to the +# test suite. +# +# This function should be overridden by the user in their unit test suite. +# Note: see _shunit_mktempFunc() for actual implementation +# +# Args: +# None +#suite() { :; } # DO NOT UNCOMMENT THIS FUNCTION + +# Adds a function name to the list of tests schedule for execution. +# +# This function should only be called from within the suite() function. +# +# Args: +# function: string: name of a function to add to current unit test suite +suite_addTest() { + shunit_func_=${1:-} + + __shunit_suite="${__shunit_suite:+${__shunit_suite} }${shunit_func_}" + __shunit_testsTotal=`expr "${__shunit_testsTotal}" + 1` + + unset shunit_func_ +} + +# Stub. This function will be called once before any tests are run. +# +# Common one-time environment preparation tasks shared by all tests can be +# defined here. +# +# This function should be overridden by the user in their unit test suite. +# Note: see _shunit_mktempFunc() for actual implementation +# +# Args: +# None +#oneTimeSetUp() { :; } # DO NOT UNCOMMENT THIS FUNCTION + +# Stub. This function will be called once after all tests are finished. +# +# Common one-time environment cleanup tasks shared by all tests can be defined +# here. +# +# This function should be overridden by the user in their unit test suite. +# Note: see _shunit_mktempFunc() for actual implementation +# +# Args: +# None +#oneTimeTearDown() { :; } # DO NOT UNCOMMENT THIS FUNCTION + +# Stub. This function will be called before each test is run. +# +# Common environment preparation tasks shared by all tests can be defined here. +# +# This function should be overridden by the user in their unit test suite. +# Note: see _shunit_mktempFunc() for actual implementation +# +# Args: +# None +#setUp() { :; } # DO NOT UNCOMMENT THIS FUNCTION + +# Note: see _shunit_mktempFunc() for actual implementation +# Stub. This function will be called after each test is run. +# +# Common environment cleanup tasks shared by all tests can be defined here. +# +# This function should be overridden by the user in their unit test suite. +# Note: see _shunit_mktempFunc() for actual implementation +# +# Args: +# None +#tearDown() { :; } # DO NOT UNCOMMENT THIS FUNCTION + +#------------------------------------------------------------------------------ +# Internal shUnit2 functions. +# + +# Create a temporary directory to store various run-time files in. +# +# This function is a cross-platform temporary directory creation tool. Not all +# OSes have the `mktemp` function, so one is included here. +# +# Args: +# None +# Outputs: +# string: the temporary directory that was created +_shunit_mktempDir() { + # Try the standard `mktemp` function. + if ( exec mktemp -dqt shunit.XXXXXX 2>/dev/null ); then + return + fi + + # The standard `mktemp` didn't work. Use our own. + # shellcheck disable=SC2039,SC3028 + if ${__SHUNIT_BUILTIN} [ -r '/dev/urandom' -a -x '/usr/bin/od' ]; then + _shunit_random_=`/usr/bin/od -vAn -N4 -tx4 "${_shunit_file_}" +#! /bin/sh +exit ${SHUNIT_TRUE} +EOF + command chmod +x "${_shunit_file_}" + done + + unset _shunit_file_ +} + +# Final cleanup function to leave things as we found them. +# +# Besides removing the temporary directory, this function is in charge of the +# final exit code of the unit test. The exit code is based on how the script +# was ended (e.g. normal exit, or via Ctrl-C). +# +# Args: +# name: string: name of the trap called (specified when trap defined) +_shunit_cleanup() { + _shunit_name_=$1 + + _shunit_signal_=0 + case "${_shunit_name_}" in + EXIT) ;; + INT) _shunit_signal_=130 ;; # 2+128 + TERM) _shunit_signal_=143 ;; # 15+128 + *) + _shunit_error "unrecognized trap value (${_shunit_name_})" + ;; + esac + if ${__SHUNIT_BUILTIN} [ "${_shunit_name_}" != 'EXIT' ]; then + _shunit_warn "trapped and now handling the (${_shunit_name_}) signal" + fi + + # Do our work. + if ${__SHUNIT_BUILTIN} [ ${__shunit_clean} -eq ${SHUNIT_FALSE} ]; then + # Ensure tear downs are only called once. + __shunit_clean=${SHUNIT_TRUE} + + tearDown || _shunit_warn 'tearDown() returned non-zero return code.' + __shunit_endCaseTime=`${__SHUNIT_CMD_DATE_SECONDS}` + oneTimeTearDown || \ + _shunit_warn 'oneTimeTearDown() returned non-zero return code.' + __shunit_endSuiteTime=`${__SHUNIT_CMD_DATE_SECONDS}` + + command rm -fr "${__shunit_tmpDir}" + fi + + if ${__SHUNIT_BUILTIN} [ "${_shunit_name_}" != 'EXIT' ]; then + # Handle all non-EXIT signals. + trap - 0 # Disable EXIT trap. + exit ${_shunit_signal_} + elif ${__SHUNIT_BUILTIN} [ ${__shunit_reportGenerated} -eq ${SHUNIT_FALSE} ]; then + _shunit_assertFail 'unknown failure encountered running a test' + _shunit_generateReport + exit ${SHUNIT_ERROR} + fi + + unset _shunit_name_ _shunit_signal_ +} + +# configureColor based on user color preference. +# +# Args: +# color: string: color mode (one of `always`, `auto`, or `never`). +_shunit_configureColor() { + _shunit_color_=${SHUNIT_FALSE} # By default, no color. + case $1 in + 'always') _shunit_color_=${SHUNIT_TRUE} ;; + 'auto') + if ${__SHUNIT_BUILTIN} [ "`_shunit_colors`" -ge 8 ]; then + _shunit_color_=${SHUNIT_TRUE} + fi + ;; + 'never'|'none') ;; # Support 'none' to support legacy usage. + *) _shunit_fatal "unrecognized color option '$1'" ;; + esac + + # shellcheck disable=SC2254 + case ${_shunit_color_} in + ${SHUNIT_TRUE}) + __shunit_ansi_none=${__SHUNIT_ANSI_NONE} + __shunit_ansi_red=${__SHUNIT_ANSI_RED} + __shunit_ansi_green=${__SHUNIT_ANSI_GREEN} + __shunit_ansi_yellow=${__SHUNIT_ANSI_YELLOW} + __shunit_ansi_cyan=${__SHUNIT_ANSI_CYAN} + ;; + ${SHUNIT_FALSE}) + __shunit_ansi_none='' + __shunit_ansi_red='' + __shunit_ansi_green='' + __shunit_ansi_yellow='' + __shunit_ansi_cyan='' + ;; + esac + + unset _shunit_color_ _shunit_tput_ +} + +# colors returns the number of supported colors for the TERM. +_shunit_colors() { + if _shunit_tput_=`${SHUNIT_CMD_TPUT} colors 2>/dev/null`; then + echo "${_shunit_tput_}" + else + echo 16 + fi + unset _shunit_tput_ +} + +# The actual running of the tests happens here. +# +# Args: +# None +_shunit_execSuite() { + for _shunit_test_ in ${__shunit_suite}; do + __shunit_testSuccess=${SHUNIT_TRUE} + + # Reset per-test info + __shunit_assertsCurrentTest=0 + __shunit_junitXmlCurrentTestCaseErrors='' + + # Disable skipping. + endSkipping + + __shunit_startCaseTime=`${__SHUNIT_CMD_DATE_SECONDS}` + + # Execute the per-test setUp() function. + if ! setUp; then + _shunit_fatal "setUp() returned non-zero return code." + fi + + # Execute the test. + echo "${__SHUNIT_TEST_PREFIX}${_shunit_test_}" + # shellcheck disable=SC2086 + if ! eval ${_shunit_test_}; then + _shunit_error "${_shunit_test_}() returned non-zero return code." + __shunit_testSuccess=${SHUNIT_ERROR} + fi + + # Execute the per-test tearDown() function. + if ! tearDown; then + _shunit_fatal "tearDown() returned non-zero return code." + fi + __shunit_endCaseTime=`${__SHUNIT_CMD_DATE_SECONDS}` + + _shunit_test_execution_time_=`"${__SHUNIT_CMD_CALC}" "${__shunit_endCaseTime}" - "${__shunit_startCaseTime}"` + + # Store current test case info in JUnit XML. + __shunit_junitXmlTestCases="${__shunit_junitXmlTestCases} + ${__shunit_junitXmlCurrentTestCaseErrors} + " + + # Update stats. + if ${__SHUNIT_BUILTIN} [ ${__shunit_testSuccess} -eq ${SHUNIT_TRUE} ]; then + __shunit_testsPassed=`expr "${__shunit_testsPassed}" + 1` + else + __shunit_testsFailed=`expr "${__shunit_testsFailed}" + 1` + fi + done + + unset _shunit_test_ _shunit_test_execution_time_ +} + +# Generates the user friendly report with appropriate OK/FAILED message. +# +# Args: +# None +# Output: +# string: the report of successful and failed tests, as well as totals. +_shunit_generateReport() { + if ${__SHUNIT_BUILTIN} [ "${__shunit_reportGenerated}" -eq ${SHUNIT_TRUE} ]; then + return + fi + + _shunit_ok_=${SHUNIT_TRUE} + + # If no exit code was provided, determine an appropriate one. + if ${__SHUNIT_BUILTIN} [ "${__shunit_testsFailed}" -gt 0 -o ${__shunit_testSuccess} -eq ${SHUNIT_FALSE} ]; then + _shunit_ok_=${SHUNIT_FALSE} + fi + + echo + _shunit_msg_="Ran ${__shunit_ansi_cyan}${__shunit_testsTotal}${__shunit_ansi_none}" + if ${__SHUNIT_BUILTIN} [ "${__shunit_testsTotal}" -eq 1 ]; then + ${__SHUNIT_CMD_ECHO_ESC} "${_shunit_msg_} test." + else + ${__SHUNIT_CMD_ECHO_ESC} "${_shunit_msg_} tests." + fi + + if ${__SHUNIT_BUILTIN} [ -n "${__shunit_junitXmlOutputFile}" ]; then + # Calculate total execution time in seconds. + _shunit_suite_execution_time_=`"${__SHUNIT_CMD_CALC}" "${__shunit_endSuiteTime}" - "${__shunit_startSuiteTime}"` + + # Generate a ISO-8601 compliant date. + _shunit_suite_start_time_preformatted_=`date -u '+%Y-%m-%dT%H:%M:%S%z' -d "@${__shunit_startSuiteTime}"` + + echo " +${__shunit_junitXmlTestCases} +" > "${__shunit_junitXmlOutputFile}" + echo + echo "JUnit XML file ${__shunit_junitXmlOutputFile} was saved." + fi + + if ${__SHUNIT_BUILTIN} [ ${_shunit_ok_} -eq ${SHUNIT_TRUE} ]; then + _shunit_msg_="${__shunit_ansi_green}OK${__shunit_ansi_none}" + if ${__SHUNIT_BUILTIN} [ "${__shunit_assertsSkipped}" -gt 0 ]; then + _shunit_msg_="${_shunit_msg_} (${__shunit_ansi_yellow}skipped=${__shunit_assertsSkipped}${__shunit_ansi_none})" + fi + else + _shunit_msg_="${__shunit_ansi_red}FAILED${__shunit_ansi_none}" + _shunit_msg_="${_shunit_msg_} (${__shunit_ansi_red}failures=${__shunit_assertsFailed}${__shunit_ansi_none}" + if ${__SHUNIT_BUILTIN} [ "${__shunit_assertsSkipped}" -gt 0 ]; then + _shunit_msg_="${_shunit_msg_},${__shunit_ansi_yellow}skipped=${__shunit_assertsSkipped}${__shunit_ansi_none}" + fi + _shunit_msg_="${_shunit_msg_})" + fi + + echo + ${__SHUNIT_CMD_ECHO_ESC} "${_shunit_msg_}" + __shunit_reportGenerated=${SHUNIT_TRUE} + + unset _shunit_msg_ _shunit_ok_ _shunit_suite_execution_time_ _shunit_suite_start_time_preformatted_ +} + +# Test for whether a function should be skipped. +# +# Args: +# None +# Returns: +# boolean: whether the test should be skipped (TRUE/FALSE constant) +_shunit_shouldSkip() { + if ${__SHUNIT_BUILTIN} test ${__shunit_skip} -eq ${SHUNIT_FALSE}; then + return ${SHUNIT_FALSE} + fi + _shunit_assertSkip +} + +# Records a successful test. +# +# Args: +# None +_shunit_assertPass() { + __shunit_assertsPassed=`expr "${__shunit_assertsPassed}" + 1` + __shunit_assertsTotal=`expr "${__shunit_assertsTotal}" + 1` + __shunit_assertsCurrentTest=`expr "${__shunit_assertsCurrentTest}" + 1` +} + +# Records a test failure. +# +# Args: +# message: string: failure message to provide user +_shunit_assertFail() { + __shunit_testSuccess=${SHUNIT_FALSE} + _shunit_incFailedCount + + _shunit_xml_message_="`_shunit_escapeXmlData "$@"`" + + __shunit_junitXmlCurrentTestCaseErrors="${__shunit_junitXmlCurrentTestCaseErrors} + " + + if ${__SHUNIT_BUILTIN} [ $# -gt 0 ]; then + ${__SHUNIT_CMD_ECHO_ESC} "${__shunit_ansi_red}ASSERT:${__shunit_ansi_none}$*" + fi + + unset _shunit_xml_message_ +} + +# Increment the count of failed asserts. +# +# Args: +# none +_shunit_incFailedCount() { + __shunit_assertsFailed=`expr "${__shunit_assertsFailed}" + 1` + __shunit_assertsTotal=`expr "${__shunit_assertsTotal}" + 1` + __shunit_assertsCurrentTest=`expr "${__shunit_assertsCurrentTest}" + 1` +} + +# Records a skipped test. +# +# Args: +# None +_shunit_assertSkip() { + __shunit_assertsSkipped=`expr "${__shunit_assertsSkipped}" + 1` + __shunit_assertsTotal=`expr "${__shunit_assertsTotal}" + 1` + __shunit_assertsCurrentTest=`expr "${__shunit_assertsCurrentTest}" + 1` +} + +# Dump the current test metrics. +# +# Args: +# none +_shunit_metrics() { + echo "< \ +total: ${__shunit_assertsTotal} \ +passed: ${__shunit_assertsPassed} \ +failed: ${__shunit_assertsFailed} \ +skipped: ${__shunit_assertsSkipped} \ +>" +} + +# Prepare a script filename for sourcing. +# +# Args: +# script: string: path to a script to source +# Returns: +# string: filename prefixed with ./ (if necessary) +_shunit_prepForSourcing() { + _shunit_script_=$1 + case "${_shunit_script_}" in + /*|./*) echo "${_shunit_script_}" ;; + *) echo "./${_shunit_script_}" ;; + esac + unset _shunit_script_ +} + +# Extract list of functions to run tests against. +# +# Args: +# script: string: name of script to extract functions from +# Returns: +# string: of function names +_shunit_extractTestFunctions() { + _shunit_script_=$1 + + # Extract the lines with test function names, strip of anything besides the + # function name, and output everything on a single line. + _shunit_regex_='^\s*((function test[A-Za-z0-9_-]*)|(test[A-Za-z0-9_-]* *\(\)))' + grep -E "${_shunit_regex_}" "${_shunit_script_}" \ + |command sed 's/^[^A-Za-z0-9_-]*//;s/^function //;s/\([A-Za-z0-9_-]*\).*/\1/g' \ + |xargs + + unset _shunit_regex_ _shunit_script_ +} + +# Escape XML data. +# +# Args: +# data: string: data to escape +# Returns: +# string: escaped data +_shunit_escapeXmlData() { + # Required XML characters to escape are described here: + # http://www.w3.org/TR/REC-xml/#syntax + # https://www.liquid-technologies.com/Reference/Glossary/XML_EscapingData.html + echo "$*" \ + |command sed 's/&/\&/g;s//\>/g;s/"/\"/g'";s/'/\'/g" +} + +#------------------------------------------------------------------------------ +# Main. +# + +# Determine the operating mode. +if ${__SHUNIT_BUILTIN} [ $# -eq 0 -o "${1:-}" = '--' ]; then + __shunit_script=${__SHUNIT_PARENT} + __shunit_mode=${__SHUNIT_MODE_SOURCED} +else + __shunit_script=$1 + if ! ${__SHUNIT_BUILTIN} [ -r "${__shunit_script}" ]; then + _shunit_fatal "unable to read from ${__shunit_script}" + fi + __shunit_mode=${__SHUNIT_MODE_STANDALONE} +fi + +# Create a temporary storage location. +__shunit_tmpDir=`_shunit_mktempDir` + +# Provide a public temporary directory for unit test scripts. +# TODO(kward): document this. +SHUNIT_TMPDIR="${__shunit_tmpDir}/tmp" +if ! command mkdir "${SHUNIT_TMPDIR}"; then + _shunit_fatal "error creating SHUNIT_TMPDIR '${SHUNIT_TMPDIR}'" +fi + +# Configure traps to clean up after ourselves. +trap '_shunit_cleanup EXIT' 0 +trap '_shunit_cleanup INT' 2 +trap '_shunit_cleanup TERM' 15 + +# Create phantom functions to work around issues with Cygwin. +_shunit_mktempFunc +PATH="${__shunit_tmpDir}:${PATH}" + +# Make sure phantom functions are executable. This will bite if `/tmp` (or the +# current `$TMPDIR`) points to a path on a partition that was mounted with the +# 'noexec' option. The noexec command was created with `_shunit_mktempFunc()`. +noexec 2>/dev/null || _shunit_fatal \ + 'Please declare TMPDIR with path on partition with exec permission.' + +# We must manually source the tests in standalone mode. +if ${__SHUNIT_BUILTIN} [ "${__shunit_mode}" = "${__SHUNIT_MODE_STANDALONE}" ]; then + # shellcheck disable=SC1090 + ${__SHUNIT_BUILTIN} . "`_shunit_prepForSourcing \"${__shunit_script}\"`" +fi + +# Configure default output coloring behavior. +_shunit_configureColor "${SHUNIT_COLOR}" + +__shunit_startSuiteTime=`${__SHUNIT_CMD_DATE_SECONDS}` + +# Execute the oneTimeSetUp function (if it exists). +if ! oneTimeSetUp; then + _shunit_fatal "oneTimeSetUp() returned non-zero return code." +fi + +# Command line selected tests or suite selected tests +if ${__SHUNIT_BUILTIN} [ "$#" -ge 2 ]; then + # Argument $1 is either the filename of tests or '--'; either way, skip it. + shift + # Remaining arguments ($2 .. $#) are assumed to be: + # - test function names. + # - configuration options, that is started with the `--` prefix. + # Interate through all remaining args in "$@" in a POSIX (likely portable) way. + # Helpful tip: https://unix.stackexchange.com/questions/314032/how-to-use-arguments-like-1-2-in-a-for-loop + for _shunit_arg_ do + case "${_shunit_arg_}" in + --output-junit-xml=*) + # It is a request for JUnit XML output. + __shunit_junitXmlOutputFile="${_shunit_arg_#--output-junit-xml=}" + ;; + --suite-name=*) + # It is a request for a custom suite name. + __shunit_suiteName="${_shunit_arg_#--suite-name=}" + ;; + --*) + _shunit_fatal "unrecognized option \"${_shunit_arg_}\"" + ;; + *) + # It is the test name, process it in a usual way. + suite_addTest "${_shunit_arg_}" + ;; + esac + done + unset _shunit_arg_ +else + # Execute the suite function defined in the parent test script. + # DEPRECATED as of 2.1.0. + suite +fi + +# If no tests or suite specified, dynamically build a list of functions. +if ${__SHUNIT_BUILTIN} [ -z "${__shunit_suite}" ]; then + shunit_funcs_=`_shunit_extractTestFunctions "${__shunit_script}"` + for shunit_func_ in ${shunit_funcs_}; do + suite_addTest "${shunit_func_}" + done +fi +unset shunit_func_ shunit_funcs_ + +# If suite name is not defined, dynamically generate it from the script name. +if ${__SHUNIT_BUILTIN} [ -z "${__shunit_suiteName}" ]; then + __shunit_suiteName="${__shunit_script##*/}" +fi + +# Prepare the suite name for XML output. +__shunit_xmlSuiteName="`_shunit_escapeXmlData "${__shunit_suiteName}"`" + +# Execute the suite of unit tests. +_shunit_execSuite + +# Execute the oneTimeTearDown function (if it exists). +if ! oneTimeTearDown; then + _shunit_fatal "oneTimeTearDown() returned non-zero return code." +fi + +__shunit_endSuiteTime=`${__SHUNIT_CMD_DATE_SECONDS}` + +# Generate a report summary. +_shunit_generateReport + +# That's it folks. +if ! ${__SHUNIT_BUILTIN} [ "${__shunit_testsFailed}" -eq 0 ]; then + return ${SHUNIT_FALSE} +fi