#!/bin/sh
# Copyright 2019, The Tor Project, Inc.
# See LICENSE for licensing information

# Integration test script for verifying that Tor configurations are parsed as
# we expect.
#
# Valid configurations are tested with --dump-config, which parses and
# validates the configuration before writing it out.  We then make sure that
# the result is what we expect, before parsing and dumping it again to make
# sure that there is no change. Optionally, we can also test the log messages
# with --verify-config.
#
# Invalid configurations are tested with --verify-config, which parses
# and validates the configuration. We capture its output and make sure that
# it contains the error message we expect.
#
# When tor is compiled with different libraries or modules, some
# configurations may have different results. We can specify these result
# variants using additional result files.

# This script looks for its test cases as individual directories in
# src/test/conf_examples/.  Each test may have these files:
#
# Configuration Files
#
# torrc -- Usually needed. This file is passed to Tor on the command line
#      with the "-f" flag. (If you omit it, you'll test Tor's behavior when
#      it receives a nonexistent configuration file.)
#
# torrc.defaults -- Optional. If present, it is passed to Tor on the command
#      line with the --defaults-torrc option. If this file is absent, an empty
#      file is passed instead to prevent Tor from reading the system defaults.
#
# cmdline -- Optional. If present, it contains command-line arguments that
#      will be passed to Tor.
#
# (included torrc files or directories) -- Optional. Additional files can be
#      included in configuration, using the "%include" directive. Files or
#      directories can be included in any of the config files listed above.
#      Include paths should be specified relative to the test case directory.
#
# Result Files
#
# expected -- If this file is present, then it should be the expected result
#      of "--dump-config short" for this test case.  Exactly one of
#      "expected" or "error" must be present, or the test will fail.
#
# expected_log -- Optional. If this file is present, then it contains a regex
#      that must be matched by some line in the output of "--verify-config",
#      which must succeed. Only used if "expected" is also present.
#
# error -- If this file is present, then it contains a regex that must be
#      matched by some line in the output of "--verify-config", which must
#      fail. Exactly one of "expected" or "error" must be present, or the
#      test will fail.
#
# {expected,expected_log,error}_${TOR_LIBS_ENABLED}* -- If this file is
#      present, then the outcome is different when some optional libraries are
#      enabled. If there is no result file matching the exact list of enabled
#      libraries, the script searches for result files with one or more of
#      those libraries disabled. The search terminates at the standard result
#      file. If expected* is present, the script also searches for
#      expected_log*.
#
#      For example:
#      A test that succeeds, regardless of any enabled libraries:
#       - expected
#      A test that has a different result if the nss library is enabled
#      (but the same result if any other library is enabled). We also check
#      the log output in this test:
#       - expected
#       - expected_log
#       - expected_nss
#       - expected_log_nss
#      A test that fails if the lzma and zstd modules are *not* enabled:
#       - error
#       - expected_lzma_zstd
#
# {expected,expected_log,error}*_no_${TOR_MODULES_DISABLED} -- If this file is
#      present, then the outcome is different when some modules are disabled.
#      If there is no result file matching the exact list of disabled modules,
#      the standard result file is used. If expected* is present, the script
#      also searches for expected_log*.
#
#      For example:
#      A test that succeeds, regardless of any disabled modules:
#       - expected
#      A test that has a different result if the relay module is disabled
#      (but the same result if just the dirauth module is disabled):
#       - expected
#       - expected_no_relay_dirauth
#      A test that fails if the dirauth module is disabled:
#       - expected
#       - error_no_dirauth
#       - error_no_relay_dirauth
#      (Disabling the relay module also disables dirauth module. But we don't
#      want to encode that knowledge in this test script, so we supply a
#      separate result file for every combination of disabled modules that
#      has a different result.)

umask 077
set -e

MYNAME="$0"

# emulate realpath(), in case coreutils or equivalent is not installed.
abspath() {
    f="$*"
    if test -d "$f"; then
        dir="$f"
        base=""
    else
        dir="$(dirname "$f")"
        base="/$(basename "$f")"
    fi
    dir="$(cd "$dir" && pwd)"
    echo "$dir$base"
}

# find the tor binary
if test $# -ge 1; then
  TOR_BINARY="$1"
  shift
else
  TOR_BINARY="${TESTING_TOR_BINARY:-./src/app/tor}"
fi

TOR_BINARY="$(abspath "$TOR_BINARY")"

