为什么在 Bash 中应该避免 eval,我应该使用什么来代替它?

我一次又一次地看到使用 eval的 Stack Overflow 上的 Bash 答案,这些答案因为使用了这种“邪恶”结构而遭到抨击。为什么 eval这么邪恶?

如果 eval不能安全使用,我应该使用什么来代替?

84767 次浏览

这个问题远不止表面上看到的那么简单。我们将从显而易见的事情开始: eval有可能执行“脏”数据。脏数据是没有被重写为“在某种情况下使用安全”XYZ 的任何数据; 在我们的示例中,它是没有被格式化以便安全计算的任何字符串。

消毒数据乍一看似乎很容易。假设我们抛出一个选项列表,bash 已经提供了一个很好的方法来清理单个元素,以及另一种方法来清理整个数组作为一个单独的字符串:

function println
{
# Send each element as a separate argument, starting with the second element.
# Arguments to printf:
#   1 -> "$1\n"
#   2 -> "$2"
#   3 -> "$3"
#   4 -> "$4"
#   etc.


printf "$1\n" "${@:2}"
}


function error
{
# Send the first element as one argument, and the rest of the elements as a combined argument.
# Arguments to println:
#   1 -> '\e[31mError (%d): %s\e[m'
#   2 -> "$1"
#   3 -> "${*:2}"


println '\e[31mError (%d): %s\e[m' "$1" "${*:2}"
exit "$1"
}


# This...
error 1234 Something went wrong.
# And this...
error 1234 'Something went wrong.'
# Result in the same output (as long as $IFS has not been modified).

现在,假设我们想添加一个选项,将输出作为参数重定向到 println。当然,我们可以在每个调用上重定向 println 的输出,但是举个例子,我们不打算这样做。我们需要使用 eval,因为变量不能用于重定向输出。

function println
{
eval printf "$2\n" "${@:3}" $1
}


function error
{
println '>&2' '\e[31mError (%d): %s\e[m' "$1" "${*:2}"
exit $1
}


error 1234 Something went wrong.

看起来不错,对吧?问题是 eval 解析两次命令行(在任何 shell 中)。在解析的第一次传递时,删除一层引用。删除引号后,将执行一些变量内容。

我们可以通过让变量在 eval中展开来解决这个问题。所有我们要做的是单引号的一切,留下双引号的地方,他们是。一个例外是: 我们必须在 eval之前展开重定向,这样就必须保持在引号之外:

function println
{
eval 'printf "$2\n" "${@:3}"' $1
}


function error
{
println '&2' '\e[31mError (%d): %s\e[m' "$1" "${*:2}"
exit $1
}


error 1234 Something went wrong.

这应该工作。它也是安全的,只要 println中的 $1永远不脏。

现在稍等一下: 我一直使用与 sudo最初使用的相同的 未被引用语法!为什么它在那里工作,而不是在这里?为什么我们什么都要单引号?sudo稍微现代一些: 它知道在接收到的每个参数中加引号,尽管这是一种过于简化的做法。eval简单地将所有内容连接起来。

不幸的是,eval没有像 sudo那样处理参数的插入式代码,因为 eval是一个内置的 shell; 这一点很重要,因为它在执行时会占用周围代码的环境和作用域,而不是像函数那样创建一个新的堆栈和作用域。

Eval 备选方案

特定的用例通常有可行的 eval替代品。这是一个简单的清单。command表示通常发送到 eval的内容,请用任何内容替换。

不行动

在 bash 中,一个简单的冒号是 no-op:

:

创建一个子 shell

( command )   # Standard notation

执行命令的输出

永远不要依赖外部命令。您应该始终控制返回值。把这些放在他们自己的线上:

$(command)   # Preferred
`command`    # Old: should be avoided, and often considered deprecated


# Nesting:
$(command1 "$(command2)")
`command "\`command\`"`  # Careful: \ only escapes $ and \ with old style, and
# special case \` results in nesting.

基于变量的重定向

在调用代码中,将 &3(或任何高于 &2的值)映射到目标:

exec 3<&0         # Redirect from stdin
exec 3>&1         # Redirect to stdout
exec 3>&2         # Redirect to stderr
exec 3> /dev/null # Don't save output anywhere
exec 3> file.txt  # Redirect to file
exec 3> "$var"    # Redirect to file stored in $var--only works for files!
exec 3<&0 4>&1    # Input and output!

如果是一次性调用,您就不必重定向整个 shell:

func arg1 arg2 3>&2

