gds (gds) wrote,
gds
gds

Category:

safe bash coding guidelines

Заметки о безопасном программировании на bash / Safe bash coding guidelines

У меня была и есть необходимость писать некоторый код на bash. Как выяснилось, не всё так плохо: в отличие от sh, bash кое-где стремится сделать шелл-программирование более безопасным. Однако, шелл — он и в африке шелл: нетипизированное интерпретируемое тормозное неконсистентное говно, однако незаменимое в некоторых случаях.

Я использовал не слишком много возможностей баша, и не старался попробовать всё. Мне в первую очередь нужно было, чтобы как можно меньше времени и сил уходило на отладку, я хотел минимизировать ущерб от багов своего кода, и мне очень нужна кроссплатформенность. Всё, о чём я пишу в этой статье, хорошо работает на современных линуксах, на бсд и даже под виндой (mingw/msys).

У меня по условиям задачи есть баш. Ни на какие другие шеллы я не рассчитываю. Соответственно, всё нижеследующее касается только баша.

shebang

Итак, начнём с начала. Как начать баш-скрипт? #!/bin/bash, #!/usr/bin/bash, #!/usr/local/bin/bash? Все варианты одинаково хорошо встречаются в природе. Однако универсальный вариант таки есть:

#! /usr/bin/env bash
/usr/bin/env ищет bash в PATH, передаёт ему оригинальное окружение, текущий скрипт и аргументы командной строки. Пробел перед интерпретатором вычитан в документации на autoconf: есть некоторые системы, где "#! /" в первых 4 байтах файла являются идентификатором шелл-скрипта, а на других системах пробел просто не мешает.

CR

Если рисуете скрипт под виндой, удостоверьтесь, что нет лишних CR (\r) символов. Например, если первая строка заканчивается не LF, а CRLF, то и env, и все шеллы будут считать \r частью имени интерпретатора. Разумеется, он будет не найден, а сообщение об ошибке скукожит ваш моск. В других местах скрипта CR тоже вреден. Проще всего избавиться от CR сразу и не ловить лишних проблем. Избавиться CR в файле можно, например, так:

tr -d '\r' < in-file > out-file

bash-style escaping

Кстати, символы вида \r можно напрямую вставлять в команды, как будто вы их прямо написали в скрипте. bash-специфичный escaping: $'\r', $'\x41' и так далее.

unset variables

Я наткнулся на такую ошибку: сделал rm -rf "$DIR/work/*", но переменная DIR была не определена, и была исполнена команда rm -rf "/work/*". Ситуация была бы сильно хуже, если бы мне захотелось удалить "$DIR/*". Вывод очевиден: нужно заставить баш выдавать ошибку при попытке раскрытия неопределённых переменных. Тут всё просто:

set -o nounset
# или
set -u

В поведении по умолчанию неопределённая переменная раскрывается одинаково с переменной, равной пустой строке. set -o nounset изменяет это, и теперь нельзя просто сделать if [ -z "$VAR" ];, чтобы проверить на пустое значение, так как вызовется ошибка при раскрытии переменной VAR, если она не определена. С помощью следующей функции можно определить, установлена ли переменная:

function var_unset ()
{
[[ "${!1-A}" != "${!1-B}" ]]
}

if var_unset MYVAR;
then
  echo unset
else
  echo set
fi
Обратите внимание, что в функцию передаётся имя переменной в виде строки.

dolboquotes