echo "Using Tor binary '$TOR_BINARY'."

# make a safe space for temporary files
DATA_DIR=$(mktemp -d -t tor_parseconf_tests.XXXXXX)
trap 'rm -rf "$DATA_DIR"' 0

# This is where we look for examples
EXAMPLEDIR="$(dirname "$0")"/conf_examples

case "$(uname -s)" in
    CYGWIN*) WINDOWS=1;;
    MINGW*) WINDOWS=1;;
    MSYS*) WINDOWS=1;;
    *) WINDOWS=0;;
esac

####
# BUG WORKAROUND FOR 31757:
#  On Appveyor, it seems that Tor sometimes randomly fails to produce
#  output with --dump-config.  Whil we are figuring this out, do not treat
#  windows errors as hard failures.
####
if test "$WINDOWS" = 1; then
    EXITCODE=0
else
    EXITCODE=1
fi

FINAL_EXIT=0
NEXT_TEST=

# Log a failure message to stderr, using $@ as a printf string and arguments
# Set NEXT_TEST to "yes" and FINAL_EXIT to $EXITCODE.
fail_printf()
{
    printf "FAIL: " >&2
    # The first argument is a printf string, so this warning is spurious
    # shellcheck disable=SC2059
    printf "$@" >&2
    NEXT_TEST="yes"
    FINAL_EXIT=$EXITCODE
}

# Log a failure message to stderr, using $@ as a printf string and arguments
# Exit with status $EXITCODE.
die_printf()
{
    printf "FAIL: CRITICAL error in '%s':" "$MYNAME" >&2
    # The first argument is a printf string, so this warning is spurious
    # shellcheck disable=SC2059
    printf "$@" >&2
    exit $EXITCODE
}

if test "$WINDOWS" = 1; then
    FILTER="dos2unix"
else
    FILTER="cat"
fi

EMPTY="${DATA_DIR}/EMPTY"
touch "$EMPTY" || die_printf "Couldn't create empty file '%s'.\\n" \
                             "$EMPTY"
NON_EMPTY="${DATA_DIR}/NON_EMPTY"
echo "This pattern should not match any log messages" \
     > "$NON_EMPTY" || die_printf "Couldn't create non-empty file '%s'.\\n" \
                                  "$NON_EMPTY"

STANDARD_LIBS="libevent\\|openssl\\|zlib"
# Lib names are restricted to [a-z0-9]* at the moment
# We don't actually want to support foreign accents here
# shellcheck disable=SC2018,SC2019
TOR_LIBS_ENABLED="$("$TOR_BINARY" --verify-config \
                      -f "$EMPTY" --defaults-torrc "$EMPTY" \
                    | sed -n 's/.* Tor .* running on .* with\(.*\) and .* .* as libc\./\1/p' \
                    | tr 'A-Z' 'a-z' | tr ',' '\n' \
                    | grep -v "$STANDARD_LIBS" | grep -v "n/a" \
                    | sed 's/\( and\)* \(lib\)*\([a-z0-9]*\) .*/\3/' \
                    | sort | tr '\n' '_')"
# Remove the last underscore, if there is one
TOR_LIBS_ENABLED=${TOR_LIBS_ENABLED%_}

# If we ever have more than 3 optional libraries, we'll need more code here
TOR_LIBS_ENABLED_COUNT="$(echo "$TOR_LIBS_ENABLED_SEARCH" \
                          | tr ' ' '\n' | wc -l)"
if test "$TOR_LIBS_ENABLED_COUNT" -gt 3; then
    die_printf "Can not handle more than 3 optional libraries.\\n"
fi
# Brute-force the combinations of libraries
TOR_LIBS_ENABLED_SEARCH_3="$(echo "$TOR_LIBS_ENABLED" \
    | sed -n \
      's/^\([^_]*\)_\([^_]*\)_\([^_]*\)$/_\1_\2 _\1_\3 _\2_\3 _\1 _\2 _\3/p')"
TOR_LIBS_ENABLED_SEARCH_2="$(echo "$TOR_LIBS_ENABLED" \
    | sed -n 's/^\([^_]*\)_\([^_]*\)$/_\1 _\2/p')"
TOR_LIBS_ENABLED_SEARCH="_$TOR_LIBS_ENABLED \
                           $TOR_LIBS_ENABLED_SEARCH_3 \
                           $TOR_LIBS_ENABLED_SEARCH_2"
