diff --git a/bash_completion b/bash_completion index 5437fd5cf23..d7812ea0819 100644 --- a/bash_completion +++ b/bash_completion @@ -148,6 +148,16 @@ _comp_readline_variable_on() _comp_deprecate_func _rl_enabled _comp_readline_variable_on # This function shell-quotes the argument +# @param $1 String to be quoted +# @var[out] ret Resulting string +_comp_quote() +{ + ret=\'${1//\'/\'\\\'\'}\' +} + +# This function shell-quotes the argument +# @deprecated Use `_comp_quote` instead. Note that `_comp_quote` stores +# the results in the variable `ret` instead of writing them to stdout. quote() { local quoted=${1//\'/\'\\\'\'} @@ -162,10 +172,77 @@ quote_readline() printf %s "$ret" } # quote_readline() +# shellcheck disable=SC1003 +_comp_dequote__initialize() +{ + unset -f "$FUNCNAME" + local regex_param='\$([_a-zA-Z][_a-zA-Z0-9]*|[-*@#?$!0-9_])|\$\{[!#]?([_a-zA-Z][_a-zA-Z0-9]*(\[([0-9]+|[*@])\])?|[-*@#?$!0-9_])\}' + local regex_quoted='\\.|'\''[^'\'']*'\''|\$?"([^\"$`!]|'$regex_param'|\\.)*"|\$'\''([^\'\'']|\\.)*'\''' + _comp_dequote__regex_safe_word='^([^\'\''"$`;&|<>()!]|'$regex_quoted'|'$regex_param')*$' +} +_comp_dequote__initialize + +# This function expands a word using `eval` in a safe way. This function can +# be typically used to get the expanded value of `${word[i]}` as +# `_comp_dequote "${word[i]}"`. When the word contains unquoted shell special +# characters, command substitutions, and other unsafe strings, the function +# call fails before applying `eval`. Otherwise, `eval` is applied to the +# string to generate the result. +# +# @param $1 String to be expanded. A safe word consists of the following +# sequence of substrings: +# +# - Shell non-special characaters: [^\'"$`;&|<>()!]. +# - Parameter expansions of the forms $PARAM, ${!PARAM}, +# ${#PARAM}, ${NAME[INDEX]}, ${!NAME[INDEX]}, ${#NAME[INDEX]} +# where INDEX is an integer, `*` or `@`, NAME is a valid +# variable name [_a-zA-Z][_a-zA-Z0-9]*, and PARAM is NAME or a +# parameter [-*@#?$!0-9_]. +# - Quotes \?, '...', "...", $'...', and $"...". In the double +# quotations, parameter expansions are allowed. +# +# @var[out] ret Array that contains the expanded results. Multiple words or no +# words may be generated through pathname expansions. +# +# Note: This function allows parameter expansions as safe strings, which might +# cause unexpected results: +# +# * This allows execution of arbitrary commands through extra expansions of +# array subscripts in name references. For example, +# +# declare -n v='dummy[$(echo xxx >/dev/tty)]' +# echo "$v" # This line executes the command 'echo xxx'. +# _comp_dequote '"$v"' # This line also executes it. +# +# * This may change the internal state of the variable that has side effects. +# For example, the state of the random number generator of RANDOM can change: +# +# RANDOM=1234 # Set seed +# echo "$RANDOM" # This produces 30658. +# RANDOM=1234 # Reset seed +# _comp_dequote '"$RANDOM"' # This line changes the internal state. +# echo "$RANDOM" # This fails to reproduce 30658. +# +# We allow these parameter expansions as a part of safe strings assuming the +# referential transparency of the simple parameter expansions and the sane +# setup of the variables by the user or other frameworks that the user loads. +_comp_dequote() +{ + ret=() # fallback value for unsafe word and failglob + [[ $1 =~ $_comp_dequote__regex_safe_word ]] || return 1 + eval "ret=($1)" 2>/dev/null # may produce failglob +} + # This function shell-dequotes the argument +# @deprecated Use `_comp_dequote' instead. Note that `_comp_dequote` stores +# the results in the array `ret` instead of writing them to stdout. dequote() { - eval printf %s "$1" 2>/dev/null + local ret + _comp_dequote "$1" + local rc=$? + printf %s "$ret" + return $rc } # Unset the given variables across a scope boundary. Useful for unshadowing @@ -1030,14 +1107,14 @@ _parse_help() shopt -s lastpipe set -o noglob - eval local cmd="$(quote "$1")" + local cmd=$1 local line rc=1 - { + ( case $cmd in - -) cat ;; - *) LC_ALL=C "$(dequote "$cmd")" ${2:---help} 2>&1 ;; + -) exec cat ;; + *) _comp_dequote "$cmd" && LC_ALL=C "$ret" ${2:---help} 2>&1 ;; esac - } | + ) | while read -r line; do [[ $line == *([[:blank:]])-* ]] || continue @@ -1067,14 +1144,14 @@ _parse_usage() shopt -s lastpipe set -o noglob - eval local cmd="$(quote "$1")" + local cmd=$1 local line match option i char rc=1 - { + ( case $cmd in - -) cat ;; - *) LC_ALL=C "$(dequote "$cmd")" ${2:---usage} 2>&1 ;; + -) exec cat ;; + *) _comp_dequote "$cmd" && LC_ALL=C "$ret" ${2:---usage} 2>&1 ;; esac - } | + ) | while read -r line; do while [[ $line =~ \[[[:space:]]*(-[^]]+)[[:space:]]*\] ]]; do diff --git a/completions/_umount.linux b/completions/_umount.linux index dfedab11053..a9bc0ca6894 100644 --- a/completions/_umount.linux +++ b/completions/_umount.linux @@ -15,8 +15,10 @@ _reply_compgen_array() # argument. local i wlist for i in ${!COMPREPLY[*]}; do - local q=$(quote "$(printf %q "${COMPREPLY[i]}")") - wlist+=$q$'\n' + local ret + printf -v ret %q "${COMPREPLY[i]}" + _comp_quote "$ret" + wlist+=$ret$'\n' done # We also have to add another round of escaping to $cur. diff --git a/completions/export b/completions/export index 4687ea22c93..60fa0de47f2 100644 --- a/completions/export +++ b/completions/export @@ -30,7 +30,10 @@ _export() case $cur in *=) - local pval=$(quote "$(eval printf %s \"\$\{${cur%=}-\}\")") + local pname=${cur%=} + local ret + _comp_quote "${!pname-}" + local pval=$ret # Complete previous value if it's not empty. if [[ $pval != \'\' ]]; then COMPREPLY=("$pval") diff --git a/completions/make b/completions/make index fb86a45c02a..9cf841a0774 100644 --- a/completions/make +++ b/completions/make @@ -135,8 +135,11 @@ _make() # with -C/--directory for ((i = 1; i < ${#words[@]}; i++)); do if [[ ${words[i]} == -@(C|-directory) ]]; then - # eval for tilde expansion - eval "makef_dir=( -C \"${words[i + 1]}\" )" + # Expand tilde expansion + local ret + _comp_dequote "${words[i + 1]-}" && + [[ -d ${ret-} ]] && + makef_dir=(-C "$ret") break fi done @@ -145,8 +148,11 @@ _make() # specified with -f/--file/--makefile for ((i = 1; i < ${#words[@]}; i++)); do if [[ ${words[i]} == -@(f|-?(make)file) ]]; then - # eval for tilde expansion - eval "makef=( -f \"${words[i + 1]}\" )" + # Expand tilde expansion + local ret + _comp_dequote "${words[i + 1]-}" && + [[ -f ${ret-} ]] && + makef=(-f "$ret") break fi done diff --git a/completions/mutt b/completions/mutt index 1a699df96a4..0af7af0db99 100644 --- a/completions/mutt +++ b/completions/mutt @@ -16,22 +16,24 @@ _muttaddr() # @output muttrc filename _muttrc() { + local muttrc= # Search COMP_WORDS for '-F muttrc' or '-Fmuttrc' argument set -- "${words[@]}" while (($# > 0)); do if [[ $1 == -F* ]]; then + local ret if ((${#1} > 2)); then - muttrc="$(dequote "${1:2}")" + _comp_dequote "${1:2}" && muttrc=$ret else shift - [[ $1 ]] && muttrc="$(dequote "$1")" + [[ ${1-} ]] && _comp_dequote "$1" && muttrc=$ret fi break fi shift done - if [[ ! -v muttrc ]]; then + if [[ ! $muttrc ]]; then if [[ -f ~/.${muttcmd}rc ]]; then muttrc=\~/.${muttcmd}rc elif [[ -f ~/.${muttcmd}/${muttcmd}rc ]]; then @@ -42,27 +44,48 @@ _muttrc() } # Recursively build list of sourced config files -# @param $1 List of config files found so far -# @param $2 Config file to process -# @output List of config files -_muttconffiles() +# @param $1... Config file to process +# @var[out] ret List of config files +# @return 0 if any conffiles are generated, 1 if none is generated. +_comp_cmd_mutt__get_conffiles() { - local file sofar - local -a newconffiles - - sofar=" $1 " - shift - while [[ ${1-} ]]; do - newconffiles=($(command sed -n 's|^source[[:space:]]\{1,\}\([^[:space:]]\{1,\}\).*$|\1|p' "$(eval printf %s $1)")) - for file in ${newconffiles+"${newconffiles[@]}"}; do - __expand_tilde_by_ref file - [[ ! -f $file || $sofar == *\ $file\ * ]] && continue - sofar+=" $file" - sofar=" $(eval _muttconffiles \"$sofar\" $file) " - done - shift + local -a conffiles=() + local -A visited=() + local file + for file; do + _comp_dequote "$file" + _comp_cmd_mutt__get_conffiles__visit "$ret" + done + ((${#conffiles[@]})) || return 1 + ret=("${conffiles[@]}") +} +# Recursion function for _comp_cmd_mutt__get_conffiles +# @var[ref] conffiles List of config files found so far +# @var[ref] visited Dictionary of config files already visited +_comp_cmd_mutt__get_conffiles__visit() +{ + [[ -f $1 && ${visited[$1]-} != yes ]] || return 0 + visited[$1]=yes + conffiles+=("$1") + + local -a newconffiles=($(command sed -n 's|^source[[:space:]]\{1,\}\([^[:space:]]\{1,\}\).*$|\1|p' "$1")) + ((${#newconffiles[@]})) || return 0 + + local file + for file in "${newconffiles[@]}"; do + __expand_tilde_by_ref file + _comp_cmd_mutt__get_conffiles__visit "$file" done - printf '%s\n' $sofar +} + +# Recursively build list of sourced config files +# @param $1... Config file to process +# @output List of config files +_muttconffiles() +{ + local ret + _comp_cmd_mutt__get_conffiles "$@" && + printf '%s\n' "${ret[@]}" } # @param $1 (cur) Current word to complete @@ -74,11 +97,14 @@ _muttaliases() muttrc=$(_muttrc) [[ ! $muttrc ]] && return - conffiles=($(eval _muttconffiles $muttrc $muttrc)) + local ret + _comp_cmd_mutt__get_conffiles "$muttrc" || return 0 + conffiles=("${ret[@]}") # shellcheck disable=SC2046 - aliases=("$(command sed -n 's|^alias[[:space:]]\{1,\}\([^[:space:]]\{1,\}\).*$|\1|p' \ - "${conffiles[@]}")") - COMPREPLY+=($(compgen -W "${aliases[*]}" -- "$cur")) + aliases=($(command sed -n 's|^alias[[:space:]]\{1,\}\([^[:space:]]\{1,\}\).*$|\1|p' \ + "${conffiles[@]}")) + ((${#aliases[@]})) && + COMPREPLY+=($(compgen -W '"${aliases[@]}"' -- "$cur")) } # @param $1 (cur) Current word to complete diff --git a/completions/pkgadd b/completions/pkgadd index f8b7f9ef5ff..4638f57287e 100644 --- a/completions/pkgadd +++ b/completions/pkgadd @@ -49,7 +49,9 @@ _pkgadd() done pkginst_list="${tmplist[*]}" else - pkginst_list="$(strings "$(dequote $device)" | + local ret + _comp_dequote "$device" + pkginst_list="$(strings "$ret" | command grep ^PKG= | sort -u | cut -d= -f2)" fi COMPREPLY=($(compgen -W "$pkginst_list" -- ${cur})) diff --git a/completions/pkgutil b/completions/pkgutil index 1852778d7b4..eff989547c0 100644 --- a/completions/pkgutil +++ b/completions/pkgutil @@ -29,7 +29,9 @@ _pkgutil() local catalog=$(_pkgutil_url2catalog "$url") catalog_files=("$catalog") elif [[ ${words[i]} == --config ]]; then - configuration_files=("$(dequote ${words[i + 1]})") + local ret + _comp_dequote "${words[i + 1]}" + [[ ${ret-} ]] && configuration_files=("$ret") elif [[ ${words[i]} == -@([iurdacUS]|-install|-upgrade|-remove|-download|-available|-compare|-catalog|-stream) ]]; then command="${words[i]}" fi diff --git a/completions/ssh b/completions/ssh index 3f764d9b8f6..49a61d9b10d 100644 --- a/completions/ssh +++ b/completions/ssh @@ -233,11 +233,12 @@ _ssh_configfile() set -- "${words[@]}" while (($# > 0)); do if [[ $1 == -F* ]]; then + local ret if ((${#1} > 2)); then - configfile="$(dequote "${1:2}")" + _comp_dequote "${1:2}" && configfile=$ret else shift - [[ ${1-} ]] && configfile="$(dequote "$1")" + [[ ${1-} ]] && _comp_dequote "$1" && configfile=$ret fi break fi diff --git a/test/t/unit/test_unit_dequote.py b/test/t/unit/test_unit_dequote.py new file mode 100644 index 00000000000..954573fddc1 --- /dev/null +++ b/test/t/unit/test_unit_dequote.py @@ -0,0 +1,161 @@ +import pytest + +from conftest import assert_bash_exec, bash_env_saved + + +@pytest.mark.bashcomp( + cmd=None, + cwd="_filedir", + ignore_env=r"^\+declare -f __tester$", +) +class TestDequote: + def test_1_char(self, bash): + assert_bash_exec( + bash, + '__tester() { local ret=dummy v=var;_comp_dequote "$1";local ext=$?;((${#ret[@]}))&&printf \'<%s>\' "${ret[@]}";echo;return $ext;}', + ) + output = assert_bash_exec(bash, "__tester a", want_output=True) + assert output.strip() == "" + + def test_2_str(self, bash): + output = assert_bash_exec(bash, "__tester abc", want_output=True) + assert output.strip() == "" + + def test_3_null(self, bash): + output = assert_bash_exec(bash, "__tester ''", want_output=True) + assert output.strip() == "" + + def test_4_empty(self, bash): + output = assert_bash_exec(bash, "__tester \"''\"", want_output=True) + assert output.strip() == "<>" + + def test_5_brace(self, bash): + output = assert_bash_exec(bash, "__tester 'a{1..3}'", want_output=True) + assert output.strip() == "" + + def test_6_glob(self, bash): + output = assert_bash_exec(bash, "__tester 'a?b'", want_output=True) + assert output.strip() == "" + + def test_7_quote_1(self, bash): + output = assert_bash_exec( + bash, "__tester '\"a\"'\\'b\\'\\$\\'c\\'", want_output=True + ) + assert output.strip() == "" + + def test_7_quote_2(self, bash): + output = assert_bash_exec( + bash, "__tester '\\\"\\'\\''\\$\\`'", want_output=True + ) + assert output.strip() == "<\"'$`>" + + def test_7_quote_3(self, bash): + output = assert_bash_exec( + bash, "__tester \\$\\'a\\\\tb\\'", want_output=True + ) + assert output.strip() == "" + + def test_7_quote_4(self, bash): + output = assert_bash_exec( + bash, '__tester \'"abc\\"def"\'', want_output=True + ) + assert output.strip() == '' + + def test_7_quote_5(self, bash): + output = assert_bash_exec( + bash, "__tester \\'abc\\'\\\\\\'\\'def\\'", want_output=True + ) + assert output.strip() == "" + + def test_8_param_1(self, bash): + output = assert_bash_exec(bash, "__tester '$v'", want_output=True) + assert output.strip() == "" + + def test_8_param_2(self, bash): + output = assert_bash_exec(bash, "__tester '${v}'", want_output=True) + assert output.strip() == "" + + def test_8_param_3(self, bash): + output = assert_bash_exec(bash, "__tester '${#v}'", want_output=True) + assert output.strip() == "<3>" + + def test_8_param_4(self, bash): + output = assert_bash_exec(bash, "__tester '${v[0]}'", want_output=True) + assert output.strip() == "" + + def test_9_qparam_1(self, bash): + output = assert_bash_exec(bash, "__tester '\"$v\"'", want_output=True) + assert output.strip() == "" + + def test_9_qparam_2(self, bash): + output = assert_bash_exec( + bash, "__tester '\"${v[@]}\"'", want_output=True + ) + assert output.strip() == "" + + def test_10_pparam_1(self, bash): + output = assert_bash_exec(bash, "__tester '$?'", want_output=True) + assert output.strip() == "<0>" + + def test_10_pparam_2(self, bash): + output = assert_bash_exec(bash, "__tester '${#1}'", want_output=True) + assert output.strip() == "<5>" # The string `${#1}` is five characters + + def test_unsafe_1(self, bash): + output = assert_bash_exec( + bash, "! __tester '$(echo hello >&2)'", want_output=True + ) + assert output.strip() == "" + + def test_unsafe_2(self, bash): + output = assert_bash_exec( + bash, "! __tester '|echo hello >&2'", want_output=True + ) + assert output.strip() == "" + + def test_unsafe_3(self, bash): + output = assert_bash_exec( + bash, "! __tester '>| important_file.txt'", want_output=True + ) + assert output.strip() == "" + + def test_unsafe_4(self, bash): + output = assert_bash_exec( + bash, "! __tester '`echo hello >&2`'", want_output=True + ) + assert output.strip() == "" + + def test_glob_default(self, bash): + with bash_env_saved(bash) as bash_env: + bash_env.shopt("failglob", False) + bash_env.shopt("nullglob", False) + output = assert_bash_exec( + bash, "__tester 'non-existent-*.txt'", want_output=True + ) + assert output.strip() == "" + + def test_glob_noglob(self, bash): + with bash_env_saved(bash) as bash_env: + bash_env.set("noglob", True) + output = assert_bash_exec( + bash, + "__tester 'non-existent-*.txt'", + want_output=True, + ) + assert output.strip() == "" + + def test_glob_failglob(self, bash): + with bash_env_saved(bash) as bash_env: + bash_env.shopt("failglob", True) + output = assert_bash_exec( + bash, "! __tester 'non-existent-*.txt'", want_output=True + ) + assert output.strip() == "" + + def test_glob_nullglob(self, bash): + with bash_env_saved(bash) as bash_env: + bash_env.shopt("nullglob", True) + output = assert_bash_exec( + bash, "__tester 'non-existent-*.txt'", want_output=True + ) + assert output.strip() == "" diff --git a/test/t/unit/test_unit_quote.py b/test/t/unit/test_unit_quote.py index b280bd684e7..f570cab5274 100644 --- a/test/t/unit/test_unit_quote.py +++ b/test/t/unit/test_unit_quote.py @@ -3,34 +3,41 @@ from conftest import TestUnitBase, assert_bash_exec -@pytest.mark.bashcomp(cmd=None) +@pytest.mark.bashcomp( + cmd=None, + ignore_env=r"^\+declare -f __tester$", +) class TestUnitQuote(TestUnitBase): def test_1(self, bash): + assert_bash_exec( + bash, + '__tester() { local ret; _comp_quote "$1"; printf %s "$ret"; }', + ) output = assert_bash_exec( - bash, 'quote "a b"', want_output=True, want_newline=False + bash, '__tester "a b"', want_output=True, want_newline=False ) assert output.strip() == "'a b'" def test_2(self, bash): output = assert_bash_exec( - bash, 'quote "a b"', want_output=True, want_newline=False + bash, '__tester "a b"', want_output=True, want_newline=False ) assert output.strip() == "'a b'" def test_3(self, bash): output = assert_bash_exec( - bash, 'quote " a "', want_output=True, want_newline=False + bash, '__tester " a "', want_output=True, want_newline=False ) assert output.strip() == "' a '" def test_4(self, bash): output = assert_bash_exec( - bash, "quote \"a'b'c\"", want_output=True, want_newline=False + bash, "__tester \"a'b'c\"", want_output=True, want_newline=False ) assert output.strip() == r"'a'\''b'\''c'" def test_5(self, bash): output = assert_bash_exec( - bash, 'quote "a\'"', want_output=True, want_newline=False + bash, '__tester "a\'"', want_output=True, want_newline=False ) assert output.strip() == r"'a'\'''"