From 0347a303f610f22e72f5c91bcbebc95593c63086 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 10:03:59 -0700 Subject: [PATCH 1/5] 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 b170cb7e135d4d2825900f0e21593a43952d43f0 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 10:14:29 -0700 Subject: [PATCH 2/5] 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 3/5] 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 4/5] 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 ca053e252ca52cc377e702930a2475d44a6d63ae Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 22:46:04 -0700 Subject: [PATCH 5/5] cronic: do not trace cronic's own commands; test allexport and xtrace Turn off verbose and xtrace for cronic itself, and restore them for the command, so that the report's TRACE OUTPUT is the command's. Discard the traces of cronic's first two commands, so that cronic is silent when the command succeeds. Explain why `$(set +o)` cannot save the caller's options. --- cronic | 37 +++++++++++++++++++++----------- tests/cronic-test/cronic-test.sh | 15 +++++++++++++ 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/cronic b/cronic index 80d7b0d..418271c 100755 --- a/cronic +++ b/cronic @@ -19,15 +19,24 @@ # Save the caller's shell options, as the letters that `$-` uses for them, # before this script changes them for itself. This script changes only -# allexport, errexit, 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=${-//[^aeu]/} - -# 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 +# allexport, errexit, nounset, verbose, and xtrace, so the command inherits the +# caller's other `set -o` options, and its `shopt` options, without any +# restoring; an option that this script changes must be added to the pattern. +# `$(set +o)` would save every `set -o` option, but bash turns off verbose in a +# command substitution, so `set +o` there would report verbose as off. +# +# Then, exit if a command exits with a non-zero status, and treat unset +# variables as an error. Do not export this script's variables to the command, +# even if the caller's exported SHELLOPTS contains allexport. Do not trace this +# script's own commands, even if the caller's exported SHELLOPTS contains +# verbose or xtrace, so that the report's "TRACE OUTPUT" is the command's. +# The traces of these two commands are discarded, so that this script produces +# no output when the command succeeds. (The lines that verbose prints before +# this point cannot be discarded.) +{ + cronic_caller_flags=${-//[^aeuvx]/} + set -eu +a +vx +} 2> /dev/null export -n cronic_caller_flags # Prints the usage line. @@ -178,10 +187,12 @@ 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 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. +# on the caller's options, so that only the `command` line is traced. The `:+` +# avoids a bare `-`, which has a special meaning to `set`. The redirections are +# on the command rather than on the subshell, so that the trace of the `command` +# line does not go to the command's standard error; under bash 4.1 or later, +# it goes to the trace file, and under an older bash, to this script's +# standard error. # The subshell is not the operand of `||` or the like, because then bash would # ignore `set -e` in it and would remove "errexit" from the SHELLOPTS that the # command inherits. diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index f35777f..30f21bc 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -315,6 +315,21 @@ 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" bash -c 'false; :' +# shellcheck disable=SC2016 # The expansion is for the command to do. +check "exported SHELLOPTS with allexport, command's environment" 0 silent \ + "$default_shellopts:allexport" bash -c \ + 'case $SHELLOPTS in *allexport*) ;; *) exit 2 ;; esac + ! printenv cronic_caller_flags && ! printenv cronic_out' +check "exported SHELLOPTS with xtrace, trace-only stderr" 0 silent \ + "$default_shellopts:xtrace" "$work/read-unset" 0 +check "exported SHELLOPTS with xtrace, trace in the report" 3 \ + "report:^+ exit 3$" "$default_shellopts:xtrace" bash -c 'exit 3' +# The trace in the report is the command's, not `cronic`'s own. +if grep -q -e '^+ set ' -e '^+ cronic_' "$work/output"; then + echo "FAIL: exported SHELLOPTS with xtrace, cronic's own commands are traced:" + cat "$work/output" + status=1 +fi CRONIC="$REAL_CRONIC" # The wrapped command sees the caller's exported variables unchanged. For