TOR_LIBS_ENABLED_SEARCH="$(echo "$TOR_LIBS_ENABLED_SEARCH" | tr ' ' '\n' \
                           | grep -v '^_*$' | tr '\n' ' ')"

TOR_MODULES_DISABLED="$("$TOR_BINARY" --list-modules | grep ': no' \
                        | cut -d ':' -f1 | sort | tr '\n' '_')"
# Remove the last underscore, if there is one
TOR_MODULES_DISABLED=${TOR_MODULES_DISABLED%_}

echo "Tor is configured with:"
echo "Optional Libraries: ${TOR_LIBS_ENABLED:-(None)}"
if test "$TOR_LIBS_ENABLED"; then
    echo "Optional Library Search List: $TOR_LIBS_ENABLED_SEARCH"
fi
echo "Disabled Modules: ${TOR_MODULES_DISABLED:-(None)}"

# Yes, unix uses "0" for a successful command
TRUE=0
FALSE=1

# Run tor --verify-config on the torrc $1, and defaults torrc $2, which may
# be $EMPTY. Pass tor the extra command line arguments $3, which will be
# passed unquoted.
# Send tor's standard output to stderr.
log_verify_config()
{
    # show the command we're about to execute
    # log_verify_config() is only called when we've failed
    printf "Tor --verify-config said:\\n" >&2
    printf "$ %s %s %s %s %s %s %s\\n" \
    "$TOR_BINARY" --verify-config \
                  -f "$1" \
                  --defaults-torrc "$2" \
                  "$3" \
                  >&2
    # We need cmdline unquoted
    # shellcheck disable=SC2086
    "$TOR_BINARY" --verify-config \
                  -f "$1" \
                  --defaults-torrc "$2" \
                  $3 \
                  >&2 \
        || true
}

# Run "tor --dump-config short" on the torrc $1, and defaults torrc $2, which
# may be $EMPTY. Pass tor the extra command line arguments $3, which will be
# passed unquoted. Send tor's standard output to $4.
#
# Set $FULL_TOR_CMD to the tor command line that was executed.
#
# If tor fails, fail_printf() using the file name $5, and context $6,
# which may be an empty string. Then run log_verify_config().
dump_config()
{
    if test "$6"; then
        CONTEXT=" $6"
    else
        CONTEXT=""
    fi

    # keep the command we're about to execute, and show if it we fail
    FULL_TOR_CMD=$(printf "$ %s %s %s %s %s %s %s %s" \
         "$TOR_BINARY" --dump-config short \
                       -f "$1" \
                       --defaults-torrc "$2" \
                       "$3"
             )
    # We need cmdline unquoted
    # shellcheck disable=SC2086
    if ! "$TOR_BINARY" --dump-config short \
                       -f "$1" \
                       --defaults-torrc "$2" \
                       $3 \
                       > "$4"; then
        fail_printf "'%s': Tor --dump-config reported an error%s:\\n%s\\n" \
                    "$5" \
                    "$CONTEXT" \
                    "$FULL_TOR_CMD"
        log_verify_config "$1" \
                          "$2" \
                          "$3"
    fi
}

# Run "$FILTER" on the input $1.
# Send the standard output to $2.
# If tor fails, log a failure message using the file name $3, and context $4,
# which may be an empty string.
filter()
{
    if test "$4"; then
        CONTEXT=" $4"
    else
        CONTEXT=""
    fi

    "$FILTER" "$1" \
              > "$2" \
        || fail_printf "'%s': Filter '%s' reported an error%s.\\n" \
                       "$3" \
                       "$FILTER" \
                       "$CONTEXT"
}

# Compare the expected file $1, and output file $2.
#
# If they are different, fail. Log the differences between the files.
# Run log_verify_config() with torrc $3, defaults torrc $4, and command
# line $5, to log Tor's error messages.
#
# If the file contents are identical, returns true. Otherwise, return false.
#
# Log failure messages using fail_printf(), with the expected file name,
# context $6, which may be an empty string, and the tor command line $7.
check_diff()
{
    if test "$6"; then
        CONTEXT=" $6"
    else
        CONTEXT=""
    fi

    if cmp "$1" "$2" > /dev/null; then
        return "$TRUE"
    else
        fail_printf "'%s': Tor --dump-config said%s:\\n%s\\n" \
                    "$1" \
                    "$CONTEXT" \
                    "$7"
        diff -u "$1" "$2" >&2 \
            || true
        log_verify_config "$3" \
                          "$4" \
                          "$5"
        return "$FALSE"
    fi
}