在被调用的函数中,重定向到 &3:

command <&3       # Redirect stdin
command >&3       # Redirect stdout
command 2>&3      # Redirect stderr
command &>&3      # Redirect stdout and stderr
command 2>&1 >&3  # idem, but for older bash versions
command >&3 2>&1  # Redirect stdout to &3, and stderr to stdout: order matters
command <&3 >&4   # Input and output!

变量间接

场景:

VAR='1 2 3'
REF=VAR

坏:

eval "echo \"\$$REF\""

为什么?如果 REF 包含双引号,这将中断并打开要利用的代码。可以对 REF 进行消毒,但是如果您拥有以下内容,那就是在浪费时间:

echo "${!REF}"

没错,从版本2开始 bash 就内置了变量间接。如果你想做一些更复杂的事情,它会比 eval稍微复杂一些:

# Add to scenario:
VAR_2='4 5 6'


# We could use:
local ref="${REF}_2"
echo "${!ref}"


# Versus the bash < 2 method, which might be simpler to those accustomed to eval:
eval "echo \"\$${REF}_2\""

无论如何,新的方法是更直观的,虽然它可能看起来不是那样的经验丰富的程序谁是用来 eval

联合数组

关联数组本质上是在 bash 4中实现的。

declare -A VAR   # Local
declare -gA VAR  # Global


# Use spaces between parentheses and contents; I've heard reports of subtle bugs
# on some versions when they are omitted having to do with spaces in keys.
declare -A VAR=( ['']='a' [0]='1' ['duck']='quack' )


VAR+=( ['alpha']='beta' [2]=3 )  # Combine arrays


VAR['cow']='moo'  # Set a single element
unset VAR['cow']  # Unset a single element


unset VAR     # Unset an entire array
unset VAR[@]  # Unset an entire array
unset VAR[*]  # Unset each element with a key corresponding to a file in the
# current directory; if * doesn't expand, unset the entire array


local KEYS=( "${!VAR[@]}" )  # Get all of the keys in VAR

在旧版本的 bash 中,您可以使用变量间接:

VAR=( )  # This will store our keys.


# Store a value with a simple key.
# You will need to declare it in a global scope to make it global prior to bash 4.
# In bash 4, use the -g option.
declare "VAR_$key"="$value"
VAR+="$key"
# Or, if your version is lacking +=
VAR=( "$VAR[@]" "$key" )


# Recover a simple value.
local var_key="VAR_$key"       # The name of the variable that holds the value
local var_value="${!var_key}"  # The actual value--requires bash 2
# For < bash 2, eval is required for this method.  Safe as long as $key is not dirty.
local var_value="`eval echo -n \"\$$var_value\""


# If you don't need to enumerate the indices quickly, and you're on bash 2+, this
# can be cut down to one line per operation:
declare "VAR_$key"="$value"                         # Store
echo "`var_key="VAR_$key" echo -n "${!var_key}"`"   # Retrieve


# If you're using more complex values, you'll need to hash your keys:
function mkkey
{
local key="`mkpasswd -5R0 "$1" 00000000`"
echo -n "${key##*$}"
}


local var_key="VAR_`mkkey "$key"`"
# ...

如何使 eval安全

eval 可以被安全地使用-但是它的所有参数需要先被引用。以下是如何:

这个函数将为你做到这一点:

function token_quote {
local quoted=()
for token; do
quoted+=( "$(printf '%q' "$token")" )
done
printf '%s\n' "${quoted[*]}"
}

示例用法:

给定一些不可信的用户输入:

% input="Trying to hack you; date"

构造要 eval 的命令:

% cmd=(echo "User gave:" "$input")

对它进行评估,用 看起来正确引用:

% eval "$(echo "${cmd[@]}")"
User gave: Trying to hack you
Thu Sep 27 20:41:31 +07 2018

请注意,您被黑客攻击了。 date是被执行的,而不是字面上的打印。

取而代之的是 token_quote():

% eval "$(token_quote "${cmd[@]}")"
User gave: Trying to hack you; date
%

eval并不邪恶——它只是被误解了:)

我将在 两部分中分解这个答案,我认为,它涵盖了人们倾向于被 eval诱惑的大部分案例:

  1. 运行奇怪的命令
  2. 摆弄动态命名的变量

运行奇怪的命令

很多时候,简单的 索引数组就足够了,前提是您要养成使用双引号的好习惯,以便在定义数组时保护扩展。

