From 299080e32ac407a6d4c770170541f309d470aeb3 Mon Sep 17 00:00:00 2001 From: Koichi Murase Date: Tue, 7 Mar 2023 11:37:48 +0900 Subject: [PATCH 1/5] Work around `shopt -u extquote` --- bash-preexec.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bash-preexec.sh b/bash-preexec.sh index 5bb11b6..975d3be 100644 --- a/bash-preexec.sh +++ b/bash-preexec.sh @@ -323,13 +323,16 @@ __bp_install() { shopt -s extdebug > /dev/null 2>&1 fi; - local existing_prompt_command + # We specify newline character through the variable `nl' because $'\n' + # inside "${var//...}" is treated literally as "\$'\\n'" when `extquote' is + # unset (shopt -u extquote). (Note: Bash 5.2's extquote seems to be buggy.) + local existing_prompt_command nl=$'\n' # Remove setting our trap install string and sanitize the existing prompt command string existing_prompt_command="${PROMPT_COMMAND:-}" # Edge case of appending to PROMPT_COMMAND existing_prompt_command="${existing_prompt_command//$__bp_install_string/:}" # no-op - existing_prompt_command="${existing_prompt_command//$'\n':$'\n'/$'\n'}" # remove known-token only - existing_prompt_command="${existing_prompt_command//$'\n':;/$'\n'}" # remove known-token only + existing_prompt_command="${existing_prompt_command//$nl:$nl/$nl}" # remove known-token only + existing_prompt_command="${existing_prompt_command//$nl:;/$nl}" # remove known-token only __bp_sanitize_string existing_prompt_command "$existing_prompt_command" if [[ "${existing_prompt_command:-:}" == ":" ]]; then existing_prompt_command= From fe5aedb3368d78fc47e017ceffca1d8265cdef0d Mon Sep 17 00:00:00 2001 From: Koichi Murase Date: Tue, 7 Mar 2023 11:42:58 +0900 Subject: [PATCH 2/5] Include the no-op colon reduction in `__bp_sanitize_string` --- bash-preexec.sh | 49 +++++++++++++++++++++++++++++++----------- test/bash-preexec.bats | 33 ++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/bash-preexec.sh b/bash-preexec.sh index 975d3be..4940aff 100644 --- a/bash-preexec.sh +++ b/bash-preexec.sh @@ -121,15 +121,46 @@ __bp_trim_whitespace() { # Trims whitespace and removes any leading or trailing semicolons from $2 and -# writes the resulting string to the variable name passed as $1. Used for -# manipulating substrings in PROMPT_COMMAND +# writes the resulting string to the variable name passed as $1. This also +# removes the no-op colons, which are converted from the hooks to remove. Used +# for manipulating substrings in PROMPT_COMMAND __bp_sanitize_string() { - local var=${1:?} text=${2:-} sanitized - __bp_trim_whitespace sanitized "$text" + local var=${1:?} sanitized=${2:-} + + local unset_extglob= + if ! shopt -q extglob; then + unset_extglob=yes + shopt -s extglob + fi + + # We specify newline character through the variable `nl' because $'\n' + # inside "${var//...}" is treated literally as "\$'\\n'" when `extquote' is + # unset (shopt -u extquote). (Note: Bash 5.2's extquote seems to be buggy.) + local tmp sp=$' \t' nl=$'\n' + while + # Quoting parameter expansions $nl in PAT of ${var//PAT/REP} is + # required by shellcheck. On the other hand, we should not quote the + # parameter expansions $nl in REP because the quotes will remain in the + # replaced result with `shopt -s compat42'. + tmp="${sanitized//[";$nl"]*(["$sp"]):*(["$sp"])[";$nl"]/$nl}" + [[ "$tmp" != "$sanitized" ]] + do + sanitized="$tmp" + done + sanitized="${sanitized#:*(["$sp"])[";$nl"]}" + sanitized="${sanitized%[";$nl"]*(["$sp"]):}" + __bp_trim_whitespace sanitized "$sanitized" sanitized=${sanitized%;} sanitized=${sanitized#;} __bp_trim_whitespace sanitized "$sanitized" + if [[ "$sanitized" == ":" ]]; then + sanitized= + fi printf -v "$var" '%s' "$sanitized" + + if [[ -n "$unset_extglob" ]]; then + shopt -u extglob + fi } # This function is installed as part of the PROMPT_COMMAND; @@ -323,20 +354,12 @@ __bp_install() { shopt -s extdebug > /dev/null 2>&1 fi; - # We specify newline character through the variable `nl' because $'\n' - # inside "${var//...}" is treated literally as "\$'\\n'" when `extquote' is - # unset (shopt -u extquote). (Note: Bash 5.2's extquote seems to be buggy.) - local existing_prompt_command nl=$'\n' + local existing_prompt_command # Remove setting our trap install string and sanitize the existing prompt command string existing_prompt_command="${PROMPT_COMMAND:-}" # Edge case of appending to PROMPT_COMMAND existing_prompt_command="${existing_prompt_command//$__bp_install_string/:}" # no-op - existing_prompt_command="${existing_prompt_command//$nl:$nl/$nl}" # remove known-token only - existing_prompt_command="${existing_prompt_command//$nl:;/$nl}" # remove known-token only __bp_sanitize_string existing_prompt_command "$existing_prompt_command" - if [[ "${existing_prompt_command:-:}" == ":" ]]; then - existing_prompt_command= - fi # Install our hooks in PROMPT_COMMAND to allow our trap to know when we've # actually entered something. diff --git a/test/bash-preexec.bats b/test/bash-preexec.bats index 3d3f6b7..c42e7f2 100644 --- a/test/bash-preexec.bats +++ b/test/bash-preexec.bats @@ -116,6 +116,39 @@ set_exit_code_and_run_precmd() { } +@test "__bp_sanitize_string should remove no-op colons" { + __bp_sanitize_string output ':' + [ "$output" == "" ] + + __bp_sanitize_string output $':\n:' + [ "$output" == "" ] + + __bp_sanitize_string output $':\n:;echo USER1' + [ "$output" == "echo USER1" ] + + __bp_sanitize_string output $'echo USER2\n:\necho USER3' + expected_result=$'echo USER2\necho USER3' + [ "$output" == "$expected_result" ] + + __bp_sanitize_string output $'echo USER4;:;echo USER5' + expected_result=$'echo USER4\necho USER5' + [ "$output" == "$expected_result" ] + + __bp_sanitize_string output $'echo USER6;:\necho USER7' + expected_result=$'echo USER6\necho USER7' + [ "$output" == "$expected_result" ] + + __bp_sanitize_string output $':\n: ; echo USER8' + [ "$output" == "echo USER8" ] + + __bp_sanitize_string output $':\n: ; echo USER9' + [ "$output" == "echo USER9" ] + + __bp_sanitize_string output $'echo USER10 ; :\n: ; echo USER11' + expected_result=$'echo USER10 \n echo USER11' + [ "$output" == "$expected_result" ] +} + @test "Appending to PROMPT_COMMAND should work after bp_install" { bp_install From c85de1bc66f9311e7cf3874f58823704e4b2aec8 Mon Sep 17 00:00:00 2001 From: Koichi Murase Date: Tue, 7 Mar 2023 14:04:02 +0900 Subject: [PATCH 3/5] Check PROMPT_COMMAND in precmd and adjust it if necessary --- bash-preexec.sh | 87 ++++++++++++++++++++++++++++++++++-------- test/bash-preexec.bats | 24 ++++++++++++ 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/bash-preexec.sh b/bash-preexec.sh index 4940aff..3f29d95 100644 --- a/bash-preexec.sh +++ b/bash-preexec.sh @@ -163,6 +163,39 @@ __bp_sanitize_string() { fi } + +# Bash >= 5.1 supports the array version of PROMPT_COMMAND. +__bp_use_array_prompt_command() { + (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )) +} + + +# Remove $1 and sanitize each elements of PROMPT_COMMAND. We want to keep +# PROMPT_COMMAND scalar in bash < 5.1 because some configuration tests the +# support for the array PROMPT_COMMAND by checking the array attribute of +# PROMPT_COMMAND. +__bp_remove_command_from_prompt_command() { + local removed_command="${1-}" + if __bp_use_array_prompt_command; then + local i sanitized_prompt_command + for i in "${!PROMPT_COMMAND[@]}"; do + sanitized_prompt_command="${PROMPT_COMMAND[i]:-}" + sanitized_prompt_command="${sanitized_prompt_command//"$removed_command"/:}" + __bp_sanitize_string sanitized_prompt_command "$sanitized_prompt_command" + if [[ -n "$sanitized_prompt_command" ]]; then + PROMPT_COMMAND[i]="$sanitized_prompt_command" + else + unset -v 'PROMPT_COMMAND[i]' + fi + done + else + local sanitized_prompt_command="${PROMPT_COMMAND:-}" + sanitized_prompt_command="${sanitized_prompt_command//"$removed_command"/:}" # no-op + __bp_sanitize_string PROMPT_COMMAND "$sanitized_prompt_command" + fi +} + + # This function is installed as part of the PROMPT_COMMAND; # It sets a variable to indicate that the prompt was just displayed, # to allow the DEBUG trap to know that the next command is likely interactive. @@ -187,6 +220,11 @@ __bp_precmd_invoke_cmd() { if (( __bp_inside_precmd > 0 )); then return fi + + # Check and adjust PROMPT_COMMAND to make sure that PROMPT_COMMAND has the + # form "__bp_precmd_invoke_cmd; ...; __bp_interactive_mode" + __bp_install_prompt_command + local __bp_inside_precmd=1 # Invoke every function defined in our function array. @@ -354,23 +392,10 @@ __bp_install() { shopt -s extdebug > /dev/null 2>&1 fi; - local existing_prompt_command # Remove setting our trap install string and sanitize the existing prompt command string - existing_prompt_command="${PROMPT_COMMAND:-}" - # Edge case of appending to PROMPT_COMMAND - existing_prompt_command="${existing_prompt_command//$__bp_install_string/:}" # no-op - __bp_sanitize_string existing_prompt_command "$existing_prompt_command" + __bp_remove_command_from_prompt_command "$__bp_install_string" - # Install our hooks in PROMPT_COMMAND to allow our trap to know when we've - # actually entered something. - PROMPT_COMMAND='__bp_precmd_invoke_cmd' - PROMPT_COMMAND+=${existing_prompt_command:+$'\n'$existing_prompt_command} - if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then - PROMPT_COMMAND+=('__bp_interactive_mode') - else - # shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0 - PROMPT_COMMAND+=$'\n__bp_interactive_mode' - fi + __bp_install_prompt_command # Add two functions to our arrays for convenience # of definition. @@ -382,6 +407,38 @@ __bp_install() { __bp_interactive_mode } + +# Encloses PROMPT_COMMAND hooks within __bp_precmd_invoke_cmd and +# __bp_interactive_mode. +__bp_install_prompt_command() { + local prompt_command="${PROMPT_COMMAND:-}" + if __bp_use_array_prompt_command; then + local IFS=$'\n' + prompt_command="${PROMPT_COMMAND[*]:-}" + IFS=$' \t\n' + fi + + # Exit if we already have a properly set-up hooks in PROMPT_COMMAND + if [[ "$prompt_command" == __bp_precmd_invoke_cmd$'\n'*$'\n'__bp_interactive_mode ]]; then + return 0 + fi + + __bp_remove_command_from_prompt_command __bp_precmd_invoke_cmd + __bp_remove_command_from_prompt_command __bp_interactive_mode + + # Install our hooks in PROMPT_COMMAND to allow our trap to know when we've + # actually entered something. + # shellcheck disable=SC2178 # PROMPT_COMMAND is not an array in bash <= 5.0 + PROMPT_COMMAND='__bp_precmd_invoke_cmd'${PROMPT_COMMAND:+$'\n'$PROMPT_COMMAND} + if __bp_use_array_prompt_command; then + PROMPT_COMMAND+=('__bp_interactive_mode') + else + # shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0 + PROMPT_COMMAND+=$'\n__bp_interactive_mode' + fi +} + + # Sets an installation string as part of our PROMPT_COMMAND to install # after our session has started. This allows bash-preexec to be included # at any point in our bash profile. diff --git a/test/bash-preexec.bats b/test/bash-preexec.bats index c42e7f2..cc6935c 100644 --- a/test/bash-preexec.bats +++ b/test/bash-preexec.bats @@ -103,6 +103,30 @@ set_exit_code_and_run_precmd() { (( trap_count_snapshot < trap_invoked_count )) } +@test "__bp_install_prompt_command should adjust modified PROMPT_COMMAND" { + unset -v PROMPT_COMMAND + PROMPT_COMMAND="echo PREHOOK" + + # First install + __bp_install_prompt_command + expected_result=$'__bp_precmd_invoke_cmd\necho PREHOOK\n__bp_interactive_mode' + [ "$(join_PROMPT_COMMAND)" == "$expected_result" ] + + # User modification + if __bp_use_array_prompt_command; then + PROMPT_COMMAND+=('echo POSTHOOK') + else + PROMPT_COMMAND+=$'\necho POSTHOOK' + fi + expected_result=$'__bp_precmd_invoke_cmd\necho PREHOOK\n__bp_interactive_mode\necho POSTHOOK' + [ "$(join_PROMPT_COMMAND)" == "$expected_result" ] + + # Re-adjust + __bp_install_prompt_command + expected_result=$'__bp_precmd_invoke_cmd\necho PREHOOK\necho POSTHOOK\n__bp_interactive_mode' + [ "$(join_PROMPT_COMMAND)" == "$expected_result" ] +} + @test "__bp_sanitize_string should remove semicolons and trim space" { __bp_sanitize_string output " true1; "$'\n' From 9c669e63ce430bd108339863a54e6857c5398840 Mon Sep 17 00:00:00 2001 From: Koichi Murase Date: Tue, 7 Mar 2023 17:19:24 +0900 Subject: [PATCH 4/5] Suppress PROMPT_COMMAND hooks from inside saved strings We try to remove the existing hooks before the re-adjustment of PROMPT_COMMAND, but it might not work when another framework saves the original value of `PROMPT_COMMAND` in another variable and tries to call it from inside their `PROMPT_COMMAND` hook. For example, Starship does that [1]. In such a case, our hooks would be called several times unexpectedly. To avoid this situation, we process our hooks only when the hooks are called at the top level. [1] https://github.com/starship/starship/blob/3d474684149e0a7959fb986f8cea1d28b4c69d87/src/init/starship.bash#L97 --- bash-preexec.sh | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/bash-preexec.sh b/bash-preexec.sh index 3f29d95..d9fbc7b 100644 --- a/bash-preexec.sh +++ b/bash-preexec.sh @@ -200,6 +200,14 @@ __bp_remove_command_from_prompt_command() { # It sets a variable to indicate that the prompt was just displayed, # to allow the DEBUG trap to know that the next command is likely interactive. __bp_interactive_mode() { + if [[ "${1-}" != "force" && ! "${BATS_VERSION-}" ]] && (( ${#FUNCNAME[*]} > 1 )); then + # When this function is not called from the top level, the current + # function call is probably performed via PROMPT_COMMAND saved by + # another framework (e.g., starship). In this case, we do not want to + # turn on the "interactive mode" here. + return 0 + fi + __bp_preexec_interactive_mode="on"; } @@ -223,7 +231,15 @@ __bp_precmd_invoke_cmd() { # Check and adjust PROMPT_COMMAND to make sure that PROMPT_COMMAND has the # form "__bp_precmd_invoke_cmd; ...; __bp_interactive_mode" - __bp_install_prompt_command + if ! __bp_install_prompt_command && [[ ! "${BATS_VERSION-}" ]] && (( ${#FUNCNAME[*]} > 1 )); then + # When PROMPT_COMMAND is already properly set up but this function is + # not called from the top level, the current function call is probably + # performed via PROMPT_COMMAND saved by another framework (e.g., + # starship). In this case, we do not need to invoke precmd because it + # is supposed to be already processed by the top-level + # __bp_precmd_invoke_cmd. + return 0 + fi local __bp_inside_precmd=1 @@ -395,7 +411,7 @@ __bp_install() { # Remove setting our trap install string and sanitize the existing prompt command string __bp_remove_command_from_prompt_command "$__bp_install_string" - __bp_install_prompt_command + __bp_install_prompt_command || true # Add two functions to our arrays for convenience # of definition. @@ -404,12 +420,14 @@ __bp_install() { # Invoke our two functions manually that were added to $PROMPT_COMMAND __bp_precmd_invoke_cmd - __bp_interactive_mode + __bp_interactive_mode force } # Encloses PROMPT_COMMAND hooks within __bp_precmd_invoke_cmd and -# __bp_interactive_mode. +# __bp_interactive_mode. If all the PROMPT_COMMAND hooks are already surrounded +# by __bp_precmd_invoke_cmd and __bp_interactive_mode, the function exits with +# status 1. __bp_install_prompt_command() { local prompt_command="${PROMPT_COMMAND:-}" if __bp_use_array_prompt_command; then @@ -420,7 +438,7 @@ __bp_install_prompt_command() { # Exit if we already have a properly set-up hooks in PROMPT_COMMAND if [[ "$prompt_command" == __bp_precmd_invoke_cmd$'\n'*$'\n'__bp_interactive_mode ]]; then - return 0 + return 1 fi __bp_remove_command_from_prompt_command __bp_precmd_invoke_cmd @@ -436,6 +454,7 @@ __bp_install_prompt_command() { # shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0 PROMPT_COMMAND+=$'\n__bp_interactive_mode' fi + return 0 } From 1dd768079bb27e7090812776ab767bdc26badc82 Mon Sep 17 00:00:00 2001 From: Koichi Murase Date: Sat, 3 Feb 2024 15:10:10 +0900 Subject: [PATCH 5/5] Suppress SC2128 for array PROMPT_COMMAND --- bash-preexec.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bash-preexec.sh b/bash-preexec.sh index d9fbc7b..a5c5a5d 100644 --- a/bash-preexec.sh +++ b/bash-preexec.sh @@ -446,7 +446,7 @@ __bp_install_prompt_command() { # Install our hooks in PROMPT_COMMAND to allow our trap to know when we've # actually entered something. - # shellcheck disable=SC2178 # PROMPT_COMMAND is not an array in bash <= 5.0 + # shellcheck disable=SC2178,SC2128 # PROMPT_COMMAND is not an array in bash <= 5.0 PROMPT_COMMAND='__bp_precmd_invoke_cmd'${PROMPT_COMMAND:+$'\n'$PROMPT_COMMAND} if __bp_use_array_prompt_command; then PROMPT_COMMAND+=('__bp_interactive_mode')