From b177e200efa694730037f2718a2f21e8fec31b0d Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 10:58:18 -0700 Subject: [PATCH 01/11] Run the command in a subshell, so that `exit` or `exec` cannot prevent the report --- cronic | 11 +++++++---- tests/cronic-test/cronic-test.sh | 6 ++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/cronic b/cronic index b26d786..c27cdc9 100755 --- a/cronic +++ b/cronic @@ -158,11 +158,14 @@ if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))); export BASH_XTRACEFD=$cronic_xtrace_fd 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. 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" +( + 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..8db51ca 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -262,6 +262,12 @@ 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 + # 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 743a005f8a70057cae25a5a3f3ed26c0fdf42ef0 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 12:22:16 -0700 Subject: [PATCH 02/11] Capture the subshell's output, and hide cronic's state and `set -u` from the command --- cronic | 20 +++++++++++++++----- tests/cronic-test/cronic-test.sh | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/cronic b/cronic index c27cdc9..37bd49a 100755 --- a/cronic +++ b/cronic @@ -159,13 +159,23 @@ if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))); 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. +# such as `exit` or `exec` cannot end this script before it reports. The +# redirections apply to the whole subshell, so that they also capture what the +# subshell writes after the command finishes, such as the output of an EXIT +# trap that the command sets. Within the subshell, the command sees neither +# this script's functions nor its variables, and runs with `set +u`, as it +# would in an ordinary shell. Removing the functions also means that a +# function such as `cronic_usage` cannot run in place of a program of the same +# name. set +e ( - command -- "$@" > "$cronic_out" 2> "$cronic_trace" -) + # shellcheck disable=SC2046 # each name is a separate argument. + unset -f $(compgen -A function cronic_) + # shellcheck disable=SC2046 # each name is a separate argument. + unset -v $(compgen -v cronic_) + set +u + "$@" +) > "$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 8db51ca..9fab00b 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -267,6 +267,21 @@ 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 +# Output that the subshell writes after the command finishes, such as from an +# EXIT trap that the command sets, is captured rather than leaked. +check "EXIT trap writes to stderr" 0 "report:^from the trap$" \ + trap 'echo "from the trap" >&2' EXIT +check "EXIT trap writes to stdout" 0 silent trap 'echo "from the trap"' EXIT +# A builtin runs with `set +u`, as it would in an ordinary shell. +# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. +check "builtin with an unset variable" 0 silent \ + eval ': "$cronic_test_unset_variable"' +# A builtin cannot see or change `cronic`'s variables or functions. +# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. +check "builtin cannot see cronic's variables" 0 silent \ + eval '[ -z "${cronic_tmpdir+set}" ]' +check "builtin cannot see cronic's functions" 1 report \ + eval 'declare -F cronic_cleanup' # The wrapped command sees the caller's exported variables unchanged. For # each variable that `cronic` assigns, the caller exports that name in From ea67077f9ded46d6ba53933871170d90e00106c6 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 12:54:16 -0700 Subject: [PATCH 03/11] Run the command in a fresh bash process, so that it sees none of cronic's state The BASH_ENV file does not run again for the command, but the command sees the caller's BASH_ENV. --- cronic | 40 +++++++++++++++++------------- tests/cronic-test/cronic-test.sh | 42 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 17 deletions(-) diff --git a/cronic b/cronic index 4e79158..c780470 100755 --- a/cronic +++ b/cronic @@ -160,24 +160,30 @@ if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))); export BASH_XTRACEFD=$cronic_xtrace_fd fi -# The command runs in a subshell rather than in this shell, so that a builtin -# such as `exit` or `exec` cannot end this script before it reports. The -# redirections apply to the whole subshell, so that they also capture what the -# subshell writes after the command finishes, such as the output of an EXIT -# trap that the command sets. Within the subshell, the command sees neither -# this script's functions nor its variables, and runs with `set +u`, as it -# would in an ordinary shell. Removing the functions also means that a -# function such as `cronic_usage` cannot run in place of a program of the same -# name. +# The command runs in a fresh bash process rather than in this shell, so that +# a builtin such as `exit` or `exec` cannot end this script before it reports, +# and so that the command sees none of this script's state: not its functions, +# its unexported variables, its positional parameters, or its `set -eu`. The +# redirections apply to the whole process, 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 command is passed as a string of quoted words rather +# than as positional parameters, so that a builtin such as `eval` sees no +# positional parameters. The leading `''` quotes the first word, so that it is +# never read as a reserved word such as `time` or `if`, nor as an assignment. +cronic_command="''$(printf '%q ' "$@")" +# A non-interactive bash runs the file named by BASH_ENV when it starts. This +# script already ran that file, so BASH_ENV is empty while the fresh bash +# starts, and the command string then restores it. Otherwise the file would +# run twice, and its output would be reported as the command's. This script +# has BASH_ENV only if the caller exported it, so the restored value is still +# exported. +if [ -n "${BASH_ENV+set}" ]; then + cronic_command="BASH_ENV=$(printf '%q' "$BASH_ENV"); $cronic_command" +else + cronic_command="unset BASH_ENV; $cronic_command" +fi set +e -( - # shellcheck disable=SC2046 # each name is a separate argument. - unset -f $(compgen -A function cronic_) - # shellcheck disable=SC2046 # each name is a separate argument. - unset -v $(compgen -v cronic_) - set +u - "$@" -) > "$cronic_out" 2> "$cronic_trace" +BASH_ENV='' "$BASH" -c "$cronic_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 2bae4f0..9004861 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -283,6 +283,48 @@ check "builtin cannot see cronic's variables" 0 silent \ eval '[ -z "${cronic_tmpdir+set}" ]' check "builtin cannot see cronic's functions" 1 report \ eval 'declare -F cronic_cleanup' +# A builtin sees neither `cronic`'s positional parameters nor its regular +# expression matches, which `--expected-status` produces. +# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. +check "builtin cannot see cronic's positional parameters" 0 silent \ + eval '[ $# -eq 0 ]' +# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. +check "builtin cannot see cronic's regular expression matches" 3 silent \ + --expected-status 3 eval '[ -z "${BASH_REMATCH[0]-}" ] && exit 3' +# The file named by BASH_ENV does not run again for the command, but the +# command sees the caller's BASH_ENV, or none if the caller set none. +# `cronic` itself runs the file, so the file writes nothing when `cronic` does. +cat > "$work/bash-env" << 'EOF' +case $0 in + */cronic) ;; + *) echo "from BASH_ENV" >&2 ;; +esac +EOF +BASH_ENV="$work/bash-env" +export BASH_ENV +check "BASH_ENV file does not run for the command" 0 silent true +# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. +check "command sees the caller's BASH_ENV" 0 silent \ + sh -c '[ "$BASH_ENV" = "$1" ]' sh "$BASH_ENV" +unset BASH_ENV +# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. +check "command sees no BASH_ENV if the caller set none" 0 silent \ + sh -c '[ -z "${BASH_ENV+set}" ]' +# A first word that is a reserved word or an assignment is a command name. +mkdir "$work/reserved" +for name in time if FOO=bar; do + cat > "$work/reserved/$name" << 'EOF' +#!/bin/sh +exit 3 +EOF + chmod +x "$work/reserved/$name" +done +saved_path="$PATH" +PATH="$work/reserved:$PATH" +for name in time if FOO=bar; do + check "command named $name" 3 silent --expected-status 3 "$name" +done +PATH="$saved_path" # The wrapped command sees the caller's exported variables unchanged. For # each variable that `cronic` assigns, the caller exports that name in From e8f6f120ed094f19aadd2d361f24d8120a2b0936 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 13:14:39 -0700 Subject: [PATCH 04/11] Hide cronic's `set -u` from the command even when the caller exports SHELLOPTS; document that the command sees exported functions --- cronic | 8 ++++++-- tests/cronic-test/cronic-test.sh | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/cronic b/cronic index c780470..f179749 100755 --- a/cronic +++ b/cronic @@ -164,6 +164,8 @@ fi # a builtin such as `exit` or `exec` cannot end this script before it reports, # and so that the command sees none of this script's state: not its functions, # its unexported variables, its positional parameters, or its `set -eu`. The +command does see the caller's environment, including any functions that the +caller exported, just as it would if the caller ran it directly. The # redirections apply to the whole process, 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 command is passed as a string of quoted words rather @@ -182,10 +184,12 @@ if [ -n "${BASH_ENV+set}" ]; then else cronic_command="unset BASH_ENV; $cronic_command" fi -set +e +# `set +u` matters even though the command runs in a fresh bash: if the +# caller exported SHELLOPTS, then the fresh bash inherits this script's options. +set +eu BASH_ENV='' "$BASH" -c "$cronic_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 9004861..b59d774 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -277,6 +277,20 @@ check "EXIT trap writes to stdout" 0 silent trap 'echo "from the trap"' EXIT # shellcheck disable=SC2016 # The expansion is for the wrapped command to do. check "builtin with an unset variable" 0 silent \ eval ': "$cronic_test_unset_variable"' +# The same holds when the caller exports SHELLOPTS, which would otherwise pass +# `cronic`'s own `set -u` to the command. +cat > "$work/cronic-with-shellopts" << EOF +#!/bin/bash +export SHELLOPTS +exec "$CRONIC" "\$@" +EOF +chmod +x "$work/cronic-with-shellopts" +saved_cronic="$CRONIC" +CRONIC="$work/cronic-with-shellopts" +# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. +check "builtin with an unset variable, SHELLOPTS exported" 0 silent \ + eval ': "$cronic_test_unset_variable"' +CRONIC="$saved_cronic" # A builtin cannot see or change `cronic`'s variables or functions. # shellcheck disable=SC2016 # The expansion is for the wrapped command to do. check "builtin cannot see cronic's variables" 0 silent \ From 85424653f65b85746db7a70831ad4c13871837a3 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 19:01:25 -0700 Subject: [PATCH 05/11] Fix uncommented lines; give the command cronic's $0; keep cronic's own trace out of the report when the caller exports xtrace --- cronic | 27 ++++++++++++++++++++++++--- tests/cronic-test/cronic-test.sh | 25 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/cronic b/cronic index f179749..c94e966 100755 --- a/cronic +++ b/cronic @@ -17,6 +17,20 @@ # 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. +# If the caller exported SHELLOPTS with xtrace on, then this script would +# trace its own commands, and that trace would be reported as the command's. +# So this script turns xtrace off, and turns it back on for the command. +case $- in + *x*) case $(declare -p SHELLOPTS) in + 'declare -'*x*) cronic_inherited_xtrace=true ;; + *) cronic_inherited_xtrace=false ;; + esac ;; + *) cronic_inherited_xtrace=false ;; +esac +if $cronic_inherited_xtrace; then + set +x +fi + # Exit if a command exits with a non-zero status, and treat unset variables # as an error. set -eu @@ -164,15 +178,22 @@ fi # a builtin such as `exit` or `exec` cannot end this script before it reports, # and so that the command sees none of this script's state: not its functions, # its unexported variables, its positional parameters, or its `set -eu`. The -command does see the caller's environment, including any functions that the -caller exported, just as it would if the caller ran it directly. The +# command does see the caller's environment, including any functions that the +# caller exported. If the caller exported SHELLOPTS, then the command inherits +# the caller's options, except that errexit and nounset are off. The # redirections apply to the whole process, 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 command is passed as a string of quoted words rather # than as positional parameters, so that a builtin such as `eval` sees no # positional parameters. The leading `''` quotes the first word, so that it is # never read as a reserved word such as `time` or `if`, nor as an assignment. +# The fresh bash's `$0` is this script's, so that its error messages name this +# script rather than bash. cronic_command="''$(printf '%q ' "$@")" +# Bash does not trace `set -x` itself, so the trace starts with the command. +if $cronic_inherited_xtrace; then + cronic_command="set -x; $cronic_command" +fi # A non-interactive bash runs the file named by BASH_ENV when it starts. This # script already ran that file, so BASH_ENV is empty while the fresh bash # starts, and the command string then restores it. Otherwise the file would @@ -187,7 +208,7 @@ fi # `set +u` matters even though the command runs in a fresh bash: if the # caller exported SHELLOPTS, then the fresh bash inherits this script's options. set +eu -BASH_ENV='' "$BASH" -c "$cronic_command" > "$cronic_out" 2> "$cronic_trace" +BASH_ENV='' "$BASH" -c "$cronic_command" "$0" > "$cronic_out" 2> "$cronic_trace" cronic_result=$? set -eu diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index b59d774..db9973c 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -291,6 +291,31 @@ CRONIC="$work/cronic-with-shellopts" check "builtin with an unset variable, SHELLOPTS exported" 0 silent \ eval ': "$cronic_test_unset_variable"' CRONIC="$saved_cronic" +# When the caller exports SHELLOPTS with xtrace on, the trace in the report +# shows only what the command runs, and not what `cronic` runs. The wrapper +# discards its own stderr, which holds its own trace. +cat > "$work/cronic-with-xtrace" << EOF +#!/bin/bash +exec 2> /dev/null +set -x +export SHELLOPTS +exec "$CRONIC" "\$@" +EOF +chmod +x "$work/cronic-with-xtrace" +CRONIC="$work/cronic-with-xtrace" +check "SHELLOPTS exported with xtrace on" 1 report false +CRONIC="$saved_cronic" +if [ "$(grep '^+' "$work/output")" != "+ false" ]; then + echo "FAIL: SHELLOPTS exported with xtrace on: the trace is not just \"+ false\":" + cat "$work/output" + status=1 +fi +# The command's `$0` is `cronic`'s, so that error messages name `cronic`. +# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. +check "command's \$0 is cronic's" 0 silent \ + eval 'case $0 in */cronic) ;; *) exit 1 ;; esac' +check "command not found names cronic" 127 "report:cronic: line 1: cronic-test-no-such-command: command not found" \ + cronic-test-no-such-command # A builtin cannot see or change `cronic`'s variables or functions. # shellcheck disable=SC2016 # The expansion is for the wrapped command to do. check "builtin cannot see cronic's variables" 0 silent \ From a7dadc2959725be19ff33cd12e7b0cb8ca028f13 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 19:19:27 -0700 Subject: [PATCH 06/11] Keep exported xtrace and verbose out of stderr and the report; detect an exported SHELLOPTS by its attributes only Co-Authored-By: Claude --- cronic | 27 +++++++++++--------- tests/cronic-test/cronic-test.sh | 42 ++++++++++++++++++++++++++------ 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/cronic b/cronic index c94e966..066855d 100755 --- a/cronic +++ b/cronic @@ -1,4 +1,5 @@ #!/bin/bash +{ set +v; } 2> /dev/null # If the caller exported SHELLOPTS with verbose on, echo no more of this script. # Cronic v2 - cron job report wrapper # Copyright 2007 Chuck Houpt @@ -19,17 +20,19 @@ # If the caller exported SHELLOPTS with xtrace on, then this script would # trace its own commands, and that trace would be reported as the command's. -# So this script turns xtrace off, and turns it back on for the command. -case $- in - *x*) case $(declare -p SHELLOPTS) in - 'declare -'*x*) cronic_inherited_xtrace=true ;; - *) cronic_inherited_xtrace=false ;; - esac ;; - *) cronic_inherited_xtrace=false ;; -esac -if $cronic_inherited_xtrace; then - set +x -fi +# So this script turns xtrace off, and turns it back on for the command. The +# trace of this test goes to /dev/null rather than to the caller's stderr. The +# regular expression matches the attributes of SHELLOPTS, such as "-rx", and +# not its value, which contains "xtrace". When xtrace is on but SHELLOPTS is +# not exported, as under `bash -x cronic`, this script is traced as usual. +{ + if [[ $- == *x* && $(declare -p SHELLOPTS) =~ ^declare\ -[a-z]*x ]]; then + cronic_inherited_xtrace=true + set +x + else + cronic_inherited_xtrace=false + fi +} 2> /dev/null # Exit if a command exits with a non-zero status, and treat unset variables # as an error. @@ -180,7 +183,7 @@ fi # its unexported variables, its positional parameters, or its `set -eu`. The # command does see the caller's environment, including any functions that the # caller exported. If the caller exported SHELLOPTS, then the command inherits -# the caller's options, except that errexit and nounset are off. The +# the caller's options, except that errexit, nounset, and verbose are off. The # redirections apply to the whole process, 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 command is passed as a string of quoted words rather diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index db9973c..1377a93 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -292,17 +292,18 @@ check "builtin with an unset variable, SHELLOPTS exported" 0 silent \ eval ': "$cronic_test_unset_variable"' CRONIC="$saved_cronic" # When the caller exports SHELLOPTS with xtrace on, the trace in the report -# shows only what the command runs, and not what `cronic` runs. The wrapper -# discards its own stderr, which holds its own trace. +# shows only what the command runs, and not what `cronic` runs, and `cronic` +# writes nothing to stderr when the command succeeds. Bash reads SHELLOPTS +# from the environment when it starts. The wrapper unsets BASH_ENV, because +# bash traces the file that BASH_ENV names before `cronic` can turn xtrace off. cat > "$work/cronic-with-xtrace" << EOF -#!/bin/bash -exec 2> /dev/null -set -x -export SHELLOPTS -exec "$CRONIC" "\$@" +#!/bin/sh +unset BASH_ENV +SHELLOPTS=xtrace exec "$CRONIC" "\$@" EOF chmod +x "$work/cronic-with-xtrace" CRONIC="$work/cronic-with-xtrace" +check "SHELLOPTS exported with xtrace on, success" 0 silent true check "SHELLOPTS exported with xtrace on" 1 report false CRONIC="$saved_cronic" if [ "$(grep '^+' "$work/output")" != "+ false" ]; then @@ -310,6 +311,33 @@ if [ "$(grep '^+' "$work/output")" != "+ false" ]; then cat "$work/output" status=1 fi +# When the caller exports SHELLOPTS with verbose on, the command does not echo +# its input to stderr, which would be reported as error output. Bash echoes +# the first lines of `cronic` before `cronic` turns verbose off, so the output +# is not empty. +cat > "$work/cronic-with-verbose" << EOF +#!/bin/sh +unset BASH_ENV +SHELLOPTS=verbose exec "$CRONIC" "\$@" +EOF +chmod +x "$work/cronic-with-verbose" +CRONIC="$work/cronic-with-verbose" +check "SHELLOPTS exported with verbose on" 0 "message:^#!/bin/bash$" true +CRONIC="$saved_cronic" +if grep -q "^END OF CRONIC OUTPUT.$" "$work/output"; then + echo "FAIL: SHELLOPTS exported with verbose on: unexpected report:" + cat "$work/output" + status=1 +fi +# When xtrace is on but SHELLOPTS is not exported, `cronic` itself is traced. +cat > "$work/cronic-under-bash-x" << EOF +#!/bin/sh +exec bash -x "$CRONIC" "\$@" +EOF +chmod +x "$work/cronic-under-bash-x" +CRONIC="$work/cronic-under-bash-x" +check "bash -x cronic" 0 "message:^+ set -eu$" true +CRONIC="$saved_cronic" # The command's `$0` is `cronic`'s, so that error messages name `cronic`. # shellcheck disable=SC2016 # The expansion is for the wrapped command to do. check "command's \$0 is cronic's" 0 silent \ From 4cb849cd66f642a1f03e854378c605ebf6374ffe Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 19:23:16 -0700 Subject: [PATCH 07/11] Simplify: run the command in a subshell rather than a fresh bash, redirecting the whole subshell and turning off `set -u` in it Co-Authored-By: Claude --- cronic | 67 ++++--------------- tests/cronic-test/cronic-test.sh | 110 ++----------------------------- 2 files changed, 19 insertions(+), 158 deletions(-) diff --git a/cronic b/cronic index 066855d..a3d5949 100755 --- a/cronic +++ b/cronic @@ -1,5 +1,4 @@ #!/bin/bash -{ set +v; } 2> /dev/null # If the caller exported SHELLOPTS with verbose on, echo no more of this script. # Cronic v2 - cron job report wrapper # Copyright 2007 Chuck Houpt @@ -18,22 +17,6 @@ # 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. -# If the caller exported SHELLOPTS with xtrace on, then this script would -# trace its own commands, and that trace would be reported as the command's. -# So this script turns xtrace off, and turns it back on for the command. The -# trace of this test goes to /dev/null rather than to the caller's stderr. The -# regular expression matches the attributes of SHELLOPTS, such as "-rx", and -# not its value, which contains "xtrace". When xtrace is on but SHELLOPTS is -# not exported, as under `bash -x cronic`, this script is traced as usual. -{ - if [[ $- == *x* && $(declare -p SHELLOPTS) =~ ^declare\ -[a-z]*x ]]; then - cronic_inherited_xtrace=true - set +x - else - cronic_inherited_xtrace=false - fi -} 2> /dev/null - # Exit if a command exits with a non-zero status, and treat unset variables # as an error. set -eu @@ -177,43 +160,21 @@ if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))); export BASH_XTRACEFD=$cronic_xtrace_fd fi -# The command runs in a fresh bash process rather than in this shell, so that -# a builtin such as `exit` or `exec` cannot end this script before it reports, -# and so that the command sees none of this script's state: not its functions, -# its unexported variables, its positional parameters, or its `set -eu`. The -# command does see the caller's environment, including any functions that the -# caller exported. If the caller exported SHELLOPTS, then the command inherits -# the caller's options, except that errexit, nounset, and verbose are off. The -# redirections apply to the whole process, 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 command is passed as a string of quoted words rather -# than as positional parameters, so that a builtin such as `eval` sees no -# positional parameters. The leading `''` quotes the first word, so that it is -# never read as a reserved word such as `time` or `if`, nor as an assignment. -# The fresh bash's `$0` is this script's, so that its error messages name this -# script rather than bash. -cronic_command="''$(printf '%q ' "$@")" -# Bash does not trace `set -x` itself, so the trace starts with the command. -if $cronic_inherited_xtrace; then - cronic_command="set -x; $cronic_command" -fi -# A non-interactive bash runs the file named by BASH_ENV when it starts. This -# script already ran that file, so BASH_ENV is empty while the fresh bash -# starts, and the command string then restores it. Otherwise the file would -# run twice, and its output would be reported as the command's. This script -# has BASH_ENV only if the caller exported it, so the restored value is still -# exported. -if [ -n "${BASH_ENV+set}" ]; then - cronic_command="BASH_ENV=$(printf '%q' "$BASH_ENV"); $cronic_command" -else - cronic_command="unset BASH_ENV; $cronic_command" -fi -# `set +u` matters even though the command runs in a fresh bash: if the -# caller exported SHELLOPTS, then the fresh bash inherits this script's options. -set +eu -BASH_ENV='' "$BASH" -c "$cronic_command" "$0" > "$cronic_out" 2> "$cronic_trace" +# 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 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. `set +u` hides this script's `set -u` from a builtin, and also from a +# bash script when the caller exported SHELLOPTS. +set +e +( + set +u + 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 1377a93..020b36e 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -277,8 +277,9 @@ check "EXIT trap writes to stdout" 0 silent trap 'echo "from the trap"' EXIT # shellcheck disable=SC2016 # The expansion is for the wrapped command to do. check "builtin with an unset variable" 0 silent \ eval ': "$cronic_test_unset_variable"' -# The same holds when the caller exports SHELLOPTS, which would otherwise pass -# `cronic`'s own `set -u` to the command. +# A bash script runs with `set +u` even when the caller exports SHELLOPTS, +# which would otherwise pass `cronic`'s own `set -u` to the script. BASH_ENV +# is empty so that the script runs no startup file. cat > "$work/cronic-with-shellopts" << EOF #!/bin/bash export SHELLOPTS @@ -288,110 +289,9 @@ chmod +x "$work/cronic-with-shellopts" saved_cronic="$CRONIC" CRONIC="$work/cronic-with-shellopts" # shellcheck disable=SC2016 # The expansion is for the wrapped command to do. -check "builtin with an unset variable, SHELLOPTS exported" 0 silent \ - eval ': "$cronic_test_unset_variable"' -CRONIC="$saved_cronic" -# When the caller exports SHELLOPTS with xtrace on, the trace in the report -# shows only what the command runs, and not what `cronic` runs, and `cronic` -# writes nothing to stderr when the command succeeds. Bash reads SHELLOPTS -# from the environment when it starts. The wrapper unsets BASH_ENV, because -# bash traces the file that BASH_ENV names before `cronic` can turn xtrace off. -cat > "$work/cronic-with-xtrace" << EOF -#!/bin/sh -unset BASH_ENV -SHELLOPTS=xtrace exec "$CRONIC" "\$@" -EOF -chmod +x "$work/cronic-with-xtrace" -CRONIC="$work/cronic-with-xtrace" -check "SHELLOPTS exported with xtrace on, success" 0 silent true -check "SHELLOPTS exported with xtrace on" 1 report false +check "bash script with an unset variable, SHELLOPTS exported" 0 silent \ + env BASH_ENV= bash -c ': "$cronic_test_unset_variable"' CRONIC="$saved_cronic" -if [ "$(grep '^+' "$work/output")" != "+ false" ]; then - echo "FAIL: SHELLOPTS exported with xtrace on: the trace is not just \"+ false\":" - cat "$work/output" - status=1 -fi -# When the caller exports SHELLOPTS with verbose on, the command does not echo -# its input to stderr, which would be reported as error output. Bash echoes -# the first lines of `cronic` before `cronic` turns verbose off, so the output -# is not empty. -cat > "$work/cronic-with-verbose" << EOF -#!/bin/sh -unset BASH_ENV -SHELLOPTS=verbose exec "$CRONIC" "\$@" -EOF -chmod +x "$work/cronic-with-verbose" -CRONIC="$work/cronic-with-verbose" -check "SHELLOPTS exported with verbose on" 0 "message:^#!/bin/bash$" true -CRONIC="$saved_cronic" -if grep -q "^END OF CRONIC OUTPUT.$" "$work/output"; then - echo "FAIL: SHELLOPTS exported with verbose on: unexpected report:" - cat "$work/output" - status=1 -fi -# When xtrace is on but SHELLOPTS is not exported, `cronic` itself is traced. -cat > "$work/cronic-under-bash-x" << EOF -#!/bin/sh -exec bash -x "$CRONIC" "\$@" -EOF -chmod +x "$work/cronic-under-bash-x" -CRONIC="$work/cronic-under-bash-x" -check "bash -x cronic" 0 "message:^+ set -eu$" true -CRONIC="$saved_cronic" -# The command's `$0` is `cronic`'s, so that error messages name `cronic`. -# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. -check "command's \$0 is cronic's" 0 silent \ - eval 'case $0 in */cronic) ;; *) exit 1 ;; esac' -check "command not found names cronic" 127 "report:cronic: line 1: cronic-test-no-such-command: command not found" \ - cronic-test-no-such-command -# A builtin cannot see or change `cronic`'s variables or functions. -# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. -check "builtin cannot see cronic's variables" 0 silent \ - eval '[ -z "${cronic_tmpdir+set}" ]' -check "builtin cannot see cronic's functions" 1 report \ - eval 'declare -F cronic_cleanup' -# A builtin sees neither `cronic`'s positional parameters nor its regular -# expression matches, which `--expected-status` produces. -# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. -check "builtin cannot see cronic's positional parameters" 0 silent \ - eval '[ $# -eq 0 ]' -# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. -check "builtin cannot see cronic's regular expression matches" 3 silent \ - --expected-status 3 eval '[ -z "${BASH_REMATCH[0]-}" ] && exit 3' -# The file named by BASH_ENV does not run again for the command, but the -# command sees the caller's BASH_ENV, or none if the caller set none. -# `cronic` itself runs the file, so the file writes nothing when `cronic` does. -cat > "$work/bash-env" << 'EOF' -case $0 in - */cronic) ;; - *) echo "from BASH_ENV" >&2 ;; -esac -EOF -BASH_ENV="$work/bash-env" -export BASH_ENV -check "BASH_ENV file does not run for the command" 0 silent true -# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. -check "command sees the caller's BASH_ENV" 0 silent \ - sh -c '[ "$BASH_ENV" = "$1" ]' sh "$BASH_ENV" -unset BASH_ENV -# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. -check "command sees no BASH_ENV if the caller set none" 0 silent \ - sh -c '[ -z "${BASH_ENV+set}" ]' -# A first word that is a reserved word or an assignment is a command name. -mkdir "$work/reserved" -for name in time if FOO=bar; do - cat > "$work/reserved/$name" << 'EOF' -#!/bin/sh -exit 3 -EOF - chmod +x "$work/reserved/$name" -done -saved_path="$PATH" -PATH="$work/reserved:$PATH" -for name in time if FOO=bar; do - check "command named $name" 3 silent --expected-status 3 "$name" -done -PATH="$saved_path" # The wrapped command sees the caller's exported variables unchanged. For # each variable that `cronic` assigns, the caller exports that name in From 8d27734d406420fe63ced453776358d94cd4f456 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 19:33:35 -0700 Subject: [PATCH 08/11] Run the command with the caller's -e and -u options rather than cronic's --- cronic | 11 +++++++++-- tests/cronic-test/cronic-test.sh | 31 +++++++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/cronic b/cronic index a3d5949..693c049 100755 --- a/cronic +++ b/cronic @@ -17,6 +17,10 @@ # 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 caller's shell options, which this script restores for the command. +# They include any that the caller passed by exporting SHELLOPTS. +cronic_caller_flags=$- + # Exit if a command exits with a non-zero status, and treat unset variables # as an error. set -eu @@ -166,11 +170,14 @@ 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. `set +u` hides this script's `set -u` from a builtin, and also from a -# bash script when the caller exported SHELLOPTS. +# sets. The subshell restores the caller's `-e` and `-u` options, replacing +# this script's, for a builtin and also for a bash script when the caller +# exported SHELLOPTS. set +e ( set +u + case $cronic_caller_flags in *e*) set -e ;; esac + case $cronic_caller_flags in *u*) set -u ;; esac 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 020b36e..ed3e01b 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -277,20 +277,43 @@ check "EXIT trap writes to stdout" 0 silent trap 'echo "from the trap"' EXIT # shellcheck disable=SC2016 # The expansion is for the wrapped command to do. check "builtin with an unset variable" 0 silent \ eval ': "$cronic_test_unset_variable"' -# A bash script runs with `set +u` even when the caller exports SHELLOPTS, -# which would otherwise pass `cronic`'s own `set -u` to the script. BASH_ENV -# is empty so that the script runs no startup file. +# 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. cat > "$work/cronic-with-shellopts" << EOF #!/bin/bash export SHELLOPTS exec "$CRONIC" "\$@" EOF -chmod +x "$work/cronic-with-shellopts" +cat > "$work/cronic-with-nounset" << EOF +#!/bin/bash +set -u +export SHELLOPTS +exec "$CRONIC" "\$@" +EOF +cat > "$work/cronic-with-errexit" << EOF +#!/bin/bash +set -e +export SHELLOPTS +exec "$CRONIC" "\$@" +EOF +chmod +x "$work/cronic-with-shellopts" "$work/cronic-with-nounset" \ + "$work/cronic-with-errexit" 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"' +check "bash script with a failing command, SHELLOPTS exported" 0 silent \ + env BASH_ENV= 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"' +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' CRONIC="$saved_cronic" # The wrapped command sees the caller's exported variables unchanged. For From 3ea3b33d9db55673d6a0a0da3e2c78b73b9ea2d4 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 19:36:43 -0700 Subject: [PATCH 09/11] When the caller exports xtrace, trace only the command, not cronic itself --- cronic | 18 +++++++++++++----- tests/cronic-test/cronic-test.sh | 25 ++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/cronic b/cronic index 693c049..f7d39d1 100755 --- a/cronic +++ b/cronic @@ -18,8 +18,14 @@ # feature of a later bash only after checking the version. # The caller's shell options, which this script restores for the command. -# They include any that the caller passed by exporting SHELLOPTS. -cronic_caller_flags=$- +# 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. +{ + cronic_caller_flags=$- + set +x +} 2> /dev/null # Exit if a command exits with a non-zero status, and treat unset variables # as an error. @@ -170,14 +176,16 @@ 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 `-e` and `-u` options, replacing -# this script's, for a builtin and also for a bash script when the caller -# exported SHELLOPTS. +# sets. The subshell restores the caller's `-e`, `-u`, and `-x` options, +# replacing this script's, for a builtin and also for a bash script when the +# caller exported SHELLOPTS. `-x` is restored last, so that only the command +# is traced. set +e ( set +u case $cronic_caller_flags in *e*) set -e ;; esac case $cronic_caller_flags in *u*) set -u ;; esac + case $cronic_caller_flags in *x*) set -x ;; esac 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 ed3e01b..f987798 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -297,8 +297,18 @@ set -e export SHELLOPTS exec "$CRONIC" "\$@" EOF +# 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 +#!/bin/bash +unset BASH_ENV +exec 3>&2 2> /dev/null +set -x +export SHELLOPTS +exec "$CRONIC" "\$@" 2>&3 3>&- +EOF chmod +x "$work/cronic-with-shellopts" "$work/cronic-with-nounset" \ - "$work/cronic-with-errexit" + "$work/cronic-with-errexit" "$work/cronic-with-xtrace" saved_cronic="$CRONIC" CRONIC="$work/cronic-with-shellopts" # shellcheck disable=SC2016 # The expansion is for the wrapped command to do. @@ -314,6 +324,19 @@ check "bash script with an unset variable, caller's nounset exported" 127 \ 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' +# 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' +check "bash script that fails, caller's xtrace exported" 1 "report:^+ false$" \ + env BASH_ENV= bash -c 'false' +if grep -q cronic_ "$work/output"; then + echo "FAIL: caller's xtrace exported: the report traces cronic itself:" + cat "$work/output" + status=1 +else + echo "PASS: caller's xtrace exported: the report does not trace cronic itself" +fi CRONIC="$saved_cronic" # The wrapped command sees the caller's exported variables unchanged. For From 06fa5e297fc7ac67e739953c511c60348dd7cfb3 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 09:33:29 -0700 Subject: [PATCH 10/11] Restore the caller's allexport; restore the caller's options with a single `set` --- cronic | 31 +++++++++++++++++-------------- tests/cronic-test/cronic-test.sh | 17 ++++++++++++++++- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/cronic b/cronic index f7d39d1..d02b53d 100755 --- a/cronic +++ b/cronic @@ -17,14 +17,18 @@ # 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 caller's shell options, 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. +# 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=$- - set +x + 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 @@ -176,16 +180,15 @@ 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 `-e`, `-u`, and `-x` options, +# 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. `-x` is restored last, so that only the command -# is traced. +# 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 - case $cronic_caller_flags in *e*) set -e ;; esac - case $cronic_caller_flags in *u*) set -u ;; esac - case $cronic_caller_flags in *x*) set -x ;; esac + set +u${cronic_caller_flags:+ -$cronic_caller_flags} 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 f987798..09eac74 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -297,6 +297,12 @@ set -e export SHELLOPTS exec "$CRONIC" "\$@" EOF +cat > "$work/cronic-with-allexport" << EOF +#!/bin/bash +set -a +export SHELLOPTS +exec "$CRONIC" "\$@" +EOF # 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 @@ -308,7 +314,8 @@ export SHELLOPTS exec "$CRONIC" "\$@" 2>&3 3>&- EOF chmod +x "$work/cronic-with-shellopts" "$work/cronic-with-nounset" \ - "$work/cronic-with-errexit" "$work/cronic-with-xtrace" + "$work/cronic-with-errexit" "$work/cronic-with-allexport" \ + "$work/cronic-with-xtrace" saved_cronic="$CRONIC" CRONIC="$work/cronic-with-shellopts" # shellcheck disable=SC2016 # The expansion is for the wrapped command to do. @@ -324,6 +331,14 @@ check "bash script with an unset variable, caller's nounset exported" 127 \ 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' +# 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" +check "no cronic variables exported, caller's allexport exported" 0 silent \ + sh -c '! env | grep -q "^cronic_"' +# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. +check "command's variable exported, caller's allexport exported" 0 silent \ + eval 'cronic_test_variable=1; sh -c "[ \"\$cronic_test_variable\" = 1 ]"' # 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 \ From 01700557baedf7ed0ad0f9f53e4b870e4db96731 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 09:36:49 -0700 Subject: [PATCH 11/11] Generate the SHELLOPTS wrappers in cronic-test.sh with a function --- tests/cronic-test/cronic-test.sh | 44 +++++++++++++------------------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 09eac74..bb21d21 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -280,29 +280,23 @@ check "builtin with an unset variable" 0 silent \ # 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. -cat > "$work/cronic-with-shellopts" << EOF -#!/bin/bash -export SHELLOPTS -exec "$CRONIC" "\$@" -EOF -cat > "$work/cronic-with-nounset" << EOF -#!/bin/bash -set -u -export SHELLOPTS -exec "$CRONIC" "\$@" -EOF -cat > "$work/cronic-with-errexit" << EOF -#!/bin/bash -set -e -export SHELLOPTS -exec "$CRONIC" "\$@" -EOF -cat > "$work/cronic-with-allexport" << EOF -#!/bin/bash -set -a -export SHELLOPTS -exec "$CRONIC" "\$@" -EOF +# Creates "$work/cronic-with-NAME", which runs `cronic` with SHELLOPTS exported, +# after `set OPTION` if OPTION is given. +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 @@ -313,9 +307,7 @@ set -x export SHELLOPTS exec "$CRONIC" "\$@" 2>&3 3>&- EOF -chmod +x "$work/cronic-with-shellopts" "$work/cronic-with-nounset" \ - "$work/cronic-with-errexit" "$work/cronic-with-allexport" \ - "$work/cronic-with-xtrace" +chmod +x "$work/cronic-with-xtrace" saved_cronic="$CRONIC" CRONIC="$work/cronic-with-shellopts" # shellcheck disable=SC2016 # The expansion is for the wrapped command to do.