From 0347a303f610f22e72f5c91bcbebc95593c63086 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 10:03:59 -0700 Subject: [PATCH 01/32] Run the command with the caller's shell options, in a subshell When the caller exports SHELLOPTS, the command inherited cronic's own -e and -u, so a bash script that reads an unset variable (such as texi2dvi) failed. Running the command in a subshell also keeps the exit and exec builtins from ending cronic before it reports. --- cronic | 30 ++++++++++++-- tests/cronic-test/cronic-test.sh | 68 ++++++++++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/cronic b/cronic index b26d786..ee6332b 100755 --- a/cronic +++ b/cronic @@ -14,6 +14,17 @@ # --duplicate-stderr options, which may be given in any order. # The options and the treatment of execution traces are documented in function # `cronic_help`; run `cronic --help`. +# COMMAND runs in a subshell, with the caller's shell options rather than this +# script's. +# This script runs under bash 3.2, which is /bin/bash on macOS, so it uses a +# feature of a later bash only after checking the version. + +# Save the caller's shell options, as the letters that `$-` uses for them, +# before this script changes them for itself. This script changes only errexit, +# nounset, verbose, and xtrace, so the command inherits the caller's other +# `set -o` options, and its `shopt` options, without any restoring; an option +# that this script changes must be added to the pattern. +cronic_caller_flags=${-//[^euvx]/} # Exit if a command exits with a non-zero status, and treat unset variables # as an error. @@ -158,11 +169,22 @@ if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))); export BASH_XTRACEFD=$cronic_xtrace_fd fi +# Run the command with the caller's `set -o` shell options rather than this +# script's. If the caller exported SHELLOPTS, then the command would otherwise +# inherit this script's options, and a bash script that reads an unset variable +# (as `/usr/bin/texi2dvi` does when /bin/sh is bash) would fail. +# The command runs in a subshell rather than in this shell, so that a builtin +# such as `exit` or `exec` cannot end this script before it reports. +# `command` because a function of this script, such as `cronic_usage`, would +# otherwise run in place of a program of the same name. The subshell turns off +# this script's nounset (errexit is off already) in the same command that turns +# on the caller's options. The `:+` avoids a bare `set -`, which would turn off +# verbose and xtrace. set +e -# `command` because the wrapped command runs in this shell, where a function of -# this script, such as `cronic_usage`, would otherwise run in place of a -# program of the same name. -command -- "$@" > "$cronic_out" 2> "$cronic_trace" +( + set +u${cronic_caller_flags:+ -$cronic_caller_flags} + command -- "$@" +) > "$cronic_out" 2> "$cronic_trace" cronic_result=$? set -e diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 7b62509..aba932c 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -6,12 +6,25 @@ # # `cronic` filters trace lines out of stderr with `grep -v`, which exits with # status 1 when it selects no lines. `cronic` runs under `set -e`, so an -# unguarded `grep -v` aborted the script at that point: no report was printed, -# the exit status was grep's 1 rather than the wrapped command's, and all of -# the temporary files under /tmp were left behind. +# unguarded `grep -v` would abort the script at that point: no report would be +# printed, the exit status would be grep's 1 rather than the wrapped command's, +# and the temporary files would be left behind. set -eu +# Do not let the user's environment file run in `cronic` or in the bash commands +# below. It could write to stderr, especially when an exported SHELLOPTS turns +# on `-u` for it. +unset BASH_ENV +# Do not pass this script's own options, such as `-e` and `-u`, to `cronic` and +# to the commands below. If SHELLOPTS is exported, then run this script again +# without it, because bash makes SHELLOPTS read-only, so it cannot be unset. +# `printenv` matches the name exactly, unlike a search of the output of `env`, +# which can match a line within the value of another variable. +if printenv SHELLOPTS > /dev/null; then + exec env -u SHELLOPTS sh "$0" "$@" +fi + SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)" CRONIC="$(CDPATH='' cd -- "${SCRIPT_DIR}/../.." && pwd -P)/cronic" @@ -87,6 +100,32 @@ printf '%s\n' "$@" 1>&2 EOF chmod +x "$work/stderr-lines" +# A bash command that reads an unset variable. +cat > "$work/read-unset" << 'EOF' +#!/bin/bash +unset CRONIC_TEST_UNSET_VARIABLE +echo "value: ${CRONIC_TEST_UNSET_VARIABLE}" > /dev/null +exit "$1" +EOF +chmod +x "$work/read-unset" + +# A bash command in which a command fails before the script exits. +cat > "$work/ignore-failure" << 'EOF' +#!/bin/bash +false +exit "$1" +EOF +chmod +x "$work/ignore-failure" + +# Runs `cronic` with SHELLOPTS set to its first argument and exported. +cat > "$work/cronic-with-shellopts" << 'EOF2' +#!/bin/bash +shellopts="$1" +shift +exec env SHELLOPTS="$shellopts" "$REAL_CRONIC" "$@" +EOF2 +chmod +x "$work/cronic-with-shellopts" + # temp_files: prints `cronic`'s temporary files, in a canonical order. temp_files() { find "$TMPDIR" -mindepth 1 -maxdepth 1 2> /dev/null | sort @@ -262,6 +301,29 @@ for name in $function_names; do done PATH="$saved_path" +# The command may be a shell builtin. +check "builtin command" 0 silent : +# A builtin that would end the shell does not prevent the report. +check "exit builtin" 3 report exit 3 +check "exec builtin" 3 report exec "$work/trace-only" 3 + +# When SHELLOPTS is exported, `cronic`'s own `-e` and `-u` options do not reach +# the wrapped command, but the caller's options do. +REAL_CRONIC="$CRONIC" +export REAL_CRONIC +CRONIC="$work/cronic-with-shellopts" +default_shellopts=braceexpand:hashall:interactive-comments +check "exported SHELLOPTS, command reads an unset variable" 0 silent \ + "$default_shellopts" "$work/read-unset" 0 +check "exported SHELLOPTS, command ignores a failure" 0 silent \ + "$default_shellopts" "$work/ignore-failure" 0 +check "exported SHELLOPTS with nounset, command reads an unset variable" 1 \ + "report:unbound variable" \ + "$default_shellopts:nounset" "$work/read-unset" 0 +check "exported SHELLOPTS with errexit, command ignores a failure" 1 report \ + "$default_shellopts:errexit" "$work/ignore-failure" 0 +CRONIC="$REAL_CRONIC" + # The wrapped command sees the caller's exported variables unchanged. For # each variable that `cronic` assigns, the caller exports that name in # uppercase, both with and without any "cronic_" prefix (for example, From 4882e52dc103e08862af75d1fc7d9191c0999f57 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 10:03:59 -0700 Subject: [PATCH 02/32] Do not let an exported xtrace or verbose make cronic write to stderr When the caller exports SHELLOPTS with xtrace or verbose, cronic traced or echoed itself to stderr, so cron sent email on every run. Also keep cronic's setup of the command out of the command's trace output. --- cronic | 64 ++++++++++---- tests/cronic-test/cronic-test.sh | 140 +++++++++++++++++++++++++++---- 2 files changed, 172 insertions(+), 32 deletions(-) diff --git a/cronic b/cronic index ee6332b..e1a2826 100755 --- a/cronic +++ b/cronic @@ -1,4 +1,8 @@ #!/bin/bash +{ + cronic_caller_flags=${-//[^euvx]/} + if compgen -e -X '!SHELLOPTS' SHELLOPTS > /dev/null; then set +vx; fi +} 2> /dev/null # Cronic v2 - cron job report wrapper # Copyright 2007 Chuck Houpt @@ -16,15 +20,33 @@ # `cronic_help`; run `cronic --help`. # COMMAND runs in a subshell, with the caller's shell options rather than this # script's. +# This script assumes that the caller has not exported a function with the name +# of a builtin or program that this script uses, such as `set` or `cat`; such a +# function would run in place of the builtin or program. +# This script assumes that bash has the `compgen` builtin, which is absent only +# if bash was built with `--disable-progcomp`. Without it, an exported +# SHELLOPTS could make this script trace itself to stderr. # This script runs under bash 3.2, which is /bin/bash on macOS, so it uses a # feature of a later bash only after checking the version. -# Save the caller's shell options, as the letters that `$-` uses for them, -# before this script changes them for itself. This script changes only errexit, -# nounset, verbose, and xtrace, so the command inherits the caller's other -# `set -o` options, and its `shopt` options, without any restoring; an option -# that this script changes must be added to the pattern. -cronic_caller_flags=${-//[^euvx]/} +# The first command sets cronic_caller_flags to the caller's shell options, as +# the letters that `$-` uses for them, before this script changes them for +# itself. This script changes only errexit, nounset, verbose, and xtrace, so +# the command inherits the caller's other `set -o` options, and its `shopt` +# options, without any restoring; an option that this script changes must be +# added to the pattern. `$-` reports the options without the cost of a command +# substitution, such as `$(set +o)`, in which bash would also report errexit as +# off. +# If the caller exported SHELLOPTS, then the first command also stops this +# script from tracing itself or echoing its input: that output would go to +# this script's stderr, and cron would send email for every run. An exported +# `verbose` nonetheless echoes every line that bash reads before it reaches the +# `set +vx`, which is why that command comes first, before any comment. Bash +# cannot tell whether xtrace came from SHELLOPTS or from the command line, so +# `bash -x cronic` traces this script only if SHELLOPTS is not exported. The +# filter makes `compgen` match SHELLOPTS exactly, not every exported variable +# whose name starts with SHELLOPTS. The redirection discards the trace of the +# first command. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. @@ -163,10 +185,12 @@ trap 'cronic_cleanup; trap - TERM EXIT; kill -s TERM $$' TERM # error output exactly, whatever PS4 is. The descriptor is allocated by bash # (as 10 or higher), so that it does not collide with a descriptor, such as 3, # that the wrapped command uses for its own purposes. Bash before 4.1 supports -# neither BASH_XTRACEFD nor descriptor allocation. +# neither BASH_XTRACEFD nor descriptor allocation. Only the subshell that runs +# the command sets BASH_XTRACEFD, so that this script's own trace, under +# `bash -x cronic`, goes to this script's stderr rather than into the report. +cronic_xtrace_fd= if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))); then exec {cronic_xtrace_fd}> "$cronic_xtrace" - export BASH_XTRACEFD=$cronic_xtrace_fd fi # Run the command with the caller's `set -o` shell options rather than this @@ -176,20 +200,32 @@ fi # The command runs in a subshell rather than in this shell, so that a builtin # such as `exit` or `exec` cannot end this script before it reports. # `command` because a function of this script, such as `cronic_usage`, would -# otherwise run in place of a program of the same name. The subshell turns off -# this script's nounset (errexit is off already) in the same command that turns -# on the caller's options. The `:+` avoids a bare `set -`, which would turn off -# verbose and xtrace. +# otherwise run in place of a program of the same name. The redirections are +# on the subshell, and the subshell sets BASH_XTRACEFD before it restores the +# caller's xtrace, so that when `-x` is set, the trace of running the command +# goes into the command's trace output rather than to this script's stderr. +# The subshell first turns off this script's xtrace and verbose, so that the +# restoring is not traced or echoed; any error in the restoring goes to the +# command's error output. The subshell then needs to turn off only this +# script's nounset (errexit is off already), in the same command that turns on +# the caller's options, so that restoring verbose and xtrace neither echoes nor +# traces the restoring of the others. The `:+` avoids a bare `set -`, which +# would turn off verbose and xtrace. set +e ( + { set +vx; } 2> /dev/null + if [ -n "$cronic_xtrace_fd" ]; then + export BASH_XTRACEFD=$cronic_xtrace_fd + fi set +u${cronic_caller_flags:+ -$cronic_caller_flags} command -- "$@" ) > "$cronic_out" 2> "$cronic_trace" cronic_result=$? set -e -# Unsetting BASH_XTRACEFD also closes its file descriptor. -unset BASH_XTRACEFD +if [ -n "$cronic_xtrace_fd" ]; then + exec {cronic_xtrace_fd}>&- +fi # Prints a basic regular expression that matches its argument literally. # The argument must not contain a backslash. diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index aba932c..255ab1f 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -131,29 +131,71 @@ temp_files() { find "$TMPDIR" -mindepth 1 -maxdepth 1 2> /dev/null | sort } -# check DESCRIPTION EXPECTED-STATUS EXPECTED-OUTPUT COMMAND...: runs `cronic` -# on COMMAND and checks its exit status, whether it printed a report, and that -# it left no temporary files behind. EXPECTED-OUTPUT is "silent", "report", -# "report:TEXT", which also requires TEXT to appear in the report, or -# "message:TEXT", which requires TEXT to appear in output that need not be a -# report. -check() { +# run_cronic DESCRIPTION EXPECTED-STATUS COMMAND...: runs COMMAND, which runs +# `cronic`, with its standard output in "$work/output" and its error output in +# "$work/stderr". If COMMAND exits with a status other than EXPECTED-STATUS, +# or leaves temporary files behind, then reports a failure and returns 1. +run_cronic() { description="$1" expected_status="$2" - expected_output="$3" - shift 3 + shift 2 before="$(temp_files)" actual_status=0 - "$CRONIC" "$@" > "$work/output" 2>&1 || actual_status=$? + "$@" > "$work/output" 2> "$work/stderr" || actual_status=$? after="$(temp_files)" if [ "$actual_status" != "$expected_status" ]; then echo "FAIL: $description: exit status $actual_status, expected $expected_status" + cat "$work/output" "$work/stderr" + status=1 + return 1 + fi + if [ "$before" != "$after" ]; then + echo "FAIL: $description: temporary files were left behind:" + echo "$after" + status=1 + return 1 + fi +} + +# check_trace_section DESCRIPTION GOAL-FILE COMMAND...: runs COMMAND, which +# runs `cronic`, and checks that it exits with status 0, leaves no temporary +# files, and produces a report whose "TRACE OUTPUT" section, through the +# following empty line, is the contents of GOAL-FILE. COMMAND's stderr is left +# in "$work/stderr". +check_trace_section() { + description="$1" + goal="$2" + shift 2 + + run_cronic "$description" 0 "$@" || return 0 + sed -n '/^TRACE OUTPUT:$/,/^$/p' "$work/output" > "$work/trace-section" + if cmp -s "$goal" "$work/trace-section"; then + echo "PASS: $description" + else + echo "FAIL: $description: got:" cat "$work/output" status=1 - return fi +} + +# check DESCRIPTION EXPECTED-STATUS EXPECTED-OUTPUT COMMAND...: runs `cronic` +# on COMMAND and checks its exit status, whether it printed a report, and that +# it left no temporary files behind. EXPECTED-OUTPUT is "silent", "report", +# "report:TEXT", which also requires TEXT to appear in the report, or +# "message:TEXT", which requires TEXT to appear in output that need not be a +# report. +check() { + description="$1" + expected_status="$2" + expected_output="$3" + shift 3 + + run_cronic "$description" "$expected_status" "$CRONIC" "$@" || return 0 + # The checks below examine both outputs, because `cronic` writes its usage + # messages to stderr. + cat "$work/stderr" >> "$work/output" case "$expected_output" in silent) @@ -185,13 +227,6 @@ check() { ;; esac - if [ "$before" != "$after" ]; then - echo "FAIL: $description: temporary files were left behind:" - echo "$after" - status=1 - return - fi - echo "PASS: $description" } @@ -322,6 +357,49 @@ check "exported SHELLOPTS with nounset, command reads an unset variable" 1 \ "$default_shellopts:nounset" "$work/read-unset" 0 check "exported SHELLOPTS with errexit, command ignores a failure" 1 report \ "$default_shellopts:errexit" "$work/ignore-failure" 0 +# With xtrace exported, a successful command with only trace output on stderr +# produces no output at all: `cronic` does not trace itself. +check "exported SHELLOPTS with xtrace, trace-only stderr" 0 silent \ + "$default_shellopts:xtrace" "$work/trace-only" 0 +# With xtrace exported, the report's trace section is the trace of running the +# command, followed by the command's own trace, with nothing that `cronic` did +# to set up the command's options. Bash writes the trace via BASH_XTRACEFD, so +# it appears under "TRACE OUTPUT". +cat > "$work/trace-section.goal" << EOF +TRACE OUTPUT: ++ command -- $work/trace-and-stderr 0 ++ set -x ++ echo 'a real error' ++ exit 0 + +EOF +check_trace_section "exported SHELLOPTS with xtrace, trace output" \ + "$work/trace-section.goal" \ + "$CRONIC" "$default_shellopts:xtrace" "$work/trace-and-stderr" 0 +if [ -s "$work/stderr" ]; then + echo "FAIL: exported SHELLOPTS with xtrace: cronic wrote to stderr:" + cat "$work/stderr" + status=1 +fi + +# With verbose exported, a successful command produces no report: `cronic` +# does not echo, into the command's error output, the commands that restore the +# caller's options. `cronic` echoes to its stderr only its lines through its +# first command, which bash reads before `cronic` turns verbose off. +sed '/^} 2> \/dev\/null$/q' "$REAL_CRONIC" > "$work/verbose-stderr.goal" +if run_cronic "exported SHELLOPTS with verbose" 0 \ + "$CRONIC" "$default_shellopts:verbose" true; then + if [ ! -s "$work/output" ] && cmp -s "$work/verbose-stderr.goal" "$work/stderr"; then + echo "PASS: exported SHELLOPTS with verbose" + else + echo "FAIL: exported SHELLOPTS with verbose: output:" + cat "$work/output" + echo "stderr:" + cat "$work/stderr" + status=1 + fi +fi + CRONIC="$REAL_CRONIC" # The wrapped command sees the caller's exported variables unchanged. For @@ -361,6 +439,32 @@ check "caller's exported variables" 0 silent "$work/check-variables" $exported_n # shellcheck disable=SC2086 # each name is a separate argument. unset $exported_names +# `bash -x cronic`, which does not export SHELLOPTS, traces `cronic` itself. +if run_cronic "bash -x traces cronic" 0 \ + bash -x "$CRONIC" "$work/trace-only" 0; then + if grep -q "cronic_debug=false" "$work/stderr"; then + echo "PASS: bash -x traces cronic" + else + echo "FAIL: bash -x traces cronic: stderr:" + cat "$work/stderr" + status=1 + fi +fi + +# Under `bash -x cronic`, the report's trace section contains nothing that +# `cronic` did to set up the command. The command does not inherit xtrace, so +# its own `set -x` is not traced. +cat > "$work/bash-x-trace-section.goal" << EOF +TRACE OUTPUT: ++ command -- $work/trace-and-stderr 0 ++ echo 'a real error' ++ exit 0 + +EOF +check_trace_section "bash -x cronic, trace output" \ + "$work/bash-x-trace-section.goal" \ + bash -x "$CRONIC" "$work/trace-and-stderr" 0 + # Bash writes its trace lines to a separate file, so they are not error # output, whatever PS4 is. export PS4 From a9f02be7dd3e2b0336758065e34e5b528326100e Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 10:03:59 -0700 Subject: [PATCH 03/32] Allow an exported shell function as the command; reserve cronic_ names The command no longer runs via `command`, so it may be an exported shell function, which runs with the caller's options. The command does not see any variable or function whose name starts with cronic_, even one that the caller exported. --- cronic | 73 ++++++++++++++++++++++++-------- tests/cronic-test/cronic-test.sh | 70 +++++++++++++++++++++++++++--- 2 files changed, 120 insertions(+), 23 deletions(-) diff --git a/cronic b/cronic index e1a2826..2377c5f 100755 --- a/cronic +++ b/cronic @@ -19,13 +19,16 @@ # 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. +# script's. Names that start with `cronic_` are reserved for this script: +# COMMAND does not see a variable or function with such a name, even one that +# the caller exported. # This script assumes that the caller has not exported a function with the name # of a builtin or program that this script uses, such as `set` or `cat`; such a # function would run in place of the builtin or program. # This script assumes that bash has the `compgen` builtin, which is absent only # if bash was built with `--disable-progcomp`. Without it, an exported -# SHELLOPTS could make this script trace itself to stderr. +# SHELLOPTS could make this script trace itself to stderr, and the command could +# see a `cronic_` function that the caller exported. # 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. @@ -48,6 +51,24 @@ # whose name starts with SHELLOPTS. The redirection discards the trace of the # first command. +# Unset every function whose name starts with `cronic_`, all of which the caller +# exported, because this script has not yet defined any. Then the only such +# functions are this script's, which the subshell that runs the command unsets +# by name. Testing the status of `compgen` first avoids the cost of a command +# substitution when there is no such function. Each name is read whole, rather +# than split from an unquoted expansion, so that a name that contains a glob +# character is not expanded to file names. The names are read from a +# here-string rather than from a process substitution, which bash before 5.1 +# rejects in posix mode, as an exported SHELLOPTS can set. The redirection +# discards the trace of these commands. +{ + if compgen -A function cronic_ > /dev/null; then + while read -r cronic_name; do + unset -f "$cronic_name" + done <<< "$(compgen -A function cronic_)" + fi +} 2> /dev/null + # Exit if a command exits with a non-zero status, and treat unset variables # as an error. set -eu @@ -77,6 +98,8 @@ cronic_help() { echo " is less likely to be overlooked" echo " -- end the options, so that COMMAND may start with \"-\"" echo " -h, --help print this message" + echo "Names that start with \"cronic_\" are reserved: COMMAND does not see a" + echo "variable or function with such a name, even one that the caller exported." echo "Execution traces (from \"set -x\") are not treated as error output." echo "Bash 4.1 or later writes them to a separate file, via BASH_XTRACEFD; they" echo "appear in the report under \"TRACE OUTPUT\". Other shells write them to" @@ -197,28 +220,42 @@ fi # 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 redirections are -# on the subshell, and the subshell sets BASH_XTRACEFD before it restores the -# caller's xtrace, so that when `-x` is set, the trace of running the command -# goes into the command's trace output rather than to this script's stderr. -# The subshell first turns off this script's xtrace and verbose, so that the -# restoring is not traced or echoed; any error in the restoring goes to the -# command's error output. The subshell then needs to turn off only this -# script's nounset (errexit is off already), in the same command that turns on -# the caller's options, so that restoring verbose and xtrace neither echoes nor -# traces the restoring of the others. The `:+` avoids a bare `set -`, which -# would turn off verbose and xtrace. +# The command runs in a subshell, rather than via `exec`, so that it can be a +# shell builtin or an exported shell function; and in a subshell rather than in +# this shell, so that a builtin such as `exit` or `exec` cannot end this script +# before it reports. The subshell is not the left operand of `||`, because +# bash would then ignore `set -e` throughout the subshell, including in a +# shell function that it runs. The redirections are on the subshell, and the +# subshell sets BASH_XTRACEFD before it restores the caller's xtrace, so that +# when `-x` is set, the trace of running the command goes into the command's +# trace output rather than to this script's stderr. +# The subshell unsets every variable whose name starts with `cronic_` and every +# function that this script defined, so that a builtin or shell function that +# is the command does not see this script's variables and functions, and so +# that a function does not take precedence over a program of the same name. +# This script's functions are listed, rather than found by +# `compgen -A function cronic_`, to avoid the cost of a command substitution. +# The list must name every function that this script defines before this +# point; the test suite fails if the command can see one that it omits. +# Then the subshell restores the caller's options. The subshell first turns +# off this script's xtrace and verbose, so that the restoring is not traced or +# echoed; any error in the restoring goes to the command's error output. The +# subshell then needs to turn off only this script's nounset (errexit is off +# already), in the same command that turns on the caller's options, so that +# restoring verbose and xtrace neither echoes nor traces the restoring of the +# others. The `:+` avoids a bare `set -`, which would turn off verbose and +# xtrace. set +e ( { set +vx; } 2> /dev/null if [ -n "$cronic_xtrace_fd" ]; then export BASH_XTRACEFD=$cronic_xtrace_fd fi - set +u${cronic_caller_flags:+ -$cronic_caller_flags} - command -- "$@" + unset -f cronic_usage_line cronic_usage cronic_help \ + cronic_set_expected cronic_cleanup + eval "unset -v \"\${!cronic_@}\" + set +u${cronic_caller_flags:+ -$cronic_caller_flags}" + "$@" ) > "$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 255ab1f..415ace9 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -117,9 +117,26 @@ exit "$1" EOF chmod +x "$work/ignore-failure" -# Runs `cronic` with SHELLOPTS set to its first argument and exported. +# Runs `cronic` with SHELLOPTS set to its first argument and exported, and +# with these exported shell functions: +# * `fail_then_continue` runs `false` and then prints a line. +# * `fail_in_pipeline` runs a pipeline whose first command fails. +# * `report_cronic_variables` writes to stderr any variable or function of +# `cronic`'s that it can see. cat > "$work/cronic-with-shellopts" << 'EOF2' #!/bin/bash +fail_then_continue() { + false + echo "after the failure" +} +fail_in_pipeline() { + false | true +} +report_cronic_variables() { + compgen -v -A function cronic_ >&2 + return 0 +} +export -f fail_then_continue fail_in_pipeline report_cronic_variables shellopts="$1" shift exec env SHELLOPTS="$shellopts" "$REAL_CRONIC" "$@" @@ -367,7 +384,7 @@ check "exported SHELLOPTS with xtrace, trace-only stderr" 0 silent \ # it appears under "TRACE OUTPUT". cat > "$work/trace-section.goal" << EOF TRACE OUTPUT: -+ command -- $work/trace-and-stderr 0 ++ $work/trace-and-stderr 0 + set -x + echo 'a real error' + exit 0 @@ -400,6 +417,28 @@ if run_cronic "exported SHELLOPTS with verbose" 0 \ fi fi +# The caller's `-e` applies within an exported shell function that is the +# command, and the caller's lack of `-e` does too. +check "exported SHELLOPTS with errexit, exported function ignores a failure" 1 \ + report "$default_shellopts:errexit" fail_then_continue +check "exported SHELLOPTS, exported function ignores a failure" 0 silent \ + "$default_shellopts" fail_then_continue +# Every one of the caller's options applies, not only `-e`, `-u`, and `-x`. +check "exported SHELLOPTS with pipefail, exported function" 1 report \ + "$default_shellopts:pipefail" fail_in_pipeline +check "exported SHELLOPTS without pipefail, exported function" 0 silent \ + "$default_shellopts" fail_in_pipeline +# A shell function that is the command does not see `cronic`'s variables or +# function. +check "exported function sees none of cronic's variables or function" 0 silent \ + "$default_shellopts" report_cronic_variables +# ... nor a variable that is not exported, which BASH_ENV set within `cronic`. +echo "cronic_from_bash_env=unexported" > "$work/bash-env" +BASH_ENV="$work/bash-env" +export BASH_ENV +check "exported function does not see unexported variable from BASH_ENV" 0 \ + silent "$default_shellopts" report_cronic_variables +unset BASH_ENV CRONIC="$REAL_CRONIC" # The wrapped command sees the caller's exported variables unchanged. For @@ -407,8 +446,8 @@ CRONIC="$REAL_CRONIC" # uppercase, both with and without any "cronic_" prefix (for example, # `CRONIC_OUT` and `OUT`). `TMPDIR` is omitted, because `cronic` reads it. # The lowercase names that `cronic` assigns, such as `cronic_out`, are not -# exported: `cronic` does overwrite them, but environment variables -# conventionally have uppercase names. +# exported here, because environment variables conventionally have uppercase +# names; the next test checks a few of them. 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" @@ -439,6 +478,27 @@ check "caller's exported variables" 0 silent "$work/check-variables" $exported_n # shellcheck disable=SC2086 # each name is a separate argument. unset $exported_names +# Names that start with `cronic_` are reserved: the command does not see a +# variable or function with such a name that the caller exported. +cat > "$work/cronic-with-cronic-names" << 'EOF' +#!/bin/bash +cronic_mine() { + exit 3 +} +cronic_debug="caller's cronic_debug" +export -f cronic_mine +export cronic_debug +exec "$REAL_CRONIC" "$@" +EOF +chmod +x "$work/cronic-with-cronic-names" +CRONIC="$work/cronic-with-cronic-names" +# shellcheck disable=SC2016 # The expansion is for `sh` to do. +check "caller's exported cronic_ variable is not seen" 0 silent \ + sh -c '[ -z "${cronic_debug+set}" ]' +check "caller's exported cronic_ function is not seen" 0 silent \ + bash -c '! declare -F cronic_mine' +CRONIC="$REAL_CRONIC" + # `bash -x cronic`, which does not export SHELLOPTS, traces `cronic` itself. if run_cronic "bash -x traces cronic" 0 \ bash -x "$CRONIC" "$work/trace-only" 0; then @@ -456,7 +516,7 @@ fi # its own `set -x` is not traced. cat > "$work/bash-x-trace-section.goal" << EOF TRACE OUTPUT: -+ command -- $work/trace-and-stderr 0 ++ $work/trace-and-stderr 0 + echo 'a real error' + exit 0 From b170cb7e135d4d2825900f0e21593a43952d43f0 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 10:14:29 -0700 Subject: [PATCH 04/32] 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 05/32] 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 06/32] Explain why the subshell is not an operand of ||, and the limitation for source and eval --- cronic | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cronic b/cronic index c3c3ea1..80d7b0d 100755 --- a/cronic +++ b/cronic @@ -182,6 +182,13 @@ fi # the caller's verbose and xtrace. The redirections are on the command rather # than on the subshell, so that the execution traces of the subshell's own # commands go to this script's standard error rather than to the command's. +# The subshell is not the operand of `||` or the like, because then bash would +# ignore `set -e` in it and would remove "errexit" from the SHELLOPTS that the +# command inherits. +# Limitation: if the command is a builtin that runs code in the subshell, such +# as `source` or `eval`, that code runs with the caller's options even if the +# caller does not export SHELLOPTS. So if the caller does not use `set -u`, +# that code silently expands an unset variable to the empty string. set +e ( set +u${cronic_caller_flags:+ -$cronic_caller_flags} From f9bc9c25b41e91f85b6c1368d9fcff9dd9f003b4 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 18:55:11 -0700 Subject: [PATCH 07/32] Restore all of the caller's set -o options; document limitations; fix tests Save the caller's SHELLOPTS rather than a hand-maintained list of option letters, so that every set -o option is restored for the command. Document that verbose and BASH_ENV can still make cronic write to stderr. In the tests, unset PS4, skip trace-section checks when bash lacks BASH_XTRACEFD, and share the PASS/FAIL reporting. --- cronic | 32 +++++++++++------ tests/cronic-test/cronic-test.sh | 62 +++++++++++++++++++------------- 2 files changed, 59 insertions(+), 35 deletions(-) diff --git a/cronic b/cronic index 504dc46..3991f99 100755 --- a/cronic +++ b/cronic @@ -1,6 +1,6 @@ #!/bin/bash { - cronic_caller_flags=${-//[^euvx]/} + cronic_caller_opts=$SHELLOPTS set +vx } 2> /dev/null @@ -21,15 +21,21 @@ # This script runs under bash 3.2, which is /bin/bash on macOS, so it uses a # feature of a later bash only after checking the version. -# The first command saves the caller's shell options, as the letters that `$-` -# uses for them, before this script changes them for itself. This script -# changes only errexit, nounset, verbose, and xtrace, so the command inherits -# the caller's other `set -o` options, and its `shopt` options, without any -# restoring; an option that this script changes must be added to the pattern. +# The first command saves the caller's `set -o` shell options, as the +# colon-separated list of those that are on, before this script changes any of +# them for itself. This script changes no `shopt` option, so the command +# inherits those without any restoring. # The first command then stops this script from tracing itself or echoing its # input, which would go to this script's stderr, so that cron would send email # for every run. It comes before any comment, because bash echoes every line # that it reads while verbose is on. The redirection discards its own trace. +# Verbose is handled only partly: bash echoes this script's lines through the +# end of the first command to stderr before that command can turn verbose off. +# So if the caller exports SHELLOPTS with verbose, or runs `bash -v cronic`, +# then cron sends email for every run. +# Likewise, bash runs the file named by BASH_ENV, if any, before this script's +# first command, so an exported xtrace, verbose, or nounset applies to that file +# and can make it write to stderr; such a cron job should unset BASH_ENV. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. @@ -66,6 +72,10 @@ cronic_help() { echo "stderr, from which lines that start with PS4 are removed. That removal" echo "is skipped if PS4 contains \"\$\", a backquote, a backslash, or a newline," echo "because such a PS4 can expand differently on each line." + echo "Under \"set -v\" exported via SHELLOPTS, or \"bash -v cronic\", bash echoes the" + echo "first lines of cronic to stderr before cronic can turn verbose off." + echo "Likewise, bash runs the BASH_ENV file, if any, before cronic starts, so" + echo "exported shell options can make that file write to stderr." exit 0 } @@ -182,12 +192,14 @@ fi # such as `exit` or `exec` cannot end this script before it reports. # `command` because a function of this script, such as `cronic_usage`, would # otherwise run in place of a program of the same name. The subshell turns off -# this script's nounset (errexit is off already) in the same command that turns -# on the caller's options, so that the restoring is neither traced nor echoed. -# The `:+` avoids a bare `set -`, which would turn off verbose and xtrace. +# every option that is on, which includes nounset, and turns on the caller's, in +# a single `set` command, so that the restoring is neither traced nor echoed. +# SHELLOPTS is not empty here, because nounset is on. The `:+` avoids a bare +# `set -o`, which would print the options, when the caller had none on. set +e ( - set +u${cronic_caller_flags:+ -$cronic_caller_flags} + # shellcheck disable=SC2086 # each option name and +o or -o is an argument. + set +o ${SHELLOPTS//:/ +o } ${cronic_caller_opts:+-o ${cronic_caller_opts//:/ -o }} command -- "$@" ) > "$cronic_out" 2> "$cronic_trace" cronic_result=$? diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index eb562a7..60b88b3 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -16,6 +16,8 @@ set -eu # below. It could write to stderr, especially when an exported SHELLOPTS turns # on `-u` for it. unset BASH_ENV +# Use the default PS4, which the goal files and the trace lines below assume. +unset PS4 # Do not pass this script's own options, such as `-e` and `-u`, to `cronic` and # to the commands below. If SHELLOPTS is exported, then run this script again # without it, because bash makes SHELLOPTS read-only, so it cannot be unset. @@ -151,21 +153,15 @@ run_cronic() { fi } -# check_trace_section DESCRIPTION GOAL-FILE COMMAND...: runs COMMAND, which -# runs `cronic`, and checks that it exits with status 0, leaves no temporary -# files, writes nothing to stderr, and produces a report whose "TRACE OUTPUT" -# section, through the following empty line, is the contents of GOAL-FILE. -check_trace_section() { - description="$1" - goal="$2" - shift 2 - - run_cronic "$description" 0 "$@" || return 0 - sed -n '/^TRACE OUTPUT:$/,/^$/p' "$work/output" > "$work/trace-section" - if [ ! -s "$work/stderr" ] && cmp -s "$goal" "$work/trace-section"; then - echo "PASS: $description" +# compare_streams DESCRIPTION OUTPUT OUTPUT-GOAL STDERR-GOAL: after run_cronic, +# reports success if the file OUTPUT, which is `cronic`'s output or a part of +# it, has the contents of OUTPUT-GOAL, and `cronic`'s stderr has the contents of +# STDERR-GOAL. +compare_streams() { + if cmp -s "$3" "$2" && cmp -s "$4" "$work/stderr"; then + echo "PASS: $1" else - echo "FAIL: $description: output:" + echo "FAIL: $1: output:" cat "$work/output" echo "stderr:" cat "$work/stderr" @@ -173,6 +169,29 @@ check_trace_section() { fi } +# check_trace_section DESCRIPTION BASH GOAL-FILE COMMAND...: runs COMMAND, +# which runs `cronic` under the bash program BASH, and checks that it exits +# with status 0, leaves no temporary files, writes nothing to stderr, and +# produces a report whose "TRACE OUTPUT" section, through the following empty +# line, is the contents of GOAL-FILE. Skips the check if BASH is older than +# 4.1, which lacks BASH_XTRACEFD, so that the report has no "TRACE OUTPUT". +check_trace_section() { + description="$1" + bash_program="$2" + goal="$3" + shift 3 + + # shellcheck disable=SC2016 # the expansions are for bash, not this shell. + if ! "$bash_program" -c '((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1)))'; then + echo "SKIP: $description, because $bash_program is older than 4.1" + return 0 + fi + + run_cronic "$description" 0 "$@" || return 0 + sed -n '/^TRACE OUTPUT:$/,/^$/p' "$work/output" > "$work/trace-section" + compare_streams "$description" "$work/trace-section" "$goal" /dev/null +} + # check DESCRIPTION EXPECTED-STATUS EXPECTED-OUTPUT COMMAND...: runs `cronic` # on COMMAND and checks its exit status, whether it printed a report, and that # it left no temporary files behind. EXPECTED-OUTPUT is "silent", "report", @@ -366,7 +385,7 @@ TRACE OUTPUT: + exit 0 EOF -check_trace_section "exported SHELLOPTS with xtrace, trace output" \ +check_trace_section "exported SHELLOPTS with xtrace, trace output" /bin/bash \ "$work/trace-section.goal" \ "$CRONIC" "$default_shellopts:xtrace" "$work/trace-and-stderr" 0 @@ -377,15 +396,8 @@ check_trace_section "exported SHELLOPTS with xtrace, trace output" \ sed '/^} 2> \/dev\/null$/q' "$REAL_CRONIC" > "$work/verbose-stderr.goal" if run_cronic "exported SHELLOPTS with verbose" 0 \ "$CRONIC" "$default_shellopts:verbose" true; then - if [ ! -s "$work/output" ] && cmp -s "$work/verbose-stderr.goal" "$work/stderr"; then - echo "PASS: exported SHELLOPTS with verbose" - else - echo "FAIL: exported SHELLOPTS with verbose: output:" - cat "$work/output" - echo "stderr:" - cat "$work/stderr" - status=1 - fi + compare_streams "exported SHELLOPTS with verbose" \ + "$work/output" /dev/null "$work/verbose-stderr.goal" fi CRONIC="$REAL_CRONIC" @@ -437,7 +449,7 @@ TRACE OUTPUT: + exit 0 EOF -check_trace_section "bash -x cronic, trace output" \ +check_trace_section "bash -x cronic, trace output" bash \ "$work/bash-x-trace-section.goal" \ bash -x "$CRONIC" "$work/trace-and-stderr" 0 From b8b1cd2a5f795f67a3e68f1d14a070e217b05978 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 19:07:38 -0700 Subject: [PATCH 08/32] Restore with builtins, document COMMAND kinds and options, skip XTRACEFD tests on old bash Run the restore code via `builtin`, so that an exported function such as `unset` cannot defeat the hiding of cronic_ names. Document in --help that COMMAND may be a builtin or an exported function and runs with the caller's shell options. Skip the tests that need BASH_XTRACEFD on bash before 4.1, and merge the two ad hoc goal checks into check_goals. --- cronic | 20 +++-- tests/cronic-test/cronic-test.sh | 125 ++++++++++++++++++------------- 2 files changed, 89 insertions(+), 56 deletions(-) diff --git a/cronic b/cronic index db98b3f..3737380 100755 --- a/cronic +++ b/cronic @@ -64,8 +64,13 @@ cronic_help() { echo " is less likely to be overlooked" echo " -- end the options, so that COMMAND may start with \"-\"" echo " -h, --help print this message" + echo "COMMAND may be a program, a shell builtin, or a shell function that the" + echo "caller exported; a function runs in place of a program of the same name." echo "Names that start with \"cronic_\" are reserved: COMMAND does not see a" echo "variable or function with such a name, even one that the caller exported." + echo "COMMAND runs with the caller's shell options, such as errexit, nounset," + echo "and xtrace, rather than with this script's. This script never traces" + echo "itself, so \"bash -x cronic COMMAND\" traces only the running of COMMAND." echo "Execution traces (from \"set -x\") are not treated as error output." echo "Bash 4.1 or later writes them to a separate file, via BASH_XTRACEFD; they" echo "appear in the report under \"TRACE OUTPUT\". Other shells write them to" @@ -200,20 +205,23 @@ fi # nounset (errexit is off already) in the same command that turns on the # caller's options, so that the restoring is neither traced nor echoed. The # `:+` avoids a bare `set -`, which would turn off verbose and xtrace. +# Each command is run via `builtin`, because the caller's exported functions +# have been imported, and outside posix mode a function such as `unset` would +# run in place of the builtin of the same name. set +e ( - while read -r cronic_name; do - unset -f "$cronic_name" - done <<< "$(compgen -A function cronic_)" - eval "unset -v \"\${!cronic_@}\" - set +u${cronic_caller_flags:+ -$cronic_caller_flags}" + while builtin read -r cronic_name; do + builtin unset -f "$cronic_name" + done <<< "$(builtin compgen -A function cronic_)" + builtin eval "builtin unset -v \"\${!cronic_@}\" + builtin set +u${cronic_caller_flags:+ -$cronic_caller_flags}" "$@" ) > "$cronic_out" 2> "$cronic_trace" cronic_result=$? set -e # Unsetting BASH_XTRACEFD also closes its file descriptor. -unset BASH_XTRACEFD +builtin unset BASH_XTRACEFD # Prints a basic regular expression that matches its argument literally. # The argument must not contain a backslash. diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 50e8a8d..cdfd7f4 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -40,6 +40,16 @@ mkdir "$TMPDIR" status=0 +# Whether `cronic`, which runs under /bin/bash, writes the execution trace to a +# separate file, via BASH_XTRACEFD, which bash 4.1 or later supports. If not, +# then bash writes its trace to stderr, like any other shell, and the tests +# that depend on the separate file are skipped. +if /bin/bash -c '((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1)))'; then + bash_xtracefd=true +else + bash_xtracefd=false +fi + # A command whose stderr is nothing but trace lines. cat > "$work/trace-only" << 'EOF' #!/bin/bash @@ -168,18 +178,18 @@ run_cronic() { fi } -# check_trace_section DESCRIPTION GOAL-FILE COMMAND...: runs COMMAND, which -# runs `cronic`, and checks that it exits with status 0, leaves no temporary -# files, writes nothing to stderr, and produces a report whose "TRACE OUTPUT" -# section, through the following empty line, is the contents of GOAL-FILE. -check_trace_section() { +# check_goals DESCRIPTION OUTPUT-GOAL STDERR-GOAL COMMAND...: runs COMMAND, +# which runs `cronic`, and checks that it exits with status 0, leaves no +# temporary files, and writes the contents of OUTPUT-GOAL to standard output +# and the contents of STDERR-GOAL to stderr. +check_goals() { description="$1" - goal="$2" - shift 2 + output_goal="$2" + stderr_goal="$3" + shift 3 run_cronic "$description" 0 "$@" || return 0 - sed -n '/^TRACE OUTPUT:$/,/^$/p' "$work/output" > "$work/trace-section" - if [ ! -s "$work/stderr" ] && cmp -s "$goal" "$work/trace-section"; then + if cmp -s "$output_goal" "$work/output" && cmp -s "$stderr_goal" "$work/stderr"; then echo "PASS: $description" else echo "FAIL: $description: output:" @@ -375,35 +385,41 @@ check "exported SHELLOPTS with xtrace, trace-only stderr" 0 silent \ # command, followed by the command's own trace, with nothing that `cronic` did # to set up the command's options. Bash writes the trace via BASH_XTRACEFD, so # it appears under "TRACE OUTPUT". -cat > "$work/trace-section.goal" << EOF +cat > "$work/trace-report.goal" << EOF +Cronic detected failure or error output for the command: +$work/trace-and-stderr 0 + +RESULT CODE: 0 (success) + +ERROR OUTPUT: +a real error + +STANDARD OUTPUT: + TRACE OUTPUT: + $work/trace-and-stderr 0 + set -x + echo 'a real error' + exit 0 +END OF CRONIC OUTPUT. EOF -check_trace_section "exported SHELLOPTS with xtrace, trace output" \ - "$work/trace-section.goal" \ - "$CRONIC" "$default_shellopts:xtrace" "$work/trace-and-stderr" 0 +if $bash_xtracefd; then + check_goals "exported SHELLOPTS with xtrace, trace output" \ + "$work/trace-report.goal" /dev/null \ + "$CRONIC" "$default_shellopts:xtrace" "$work/trace-and-stderr" 0 +else + echo "SKIP: exported SHELLOPTS with xtrace, trace output, because bash is older than 4.1" +fi # With verbose exported, a successful command produces no report: `cronic` # does not echo, into the command's error output, the commands that restore the # caller's options. `cronic` echoes to its stderr only its lines through its # first command, which bash reads before `cronic` turns verbose off. sed '/^} 2> \/dev\/null$/q' "$REAL_CRONIC" > "$work/verbose-stderr.goal" -if run_cronic "exported SHELLOPTS with verbose" 0 \ - "$CRONIC" "$default_shellopts:verbose" true; then - if [ ! -s "$work/output" ] && cmp -s "$work/verbose-stderr.goal" "$work/stderr"; then - echo "PASS: exported SHELLOPTS with verbose" - else - echo "FAIL: exported SHELLOPTS with verbose: output:" - cat "$work/output" - echo "stderr:" - cat "$work/stderr" - status=1 - fi -fi +check_goals "exported SHELLOPTS with verbose" \ + /dev/null "$work/verbose-stderr.goal" \ + "$CRONIC" "$default_shellopts:verbose" true # The caller's `-e` applies within an exported shell function that is the # command, and the caller's lack of `-e` does too. @@ -467,7 +483,9 @@ check "caller's exported variables" 0 silent "$work/check-variables" $exported_n unset $exported_names # Names that start with `cronic_` are reserved: the command does not see a -# variable or function with such a name that the caller exported. +# variable or function with such a name that the caller exported, even when the +# caller also exported functions named like the builtins that `cronic` uses to +# hide such names. cat > "$work/cronic-with-cronic-names" << 'EOF' #!/bin/bash cronic_mine() { @@ -476,6 +494,11 @@ cronic_mine() { cronic_debug="caller's cronic_debug" export -f cronic_mine export cronic_debug +compgen() { echo "function compgen ran" >&2; } +eval() { echo "function eval ran" >&2; } +read() { echo "function read ran" >&2; } +unset() { echo "function unset ran" >&2; } +export -f compgen eval read unset exec "$REAL_CRONIC" "$@" EOF chmod +x "$work/cronic-with-cronic-names" @@ -490,34 +513,36 @@ CRONIC="$REAL_CRONIC" # `bash -x cronic` does not trace `cronic` itself, but does trace running the # command. The command does not inherit xtrace, because SHELLOPTS is not # exported, so its own `set -x` is not traced. -cat > "$work/bash-x-trace-section.goal" << EOF -TRACE OUTPUT: -+ $work/trace-and-stderr 0 -+ echo 'a real error' -+ exit 0 - -EOF -check_trace_section "bash -x cronic, trace output" \ - "$work/bash-x-trace-section.goal" \ - bash -x "$CRONIC" "$work/trace-and-stderr" 0 +grep -v '^+ set -x$' "$work/trace-report.goal" > "$work/bash-x-trace-report.goal" +if $bash_xtracefd; then + check_goals "bash -x cronic, trace output" \ + "$work/bash-x-trace-report.goal" /dev/null \ + bash -x "$CRONIC" "$work/trace-and-stderr" 0 +else + echo "SKIP: bash -x cronic, trace output, because bash is older than 4.1" +fi # Bash writes its trace lines to a separate file, so they are not error # output, whatever PS4 is. export PS4 -# shellcheck disable=SC2016 # The expansion is for the traced command to do. -PS4='+${LINENO}: ' -check "bash, PS4 with expansions, trace-only stderr" 0 silent \ - "$work/trace-only" 0 -check "bash, PS4 with expansions, trace in the report" 3 \ - "report:echo 'the standard output'$" "$work/trace-only" 3 -check "bash, PS4 with expansions, trace lines and real stderr" 0 \ - "report:^a real error$" "$work/trace-and-stderr" 0 -PS4='+\D{%H}: ' -check "bash, PS4 with an escape, trace-only stderr" 0 silent \ - "$work/trace-only" 0 -PS4='[trace] ' -check "bash, PS4 with metacharacters, trace-only stderr" 0 silent \ - "$work/trace-only" 0 +if $bash_xtracefd; then + # shellcheck disable=SC2016 # The expansion is for the traced command to do. + PS4='+${LINENO}: ' + check "bash, PS4 with expansions, trace-only stderr" 0 silent \ + "$work/trace-only" 0 + check "bash, PS4 with expansions, trace in the report" 3 \ + "report:echo 'the standard output'$" "$work/trace-only" 3 + check "bash, PS4 with expansions, trace lines and real stderr" 0 \ + "report:^a real error$" "$work/trace-and-stderr" 0 + PS4='+\D{%H}: ' + check "bash, PS4 with an escape, trace-only stderr" 0 silent \ + "$work/trace-only" 0 + PS4='[trace] ' + check "bash, PS4 with metacharacters, trace-only stderr" 0 silent \ + "$work/trace-only" 0 +else + echo "SKIP: tests of bash with any PS4, because bash is older than 4.1" +fi # The tests below concern trace lines that a shell other than bash writes to # stderr; they are recognized by PS4. Bash running as root does not import From b5c038455d2212a10b3cf1a06bba6856ed7649e0 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 19:23:59 -0700 Subject: [PATCH 09/32] Remove the caller's functions at startup and restore them only for the command Cronic's own code no longer sees a caller's exported function, so no command needs a `builtin` prefix. Posix mode makes special builtins such as `set`, `unset`, and `export` run in place of functions while saving and restoring. --- cronic | 57 ++++++++++++++++++++++++-------- tests/cronic-test/cronic-test.sh | 16 ++++++--- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/cronic b/cronic index 3737380..e55cdde 100755 --- a/cronic +++ b/cronic @@ -1,7 +1,25 @@ #!/bin/bash { cronic_caller_flags=${-//[^euvx]/} + if [[ :$SHELLOPTS: == *:posix:* ]]; then + cronic_posix_off= + else + cronic_posix_off="+o posix" + POSIXLY_CORRECT=y + fi set +vx + cronic_caller_functions=$(unset -f command; command declare -f builtin) || : + cronic_caller_function_names=${cronic_caller_functions:+ builtin} + unset -f builtin + while builtin read -r cronic_name; do + if [[ -n $cronic_name && $cronic_name != cronic_* ]]; then + cronic_caller_functions+=$'\n'"$(builtin declare -f -- "$cronic_name")" + builtin printf -v cronic_name_quoted ' %q' "$cronic_name" + cronic_caller_function_names+=$cronic_name_quoted + fi + unset -f -- "$cronic_name" + done <<< "$(builtin compgen -A function)" + [ -z "$cronic_posix_off" ] || unset POSIXLY_CORRECT } 2> /dev/null # Cronic v2 - cron job report wrapper @@ -34,6 +52,15 @@ # input, which would go to this script's stderr, so that cron would send email # for every run. It comes before any comment, because bash echoes every line # that it reads while verbose is on. The redirection discards its own trace. +# Finally, the first command saves the definitions of the caller's exported +# functions, which bash has imported, and removes every function, so that none +# runs in place of a builtin or program that this script uses. The command +# gets the functions back. Assigning POSIXLY_CORRECT turns on posix mode, in +# which a special builtin, such as `set` or `unset`, runs in place of a function +# of the same name. Once `builtin` is saved (via `command`, in a subshell that +# removes any function named `command`) and removed, it runs the other builtins. +# Unsetting POSIXLY_CORRECT turns posix mode off again, unless the caller's +# shell was already in posix mode. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. @@ -200,28 +227,30 @@ fi # not run in place of a program of the same name. Each function name is read # whole, so that a name that contains a glob character is not expanded; and # from a here-string, because bash before 5.1 rejects a process substitution in -# posix mode, which an exported SHELLOPTS can set. The `eval` expands -# cronic_caller_flags before `unset` removes it. It turns off this script's -# nounset (errexit is off already) in the same command that turns on the -# caller's options, so that the restoring is neither traced nor echoed. The -# `:+` avoids a bare `set -`, which would turn off verbose and xtrace. -# Each command is run via `builtin`, because the caller's exported functions -# have been imported, and outside posix mode a function such as `unset` would -# run in place of the builtin of the same name. +# posix mode, which an exported SHELLOPTS can set. The `eval` expands the +# cronic_caller_* variables before `unset` removes them. It then defines the +# caller's functions, which can replace a builtin, so the remaining commands run +# in posix mode, as special builtins. The last command turns posix mode off +# (unless the caller's shell was in posix mode), turns off this script's +# nounset (errexit is off already), and turns on the caller's options, so that +# the restoring is neither traced nor echoed. set +e ( - while builtin read -r cronic_name; do - builtin unset -f "$cronic_name" - done <<< "$(builtin compgen -A function cronic_)" - builtin eval "builtin unset -v \"\${!cronic_@}\" - builtin set +u${cronic_caller_flags:+ -$cronic_caller_flags}" + while read -r cronic_name; do + unset -f "$cronic_name" + done <<< "$(compgen -A function cronic_)" + eval "unset -v \"\${!cronic_@}\" + $cronic_caller_functions + ${cronic_posix_off:+POSIXLY_CORRECT=y} + ${cronic_caller_function_names:+export -f$cronic_caller_function_names} + set +u $cronic_posix_off${cronic_caller_flags:+ -$cronic_caller_flags}" "$@" ) > "$cronic_out" 2> "$cronic_trace" cronic_result=$? set -e # Unsetting BASH_XTRACEFD also closes its file descriptor. -builtin unset BASH_XTRACEFD +unset BASH_XTRACEFD # Prints a basic regular expression that matches its argument literally. # The argument must not contain a backslash. diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index cdfd7f4..1992123 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -483,9 +483,9 @@ check "caller's exported variables" 0 silent "$work/check-variables" $exported_n unset $exported_names # Names that start with `cronic_` are reserved: the command does not see a -# variable or function with such a name that the caller exported, even when the -# caller also exported functions named like the builtins that `cronic` uses to -# hide such names. +# variable or function with such a name that the caller exported. The caller +# also exports functions named like builtins that `cronic` uses, which must not +# run in place of those builtins, though the command does see them. cat > "$work/cronic-with-cronic-names" << 'EOF' #!/bin/bash cronic_mine() { @@ -494,11 +494,15 @@ cronic_mine() { cronic_debug="caller's cronic_debug" export -f cronic_mine export cronic_debug +builtin() { echo "function builtin ran" >&2; } compgen() { echo "function compgen ran" >&2; } +declare() { echo "function declare ran" >&2; } eval() { echo "function eval ran" >&2; } +export() { echo "function export ran" >&2; } read() { echo "function read ran" >&2; } +set() { echo "function set ran" >&2; } unset() { echo "function unset ran" >&2; } -export -f compgen eval read unset +command export -f builtin compgen declare eval export read set unset exec "$REAL_CRONIC" "$@" EOF chmod +x "$work/cronic-with-cronic-names" @@ -507,7 +511,9 @@ CRONIC="$work/cronic-with-cronic-names" check "caller's exported cronic_ variable is not seen" 0 silent \ sh -c '[ -z "${cronic_debug+set}" ]' check "caller's exported cronic_ function is not seen" 0 silent \ - bash -c '! declare -F cronic_mine' + bash -c '! command declare -F cronic_mine' +check "caller's exported functions are seen" 0 silent \ + bash -c 'command declare -F builtin set unset > /dev/null' CRONIC="$REAL_CRONIC" # `bash -x cronic` does not trace `cronic` itself, but does trace running the From 9c89a54cac2172dcde91c9760f7472a982a027d0 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 19:34:47 -0700 Subject: [PATCH 10/32] Save only exported functions, with one declare -f, outside posix mode Posix mode rejects a function name such as my-fn, which was lost, and with an exported errexit stopped cronic silently. A function that BASH_ENV defined is no longer exported to the command. --- cronic | 36 +++++++++++++++++++++----------- tests/cronic-test/cronic-test.sh | 25 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/cronic b/cronic index e55cdde..41ec4e5 100755 --- a/cronic +++ b/cronic @@ -8,15 +8,24 @@ POSIXLY_CORRECT=y fi set +vx - cronic_caller_functions=$(unset -f command; command declare -f builtin) || : - cronic_caller_function_names=${cronic_caller_functions:+ builtin} + cronic_exported=$(export -pf) + cronic_caller_functions= + if [[ $'\n'$cronic_exported$'\n' == *$'\n'"export -f builtin"$'\n'* ]]; then + cronic_caller_functions=$(unset -f command; unset POSIXLY_CORRECT; command declare -f builtin) + fi unset -f builtin + cronic_caller_function_names= while builtin read -r cronic_name; do + cronic_name=${cronic_name#export -f } if [[ -n $cronic_name && $cronic_name != cronic_* ]]; then - cronic_caller_functions+=$'\n'"$(builtin declare -f -- "$cronic_name")" builtin printf -v cronic_name_quoted ' %q' "$cronic_name" cronic_caller_function_names+=$cronic_name_quoted fi + done <<< "$cronic_exported" + if [[ -n $cronic_caller_function_names ]]; then + cronic_caller_functions+=$'\n'$(unset POSIXLY_CORRECT; builtin eval "builtin declare -f --$cronic_caller_function_names") || : + fi + while builtin read -r cronic_name; do unset -f -- "$cronic_name" done <<< "$(builtin compgen -A function)" [ -z "$cronic_posix_off" ] || unset POSIXLY_CORRECT @@ -53,14 +62,17 @@ # for every run. It comes before any comment, because bash echoes every line # that it reads while verbose is on. The redirection discards its own trace. # Finally, the first command saves the definitions of the caller's exported -# functions, which bash has imported, and removes every function, so that none -# runs in place of a builtin or program that this script uses. The command -# gets the functions back. Assigning POSIXLY_CORRECT turns on posix mode, in -# which a special builtin, such as `set` or `unset`, runs in place of a function -# of the same name. Once `builtin` is saved (via `command`, in a subshell that -# removes any function named `command`) and removed, it runs the other builtins. -# Unsetting POSIXLY_CORRECT turns posix mode off again, unless the caller's -# shell was already in posix mode. +# functions, which bash has imported, and removes every function, including any +# that BASH_ENV defined, so that none runs in place of a builtin or program that +# this script uses. The command gets the exported functions back. Assigning +# POSIXLY_CORRECT turns on posix mode, in which a special builtin, such as +# `export`, `set`, or `unset`, runs in place of a function of the same name; in +# posix mode, `export -pf` lists the names of the exported functions. Once +# `builtin` is saved (via `command`, in a subshell that removes any function +# named `command`) and removed, it runs the other builtins. `declare -f` runs +# with posix mode off, because posix mode rejects a function name, such as +# `my-fn`, that is not an identifier. Unsetting POSIXLY_CORRECT turns posix +# mode off again, unless the caller's shell was already in posix mode. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. @@ -242,7 +254,7 @@ set +e eval "unset -v \"\${!cronic_@}\" $cronic_caller_functions ${cronic_posix_off:+POSIXLY_CORRECT=y} - ${cronic_caller_function_names:+export -f$cronic_caller_function_names} + ${cronic_caller_function_names:+export -f --$cronic_caller_function_names} set +u $cronic_posix_off${cronic_caller_flags:+ -$cronic_caller_flags}" "$@" ) > "$cronic_out" 2> "$cronic_trace" diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 1992123..70f3d46 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -516,6 +516,31 @@ check "caller's exported functions are seen" 0 silent \ bash -c 'command declare -F builtin set unset > /dev/null' CRONIC="$REAL_CRONIC" +# A function whose name is not an identifier reaches the command, even when +# the caller exported errexit. A function that BASH_ENV defined, rather than +# one that the caller exported, is not exported to the command. +cat > "$work/cronic-with-odd-names" << 'EOF' +#!/bin/bash +my-fn() { + echo "my-fn ran" +} +export -f my-fn +set -o errexit +export SHELLOPTS +exec "$REAL_CRONIC" "$@" +EOF +chmod +x "$work/cronic-with-odd-names" +echo 'from_bash_env() { :; }' > "$work/bash-env" +CRONIC="$work/cronic-with-odd-names" +BASH_ENV="$work/bash-env" +export BASH_ENV +check "caller's exported function whose name is not an identifier" 1 \ + "report:my-fn ran" bash -c 'my-fn; false' +check "function from BASH_ENV is not exported" 0 silent \ + bash -c '! printenv "BASH_FUNC_from_bash_env%%"' +unset BASH_ENV +CRONIC="$REAL_CRONIC" + # `bash -x cronic` does not trace `cronic` itself, but does trace running the # command. The command does not inherit xtrace, because SHELLOPTS is not # exported, so its own `set -x` is not traced. From dc1ee8c8d4d29281cd2a9498afc084a1c484bec2 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 19:36:52 -0700 Subject: [PATCH 11/32] Remove this script's functions in the subshell with one unset -f --- cronic | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/cronic b/cronic index 41ec4e5..3c7876e 100755 --- a/cronic +++ b/cronic @@ -234,12 +234,11 @@ fi # reports. The subshell is not the left operand of `||`, because bash would # then ignore `set -e` throughout the subshell, including in a shell function # that it runs. -# The subshell unsets every function and variable whose name starts with -# `cronic_`, so that the command does not see them, and so that a function does -# not run in place of a program of the same name. Each function name is read -# whole, so that a name that contains a glob character is not expanded; and -# from a here-string, because bash before 5.1 rejects a process substitution in -# posix mode, which an exported SHELLOPTS can set. The `eval` expands the +# The subshell unsets this script's functions, which are the only ones that +# remain, and every variable whose name starts with `cronic_`, so that the +# command does not see them, and so that a function does not run in place of a +# program of the same name. The names of this script's functions are +# identifiers, so they need no quoting. The `eval` expands the # cronic_caller_* variables before `unset` removes them. It then defines the # caller's functions, which can replace a builtin, so the remaining commands run # in posix mode, as special builtins. The last command turns posix mode off @@ -248,9 +247,8 @@ fi # the restoring is neither traced nor echoed. set +e ( - while read -r cronic_name; do - unset -f "$cronic_name" - done <<< "$(compgen -A function cronic_)" + # shellcheck disable=SC2046 # each name is a separate argument. + unset -f $(compgen -A function) eval "unset -v \"\${!cronic_@}\" $cronic_caller_functions ${cronic_posix_off:+POSIXLY_CORRECT=y} From f1a5de8593b0ccbafc62d819583a910f7ee0f066 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 19:51:39 -0700 Subject: [PATCH 12/32] Test exported builtin and command functions; explain why builtin is saved separately --- cronic | 11 +++++++---- tests/cronic-test/cronic-test.sh | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/cronic b/cronic index 3c7876e..9036833 100755 --- a/cronic +++ b/cronic @@ -69,10 +69,13 @@ # `export`, `set`, or `unset`, runs in place of a function of the same name; in # posix mode, `export -pf` lists the names of the exported functions. Once # `builtin` is saved (via `command`, in a subshell that removes any function -# named `command`) and removed, it runs the other builtins. `declare -f` runs -# with posix mode off, because posix mode rejects a function name, such as -# `my-fn`, that is not an identifier. Unsetting POSIXLY_CORRECT turns posix -# mode off again, unless the caller's shell was already in posix mode. +# named `command`) and removed, it runs the other builtins. `builtin` needs +# that separate path because `command` cannot be removed first: `builtin` +# saves the definition of `command` along with the caller's other functions. +# `declare -f` runs with posix mode off, because posix mode rejects a function +# name, such as `my-fn`, that is not an identifier. Unsetting POSIXLY_CORRECT +# turns posix mode off again, unless the caller's shell was already in posix +# mode. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 70f3d46..daa3cbc 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -516,6 +516,23 @@ check "caller's exported functions are seen" 0 silent \ bash -c 'command declare -F builtin set unset > /dev/null' CRONIC="$REAL_CRONIC" +# The caller may export functions named `builtin` and `command`, which `cronic` +# uses to save the caller's functions; the command sees both of them. +cat > "$work/cronic-with-builtin-and-command" << 'EOF' +#!/bin/bash +builtin() { echo "function builtin ran"; } +command() { echo "function command ran"; } +export -f builtin command +exec "$REAL_CRONIC" "$@" +EOF +chmod +x "$work/cronic-with-builtin-and-command" +CRONIC="$work/cronic-with-builtin-and-command" +# shellcheck disable=SC2016 # The expansion is for `bash` to do. +check "caller's exported functions named builtin and command are seen" 1 \ + "report:function builtin ran, function command ran" \ + bash -c 'echo "$(builtin), $(command)"; false' +CRONIC="$REAL_CRONIC" + # A function whose name is not an identifier reaches the command, even when # the caller exported errexit. A function that BASH_ENV defined, rather than # one that the caller exported, is not exported to the command. From f764fe69d28297531572431d53c04c2622ebc81e Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 19:56:22 -0700 Subject: [PATCH 13/32] Report a function that cronic cannot remove; document options that BASH_ENV sets --- cronic | 60 ++++++++++++++++++-------------- tests/cronic-test/cronic-test.sh | 8 +++++ 2 files changed, 41 insertions(+), 27 deletions(-) diff --git a/cronic b/cronic index 9036833..d3c9393 100755 --- a/cronic +++ b/cronic @@ -8,28 +8,28 @@ POSIXLY_CORRECT=y fi set +vx - cronic_exported=$(export -pf) - cronic_caller_functions= - if [[ $'\n'$cronic_exported$'\n' == *$'\n'"export -f builtin"$'\n'* ]]; then - cronic_caller_functions=$(unset -f command; unset POSIXLY_CORRECT; command declare -f builtin) - fi - unset -f builtin - cronic_caller_function_names= - while builtin read -r cronic_name; do - cronic_name=${cronic_name#export -f } - if [[ -n $cronic_name && $cronic_name != cronic_* ]]; then - builtin printf -v cronic_name_quoted ' %q' "$cronic_name" - cronic_caller_function_names+=$cronic_name_quoted - fi - done <<< "$cronic_exported" - if [[ -n $cronic_caller_function_names ]]; then - cronic_caller_functions+=$'\n'$(unset POSIXLY_CORRECT; builtin eval "builtin declare -f --$cronic_caller_function_names") || : - fi - while builtin read -r cronic_name; do - unset -f -- "$cronic_name" - done <<< "$(builtin compgen -A function)" - [ -z "$cronic_posix_off" ] || unset POSIXLY_CORRECT } 2> /dev/null +cronic_exported=$(export -pf) +cronic_caller_functions= +if [[ $'\n'$cronic_exported$'\n' == *$'\n'"export -f builtin"$'\n'* ]]; then + cronic_caller_functions=$(unset -f command; unset POSIXLY_CORRECT; command declare -f builtin) +fi +unset -f builtin +cronic_caller_function_names= +while builtin read -r cronic_name; do + cronic_name=${cronic_name#export -f } + if [[ -n $cronic_name && $cronic_name != cronic_* ]]; then + builtin printf -v cronic_name_quoted ' %q' "$cronic_name" + cronic_caller_function_names+=$cronic_name_quoted + fi +done <<< "$cronic_exported" +if [[ -n $cronic_caller_function_names ]]; then + cronic_caller_functions+=$'\n'$(unset POSIXLY_CORRECT; builtin eval "builtin declare -f --$cronic_caller_function_names") || : +fi +while builtin read -r cronic_name; do + unset -f -- "$cronic_name" +done <<< "$(builtin compgen -A function)" +[ -z "$cronic_posix_off" ] || unset POSIXLY_CORRECT # Cronic v2 - cron job report wrapper # Copyright 2007 Chuck Houpt @@ -53,7 +53,9 @@ # feature of a later bash only after checking the version. # The first command saves the caller's shell options, as the letters that `$-` -# uses for them, before this script changes them for itself. This script +# uses for them, before this script changes them for itself. They include any +# option that BASH_ENV set, because bash runs BASH_ENV before this script's +# first line and does not record the options that it started with. 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. @@ -61,8 +63,8 @@ # input, which would go to this script's stderr, so that cron would send email # for every run. It comes before any comment, because bash echoes every line # that it reads while verbose is on. The redirection discards its own trace. -# Finally, the first command saves the definitions of the caller's exported -# functions, which bash has imported, and removes every function, including any +# The commands after it save the definitions of the caller's exported +# functions, which bash has imported, and remove every function, including any # that BASH_ENV defined, so that none runs in place of a builtin or program that # this script uses. The command gets the exported functions back. Assigning # POSIXLY_CORRECT turns on posix mode, in which a special builtin, such as @@ -75,7 +77,10 @@ # `declare -f` runs with posix mode off, because posix mode rejects a function # name, such as `my-fn`, that is not an identifier. Unsetting POSIXLY_CORRECT # turns posix mode off again, unless the caller's shell was already in posix -# mode. +# mode. Their error output is not discarded: if `unset` cannot remove a +# function, such as one that BASH_ENV made readonly, then in posix mode bash +# reports the error and exits, so cron sends email rather than failing +# silently. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. @@ -111,8 +116,9 @@ cronic_help() { echo "Names that start with \"cronic_\" are reserved: COMMAND does not see a" echo "variable or function with such a name, even one that the caller exported." echo "COMMAND runs with the caller's shell options, such as errexit, nounset," - echo "and xtrace, rather than with this script's. This script never traces" - echo "itself, so \"bash -x cronic COMMAND\" traces only the running of COMMAND." + echo "and xtrace (including any that BASH_ENV sets), rather than with this" + echo "script's. This script never traces itself, so \"bash -x cronic COMMAND\"" + echo "traces only the running of COMMAND." echo "Execution traces (from \"set -x\") are not treated as error output." echo "Bash 4.1 or later writes them to a separate file, via BASH_XTRACEFD; they" echo "appear in the report under \"TRACE OUTPUT\". Other shells write them to" diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index daa3cbc..ef2444f 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -558,6 +558,14 @@ check "function from BASH_ENV is not exported" 0 silent \ unset BASH_ENV CRONIC="$REAL_CRONIC" +# A function that BASH_ENV made readonly cannot be removed, so `cronic` does not +# run the command, but it fails with an error message rather than silently. +echo 'grep() { echo "function grep ran"; }; readonly -f grep' > "$work/bash-env" +BASH_ENV="$work/bash-env" +export BASH_ENV +check "readonly function from BASH_ENV" 1 "message:readonly function" true +unset BASH_ENV + # `bash -x cronic` does not trace `cronic` itself, but does trace running the # command. The command does not inherit xtrace, because SHELLOPTS is not # exported, so its own `set -x` is not traced. From 98a2358acfbe6a3e730c95ea3f87baf4b7d470fb Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 20:40:21 -0700 Subject: [PATCH 14/32] Remove aliases at startup so that BASH_ENV cannot change the caller's functions; skip empty names --- cronic | 8 +++++++- tests/cronic-test/cronic-test.sh | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/cronic b/cronic index d3c9393..31c8a90 100755 --- a/cronic +++ b/cronic @@ -9,6 +9,7 @@ fi set +vx } 2> /dev/null +\unalias -a cronic_exported=$(export -pf) cronic_caller_functions= if [[ $'\n'$cronic_exported$'\n' == *$'\n'"export -f builtin"$'\n'* ]]; then @@ -27,7 +28,7 @@ if [[ -n $cronic_caller_function_names ]]; then cronic_caller_functions+=$'\n'$(unset POSIXLY_CORRECT; builtin eval "builtin declare -f --$cronic_caller_function_names") || : fi while builtin read -r cronic_name; do - unset -f -- "$cronic_name" + [[ -z $cronic_name ]] || unset -f -- "$cronic_name" done <<< "$(builtin compgen -A function)" [ -z "$cronic_posix_off" ] || unset POSIXLY_CORRECT @@ -63,6 +64,11 @@ done <<< "$(builtin compgen -A function)" # input, which would go to this script's stderr, so that cron would send email # for every run. It comes before any comment, because bash echoes every line # that it reads while verbose is on. The redirection discards its own trace. +# The next command removes every alias, such as one that BASH_ENV defined, so +# that no alias changes this script's commands or the caller's functions, which +# the command gets back by re-parsing their definitions; the backslash stops an +# alias named `unalias` from applying. Bash imports the caller's exported +# functions before it runs BASH_ENV, so their bodies never contain an alias. # The commands after it save the definitions of the caller's exported # functions, which bash has imported, and remove every function, including any # that BASH_ENV defined, so that none runs in place of a builtin or program that diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index ef2444f..9bdb69f 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -558,6 +558,33 @@ check "function from BASH_ENV is not exported" 0 silent \ unset BASH_ENV CRONIC="$REAL_CRONIC" +# An alias that BASH_ENV defined does not change the caller's exported function, +# just as it would not if bash ran the function directly. BASH_ENV turns on +# posix mode, because bash turns off expand_aliases when it leaves posix mode. +# The caller sets BASH_ENV only for `cronic`, so that the caller's own +# definition of the function contains no alias. +cat > "$work/cronic-with-alias" << 'EOF' +#!/bin/bash +print_hi() { + echo hi +} +export -f print_hi +BASH_ENV="$1" +export BASH_ENV +shift +exec "$REAL_CRONIC" "$@" +EOF +chmod +x "$work/cronic-with-alias" +cat > "$work/bash-env" << 'EOF' +shopt -s expand_aliases +alias echo='echo ALIASED' +set -o posix +EOF +CRONIC="$work/cronic-with-alias" +check "alias from BASH_ENV does not change exported function" 1 "report:^hi$" \ + "$work/bash-env" bash -c 'print_hi; false' +CRONIC="$REAL_CRONIC" + # A function that BASH_ENV made readonly cannot be removed, so `cronic` does not # run the command, but it fails with an error message rather than silently. echo 'grep() { echo "function grep ran"; }; readonly -f grep' > "$work/bash-env" From 2664126e2e8bebc189452715099536f70a0b7af2 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 22:02:01 -0700 Subject: [PATCH 15/32] Exit explicitly when unset cannot remove a function, because bash before 5.3 does not --- cronic | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cronic b/cronic index 02a37c4..07a7611 100755 --- a/cronic +++ b/cronic @@ -28,7 +28,7 @@ if [[ -n $cronic_caller_function_names ]]; then cronic_caller_functions+=$'\n'$(unset POSIXLY_CORRECT; builtin eval "builtin declare -f --$cronic_caller_function_names") || : fi while builtin read -r cronic_name; do - [[ -z $cronic_name ]] || unset -f -- "$cronic_name" + [[ -z $cronic_name ]] || unset -f -- "$cronic_name" || exit 1 done <<< "$(builtin compgen -A function)" [ -z "$cronic_posix_off" ] || unset POSIXLY_CORRECT @@ -90,9 +90,11 @@ done <<< "$(builtin compgen -A function)" # name, such as `my-fn`, that is not an identifier. Unsetting POSIXLY_CORRECT # turns posix mode off again, unless the caller's shell was already in posix # mode. Their error output is not discarded: if `unset` cannot remove a -# function, such as one that BASH_ENV made readonly, then in posix mode bash -# reports the error and exits, so cron sends email rather than failing -# silently. +# function, such as one that BASH_ENV made readonly, then bash reports the +# error and this script exits, so cron sends email rather than failing +# silently. The exit is explicit because only bash 5.3 or later exits when, in +# posix mode, `unset` fails on a readonly function. `exit` is a special +# builtin, so in posix mode no function runs in its place. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. From 20282ca0a9606cf466386fda761be22dc180d12a Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 10:31:27 -0700 Subject: [PATCH 16/32] cronic: do not trace cronic's own `command -- ...` line when the caller's xtrace is on --- cronic | 20 +++++++++++++++----- tests/cronic-test/cronic-test.sh | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/cronic b/cronic index 1bb3849..3a4d5ac 100755 --- a/cronic +++ b/cronic @@ -184,14 +184,24 @@ fi # 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 -# meaning to `set`. +# caller exported SHELLOPTS. The `:+` avoids a bare `-`, which has a special +# meaning to `set`. With the caller's xtrace on, bash traces the `set` command +# and the `command -- ...` line, which are this script's, not the command's. +# So the subshell points the trace descriptor at /dev/null, and the redirection +# that points it back at the trace file is on the `command` line itself, which +# bash traces before performing the redirections. `eval` because a +# redirection's target descriptor must be written literally. The trace of a +# builtin COMMAND, such as `exit`, is the `command -- ...` line, so it is +# discarded too. set +e ( + cronic_redirections= + if [ -n "${cronic_xtrace_fd-}" ]; then + eval "exec {cronic_saved_fd}>&$cronic_xtrace_fd $cronic_xtrace_fd> /dev/null" + cronic_redirections="$cronic_xtrace_fd>&$cronic_saved_fd $cronic_saved_fd>&-" + fi set +u${cronic_caller_flags:+ -$cronic_caller_flags} - command -- "$@" + eval "command -- \"\$@\" $cronic_redirections" ) > "$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 52f89ee..1279498 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -400,7 +400,7 @@ 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 +if grep -q -e cronic_ -e "command --" "$work/output"; then echo "FAIL: caller's xtrace exported: the report traces cronic itself:" cat "$work/output" status=1 From bd37b2cc75cc3f6e7ae31129062b5ee321dc1f96 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 10:31:36 -0700 Subject: [PATCH 17/32] Suppress a shellcheck false positive --- cronic | 1 + 1 file changed, 1 insertion(+) diff --git a/cronic b/cronic index 3a4d5ac..15eb4a4 100755 --- a/cronic +++ b/cronic @@ -198,6 +198,7 @@ set +e cronic_redirections= if [ -n "${cronic_xtrace_fd-}" ]; then eval "exec {cronic_saved_fd}>&$cronic_xtrace_fd $cronic_xtrace_fd> /dev/null" + # shellcheck disable=SC2154 # The `eval` above assigns cronic_saved_fd. cronic_redirections="$cronic_xtrace_fd>&$cronic_saved_fd $cronic_saved_fd>&-" fi set +u${cronic_caller_flags:+ -$cronic_caller_flags} From bb5b3824a2b7cf978ed1bacce97bedac55c6d7ed Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 11:56:49 -0700 Subject: [PATCH 18/32] Fix cronic under bash 3.2: remove functions with posix mode off; do not test via sh, which may be bash In posix mode, bash 3.2 rejects `unset -f` on a function whose name is not an identifier, so cronic now turns posix mode off before removing functions, and runs `unset` and `exit` via `builtin`. When `sh` is bash, as on macOS, it assigns POSIXLY_CORRECT itself, so two tests now run bash rather than `sh`. --- cronic | 16 ++++++++-------- tests/cronic-test/cronic-test.sh | 11 +++++++---- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/cronic b/cronic index 7755ed4..6c343be 100755 --- a/cronic +++ b/cronic @@ -27,10 +27,10 @@ done <<< "$cronic_exported" if [[ -n $cronic_caller_function_names ]]; then cronic_caller_functions+=$'\n'$(unset POSIXLY_CORRECT; builtin eval "builtin declare -f --$cronic_caller_function_names") || : fi +[[ -z $cronic_posix_off ]] || unset POSIXLY_CORRECT while builtin read -r cronic_name; do - [[ -z $cronic_name ]] || unset -f -- "$cronic_name" || exit 1 + [[ -z $cronic_name ]] || builtin unset -f -- "$cronic_name" || builtin exit 1 done <<< "$(builtin compgen -A function)" -[ -z "$cronic_posix_off" ] || unset POSIXLY_CORRECT # Cronic v2 - cron job report wrapper # Copyright 2007 Chuck Houpt @@ -91,12 +91,12 @@ done <<< "$(builtin compgen -A function)" # `declare -f` runs with posix mode off, because posix mode rejects a function # name, such as `my-fn`, that is not an identifier. Unsetting POSIXLY_CORRECT # turns posix mode off again, unless the caller's shell was already in posix -# mode. Their error output is not discarded: if `unset` cannot remove a -# function, such as one that BASH_ENV made readonly, then bash reports the -# error and this script exits, so cron sends email rather than failing -# silently. The exit is explicit because only bash 5.3 or later exits when, in -# posix mode, `unset` fails on a readonly function. `exit` is a special -# builtin, so in posix mode no function runs in its place. +# mode, before the loop that removes the functions, because in posix mode bash +# 3.2 also rejects such a name in `unset -f`. The loop runs `unset` and +# `exit` via `builtin`, so that no function runs in their place. Their error +# output is not discarded: if `unset` cannot remove a function, such as one +# that BASH_ENV made readonly, then bash reports the error and this script +# exits, so cron sends email rather than failing silently. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 5bbe58a..c53bb0a 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -535,11 +535,12 @@ check "exported function does not see unexported variable from BASH_ENV" 0 \ silent "$default_shellopts" report_cronic_variables unset BASH_ENV # Under the caller's allexport, the command's environment contains none of -# `cronic`'s variables, nor the POSIXLY_CORRECT that `cronic` assigns. -# shellcheck disable=SC2016 # The expansion is for `sh` to do. +# `cronic`'s variables, nor the POSIXLY_CORRECT that `cronic` assigns. The +# command is `bash` rather than `sh`, because when `sh` is bash, as on macOS, +# it assigns POSIXLY_CORRECT itself, and allexport exports it. check "exported SHELLOPTS with allexport, environment has no cronic variable" 0 \ silent "$default_shellopts:allexport" \ - sh -c 'env | grep -E "^(cronic_|POSIXLY_CORRECT=)" >&2; :' + bash -c 'env | grep -E "^(cronic_|POSIXLY_CORRECT=)" >&2; :' CRONIC="$REAL_CRONIC" # The wrapped command sees the caller's exported variables unchanged. For @@ -562,8 +563,10 @@ for name in $variable_names; do exported_names="$exported_names $exported" done done +# `check-variables` runs under bash rather than `sh`, because when `sh` is +# bash, as on macOS, it replaces the caller's POSIXLY_CORRECT by "y". cat > "$work/check-variables" << 'EOF' -#!/bin/sh +#!/usr/bin/env bash for name; do eval "value=\${$name-}" [ "$value" = "caller's $name" ] || exit 3 From 41e6f95290918cbef0d4a49ae06412e1d79f3c21 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:01:23 -0700 Subject: [PATCH 19/32] cronic: fix the trace suppression under bash 3.2, and avoid an extra trace level Swap the trace descriptor only when the caller's xtrace is on and bash is 4.1 or later; run the command directly otherwise. Put the restoring redirection in a function rather than an eval, so that a builtin's trace is not nested an extra level. Test a builtin under xtrace, and check every xtrace case for cronic's own trace. Co-Authored-By: Claude Opus 5.5 --- cronic | 34 +++++++++------- tests/cronic-test/cronic-test.sh | 67 +++++++++++++++++++------------- 2 files changed, 61 insertions(+), 40 deletions(-) diff --git a/cronic b/cronic index 15eb4a4..a904644 100755 --- a/cronic +++ b/cronic @@ -185,24 +185,32 @@ fi # 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. The `:+` avoids a bare `-`, which has a special -# meaning to `set`. With the caller's xtrace on, bash traces the `set` command -# and the `command -- ...` line, which are this script's, not the command's. -# So the subshell points the trace descriptor at /dev/null, and the redirection -# that points it back at the trace file is on the `command` line itself, which -# bash traces before performing the redirections. `eval` because a -# redirection's target descriptor must be written literally. The trace of a -# builtin COMMAND, such as `exit`, is the `command -- ...` line, so it is -# discarded too. +# meaning to `set`. With the caller's xtrace on, bash traces the +# `command -- ...` line, which is this script's, not the command's. So under +# bash 4.1 or later, the subshell points the trace descriptor at /dev/null, and +# the redirection that points it back at the trace file is on the `command` line +# itself, which bash traces before performing the redirections. That line is in +# a function, rather than in an `eval`, because an `eval` would add a level of +# nesting to every trace line of a builtin COMMAND. The function is defined by +# `eval` because the descriptor that a redirection changes, on the left of the +# operator, must be written literally; only the right side may be a variable. +# The trace of a builtin COMMAND, such as `exit`, is the `command -- ...` line, +# so it is discarded too. A bash before 4.1 writes the trace to stderr, which +# cannot be pointed at /dev/null without discarding the command's error output. set +e ( - cronic_redirections= - if [ -n "${cronic_xtrace_fd-}" ]; then + if [ -n "${cronic_xtrace_fd-}" ] && [[ $cronic_caller_flags == *x* ]]; then eval "exec {cronic_saved_fd}>&$cronic_xtrace_fd $cronic_xtrace_fd> /dev/null" # shellcheck disable=SC2154 # The `eval` above assigns cronic_saved_fd. - cronic_redirections="$cronic_xtrace_fd>&$cronic_saved_fd $cronic_saved_fd>&-" + eval "cronic_command() { + command -- \"\$@\" $cronic_xtrace_fd>&$cronic_saved_fd $cronic_saved_fd>&- + }" + set +u${cronic_caller_flags:+ -$cronic_caller_flags} + cronic_command "$@" + else + set +u${cronic_caller_flags:+ -$cronic_caller_flags} + command -- "$@" fi - set +u${cronic_caller_flags:+ -$cronic_caller_flags} - eval "command -- \"\$@\" $cronic_redirections" ) > "$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 1279498..8c1dcf7 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -336,6 +336,10 @@ check "exec builtin" 3 report exec "$work/trace-only" 3 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 +# The trace of an EXIT trap that a command sets, after turning on xtrace +# itself, is reported. +check "EXIT trap of a traced command" 3 "report:^++* false$" \ + eval 'set -x; trap false EXIT; exit 3' # 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 \ @@ -394,19 +398,45 @@ check "no cronic variables exported, caller's allexport exported" 0 silent \ # 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 ]"' +# A bash that supports BASH_XTRACEFD and the allocation of a file descriptor +# (bash 4.1 or later) writes its trace lines to a separate file, so they are +# not error output, whatever PS4 is. An earlier bash, such as macOS's +# /bin/bash, writes them to stderr, where `cronic` cannot recognize them when +# PS4 contains an expansion or an escape, so it reports them as error output. +# This tests for the features themselves, rather than for the bash version +# that `cronic` tests for, so that it detects a wrong version test in `cronic`. +# `cronic` and the bash scripts above all run under the first `bash` in PATH, +# so that is the bash tested here. The probe checks whether a trace line +# reaches the file, rather than whether bash writes to stderr, because bash can +# write an unrelated warning to stderr, such as for an uninstalled locale. +if bash -c 'exec {fd}> "$1" && BASH_XTRACEFD=$fd && set -x && :' \ + bash "$work/xtracefd-probe" 2> /dev/null && [ -s "$work/xtracefd-probe" ]; then + bash_has_xtracefd=true +else + bash_has_xtracefd=false +fi # When the caller exports xtrace, `cronic` traces only the command, not itself. +# A bash before 4.1 writes the trace to stderr, so `cronic` cannot discard the +# trace of its `command -- ...` line. CRONIC="$work/cronic-with-xtrace" -check "bash script, caller's xtrace exported" 0 silent \ +# check_xtrace DESCRIPTION EXPECTED-STATUS EXPECTED-OUTPUT COMMAND...: like +# `check`, and also checks that the output does not trace `cronic` itself. +check_xtrace() { + check "$@" + if grep -q -e cronic_ "$work/output" \ + || { [ "$bash_has_xtracefd" = true ] && grep -q -e "command --" "$work/output"; }; then + echo "FAIL: $1: the report traces cronic itself:" + cat "$work/output" + status=1 + fi +} +check_xtrace "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 -e cronic_ -e "command --" "$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 +check_xtrace "bash script that fails, caller's xtrace exported" 1 \ + "report:^+ false$" env BASH_ENV= bash -c 'false' +# A builtin's trace lines are nested 1 deeper than the builtin. +check_xtrace "builtin that fails, caller's xtrace exported" 1 \ + "report:^++ false$" eval false CRONIC="$saved_cronic" # The wrapped command sees the caller's exported variables unchanged. For @@ -446,23 +476,6 @@ check "caller's exported variables" 0 silent "$work/check-variables" $exported_n # shellcheck disable=SC2086 # each name is a separate argument. unset $exported_names -# A bash that supports BASH_XTRACEFD and the allocation of a file descriptor -# (bash 4.1 or later) writes its trace lines to a separate file, so they are -# not error output, whatever PS4 is. An earlier bash, such as macOS's -# /bin/bash, writes them to stderr, where `cronic` cannot recognize them when -# PS4 contains an expansion or an escape, so it reports them as error output. -# This tests for the features themselves, rather than for the bash version -# that `cronic` tests for, so that it detects a wrong version test in `cronic`. -# `cronic` and the bash scripts above all run under the first `bash` in PATH, -# so that is the bash tested here. The probe checks whether a trace line -# reaches the file, rather than whether bash writes to stderr, because bash can -# write an unrelated warning to stderr, such as for an uninstalled locale. -if bash -c 'exec {fd}> "$1" && BASH_XTRACEFD=$fd && set -x && :' \ - bash "$work/xtracefd-probe" 2> /dev/null && [ -s "$work/xtracefd-probe" ]; then - bash_has_xtracefd=true -else - bash_has_xtracefd=false -fi export PS4 # shellcheck disable=SC2016 # The expansion is for the traced command to do. PS4='+${LINENO}: ' From 004c6eeec0f7c69f5269383e9391374c76b2f151 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:02:14 -0700 Subject: [PATCH 20/32] 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 21/32] 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 22/32] 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 b67130f71f05393741a68322b07c0d57d0fc0f1d Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:17:15 -0700 Subject: [PATCH 23/32] Address review findings: portable shebangs, keep caller's options on, same-shell re-exec, reuse BASH_XTRACEFD probe, clarify first-command comment --- cronic | 33 +++++++++---- tests/cronic-test/cronic-test.sh | 79 +++++++++++++++++--------------- 2 files changed, 66 insertions(+), 46 deletions(-) diff --git a/cronic b/cronic index 9c3a8d3..405eb34 100755 --- a/cronic +++ b/cronic @@ -23,14 +23,17 @@ # BASH_XTRACEFD (bash 4.1), only after checking the version; see the # BASH_VERSINFO check below. -# The first command saves the caller's `set -o` shell options, as the -# colon-separated list of those that are on, before this script changes any of -# them for itself. This script changes no `shopt` option, so the command +# The first command (lines 2-5) saves the caller's `set -o` shell options, as +# the colon-separated list of those that are on, before this script changes any +# of them for itself. This script changes no `shopt` option, so the command # inherits those without any restoring. # The first command then stops this script from tracing itself or echoing its # input, which would go to this script's stderr, so that cron would send email -# for every run. It comes before any comment, because bash echoes every line -# that it reads while verbose is on. The redirection discards its own trace. +# for every run. The redirection discards its own trace. +# Do not add anything, not even a comment, before or within the first command, +# because bash echoes to stderr every line that it reads while verbose is on. +# The verbose check in tests/cronic-test/cronic-test.sh expects that echo to +# end at the first line that is exactly `} 2> /dev/null`. # Verbose is handled only partly: bash echoes this script's lines through the # end of the first command to stderr before that command can turn verbose off. # So if the caller exports SHELLOPTS with verbose, or runs `bash -v cronic`, @@ -198,10 +201,13 @@ fi # otherwise run in place of a program of the same name. The redirections # apply to the whole subshell, so that they also capture what it writes after # the command finishes, such as the output of an EXIT trap that the command -# sets. The subshell turns off every option that is on, which includes -# nounset, and turns on the caller's, in a single `set` command, so that the -# restoring is neither traced nor echoed. -# SHELLOPTS is not empty here, because nounset is on. The `:+` avoids a bare +# sets. The subshell turns off every option that is on but that the caller +# had off, and turns on the caller's, in a single `set` command, so that the +# restoring is neither traced nor echoed. It does not turn off and back on an +# option that the caller had on, because that cannot always be undone: turning +# off privileged resets the effective user and group IDs to the real ones. +# That `set` command has an argument, because nounset is on here, so either +# the caller had it on or the subshell turns it off. The `:+` avoids a bare # `set -o`, which would print the options, when the caller had none on. # The subshell is not the operand of `||` or the like, because then bash would # ignore `set -e` in it and would remove "errexit" from the SHELLOPTS that the @@ -211,9 +217,16 @@ fi # caller does not export SHELLOPTS. So if the caller does not use `set -u`, # that code silently expands an unset variable to the empty string. set +e +cronic_off_opts= +for cronic_opt in ${SHELLOPTS//:/ }; do + case ":$cronic_caller_opts:" in + *":$cronic_opt:"*) ;; + *) cronic_off_opts="$cronic_off_opts +o $cronic_opt" ;; + esac +done ( # shellcheck disable=SC2086 # each option name and +o or -o is an argument. - set +o ${SHELLOPTS//:/ +o } ${cronic_caller_opts:+-o ${cronic_caller_opts//:/ -o }} + set $cronic_off_opts ${cronic_caller_opts:+-o ${cronic_caller_opts//:/ -o }} command -- "$@" ) > "$cronic_out" 2> "$cronic_trace" cronic_result=$? diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 6d0412f..402a52d 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -27,12 +27,20 @@ unset BASH_ENV # Use the default PS4, which the goal files and the trace lines below assume. unset PS4 # Do not pass this script's own options, such as `-e` and `-u`, to `cronic` and -# to the commands below. If SHELLOPTS is exported, then run this script again -# without it, because bash makes SHELLOPTS read-only, so it cannot be unset. +# to the commands below. If SHELLOPTS is exported, then remove it from the +# environment. bash makes SHELLOPTS read-only, so it cannot be unset, but +# `export -n` stops exporting it; then this script runs again under the same +# bash, so that the caller's options do not apply to this script either. Any +# other shell ignores SHELLOPTS, so it can simply unset it. # `printenv` matches the name exactly, unlike a search of the output of `env`, # which can match a line within the value of another variable. if printenv SHELLOPTS > /dev/null; then - exec env -u SHELLOPTS sh "$0" "$@" + if [ -n "${BASH:-}" ]; then + # shellcheck disable=SC3045 # this branch runs only under bash. + export -n SHELLOPTS + exec "$BASH" "$0" "$@" + fi + unset SHELLOPTS fi SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)" @@ -144,7 +152,7 @@ chmod +x "$work/stderr-lines" # A bash command that reads an unset variable. cat > "$work/read-unset" << 'EOF' -#!/bin/bash +#!/usr/bin/env bash unset CRONIC_TEST_UNSET_VARIABLE echo "value: ${CRONIC_TEST_UNSET_VARIABLE}" > /dev/null exit "$1" @@ -153,13 +161,31 @@ chmod +x "$work/read-unset" # Runs `cronic` with SHELLOPTS set to its first argument and exported. cat > "$work/cronic-with-shellopts" << 'EOF2' -#!/bin/bash +#!/usr/bin/env bash shellopts="$1" shift exec env SHELLOPTS="$shellopts" "$REAL_CRONIC" "$@" EOF2 chmod +x "$work/cronic-with-shellopts" +# A bash that supports BASH_XTRACEFD and the allocation of a file descriptor +# (bash 4.1 or later) writes its trace lines to a separate file, so they are +# not error output, whatever PS4 is. An earlier bash, such as macOS's +# /bin/bash, writes them to stderr, where `cronic` cannot recognize them when +# PS4 contains an expansion or an escape, so it reports them as error output. +# This tests for the features themselves, rather than for the bash version +# that `cronic` tests for, so that it detects a wrong version test in `cronic`. +# `cronic` and the bash scripts above all run under the first `bash` in PATH, +# so that is the bash tested here. The probe checks whether a trace line +# reaches the file, rather than whether bash writes to stderr, because bash can +# write an unrelated warning to stderr, such as for an uninstalled locale. +if bash -c 'exec {fd}> "$1" && BASH_XTRACEFD=$fd && set -x && :' \ + bash "$work/xtracefd-probe" 2> /dev/null && [ -s "$work/xtracefd-probe" ]; then + bash_has_xtracefd=true +else + bash_has_xtracefd=false +fi + # temp_files: prints `cronic`'s temporary files, in a canonical order. temp_files() { find "$TMPDIR" -mindepth 1 -maxdepth 1 2> /dev/null | sort @@ -209,21 +235,19 @@ compare_streams() { fi } -# check_trace_section DESCRIPTION BASH GOAL-FILE COMMAND...: runs COMMAND, -# which runs `cronic` under the bash program BASH, and checks that it exits -# with status 0, leaves no temporary files, writes nothing to stderr, and -# produces a report whose "TRACE OUTPUT" section, through the following empty -# line, is the contents of GOAL-FILE. Skips the check if BASH is older than -# 4.1, which lacks BASH_XTRACEFD, so that the report has no "TRACE OUTPUT". +# check_trace_section DESCRIPTION GOAL-FILE COMMAND...: runs COMMAND, which +# runs `cronic`, and checks that it exits with status 0, leaves no temporary +# files, writes nothing to stderr, and produces a report whose "TRACE OUTPUT" +# section, through the following empty line, is the contents of GOAL-FILE. +# Skips the check if bash lacks BASH_XTRACEFD, so that the report has no +# "TRACE OUTPUT". check_trace_section() { description="$1" - bash_program="$2" - goal="$3" - shift 3 + goal="$2" + shift 2 - # shellcheck disable=SC2016 # the expansions are for bash, not this shell. - if ! "$bash_program" -c '((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1)))'; then - echo "SKIP: $description, because $bash_program is older than 4.1" + if [ "$bash_has_xtracefd" = false ]; then + echo "SKIP: $description, because bash lacks BASH_XTRACEFD" return 0 fi @@ -466,7 +490,7 @@ TRACE OUTPUT: + exit 0 EOF -check_trace_section "exported SHELLOPTS with xtrace, trace output" bash \ +check_trace_section "exported SHELLOPTS with xtrace, trace output" \ "$work/trace-section.goal" \ "$CRONIC" "$default_shellopts:xtrace" "$work/trace-and-stderr" 0 @@ -530,27 +554,10 @@ TRACE OUTPUT: + exit 0 EOF -check_trace_section "bash -x cronic, trace output" bash \ +check_trace_section "bash -x cronic, trace output" \ "$work/bash-x-trace-section.goal" \ bash -x "$CRONIC" "$work/trace-and-stderr" 0 -# A bash that supports BASH_XTRACEFD and the allocation of a file descriptor -# (bash 4.1 or later) writes its trace lines to a separate file, so they are -# not error output, whatever PS4 is. An earlier bash, such as macOS's -# /bin/bash, writes them to stderr, where `cronic` cannot recognize them when -# PS4 contains an expansion or an escape, so it reports them as error output. -# This tests for the features themselves, rather than for the bash version -# that `cronic` tests for, so that it detects a wrong version test in `cronic`. -# `cronic` and the bash scripts above all run under the first `bash` in PATH, -# so that is the bash tested here. The probe checks whether a trace line -# reaches the file, rather than whether bash writes to stderr, because bash can -# write an unrelated warning to stderr, such as for an uninstalled locale. -if bash -c 'exec {fd}> "$1" && BASH_XTRACEFD=$fd && set -x && :' \ - bash "$work/xtracefd-probe" 2> /dev/null && [ -s "$work/xtracefd-probe" ]; then - bash_has_xtracefd=true -else - bash_has_xtracefd=false -fi export PS4 # shellcheck disable=SC2016 # The expansion is for the traced command to do. PS4='+${LINENO}: ' From dd7cedfdfb00da24e0a18977a05d4db8c12e2f4a Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:27:30 -0700 Subject: [PATCH 24/32] cronic: restore only the options that cronic changes, using $- letters --- cronic | 37 +++++++++++++------------------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/cronic b/cronic index 405eb34..6849bbe 100755 --- a/cronic +++ b/cronic @@ -1,6 +1,6 @@ #!/usr/bin/env bash { - cronic_caller_opts=$SHELLOPTS + cronic_caller_flags=${-//[^aeuvx]/} set +vx } 2> /dev/null @@ -23,10 +23,11 @@ # BASH_XTRACEFD (bash 4.1), only after checking the version; see the # BASH_VERSINFO check below. -# The first command (lines 2-5) saves the caller's `set -o` shell options, as -# the colon-separated list of those that are on, before this script changes any -# of them for itself. This script changes no `shopt` option, so the command -# inherits those without any restoring. +# The first command (lines 2-5) saves the caller's settings of the shell +# options that this script changes for itself, as the letters that `$-` uses +# for them: allexport, errexit, nounset, verbose, and xtrace. The command +# inherits every other option, including every `shopt` option, unchanged. If +# this script ever changes another option, add its letter there. # The first command then stops this script from tracing itself or echoing its # input, which would go to this script's stderr, so that cron would send email # for every run. The redirection discards its own trace. @@ -46,7 +47,7 @@ # as an error. Do not export this script's variables to the command, even if # the caller's exported SHELLOPTS contains allexport. set -eu +a -export -n cronic_caller_opts +export -n cronic_caller_flags # Prints the usage line. cronic_usage_line() { @@ -191,7 +192,7 @@ if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))); export BASH_XTRACEFD=$cronic_xtrace_fd fi -# Run the command with the caller's `set -o` shell options rather than this +# Run the command with the caller's shell options rather than this # script's. If the caller exported SHELLOPTS, then the command would otherwise # inherit this script's options, and a bash script that reads an unset variable # (as `/usr/bin/texi2dvi` does when /bin/sh is bash) would fail. @@ -201,14 +202,10 @@ fi # otherwise run in place of a program of the same name. The redirections # apply to the whole subshell, so that they also capture what it writes after # the command finishes, such as the output of an EXIT trap that the command -# sets. The subshell turns off every option that is on but that the caller -# had off, and turns on the caller's, in a single `set` command, so that the -# restoring is neither traced nor echoed. It does not turn off and back on an -# option that the caller had on, because that cannot always be undone: turning -# off privileged resets the effective user and group IDs to the real ones. -# That `set` command has an argument, because nounset is on here, so either -# the caller had it on or the subshell turns it off. The `:+` avoids a bare -# `set -o`, which would print the options, when the caller had none on. +# sets. Of the options that this script changes, only nounset is on here, so +# the subshell turns it off and turns on the caller's, in a single `set` +# command, so that the restoring is neither traced nor echoed. The `:+` avoids +# a bare `-`, which has a special meaning to `set`. # The subshell is not the operand of `||` or the like, because then bash would # ignore `set -e` in it and would remove "errexit" from the SHELLOPTS that the # command inherits. @@ -217,16 +214,8 @@ fi # caller does not export SHELLOPTS. So if the caller does not use `set -u`, # that code silently expands an unset variable to the empty string. set +e -cronic_off_opts= -for cronic_opt in ${SHELLOPTS//:/ }; do - case ":$cronic_caller_opts:" in - *":$cronic_opt:"*) ;; - *) cronic_off_opts="$cronic_off_opts +o $cronic_opt" ;; - esac -done ( - # shellcheck disable=SC2086 # each option name and +o or -o is an argument. - set $cronic_off_opts ${cronic_caller_opts:+-o ${cronic_caller_opts//:/ -o }} + set +u${cronic_caller_flags:+ -$cronic_caller_flags} command -- "$@" ) > "$cronic_out" 2> "$cronic_trace" cronic_result=$? From 9e13f70adc7615f128ff368071f143d3e992a223 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:30:16 -0700 Subject: [PATCH 25/32] cronic: do not handle verbose; document that the caller should not export it --- cronic | 40 ++++++++++++++------------------ tests/cronic-test/cronic-test.sh | 11 --------- 2 files changed, 18 insertions(+), 33 deletions(-) diff --git a/cronic b/cronic index 6849bbe..4d185dd 100755 --- a/cronic +++ b/cronic @@ -1,7 +1,7 @@ #!/usr/bin/env bash { - cronic_caller_flags=${-//[^aeuvx]/} - set +vx + cronic_caller_flags=${-//[^aeux]/} + set +x } 2> /dev/null # Cronic v2 - cron job report wrapper @@ -25,23 +25,18 @@ # The first command (lines 2-5) saves the caller's settings of the shell # options that this script changes for itself, as the letters that `$-` uses -# for them: allexport, errexit, nounset, verbose, and xtrace. The command +# for them: allexport, errexit, nounset, and xtrace. The command # inherits every other option, including every `shopt` option, unchanged. If # this script ever changes another option, add its letter there. -# The first command then stops this script from tracing itself or echoing its -# input, which would go to this script's stderr, so that cron would send email -# for every run. The redirection discards its own trace. -# Do not add anything, not even a comment, before or within the first command, -# because bash echoes to stderr every line that it reads while verbose is on. -# The verbose check in tests/cronic-test/cronic-test.sh expects that echo to -# end at the first line that is exactly `} 2> /dev/null`. -# Verbose is handled only partly: bash echoes this script's lines through the -# end of the first command to stderr before that command can turn verbose off. -# So if the caller exports SHELLOPTS with verbose, or runs `bash -v cronic`, -# then cron sends email for every run. -# Likewise, bash runs the file named by BASH_ENV, if any, before this script's -# first command, so an exported xtrace, verbose, or nounset applies to that file -# and can make it write to stderr; such a cron job should unset BASH_ENV. +# The first command then stops this script from tracing itself, which would go +# to this script's stderr, so that cron would send email for every run. The +# redirection discards its own trace. +# This script does not handle verbose: if the caller exports SHELLOPTS with +# verbose, or runs `bash -v cronic`, then bash echoes this script's lines to +# stderr, so cron sends email for every run. `cronic --help` says not to. +# Bash runs the file named by BASH_ENV, if any, before this script's first +# command, so an exported xtrace or nounset applies to that file and can make +# it write to stderr; such a cron job should unset BASH_ENV. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. Do not export this script's variables to the command, even if @@ -74,16 +69,17 @@ cronic_help() { echo " is less likely to be overlooked" echo " -- end the options, so that COMMAND may start with \"-\"" echo " -h, --help print this message" + echo "Bash runs the BASH_ENV file, if any, before cronic starts, so" + echo "exported shell options can make that file write to stderr." echo "Execution traces (from \"set -x\") are not treated as error output." echo "Bash 4.1 or later writes them to a separate file, via BASH_XTRACEFD; they" echo "appear in the report under \"TRACE OUTPUT\". Other shells write them to" echo "stderr, from which lines that start with PS4 are removed. That removal" echo "is skipped if PS4 contains \"\$\", a backquote, a backslash, or a newline," echo "because such a PS4 can expand differently on each line." - echo "Under \"set -v\" exported via SHELLOPTS, or \"bash -v cronic\", bash echoes the" - echo "first lines of cronic to stderr before cronic can turn verbose off." - echo "Likewise, bash runs the BASH_ENV file, if any, before cronic starts, so" - echo "exported shell options can make that file write to stderr." + echo "Do not export the verbose option (\"set -v\") via SHELLOPTS: bash would echo" + echo "the lines of this script and of a bash COMMAND to stderr, so every run would" + echo "produce output." exit 0 } @@ -204,7 +200,7 @@ fi # the command finishes, such as the output of an EXIT trap that the command # sets. Of the options that this script changes, only nounset is on here, so # the subshell turns it off and turns on the caller's, in a single `set` -# command, so that the restoring is neither traced nor echoed. The `:+` avoids +# command, so that the restoring is not traced. The `:+` avoids # a bare `-`, which has a special meaning to `set`. # The subshell is not the operand of `||` or the like, because then bash would # ignore `set -e` in it and would remove "errexit" from the SHELLOPTS that the diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 402a52d..d01d289 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -494,17 +494,6 @@ check_trace_section "exported SHELLOPTS with xtrace, trace output" \ "$work/trace-section.goal" \ "$CRONIC" "$default_shellopts:xtrace" "$work/trace-and-stderr" 0 -# With verbose exported, a successful command produces no report: `cronic` -# does not echo, into the command's error output, the commands that restore the -# caller's options. `cronic` echoes to its stderr only its lines through its -# first command, which bash reads before `cronic` turns verbose off. -sed '/^} 2> \/dev\/null$/q' "$REAL_CRONIC" > "$work/verbose-stderr.goal" -if run_cronic "exported SHELLOPTS with verbose" 0 \ - "$CRONIC" "$default_shellopts:verbose" true; then - compare_streams "exported SHELLOPTS with verbose" \ - "$work/output" /dev/null "$work/verbose-stderr.goal" -fi - CRONIC="$REAL_CRONIC" # The wrapped command sees the caller's exported variables unchanged. For From 1867f64b2041bbd55ed45a7af4d00a5310fcd5f8 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:32:41 -0700 Subject: [PATCH 26/32] cronic-test: remove SHELLOPTS with `env -u` rather than the bashism `export -n` --- tests/cronic-test/cronic-test.sh | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index d01d289..89a5ac3 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -28,17 +28,15 @@ unset BASH_ENV unset PS4 # Do not pass this script's own options, such as `-e` and `-u`, to `cronic` and # to the commands below. If SHELLOPTS is exported, then remove it from the -# environment. bash makes SHELLOPTS read-only, so it cannot be unset, but -# `export -n` stops exporting it; then this script runs again under the same -# bash, so that the caller's options do not apply to this script either. Any -# other shell ignores SHELLOPTS, so it can simply unset it. +# environment. bash makes SHELLOPTS read-only, so it cannot be unset; +# instead, this script runs again under the same bash, with SHELLOPTS removed +# from its environment, so that the caller's options do not apply to this script +# either. Any other shell ignores SHELLOPTS, so it can simply unset it. # `printenv` matches the name exactly, unlike a search of the output of `env`, # which can match a line within the value of another variable. if printenv SHELLOPTS > /dev/null; then if [ -n "${BASH:-}" ]; then - # shellcheck disable=SC3045 # this branch runs only under bash. - export -n SHELLOPTS - exec "$BASH" "$0" "$@" + exec env -u SHELLOPTS "$BASH" "$0" "$@" fi unset SHELLOPTS fi From ccfbe14e3790d2ee3e4fbd01f210187b0f3951be Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:38:55 -0700 Subject: [PATCH 27/32] Address review findings: document an inherited BASH_XTRACEFD, quote the traced path as bash does, clarify help text, show diffs on test failure --- cronic | 32 +++++++++++++++++++------------- tests/cronic-test/cronic-test.sh | 17 +++++++++++++---- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/cronic b/cronic index 4d185dd..594a28d 100755 --- a/cronic +++ b/cronic @@ -23,20 +23,25 @@ # BASH_XTRACEFD (bash 4.1), only after checking the version; see the # BASH_VERSINFO check below. -# The first command (lines 2-5) saves the caller's settings of the shell -# options that this script changes for itself, as the letters that `$-` uses -# for them: allexport, errexit, nounset, and xtrace. The command -# inherits every other option, including every `shopt` option, unchanged. If -# this script ever changes another option, add its letter there. +# The first command, just after the shebang, saves the caller's settings of the +# shell options that this script changes for itself, as the letters that `$-` +# uses for them: allexport, errexit, nounset, and xtrace. The command inherits +# every other option, including every `shopt` option, unchanged. If this +# script ever changes another option, add its letter there. # The first command then stops this script from tracing itself, which would go # to this script's stderr, so that cron would send email for every run. The -# redirection discards its own trace. +# redirection discards its own trace. If the caller exports BASH_XTRACEFD, +# then bash writes that trace to the caller's trace descriptor instead, not to +# stderr. If that descriptor is not open, then bash reports so on stderr +# before it runs this script's first command, as for any bash script; cronic +# cannot prevent that. # This script does not handle verbose: if the caller exports SHELLOPTS with # verbose, or runs `bash -v cronic`, then bash echoes this script's lines to # stderr, so cron sends email for every run. `cronic --help` says not to. # Bash runs the file named by BASH_ENV, if any, before this script's first -# command, so an exported xtrace or nounset applies to that file and can make -# it write to stderr; such a cron job should unset BASH_ENV. +# command, so the caller's exported options, such as xtrace and nounset, apply +# to that file and can make it write to stderr; such a cron job should unset +# BASH_ENV. `cronic --help` says so. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. Do not export this script's variables to the command, even if @@ -69,17 +74,18 @@ cronic_help() { echo " is less likely to be overlooked" echo " -- end the options, so that COMMAND may start with \"-\"" echo " -h, --help print this message" - echo "Bash runs the BASH_ENV file, if any, before cronic starts, so" - echo "exported shell options can make that file write to stderr." echo "Execution traces (from \"set -x\") are not treated as error output." echo "Bash 4.1 or later writes them to a separate file, via BASH_XTRACEFD; they" echo "appear in the report under \"TRACE OUTPUT\". Other shells write them to" echo "stderr, from which lines that start with PS4 are removed. That removal" echo "is skipped if PS4 contains \"\$\", a backquote, a backslash, or a newline," echo "because such a PS4 can expand differently on each line." - echo "Do not export the verbose option (\"set -v\") via SHELLOPTS: bash would echo" - echo "the lines of this script and of a bash COMMAND to stderr, so every run would" - echo "produce output." + echo "Do not run \"bash -v cronic\", nor export the verbose option (\"set -v\") via" + echo "SHELLOPTS: bash would echo the lines of this script (and, if exported, of a" + echo "bash COMMAND) to stderr, so every run would produce output." + echo "Bash runs the file named by BASH_ENV, if any, before cronic starts, so" + echo "exported shell options can make that file write to stderr. To prevent that," + echo "unset BASH_ENV." exit 0 } diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 89a5ac3..2b723a3 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -158,12 +158,12 @@ EOF chmod +x "$work/read-unset" # Runs `cronic` with SHELLOPTS set to its first argument and exported. -cat > "$work/cronic-with-shellopts" << 'EOF2' +cat > "$work/cronic-with-shellopts" << 'EOF' #!/usr/bin/env bash shellopts="$1" shift exec env SHELLOPTS="$shellopts" "$REAL_CRONIC" "$@" -EOF2 +EOF chmod +x "$work/cronic-with-shellopts" # A bash that supports BASH_XTRACEFD and the allocation of a file descriptor @@ -229,6 +229,10 @@ compare_streams() { cat "$work/output" echo "stderr:" cat "$work/stderr" + echo "differences from the goal output:" + diff "$3" "$2" || true + echo "differences from the goal stderr:" + diff "$4" "$work/stderr" || true status=1 fi } @@ -480,9 +484,14 @@ check "exported SHELLOPTS with xtrace, trace-only stderr" 0 silent \ # command, followed by the command's own trace, with nothing that `cronic` did # to set up the command's options. Bash writes the trace via BASH_XTRACEFD, so # it appears under "TRACE OUTPUT". +# Bash quotes a word in a trace line if it contains a space or a shell +# metacharacter, so let bash write the expected trace of the path, which is +# under the caller's TMPDIR. +# shellcheck disable=SC2016 # The expansion is for bash to do. +traced_command=$(bash -c 'set -x; : -- "$1" 0' sh "$work/trace-and-stderr" 2>&1 | sed 's/^+ : /+ command /') cat > "$work/trace-section.goal" << EOF TRACE OUTPUT: -+ command -- $work/trace-and-stderr 0 +$traced_command + set -x + echo 'a real error' + exit 0 @@ -536,7 +545,7 @@ unset $exported_names # exported, so its own `set -x` is not traced. cat > "$work/bash-x-trace-section.goal" << EOF TRACE OUTPUT: -+ command -- $work/trace-and-stderr 0 +$traced_command + echo 'a real error' + exit 0 From febd38e5263c3d43766ca717777283111c0acd3a Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:43:47 -0700 Subject: [PATCH 28/32] Address review findings: keep bash warnings out of the expected trace, clarify BASH_ENV help text, inline compare_streams, regroup header comments --- cronic | 33 ++++++++++++++------------- tests/cronic-test/cronic-test.sh | 38 +++++++++++++------------------- 2 files changed, 33 insertions(+), 38 deletions(-) diff --git a/cronic b/cronic index 594a28d..12568eb 100755 --- a/cronic +++ b/cronic @@ -23,26 +23,29 @@ # BASH_XTRACEFD (bash 4.1), only after checking the version; see the # BASH_VERSINFO check below. -# The first command, just after the shebang, saves the caller's settings of the -# shell options that this script changes for itself, as the letters that `$-` -# uses for them: allexport, errexit, nounset, and xtrace. The command inherits -# every other option, including every `shopt` option, unchanged. If this -# script ever changes another option, add its letter there. -# The first command then stops this script from tracing itself, which would go -# to this script's stderr, so that cron would send email for every run. The -# redirection discards its own trace. If the caller exports BASH_XTRACEFD, -# then bash writes that trace to the caller's trace descriptor instead, not to -# stderr. If that descriptor is not open, then bash reports so on stderr -# before it runs this script's first command, as for any bash script; cronic -# cannot prevent that. # This script does not handle verbose: if the caller exports SHELLOPTS with # verbose, or runs `bash -v cronic`, then bash echoes this script's lines to # stderr, so cron sends email for every run. `cronic --help` says not to. + # Bash runs the file named by BASH_ENV, if any, before this script's first # command, so the caller's exported options, such as xtrace and nounset, apply # to that file and can make it write to stderr; such a cron job should unset # BASH_ENV. `cronic --help` says so. +# The first command in this file, just after the shebang, saves the caller's +# settings of the shell options that this script changes for itself, as the +# letters that `$-` uses for them: allexport, errexit, nounset, and xtrace. +# The command inherits every other option, including every `shopt` option, +# unchanged. If this script ever changes another option, add its letter there. + +# The first command then stops this script from tracing itself, which would go +# to this script's stderr, so that cron would send email for every run. The +# redirection discards its own trace. If the caller exports BASH_XTRACEFD, +# then bash writes that trace to the caller's trace descriptor instead, not to +# stderr. If that descriptor is not open, then bash reports so on stderr +# before it runs this script's first command, as for any bash script; cronic +# cannot prevent that. + # Exit if a command exits with a non-zero status, and treat unset variables # as an error. Do not export this script's variables to the command, even if # the caller's exported SHELLOPTS contains allexport. @@ -83,9 +86,9 @@ cronic_help() { echo "Do not run \"bash -v cronic\", nor export the verbose option (\"set -v\") via" echo "SHELLOPTS: bash would echo the lines of this script (and, if exported, of a" echo "bash COMMAND) to stderr, so every run would produce output." - echo "Bash runs the file named by BASH_ENV, if any, before cronic starts, so" - echo "exported shell options can make that file write to stderr. To prevent that," - echo "unset BASH_ENV." + echo "Bash runs the file named by BASH_ENV, if any, before cronic's own code, so" + echo "cronic cannot capture that file's output, and exported shell options can make" + echo "that file write to stderr. To prevent that, unset BASH_ENV." exit 0 } diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 2b723a3..f2335da 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -217,26 +217,6 @@ run_cronic() { fi } -# compare_streams DESCRIPTION OUTPUT OUTPUT-GOAL STDERR-GOAL: after run_cronic, -# reports success if the file OUTPUT, which is `cronic`'s output or a part of -# it, has the contents of OUTPUT-GOAL, and `cronic`'s stderr has the contents of -# STDERR-GOAL. -compare_streams() { - if cmp -s "$3" "$2" && cmp -s "$4" "$work/stderr"; then - echo "PASS: $1" - else - echo "FAIL: $1: output:" - cat "$work/output" - echo "stderr:" - cat "$work/stderr" - echo "differences from the goal output:" - diff "$3" "$2" || true - echo "differences from the goal stderr:" - diff "$4" "$work/stderr" || true - status=1 - fi -} - # check_trace_section DESCRIPTION GOAL-FILE COMMAND...: runs COMMAND, which # runs `cronic`, and checks that it exits with status 0, leaves no temporary # files, writes nothing to stderr, and produces a report whose "TRACE OUTPUT" @@ -255,7 +235,17 @@ check_trace_section() { run_cronic "$description" 0 "$@" || return 0 sed -n '/^TRACE OUTPUT:$/,/^$/p' "$work/output" > "$work/trace-section" - compare_streams "$description" "$work/trace-section" "$goal" /dev/null + if cmp -s "$goal" "$work/trace-section" && [ ! -s "$work/stderr" ]; then + echo "PASS: $description" + else + echo "FAIL: $description: output:" + cat "$work/output" + echo "stderr:" + cat "$work/stderr" + echo "differences from the goal trace section:" + diff "$goal" "$work/trace-section" || true + status=1 + fi } # check DESCRIPTION EXPECTED-STATUS EXPECTED-OUTPUT COMMAND...: runs `cronic` @@ -486,9 +476,11 @@ check "exported SHELLOPTS with xtrace, trace-only stderr" 0 silent \ # it appears under "TRACE OUTPUT". # Bash quotes a word in a trace line if it contains a space or a shell # metacharacter, so let bash write the expected trace of the path, which is -# under the caller's TMPDIR. +# under the caller's TMPDIR. The trace goes to a separate file descriptor, +# and stderr is discarded, so that an unrelated warning from bash, such as for +# an uninstalled locale, is not part of the expected trace. # shellcheck disable=SC2016 # The expansion is for bash to do. -traced_command=$(bash -c 'set -x; : -- "$1" 0' sh "$work/trace-and-stderr" 2>&1 | sed 's/^+ : /+ command /') +traced_command=$(bash -c 'BASH_XTRACEFD=3; set -x; : -- "$1" 0' sh "$work/trace-and-stderr" 3>&1 2> /dev/null | sed 's/^+ : /+ command /') cat > "$work/trace-section.goal" << EOF TRACE OUTPUT: $traced_command From cf493e9880efea38ee6c6d5e4e91d4bcd7c80682 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 12:48:57 -0700 Subject: [PATCH 29/32] Simplify: keep the option-saving code with its comment, shorten comments, drop the SHELLOPTS re-exec and two helper scripts from the tests --- cronic | 72 ++++++++++++++------------------ tests/cronic-test/cronic-test.sh | 55 +++++++----------------- 2 files changed, 48 insertions(+), 79 deletions(-) diff --git a/cronic b/cronic index 12568eb..3187109 100755 --- a/cronic +++ b/cronic @@ -1,8 +1,4 @@ #!/usr/bin/env bash -{ - cronic_caller_flags=${-//[^aeux]/} - set +x -} 2> /dev/null # Cronic v2 - cron job report wrapper # Copyright 2007 Chuck Houpt @@ -23,34 +19,31 @@ # BASH_XTRACEFD (bash 4.1), only after checking the version; see the # BASH_VERSINFO check below. -# This script does not handle verbose: if the caller exports SHELLOPTS with -# verbose, or runs `bash -v cronic`, then bash echoes this script's lines to -# stderr, so cron sends email for every run. `cronic --help` says not to. - -# Bash runs the file named by BASH_ENV, if any, before this script's first -# command, so the caller's exported options, such as xtrace and nounset, apply -# to that file and can make it write to stderr; such a cron job should unset -# BASH_ENV. `cronic --help` says so. +# Limitations, which `cronic --help` documents: every run produces output if +# the caller exports the verbose option or runs `bash -v cronic`, or if the file +# named by BASH_ENV writes to stderr. -# The first command in this file, just after the shebang, saves the caller's -# settings of the shell options that this script changes for itself, as the -# letters that `$-` uses for them: allexport, errexit, nounset, and xtrace. -# The command inherits every other option, including every `shopt` option, -# unchanged. If this script ever changes another option, add its letter there. - -# The first command then stops this script from tracing itself, which would go -# to this script's stderr, so that cron would send email for every run. The -# redirection discards its own trace. If the caller exports BASH_XTRACEFD, -# then bash writes that trace to the caller's trace descriptor instead, not to -# stderr. If that descriptor is not open, then bash reports so on stderr -# before it runs this script's first command, as for any bash script; cronic -# cannot prevent that. +# Save the caller's settings of the options that this script changes, as the +# letters that `$-` uses for them, so that the command runs with them. The +# command inherits every other option, including every `shopt` option, +# unchanged. If this script ever changes another option, add its letter here. +# Then turn off xtrace, so that this script does not trace itself to stderr, +# which would make every run produce output, and allexport, so that this script +# does not export its variables to the command. `cronic_caller_flags`, which is +# assigned before that, is un-exported explicitly. The redirection discards +# the trace of `set +ax` itself. If the caller exports BASH_XTRACEFD, then +# bash writes that trace to the caller's trace descriptor instead. If that +# descriptor is not open, then bash reports so on stderr before this script +# runs, as for any bash script; cronic cannot prevent that. +{ + cronic_caller_flags=${-//[^aeux]/} + set +ax + export -n cronic_caller_flags +} 2> /dev/null # Exit if a command exits with a non-zero status, and treat unset variables -# as an error. Do not export this script's variables to the command, even if -# the caller's exported SHELLOPTS contains allexport. -set -eu +a -export -n cronic_caller_flags +# as an error. +set -eu # Prints the usage line. cronic_usage_line() { @@ -197,20 +190,19 @@ if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))); export BASH_XTRACEFD=$cronic_xtrace_fd fi -# Run the command with the caller's shell options rather than this -# script's. If the caller exported SHELLOPTS, then the command would otherwise -# inherit this script's options, and a bash script that reads an unset variable -# (as `/usr/bin/texi2dvi` does when /bin/sh is bash) would fail. # The command runs in a subshell rather than in this shell, so that a builtin # such as `exit` or `exec` cannot end this script before it reports. +# The subshell restores the caller's options that this script changed. +# Otherwise, if the caller exported SHELLOPTS, a bash script that reads an unset +# variable (as `/usr/bin/texi2dvi` does when /bin/sh is bash) would fail. Of +# those options, only nounset is on here, so a single `set` command turns it off +# and turns on the caller's, so that the restoring is not traced. The `:+` +# avoids a bare `-`, which has a special meaning to `set`. # `command` because a function of this script, such as `cronic_usage`, would -# otherwise run in place of a program of the same name. The redirections -# apply to the whole subshell, so that they also capture what it writes after -# the command finishes, such as the output of an EXIT trap that the command -# sets. Of the options that this script changes, only nounset is on here, so -# the subshell turns it off and turns on the caller's, in a single `set` -# command, so that the restoring is not traced. The `:+` avoids -# a bare `-`, which has a special meaning to `set`. +# otherwise run in place of a program of the same name. +# The redirections apply to the whole subshell, so that they also capture what +# it writes after the command finishes, such as the output of an EXIT trap that +# the command sets. # The subshell is not the operand of `||` or the like, because then bash would # ignore `set -e` in it and would remove "errexit" from the SHELLOPTS that the # command inherits. diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index f2335da..fd222db 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -26,20 +26,6 @@ set -eu unset BASH_ENV # Use the default PS4, which the goal files and the trace lines below assume. unset PS4 -# Do not pass this script's own options, such as `-e` and `-u`, to `cronic` and -# to the commands below. If SHELLOPTS is exported, then remove it from the -# environment. bash makes SHELLOPTS read-only, so it cannot be unset; -# instead, this script runs again under the same bash, with SHELLOPTS removed -# from its environment, so that the caller's options do not apply to this script -# either. Any other shell ignores SHELLOPTS, so it can simply unset it. -# `printenv` matches the name exactly, unlike a search of the output of `env`, -# which can match a line within the value of another variable. -if printenv SHELLOPTS > /dev/null; then - if [ -n "${BASH:-}" ]; then - exec env -u SHELLOPTS "$BASH" "$0" "$@" - fi - unset SHELLOPTS -fi SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)" CRONIC="$(CDPATH='' cd -- "${SCRIPT_DIR}/../.." && pwd -P)/cronic" @@ -148,24 +134,6 @@ printf '%s\n' "$@" 1>&2 EOF chmod +x "$work/stderr-lines" -# A bash command that reads an unset variable. -cat > "$work/read-unset" << 'EOF' -#!/usr/bin/env bash -unset CRONIC_TEST_UNSET_VARIABLE -echo "value: ${CRONIC_TEST_UNSET_VARIABLE}" > /dev/null -exit "$1" -EOF -chmod +x "$work/read-unset" - -# Runs `cronic` with SHELLOPTS set to its first argument and exported. -cat > "$work/cronic-with-shellopts" << 'EOF' -#!/usr/bin/env bash -shellopts="$1" -shift -exec env SHELLOPTS="$shellopts" "$REAL_CRONIC" "$@" -EOF -chmod +x "$work/cronic-with-shellopts" - # A bash that supports BASH_XTRACEFD and the allocation of a file descriptor # (bash 4.1 or later) writes its trace lines to a separate file, so they are # not error output, whatever PS4 is. An earlier bash, such as macOS's @@ -445,17 +413,26 @@ check "builtin with an unset variable" 0 silent \ # When SHELLOPTS is exported, `cronic`'s own `-e` and `-u` options do not reach # the wrapped command, but the caller's options do. -REAL_CRONIC="$CRONIC" -export REAL_CRONIC -CRONIC="$work/cronic-with-shellopts" +real_cronic="$CRONIC" +# cronic_with_shellopts SHELLOPTS ARG...: runs `cronic` on the ARGs, with +# SHELLOPTS exported. +# shellcheck disable=SC2329 # `check` calls it, as "$CRONIC". +cronic_with_shellopts() { + shellopts="$1" + shift + env SHELLOPTS="$shellopts" "$real_cronic" "$@" +} +CRONIC=cronic_with_shellopts default_shellopts=braceexpand:hashall:interactive-comments +# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. check "exported SHELLOPTS, command reads an unset variable" 0 silent \ - "$default_shellopts" "$work/read-unset" 0 + "$default_shellopts" bash -c ': "$cronic_test_unset_variable"' check "exported SHELLOPTS, command ignores a failure" 0 silent \ "$default_shellopts" bash -c 'false; :' -check "exported SHELLOPTS with nounset, command reads an unset variable" 1 \ +# shellcheck disable=SC2016 # The expansion is for the wrapped command to do. +check "exported SHELLOPTS with nounset, command reads an unset variable" 127 \ "report:unbound variable" \ - "$default_shellopts:nounset" "$work/read-unset" 0 + "$default_shellopts:nounset" bash -c ': "$cronic_test_unset_variable"' check "exported SHELLOPTS with errexit, command ignores a failure" 1 report \ "$default_shellopts:errexit" bash -c 'false; :' # With allexport exported, `cronic` does not export its own variables to the @@ -493,7 +470,7 @@ check_trace_section "exported SHELLOPTS with xtrace, trace output" \ "$work/trace-section.goal" \ "$CRONIC" "$default_shellopts:xtrace" "$work/trace-and-stderr" 0 -CRONIC="$REAL_CRONIC" +CRONIC="$real_cronic" # The wrapped command sees the caller's exported variables unchanged. For # each variable that `cronic` assigns, the caller exports that name in From b7068ccc9b9e19068b4287f54d8c7b509ac861a3 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 13:05:01 -0700 Subject: [PATCH 30/32] cronic-test: require the report terminator in check_trace_section --- tests/cronic-test/cronic-test.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index fd222db..62da7be 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -187,8 +187,9 @@ run_cronic() { # check_trace_section DESCRIPTION GOAL-FILE COMMAND...: runs COMMAND, which # runs `cronic`, and checks that it exits with status 0, leaves no temporary -# files, writes nothing to stderr, and produces a report whose "TRACE OUTPUT" -# section, through the following empty line, is the contents of GOAL-FILE. +# files, writes nothing to stderr, and produces a complete report whose "TRACE +# OUTPUT" section, through the following empty line, is the contents of +# GOAL-FILE. # Skips the check if bash lacks BASH_XTRACEFD, so that the report has no # "TRACE OUTPUT". check_trace_section() { @@ -203,7 +204,9 @@ check_trace_section() { run_cronic "$description" 0 "$@" || return 0 sed -n '/^TRACE OUTPUT:$/,/^$/p' "$work/output" > "$work/trace-section" - if cmp -s "$goal" "$work/trace-section" && [ ! -s "$work/stderr" ]; then + if cmp -s "$goal" "$work/trace-section" \ + && grep -q "^END OF CRONIC OUTPUT.$" "$work/output" \ + && [ ! -s "$work/stderr" ]; then echo "PASS: $description" else echo "FAIL: $description: output:" From ac322c48cdb88f11dbc9e2577f388c55f826b716 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 13:42:33 -0700 Subject: [PATCH 31/32] cronic: do not pass a caller-exported function named `builtin` to the command This removes the special handling that saved `builtin` and the switching back into posix mode in the subshell. Also factor the BASH_XTRACEFD skip in the tests into `check_trace_goals`. --- cronic | 52 ++++++++++++++----------------- tests/cronic-test/cronic-test.sh | 53 ++++++++++++++++---------------- 2 files changed, 49 insertions(+), 56 deletions(-) diff --git a/cronic b/cronic index 975d9e7..07ac4f8 100755 --- a/cronic +++ b/cronic @@ -23,7 +23,9 @@ # # Limitations, which `cronic --help` documents: every run produces output if # the caller exports the verbose option or runs `bash -v cronic`, or if the file -# named by BASH_ENV writes to stderr. +# named by BASH_ENV writes to stderr. COMMAND does not see a function named +# `builtin` that the caller exported, because this script removes it so that it +# can run builtins via `builtin`. # This script runs under the first `bash` in PATH, which on macOS may be # /bin/bash, bash 3.2. So it uses a feature of a later bash, such as @@ -36,7 +38,8 @@ # script's first line. The command inherits every other option, including # every `shopt` option, unchanged. If this script ever changes another option, # add its letter here. Posix mode, which has no letter, is saved in -# `cronic_posix_off`, as the `set` arguments that turn it off again. +# `cronic_caller_posix`, which is non-empty if the caller's shell was in posix +# mode. # # Then turn on posix mode, by assigning POSIXLY_CORRECT, so that a special # builtin, such as `export`, `set`, or `unset`, runs in place of a function of @@ -52,13 +55,13 @@ { cronic_caller_flags=${-//[^aeux]/} if [[ :$SHELLOPTS: == *:posix:* ]]; then - cronic_posix_off= + cronic_caller_posix=y else - cronic_posix_off="+o posix" + cronic_caller_posix= POSIXLY_CORRECT=y fi set +ax - export -n cronic_caller_flags cronic_posix_off + export -n cronic_caller_flags cronic_caller_posix } 2> /dev/null # The next command removes every alias, such as one that BASH_ENV defined, so @@ -70,11 +73,8 @@ # functions, which bash has imported, and remove every function, including any # that BASH_ENV defined, so that none runs in place of a builtin or program that # this script uses. The command gets the exported functions back. In posix -# mode, `export -pf` lists the names of the exported functions. Once -# `builtin` is saved (via `command`, in a subshell that removes any function -# named `command`) and removed, it runs the other builtins. `builtin` needs -# that separate path because `command` cannot be removed first: `builtin` -# saves the definition of `command` along with the caller's other functions. +# mode, `export -pf` lists the names of the exported functions. Once any +# function named `builtin` is removed, `builtin` runs the other builtins. # `declare -f` runs with posix mode off, because posix mode rejects a function # name, such as `my-fn`, that is not an identifier. Unsetting POSIXLY_CORRECT # turns posix mode off again, unless the caller's shell was already in posix @@ -86,23 +86,20 @@ # exits, so cron sends email rather than failing silently. \unalias -a cronic_exported=$(export -pf) -cronic_caller_functions= -if [[ $'\n'$cronic_exported$'\n' == *$'\n'"export -f builtin"$'\n'* ]]; then - cronic_caller_functions=$(unset -f command; unset POSIXLY_CORRECT; command declare -f builtin) -fi unset -f builtin +cronic_caller_functions= cronic_caller_function_names= while builtin read -r cronic_name; do cronic_name=${cronic_name#export -f } - if [[ -n $cronic_name && $cronic_name != cronic_* ]]; then + if [[ -n $cronic_name && $cronic_name != cronic_* && $cronic_name != builtin ]]; then builtin printf -v cronic_name_quoted ' %q' "$cronic_name" cronic_caller_function_names+=$cronic_name_quoted fi done <<< "$cronic_exported" if [[ -n $cronic_caller_function_names ]]; then - cronic_caller_functions+=$'\n'$(unset POSIXLY_CORRECT; builtin eval "builtin declare -f --$cronic_caller_function_names") || : + cronic_caller_functions=$(unset POSIXLY_CORRECT; builtin eval "builtin declare -f --$cronic_caller_function_names") || : fi -[[ -z $cronic_posix_off ]] || unset POSIXLY_CORRECT +[[ -n $cronic_caller_posix ]] || unset POSIXLY_CORRECT while builtin read -r cronic_name; do [[ -z $cronic_name ]] || builtin unset -f -- "$cronic_name" || builtin exit 1 done <<< "$(builtin compgen -A function)" @@ -140,6 +137,8 @@ cronic_help() { echo "caller exported; a function runs in place of a program of the same name." echo "Names that start with \"cronic_\" are reserved: COMMAND does not see a" echo "variable or function with such a name, even one that the caller exported." + echo "Nor does COMMAND see a function named \"builtin\" that the caller exported," + echo "because cronic removes it so that it can run builtins via \"builtin\"." echo "COMMAND runs with the caller's shell options, such as errexit, nounset," echo "and xtrace (including any that BASH_ENV sets), rather than with this" echo "script's. This script never traces itself, so \"bash -x cronic COMMAND\"" @@ -277,19 +276,15 @@ fi # program of the same name. The names of this script's functions are # identifiers, so they need no quoting. The `eval` expands the cronic_* # variables before `unset` removes them. It then defines the caller's -# functions, which can replace a builtin, so the remaining commands run in -# posix mode, as special builtins. +# functions, which can replace a builtin, so the remaining commands run via +# `builtin`. # # The last command of the `eval` restores the caller's options that this script # changed. Otherwise, if the caller exported SHELLOPTS, a bash script that # reads an unset variable (as `/usr/bin/texi2dvi` does when /bin/sh is bash) -# would fail. Of those options, only nounset and posix mode are on here, so a -# single `set` command turns them off (posix mode only if the caller's shell -# was not in posix mode) and turns on the caller's, so that the restoring is -# not traced. The `:+` avoids a bare `-`, which has a special meaning to `set`. -# That command never turns posix mode off and on again, because doing so would -# replace the caller's value of POSIXLY_CORRECT by "y"; turning posix mode off -# unsets POSIXLY_CORRECT. +# would fail. Of those options, only nounset is on here, so a single `set` +# command turns it off and turns on the caller's, so that the restoring is not +# traced. The `:+` avoids a bare `-`, which has a special meaning to `set`. # # 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 @@ -309,9 +304,8 @@ set +e unset -f $(compgen -A function) eval "unset -v \"\${!cronic_@}\" $cronic_caller_functions - ${cronic_posix_off:+POSIXLY_CORRECT=y} - ${cronic_caller_function_names:+export -f --$cronic_caller_function_names} - set $cronic_posix_off +u${cronic_caller_flags:+ -$cronic_caller_flags}" + ${cronic_caller_function_names:+builtin export -f --$cronic_caller_function_names} + builtin set +u${cronic_caller_flags:+ -$cronic_caller_flags}" "$@" ) > "$cronic_out" 2> "$cronic_trace" cronic_result=$? diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 3b71e8e..b9d8fce 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -235,6 +235,16 @@ check_goals() { fi } +# check_trace_goals: like check_goals, but skips the check if bash lacks +# BASH_XTRACEFD, so that the report has no "TRACE OUTPUT". +check_trace_goals() { + if [ "$bash_has_xtracefd" = false ]; then + echo "SKIP: $1, because bash is older than 4.1" + return 0 + fi + check_goals "$@" +} + # check DESCRIPTION EXPECTED-STATUS EXPECTED-OUTPUT COMMAND...: runs `cronic` # on COMMAND and checks its exit status, whether it printed a report, and that # it left no temporary files behind. EXPECTED-OUTPUT is "silent", "report", @@ -481,13 +491,9 @@ TRACE OUTPUT: END OF CRONIC OUTPUT. EOF -if [ "$bash_has_xtracefd" = true ]; then - check_goals "exported SHELLOPTS with xtrace, trace output" \ - "$work/trace-report.goal" /dev/null \ - "$CRONIC" "$default_shellopts:xtrace" "$work/trace-and-stderr" 0 -else - echo "SKIP: exported SHELLOPTS with xtrace, trace output, because bash is older than 4.1" -fi +check_trace_goals "exported SHELLOPTS with xtrace, trace output" \ + "$work/trace-report.goal" /dev/null \ + "$CRONIC" "$default_shellopts:xtrace" "$work/trace-and-stderr" 0 # With xtrace exported, the trace lines of a builtin are nested 1 deeper than # the builtin, whose own trace is the line that runs it, which is not reported. # The trace of an EXIT trap that the builtin sets is reported, even if the @@ -509,13 +515,9 @@ TRACE OUTPUT: END OF CRONIC OUTPUT. EOF -if [ "$bash_has_xtracefd" = true ]; then - check_goals "exported SHELLOPTS with xtrace, EXIT trap of a builtin" \ - "$work/exit-trap-trace-report.goal" /dev/null \ - "$CRONIC" "$default_shellopts:xtrace" eval "trap 'echo trapped >&2' EXIT" -else - echo "SKIP: exported SHELLOPTS with xtrace, EXIT trap of a builtin, because bash is older than 4.1" -fi +check_trace_goals "exported SHELLOPTS with xtrace, EXIT trap of a builtin" \ + "$work/exit-trap-trace-report.goal" /dev/null \ + "$CRONIC" "$default_shellopts:xtrace" eval "trap 'echo trapped >&2' EXIT" # The caller's `-e` applies within an exported shell function that is the # command, and the caller's lack of `-e` does too. @@ -620,11 +622,12 @@ check "caller's exported cronic_ variable is not seen" 0 silent \ check "caller's exported cronic_ function is not seen" 0 silent \ bash -c '! command declare -F cronic_mine' check "caller's exported functions are seen" 0 silent \ - bash -c 'command declare -F builtin set unset > /dev/null' + bash -c 'command declare -F set unset > /dev/null' CRONIC="$REAL_CRONIC" -# The caller may export functions named `builtin` and `command`, which `cronic` -# uses to save the caller's functions; the command sees both of them. +# The command does not see the caller's exported function named `builtin`, +# which `cronic` removes so that it can run builtins via `builtin`, but it does +# see one named `command`. cat > "$work/cronic-with-builtin-and-command" << 'EOF' #!/bin/bash builtin() { echo "function builtin ran"; } @@ -635,9 +638,9 @@ EOF chmod +x "$work/cronic-with-builtin-and-command" CRONIC="$work/cronic-with-builtin-and-command" # shellcheck disable=SC2016 # The expansion is for `bash` to do. -check "caller's exported functions named builtin and command are seen" 1 \ - "report:function builtin ran, function command ran" \ - bash -c 'echo "$(builtin), $(command)"; false' +check "caller's exported function named builtin is not seen, command is" 1 \ + "report:^no builtin, function command ran$" \ + bash -c 'declare -F builtin || echo "no builtin, $(command)"; false' CRONIC="$REAL_CRONIC" # A function whose name is not an identifier reaches the command, even when @@ -704,13 +707,9 @@ unset BASH_ENV # command. The command does not inherit xtrace, because SHELLOPTS is not # exported, so its own `set -x` is not traced. grep -v '^+ set -x$' "$work/trace-report.goal" > "$work/bash-x-trace-report.goal" -if [ "$bash_has_xtracefd" = true ]; then - check_goals "bash -x cronic, trace output" \ - "$work/bash-x-trace-report.goal" /dev/null \ - bash -x "$CRONIC" "$work/trace-and-stderr" 0 -else - echo "SKIP: bash -x cronic, trace output, because bash is older than 4.1" -fi +check_trace_goals "bash -x cronic, trace output" \ + "$work/bash-x-trace-report.goal" /dev/null \ + bash -x "$CRONIC" "$work/trace-and-stderr" 0 # When `bash_has_xtracefd` is true, bash writes its trace lines to a separate # file, so they are not error output, whatever PS4 is. From 8a379b7f1b2c3d3169fe06338d5f3447b95df43a Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 13:46:24 -0700 Subject: [PATCH 32/32] cronic: do not run the command if a cronic_ variable cannot be removed Previously the error was reported but the command still ran, unlike when a function cannot be removed. --- cronic | 6 ++++-- tests/cronic-test/cronic-test.sh | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/cronic b/cronic index 07ac4f8..1e4e1df 100755 --- a/cronic +++ b/cronic @@ -275,7 +275,9 @@ fi # command does not see them, and so that a function does not run in place of a # program of the same name. The names of this script's functions are # identifiers, so they need no quoting. The `eval` expands the cronic_* -# variables before `unset` removes them. It then defines the caller's +# variables before `unset` removes them. If `unset` cannot remove one, such as +# one that BASH_ENV made readonly, then the subshell exits without running the +# command, and the report shows the error. The `eval` then defines the caller's # functions, which can replace a builtin, so the remaining commands run via # `builtin`. # @@ -302,7 +304,7 @@ set +e ( # shellcheck disable=SC2046 # each name is a separate argument. unset -f $(compgen -A function) - eval "unset -v \"\${!cronic_@}\" + eval "unset -v \"\${!cronic_@}\" || exit 1 $cronic_caller_functions ${cronic_caller_function_names:+builtin export -f --$cronic_caller_function_names} builtin 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 b9d8fce..9a1ce3a 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -701,6 +701,11 @@ echo 'grep() { echo "function grep ran"; }; readonly -f grep' > "$work/bash-env" BASH_ENV="$work/bash-env" export BASH_ENV check "readonly function from BASH_ENV" 1 "message:readonly function" true +# Likewise, a `cronic_` variable that BASH_ENV made readonly cannot be removed, +# so the command, which would exit with status 5, does not run. +echo 'readonly cronic_mine=x' > "$work/bash-env" +check "readonly cronic_ variable from BASH_ENV" 1 "report:readonly variable" \ + sh -c 'exit 5' unset BASH_ENV # `bash -x cronic` traces neither `cronic` itself nor the line that runs the