# Run "tor --dump-config short" on the torrc $1, and defaults torrc $2, which
# may be $EMPTY. Pass tor the extra command line arguments $3, which will be
# passed unquoted. Send tor's standard output to $4, after running $FILTER
# on it.
#
# If tor fails, run log_verify_config().
#
# Compare the expected file $5, and output file. If they are different, fail.
# If this is the first step that failed in this test, run log_verify_config().
#
# If the file contents are identical, returns true. Otherwise, return false,
# and log the differences between the files.
#
# Log failure messages using fail_printf(), with the expected file name, and
# context $6, which may be an empty string.
check_dump_config()
{
    OUTPUT="$4"
    OUTPUT_RAW="${OUTPUT}_raw"

    FULL_TOR_CMD=
    dump_config "$1" \
                "$2" \
                "$3" \
                "$OUTPUT_RAW" \
                "$5" \
                "$6"

    filter "$OUTPUT_RAW" \
           "$OUTPUT" \
           "$5" \
           "$6"

    if check_diff "$5" \
                  "$OUTPUT" \
                  "$1" \
                  "$2" \
                  "$3" \
                  "$6" \
                  "$FULL_TOR_CMD"; then
        return "$TRUE"
    else
        return "$FALSE"
    fi
}

# Check if $1 is an empty file.
# If it is, fail_printf() using $2 as the type of file.
# Returns true if the file is empty, false otherwise.
check_empty_pattern()
{
    if ! test -s "$1"; then
        fail_printf "%s file '%s' is empty, and will match any output.\\n" \
                    "$2" \
                    "$1"
        return "$TRUE"
    else
        return "$FALSE"
    fi
}

# Run tor --verify-config on the torrc $1, and defaults torrc $2, which may
# be $EMPTY. Pass tor the extra command line arguments $3, which will be
# passed unquoted. Send tor's standard output to $4.
#
# Set $FULL_TOR_CMD to the tor command line that was executed.
#
# If tor's exit status does not match the boolean $5, fail_printf()
# using the file name $6, and context $7, which is required.
verify_config()
{
    RESULT=$TRUE

    # keep the command we're about to execute, and show if it we fail
    FULL_TOR_CMD=$(printf "$ %s %s %s %s %s %s %s" \
    "$TOR_BINARY" --verify-config \
                  -f "$1" \
                  --defaults-torrc "$2" \
                  "$3"
             )
    # We need cmdline unquoted
    # shellcheck disable=SC2086
    "$TOR_BINARY" --verify-config \
                  -f "$1" \
                  --defaults-torrc "$2" \
                  $3 \
                  > "$4" || RESULT=$FALSE

    # Convert the actual and expected results to boolean, and compare
    if test $((! (! RESULT))) -ne $((! (! $5))); then
        fail_printf "'%s': Tor --verify-config did not %s:\\n%s\\n" \
                    "$6" \
                    "$7" \
                    "$FULL_TOR_CMD"
        cat "$4" >&2
    fi
}

# Check for the patterns in the match file $1, in the output file $2.
# Uses grep with the entire contents of the match file as the pattern.
# (Not "grep -f".)
#
# If the pattern does not match any lines in the output file, fail.
# Log the pattern, and the entire contents of the output file.
#
# Log failure messages using fail_printf(), with the match file name,
# context $3, and tor command line $4, which are required.
check_pattern()
{
    expect_log="$(cat "$1")"
    if ! grep "$expect_log" "$2" > /dev/null; then
        fail_printf "Expected %s '%s':\\n%s\\n" \
                    "$3" \
                    "$1" \
                    "$expect_log"
        printf "Tor --verify-config said:\\n%s\\n" \
               "$4" >&2
        cat "$2" >&2
    fi
}

# Run tor --verify-config on the torrc $1, and defaults torrc $2, which may
# be $EMPTY. Pass tor the extra command line arguments $3, which will be
# passed unquoted. Send tor's standard output to $4.
#
# If tor's exit status does not match the boolean $5, fail.
#
# Check for the patterns in the match file $6, in the output file.
# Uses grep with the entire contents of the match file as the pattern.
# (Not "grep -f".) The match file must not be empty.
#
# If the pattern does not match any lines in the output file, fail.
# Log the pattern, and the entire contents of the output file.
#
# Log failure messages using fail_printf(), with the match file name,
# and context $7, which is required.
check_verify_config()
{
    if check_empty_pattern "$6" "$7"; then
        return
    fi

    FULL_TOR_CMD=
    verify_config "$1" \
                  "$2" \
                  "$3" \
                  "$4" \
                  "$5" \
                  "$6" \
                  "$7"

    check_pattern "$6" \
                  "$4" \
                  "$7" \
                  "$FULL_TOR_CMD"
}

