diff --git a/cronic b/cronic index 7666461..418271c 100755 --- a/cronic +++ b/cronic @@ -17,9 +17,27 @@ # 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. -# Exit if a command exits with a non-zero status, and treat unset variables -# as an error. -set -eu +# Save the caller's shell options, as the letters that `$-` uses for them, +# before this script changes them for itself. This script changes only +# allexport, errexit, nounset, verbose, and xtrace, so the command inherits the +# caller's other `set -o` options, and its `shopt` options, without any +# restoring; an option that this script changes must be added to the pattern. +# `$(set +o)` would save every `set -o` option, but bash turns off verbose in a +# command substitution, so `set +o` there would report verbose as off. +# +# Then, exit if a command exits with a non-zero status, and treat unset +# variables as an error. Do not export this script's variables to the command, +# even if the caller's exported SHELLOPTS contains allexport. Do not trace this +# script's own commands, even if the caller's exported SHELLOPTS contains +# verbose or xtrace, so that the report's "TRACE OUTPUT" is the command's. +# The traces of these two commands are discarded, so that this script produces +# no output when the command succeeds. (The lines that verbose prints before +# this point cannot be discarded.) +{ + cronic_caller_flags=${-//[^aeuvx]/} + set -eu +a +vx +} 2> /dev/null +export -n cronic_caller_flags # Prints the usage line. cronic_usage_line() { @@ -160,12 +178,31 @@ if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))); export BASH_XTRACEFD=$cronic_xtrace_fd fi +# Run the command with the caller's `set -o` shell options rather than this +# script's. If the caller exported SHELLOPTS, then the command would otherwise +# inherit this script's options, and a bash script that reads an unset variable +# (as `/usr/bin/texi2dvi` does when /bin/sh is bash) would fail. # 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 subshell 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` line is traced. The `:+` +# avoids a bare `-`, which has a special meaning to `set`. The redirections are +# on the command rather than on the subshell, so that the trace of the `command` +# line does not go to the command's standard error; under bash 4.1 or later, +# it goes to the trace file, and under an older bash, to this script's +# standard error. +# The subshell is not the operand of `||` or the like, because then bash would +# ignore `set -e` in it and would remove "errexit" from the SHELLOPTS that the +# command inherits. +# Limitation: if the command is a builtin that runs code in the subshell, such +# as `source` or `eval`, that code runs with the caller's options even if the +# caller does not export SHELLOPTS. So if the caller does not use `set -u`, +# that code silently expands an unset variable to the empty string. set +e ( + set +u${cronic_caller_flags:+ -$cronic_caller_flags} command -- "$@" > "$cronic_out" 2> "$cronic_trace" ) cronic_result=$? diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 874bf76..30f21bc 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -13,6 +13,19 @@ set -eu +# Do not let the user's environment file run in `cronic` or in the bash commands +# below. It could write to stderr, especially when an exported SHELLOPTS turns +# on `-u` for it. +unset BASH_ENV +# Do not pass this script's own options, such as `-e` and `-u`, to `cronic` and +# to the commands below. If SHELLOPTS is exported, then run this script again +# without it, because bash makes SHELLOPTS read-only, so it cannot be unset. +# `printenv` matches the name exactly, unlike a search of the output of `env`, +# which can match a line within the value of another variable. +if printenv SHELLOPTS > /dev/null; then + exec env -u SHELLOPTS sh "$0" "$@" +fi + SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)" CRONIC="$(CDPATH='' cd -- "${SCRIPT_DIR}/../.." && pwd -P)/cronic" @@ -88,6 +101,24 @@ printf '%s\n' "$@" 1>&2 EOF chmod +x "$work/stderr-lines" +# A bash command that reads an unset variable. +cat > "$work/read-unset" << 'EOF' +#!/bin/bash +unset CRONIC_TEST_UNSET_VARIABLE +echo "value: ${CRONIC_TEST_UNSET_VARIABLE}" > /dev/null +exit "$1" +EOF +chmod +x "$work/read-unset" + +# Runs `cronic` with SHELLOPTS set to its first argument and exported. +cat > "$work/cronic-with-shellopts" << 'EOF2' +#!/bin/bash +shellopts="$1" +shift +exec env SHELLOPTS="$shellopts" "$REAL_CRONIC" "$@" +EOF2 +chmod +x "$work/cronic-with-shellopts" + # temp_files: prints `cronic`'s temporary files, in a canonical order. temp_files() { find "$TMPDIR" -mindepth 1 -maxdepth 1 2> /dev/null | sort @@ -269,6 +300,38 @@ check "builtin command" 0 silent : check "exit builtin" 3 report exit 3 check "exec builtin" 3 report exec "$work/trace-only" 3 +# When SHELLOPTS is exported, `cronic`'s own `-e` and `-u` options do not reach +# the wrapped command, but the caller's options do. +REAL_CRONIC="$CRONIC" +export REAL_CRONIC +CRONIC="$work/cronic-with-shellopts" +default_shellopts=braceexpand:hashall:interactive-comments +check "exported SHELLOPTS, command reads an unset variable" 0 silent \ + "$default_shellopts" "$work/read-unset" 0 +check "exported SHELLOPTS, command ignores a failure" 0 silent \ + "$default_shellopts" bash -c 'false; :' +check "exported SHELLOPTS with nounset, command reads an unset variable" 1 \ + "report:unbound variable" \ + "$default_shellopts:nounset" "$work/read-unset" 0 +check "exported SHELLOPTS with errexit, command ignores a failure" 1 report \ + "$default_shellopts:errexit" bash -c 'false; :' +# shellcheck disable=SC2016 # The expansion is for the command to do. +check "exported SHELLOPTS with allexport, command's environment" 0 silent \ + "$default_shellopts:allexport" bash -c \ + 'case $SHELLOPTS in *allexport*) ;; *) exit 2 ;; esac + ! printenv cronic_caller_flags && ! printenv cronic_out' +check "exported SHELLOPTS with xtrace, trace-only stderr" 0 silent \ + "$default_shellopts:xtrace" "$work/read-unset" 0 +check "exported SHELLOPTS with xtrace, trace in the report" 3 \ + "report:^+ exit 3$" "$default_shellopts:xtrace" bash -c 'exit 3' +# The trace in the report is the command's, not `cronic`'s own. +if grep -q -e '^+ set ' -e '^+ cronic_' "$work/output"; then + echo "FAIL: exported SHELLOPTS with xtrace, cronic's own commands are traced:" + cat "$work/output" + status=1 +fi +CRONIC="$REAL_CRONIC" + # The wrapped command sees the caller's exported variables unchanged. For # each variable that `cronic` assigns, the caller exports that name in # uppercase, both with and without any "cronic_" prefix (for example,