Краткая история жизни и смерти багов консольных скриптов, для борьбы с которыми привлекался загадочный и не имеющий собственного значения x. Что это за символ, от каких проблем он спасал и актуально ли его применение сегодня?
При написании консольных скриптов мы иногда сталкиваемся со сравнениями, в которых каждое значение имеет префикс “x”. Вот примеры с GitHub:
if [ "x${JAVA}" = "x" ]; then
if [ "x${server_ip}" = "xlocalhost" ]; then
if test x$1 = 'x--help' ; then
Назову этот прием x-hack.
Для любой POSIX-совместимой оболочки значение x-hack будет равно нулю, то есть сравнение в 100% случаев сработает и без x
. В чем же тогда его суть?
Ресурсы вроде StackOverflow Q&A размыто поясняют, что это альтернатива цитированию вне контекста, указывающему на проблемы с «некоторыми версиями» конкретных оболочек или в целом предостерегающему о загадочном поведении, в особенности древних UNIX-систем. Примерами же эти пояснения не подкрепляются.
Чтобы определить, должна ли ShellCheck об этом предупреждать, и если да, то на каком логическом обосновании, я решил обратиться к истории Unix, а именно к архивам Unix Heritage Society. К сожалению, мне не удалось заглянуть в тщательно охраняемый мир подобий HP-UX и AIX, так что пастухам динозавров рекомендую сохранять бдительность.
Вот найденные мной кейсы, которые могут провалиться.
Левая сторона представлена унарным оператором
Оболочка AT&T Unix v6 от 1973 года, по крайней мере согласно данным из PWB/UNIX от 1977 года, проваливала выполнение тестовых команд, где левая сторона была представлена унарным оператором. Это мог заметить любой, кто пытался выполнить проверку параметров командной строки:
% arg="-f"
% test "$arg" = "-f"
syntax error: -f
% test "x$arg" = "x-f"
(true)
Ошибка была исправлена в оболочке Борна ОС Unix v7, выпущенной в 1979 году. Тем не менее test
и [
были также доступны как отдельные исполняемые файлы, и сохранили вариант ошибочного поведения:
$ arg="-f"
$ [ "$arg" = "-f" ]
(false)
$ [ "x$arg" = "x-f" ]
(true)
Это произошло, потому что в утилите использовался простой метод рекурсивного спуска, который давал унарным операторам преимущество над бинарными и игнорировал завершающие аргументы.
«Современное» поведение оболочки Борна в 1988 было скопировано общедоступной KornShell и стало частью POSIX.2 в 1992 году. В GNU Bash 1.14 то же самое было сделано для встроенной инструкции [
, при этом пакет GNU shellutils, предоставлявший внешние исполняемые файлы test
/[
, последовал уже за POSIX. В результате ранние дистрибутивы GNU/Linux вроде SLS этим багом затронуты не были также, как и FreeBSD 1.0.
X-hack
в данном случае эффективен по той причине, что ни один унарный оператор не может начинаться с x
.
Одна из сторон представлена оператором длины строки -1
Похожая проблема, просуществовавшая дольше предыдущей, была связана с оператором длины строки -1
. В отличие от стандартных унарных предикатов этот считывался только как часть операнда для бинарных предикатов:
var="helloworld"
[ -l "$var" -gt 8 ] && echo "String is longer than 8 chars"
Согласно приведенному выше обоснованию, он не перешел в POSIX, так как: «не был задокументирован в большинстве реализаций, был удален из некоторых реализаций (включая System V), и эта функциональность предоставляется оболочкой». В пример приводится [ ${#var} -gt 8 ]
.
Это не было проблемой в UNIX v7, где приоритет отдавался =
, но в Bash 1.14 от 1996 года данный оператор считывался наперед:
$ var="-l"
$ [ "$var" = "-l" ]
test: -l: binary operator expected
$ [ "x$var" = "x-l" ]
(true)
Та же проблема касалась и правой стороны, но только во вложенных выражениях. Проверка на -1
гарантировала наличие второго аргумента, следовательно, требовалось дополнительное выражение или скобки для его активации:
$ [ "$1" = "-l" -o 1 -eq 1 ]
[: too many arguments
$ [ "x$1" = "x-l" -o 1 -eq 1 ]
(true)
Позже в том же году этот оператор был удален из Bash 2.0, и проблема ушла вместе с ним.
Левая сторона представлена "!"
Еще одно затруднение в ранних оболочках возникало, когда левая сторона сравнения была представлена оператором отрицания !
:
$ var="!"
$ [ "$var" = "!" ]
test: argument expected (UNIX v7, 1979)
test: =: unary operator expected (bash 1.14, 1996)
(false) (pd-ksh88, 1988)
$ [ "x$var" = "x!" ]
(true)
Опять же, x-hack решал проблему, не позволяя распознать !
как оператор отрицания.
Ksh рассматривала его как [ ! "=" ]
и игнорировала остальные аргументы. В итоге просто возвращался false
, так как =
не является нулевой строкой. При этом в ksh завершающие аргументы игнорируются и по сей день:
$ [ -e / random words/ops here ]
(true) (ksh93, 2021)
bash: [: too many arguments (bash5, 2021)
В Bash 2.0 и ksh93 эта проблема в соответствии с POSIX была решена за счет предоставления оператору =
приоритета в случае с тремя аргументами.
Левая сторона представлена "("
Это, безусловно, моя любимая.
Встроенная в UNIX v7 оболочка давала сбой, когда левая сторона была представлена левой скобкой:
$ left="(" right="("
$ [ "$left" = "$right" ]
test: argument expected
$ [ "x$left" = "x$right" ]
(true)
Это происходило из-за того, что (
получал приоритет над =
и становился недопустимой группой скобок.
Но почему моя любимая? Вот Dash 0.5.4 вплоть до 2009:
$ left="(" right="("
$ [ "$left" = "$right" ]
[: 1: closing paren expected
$ [ "x$left" = "x$right" ]
(true)
На момент публикации темы в StakOverflow Q&A данный баг продолжал существовать.
Но это еще не все!
Вот Zsh конца 2015 года перед самым выходом версии 5.3:
% left="(" right=")"
% [ "$left" = "$right" ]
(true)
% [ "x$left" = "x$right" ]
(false)
Удивительно, что x-hack продолжали использовать для обхода ряда багов аж до 2015 года, семь лет после того, как на StackOverflow этот прием списали как архаичный пережиток прошлого.
Конечно же, встретить эти баги становится все труднее. Ошибка в Zsh, к примеру, срабатывает только при сравнении левой и правой скобок, так как в остальных случаях парсер понимает в чем дело.
В запоздавших можно также записать Solaris, чья /bin/sh
оставалась устаревшей оболочкой Борна даже в Solaris 10 2009 года. Однако причиной такой задержки определенно стала совместимость, а не оценка разработчиками этой оболочки как оптимальной. «Совместимая со стандартами» оболочка оставалась опцией достаточно долго, пока Solaris 11 не перетащил ее в 21 век – или как минимум в 90-е – переключившись на ksh93 по умолчанию в 2011 году.
X-hack выручает во всех подобных случаях, не давая распознать операнды как скобки.
Заключение
Прием с добавлением x
действительно был полезен и успешно исправлял ряд реальных практических проблем в нескольких оболочках.
Тем не менее его ценность уже к концу 90-х практически полностью сошла на нет, и лишь несколько остававшихся проблем были подчищены только к 2010 году – поразительно поздно, но все же больше десяти лет назад.
Последний баг умудрился дожить до 2015 года, но только в одной оболочке и только в очень специфичном случае сравнения открывающей скобки с закрывающей.
Думаю, что настало время отказаться от этой идиомы, и даже ShellCheck теперь по умолчанию предлагает соответствующие рекомендации по стилю.
Эпилог
Проблема с [ "(" = ")" ]
в Dash была впервые отмечена в 2008 году и проявлялась как в Bash 3.2.48, так и в Dash 0.5.4. В bash на macOS ее можно встретить до сих пор:
$ str="-e"
$ [ ( ! "$str" ) ]
[: 1: closing paren expected # dash
bash: [: `)' expected, found ] # bash
POSIX исправляет все эти неоднозначности в командах, содержащих вплоть до 4-х параметров, гарантируя, что условные конструкции оболочки будут работать одинаково везде и всегда.
Мейнтейнер Dash, Герберт Сюй, по этому поводу оставил в исправлении такой комментарий:
/*
* Регламент POSIX: написавший это заслуживает Нобелевской премии мира*
*/
Автор: Дмитрий Брайт