for dir in "${EXAMPLEDIR}"/*; do
    NEXT_TEST=

    if ! test -d "$dir"; then
       # Only count directories.
       continue
    fi

    testname="$(basename "${dir}")"
    # We use printf since "echo -n" is not standard
    printf "%s: " \
           "$testname"

    PREV_DIR="$(pwd)"
    cd "$dir"

    if test -f "./torrc.defaults"; then
        DEFAULTS="./torrc.defaults"
    else
        DEFAULTS="${DATA_DIR}/EMPTY"
    fi

    if test -f "./cmdline"; then
        CMDLINE="$(cat ./cmdline)"
    else
        CMDLINE=""
    fi

    EXPECTED=
    EXPECTED_LOG=
    ERROR=
    # Search for a custom result file for any combination of enabled optional
    # libraries
    # The libs in the list are [A-Za-z0-9_]* and space-separated.
    # shellcheck disable=SC2086
    for lib_suffix in $TOR_LIBS_ENABLED_SEARCH ""; do
        # Search for a custom result file for any disabled modules
        for mod_suffix in "_no_${TOR_MODULES_DISABLED}" ""; do
            suffix="${lib_suffix}${mod_suffix}"

            if test -f "./expected${suffix}"; then

                # Check for broken configs
                if test -f "./error${suffix}"; then
                    fail_printf "Found both '%s' and '%s'.%s\\n" \
                                "${dir}/expected${suffix}" \
                                "${dir}/error${suffix}" \
                                "(Only one of these files should exist.)"
                    break
                fi

                EXPECTED="./expected${suffix}"
                if test -f "./expected_log${suffix}"; then
                    EXPECTED_LOG="./expected_log${suffix}"
                fi
                break

            elif test -f "./error${suffix}"; then
                ERROR="./error${suffix}"
                break
            fi
        done

        # Exit as soon as the inner loop finds a file, or fails
        if test -f "$EXPECTED" || test -f "$ERROR" || test "$NEXT_TEST"; then
            break
        fi
    done

    if test "$NEXT_TEST"; then
        # The test failed inside the file search loop: go to the next test
        continue
    elif test -f "$EXPECTED"; then
        # This case should succeed: run dump-config and see if it does.

        if check_dump_config "./torrc" \
                             "$DEFAULTS" \
                             "$CMDLINE" \
                             "${DATA_DIR}/output.${testname}" \
                             "$EXPECTED" \
                             ""; then
            # Check round-trip.
            check_dump_config "${DATA_DIR}/output.${testname}" \
                              "$EMPTY" \
                              "" \
                              "${DATA_DIR}/output_2.${testname}" \
                              "$EXPECTED" \
                              "on round-trip" || true
        fi

        if test -f "$EXPECTED_LOG"; then
            # This case should succeed: run verify-config and see if it does.

            check_verify_config "./torrc" \
                                "$DEFAULTS" \
                                "$CMDLINE" \
                                "${DATA_DIR}/output_log.${testname}" \
                                "$TRUE" \
                                "$EXPECTED_LOG" \
                                "log success"
        else
            printf "\\nNOTICE: Missing '%s_log' file:\\n" \
                   "$EXPECTED" >&2
            log_verify_config "./torrc" \
                              "$DEFAULTS" \
                              "$CMDLINE"
        fi

   elif test -f "$ERROR"; then
        # This case should fail: run verify-config and see if it does.

        check_verify_config "./torrc" \
                            "$DEFAULTS" \
                            "$CMDLINE" \
                            "${DATA_DIR}/output.${testname}" \
                            "$FALSE" \
                            "$ERROR" \
                            "log error"
    else
        # This case is not actually configured with a success or a failure.
        # call that an error.
        fail_printf "Did not find ${dir}/*expected or ${dir}/*error.\\n"
    fi

    if test -z "$NEXT_TEST"; then
        echo "OK"
    fi

    cd "$PREV_DIR"

done

exit "$FINAL_EXIT"
