diff --git a/cronic b/cronic index 7666461..d02b53d 100755 --- a/cronic +++ b/cronic @@ -17,6 +17,20 @@ # This script runs under bash 3.2, which is /bin/bash on macOS, so it uses a # feature of a later bash only after checking the version. +# The caller's shell options that this script changes, as the letters that `$-` +# uses for them, which this script restores for the command. They include any +# that the caller passed by exporting SHELLOPTS. This script turns xtrace off, +# so that its own commands are not traced to stderr, which would make every run +# produce output; the redirection discards the trace of `set +x` itself. It +# turns allexport off, so that its own variables are not exported to the +# command; `cronic_caller_flags`, which is assigned before that, is un-exported +# explicitly. +{ + cronic_caller_flags=${-//[^aeux]/} + set +ax + export -n cronic_caller_flags +} 2> /dev/null + # Exit if a command exits with a non-zero status, and treat unset variables # as an error. set -eu @@ -163,11 +177,20 @@ fi # The command runs in a subshell rather than in this shell, so that a builtin # such as `exit` or `exec` cannot end this script before it reports. # `command` because a function of this script, such as `cronic_usage`, would -# otherwise run in place of a program of the same name. +# otherwise run in place of a program of the same name. The redirections +# apply to the whole subshell, so that they also capture what it writes after +# the command finishes, such as the output of an EXIT trap that the command +# sets. The subshell restores the caller's `-a`, `-e`, `-u`, and `-x` options, +# replacing this script's, for a builtin and also for a bash script when the +# caller exported SHELLOPTS. It turns off this script's nounset (errexit is off +# already) in the same command that turns on the caller's options, so that only +# the command is traced. The `:+` avoids a bare `-`, which has a special +# meaning to `set`. set +e ( - command -- "$@" > "$cronic_out" 2> "$cronic_trace" -) + set +u${cronic_caller_flags:+ -$cronic_caller_flags} + command -- "$@" +) > "$cronic_out" 2> "$cronic_trace" cronic_result=$? set -e diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 874bf76..bb21d21 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -268,6 +268,83 @@ check "builtin command" 0 silent : # A builtin that would end the shell does not prevent the report. check "exit builtin" 3 report exit 3 check "exec builtin" 3 report exec "$work/trace-only" 3 +# Output that the subshell writes after the command finishes, such as from an +# EXIT trap that the command sets, is captured rather than leaked. +check "EXIT trap writes to stderr" 0 "report:^from the trap$" \ + trap 'echo "from the trap" >&2' EXIT +check "EXIT trap writes to stdout" 0 silent trap 'echo "from the trap"' EXIT +# A builtin runs with `set +u`, as it would in an ordinary shell. +# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. +check "builtin with an unset variable" 0 silent \ + eval ': "$cronic_test_unset_variable"' +# When the caller exports SHELLOPTS, a bash script runs with the caller's +# options rather than with `cronic`'s own `set -eu`. BASH_ENV is empty so that +# the script runs no startup file. +# Creates "$work/cronic-with-NAME", which runs `cronic` with SHELLOPTS exported, +# after `set OPTION` if OPTION is given. +make_cronic_wrapper() { # NAME [OPTION] + { + echo "#!/bin/bash" + if [ $# -gt 1 ]; then + echo "set $2" + fi + echo "export SHELLOPTS" + echo "exec \"$CRONIC\" \"\$@\"" + } > "$work/cronic-with-$1" + chmod +x "$work/cronic-with-$1" +} +make_cronic_wrapper shellopts +make_cronic_wrapper nounset -u +make_cronic_wrapper errexit -e +make_cronic_wrapper allexport -a +# The wrapper's own trace goes to /dev/null, and `cronic` reads no startup +# file, so that any trace in the output comes from `cronic`. +cat > "$work/cronic-with-xtrace" << EOF +#!/bin/bash +unset BASH_ENV +exec 3>&2 2> /dev/null +set -x +export SHELLOPTS +exec "$CRONIC" "\$@" 2>&3 3>&- +EOF +chmod +x "$work/cronic-with-xtrace" +saved_cronic="$CRONIC" +CRONIC="$work/cronic-with-shellopts" +# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. +check "bash script with an unset variable, SHELLOPTS exported" 0 silent \ + env BASH_ENV= bash -c ': "$cronic_test_unset_variable"' +check "bash script with a failing command, SHELLOPTS exported" 0 silent \ + env BASH_ENV= bash -c 'false; true' +CRONIC="$work/cronic-with-nounset" +# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. +check "bash script with an unset variable, caller's nounset exported" 127 \ + "report:cronic_test_unset_variable" \ + env BASH_ENV= bash -c ': "$cronic_test_unset_variable"' +CRONIC="$work/cronic-with-errexit" +check "bash script with a failing command, caller's errexit exported" 1 report \ + env BASH_ENV= bash -c 'false; true' +# When the caller exports allexport, `cronic` does not export its own variables +# to the command, but a variable that the command assigns is exported. +CRONIC="$work/cronic-with-allexport" +check "no cronic variables exported, caller's allexport exported" 0 silent \ + sh -c '! env | grep -q "^cronic_"' +# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. +check "command's variable exported, caller's allexport exported" 0 silent \ + eval 'cronic_test_variable=1; sh -c "[ \"\$cronic_test_variable\" = 1 ]"' +# When the caller exports xtrace, `cronic` traces only the command, not itself. +CRONIC="$work/cronic-with-xtrace" +check "bash script, caller's xtrace exported" 0 silent \ + env BASH_ENV= bash -c 'true' +check "bash script that fails, caller's xtrace exported" 1 "report:^+ false$" \ + env BASH_ENV= bash -c 'false' +if grep -q cronic_ "$work/output"; then + echo "FAIL: caller's xtrace exported: the report traces cronic itself:" + cat "$work/output" + status=1 +else + echo "PASS: caller's xtrace exported: the report does not trace cronic itself" +fi +CRONIC="$saved_cronic" # The wrapped command sees the caller's exported variables unchanged. For # each variable that `cronic` assigns, the caller exports that name in