diff --git a/cronic b/cronic index b26d786..2377c5f 100755 --- a/cronic +++ b/cronic @@ -1,4 +1,8 @@ #!/bin/bash +{ + cronic_caller_flags=${-//[^euvx]/} + if compgen -e -X '!SHELLOPTS' SHELLOPTS > /dev/null; then set +vx; fi +} 2> /dev/null # Cronic v2 - cron job report wrapper # Copyright 2007 Chuck Houpt @@ -14,6 +18,56 @@ # --duplicate-stderr options, which may be given in any order. # The options and the treatment of execution traces are documented in function # `cronic_help`; run `cronic --help`. +# COMMAND runs in a subshell, with the caller's shell options rather than this +# script's. Names that start with `cronic_` are reserved for this script: +# COMMAND does not see a variable or function with such a name, even one that +# the caller exported. +# This script assumes that the caller has not exported a function with the name +# of a builtin or program that this script uses, such as `set` or `cat`; such a +# function would run in place of the builtin or program. +# This script assumes that bash has the `compgen` builtin, which is absent only +# if bash was built with `--disable-progcomp`. Without it, an exported +# SHELLOPTS could make this script trace itself to stderr, and the command could +# see a `cronic_` function that the caller exported. +# 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 first command sets cronic_caller_flags to the caller's shell options, as +# the letters that `$-` uses for them, before this script changes them for +# itself. This script changes only 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. `$-` reports the options without the cost of a command +# substitution, such as `$(set +o)`, in which bash would also report errexit as +# off. +# If the caller exported SHELLOPTS, then the first command also stops this +# script from tracing itself or echoing its input: that output would go to +# this script's stderr, and cron would send email for every run. An exported +# `verbose` nonetheless echoes every line that bash reads before it reaches the +# `set +vx`, which is why that command comes first, before any comment. Bash +# cannot tell whether xtrace came from SHELLOPTS or from the command line, so +# `bash -x cronic` traces this script only if SHELLOPTS is not exported. The +# filter makes `compgen` match SHELLOPTS exactly, not every exported variable +# whose name starts with SHELLOPTS. The redirection discards the trace of the +# first command. + +# Unset every function whose name starts with `cronic_`, all of which the caller +# exported, because this script has not yet defined any. Then the only such +# functions are this script's, which the subshell that runs the command unsets +# by name. Testing the status of `compgen` first avoids the cost of a command +# substitution when there is no such function. Each name is read whole, rather +# than split from an unquoted expansion, so that a name that contains a glob +# character is not expanded to file names. The names are read from a +# here-string rather than from a process substitution, which bash before 5.1 +# rejects in posix mode, as an exported SHELLOPTS can set. The redirection +# discards the trace of these commands. +{ + if compgen -A function cronic_ > /dev/null; then + while read -r cronic_name; do + unset -f "$cronic_name" + done <<< "$(compgen -A function cronic_)" + fi +} 2> /dev/null # Exit if a command exits with a non-zero status, and treat unset variables # as an error. @@ -44,6 +98,8 @@ cronic_help() { echo " is less likely to be overlooked" echo " -- end the options, so that COMMAND may start with \"-\"" echo " -h, --help print this message" + echo "Names that start with \"cronic_\" are reserved: COMMAND does not see a" + echo "variable or function with such a name, even one that the caller exported." echo "Execution traces (from \"set -x\") are not treated as error output." echo "Bash 4.1 or later writes them to a separate file, via BASH_XTRACEFD; they" echo "appear in the report under \"TRACE OUTPUT\". Other shells write them to" @@ -152,22 +208,61 @@ trap 'cronic_cleanup; trap - TERM EXIT; kill -s TERM $$' TERM # error output exactly, whatever PS4 is. The descriptor is allocated by bash # (as 10 or higher), so that it does not collide with a descriptor, such as 3, # that the wrapped command uses for its own purposes. Bash before 4.1 supports -# neither BASH_XTRACEFD nor descriptor allocation. +# neither BASH_XTRACEFD nor descriptor allocation. Only the subshell that runs +# the command sets BASH_XTRACEFD, so that this script's own trace, under +# `bash -x cronic`, goes to this script's stderr rather than into the report. +cronic_xtrace_fd= if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))); then exec {cronic_xtrace_fd}> "$cronic_xtrace" - 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 via `exec`, so that it can be a +# shell builtin or an exported shell function; and in a subshell rather than in +# this shell, so that a builtin such as `exit` or `exec` cannot end this script +# before it reports. The subshell is not the left operand of `||`, because +# bash would then ignore `set -e` throughout the subshell, including in a +# shell function that it runs. The redirections are on the subshell, and the +# subshell sets BASH_XTRACEFD before it restores the caller's xtrace, so that +# when `-x` is set, the trace of running the command goes into the command's +# trace output rather than to this script's stderr. +# The subshell unsets every variable whose name starts with `cronic_` and every +# function that this script defined, so that a builtin or shell function that +# is the command does not see this script's variables and functions, and so +# that a function does not take precedence over a program of the same name. +# This script's functions are listed, rather than found by +# `compgen -A function cronic_`, to avoid the cost of a command substitution. +# The list must name every function that this script defines before this +# point; the test suite fails if the command can see one that it omits. +# Then the subshell restores the caller's options. The subshell first turns +# off this script's xtrace and verbose, so that the restoring is not traced or +# echoed; any error in the restoring goes to the command's error output. The +# subshell then needs to turn off only this script's nounset (errexit is off +# already), in the same command that turns on the caller's options, so that +# restoring verbose and xtrace neither echoes nor traces the restoring of the +# others. The `:+` avoids a bare `set -`, which would turn off verbose and +# xtrace. set +e -# `command` because the wrapped command runs in this shell, where a function of -# this script, such as `cronic_usage`, would otherwise run in place of a -# program of the same name. -command -- "$@" > "$cronic_out" 2> "$cronic_trace" +( + { set +vx; } 2> /dev/null + if [ -n "$cronic_xtrace_fd" ]; then + export BASH_XTRACEFD=$cronic_xtrace_fd + fi + unset -f cronic_usage_line cronic_usage cronic_help \ + cronic_set_expected cronic_cleanup + eval "unset -v \"\${!cronic_@}\" + set +u${cronic_caller_flags:+ -$cronic_caller_flags}" + "$@" +) > "$cronic_out" 2> "$cronic_trace" cronic_result=$? set -e -# Unsetting BASH_XTRACEFD also closes its file descriptor. -unset BASH_XTRACEFD +if [ -n "$cronic_xtrace_fd" ]; then + exec {cronic_xtrace_fd}>&- +fi # Prints a basic regular expression that matches its argument literally. # The argument must not contain a backslash. diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 7b62509..415ace9 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -6,12 +6,25 @@ # # `cronic` filters trace lines out of stderr with `grep -v`, which exits with # status 1 when it selects no lines. `cronic` runs under `set -e`, so an -# unguarded `grep -v` aborted the script at that point: no report was printed, -# the exit status was grep's 1 rather than the wrapped command's, and all of -# the temporary files under /tmp were left behind. +# unguarded `grep -v` would abort the script at that point: no report would be +# printed, the exit status would be grep's 1 rather than the wrapped command's, +# and the temporary files would be left behind. 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" @@ -87,34 +100,119 @@ 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" + +# A bash command in which a command fails before the script exits. +cat > "$work/ignore-failure" << 'EOF' +#!/bin/bash +false +exit "$1" +EOF +chmod +x "$work/ignore-failure" + +# Runs `cronic` with SHELLOPTS set to its first argument and exported, and +# with these exported shell functions: +# * `fail_then_continue` runs `false` and then prints a line. +# * `fail_in_pipeline` runs a pipeline whose first command fails. +# * `report_cronic_variables` writes to stderr any variable or function of +# `cronic`'s that it can see. +cat > "$work/cronic-with-shellopts" << 'EOF2' +#!/bin/bash +fail_then_continue() { + false + echo "after the failure" +} +fail_in_pipeline() { + false | true +} +report_cronic_variables() { + compgen -v -A function cronic_ >&2 + return 0 +} +export -f fail_then_continue fail_in_pipeline report_cronic_variables +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 } -# check DESCRIPTION EXPECTED-STATUS EXPECTED-OUTPUT COMMAND...: runs `cronic` -# on COMMAND and checks its exit status, whether it printed a report, and that -# it left no temporary files behind. EXPECTED-OUTPUT is "silent", "report", -# "report:TEXT", which also requires TEXT to appear in the report, or -# "message:TEXT", which requires TEXT to appear in output that need not be a -# report. -check() { +# run_cronic DESCRIPTION EXPECTED-STATUS COMMAND...: runs COMMAND, which runs +# `cronic`, with its standard output in "$work/output" and its error output in +# "$work/stderr". If COMMAND exits with a status other than EXPECTED-STATUS, +# or leaves temporary files behind, then reports a failure and returns 1. +run_cronic() { description="$1" expected_status="$2" - expected_output="$3" - shift 3 + shift 2 before="$(temp_files)" actual_status=0 - "$CRONIC" "$@" > "$work/output" 2>&1 || actual_status=$? + "$@" > "$work/output" 2> "$work/stderr" || actual_status=$? after="$(temp_files)" if [ "$actual_status" != "$expected_status" ]; then echo "FAIL: $description: exit status $actual_status, expected $expected_status" + cat "$work/output" "$work/stderr" + status=1 + return 1 + fi + if [ "$before" != "$after" ]; then + echo "FAIL: $description: temporary files were left behind:" + echo "$after" + status=1 + return 1 + fi +} + +# check_trace_section DESCRIPTION GOAL-FILE COMMAND...: runs COMMAND, which +# runs `cronic`, and checks that it exits with status 0, leaves no temporary +# files, and produces a report whose "TRACE OUTPUT" section, through the +# following empty line, is the contents of GOAL-FILE. COMMAND's stderr is left +# in "$work/stderr". +check_trace_section() { + description="$1" + goal="$2" + shift 2 + + run_cronic "$description" 0 "$@" || return 0 + sed -n '/^TRACE OUTPUT:$/,/^$/p' "$work/output" > "$work/trace-section" + if cmp -s "$goal" "$work/trace-section"; then + echo "PASS: $description" + else + echo "FAIL: $description: got:" cat "$work/output" status=1 - return fi +} + +# check DESCRIPTION EXPECTED-STATUS EXPECTED-OUTPUT COMMAND...: runs `cronic` +# on COMMAND and checks its exit status, whether it printed a report, and that +# it left no temporary files behind. EXPECTED-OUTPUT is "silent", "report", +# "report:TEXT", which also requires TEXT to appear in the report, or +# "message:TEXT", which requires TEXT to appear in output that need not be a +# report. +check() { + description="$1" + expected_status="$2" + expected_output="$3" + shift 3 + + run_cronic "$description" "$expected_status" "$CRONIC" "$@" || return 0 + # The checks below examine both outputs, because `cronic` writes its usage + # messages to stderr. + cat "$work/stderr" >> "$work/output" case "$expected_output" in silent) @@ -146,13 +244,6 @@ check() { ;; esac - if [ "$before" != "$after" ]; then - echo "FAIL: $description: temporary files were left behind:" - echo "$after" - status=1 - return - fi - echo "PASS: $description" } @@ -262,13 +353,101 @@ for name in $function_names; do done PATH="$saved_path" +# The command may be a shell builtin. +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 + +# 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" "$work/ignore-failure" 0 +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" "$work/ignore-failure" 0 +# With xtrace exported, a successful command with only trace output on stderr +# produces no output at all: `cronic` does not trace itself. +check "exported SHELLOPTS with xtrace, trace-only stderr" 0 silent \ + "$default_shellopts:xtrace" "$work/trace-only" 0 +# With xtrace exported, the report's trace section is the trace of running the +# command, followed by the command's own trace, with nothing that `cronic` did +# to set up the command's options. Bash writes the trace via BASH_XTRACEFD, so +# it appears under "TRACE OUTPUT". +cat > "$work/trace-section.goal" << EOF +TRACE OUTPUT: ++ $work/trace-and-stderr 0 ++ set -x ++ echo 'a real error' ++ exit 0 + +EOF +check_trace_section "exported SHELLOPTS with xtrace, trace output" \ + "$work/trace-section.goal" \ + "$CRONIC" "$default_shellopts:xtrace" "$work/trace-and-stderr" 0 +if [ -s "$work/stderr" ]; then + echo "FAIL: exported SHELLOPTS with xtrace: cronic wrote to stderr:" + cat "$work/stderr" + status=1 +fi + +# With verbose exported, a successful command produces no report: `cronic` +# does not echo, into the command's error output, the commands that restore the +# caller's options. `cronic` echoes to its stderr only its lines through its +# first command, which bash reads before `cronic` turns verbose off. +sed '/^} 2> \/dev\/null$/q' "$REAL_CRONIC" > "$work/verbose-stderr.goal" +if run_cronic "exported SHELLOPTS with verbose" 0 \ + "$CRONIC" "$default_shellopts:verbose" true; then + if [ ! -s "$work/output" ] && cmp -s "$work/verbose-stderr.goal" "$work/stderr"; then + echo "PASS: exported SHELLOPTS with verbose" + else + echo "FAIL: exported SHELLOPTS with verbose: output:" + cat "$work/output" + echo "stderr:" + cat "$work/stderr" + status=1 + fi +fi + +# The caller's `-e` applies within an exported shell function that is the +# command, and the caller's lack of `-e` does too. +check "exported SHELLOPTS with errexit, exported function ignores a failure" 1 \ + report "$default_shellopts:errexit" fail_then_continue +check "exported SHELLOPTS, exported function ignores a failure" 0 silent \ + "$default_shellopts" fail_then_continue +# Every one of the caller's options applies, not only `-e`, `-u`, and `-x`. +check "exported SHELLOPTS with pipefail, exported function" 1 report \ + "$default_shellopts:pipefail" fail_in_pipeline +check "exported SHELLOPTS without pipefail, exported function" 0 silent \ + "$default_shellopts" fail_in_pipeline +# A shell function that is the command does not see `cronic`'s variables or +# function. +check "exported function sees none of cronic's variables or function" 0 silent \ + "$default_shellopts" report_cronic_variables +# ... nor a variable that is not exported, which BASH_ENV set within `cronic`. +echo "cronic_from_bash_env=unexported" > "$work/bash-env" +BASH_ENV="$work/bash-env" +export BASH_ENV +check "exported function does not see unexported variable from BASH_ENV" 0 \ + silent "$default_shellopts" report_cronic_variables +unset BASH_ENV +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, # `CRONIC_OUT` and `OUT`). `TMPDIR` is omitted, because `cronic` reads it. # The lowercase names that `cronic` assigns, such as `cronic_out`, are not -# exported: `cronic` does overwrite them, but environment variables -# conventionally have uppercase names. +# exported here, because environment variables conventionally have uppercase +# names; the next test checks a few of them. variable_names=$(sed -n 's/^ *\([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p' "$CRONIC" | sort -u) if [ -z "$variable_names" ]; then echo "FAIL: found no variables in $CRONIC" @@ -299,6 +478,53 @@ check "caller's exported variables" 0 silent "$work/check-variables" $exported_n # shellcheck disable=SC2086 # each name is a separate argument. unset $exported_names +# Names that start with `cronic_` are reserved: the command does not see a +# variable or function with such a name that the caller exported. +cat > "$work/cronic-with-cronic-names" << 'EOF' +#!/bin/bash +cronic_mine() { + exit 3 +} +cronic_debug="caller's cronic_debug" +export -f cronic_mine +export cronic_debug +exec "$REAL_CRONIC" "$@" +EOF +chmod +x "$work/cronic-with-cronic-names" +CRONIC="$work/cronic-with-cronic-names" +# shellcheck disable=SC2016 # The expansion is for `sh` to do. +check "caller's exported cronic_ variable is not seen" 0 silent \ + sh -c '[ -z "${cronic_debug+set}" ]' +check "caller's exported cronic_ function is not seen" 0 silent \ + bash -c '! declare -F cronic_mine' +CRONIC="$REAL_CRONIC" + +# `bash -x cronic`, which does not export SHELLOPTS, traces `cronic` itself. +if run_cronic "bash -x traces cronic" 0 \ + bash -x "$CRONIC" "$work/trace-only" 0; then + if grep -q "cronic_debug=false" "$work/stderr"; then + echo "PASS: bash -x traces cronic" + else + echo "FAIL: bash -x traces cronic: stderr:" + cat "$work/stderr" + status=1 + fi +fi + +# Under `bash -x cronic`, the report's trace section contains nothing that +# `cronic` did to set up the command. The command does not inherit xtrace, so +# its own `set -x` is not traced. +cat > "$work/bash-x-trace-section.goal" << EOF +TRACE OUTPUT: ++ $work/trace-and-stderr 0 ++ echo 'a real error' ++ exit 0 + +EOF +check_trace_section "bash -x cronic, trace output" \ + "$work/bash-x-trace-section.goal" \ + bash -x "$CRONIC" "$work/trace-and-stderr" 0 + # Bash writes its trace lines to a separate file, so they are not error # output, whatever PS4 is. export PS4