Почему в Bash следует избегать eval, и что я должен использовать вместо этого?

Снова и снова я вижу ответы Bash на переполнение стека, используя eval и ответы обижаются, каламбур предназначен для использования такой "злой" конструкции. Почему eval такой злой?

Если eval не может использоваться безопасно, что я должен использовать вместо этого?

4 ответа

Решение

Это больше, чем кажется на первый взгляд. Начнем с очевидного: 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 дважды анализирует командную строку (в любой оболочке). На первом проходе разбора один слой цитирования удаляется. При удалении кавычек выполняется некоторое переменное содержимое.

Мы можем исправить это, разрешив расширение переменной в пределах 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.

Это должно работать. Это также безопасно, пока $1 в println никогда не бывает грязным

Теперь подождите минутку: я использую тот же синтаксис без кавычек, который мы использовали изначально sudo Все время! Почему это работает там, а не здесь? Почему мы должны все заключать в кавычки? sudo немного более современен: он знает, что заключать в кавычки каждый аргумент, который он получает, хотя это слишком упрощает. eval просто объединяет все

К сожалению, нет замены для замены eval который рассматривает аргументы как sudo делает, как eval встроенная оболочка; это важно, поскольку при выполнении он принимает во внимание среду и область действия окружающего кода, а не создает новый стек и область, как функция.

альтернативы eval

Конкретные случаи использования часто имеют жизнеспособные альтернативы eval, Вот удобный список. command представляет то, что вы обычно отправляете eval; подставьте во что угодно.

Нет-оп

Простое двоеточие в no-op в bash:

Создать вложенную оболочку

( 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!

Если бы это был одноразовый вызов, вам не пришлось бы перенаправлять всю оболочку:

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}"

Это верно, bash имеет переменную косвенную привязку, начиная с версии 2. Это становится немного сложнее, чем 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,

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"

Создайте команду для оценки:

% 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 не зло - это просто неправильно поняли:)

Я разделю этот ответ на две части , которые, я думаю, охватывают большую часть случаев, когда люди склонны соблазняться:

  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 barа также plop 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. Наслаждайтесь лучшей подсветкой синтаксиса, чем со строками, поскольку вам не нужно вкладывать кавычки или использовать $-s, что «не будет оцениваться сразу, но будет в какой-то момент».

Для меня главное преимущество такого подхода (и наоборот недостаток ) в том, что можно следовать той же логике, что и обычно в отношении цитирования, расширения и т.д. Не нужно ломать голову, пытаясь поставить кавычки в кавычки в кавычки «заранее» при попытке выяснить, какая команда будет интерпретировать какую пару кавычек в какой момент. И, конечно же, многие вещи, упомянутые выше, труднее или совершенно невозможно реализовать с помощью .

С ними мне никогда не приходилось полагаться на последние шесть лет или около того, а читабельность и надежность (в частности, в отношении аргументов, содержащих пробелы), возможно, повысились. Вам даже не нужно знать, 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

Как насчет

ls -la /path/to/foo | grep bar | bash

или же

(ls -la /path/to/foo | grep bar) | bash

?

Другие вопросы по тегам