From 004c6eeec0f7c69f5269383e9391374c76b2f151 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:02:14 -0700 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4761b5307205da880a9f7b8475f25154e1cd5221 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:30:16 -0700 Subject: [PATCH 4/4] cronic: do not handle verbose; document that the caller should not export it; tests: drop redundant BASH_ENV settings --- cronic | 43 ++++++++++++++++--------------- tests/cronic-test/cronic-test.sh | 44 ++++++++++---------------------- 2 files changed, 35 insertions(+), 52 deletions(-) diff --git a/cronic b/cronic index 059f7bf..1644dcf 100755 --- a/cronic +++ b/cronic @@ -1,9 +1,4 @@ #!/usr/bin/env bash -{ - cronic_caller_flags=${-//[^aeuvx]/} - set +avx - export -n cronic_caller_flags -} 2> /dev/null # Cronic v2 - cron job report wrapper # Copyright 2007 Chuck Houpt @@ -24,16 +19,19 @@ # 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 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. +# 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. @@ -70,6 +68,9 @@ 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 "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 } @@ -184,12 +185,12 @@ 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`, `-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`. +# sets. The subshell restores the caller's `-a`, `-e`, `-u`, and `-x` options, +# replacing this script's, for a builtin and also for a bash script when the +# caller exported SHELLOPTS. It turns off this script's nounset (errexit is off +# already) in the same command that turns on the caller's options, so that only +# the command is traced. The `:+` avoids a bare `-`, which has a special +# meaning to `set`. set +e ( 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 733b144..6d8f9fb 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -137,9 +137,8 @@ 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, "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. +# the report, or "message:TEXT", which requires TEXT to appear in output that +# need not be a report. check() { description="$1" expected_status="$2" @@ -195,15 +194,6 @@ 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 @@ -351,13 +341,12 @@ check "EXIT trap writes to stdout" 0 silent trap 'echo "from the trap"' EXIT check "builtin with an unset variable" 0 silent \ eval ': "$cronic_test_unset_variable"' # When the caller exports SHELLOPTS, a bash script runs with the caller's -# options rather than with `cronic`'s own `set -eu`. BASH_ENV is empty so that -# the script runs no startup file. +# options rather than with `cronic`'s own `set -eu`. # Creates "$work/cronic-with-NAME", which runs `cronic` with SHELLOPTS exported, -# 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`. +# after `set OPTION` if OPTION is given. The wrapper unsets BASH_ENV, so that +# neither `cronic` nor a bash script that it runs reads the user's startup +# file. 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] cat > "$work/cronic-with-$1" << EOF #!/bin/bash @@ -374,22 +363,21 @@ 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. check "bash script with an unset variable, SHELLOPTS exported" 0 silent \ - env BASH_ENV= bash -c ': "$cronic_test_unset_variable"' + bash -c ': "$cronic_test_unset_variable"' check "bash script with a failing command, SHELLOPTS exported" 0 silent \ - env BASH_ENV= bash -c 'false; true' + bash -c 'false; true' CRONIC="$work/cronic-with-nounset" # shellcheck disable=SC2016 # The expansion is for the wrapped command to do. check "bash script with an unset variable, caller's nounset exported" 127 \ "report:cronic_test_unset_variable" \ - env BASH_ENV= bash -c ': "$cronic_test_unset_variable"' + bash -c ': "$cronic_test_unset_variable"' CRONIC="$work/cronic-with-errexit" check "bash script with a failing command, caller's errexit exported" 1 report \ - env BASH_ENV= bash -c 'false; true' + bash -c 'false; true' # When the caller exports allexport, `cronic` does not export its own variables # to the command, but a variable that the command assigns is exported. CRONIC="$work/cronic-with-allexport" @@ -401,9 +389,9 @@ check "command's variable exported, caller's allexport exported" 0 silent \ # When the caller exports xtrace, `cronic` traces only the command, not itself. CRONIC="$work/cronic-with-xtrace" check "bash script, caller's xtrace exported" 0 silent \ - env BASH_ENV= bash -c 'true' + bash -c 'true' check "bash script that fails, caller's xtrace exported" 1 "report:^+ false$" \ - env BASH_ENV= bash -c 'false' + bash -c 'false' if grep -q cronic_ "$work/output"; then echo "FAIL: caller's xtrace exported: the report traces cronic itself:" cat "$work/output" @@ -411,12 +399,6 @@ 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. -CRONIC="$work/cronic-with-verbose" -sed '/^} 2> \/dev\/null$/q' "$saved_cronic" > "$work/expected" -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