# One nasty argument which must remain a single argument and not be split:
f='foo bar'
# The command in an indexed array (use `declare -a` if you really want to be explicit):
cmd=(
touch
"$f"
# Yet another nasty argument, this time hardcoded:
'plop yo'
)
# Let Bash expand the array and run it as a command:
"${cmd[@]}"

这将创建 foo barplop yo(两个文件,而不是四个)。

注意,有时候它可以生成更多可读的脚本,只在数组中放入参数(或者一堆选项)(至少你第一眼就知道你在运行什么) :

touch "${args[@]}"
touch "${opts[@]}" file1 file2

额外的好处是,数组可以让你轻松地:

  1. 添加关于特定论点的评论:
cmd=(
# Important because blah blah:
-v
)
  1. 通过在数组定义中保留空行来对参数进行可读性分组。
  2. 为了调试的目的注释掉特定的参数。
  3. 在命令中添加参数,有时根据特定条件或循环动态添加:
cmd=(myprog)
for f in foo bar
do
cmd+=(-i "$f")
done
if [[ $1 = yo ]]
then
cmd+=(plop)
fi
to_be_added=(one two 't h r e e')
cmd+=("${to_be_added[@]}")
  1. 在配置文件中定义命令,同时允许配置定义的包含空格的参数:
readonly ENCODER=(ffmpeg -blah --blah 'yo plop')
# Deprecated:
#readonly ENCODER=(avconv -bloh --bloh 'ya plap')
# […]
"${ENCODER[@]}" foo bar
  1. 使用 printf 的 %q:
function please_log_that {
printf 'Running:'
# From `help printf`:
# “The format is re-used as necessary to consume all of the arguments.”
# From `man printf` for %q:
# “printed in a format that can be reused as shell input,
# escaping  non-printable  characters with the proposed POSIX $'' syntax.”
printf ' %q' "$@"
echo
}


arg='foo bar'
cmd=(prog "$arg" 'plop yo' $'arg\nnewline\tand tab')
please_log_that "${cmd[@]}"
# ⇒ “Running: prog foo\ bar plop\ yo $'arg\nnewline\tand tab'”
# You can literally copy and paste that ↑ to a terminal and get the same execution.
  1. 与使用 eval字符串相比,你可以享受更好的语法突显,因为你不需要嵌套引号或者使用“不会马上被评估但是会在某个时刻被评估”的 $-s。

对我来说,这种方法的主要优点(反过来也是 eval的缺点)是 你可以遵循同样的逻辑通常关于报价,扩展等。不需要绞尽脑汁在“提前”把引号放在引号中,同时试图找出哪个命令将在哪个时刻解释哪对引号。当然,上面提到的许多事情是很难或者根本不可能用 eval来实现的。

有了这些,我在过去六年左右的时间里从来不需要依赖于 eval,可读性和健壮性(特别是关于包含空格的参数)可以说得到了提高。你甚至不需要知道 IFS是否已经被锻炼过了!当然,仍然存在实际需要 eval的边缘情况(例如,如果用户必须能够通过交互式提示或其他方式提供完整的脚本) ,但希望你不会每天都遇到这种情况。

摆弄动态命名的变量

declare -n(或者它的函数内对应的 local -n)以及 ${!foo}在大多数时候都可以做到这一点。

$ help declare | grep -- -n
-n    make NAME a reference to the variable named by its value

如果没有例子的话就不是很清楚了:

declare -A global_associative_array=(
[foo]=bar
[plop]=yo
)


# $1    Name of global array to fiddle with.
fiddle_with_array() {
# Check this if you want to make sure you’ll avoid
# circular references, but it’s only if you really
# want this to be robust.
# You can also give an ugly name like “__ref” to your
# local variable as a cheaper way to make collisions less likely.
if [[ $1 != ref ]]
then
local -n ref=$1
fi
    

printf 'foo → %s\nplop → %s\n' "${ref[foo]}" "${ref[plop]}"
}


# Call the function with the array NAME as argument,
# not trying to get its content right away here or anything.
fiddle_with_array global_associative_array


# This will print:
# foo → bar
# plop → yo

(我喜欢这个技巧,因为它让我感觉像是在将对象传递给函数,就像在面向对象的语言中一样。这种可能性令人难以置信。)

至于 ${!…}(它获取由另一个变量命名的变量的值) :

foo=bar
plop=yo


for var_name in foo plop
do
printf '%s = %q\n' "$var_name" "${!var_name}"
done


# This will print:
# foo = bar
# plop = yo