From c058c5a85b10750812d5714da10b02ca3be4fd3b Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 10:03:25 -0700 Subject: [PATCH 1/3] Run cronic's wrapped command with the caller's nounset setting When the caller exports SHELLOPTS, cronic's `set -u` was inherited by the wrapped command, so a bash script that it ran (such as texi2dvi, via `makeinfo --pdf`, where /bin/sh is bash) failed on an unset variable. --- cronic | 14 +++++++++++++- tests/cronic-test/cronic-test.sh | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/cronic b/cronic index b26d786..5e49808 100755 --- a/cronic +++ b/cronic @@ -15,6 +15,17 @@ # The options and the treatment of execution traces are documented in function # `cronic_help`; run `cronic --help`. +# Whether the caller treats unset variables as an error. When the caller +# exports SHELLOPTS, this script starts with the caller's options, and the +# wrapped command inherits this script's options, so the wrapped command is run +# with the caller's setting rather than with this script's `set -u`. +# Otherwise, a script that the wrapped command runs, such as a `#!/bin/sh` +# script where `/bin/sh` is bash, would fail on its first unset variable. +case $- in + *u*) cronic_caller_nounset=true ;; + *) cronic_caller_nounset=false ;; +esac + # Exit if a command exits with a non-zero status, and treat unset variables # as an error. set -eu @@ -159,12 +170,13 @@ if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))); fi set +e +[ "$cronic_caller_nounset" = true ] || set +u # `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" cronic_result=$? -set -e +set -eu # Unsetting BASH_XTRACEFD also closes its file descriptor. unset BASH_XTRACEFD diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 7b62509..db51b56 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -374,4 +374,36 @@ check "PS4 with a newline, error line that contains its second line" 0 \ "report:^error: bad b input$" "$work/stderr-lines" "error: bad b input" unset PS4 +# A command that expands an unset variable. +cat > "$work/unset-variable" << 'EOF' +#!/bin/bash +echo "${cronic_test_unset_variable}" +EOF +chmod +x "$work/unset-variable" + +# Callers that export SHELLOPTS, without and with `set -u`. When the caller +# exports SHELLOPTS, the wrapped command gets the caller's `set -u` setting, +# not `cronic`'s. +cat > "$work/caller" << EOF +#!/bin/bash +export SHELLOPTS +exec "$CRONIC" "\$@" +EOF +chmod +x "$work/caller" +cat > "$work/caller-nounset" << EOF +#!/bin/bash +set -u +export SHELLOPTS +exec "$CRONIC" "\$@" +EOF +chmod +x "$work/caller-nounset" +saved_cronic="$CRONIC" +CRONIC="$work/caller" +check "exported SHELLOPTS without nounset, unset variable" 0 silent \ + "$work/unset-variable" +CRONIC="$work/caller-nounset" +check "exported SHELLOPTS with nounset, unset variable" 1 \ + "report:unbound variable" "$work/unset-variable" +CRONIC="$saved_cronic" + exit "$status" From 786d09d39182170535a401a049bb13859af10e1e Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 10:46:18 -0700 Subject: [PATCH 2/3] Run cronic's wrapped command with all of the caller's options, including errexit Save the caller's options with `set +o` and restore them in a subshell that runs the wrapped command. Document the limitation for builtins such as `source` and `eval`. Use one test wrapper script instead of two. --- cronic | 43 ++++++++++++++++++++------------ tests/cronic-test/cronic-test.sh | 20 ++++++--------- 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/cronic b/cronic index 5e49808..9c68ebd 100755 --- a/cronic +++ b/cronic @@ -15,16 +15,22 @@ # The options and the treatment of execution traces are documented in function # `cronic_help`; run `cronic --help`. -# Whether the caller treats unset variables as an error. When the caller -# exports SHELLOPTS, this script starts with the caller's options, and the -# wrapped command inherits this script's options, so the wrapped command is run -# with the caller's setting rather than with this script's `set -u`. -# Otherwise, a script that the wrapped command runs, such as a `#!/bin/sh` -# script where `/bin/sh` is bash, would fail on its first unset variable. -case $- in - *u*) cronic_caller_nounset=true ;; - *) cronic_caller_nounset=false ;; -esac +# The caller's options, as commands that restore them. When the caller exports +# SHELLOPTS, this script starts with the caller's options, and the wrapped +# command inherits this script's options, so this script restores the caller's +# options while it runs the wrapped command. Otherwise, any bash script that +# the wrapped command runs, such as a `#!/bin/bash` script, would get this +# script's `set -eu` rather than the caller's options and, for example, fail on +# its first unset variable. +# Limitation: if the wrapped command is a builtin that runs code in this +# script's shell, such as `source` or `eval`, that code also 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. +# Not `$(set +o)`, because a command substitution, unlike a process +# substitution, does not inherit `set -e`. `read` exits with status 1 at the +# end of its input. +read -r -d '' cronic_caller_opts < <(set +o) || true # Exit if a command exits with a non-zero status, and treat unset variables # as an error. @@ -169,14 +175,19 @@ if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))); export BASH_XTRACEFD=$cronic_xtrace_fd fi +# The wrapped command runs in a subshell with the caller's options. It is not +# the operand of `||` or the like, because then bash would ignore `set -e` and +# would remove "errexit" from the SHELLOPTS that the wrapped command inherits. set +e -[ "$cronic_caller_nounset" = true ] || set +u -# `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" +( + eval "$cronic_caller_opts" + # `command` because the wrapped command runs in a subshell of 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" cronic_result=$? -set -eu +set -e # Unsetting BASH_XTRACEFD also closes its file descriptor. unset BASH_XTRACEFD diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index db51b56..dcd1a9b 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -381,29 +381,23 @@ echo "${cronic_test_unset_variable}" EOF chmod +x "$work/unset-variable" -# Callers that export SHELLOPTS, without and with `set -u`. When the caller -# exports SHELLOPTS, the wrapped command gets the caller's `set -u` setting, -# not `cronic`'s. +# A caller that sets the option given as its first argument, such as `-u` or +# `+u`, and exports SHELLOPTS. When the caller exports SHELLOPTS, the wrapped +# command gets the caller's `set -u` setting, not `cronic`'s. cat > "$work/caller" << EOF #!/bin/bash +set "\$1" +shift export SHELLOPTS exec "$CRONIC" "\$@" EOF chmod +x "$work/caller" -cat > "$work/caller-nounset" << EOF -#!/bin/bash -set -u -export SHELLOPTS -exec "$CRONIC" "\$@" -EOF -chmod +x "$work/caller-nounset" saved_cronic="$CRONIC" CRONIC="$work/caller" check "exported SHELLOPTS without nounset, unset variable" 0 silent \ - "$work/unset-variable" -CRONIC="$work/caller-nounset" + +u "$work/unset-variable" check "exported SHELLOPTS with nounset, unset variable" 1 \ - "report:unbound variable" "$work/unset-variable" + "report:unbound variable" -u "$work/unset-variable" CRONIC="$saved_cronic" exit "$status" From 85f61c4a6ed6d53aebce1d6b3659d75e062f9787 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 12:12:09 -0700 Subject: [PATCH 3/3] Brevity --- cronic | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/cronic b/cronic index 9c68ebd..3c92872 100755 --- a/cronic +++ b/cronic @@ -15,13 +15,11 @@ # The options and the treatment of execution traces are documented in function # `cronic_help`; run `cronic --help`. -# The caller's options, as commands that restore them. When the caller exports -# SHELLOPTS, this script starts with the caller's options, and the wrapped -# command inherits this script's options, so this script restores the caller's -# options while it runs the wrapped command. Otherwise, any bash script that -# the wrapped command runs, such as a `#!/bin/bash` script, would get this -# script's `set -eu` rather than the caller's options and, for example, fail on -# its first unset variable. +# The caller's options, as commands that restore them. This script restores +# the caller's options while it runs the wrapped command. Otherwise, any bash +# script that the wrapped command runs, such as a `#!/bin/bash` script, would +# get this script's `set -eu` rather than the caller's options and, for example, +# fail on its first unset variable. # Limitation: if the wrapped command is a builtin that runs code in this # script's shell, such as `source` or `eval`, that code also runs with the # caller's options, even if the caller does not export SHELLOPTS. So if the