diff --git a/cronic b/cronic index 587b85a..1e4e1df 100755 --- a/cronic +++ b/cronic @@ -16,9 +16,16 @@ # # For usage information, run `cronic --help`. # +# 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 bash has the `compgen` builtin, which is +# absent only if bash was built with `--disable-progcomp`. +# # 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 @@ -26,24 +33,77 @@ # BASH_VERSINFO check below. # 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. +# letters that `$-` uses for them, so that the command runs with them. They +# include any option that BASH_ENV set, because bash runs BASH_ENV before this +# 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_caller_posix`, which is non-empty if the caller's shell was in posix +# mode. # -# 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. +# 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 +# the same name that the caller exported. 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. The variables assigned before that are un-exported explicitly, +# or, for POSIXLY_CORRECT, unset below. The redirection discards the trace of +# this block. 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]/} + if [[ :$SHELLOPTS: == *:posix:* ]]; then + cronic_caller_posix=y + else + cronic_caller_posix= + POSIXLY_CORRECT=y + fi set +ax - export -n cronic_caller_flags + 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 +# 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 +# this script uses. The command gets the exported functions back. In posix +# 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 +# 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. +\unalias -a +cronic_exported=$(export -pf) +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_* && $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=$(unset POSIXLY_CORRECT; builtin eval "builtin declare -f --$cronic_caller_function_names") || : +fi +[[ -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)" + # Exit if a command exits with a non-zero status, and treat unset variables # as an error. set -eu @@ -73,6 +133,16 @@ 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 "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\"" + 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" @@ -195,26 +265,36 @@ if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))); export BASH_XTRACEFD=$cronic_xtrace_fd fi -# The command runs in a subshell rather than in this shell, so that a builtin -# such as `exit` or `exec` cannot end this script before it reports. +# The command runs in a subshell rather than via `exec`, so that it can be a +# shell builtin or an exported shell function, and 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`. +# 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_* +# 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`. # -# `command` because a function of this script, such as `cronic_usage`, would -# otherwise run in place of a program of the same name. +# 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 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 # 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. +# ignore `set -e` throughout the subshell, including in a shell function that it +# runs, 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 @@ -222,8 +302,13 @@ fi # that code silently expands an unset variable to the empty string. set +e ( - set +u${cronic_caller_flags:+ -$cronic_caller_flags} - command -- "$@" + # shellcheck disable=SC2046 # each name is a separate argument. + unset -f $(compgen -A function) + 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}" + "$@" ) > "$cronic_out" 2> "$cronic_trace" cronic_result=$? set -e @@ -232,8 +317,8 @@ set -e unset BASH_XTRACEFD # With the caller's xtrace on, the first line of the trace file is the trace of -# the `command -- ...` line above, which is this script's, not the command's, -# so it is removed. (The `set` command before it is not traced.) For a builtin +# the `"$@"` line above, which is this script's, not the command's, so it is +# removed. (The `set` command before it is not traced.) For a builtin # COMMAND, such as `exit`, that line is the builtin's own trace. If PS4 # contains a newline, then that trace spans several lines, of which only the # first is removed. A bash before 4.1 writes the trace to stderr, and that line diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 6b29186..9a1ce3a 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -43,6 +43,25 @@ mkdir "$TMPDIR" status=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 below 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 + # A command whose stderr is nothing but trace lines. cat > "$work/trace-only" << 'EOF' #!/usr/bin/env bash @@ -135,24 +154,31 @@ printf '%s\n' "$@" 1>&2 EOF chmod +x "$work/stderr-lines" -# 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 +# 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" "$@" +EOF2 +chmod +x "$work/cronic-with-shellopts" # temp_files: prints `cronic`'s temporary files, in a canonical order. temp_files() { @@ -187,40 +213,38 @@ 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 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() { +# 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 - - if [ "$bash_has_xtracefd" = false ]; then - echo "SKIP: $description, because bash lacks BASH_XTRACEFD" - return 0 - fi + 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 cmp -s "$goal" "$work/trace-section" \ - && grep -q "^END OF CRONIC OUTPUT.$" "$work/output" \ - && [ ! -s "$work/stderr" ]; then + if cmp -s "$output_goal" "$work/output" && cmp -s "$stderr_goal" "$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_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", @@ -418,16 +442,9 @@ 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" -# 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 +REAL_CRONIC="$CRONIC" +export REAL_CRONIC +CRONIC="$work/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 \ @@ -454,41 +471,94 @@ 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 command's own trace, # with nothing that `cronic` did to set up the command's options or to run the -# command, such as its `command -- ...` line. Bash writes the trace via -# BASH_XTRACEFD, so it appears under "TRACE OUTPUT". -cat > "$work/trace-section.goal" << EOF +# command. Bash writes the trace via BASH_XTRACEFD, so it appears under +# "TRACE OUTPUT". +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: + 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" \ +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 `command -- ...` line, which is not -# reported. The trace of an EXIT trap that the builtin sets is reported, even -# if the builtin returns normally rather than calling `exit`. -cat > "$work/exit-trap-trace-section.goal" << 'EOF' +# 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 +# builtin returns normally rather than calling `exit`. +cat > "$work/exit-trap-trace-report.goal" << 'EOF' +Cronic detected failure or error output for the command: +eval trap 'echo trapped >&2' EXIT + +RESULT CODE: 0 (success) + +ERROR OUTPUT: +trapped + +STANDARD OUTPUT: + TRACE OUTPUT: ++ trap 'echo trapped >&2' EXIT ++ echo trapped +END OF CRONIC OUTPUT. EOF -check_trace_section "exported SHELLOPTS with xtrace, EXIT trap of a builtin" \ - "$work/exit-trap-trace-section.goal" \ +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" -CRONIC="$real_cronic" +# 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 +# functions. +check "exported function sees none of cronic's variables or functions" 0 silent \ + "$default_shellopts" report_cronic_variables +check "exported function sees none of cronic's variables or functions, xtrace" \ + 0 silent "$default_shellopts:xtrace" 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 +# Under the caller's allexport, the command's environment contains none of +# `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" \ + bash -c 'env | grep -E "^(cronic_|POSIXLY_CORRECT=)" >&2; :' +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, # `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" @@ -502,8 +572,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 @@ -519,19 +591,133 @@ 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` traces neither `cronic` itself nor its `command -- ...` line. -# 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: -+ echo 'a real error' -+ exit 0 - +# Names that start with `cronic_` are reserved: the command does not see a +# 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() { + exit 3 +} +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; } +command export -f builtin compgen declare eval export read set unset +exec "$REAL_CRONIC" "$@" EOF -check_trace_section "bash -x cronic, trace output" \ - "$work/bash-x-trace-section.goal" \ +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 '! command declare -F cronic_mine' +check "caller's exported functions are seen" 0 silent \ + bash -c 'command declare -F set unset > /dev/null' +CRONIC="$REAL_CRONIC" + +# 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"; } +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 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 +# 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" + +# 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" +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 +# 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" +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. export PS4 # shellcheck disable=SC2016 # The expansion is for the traced command to do. PS4='+${LINENO}: '