А вы заметили в примерах выше, что все строки заключены в двойные кавычки? К сожалению, баш — тупая штука. Везде, где хочет, разбивает строку на слова. Например, если бы DIR в этой команде: rm -rf $DIR/* было бы равно "a " (с пробелом в конце), то команда раскрылась бы в rm -rf a /*, что очень неприятно. Да и вообще, есть множество случаев, когда разбиение на слова портит всё. Поэтому для безопасного программирования рекомендую везде, где только можно, заключать строки в двойные кавычки. Заодно будете видеть в коде, где идёт работа не со строками — например, где работаете с числами или где явно разбиваете строку по словам.

safe sequences

Далее, часто встречается код вида:

cd work
make target
Он попытается выполнить make даже если директория work не найдена, что, по логике, ошибочно. Конечно, можно переписать код в виде
cd work && make target
однако у меня в коде оказалось так, что в 95% случаев ненулевой статус выполнения команды означает ошибку в этой команде или где-то ещё, и означает, что дальнейшие команды не только не помогут, но даже испортят ситуацию, поэтому логично было бы останавливаться на каждой ошибке сразу по мере её обнаружения. Достигается так:
set -o errexit
# или
set -e
В случаях, когда ошибку нужно игнорировать, можно использовать запись maybe-fails || true или { maybe-fails || true; }

safe pipes

При обработке данных в баш-скриптах незаменимы пайпы: cat in-file | grep pattern | sed s/from/to/ > out-file. Однако часто бывает так, что какая-то команда из всего пайпа выдаёт ошибку, в результате ставя под сомнение всё вычисление. А в стандартном случае код завершения всего пайпа равен коду завершения последней его команды, и ошибки из середины не ловятся. Это можно исправить опцией

set -o pipefail
После установки этой опции баш будет выдавать в качестве кода завершения пайпа код завершения последней из команд, вернувших ненулевой статус, или же 0 при отсутствии таковых.

Тем не менее, иногда нужно игнорировать ошибки в пайпах.

cat file | grep maybe-doesnt-match | ...
Если в файле не будет шаблона maybe-doesnt-match, то grep вернёт ненулевой статус. Решение такое:
cat file | { grep maybe-doesnt-match || true; } | ...
Плюс в том, что игнорирование ошибок тут явное и целенаправленное, и это правильно.

command substitution

Иногда нужно получить вывод команды и использовать в своих грязных делишках (замечу, что в баше чистых делишек нет). Стандартный способ — обратные апострофы: DIR=`pwd`. Однако там есть правила escaping'а, которыми обременять мозг было лень, поэтому можно использовать bash-специфичный метод:

DIR=$(pwd)
В случаях, когда этот результат будет использоваться в команде, разбивающей строки на слова (например, for A in $(echo a b)), для того, чтобы избежать этого разбиения, можно заключить всю конструкцию в двойные кавычки: "$(echo a b)".

safe "var := command substitution"

А ещё в баше есть переменные и функции. Круто, правда? Расскажу немного про функции. Объявляются: function funcname () {...тело...}. Любой вызов funcname arg1 arg2 ... argN вызывает выполнение тела функции, где фактические параметры передаются в формальных параметрах с именами $1, $2, .. $n, ну и остальные трюки вида $#, "$@" тоже работают, как в обычных баш-скриптах с передачей командной строки. Функция может менять глобальные переменные, может объявлять локальные переменные, может читать свой stdin (например, командой read), может писать в свой stdout, может возвратить код завершения.

Переменные, локальные для функции, нужно объявлять как local VAR. Переменные вне функции, локальные для скрипта, никак не объявляются. Переменную можно присунуть в окружение, это делается командой export VAR. Все видели код вида export VAR=$(somecmd), а некоторые даже видели local VAR=$(somecmd). Но не все знают, что в случае ошибки при исполнении somecmd переменная всё равно будет экспортирована/объявлена, примет пустое значение, а ошибка будет проигнорирована. Чтобы избежать этого, делаем так:

# в скрипте вне функций:
EXPVAR=$(somecmd)
export EXPVAR

# в функции:
local LOCVAR
LOCVAR=$(somecmd)
И, при желании, DIR=$(pwd) || { echo WTF???; exit 1; }. Конечно, если бы в этом примере перед DIR=$(pwd) было написано local или export, ошибку мы бы не словили.

Ещё стоит заметить, что в коде VAR=$(somecmd) || do_on_error в случае ошибки выполнения somecmd старое значение VAR будет изменено (в моих случаях значение заменялось на пустую строку).

Ещё есть одно интересное свойство: каждая команда, находящаяся в цепочке пайпов, выполняется в своём subshell'е, что даёт возможность разбивать логику работы в цепочку вычислений, каждое из которых выполняется своей функцией в своём процессе, при этом процессы работают асинхронно.

tests and asserts

Как осуществляются сравнения, проверки и прочее в баше? Посредством внешней команды test, которая ещё имеет псевдоним "[". Получается гламурненько: if test -f "$FILE"; then ... переписывается в виде:

if [ -f "$FILE" ];
then
  echo file \"$FILE\" not found
fi
Однако в баше есть встроенные возможности для проверки, аналогичные внешней команде test: двойные квадратные скобки. Это быстрее выполняется, это стандартнее работает (версий баша меньше, чем версий test). Предыдущий пример переписывается в виде [[ -f "$FILE" ]].

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

function twoargs ()
{
[ $# -eq 2 ] # передали 2 аргумента
...
Неконсистентность и тут: если бы я написал этот код с использованием встроенных [[ ... ]], он не выдал бы ошибки по причине тараканов в баше (видите ли, [[ ... ]] не считается "simple command"). Нужно или оставить его честной командой "[", как в примере выше, или использовать
[[ $# -eq 2 ]] || { echo where are my two args???; return 1; }

some strings are more equal than others

Чтобы сравнение строк было точное, и чтобы не оказалось, что, оказывается, строки "abc" и "a*" равны, необходимо заключать строки в кавычки, и вместо [[ $A == $B ]] использовать [[ "$A" == "$B" ]]. Без кавычек правый аргумент будет интерпретирован как маска файла, что нужно не часто. Поэтому и тут действует общая рекомендация: "строка? — в кавычки!".

Ещё одна причина, по которой две неравные строки могут быть равны при сравнении в баше — его опция nocasematch, которая, если установлена, указывает башу при сравнениях строк не учитывать регистр. Если вам нужно сравнивать строки с учётом регистра, можно использовать следующую функцию:

function str_eq ()
{
[[ $# -eq 2 ]] || die "str_eq: expected 2 args"
local R=0

if shopt -q nocasematch;
then
  # echo nocase=true
  shopt -u nocasematch
  [[ "$1" == "$2" ]] || R="$?"
  shopt -s nocasematch
else
  # echo nocase=false
  [[ "$1" == "$2" ]] || R="$?"
fi

return $R
}

# пример:
shopt -s nocasematch
if [[ "A" = "a" ]]; then echo == : eq; else echo == : neq; fi;
if str_eq "A" "a"; then echo str: eq; else echo str: neq; fi;

subshell vs "group command"

Ещё существует разница между (cmd1; ...; cmdN) и { cmd1; ...; cmdN; }. Синтаксически — во втором случае нужно отделять скобки от команд пробельными символами (пробел, табуляция, перевод строки), и завершать последнюю команду точкой-с-запятой либо переводом строки (подробнее смотрите man bash). Семантически — первый вариант порождает честный subshell, в котором есть своё окружение, своя рабочая директория, и вообще, всё по-честному, хоть и дороже по производительности. Второй вариант subshell не рождает, своей директории нет, при попытке сделать suspend продолжает выполнять следующие команды (например, нажмите Ctrl-Z в то время, когда код { read A; echo ok; } читает строку). Вывод: использовать { ... } только в случаях, когда первые команды выполняются быстро, без крутых сайд-эффектов, например, как в случае с { echo error; exit 1; }.

subshell strikes back

Однако с subshell'ами та же фигня, что и с двойными квадратными скобками — баш не считает их "simple command", поэтому следующий код выдаёт обе строки: echo A; (false); echo B. Поэтому везде, где нужно ловить ошибку subshell'а, это надо делать явным образом:

echo A; (false) || exit $?; echo B  # если вне функции
# или
echo A; (false) || return $?; echo B  # если внутри функции
Уродство, я считаю. Как, впрочем, и другой вариант:
function subsh ()
  {
  ( eval "$@" )  # возможно не "$@", а то же самое, что в функции retcode
  };

echo A; subsh echo in-subshell \; false ; echo B
# или
echo A; subsh "echo in-subshell ; false"; echo B
Суть — сделали функцию, которая уже считается за simple command, но которая рождает subshell внутри себя и возвращает его код завершения.

stack backtrace

Насчёт культурной выдачи ошибок нам тоже есть куда идти. Мне нравится видеть обратную трассировку стека вызовов функций. Идея кода не моя, в интернетах отрыл.

export STACK_BT="1"

function die ()
{
echo " *** FAIL *** $0: $1" 1>&2
if [[ "$STACK_BT" == "1" ]];
then
  echo "Stack trace:" 1>&2
  for (( i=1; i<${#FUNCNAME[@]}; i++ )); do
    echo "  ${FUNCNAME[$i]} (${BASH_SOURCE[$i]}:${BASH_LINENO[$i-1]})" 1>&2
  done;
fi;

exit 1;
};

trap "die trap" ERR;
Командой trap говорим, что в случае ошибок, которые баш словит сам по себе, нужно выполнить функцию die с аргументом "trap". И вообще, функция die берёт сообщение из первого аргумента, так как я считаю неправильным передавать одну строку в нескольких аргументах, разбивая её на слова.

Заодно покажу свою функцию для выдачи предупреждений, авось пригодится:

function warn ()
{
echo " *** $0: (${BASH_SOURCE[0]}:${BASH_LINENO[0]}): $1" 1>&2;
};

making errexit and errtrace useful

Однако, если мы такие прогрессивные и используем set -o errexit и trap "die trap" ERR, то надо ещё озаботиться, чтобы trap наследовался при вызовах вложенных функций, скриптов и subshell'ов. Это делается опцией

set -o errtrace
# или
set -E

Наследовать trap — чего ещё надо! Проблема опять в кривизне баша. А именно: ERR trap, опции errexit и errtrace не наследуются при выполнении команд, находящихся в условиях while, until, if, в составе списка, разделённого "&&" или "||", и при инвертировании возвращаемого значения команды через "!". То есть, просто так берут и не наследуются. Одно дело, если бы наследовали, но ошибку рассматривали без вызова ERR trap, тем не менее, прерывая выполнение вызванного кода — была бы правильная логика. То есть,

$ (set -o errexit; trap "echo trapped" ERR; set -E; (false; echo WTF???) )
trapped
$ (set -o errexit; trap "echo trapped" ERR; set -E; (false; echo WTF???) || true)
WTF???
Рушится вся композиционируемость. Не будешь же в начале каждой функции или каждого subshell'а писать set -e; set -E;.

Тот вариант, который я предлагаю, тоже не блещет, но мне он показался лучшим, и он прилично работает. Название функции не слишком адекватное, но идей лучше не было.

function retcode ()
  {
  local CMD=""
  for P in "$@";
  do
    if [[ ! -z "$CMD" ]]; then CMD+=" "; fi;
    CMD+="\"$P\""
  done;
  # echo +eval $CMD
  ( trap 'return $?' ERR ; eval $CMD )
  }

# и пример использования:
set -e
set -E

function badfunc ()
  {
  false
  echo WTF???
  }

badfunc || echo bad           # выводит "WTF???"
retcode badfunc || echo bad   # выводит "bad"
Аналогично и с другими конструкциями, где не наследуются ERR trap и опции -e -E. Другое дело в том, что иногда нет смысла в использовании функции — если нет необходимости прерывать выполнение функции при возникновении ошибок или если там нечего прерывать, то можно и классическим образом вызывать, заодно сэкономить на порождении subshell'а и на eval внутри retcode.

word splitting? useful??

Ещё про разбиение на слова. Однажды мне понадобилось обработать файлы, которые надо найти по определённым условиям, но в именах которых могут быть пробелы. Баш по умолчанию считает разделителем слов пробел, перенос строки и символ табуляции. Ещё одна кривость баша: он не воспринимает IFS=$'\x00', что обламывает весь find ... -print0. Пришлось разделять результат выдачи find'а символом $'\n'. То есть, как-то так:

$ (IFS=$'\n'; for X in $(find ./a*); do echo X="$X"; done)
X=./a b
X=./a b/c d
Обратите внимание, что в данном случае $(find ./a*) находится не в кавычках, так как нам явным образом нужно, чтобы баш разбил вывод find'а на слова по символу $'\n', то есть, на строки. (а зачем теоретически неплохо поставить кавычки в echo X="$X", любознательный читатель поймёт, поставив в кавычки $(find ./a*) и пропустив вывод из примера через какой-нибудь od -t x1). Ну а subshell в примере — чтобы не портить текущий IFS.

argument list vs quoted text

Возникла необходимость одной командой find получить список файлов как в директории $A/, так и в директории $B/, но директория $B не всегда может существовать. Это вызовет ругань find. Просто перевести её в /dev/null — решение кривое, так как ругань может быть по другим поводам. Фильтровать ругань по названию директории и сообщению об ошибке — криво. Если обнулить $B при её отсутствии перед find (кодом вида [[ -d "$B" ]] && B="$B/" || B="") и вызвать find "$A/" "$B", то find будет ругаться уже на то, что ему передают пустой аргумент ("$B" раскрывается в ""). Если передавать $B без кавычек, а в $B будет пробел, то $B разобьётся на слова, которые передадутся в отдельных аргументах, что ошибочно. Решение — использовать раскрытие ${parameter:+word}:

[[ -d "$B" ]] || B=""
find "$A" ${B:+"$B"/}
Отсутствие кавычек вокруг конструкции ${B:+"$B"} — характерный признак того, что дело не чисто. А именно, в случае, когда результат раскрытия равен пустой строке, этот аргумент будет не передан как пустая строка, а просто пропущен.

source

Умные дядьки советуют для включения скрипта в текущее окружение использовать не старую форму . script.sh, а новую: source script.sh. В принципе, соглашусь, что она более читаемая, но других разниц нет, и, если шрифт нормальный и глаз зоркий, то точку не пропустите. И ещё учтите, что при отсутствии слеша в имени включаемого файла он будет искаться в PATH. Это поведение можно отключить опциями, но проще явно указать путь к этому файлу. Относительный путь тоже устроит.

sample

#! /usr/bin/env bash

set -o nounset
set -o errexit
set -o pipefail
set -o errtrace


# used variables:
#   STACK_BT : "0" / "1" -- show stack backtrace on error
#
# available functions:
#   die "text"  -- output text to stderr, optionally backtrace, and exit.
#   warn "text"  -- output text to stderr.
#   var_unset "var_name"  -- returns true if var_name is unset.
#   str_eq "string1" "string2"  -- compares two strings case-sensitive.
#   retcode cmd args  -- correctly propagates -e -E

export STACK_BT="1"

function die ()
{
echo " *** FAIL *** $0: $1" 1>&2
if [[ "$STACK_BT" == "1" ]];
then
  echo "Stack trace:" 1>&2
  for (( i=1; i<${#FUNCNAME[@]}; i++ )); do
    echo "  ${FUNCNAME[$i]} (${BASH_SOURCE[$i]}:${BASH_LINENO[$i-1]})" 1>&2
  done;
fi;

exit 1;
};

trap "die trap" ERR;

function warn ()
{
echo " *** $0: (${BASH_SOURCE[0]}:${BASH_LINENO[0]}): $1" 1>&2;
};

function var_unset ()
{
[[ "${!1-A}" != "${!1-B}" ]]
}

function str_eq ()
{
[[ $# -eq 2 ]] || die "str_eq: expected 2 args"
local R=0

if shopt -q nocasematch;
then
  # echo nocase=true
  shopt -u nocasematch
  [[ "$1" == "$2" ]] || R="$?"
  shopt -s nocasematch
else
  # echo nocase=false
  [[ "$1" == "$2" ]] || R="$?"
fi

return $R
}

function retcode ()
  {
  local CMD=""
  for P in "$@";
  do
    if [[ ! -z "$CMD" ]]; then CMD+=" "; fi;
    CMD+="\"$P\""
  done;
  # echo +eval $CMD
  ( trap 'return $?' ERR ; eval $CMD )
  }

#####################

# your code here

exit 0

exit 0

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

Пост будет обновляться по мере получения информации по данному вопросу.

Редакция будет рада обратной связи, замечаниям, дополнениям, критике и прочей фигне. Редакция вообще очень рада бывает иногда.

upd1@2010-11-01.
Дополнительное чтиво.

Subscribe
  • Post a new comment

    Error

    Anonymous comments are disabled in this journal

    default userpic

    Your reply will be screened

    Your IP address will be recorded 

  • 4 comments