From 0347a303f610f22e72f5c91bcbebc95593c63086 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 10:03:59 -0700 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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 b67130f71f05393741a68322b07c0d57d0fc0f1d Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:17:15 -0700 Subject: [PATCH 07/14] 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 08/14] 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 09/14] 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 10/14] 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 11/14] 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 12/14] 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 13/14] 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 b7068ccc9b9e19068b4287f54d8c7b509ac861a3 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 13:05:01 -0700 Subject: [PATCH 14/14] cronic-test: require the report terminator in check_trace_section --- tests/cronic-test/cronic-test.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index fd222db..62da7be 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -187,8 +187,9 @@ run_cronic() { # 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. +# files, writes nothing to stderr, and produces a complete 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() { @@ -203,7 +204,9 @@ check_trace_section() { run_cronic "$description" 0 "$@" || return 0 sed -n '/^TRACE OUTPUT:$/,/^$/p' "$work/output" > "$work/trace-section" - if cmp -s "$goal" "$work/trace-section" && [ ! -s "$work/stderr" ]; then + if cmp -s "$goal" "$work/trace-section" \ + && grep -q "^END OF CRONIC OUTPUT.$" "$work/output" \ + && [ ! -s "$work/stderr" ]; then echo "PASS: $description" else echo "FAIL: $description: output:"