From 0347a303f610f22e72f5c91bcbebc95593c63086 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 10:03:59 -0700 Subject: [PATCH 01/23] Run the command with the caller's shell options, in a subshell When the caller exports SHELLOPTS, the command inherited cronic's own -e and -u, so a bash script that reads an unset variable (such as texi2dvi) failed. Running the command in a subshell also keeps the exit and exec builtins from ending cronic before it reports. --- cronic | 30 ++++++++++++-- tests/cronic-test/cronic-test.sh | 68 ++++++++++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/cronic b/cronic index b26d786..ee6332b 100755 --- a/cronic +++ b/cronic @@ -14,6 +14,17 @@ # --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. +# 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. + +# Save 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. +cronic_caller_flags=${-//[^euvx]/} # Exit if a command exits with a non-zero status, and treat unset variables # as an error. @@ -158,11 +169,22 @@ 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. The subshell turns off +# this script's nounset (errexit is off already) in the same command that turns +# on the caller's options. 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 +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 7b62509..aba932c 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,6 +100,32 @@ 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. +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 @@ -262,6 +301,29 @@ 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 +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, From 4882e52dc103e08862af75d1fc7d9191c0999f57 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 10:03:59 -0700 Subject: [PATCH 02/23] Do not let an exported xtrace or verbose make cronic write to stderr When the caller exports SHELLOPTS with xtrace or verbose, cronic traced or echoed itself to stderr, so cron sent email on every run. Also keep cronic's setup of the command out of the command's trace output. --- cronic | 64 ++++++++++---- tests/cronic-test/cronic-test.sh | 140 +++++++++++++++++++++++++++---- 2 files changed, 172 insertions(+), 32 deletions(-) diff --git a/cronic b/cronic index ee6332b..e1a2826 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 @@ -16,15 +20,33 @@ # `cronic_help`; run `cronic --help`. # COMMAND runs in a subshell, with the caller's shell options rather than this # script's. +# 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. # 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. -# Save 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. -cronic_caller_flags=${-//[^euvx]/} +# 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. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. @@ -163,10 +185,12 @@ 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 @@ -176,20 +200,32 @@ 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. The subshell turns off -# this script's nounset (errexit is off already) in the same command that turns -# on the caller's options. The `:+` avoids a bare `set -`, which would turn off -# verbose and xtrace. +# otherwise run in place of a program of the same name. 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 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 ( + { set +vx; } 2> /dev/null + if [ -n "$cronic_xtrace_fd" ]; then + export BASH_XTRACEFD=$cronic_xtrace_fd + fi set +u${cronic_caller_flags:+ -$cronic_caller_flags} command -- "$@" ) > "$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 aba932c..255ab1f 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -131,29 +131,71 @@ 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) @@ -185,13 +227,6 @@ check() { ;; esac - if [ "$before" != "$after" ]; then - echo "FAIL: $description: temporary files were left behind:" - echo "$after" - status=1 - return - fi - echo "PASS: $description" } @@ -322,6 +357,49 @@ check "exported SHELLOPTS with nounset, command reads an unset variable" 1 \ "$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: ++ command -- $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 + CRONIC="$REAL_CRONIC" # The wrapped command sees the caller's exported variables unchanged. For @@ -361,6 +439,32 @@ check "caller's exported variables" 0 silent "$work/check-variables" $exported_n # shellcheck disable=SC2086 # each name is a separate argument. unset $exported_names +# `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: ++ command -- $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 From b170cb7e135d4d2825900f0e21593a43952d43f0 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 10:14:29 -0700 Subject: [PATCH 03/23] Simplify: inline a test command, drop a redundant comment --- cronic | 2 -- tests/cronic-test/cronic-test.sh | 12 ++---------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/cronic b/cronic index ee6332b..dae3ffa 100755 --- a/cronic +++ b/cronic @@ -14,8 +14,6 @@ # --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. # 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. diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index aba932c..e4763c1 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -109,14 +109,6 @@ 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. cat > "$work/cronic-with-shellopts" << 'EOF2' #!/bin/bash @@ -316,12 +308,12 @@ 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 + "$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" "$work/ignore-failure" 0 + "$default_shellopts:errexit" bash -c 'false; :' CRONIC="$REAL_CRONIC" # The wrapped command sees the caller's exported variables unchanged. For From d488e05b8b4c962d0701df5f089d4f2de45c4724 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 10:57:21 -0700 Subject: [PATCH 04/23] Keep the redirections out of the subshell's traces; also save and restore allexport --- cronic | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/cronic b/cronic index dae3ffa..e7f1388 100755 --- a/cronic +++ b/cronic @@ -18,15 +18,17 @@ # feature of a later bash only after checking the version. # Save 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 +# before this script changes them for itself. This script changes only +# allexport, errexit, and nounset, 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. -cronic_caller_flags=${-//[^euvx]/} +cronic_caller_flags=${-//[^aeu]/} # Exit if a command exits with a non-zero status, and treat unset variables -# as an error. -set -eu +# as an error. Do not export this script's variables to the command, even if +# the caller's exported SHELLOPTS contains allexport. +set -eu +a +export -n cronic_caller_flags # Prints the usage line. cronic_usage_line() { @@ -176,13 +178,15 @@ fi # `command` because a function of this script, such as `cronic_usage`, would # 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. The `:+` avoids a bare `set -`, which would turn off -# verbose and xtrace. +# on the caller's options. The `:+` avoids a trailing `-`, which would turn off +# the caller's verbose and xtrace. The redirections are on the command rather +# than on the subshell, so that the execution traces of the subshell's own +# commands go to this script's standard error rather than to the command's. set +e ( set +u${cronic_caller_flags:+ -$cronic_caller_flags} - command -- "$@" -) > "$cronic_out" 2> "$cronic_trace" + command -- "$@" > "$cronic_out" 2> "$cronic_trace" +) cronic_result=$? set -e From ad31cecf42599f584d50174af7f5ac17242e8722 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 12:31:37 -0700 Subject: [PATCH 05/23] Explain why the subshell is not an operand of ||, and the limitation for source and eval --- cronic | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cronic b/cronic index c3c3ea1..80d7b0d 100755 --- a/cronic +++ b/cronic @@ -182,6 +182,13 @@ fi # the caller's verbose and xtrace. The redirections are on the command rather # than on the subshell, so that the execution traces of the subshell's own # commands go to this script's standard error rather than to the command's. +# 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} From f9bc9c25b41e91f85b6c1368d9fcff9dd9f003b4 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 18:55:11 -0700 Subject: [PATCH 06/23] Restore all of the caller's set -o options; document limitations; fix tests Save the caller's SHELLOPTS rather than a hand-maintained list of option letters, so that every set -o option is restored for the command. Document that verbose and BASH_ENV can still make cronic write to stderr. In the tests, unset PS4, skip trace-section checks when bash lacks BASH_XTRACEFD, and share the PASS/FAIL reporting. --- cronic | 32 +++++++++++------ tests/cronic-test/cronic-test.sh | 62 +++++++++++++++++++------------- 2 files changed, 59 insertions(+), 35 deletions(-) diff --git a/cronic b/cronic index 504dc46..3991f99 100755 --- a/cronic +++ b/cronic @@ -1,6 +1,6 @@ #!/bin/bash { - cronic_caller_flags=${-//[^euvx]/} + cronic_caller_opts=$SHELLOPTS set +vx } 2> /dev/null @@ -21,15 +21,21 @@ # 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 saves 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. +# The first command saves the caller's `set -o` shell options, as the +# colon-separated list of those that are on, before this script changes any of +# them for itself. This script changes no `shopt` option, so the command +# inherits those without any restoring. # The first command then stops this script from tracing itself or echoing its # input, which would go to this script's stderr, so that cron would send email # for every run. It comes before any comment, because bash echoes every line # that it reads while verbose is on. The redirection discards its own trace. +# Verbose is handled only partly: bash echoes this script's lines through the +# end of the first command to stderr before that command can turn verbose off. +# So if the caller exports SHELLOPTS with verbose, or runs `bash -v cronic`, +# then cron sends email for every run. +# Likewise, bash runs the file named by BASH_ENV, if any, before this script's +# first command, so an exported xtrace, verbose, or nounset applies to that file +# and can make it write to stderr; such a cron job should unset BASH_ENV. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. @@ -66,6 +72,10 @@ cronic_help() { echo "stderr, from which lines that start with PS4 are removed. That removal" echo "is skipped if PS4 contains \"\$\", a backquote, a backslash, or a newline," echo "because such a PS4 can expand differently on each line." + echo "Under \"set -v\" exported via SHELLOPTS, or \"bash -v cronic\", bash echoes the" + echo "first lines of cronic to stderr before cronic can turn verbose off." + echo "Likewise, bash runs the BASH_ENV file, if any, before cronic starts, so" + echo "exported shell options can make that file write to stderr." exit 0 } @@ -182,12 +192,14 @@ fi # 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. 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 the restoring is neither traced nor echoed. -# The `:+` avoids a bare `set -`, which would turn off verbose and xtrace. +# every option that is on, which includes nounset, and turns on the caller's, in +# a single `set` command, so that the restoring is neither traced nor echoed. +# SHELLOPTS is not empty here, because nounset is on. The `:+` avoids a bare +# `set -o`, which would print the options, when the caller had none on. set +e ( - set +u${cronic_caller_flags:+ -$cronic_caller_flags} + # shellcheck disable=SC2086 # each option name and +o or -o is an argument. + set +o ${SHELLOPTS//:/ +o } ${cronic_caller_opts:+-o ${cronic_caller_opts//:/ -o }} 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 eb562a7..60b88b3 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -16,6 +16,8 @@ set -eu # below. It could write to stderr, especially when an exported SHELLOPTS turns # on `-u` for it. unset BASH_ENV +# Use the default PS4, which the goal files and the trace lines below assume. +unset PS4 # 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. @@ -151,21 +153,15 @@ run_cronic() { 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, writes nothing to stderr, and produces a report whose "TRACE OUTPUT" -# section, through the following empty line, is the contents of GOAL-FILE. -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 [ ! -s "$work/stderr" ] && cmp -s "$goal" "$work/trace-section"; then - echo "PASS: $description" +# compare_streams DESCRIPTION OUTPUT OUTPUT-GOAL STDERR-GOAL: after run_cronic, +# reports success if the file OUTPUT, which is `cronic`'s output or a part of +# it, has the contents of OUTPUT-GOAL, and `cronic`'s stderr has the contents of +# STDERR-GOAL. +compare_streams() { + if cmp -s "$3" "$2" && cmp -s "$4" "$work/stderr"; then + echo "PASS: $1" else - echo "FAIL: $description: output:" + echo "FAIL: $1: output:" cat "$work/output" echo "stderr:" cat "$work/stderr" @@ -173,6 +169,29 @@ check_trace_section() { fi } +# check_trace_section DESCRIPTION BASH GOAL-FILE COMMAND...: runs COMMAND, +# which runs `cronic` under the bash program BASH, and checks that it exits +# with status 0, leaves no temporary files, writes nothing to stderr, and +# produces a report whose "TRACE OUTPUT" section, through the following empty +# line, is the contents of GOAL-FILE. Skips the check if BASH is older than +# 4.1, which lacks BASH_XTRACEFD, so that the report has no "TRACE OUTPUT". +check_trace_section() { + description="$1" + bash_program="$2" + goal="$3" + shift 3 + + # shellcheck disable=SC2016 # the expansions are for bash, not this shell. + if ! "$bash_program" -c '((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1)))'; then + echo "SKIP: $description, because $bash_program is older than 4.1" + return 0 + fi + + run_cronic "$description" 0 "$@" || return 0 + sed -n '/^TRACE OUTPUT:$/,/^$/p' "$work/output" > "$work/trace-section" + compare_streams "$description" "$work/trace-section" "$goal" /dev/null +} + # 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", @@ -366,7 +385,7 @@ TRACE OUTPUT: + exit 0 EOF -check_trace_section "exported SHELLOPTS with xtrace, trace output" \ +check_trace_section "exported SHELLOPTS with xtrace, trace output" /bin/bash \ "$work/trace-section.goal" \ "$CRONIC" "$default_shellopts:xtrace" "$work/trace-and-stderr" 0 @@ -377,15 +396,8 @@ check_trace_section "exported SHELLOPTS with xtrace, trace output" \ 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 + compare_streams "exported SHELLOPTS with verbose" \ + "$work/output" /dev/null "$work/verbose-stderr.goal" fi CRONIC="$REAL_CRONIC" @@ -437,7 +449,7 @@ TRACE OUTPUT: + exit 0 EOF -check_trace_section "bash -x cronic, trace output" \ +check_trace_section "bash -x cronic, trace output" bash \ "$work/bash-x-trace-section.goal" \ bash -x "$CRONIC" "$work/trace-and-stderr" 0 From 20282ca0a9606cf466386fda761be22dc180d12a Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 10:31:27 -0700 Subject: [PATCH 07/23] cronic: do not trace cronic's own `command -- ...` line when the caller's xtrace is on --- cronic | 20 +++++++++++++++----- tests/cronic-test/cronic-test.sh | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/cronic b/cronic index 1bb3849..3a4d5ac 100755 --- a/cronic +++ b/cronic @@ -184,14 +184,24 @@ fi # 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`. +# caller exported SHELLOPTS. The `:+` avoids a bare `-`, which has a special +# meaning to `set`. With the caller's xtrace on, bash traces the `set` command +# and the `command -- ...` line, which are this script's, not the command's. +# So the subshell points the trace descriptor at /dev/null, and the redirection +# that points it back at the trace file is on the `command` line itself, which +# bash traces before performing the redirections. `eval` because a +# redirection's target descriptor must be written literally. The trace of a +# builtin COMMAND, such as `exit`, is the `command -- ...` line, so it is +# discarded too. set +e ( + cronic_redirections= + if [ -n "${cronic_xtrace_fd-}" ]; then + eval "exec {cronic_saved_fd}>&$cronic_xtrace_fd $cronic_xtrace_fd> /dev/null" + cronic_redirections="$cronic_xtrace_fd>&$cronic_saved_fd $cronic_saved_fd>&-" + fi set +u${cronic_caller_flags:+ -$cronic_caller_flags} - command -- "$@" + eval "command -- \"\$@\" $cronic_redirections" ) > "$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 52f89ee..1279498 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -400,7 +400,7 @@ 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 +if grep -q -e cronic_ -e "command --" "$work/output"; then echo "FAIL: caller's xtrace exported: the report traces cronic itself:" cat "$work/output" status=1 From bd37b2cc75cc3f6e7ae31129062b5ee321dc1f96 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 10:31:36 -0700 Subject: [PATCH 08/23] Suppress a shellcheck false positive --- cronic | 1 + 1 file changed, 1 insertion(+) diff --git a/cronic b/cronic index 3a4d5ac..15eb4a4 100755 --- a/cronic +++ b/cronic @@ -198,6 +198,7 @@ set +e cronic_redirections= if [ -n "${cronic_xtrace_fd-}" ]; then eval "exec {cronic_saved_fd}>&$cronic_xtrace_fd $cronic_xtrace_fd> /dev/null" + # shellcheck disable=SC2154 # The `eval` above assigns cronic_saved_fd. cronic_redirections="$cronic_xtrace_fd>&$cronic_saved_fd $cronic_saved_fd>&-" fi set +u${cronic_caller_flags:+ -$cronic_caller_flags} From 41e6f95290918cbef0d4a49ae06412e1d79f3c21 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:01:23 -0700 Subject: [PATCH 09/23] cronic: fix the trace suppression under bash 3.2, and avoid an extra trace level Swap the trace descriptor only when the caller's xtrace is on and bash is 4.1 or later; run the command directly otherwise. Put the restoring redirection in a function rather than an eval, so that a builtin's trace is not nested an extra level. Test a builtin under xtrace, and check every xtrace case for cronic's own trace. Co-Authored-By: Claude Opus 5.5 --- cronic | 34 +++++++++------- tests/cronic-test/cronic-test.sh | 67 +++++++++++++++++++------------- 2 files changed, 61 insertions(+), 40 deletions(-) diff --git a/cronic b/cronic index 15eb4a4..a904644 100755 --- a/cronic +++ b/cronic @@ -185,24 +185,32 @@ fi # 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. The `:+` avoids a bare `-`, which has a special -# meaning to `set`. With the caller's xtrace on, bash traces the `set` command -# and the `command -- ...` line, which are this script's, not the command's. -# So the subshell points the trace descriptor at /dev/null, and the redirection -# that points it back at the trace file is on the `command` line itself, which -# bash traces before performing the redirections. `eval` because a -# redirection's target descriptor must be written literally. The trace of a -# builtin COMMAND, such as `exit`, is the `command -- ...` line, so it is -# discarded too. +# meaning to `set`. With the caller's xtrace on, bash traces the +# `command -- ...` line, which is this script's, not the command's. So under +# bash 4.1 or later, the subshell points the trace descriptor at /dev/null, and +# the redirection that points it back at the trace file is on the `command` line +# itself, which bash traces before performing the redirections. That line is in +# a function, rather than in an `eval`, because an `eval` would add a level of +# nesting to every trace line of a builtin COMMAND. The function is defined by +# `eval` because the descriptor that a redirection changes, on the left of the +# operator, must be written literally; only the right side may be a variable. +# The trace of a builtin COMMAND, such as `exit`, is the `command -- ...` line, +# so it is discarded too. A bash before 4.1 writes the trace to stderr, which +# cannot be pointed at /dev/null without discarding the command's error output. set +e ( - cronic_redirections= - if [ -n "${cronic_xtrace_fd-}" ]; then + if [ -n "${cronic_xtrace_fd-}" ] && [[ $cronic_caller_flags == *x* ]]; then eval "exec {cronic_saved_fd}>&$cronic_xtrace_fd $cronic_xtrace_fd> /dev/null" # shellcheck disable=SC2154 # The `eval` above assigns cronic_saved_fd. - cronic_redirections="$cronic_xtrace_fd>&$cronic_saved_fd $cronic_saved_fd>&-" + eval "cronic_command() { + command -- \"\$@\" $cronic_xtrace_fd>&$cronic_saved_fd $cronic_saved_fd>&- + }" + set +u${cronic_caller_flags:+ -$cronic_caller_flags} + cronic_command "$@" + else + set +u${cronic_caller_flags:+ -$cronic_caller_flags} + command -- "$@" fi - set +u${cronic_caller_flags:+ -$cronic_caller_flags} - eval "command -- \"\$@\" $cronic_redirections" ) > "$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 1279498..8c1dcf7 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -336,6 +336,10 @@ check "exec builtin" 3 report exec "$work/trace-only" 3 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 +# The trace of an EXIT trap that a command sets, after turning on xtrace +# itself, is reported. +check "EXIT trap of a traced command" 3 "report:^++* false$" \ + eval 'set -x; trap false EXIT; exit 3' # 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 \ @@ -394,19 +398,45 @@ check "no cronic variables exported, caller's allexport exported" 0 silent \ # 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 ]"' +# A bash that supports BASH_XTRACEFD and the allocation of a file descriptor +# (bash 4.1 or later) writes its trace lines to a separate file, so they are +# not error output, whatever PS4 is. An earlier bash, such as macOS's +# /bin/bash, writes them to stderr, where `cronic` cannot recognize them when +# PS4 contains an expansion or an escape, so it reports them as error output. +# This tests for the features themselves, rather than for the bash version +# that `cronic` tests for, so that it detects a wrong version test in `cronic`. +# `cronic` and the bash scripts above all run under the first `bash` in PATH, +# so that is the bash tested here. The probe checks whether a trace line +# reaches the file, rather than whether bash writes to stderr, because bash can +# write an unrelated warning to stderr, such as for an uninstalled locale. +if bash -c 'exec {fd}> "$1" && BASH_XTRACEFD=$fd && set -x && :' \ + bash "$work/xtracefd-probe" 2> /dev/null && [ -s "$work/xtracefd-probe" ]; then + bash_has_xtracefd=true +else + bash_has_xtracefd=false +fi # When the caller exports xtrace, `cronic` traces only the command, not itself. +# A bash before 4.1 writes the trace to stderr, so `cronic` cannot discard the +# trace of its `command -- ...` line. CRONIC="$work/cronic-with-xtrace" -check "bash script, caller's xtrace exported" 0 silent \ +# check_xtrace DESCRIPTION EXPECTED-STATUS EXPECTED-OUTPUT COMMAND...: like +# `check`, and also checks that the output does not trace `cronic` itself. +check_xtrace() { + check "$@" + if grep -q -e cronic_ "$work/output" \ + || { [ "$bash_has_xtracefd" = true ] && grep -q -e "command --" "$work/output"; }; then + echo "FAIL: $1: the report traces cronic itself:" + cat "$work/output" + status=1 + fi +} +check_xtrace "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 -e cronic_ -e "command --" "$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 +check_xtrace "bash script that fails, caller's xtrace exported" 1 \ + "report:^+ false$" env BASH_ENV= bash -c 'false' +# A builtin's trace lines are nested 1 deeper than the builtin. +check_xtrace "builtin that fails, caller's xtrace exported" 1 \ + "report:^++ false$" eval false CRONIC="$saved_cronic" # The wrapped command sees the caller's exported variables unchanged. For @@ -446,23 +476,6 @@ check "caller's exported variables" 0 silent "$work/check-variables" $exported_n # shellcheck disable=SC2086 # each name is a separate argument. unset $exported_names -# A bash that supports BASH_XTRACEFD and the allocation of a file descriptor -# (bash 4.1 or later) writes its trace lines to a separate file, so they are -# not error output, whatever PS4 is. An earlier bash, such as macOS's -# /bin/bash, writes them to stderr, where `cronic` cannot recognize them when -# PS4 contains an expansion or an escape, so it reports them as error output. -# This tests for the features themselves, rather than for the bash version -# that `cronic` tests for, so that it detects a wrong version test in `cronic`. -# `cronic` and the bash scripts above all run under the first `bash` in PATH, -# so that is the bash tested here. The probe checks whether a trace line -# reaches the file, rather than whether bash writes to stderr, because bash can -# write an unrelated warning to stderr, such as for an uninstalled locale. -if bash -c 'exec {fd}> "$1" && BASH_XTRACEFD=$fd && set -x && :' \ - bash "$work/xtracefd-probe" 2> /dev/null && [ -s "$work/xtracefd-probe" ]; then - bash_has_xtracefd=true -else - bash_has_xtracefd=false -fi export PS4 # shellcheck disable=SC2016 # The expansion is for the traced command to do. PS4='+${LINENO}: ' From 004c6eeec0f7c69f5269383e9391374c76b2f151 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:02:14 -0700 Subject: [PATCH 10/23] cronic: turn the caller's verbose off for cronic and restore it for the command; tests: generate every SHELLOPTS wrapper with one function, which unsets BASH_ENV --- cronic | 41 +++++++++++++++------------- tests/cronic-test/cronic-test.sh | 47 ++++++++++++++++++-------------- 2 files changed, 48 insertions(+), 40 deletions(-) diff --git a/cronic b/cronic index 1bb3849..ab42be2 100755 --- a/cronic +++ b/cronic @@ -1,4 +1,21 @@ #!/usr/bin/env bash +{ + cronic_caller_flags=${-//[^aeuvx]/} + set +avx + export -n cronic_caller_flags +} 2> /dev/null +# The block above is first, because with the caller's verbose on, bash echoes +# each line of this script to stderr as it reads it, until `set +v` runs. +# `cronic_caller_flags` holds 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 verbose and xtrace off, so that its own +# commands are not echoed or traced to stderr, which would make every run +# produce output; the redirection discards the trace of `set +avx` itself. +# Even so, with verbose on, bash echoes the lines through the block, so every +# run produces output. This script 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 v2 - cron job report wrapper # Copyright 2007 Chuck Houpt @@ -19,20 +36,6 @@ # BASH_XTRACEFD (bash 4.1), only after checking the version; see the # BASH_VERSINFO check below. -# 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 @@ -182,11 +185,11 @@ fi # 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 +# sets. The subshell restores the caller's `-a`, `-e`, `-u`, `-v`, 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 ( diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 52f89ee..0b74ff6 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -344,33 +344,27 @@ check "builtin with an unset variable" 0 silent \ # 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. +# after `set OPTION` if OPTION is given. `cronic` reads no startup file, so +# that the user's BASH_ENV file does not run with the option on. The +# wrapper's own trace goes to /dev/null, so that with `set -x`, any trace in +# the output comes from `cronic`. 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 + cat > "$work/cronic-with-$1" << EOF #!/bin/bash unset BASH_ENV exec 3>&2 2> /dev/null -set -x +${2:+set $2} export SHELLOPTS exec "$CRONIC" "\$@" 2>&3 3>&- EOF -chmod +x "$work/cronic-with-xtrace" + 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 +make_cronic_wrapper xtrace -x +make_cronic_wrapper verbose -v saved_cronic="$CRONIC" CRONIC="$work/cronic-with-shellopts" # shellcheck disable=SC2016 # The expansion is for the wrapped command to do. @@ -407,6 +401,17 @@ if grep -q cronic_ "$work/output"; then else echo "PASS: caller's xtrace exported: the report does not trace cronic itself" fi +# When the caller exports verbose, bash echoes the lines of `cronic` through +# the block that turns verbose off, but nothing more. +"$work/cronic-with-verbose" true > "$work/output" 2>&1 +sed '/^} 2> \/dev\/null$/q' "$saved_cronic" > "$work/expected" +if cmp -s "$work/expected" "$work/output"; then + echo "PASS: caller's verbose exported: cronic echoes only its first lines" +else + echo "FAIL: caller's verbose exported: cronic echoes more than its first lines:" + cat "$work/output" + status=1 +fi CRONIC="$saved_cronic" # The wrapped command sees the caller's exported variables unchanged. For @@ -416,7 +421,7 @@ CRONIC="$saved_cronic" # The lowercase names that `cronic` assigns, such as `cronic_out`, are not # exported: `cronic` does overwrite them, but environment variables # conventionally have uppercase names. -variable_names=$(sed -n 's/^ *\([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p' "$CRONIC" | sort -u) +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" status=1 From 8a59d8f1243e743e4d9cd1677b5040b1fc0058b8 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:13:51 -0700 Subject: [PATCH 11/23] cronic: put the copyright header near the top, and clarify the verbose comment; tests: run the verbose test through `check` --- cronic | 30 ++++++++++++++++-------------- tests/cronic-test/cronic-test.sh | 27 ++++++++++++++++----------- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/cronic b/cronic index ab42be2..966c922 100755 --- a/cronic +++ b/cronic @@ -4,18 +4,6 @@ set +avx export -n cronic_caller_flags } 2> /dev/null -# The block above is first, because with the caller's verbose on, bash echoes -# each line of this script to stderr as it reads it, until `set +v` runs. -# `cronic_caller_flags` holds 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 verbose and xtrace off, so that its own -# commands are not echoed or traced to stderr, which would make every run -# produce output; the redirection discards the trace of `set +avx` itself. -# Even so, with verbose on, bash echoes the lines through the block, so every -# run produces output. This script 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 v2 - cron job report wrapper # Copyright 2007 Chuck Houpt @@ -36,6 +24,20 @@ # BASH_XTRACEFD (bash 4.1), only after checking the version; see the # BASH_VERSINFO check below. +# The block at the top of this script is first, because with the caller's +# verbose on, bash echoes each line of this script to stderr as it reads it, +# until `set +v` runs. `cronic_caller_flags` holds 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 +avx` itself. This script turns +# verbose off, so that bash echoes only the lines through the block, not the +# whole script. With verbose on, every run therefore produces output: the +# echo of those lines. This script 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. + # Exit if a command exits with a non-zero status, and treat unset variables # as an error. set -eu @@ -189,8 +191,8 @@ fi # 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`. +# options, so that only the command is traced. The `:+` avoids a bare `-`, +# which has a special meaning to `set`. set +e ( set +u${cronic_caller_flags:+ -$cronic_caller_flags} diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 0b74ff6..733b144 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -137,8 +137,9 @@ temp_files() { # it left no temporary files behind. EXPECTED-OUTPUT is "silent", "report", # "report:TEXT", which also requires TEXT to appear in the report, # "report-once:TEXT", which also requires TEXT to appear on exactly one line of -# the report, or "message:TEXT", which requires TEXT to appear in output that -# need not be a report. +# the report, "message:TEXT", which requires TEXT to appear in output that need +# not be a report, or "exactly:FILE", which requires the output to be the +# contents of FILE. check() { description="$1" expected_status="$2" @@ -194,6 +195,15 @@ check() { return fi ;; + exactly:*) + if ! cmp -s "${expected_output#*:}" "$work/output"; then + echo "FAIL: $description: expected the output to be the contents of" \ + "${expected_output#*:}, but got:" + cat "$work/output" + status=1 + return + fi + ;; esac if [ "$before" != "$after" ]; then @@ -403,15 +413,10 @@ else fi # When the caller exports verbose, bash echoes the lines of `cronic` through # the block that turns verbose off, but nothing more. -"$work/cronic-with-verbose" true > "$work/output" 2>&1 +CRONIC="$work/cronic-with-verbose" sed '/^} 2> \/dev\/null$/q' "$saved_cronic" > "$work/expected" -if cmp -s "$work/expected" "$work/output"; then - echo "PASS: caller's verbose exported: cronic echoes only its first lines" -else - echo "FAIL: caller's verbose exported: cronic echoes more than its first lines:" - cat "$work/output" - status=1 -fi +check "caller's verbose exported: cronic echoes only its first lines" 0 \ + "exactly:$work/expected" true CRONIC="$saved_cronic" # The wrapped command sees the caller's exported variables unchanged. For @@ -421,7 +426,7 @@ CRONIC="$saved_cronic" # The lowercase names that `cronic` assigns, such as `cronic_out`, are not # exported: `cronic` does overwrite them, but environment variables # conventionally have uppercase names. -variable_names=$(sed -n 's/^[{ ]*\([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p' "$CRONIC" | sort -u) +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" status=1 From 00f2c49d4bab9a530e6498ba20658b7d2fd67e35 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:17:02 -0700 Subject: [PATCH 12/23] cronic: shorten the comment about the option-saving block --- cronic | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/cronic b/cronic index 966c922..059f7bf 100755 --- a/cronic +++ b/cronic @@ -25,18 +25,15 @@ # BASH_VERSINFO check below. # The block at the top of this script is first, because with the caller's -# verbose on, bash echoes each line of this script to stderr as it reads it, -# until `set +v` runs. `cronic_caller_flags` holds 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 +avx` itself. This script turns -# verbose off, so that bash echoes only the lines through the block, not the -# whole script. With verbose on, every run therefore produces output: the -# echo of those lines. This script 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. +# verbose on, bash echoes each line to stderr as it reads it, until `set +v` +# runs; so with verbose on, every run echoes those lines. +# `cronic_caller_flags` holds the caller's shell options that this script +# changes (including any passed by exporting SHELLOPTS), as the letters that +# `$-` uses for them; this script restores them for the command. It turns off +# verbose and xtrace so that its own commands are not echoed or traced, and +# allexport so that its own variables are not exported to the command +# (`cronic_caller_flags`, assigned before that, is un-exported explicitly). +# The redirection discards the trace of `set +avx` itself. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. From b67130f71f05393741a68322b07c0d57d0fc0f1d Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:17:15 -0700 Subject: [PATCH 13/23] Address review findings: portable shebangs, keep caller's options on, same-shell re-exec, reuse BASH_XTRACEFD probe, clarify first-command comment --- cronic | 33 +++++++++---- tests/cronic-test/cronic-test.sh | 79 +++++++++++++++++--------------- 2 files changed, 66 insertions(+), 46 deletions(-) diff --git a/cronic b/cronic index 9c3a8d3..405eb34 100755 --- a/cronic +++ b/cronic @@ -23,14 +23,17 @@ # BASH_XTRACEFD (bash 4.1), only after checking the version; see the # BASH_VERSINFO check below. -# The first command saves the caller's `set -o` shell options, as the -# colon-separated list of those that are on, before this script changes any of -# them for itself. This script changes no `shopt` option, so the command +# The first command (lines 2-5) saves the caller's `set -o` shell options, as +# the colon-separated list of those that are on, before this script changes any +# of them for itself. This script changes no `shopt` option, so the command # inherits those without any restoring. # The first command then stops this script from tracing itself or echoing its # input, which would go to this script's stderr, so that cron would send email -# for every run. It comes before any comment, because bash echoes every line -# that it reads while verbose is on. The redirection discards its own trace. +# for every run. The redirection discards its own trace. +# Do not add anything, not even a comment, before or within the first command, +# because bash echoes to stderr every line that it reads while verbose is on. +# The verbose check in tests/cronic-test/cronic-test.sh expects that echo to +# end at the first line that is exactly `} 2> /dev/null`. # Verbose is handled only partly: bash echoes this script's lines through the # end of the first command to stderr before that command can turn verbose off. # So if the caller exports SHELLOPTS with verbose, or runs `bash -v cronic`, @@ -198,10 +201,13 @@ fi # 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 turns off every option that is on, which includes -# nounset, and turns on the caller's, in a single `set` command, so that the -# restoring is neither traced nor echoed. -# SHELLOPTS is not empty here, because nounset is on. The `:+` avoids a bare +# sets. The subshell turns off every option that is on but that the caller +# had off, and turns on the caller's, in a single `set` command, so that the +# restoring is neither traced nor echoed. It does not turn off and back on an +# option that the caller had on, because that cannot always be undone: turning +# off privileged resets the effective user and group IDs to the real ones. +# That `set` command has an argument, because nounset is on here, so either +# the caller had it on or the subshell turns it off. The `:+` avoids a bare # `set -o`, which would print the options, when the caller had none on. # 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 @@ -211,9 +217,16 @@ fi # 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 +cronic_off_opts= +for cronic_opt in ${SHELLOPTS//:/ }; do + case ":$cronic_caller_opts:" in + *":$cronic_opt:"*) ;; + *) cronic_off_opts="$cronic_off_opts +o $cronic_opt" ;; + esac +done ( # shellcheck disable=SC2086 # each option name and +o or -o is an argument. - set +o ${SHELLOPTS//:/ +o } ${cronic_caller_opts:+-o ${cronic_caller_opts//:/ -o }} + set $cronic_off_opts ${cronic_caller_opts:+-o ${cronic_caller_opts//:/ -o }} 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 6d0412f..402a52d 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -27,12 +27,20 @@ unset BASH_ENV # Use the default PS4, which the goal files and the trace lines below assume. unset PS4 # 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. +# to the commands below. If SHELLOPTS is exported, then remove it from the +# environment. bash makes SHELLOPTS read-only, so it cannot be unset, but +# `export -n` stops exporting it; then this script runs again under the same +# bash, so that the caller's options do not apply to this script either. Any +# other shell ignores SHELLOPTS, so it can simply unset it. # `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" "$@" + if [ -n "${BASH:-}" ]; then + # shellcheck disable=SC3045 # this branch runs only under bash. + export -n SHELLOPTS + exec "$BASH" "$0" "$@" + fi + unset SHELLOPTS fi SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)" @@ -144,7 +152,7 @@ chmod +x "$work/stderr-lines" # A bash command that reads an unset variable. cat > "$work/read-unset" << 'EOF' -#!/bin/bash +#!/usr/bin/env bash unset CRONIC_TEST_UNSET_VARIABLE echo "value: ${CRONIC_TEST_UNSET_VARIABLE}" > /dev/null exit "$1" @@ -153,13 +161,31 @@ chmod +x "$work/read-unset" # Runs `cronic` with SHELLOPTS set to its first argument and exported. cat > "$work/cronic-with-shellopts" << 'EOF2' -#!/bin/bash +#!/usr/bin/env bash shellopts="$1" shift exec env SHELLOPTS="$shellopts" "$REAL_CRONIC" "$@" EOF2 chmod +x "$work/cronic-with-shellopts" +# A bash that supports BASH_XTRACEFD and the allocation of a file descriptor +# (bash 4.1 or later) writes its trace lines to a separate file, so they are +# not error output, whatever PS4 is. An earlier bash, such as macOS's +# /bin/bash, writes them to stderr, where `cronic` cannot recognize them when +# PS4 contains an expansion or an escape, so it reports them as error output. +# This tests for the features themselves, rather than for the bash version +# that `cronic` tests for, so that it detects a wrong version test in `cronic`. +# `cronic` and the bash scripts above all run under the first `bash` in PATH, +# so that is the bash tested here. The probe checks whether a trace line +# reaches the file, rather than whether bash writes to stderr, because bash can +# write an unrelated warning to stderr, such as for an uninstalled locale. +if bash -c 'exec {fd}> "$1" && BASH_XTRACEFD=$fd && set -x && :' \ + bash "$work/xtracefd-probe" 2> /dev/null && [ -s "$work/xtracefd-probe" ]; then + bash_has_xtracefd=true +else + bash_has_xtracefd=false +fi + # temp_files: prints `cronic`'s temporary files, in a canonical order. temp_files() { find "$TMPDIR" -mindepth 1 -maxdepth 1 2> /dev/null | sort @@ -209,21 +235,19 @@ compare_streams() { fi } -# check_trace_section DESCRIPTION BASH GOAL-FILE COMMAND...: runs COMMAND, -# which runs `cronic` under the bash program BASH, and checks that it exits -# with status 0, leaves no temporary files, writes nothing to stderr, and -# produces a report whose "TRACE OUTPUT" section, through the following empty -# line, is the contents of GOAL-FILE. Skips the check if BASH is older than -# 4.1, which lacks BASH_XTRACEFD, so that the report has no "TRACE OUTPUT". +# check_trace_section DESCRIPTION GOAL-FILE COMMAND...: runs COMMAND, which +# runs `cronic`, and checks that it exits with status 0, leaves no temporary +# files, writes nothing to stderr, and produces a report whose "TRACE OUTPUT" +# section, through the following empty line, is the contents of GOAL-FILE. +# Skips the check if bash lacks BASH_XTRACEFD, so that the report has no +# "TRACE OUTPUT". check_trace_section() { description="$1" - bash_program="$2" - goal="$3" - shift 3 + goal="$2" + shift 2 - # shellcheck disable=SC2016 # the expansions are for bash, not this shell. - if ! "$bash_program" -c '((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1)))'; then - echo "SKIP: $description, because $bash_program is older than 4.1" + if [ "$bash_has_xtracefd" = false ]; then + echo "SKIP: $description, because bash lacks BASH_XTRACEFD" return 0 fi @@ -466,7 +490,7 @@ TRACE OUTPUT: + exit 0 EOF -check_trace_section "exported SHELLOPTS with xtrace, trace output" bash \ +check_trace_section "exported SHELLOPTS with xtrace, trace output" \ "$work/trace-section.goal" \ "$CRONIC" "$default_shellopts:xtrace" "$work/trace-and-stderr" 0 @@ -530,27 +554,10 @@ TRACE OUTPUT: + exit 0 EOF -check_trace_section "bash -x cronic, trace output" bash \ +check_trace_section "bash -x cronic, trace output" \ "$work/bash-x-trace-section.goal" \ bash -x "$CRONIC" "$work/trace-and-stderr" 0 -# A bash that supports BASH_XTRACEFD and the allocation of a file descriptor -# (bash 4.1 or later) writes its trace lines to a separate file, so they are -# not error output, whatever PS4 is. An earlier bash, such as macOS's -# /bin/bash, writes them to stderr, where `cronic` cannot recognize them when -# PS4 contains an expansion or an escape, so it reports them as error output. -# This tests for the features themselves, rather than for the bash version -# that `cronic` tests for, so that it detects a wrong version test in `cronic`. -# `cronic` and the bash scripts above all run under the first `bash` in PATH, -# so that is the bash tested here. The probe checks whether a trace line -# reaches the file, rather than whether bash writes to stderr, because bash can -# write an unrelated warning to stderr, such as for an uninstalled locale. -if bash -c 'exec {fd}> "$1" && BASH_XTRACEFD=$fd && set -x && :' \ - bash "$work/xtracefd-probe" 2> /dev/null && [ -s "$work/xtracefd-probe" ]; then - bash_has_xtracefd=true -else - bash_has_xtracefd=false -fi export PS4 # shellcheck disable=SC2016 # The expansion is for the traced command to do. PS4='+${LINENO}: ' From dd7cedfdfb00da24e0a18977a05d4db8c12e2f4a Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:27:30 -0700 Subject: [PATCH 14/23] cronic: restore only the options that cronic changes, using $- letters --- cronic | 37 +++++++++++++------------------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/cronic b/cronic index 405eb34..6849bbe 100755 --- a/cronic +++ b/cronic @@ -1,6 +1,6 @@ #!/usr/bin/env bash { - cronic_caller_opts=$SHELLOPTS + cronic_caller_flags=${-//[^aeuvx]/} set +vx } 2> /dev/null @@ -23,10 +23,11 @@ # BASH_XTRACEFD (bash 4.1), only after checking the version; see the # BASH_VERSINFO check below. -# The first command (lines 2-5) saves the caller's `set -o` shell options, as -# the colon-separated list of those that are on, before this script changes any -# of them for itself. This script changes no `shopt` option, so the command -# inherits those without any restoring. +# The first command (lines 2-5) saves the caller's settings of the shell +# options that this script changes for itself, as the letters that `$-` uses +# for them: allexport, errexit, nounset, verbose, and xtrace. The command +# inherits every other option, including every `shopt` option, unchanged. If +# this script ever changes another option, add its letter there. # The first command then stops this script from tracing itself or echoing its # input, which would go to this script's stderr, so that cron would send email # for every run. The redirection discards its own trace. @@ -46,7 +47,7 @@ # as an error. Do not export this script's variables to the command, even if # the caller's exported SHELLOPTS contains allexport. set -eu +a -export -n cronic_caller_opts +export -n cronic_caller_flags # Prints the usage line. cronic_usage_line() { @@ -191,7 +192,7 @@ 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 +# Run the command with the caller's 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. @@ -201,14 +202,10 @@ fi # 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 turns off every option that is on but that the caller -# had off, and turns on the caller's, in a single `set` command, so that the -# restoring is neither traced nor echoed. It does not turn off and back on an -# option that the caller had on, because that cannot always be undone: turning -# off privileged resets the effective user and group IDs to the real ones. -# That `set` command has an argument, because nounset is on here, so either -# the caller had it on or the subshell turns it off. The `:+` avoids a bare -# `set -o`, which would print the options, when the caller had none on. +# sets. Of the options that this script changes, only nounset is on here, so +# the subshell turns it off and turns on the caller's, in a single `set` +# command, so that the restoring is neither traced nor echoed. The `:+` avoids +# a bare `-`, which has a special meaning to `set`. # 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. @@ -217,16 +214,8 @@ fi # 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 -cronic_off_opts= -for cronic_opt in ${SHELLOPTS//:/ }; do - case ":$cronic_caller_opts:" in - *":$cronic_opt:"*) ;; - *) cronic_off_opts="$cronic_off_opts +o $cronic_opt" ;; - esac -done ( - # shellcheck disable=SC2086 # each option name and +o or -o is an argument. - set $cronic_off_opts ${cronic_caller_opts:+-o ${cronic_caller_opts//:/ -o }} + set +u${cronic_caller_flags:+ -$cronic_caller_flags} command -- "$@" ) > "$cronic_out" 2> "$cronic_trace" cronic_result=$? From 9e13f70adc7615f128ff368071f143d3e992a223 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:30:16 -0700 Subject: [PATCH 15/23] cronic: do not handle verbose; document that the caller should not export it --- cronic | 40 ++++++++++++++------------------ tests/cronic-test/cronic-test.sh | 11 --------- 2 files changed, 18 insertions(+), 33 deletions(-) diff --git a/cronic b/cronic index 6849bbe..4d185dd 100755 --- a/cronic +++ b/cronic @@ -1,7 +1,7 @@ #!/usr/bin/env bash { - cronic_caller_flags=${-//[^aeuvx]/} - set +vx + cronic_caller_flags=${-//[^aeux]/} + set +x } 2> /dev/null # Cronic v2 - cron job report wrapper @@ -25,23 +25,18 @@ # The first command (lines 2-5) saves the caller's settings of the shell # options that this script changes for itself, as the letters that `$-` uses -# for them: allexport, errexit, nounset, verbose, and xtrace. The command +# for them: allexport, errexit, nounset, and xtrace. The command # inherits every other option, including every `shopt` option, unchanged. If # this script ever changes another option, add its letter there. -# The first command then stops this script from tracing itself or echoing its -# input, which would go to this script's stderr, so that cron would send email -# for every run. The redirection discards its own trace. -# Do not add anything, not even a comment, before or within the first command, -# because bash echoes to stderr every line that it reads while verbose is on. -# The verbose check in tests/cronic-test/cronic-test.sh expects that echo to -# end at the first line that is exactly `} 2> /dev/null`. -# Verbose is handled only partly: bash echoes this script's lines through the -# end of the first command to stderr before that command can turn verbose off. -# So if the caller exports SHELLOPTS with verbose, or runs `bash -v cronic`, -# then cron sends email for every run. -# Likewise, bash runs the file named by BASH_ENV, if any, before this script's -# first command, so an exported xtrace, verbose, or nounset applies to that file -# and can make it write to stderr; such a cron job should unset BASH_ENV. +# The first command then stops this script from tracing itself, which would go +# to this script's stderr, so that cron would send email for every run. The +# redirection discards its own trace. +# This script does not handle verbose: if the caller exports SHELLOPTS with +# verbose, or runs `bash -v cronic`, then bash echoes this script's lines to +# stderr, so cron sends email for every run. `cronic --help` says not to. +# Bash runs the file named by BASH_ENV, if any, before this script's first +# command, so an exported xtrace or nounset applies to that file and can make +# it write to stderr; such a cron job should unset BASH_ENV. # 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 @@ -74,16 +69,17 @@ 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 "Bash runs the BASH_ENV file, if any, before cronic starts, so" + echo "exported shell options can make that file write to stderr." 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" echo "stderr, from which lines that start with PS4 are removed. That removal" echo "is skipped if PS4 contains \"\$\", a backquote, a backslash, or a newline," echo "because such a PS4 can expand differently on each line." - echo "Under \"set -v\" exported via SHELLOPTS, or \"bash -v cronic\", bash echoes the" - echo "first lines of cronic to stderr before cronic can turn verbose off." - echo "Likewise, bash runs the BASH_ENV file, if any, before cronic starts, so" - echo "exported shell options can make that file write to stderr." + echo "Do not export the verbose option (\"set -v\") via SHELLOPTS: bash would echo" + echo "the lines of this script and of a bash COMMAND to stderr, so every run would" + echo "produce output." exit 0 } @@ -204,7 +200,7 @@ fi # the command finishes, such as the output of an EXIT trap that the command # sets. Of the options that this script changes, only nounset is on here, so # the subshell turns it off and turns on the caller's, in a single `set` -# command, so that the restoring is neither traced nor echoed. The `:+` avoids +# command, so that the restoring is not traced. The `:+` avoids # a bare `-`, which has a special meaning to `set`. # 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 diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 402a52d..d01d289 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -494,17 +494,6 @@ check_trace_section "exported SHELLOPTS with xtrace, trace output" \ "$work/trace-section.goal" \ "$CRONIC" "$default_shellopts:xtrace" "$work/trace-and-stderr" 0 -# 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 - compare_streams "exported SHELLOPTS with verbose" \ - "$work/output" /dev/null "$work/verbose-stderr.goal" -fi - CRONIC="$REAL_CRONIC" # The wrapped command sees the caller's exported variables unchanged. For From 1867f64b2041bbd55ed45a7af4d00a5310fcd5f8 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:32:41 -0700 Subject: [PATCH 16/23] cronic-test: remove SHELLOPTS with `env -u` rather than the bashism `export -n` --- tests/cronic-test/cronic-test.sh | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index d01d289..89a5ac3 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -28,17 +28,15 @@ unset BASH_ENV unset PS4 # 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 remove it from the -# environment. bash makes SHELLOPTS read-only, so it cannot be unset, but -# `export -n` stops exporting it; then this script runs again under the same -# bash, so that the caller's options do not apply to this script either. Any -# other shell ignores SHELLOPTS, so it can simply unset it. +# environment. bash makes SHELLOPTS read-only, so it cannot be unset; +# instead, this script runs again under the same bash, with SHELLOPTS removed +# from its environment, so that the caller's options do not apply to this script +# either. Any other shell ignores SHELLOPTS, so it can simply unset it. # `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 if [ -n "${BASH:-}" ]; then - # shellcheck disable=SC3045 # this branch runs only under bash. - export -n SHELLOPTS - exec "$BASH" "$0" "$@" + exec env -u SHELLOPTS "$BASH" "$0" "$@" fi unset SHELLOPTS fi From ccfbe14e3790d2ee3e4fbd01f210187b0f3951be Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:38:55 -0700 Subject: [PATCH 17/23] Address review findings: document an inherited BASH_XTRACEFD, quote the traced path as bash does, clarify help text, show diffs on test failure --- cronic | 32 +++++++++++++++++++------------- tests/cronic-test/cronic-test.sh | 17 +++++++++++++---- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/cronic b/cronic index 4d185dd..594a28d 100755 --- a/cronic +++ b/cronic @@ -23,20 +23,25 @@ # BASH_XTRACEFD (bash 4.1), only after checking the version; see the # BASH_VERSINFO check below. -# The first command (lines 2-5) saves the caller's settings of the shell -# options that this script changes for itself, as the letters that `$-` uses -# for them: allexport, errexit, nounset, and xtrace. The command -# inherits every other option, including every `shopt` option, unchanged. If -# this script ever changes another option, add its letter there. +# The first command, just after the shebang, saves the caller's settings of the +# shell options that this script changes for itself, as the letters that `$-` +# uses for them: allexport, errexit, nounset, and xtrace. The command inherits +# every other option, including every `shopt` option, unchanged. If this +# script ever changes another option, add its letter there. # The first command then stops this script from tracing itself, which would go # to this script's stderr, so that cron would send email for every run. The -# redirection discards its own trace. +# redirection discards its own trace. If the caller exports BASH_XTRACEFD, +# then bash writes that trace to the caller's trace descriptor instead, not to +# stderr. If that descriptor is not open, then bash reports so on stderr +# before it runs this script's first command, as for any bash script; cronic +# cannot prevent that. # This script does not handle verbose: if the caller exports SHELLOPTS with # verbose, or runs `bash -v cronic`, then bash echoes this script's lines to # stderr, so cron sends email for every run. `cronic --help` says not to. # Bash runs the file named by BASH_ENV, if any, before this script's first -# command, so an exported xtrace or nounset applies to that file and can make -# it write to stderr; such a cron job should unset BASH_ENV. +# command, so the caller's exported options, such as xtrace and nounset, apply +# to that file and can make it write to stderr; such a cron job should unset +# BASH_ENV. `cronic --help` says so. # 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 @@ -69,17 +74,18 @@ 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 "Bash runs the BASH_ENV file, if any, before cronic starts, so" - echo "exported shell options can make that file write to stderr." 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" echo "stderr, from which lines that start with PS4 are removed. That removal" echo "is skipped if PS4 contains \"\$\", a backquote, a backslash, or a newline," echo "because such a PS4 can expand differently on each line." - echo "Do not export the verbose option (\"set -v\") via SHELLOPTS: bash would echo" - echo "the lines of this script and of a bash COMMAND to stderr, so every run would" - echo "produce output." + echo "Do not run \"bash -v cronic\", nor export the verbose option (\"set -v\") via" + echo "SHELLOPTS: bash would echo the lines of this script (and, if exported, of a" + echo "bash COMMAND) to stderr, so every run would produce output." + echo "Bash runs the file named by BASH_ENV, if any, before cronic starts, so" + echo "exported shell options can make that file write to stderr. To prevent that," + echo "unset BASH_ENV." exit 0 } diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 89a5ac3..2b723a3 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -158,12 +158,12 @@ EOF chmod +x "$work/read-unset" # Runs `cronic` with SHELLOPTS set to its first argument and exported. -cat > "$work/cronic-with-shellopts" << 'EOF2' +cat > "$work/cronic-with-shellopts" << 'EOF' #!/usr/bin/env bash shellopts="$1" shift exec env SHELLOPTS="$shellopts" "$REAL_CRONIC" "$@" -EOF2 +EOF chmod +x "$work/cronic-with-shellopts" # A bash that supports BASH_XTRACEFD and the allocation of a file descriptor @@ -229,6 +229,10 @@ compare_streams() { cat "$work/output" echo "stderr:" cat "$work/stderr" + echo "differences from the goal output:" + diff "$3" "$2" || true + echo "differences from the goal stderr:" + diff "$4" "$work/stderr" || true status=1 fi } @@ -480,9 +484,14 @@ check "exported SHELLOPTS with xtrace, trace-only stderr" 0 silent \ # 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". +# Bash quotes a word in a trace line if it contains a space or a shell +# metacharacter, so let bash write the expected trace of the path, which is +# under the caller's TMPDIR. +# shellcheck disable=SC2016 # The expansion is for bash to do. +traced_command=$(bash -c 'set -x; : -- "$1" 0' sh "$work/trace-and-stderr" 2>&1 | sed 's/^+ : /+ command /') cat > "$work/trace-section.goal" << EOF TRACE OUTPUT: -+ command -- $work/trace-and-stderr 0 +$traced_command + set -x + echo 'a real error' + exit 0 @@ -536,7 +545,7 @@ unset $exported_names # exported, so its own `set -x` is not traced. cat > "$work/bash-x-trace-section.goal" << EOF TRACE OUTPUT: -+ command -- $work/trace-and-stderr 0 +$traced_command + echo 'a real error' + exit 0 From febd38e5263c3d43766ca717777283111c0acd3a Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:43:47 -0700 Subject: [PATCH 18/23] Address review findings: keep bash warnings out of the expected trace, clarify BASH_ENV help text, inline compare_streams, regroup header comments --- cronic | 33 ++++++++++++++------------- tests/cronic-test/cronic-test.sh | 38 +++++++++++++------------------- 2 files changed, 33 insertions(+), 38 deletions(-) diff --git a/cronic b/cronic index 594a28d..12568eb 100755 --- a/cronic +++ b/cronic @@ -23,26 +23,29 @@ # BASH_XTRACEFD (bash 4.1), only after checking the version; see the # BASH_VERSINFO check below. -# The first command, just after the shebang, saves the caller's settings of the -# shell options that this script changes for itself, as the letters that `$-` -# uses for them: allexport, errexit, nounset, and xtrace. The command inherits -# every other option, including every `shopt` option, unchanged. If this -# script ever changes another option, add its letter there. -# The first command then stops this script from tracing itself, which would go -# to this script's stderr, so that cron would send email for every run. The -# redirection discards its own trace. If the caller exports BASH_XTRACEFD, -# then bash writes that trace to the caller's trace descriptor instead, not to -# stderr. If that descriptor is not open, then bash reports so on stderr -# before it runs this script's first command, as for any bash script; cronic -# cannot prevent that. # This script does not handle verbose: if the caller exports SHELLOPTS with # verbose, or runs `bash -v cronic`, then bash echoes this script's lines to # stderr, so cron sends email for every run. `cronic --help` says not to. + # Bash runs the file named by BASH_ENV, if any, before this script's first # command, so the caller's exported options, such as xtrace and nounset, apply # to that file and can make it write to stderr; such a cron job should unset # BASH_ENV. `cronic --help` says so. +# The first command in this file, just after the shebang, saves the caller's +# settings of the shell options that this script changes for itself, as the +# letters that `$-` uses for them: allexport, errexit, nounset, and xtrace. +# The command inherits every other option, including every `shopt` option, +# unchanged. If this script ever changes another option, add its letter there. + +# The first command then stops this script from tracing itself, which would go +# to this script's stderr, so that cron would send email for every run. The +# redirection discards its own trace. If the caller exports BASH_XTRACEFD, +# then bash writes that trace to the caller's trace descriptor instead, not to +# stderr. If that descriptor is not open, then bash reports so on stderr +# before it runs this script's first command, as for any bash script; cronic +# cannot prevent that. + # 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. @@ -83,9 +86,9 @@ cronic_help() { echo "Do not run \"bash -v cronic\", nor export the verbose option (\"set -v\") via" echo "SHELLOPTS: bash would echo the lines of this script (and, if exported, of a" echo "bash COMMAND) to stderr, so every run would produce output." - echo "Bash runs the file named by BASH_ENV, if any, before cronic starts, so" - echo "exported shell options can make that file write to stderr. To prevent that," - echo "unset BASH_ENV." + echo "Bash runs the file named by BASH_ENV, if any, before cronic's own code, so" + echo "cronic cannot capture that file's output, and exported shell options can make" + echo "that file write to stderr. To prevent that, unset BASH_ENV." exit 0 } diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 2b723a3..f2335da 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -217,26 +217,6 @@ run_cronic() { fi } -# compare_streams DESCRIPTION OUTPUT OUTPUT-GOAL STDERR-GOAL: after run_cronic, -# reports success if the file OUTPUT, which is `cronic`'s output or a part of -# it, has the contents of OUTPUT-GOAL, and `cronic`'s stderr has the contents of -# STDERR-GOAL. -compare_streams() { - if cmp -s "$3" "$2" && cmp -s "$4" "$work/stderr"; then - echo "PASS: $1" - else - echo "FAIL: $1: output:" - cat "$work/output" - echo "stderr:" - cat "$work/stderr" - echo "differences from the goal output:" - diff "$3" "$2" || true - echo "differences from the goal stderr:" - diff "$4" "$work/stderr" || true - status=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, writes nothing to stderr, and produces a report whose "TRACE OUTPUT" @@ -255,7 +235,17 @@ check_trace_section() { run_cronic "$description" 0 "$@" || return 0 sed -n '/^TRACE OUTPUT:$/,/^$/p' "$work/output" > "$work/trace-section" - compare_streams "$description" "$work/trace-section" "$goal" /dev/null + if cmp -s "$goal" "$work/trace-section" && [ ! -s "$work/stderr" ]; then + echo "PASS: $description" + else + echo "FAIL: $description: output:" + cat "$work/output" + echo "stderr:" + cat "$work/stderr" + echo "differences from the goal trace section:" + diff "$goal" "$work/trace-section" || true + status=1 + fi } # check DESCRIPTION EXPECTED-STATUS EXPECTED-OUTPUT COMMAND...: runs `cronic` @@ -486,9 +476,11 @@ check "exported SHELLOPTS with xtrace, trace-only stderr" 0 silent \ # it appears under "TRACE OUTPUT". # Bash quotes a word in a trace line if it contains a space or a shell # metacharacter, so let bash write the expected trace of the path, which is -# under the caller's TMPDIR. +# under the caller's TMPDIR. The trace goes to a separate file descriptor, +# and stderr is discarded, so that an unrelated warning from bash, such as for +# an uninstalled locale, is not part of the expected trace. # shellcheck disable=SC2016 # The expansion is for bash to do. -traced_command=$(bash -c 'set -x; : -- "$1" 0' sh "$work/trace-and-stderr" 2>&1 | sed 's/^+ : /+ command /') +traced_command=$(bash -c 'BASH_XTRACEFD=3; set -x; : -- "$1" 0' sh "$work/trace-and-stderr" 3>&1 2> /dev/null | sed 's/^+ : /+ command /') cat > "$work/trace-section.goal" << EOF TRACE OUTPUT: $traced_command From cf493e9880efea38ee6c6d5e4e91d4bcd7c80682 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:48:57 -0700 Subject: [PATCH 19/23] Simplify: keep the option-saving code with its comment, shorten comments, drop the SHELLOPTS re-exec and two helper scripts from the tests --- cronic | 72 ++++++++++++++------------------ tests/cronic-test/cronic-test.sh | 55 +++++++----------------- 2 files changed, 48 insertions(+), 79 deletions(-) diff --git a/cronic b/cronic index 12568eb..3187109 100755 --- a/cronic +++ b/cronic @@ -1,8 +1,4 @@ #!/usr/bin/env bash -{ - cronic_caller_flags=${-//[^aeux]/} - set +x -} 2> /dev/null # Cronic v2 - cron job report wrapper # Copyright 2007 Chuck Houpt @@ -23,34 +19,31 @@ # BASH_XTRACEFD (bash 4.1), only after checking the version; see the # BASH_VERSINFO check below. -# This script does not handle verbose: if the caller exports SHELLOPTS with -# verbose, or runs `bash -v cronic`, then bash echoes this script's lines to -# stderr, so cron sends email for every run. `cronic --help` says not to. - -# Bash runs the file named by BASH_ENV, if any, before this script's first -# command, so the caller's exported options, such as xtrace and nounset, apply -# to that file and can make it write to stderr; such a cron job should unset -# BASH_ENV. `cronic --help` says so. +# Limitations, which `cronic --help` documents: every run produces output if +# the caller exports the verbose option or runs `bash -v cronic`, or if the file +# named by BASH_ENV writes to stderr. -# The first command in this file, just after the shebang, saves the caller's -# settings of the shell options that this script changes for itself, as the -# letters that `$-` uses for them: allexport, errexit, nounset, and xtrace. -# The command inherits every other option, including every `shopt` option, -# unchanged. If this script ever changes another option, add its letter there. - -# The first command then stops this script from tracing itself, which would go -# to this script's stderr, so that cron would send email for every run. The -# redirection discards its own trace. If the caller exports BASH_XTRACEFD, -# then bash writes that trace to the caller's trace descriptor instead, not to -# stderr. If that descriptor is not open, then bash reports so on stderr -# before it runs this script's first command, as for any bash script; cronic -# cannot prevent that. +# Save the caller's settings of the options that this script changes, as the +# letters that `$-` uses for them, so that the command runs with them. The +# command inherits every other option, including every `shopt` option, +# unchanged. If this script ever changes another option, add its letter here. +# Then turn off xtrace, so that this script does not trace itself to stderr, +# which would make every run produce output, and allexport, so that this script +# does not export its variables to the command. `cronic_caller_flags`, which is +# assigned before that, is un-exported explicitly. The redirection discards +# the trace of `set +ax` itself. If the caller exports BASH_XTRACEFD, then +# bash writes that trace to the caller's trace descriptor instead. If that +# descriptor is not open, then bash reports so on stderr before this script +# runs, as for any bash script; cronic cannot prevent that. +{ + 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. Do not export this script's variables to the command, even if -# the caller's exported SHELLOPTS contains allexport. -set -eu +a -export -n cronic_caller_flags +# as an error. +set -eu # Prints the usage line. cronic_usage_line() { @@ -197,20 +190,19 @@ 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 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. +# The subshell restores the caller's options that this script changed. +# Otherwise, if the caller exported SHELLOPTS, a bash script that reads an unset +# variable (as `/usr/bin/texi2dvi` does when /bin/sh is bash) would fail. Of +# those options, only nounset is on here, so a single `set` command turns it off +# and turns on the caller's, so that the restoring is not traced. The `:+` +# avoids a bare `-`, which has a special meaning to `set`. # `command` because a function of this script, such as `cronic_usage`, would -# 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. Of the options that this script changes, only nounset is on here, so -# the subshell turns it off and turns on the caller's, in a single `set` -# command, so that the restoring is not traced. The `:+` avoids -# a bare `-`, which has a special meaning to `set`. +# 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 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. diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index f2335da..fd222db 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -26,20 +26,6 @@ set -eu unset BASH_ENV # Use the default PS4, which the goal files and the trace lines below assume. unset PS4 -# 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 remove it from the -# environment. bash makes SHELLOPTS read-only, so it cannot be unset; -# instead, this script runs again under the same bash, with SHELLOPTS removed -# from its environment, so that the caller's options do not apply to this script -# either. Any other shell ignores SHELLOPTS, so it can simply unset it. -# `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 - if [ -n "${BASH:-}" ]; then - exec env -u SHELLOPTS "$BASH" "$0" "$@" - fi - unset SHELLOPTS -fi SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)" CRONIC="$(CDPATH='' cd -- "${SCRIPT_DIR}/../.." && pwd -P)/cronic" @@ -148,24 +134,6 @@ printf '%s\n' "$@" 1>&2 EOF chmod +x "$work/stderr-lines" -# A bash command that reads an unset variable. -cat > "$work/read-unset" << 'EOF' -#!/usr/bin/env 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" << 'EOF' -#!/usr/bin/env bash -shellopts="$1" -shift -exec env SHELLOPTS="$shellopts" "$REAL_CRONIC" "$@" -EOF -chmod +x "$work/cronic-with-shellopts" - # A bash that supports BASH_XTRACEFD and the allocation of a file descriptor # (bash 4.1 or later) writes its trace lines to a separate file, so they are # not error output, whatever PS4 is. An earlier bash, such as macOS's @@ -445,17 +413,26 @@ check "builtin with an unset variable" 0 silent \ # 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" +real_cronic="$CRONIC" +# cronic_with_shellopts SHELLOPTS ARG...: runs `cronic` on the ARGs, with +# SHELLOPTS exported. +# shellcheck disable=SC2329 # `check` calls it, as "$CRONIC". +cronic_with_shellopts() { + shellopts="$1" + shift + env SHELLOPTS="$shellopts" "$real_cronic" "$@" +} +CRONIC=cronic_with_shellopts default_shellopts=braceexpand:hashall:interactive-comments +# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. check "exported SHELLOPTS, command reads an unset variable" 0 silent \ - "$default_shellopts" "$work/read-unset" 0 + "$default_shellopts" bash -c ': "$cronic_test_unset_variable"' 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 \ +# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. +check "exported SHELLOPTS with nounset, command reads an unset variable" 127 \ "report:unbound variable" \ - "$default_shellopts:nounset" "$work/read-unset" 0 + "$default_shellopts:nounset" bash -c ': "$cronic_test_unset_variable"' check "exported SHELLOPTS with errexit, command ignores a failure" 1 report \ "$default_shellopts:errexit" bash -c 'false; :' # With allexport exported, `cronic` does not export its own variables to the @@ -493,7 +470,7 @@ check_trace_section "exported SHELLOPTS with xtrace, trace output" \ "$work/trace-section.goal" \ "$CRONIC" "$default_shellopts:xtrace" "$work/trace-and-stderr" 0 -CRONIC="$REAL_CRONIC" +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 From 8cce3eb22aafd02189f32622290aacced2b522a2 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 13:12:17 -0700 Subject: [PATCH 20/23] Separate paragraphs in long comments --- cronic | 12 ++++++++++++ tests/cronic-test/cronic-test.sh | 2 ++ 2 files changed, 14 insertions(+) diff --git a/cronic b/cronic index a4597e9..ecdb87d 100755 --- a/cronic +++ b/cronic @@ -10,10 +10,13 @@ # execution traces from the error output, so jobs can be run with execution # tracing to aid forensic debugging. Apart from its options, Cronic simply # executes its arguments. +# # Extended by Michael Ernst to add the --expected-status, --permit-stderr, and # --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`. +# # This script runs under the first `bash` in PATH, which on macOS may be # /bin/bash, bash 3.2. So it uses a feature of a later bash, such as # BASH_XTRACEFD (bash 4.1), only after checking the version; see the @@ -27,6 +30,7 @@ # letters that `$-` uses for them, so that the command runs with them. The # command inherits every other option, including every `shopt` option, # unchanged. If this script ever changes another option, add its letter here. +# # Then turn off xtrace, so that this script does not trace itself to stderr, # which would make every run produce output, and allexport, so that this script # does not export its variables to the command. `cronic_caller_flags`, which is @@ -192,17 +196,21 @@ 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. +# # The subshell restores the caller's options that this script changed. # Otherwise, if the caller exported SHELLOPTS, a bash script that reads an unset # variable (as `/usr/bin/texi2dvi` does when /bin/sh is bash) would fail. Of # those options, only nounset is on here, so a single `set` command turns it off # and turns on the caller's, so that the restoring is not traced. The `:+` # avoids a bare `-`, which has a special meaning to `set`. +# # `command` because a function of this script, such as `cronic_usage`, would # 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. +# # With the caller's xtrace on, bash traces the `command -- ...` line, which is # this script's, not the command's. So under bash 4.1 or later, the subshell # points the trace descriptor at /dev/null, and the redirection that points it @@ -215,9 +223,11 @@ fi # builtin COMMAND, such as `exit`, is the `command -- ...` line, so it is # discarded too. A bash before 4.1 writes the trace to stderr, which cannot be # pointed at /dev/null without discarding the command's error output. +# # 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`, @@ -304,11 +314,13 @@ fi # For example, git does this, and the POSIX standard requires it for make. # The full stderr output is shown, but file cronic_reduced_err is checked for # emptiness below. +# # GNU make names itself by the name it was invoked as, without the directory, # such as `make`, `gmake`, `gnumake` (on macOS), or `mingw32-make`. It quotes # the directory as 'dir' from version 4.0, and as `dir' before then; macOS's # /usr/bin/make is GNU make 3.81. # TODO: Expand this. +# # `|| [ $? -eq 1 ]` because `grep -v` exits with status 1 when it selects no # lines. If `grep` fails (status 2), the unreduced stderr is used. grep -a -E -v \ diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index e07e183..69a436a 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -10,6 +10,7 @@ # printed, and the exit status would be grep's 1 rather than the wrapped # command's. (The temporary files would still be removed, by `cronic`'s EXIT # trap, so the left-behind-files check in `check` does not detect that bug.) +# # The checks that catch that bug are the "trace-only stderr" checks that use # `stderr-lines` with a literal PS4, and the "nested trace in stderr" check, # whose bash script writes its trace lines to stderr whatever the bash version. @@ -139,6 +140,7 @@ chmod +x "$work/stderr-lines" # not error output, whatever PS4 is. An earlier bash, such as macOS's # /bin/bash, writes them to stderr, where `cronic` cannot recognize them when # PS4 contains an expansion or an escape, so it reports them as error output. +# # This tests for the features themselves, rather than for the bash version # that `cronic` tests for, so that it detects a wrong version test in `cronic`. # `cronic` and the bash scripts above all run under the first `bash` in PATH, From 1074846b1d8e1a2c37a915c795cea0242709ae65 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 13:13:18 -0700 Subject: [PATCH 21/23] Remove the trace of the `command` line from the trace file, rather than running the command in a function --- cronic | 41 +++++++++++++------------------- tests/cronic-test/cronic-test.sh | 11 +++++++++ 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/cronic b/cronic index ecdb87d..b6367a8 100755 --- a/cronic +++ b/cronic @@ -80,6 +80,8 @@ cronic_help() { echo "stderr, from which lines that start with PS4 are removed. That removal" echo "is skipped if PS4 contains \"\$\", a backquote, a backslash, or a newline," echo "because such a PS4 can expand differently on each line." + echo "If the caller's xtrace is on, cronic's own trace of running COMMAND is" + echo "removed from the TRACE OUTPUT; that works only if PS4 contains no newline." echo "Do not run \"bash -v cronic\", nor export the verbose option (\"set -v\") via" echo "SHELLOPTS: bash would echo the lines of this script (and, if exported, of a" echo "bash COMMAND) to stderr, so every run would produce output." @@ -211,19 +213,6 @@ fi # it writes after the command finishes, such as the output of an EXIT trap that # the command sets. # -# With the caller's xtrace on, bash traces the `command -- ...` line, which is -# this script's, not the command's. So under bash 4.1 or later, the subshell -# points the trace descriptor at /dev/null, and the redirection that points it -# back at the trace file is on the `command` line itself, which bash traces -# before performing the redirections. That line is in a function, rather than -# in an `eval`, because an `eval` would add a level of nesting to every trace -# line of a builtin COMMAND. The function is defined by `eval` because the -# descriptor that a redirection changes, on the left of the operator, must be -# written literally; only the right side may be a variable. The trace of a -# builtin COMMAND, such as `exit`, is the `command -- ...` line, so it is -# discarded too. A bash before 4.1 writes the trace to stderr, which cannot be -# pointed at /dev/null without discarding the command's error output. -# # 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. @@ -234,18 +223,8 @@ fi # that code silently expands an unset variable to the empty string. set +e ( - if [ -n "${cronic_xtrace_fd-}" ] && [[ $cronic_caller_flags == *x* ]]; then - eval "exec {cronic_saved_fd}>&$cronic_xtrace_fd $cronic_xtrace_fd> /dev/null" - # shellcheck disable=SC2154 # The `eval` above assigns cronic_saved_fd. - eval "cronic_command() { - command -- \"\$@\" $cronic_xtrace_fd>&$cronic_saved_fd $cronic_saved_fd>&- - }" - set +u${cronic_caller_flags:+ -$cronic_caller_flags} - cronic_command "$@" - else - set +u${cronic_caller_flags:+ -$cronic_caller_flags} - command -- "$@" - fi + set +u${cronic_caller_flags:+ -$cronic_caller_flags} + command -- "$@" ) > "$cronic_out" 2> "$cronic_trace" cronic_result=$? set -e @@ -253,6 +232,18 @@ set -e # Unsetting BASH_XTRACEFD also closes its file descriptor. unset BASH_XTRACEFD +# With the caller's xtrace on, the first line of the trace file is the trace of +# the `command -- ...` line above, which is this script's, not the command's, +# so it is removed. (The `set` command before it is not traced.) For a builtin +# COMMAND, such as `exit`, that line is the builtin's own trace. If PS4 +# contains a newline, then that trace spans several lines, of which only the +# first is removed. A bash before 4.1 writes the trace to stderr, and that line +# is not removed from it. +if [ -n "${cronic_xtrace_fd-}" ] && [[ $cronic_caller_flags == *x* ]]; then + sed 1d "$cronic_xtrace" > "$cronic_xtrace.reduced" + cronic_xtrace=$cronic_xtrace.reduced +fi + # End stderr with a newline, if it is not empty. `grep` ends its output with a # newline, so without this, the filtered stderr would differ from the # unfiltered stderr even when `grep` removed no line, and the report would diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 69a436a..4863936 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -481,6 +481,17 @@ if [ "$bash_has_xtracefd" = true ] && grep -q -e "command --" "$work/output"; th cat "$work/output" status=1 fi +# With xtrace exported, the trace of an EXIT trap that a builtin sets is +# reported, even if the builtin returns normally rather than calling `exit`. +cat > "$work/exit-trap-trace-section.goal" << 'EOF' +TRACE OUTPUT: +++ trap 'echo trapped >&2' EXIT +++ echo trapped + +EOF +check_trace_section "exported SHELLOPTS with xtrace, EXIT trap of a builtin" \ + "$work/exit-trap-trace-section.goal" \ + "$CRONIC" "$default_shellopts:xtrace" eval "trap 'echo trapped >&2' EXIT" CRONIC="$real_cronic" From da43e5c17b7226c59d0dc68c4f26831ba83f5b53 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 13:15:29 -0700 Subject: [PATCH 22/23] Remove tests made redundant by the EXIT-trap trace test --- tests/cronic-test/cronic-test.sh | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 4863936..6b29186 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -411,10 +411,6 @@ check "exec builtin" 3 report exec "$work/trace-only" 3 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 -# The trace of an EXIT trap that a command sets, after turning on xtrace -# itself, is reported. -check "EXIT trap of a traced command" 3 "report:^++* false$" \ - eval 'set -x; trap false EXIT; exit 3' # 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 \ @@ -471,18 +467,9 @@ check_trace_section "exported SHELLOPTS with xtrace, trace output" \ "$work/trace-section.goal" \ "$CRONIC" "$default_shellopts:xtrace" "$work/trace-and-stderr" 0 # With xtrace exported, the trace lines of a builtin are nested 1 deeper than -# the builtin, whose own trace is the `command -- ...` line. Under bash 4.1 or -# later, that line is discarded. -check "exported SHELLOPTS with xtrace, builtin that fails" 1 \ - "report:^++ false$" "$default_shellopts:xtrace" eval false -if [ "$bash_has_xtracefd" = true ] && grep -q -e "command --" "$work/output"; then - echo "FAIL: exported SHELLOPTS with xtrace, builtin that fails: the report" \ - "traces cronic itself:" - cat "$work/output" - status=1 -fi -# With xtrace exported, the trace of an EXIT trap that a builtin sets is -# reported, even if the builtin returns normally rather than calling `exit`. +# the builtin, whose own trace is the `command -- ...` line, which is not +# reported. The trace of an EXIT trap that the builtin sets is reported, even +# if the builtin returns normally rather than calling `exit`. cat > "$work/exit-trap-trace-section.goal" << 'EOF' TRACE OUTPUT: ++ trap 'echo trapped >&2' EXIT From 1cb7f3b2d533b9f348fb474b3c5f57cda04aab3e Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 13:19:10 -0700 Subject: [PATCH 23/23] Usage info --- cronic | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/cronic b/cronic index b6367a8..587b85a 100755 --- a/cronic +++ b/cronic @@ -14,18 +14,17 @@ # Extended by Michael Ernst to add the --expected-status, --permit-stderr, and # --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`. +# For usage information, run `cronic --help`. # +# Limitations, which `cronic --help` documents: every run produces output if +# the caller exports the verbose option or runs `bash -v cronic`, or if the file +# named by BASH_ENV writes to stderr. + # This script runs under the first `bash` in PATH, which on macOS may be # /bin/bash, bash 3.2. So it uses a feature of a later bash, such as # BASH_XTRACEFD (bash 4.1), only after checking the version; see the # BASH_VERSINFO check below. -# Limitations, which `cronic --help` documents: every run produces output if -# the caller exports the verbose option or runs `bash -v cronic`, or if the file -# named by BASH_ENV writes to stderr. - # Save the caller's settings of the options that this script changes, as the # letters that `$-` uses for them, so that the command runs with them. The # command inherits every other option, including every `shopt` option,