Почему bash?
В bash есть массивы и безопасный режим. При правильном использовании bash почти соответствует практикам безопасного кодирования.
В fish сложнее допустить ошибку, но там нет безопасного режима. Поэтому хорошей идеей станет прототипирование в fish, а затем трансляция с fish на bash, если вы умеете правильно это делать.
Предисловие
Данное руководство сопровождает ShellHarden, но автор также рекомендует ShellCheck, чтобы правила ShellHarden не расходились с ShellCheck.
Bash — не тот язык, где самый правильный способ решить проблему одновременно является самым простым. Если принимать экзамен по безопасному программированию в bash, то первое правило BashPitfalls звучало бы так: всегда используй кавычки.
Главное, что нужно знать о программировании в bash
Маниакально ставить кавычки! Незакавыченная переменная должна расцениваться как взведённая бомба: она взрывается при контакте с пробелом. Да, «взрывается» в смысле разделения строки на массив. В частности, расширения переменных вроде
$var
и подстановки команд вроде $(cmd)
подвергаются расщеплению слов, когда внутренняя строка расширяется в массив из-за расщепления в специальной переменной $IFS
с пробелом по умолчанию. Это обычно незаметно, потому что чаще всего результатом становится массив из 1 элемента, неотличимый от ожидаемой строки.Расширяется не только это, но и групповые символы (
*?
). Этот процесс происходит после расщепления слова, так что если в слове есть хоть один групповой символ, то слово превращается в групповой шаблон, который распространяется на любые подходящие пути файлов. Так что эта фича начинает применяться к файловой системе!Закавычивание подавляет и расщепление слов, и расширение шаблона для переменных и подстановок команд.
Расширение переменной:
- Хорошо:
"$my_var"
- Плохо:
$my_var
Подстановка команды:
- Хорошо:
"$(cmd)"
- Плохо:
$(cmd)
Есть исключения с необязательными кавычками, но кавычки никогда не помешают, а общее правило — бояться незакавыченных переменных, так что ради вашего блага не станем искать пограничные исключения. Это выглядит неправильно, и неправильная практика достаточно распространена, чтобы вызвать подозрение: написано немало скриптов со сломанной обработкой имён файлов и пробелов в них…
ShellHarden упоминает только несколько исключений — это переменные с численным содержимым, такие как
$?
, $#
и ${#array[@]}
.Нужно ли использовать обратные галочки?
Подстановки команд могут иметь и такой вид:
- Правильно:
"`cmd`"
- Плохо:
`cmd`
Хотя такой стиль можно использовать правильно, но он выглядит менее удобным в кавычках и менее читабельным при вложенности. Консенсус тут довольно ясен: избегайте его.
ShellHarden переписывает такие галочки в форму скобки в долларах.
Нужно ли использовать фигурные скобки?
Скобки используются для интерполяции строк, так что обычно избыточны:
- Плохо:
some_command $arg1 $arg2 $arg3
- Плохо и многословно:
some_command ${arg1} ${arg2} ${arg3}
- Хорошо, но многословно:
some_command "${arg1}" "${arg2}" "${arg3}"
- Хорошо:
some_command "$arg1" "$arg2" "$arg3"
Теоретически всегда использовать фигурные скобки не является проблемой, но по опыту вашего автора существует сильная отрицательная корреляция между ненужным использованием фигурных скобок и правильным использованием кавычек — почти каждый выбирает «плохую и многословную» вместо «хорошей, но многословной» формы!
Теории вашего автора:
- Из-за страха сделать что-то неправильно: вместо настоящей опасности (отсутствие кавычек) новички могут беспокоиться, что переменная
$prefix
вызовет расширение переменной"$prefix_postfix"
, но всё работает не так. - Карго-культ: написание кода по завету неправильного страха, который ему предшествовал.
- Скобки конкурируют с кавычками за лимит допустимой многословности.
Поэтому было решено запретить ненужные фигурные скобки: ShellHarden заменяет эти варианты самой простой хорошей формой.
А теперь об интерполяции строк, где фигурные скобки действительно полезны:
- Плохо (конкатенация):
$var1"more string content"$var2
- Хорошо (конкатенация):
"$var1""more string content""$var2"
- Хорошо (интерполяция):
"${var1}more string content${var2}"
Конкатенация и интерполяция в bash эквиваленты даже в массивах (что нелепо).
Поскольку ShellHarden не форматирует стили, ему не положено изменять правильный код. Это справедливо для варианта «хорошо (интерполяция)»: с точки зрения ShellHarden это будет канонически правильная форма.
Сейчас ShellHarden добавляет и удаляет фигурные скобки по мере необходимости: в плохом примере var1 снабжается скобками, но они не допускаются для var2 даже в случае «хорошо (интерполяция)», поскольку они никогда не нужны в конце строки. Последнее требование вполне может быть отменено.
Попался: нумерованные аргументы
В отличие от названий переменных нормального идентификатора (в regex:
[_a-zA-Z][_a-zA-Z0-9]*
), нумерованные аргументы требуют скобок (интерполяция строк не требует). ShellCheck говорит:echo "$10"
^-- SC1037: Braces are required for positionals over 9, e.g. ${10}.
ShellHarden отказывается это исправлять (считает слишком тонкой разницей).
Поскольку скобки разрешены до 9, то ShellHarden разрешает их для всех нумерованных аргументах.
Использование массивов
Чтобы иметь возможность закавычивать все переменные, вы должны использовать настоящие массивы, а не разделённые пробелами псевдомассивные строки.
Синтаксис многословный, но придётся справиться. Этот башизм — только одна причина отказаться от совместимости POSIX для большинства shell-скриптов.
Хорошо:
array=(
a
b
)
array+=(c)
if [ ${#array[@]} -gt 0 ]; then
rm -- "${array[@]}"
fi
Плохо:
pseudoarray=" a b "
pseudoarray="$pseudoarray c"
if ! [ "$pseudoarray" = '' ]; then
rm -- $pseudoarray
fi
Вот почему массивы — настолько базовая функция для оболочки: аргументы команд фундаментально — это массивы (а shell-скрипты — это команды и аргументы). Можно сказать, что оболочка, которая искусственно делает невозможной передачу нескольких аргументов, будет комичной и негодной. Некоторые широко распространённые оболочки из этой категории включают Dash и Busybox Ash. Это минимальные POSIX-совместимые оболочки — но что хорошего в совместимости, если самый важный материал не на POSIX?
Исключительные случаи, когда вы реально собираетесь разбить строку
Пример с
\v
в качестве разделителя данных (обратите внимание на второе вхождение):IFS=$'\v' read -d '' -ra a < <(printf '%s\v' "$s") || true
Так мы избегаем расширения шаблона, и способ работает даже если разделителем данных будет
\n
. Второе вхождение разделителя данных защищает последний элемент, если он окажется пробелом. По какой-то причине первым должен идти параметр -d
, так что сцепить параметры в -rad ''
заманчиво, но не сработает. Поскольку в данном случае read возвращает ненулевое значение, то его следует защитить от errexit (|| true
), если это включено. Протестировано в bash 4.0, 4.1, 4.2, 4.3 и 4.4.Альтернативный вариант для bash 4.4:
readarray -td $'\v' a < <(printf '%s\v' "$s")
С чего начать bash-скрипт
С чего-нибудь такого:
#!/usr/bin/env bash
if test "$BASH" = "" || "$BASH" -uc "a=();true \"\${a[@]}\"" 2>/dev/null; then
# Bash 4.4, Zsh
set -euo pipefail
else
# Bash 4.3 and older chokes on empty arrays with set -u.
set -eo pipefail
fi
shopt -s nullglob globstar
Это включает в себя:
- Шебанг:
- Вопросы переносимости: абсолютный путь к
env
вероятно лучше для переносимости, чем абсолютный путь кbash
. Можно посмотреть на пример NixOS. POSIX требует наличия env, но не bash. - Вопросы безопасности: ни для какого языка здесь не будут благосклонно приняты варианты вроде
-euo pipefail
! Такое становится невозможным при использовании редиректаenv
, но даже если ваш шебанг начинается с#!/bin/bash
, это не место для параметров, которые влияют на значение скрипта, потому что они могут быть переопределены, что сделает возможным неправильное выполнение скрипта. Однако в качестве бонуса можно сделать переопределяемыми опции, не влияющие на значение скрипта, такие какset -x
, если они используются.
- Вопросы переносимости: абсолютный путь к
- Что нам нужно из неофициального строгого режима Bash, с проверкой фич
set -u
. Нам не нужен весь строгий режим Bash, потому что совместимость shellcheck/shellharden означает закавычивание всего и вся, что уже гораздо строже. Кроме того, опцияset -u
не должна использоваться в Bash 4.3 и более ранних версиях. Поскольку данная опция в тех версиях расценивает пустые массивы как сброшенные, то массивы невозможно использовать для целей, описанных здесь. Использование массивов — второй по важности совет из этого руководства (после кавычек) и единственная причина, по которой мы жертвуем совместимостью с POSIX, поэтому такое никак недопустимо: либо вообще не применяйтеset -u
, либо используйте Bash 4.4 или другую нормальную оболочку вроде Zsh. Такое легче сказать, чем сделать, ведь существует вероятность, что некто всё-таки запустит ваш скрипт в древней версии Bash. К счастью, всё работающее сset -u
будет работать и без него (дляset -e
такого не скажешь). Вот почему важно использовать проверку версии. Остерегайтесь предположения, что тестирование и разработка происходят в оболочке, совместимой с Bash 4.4 (так что аспектset -u
протестируют). Если вас это беспокоит, то другой вариант отказаться от совместимости (сбой скрипта при сбое проверки версии), или отказаться отset -u
. shopt -s nullglob
заставляет корректно работатьfor f in *.txt
, если*.txt
не находит файлов. Поведение по умолчанию (aka passglob) передаёт шаблон без изменений, что в случае нулевого результата опасно по нескольким причинам. Для globstar это активирует рекурсивную подстановку. Подстановку легче правильно использовать, чемfind
. Так что используйте её.
Но не:
IFS=''
set -f
shopt -s failglob
- Установка внутреннего разделителя полей пустой строкой сделает невозможным расщепление слова. Звучит как идеальное решение. К сожалению, это неполная замена для закавычивания переменных и подстановок команд, а поскольку вы собираетесь использовать кавычки, то это ничего не даёт. Причина, почему кавычки по-прежнему нужно использовать, заключается в том, что в противном случае пустые строки становятся пустыми массивами (как в
test $x = ""
) и по-прежнему возможно непрямое расширение шаблона. Более того, проблемы с этой переменной также вызовет проблемы с использующими её командами вродеread
, что поломает конструкции типаcat /etc/fstab | while read -r dev mnt fs opt dump pass; do echo "$fs"; done'
. - Отключается расширение шаблона: не только печально известного косвенного расширения, но и беспроблемного прямого, который, как я говорил, вы должны использовать. Так что это трудно принять. И это ещё и совершенно необязательно для скрипта, совместимого с shellcheck/shellharden.
- В отличие от nullglob, failglob сбоит при нулевом результате. Хотя для большинства команд это имеет смысл, например,
rm -- *.txt
(потому что для большинства команд всё равно не ожидается выполнения при нулевом результате), очевидно, failglob можно использовать только если вы не предполагаете нулевой результат. Это значит, что обычно вы не станете размещать групповые шаблоны в аргументах команды, если не предполагаете то же самое. Но что всегда может произойти, так это использование nullglob и расширение шаблона на нулевые аргументы в конструкциях, которые могут их принимать, таких как цикл или присваивание значений массиву (txt_files=(*.txt)
).
Как завершить bash-скрипт
Статус выхода скрипта — это статус последней выполненной команды. Удостоверьтесь, что она представляет реальный успех или неудачу.
Самое худшее — это оставить решение несвязанному условию в виде списка AND в конце скрипта. Если условие ложно, то последней выполненной командой будет само это условие.
Для errexit в первую очередь никогда не используются условия в виде списка AND. Если errexit не используется, рассмотрите возможность обработки ошибок даже для последней команды, так что её статус выхода не будет замаскирован, если к сценарию добавится дополнительный код.
Плохо:
condition && extra_stuff
Хорошо (вариант errexit):
if condition; then
extra_stuff
fi
Хорошо (вариант с обработкой ошибки):
if condition; then
extra_stuff || exit
fi
exit 0
Как использовать errexit
Как
set -e
.Отсроченная очистка на уровне программы
Если errexit работает как следует, используйте это для установки любой необходимой очистки при выходе.
tmpfile="$(mktemp -t myprogram-XXXXXX)"
cleanup() {
rm -f "$tmpfile"
}
trap cleanup EXIT
Попался: errexit игнорируется в аргументах команды
Вот очень хитрая ветвящаяся «бомба», понимание которой дорогого мне стоило. Мой скрипт сборки отлично работал на разных машинах разработчиков, но поставил на колени сервер сборки:
set -e # Fail if nproc is not installed
make -j"$(nproc)"
Правильно (подстановка команды в задании):
set -e # Fail if nproc is not installed
jobs="$(nproc)"
make -j"$jobs"
Предупреждение: встроенные команды
local
и export
остаются командами, так что такое по-прежнему остаётся неправильным:set -e # Fail if nproc is not installed
local jobs="$(nproc)"
make -j"$jobs"
ShellCheck предупреждает только об особенных командах вроде
local
в данном случае.Для использования
local
, отделите декларацию от задания:set -e # Fail if nproc is not installed
local jobs
jobs="$(nproc)"
make -j"$jobs"
Попался: errexit игнорируется в зависимости от контекста вызывающей стороны
Иногда POSIX ужасен. Errexit игнорируется в функциях, групповых командах и даже подоболочках, если вызывающая сторона проверяет её успех. Все эти примеры печатают
Unreachable
и Great success
, как бы странно это ни казалось.Подоболочка:
(
set -e
false
echo Unreachable
) && echo Great success
Групповая команда:
{
set -e
false
echo Unreachable
} && echo Great success
Функция:
f() {
set -e
false
echo Unreachable
}
f && echo Great success
Из-за этого bash с errexit практически непригоден для компоновки: да, возможно обернуть функции errexit, чтобы они работали, но возникают сомнения, что сэкономленные усилия (над явной обработкой ошибок) стoят того. Вместо этого рассмотрите возможность разделения на полностью автономные скрипты.
Как избежать вызова оболочки неправильным кавычками
При вызове команды из других языков программирования проще всего ошибиться и неявно вызвать оболочку. Если эта команда оболочки статична, то хорошо — она либо работает, либо нет. Но если ваша программа как-то обрабатывает строки для сборки этой команды, то нужно понимать — вы генерируете shell-скрипт! Редко хочется такое делать, и весьма утомительно всё правильно обставить:
- закавычивать каждый аргумент;
- экранировать соответствующие символы в аргументах.
Независимо от того, на каком языке программирования вы это делаете, существует минимум три способа правильно построить команду. В порядке предпочтительности:
План А: обойтись без оболочки
Если это просто команда с аргументами (то есть никаких функций оболочки вроде конвейерной пересылки или перенаправления), то выберите вариант массива.
- Плохо (python3):
subprocess.check_call('rm -rf ' + path)
- Хорошо (python3):
subprocess.check_call(['rm', '-rf', path])
Плохо (C++):
std::string cmd = "rm -rf ";
cmd += path;
system(cmd);
Хорошо (C/POSIX), минус обработка ошибок:
char* const args[] = {"rm", "-rf", path, NULL};
pid_t child;
posix_spawnp(&child, args[0], NULL, NULL, args, NULL);
int status;
waitpid(child, &status, 0);
План B: статичный shell-скрипт
Если требуется оболочка, пусть аргументы будут аргументами. Вы могли подумать, что было громоздким писать специальный shell-скрипт в собственном файле и обращение к нему, пока не увидите такой трюк:
Плохо (python3):
subprocess.check_call('docker exec {} bash -ec "printf %s {} > {}"'.format(instance, content, path))
Хорошо (python3):
subprocess.check_call(['docker', 'exec', instance, 'bash', '-ec', 'printf %s "$0" > "$1"', content, path])
Можете заметить shell-скрипт?
Всё верно, команда printf с перенаправлением. Обратите внимание на корректно закавыченные нумерованные аргументы. Внедрение статичного shell-скрипта — это нормально.
Эти примеры запускаются в Docker, потому что иначе они не будут такими полезными, но Docker тоже прекрасный пример команды, которая запускает другие команды на основе аргументов. В отличие от Ssh, как увидим далее.
Последний вариант: обработка строк
Если это должна быть строка (например, потому, что она должна работать через
ssh
), то её невозможно обойти. Придётся закавычивать каждый аргумент и экранировать любые символы, необходимые для выхода из этих кавычек. Простейшим является переход на одинарные кавычки, потому что у них простейшие правила экранирования. Только одно правило: '
>'\"
.Типичное имя файла в одинарных кавычках:
echo 'Don'\''t stop (12" dub mix).mp3'
Как использовать этот трюк для безопасного выполнения команд по ssh? Это невозможно! Ну, вот «часто правильное» решение:
- «Часто правильное» решение (python3):
subprocess.check_call(['ssh', 'user@host', "sha1sum '{}'".format(path.replace("'", "'\\''"))])
Мы должны сами объединить все аргументы в строку, чтобы Ssh не сделал это неправильно: если вы попытаетесь передать несколько аргументов ssh, он начнёт предательски объединять аргументы без кавычек.
Причина, по которой это обычно невозможно, заключается в том, что правильное решение зависит от предпочтений пользователя на другом конце, а именно удалённой оболочки, которая может быть чем угодно. В принципе, это может быть даже ваша мама. «Часто правильно» предполагать, что удалённой оболочкой является bash или другая POSIX-совместимая оболочка, но fish несовместима на данном этапе.
Комментарии (38)
potan
04.06.2018 08:04`` я чаще всего испольхую вместе с for, а там лишние "" мешают. Безопасного варианта этой конструкции я так и не придумал.
OnYourLips
04.06.2018 10:04Если приходится писать много bash-скриптов (или один, но длинный), то что-то пошло не так. И скорее всего, выбран неправильный инструмент (или переизобретение существующего).
Есть большое количество automation tools, которые помогут минимизировать количество bash.
pensnarik
04.06.2018 11:04Есть приложения, целиком написанные на bash. Например, обёртка на пакетным менеджером Arch Linux yaourt. Читать такой код довольно сложно.
oldbay
04.06.2018 11:48+2Считаю что bash хорошо подходит именно для сценариев управления *nix сервера. Он напрямую работает с системными утилитами и не перенасыщен синтаксическими конструкциями (сахаром) — потому является отличным связующим субстратом для создания системных скриптов с относительно низким порогом вхождения.
Как язык для написания больших программ и утилит bash просто ужасен:
Неудобное и не очевидное управление переменными и их типами;
Отсутствие понятие зон видимости переменных — в результате в большом скрипте сложно их отслеживать;
Сложно парсить вывод утилит и файлов — приходится применять всякие сторонние awk и sed, с тем ещё синтаксисом;
Отсутствие возможности работы с каким либо сторонним API, bash биндингов ожидаемо не существует;
Ну и банально — катастрофически медленное выполнение циклов и условий — по сравнением с bash даже python кажется спринтером.
п.с:
Потому считаю что bash очень хорош, но в своей нише использования. Остальное от лукавого :)
funca
04.06.2018 14:53Bash это классный и выразительный язык, если уметь на нем писать. Там есть локальные переменные, функции и много всего остального. В качестве примера рекомендую исходники git или подсистемы конфигурации в openwrt.
ValdikSS
04.06.2018 11:55+2Или, например, winetricks.
О безопасном программировании на bash есть хорошая страница в wiki mywiki.wooledge.org/BashPitfalls
miga
04.06.2018 11:44+3Очень большая статья, можно сократить:
Как безопасно программировать в bash.
Программируйте в нормальных языках программирования, а шелл оставьте для ad-hoc однострочников
begemot_sun
04.06.2018 14:31Большие баш скрипты, в которых есть ветвления и нелинейная логика более 5 строк я бы жёг напалмом.
funca
04.06.2018 15:10У bash до смешного низкий порог входа. Но дальше все как у взрослых. В языке есть куча средств для работы со сложностью, но осилить man bash, а главное понять, хватает не всех.
sena
04.06.2018 17:42но что хорошего в совместимости
Совместимость это очень хорошо, но не всегда это нужно.
Karpion
04.06.2018 18:56Незакавыченная переменная должна расцениваться как взведённая бомба: она взрывается при контакте с пробелом. Да, «взрывается» в смысле разделения строки на массив.
Видите ли, такое поведение достаточно часто требуется. Например:arguments="xvzf archive.tgz -C /home/user" tar $arguments
С кавычками это будет работать неправильно, т.к. команда должна получить командную строку, порезанную на аргументы функции main().st0ne_c0ld
04.06.2018 19:09arcfile="archive.tgz"
current_dir="/home/user"
tar xzvf "$arcfile" "$current_dir"
Скажем так, ваш случай выглядит не так чтобы вынужденно-обязательным. При этом, если часто надо такое писать — можно и функции завести с нужным шаблоном аргументов…Karpion
04.06.2018 20:46Что-то я слабо представляю, как это сделать в случае, когда количество параметров м.б. произвольным.
Рекомендую глянуть в файл /etc/rc.conf во FreeBSD — там параметры работы системы задаются в виде аргументов командной строки (ну и есть параметры, указывающие, нужно ли вообще запускать эту команду — например, команду запуска демона, аргументы к которой заданы во второй переменной).
mayorovp
04.06.2018 20:45+1Вот только если имя пользователя или архива содержит пробел — такой способ уже не сработает.
Вот такой способ выглядит более общим:
arguments=(xvzf "архив с пробелом.tgz" -C /home/user) tar ${arguments[*]}
RumataEstora
04.06.2018 19:07Однажды мне понадобилось выполнить некий набор команд на удаленной машине. Чтобы не мучиться с кавычками сделал так:
some-function() { : # do something useful } { declare -f some-function echo "some-function some-parameters" } | ssh some-host bash
kvaps
Спасибо за перевод,
Как вариант можно использовать
base64
и передавать уже закодированную строку для выполнения. А на другом конце просто делать:В случае с bash можно также обойтись простой передачей необходимого через пайп.
Еще хотелось бы добавить про экранирование Here Document, например все знают, что эта команда:
вернет:
Это может быть удобно для подстановки переменных в Here Document.
При необходимости знак "
$
" и скобки можно экранировать с помощью "\
"Но мало кто знает, что можно заэкранировать весь Here Document целиком, например:
не станет расскрывать переменные и вернет:
Это может быть полезным для выполнения скриптов на удаленной машине, например.
iig
Еще один способ выстрелить в ногу из баша. Весь Here Document экранируется одним слешом — не знал. Глаз может и не заметить.
RumataEstora
Тогда уж лучше так (закавыченный маркер):