はじめに
/bin/[ という変な名前のファイルを削除したらシステムが壊れてしまった。実はそれが [ の実体だとか言う話や [ ] で文字列の頭に x などをつけて比較するイディオムは有名ですが、それらはもう過去の話です。この記事では過去の話となってしまった [ コマンドの話についてまとめてみました。
注意 環境によっては /bin/[ や /bin/test ではなく /usr/bin/[ や /usr/bin/test の場合もありますが、この記事では「外部コマンド版の [」と書くのが面倒なので、そういう意味として /bin/[ と書いています。
関連記事
- シェルスクリプトの謎の
${1+"$@"}はPOSIX に準拠できてないレガシーシェルのための回避策・・・を使うのはもうやめよう! - trコマンドの範囲指定を[0-9]と書く人はオジサン(POSIX準拠は0-9)
/bin/[
[ の正体はなに?
シェルスクリプトの if 条件などでよく使われる [ はシェルの文法ではなくコマンドです。昔は /bin/[ が呼び出されていました。今は(私が知る限り)すべての Bourne シェル / POSIX シェルにビルトインされているので /bin/[ は呼び出されません。ただし POSIX のシェルスクリプト言語の仕様に [ は含まれていないため、その気になれば [ がビルトインされてない POSIX に準拠したシェルを作ることは一応可能です。つまり処理系の実装と言語仕様は別ということです。
if [ -e "$file" ]; then
: ↑ この [ のこと
fi
[ の正体が何なのかは type コマンドを使うと調べることができます。type コマンドは(変数の「型」ではなく)単語の「種類」が何かを調べるものだからこのような名前なのでしょう。
# Ubuntu 20.04 の bash での実行結果
# シェルから [ が何として見えているか
$ type [
[ is a shell builtin
# PATH から見つかる全ての [ コマンドを出力する
# 補足 zsh では which がシェルビルトインコマンドで、シェルビルトイン版の [ も出力される
$ which -a [
/usr/bin/[
/bin/[
$ type [[
[[ is a shell keyword
# zsh では [[ をパターンとして認識してしまうのでダブルクォートが必要
$ type "[["
[[ is a reserved word
ちなみに [ の外部コマンド版が /usr/bin/ と /bin/ の両方にあるのは Ubuntu 20.04 では /bin が /usr/bin へのシンボリックリンクになっているからです。Ubuntu や Solaris 10、11 などでは(ディスクサイズが大きい今では分ける理由がないから)と統合されています。Debian では統合されてない上に [ は /usr/bin 以下にあったりします。元々 /usr/bin は必要性が低いコマンドを置く場所なので、そこに [ があるというのは面白いですね。
[ が /bin/[ だったのはいつまで?
この /bin/[ が使われなくなったのは今から 40 年前の Unix System III (1981) に搭載された Bourne シェルからのようです。
The Traditional Bourne Shell Family より
built-in "test", aka "[", added (the latter actually was always existent in code, but commented out)
Unix System III より前に存在した Bourne シェルを搭載した Unix は Version 7 (1979) だけです。/bin/[ を呼び出していた Version 7 シェルを搭載していた OS は 1990 年代前半まではリリースされていたようですが、そこからしばらく使われていたにしてても 2000 年代に入る前にはほとんど使われなくなっていたでしょう。もちろん ksh88、bash、zsh などでは最初のリリースバージョンからシェルにビルトインされています。dash や busybox ash でもシェルビルトインです。
ちなみにシェルにビルトインする理由は実行速度向上のためです。シェルが遅くなる原因の多くは(遅い fork & exec を伴う)外部コマンドを実行することなので、処理速度が遅い昔は特にシェルにビルトインすることで実行速度を上げることに大きなメリットがあったはずです。
ash 系ではいつからビルトイン?
ash 系のシェルで [ がビルトインになったのは少し遅くですが、それでも 2001 年までにはビルトインになっています。
- NetBSD 2000-04-10
- FreeBSD 2001-11-17
- Ubuntu / Debian 系の dash では少なくとも 2002/11/24 の 0.5.2 時点でビルトイン
/bin/[ を削除したらどうなる?
今はシェルにビルトインされているので /bin/[ を削除しても普通は問題なくシェルスクリプトは動きます。が予期せず /bin/[ が呼び出されてしまっているシェルスクリプトがあるかもしれないので実際に削除するのはやめておいたほうがよいでしょう。こんな書き方をする人がいるとは思えませんが、たとえば明示的に /bin/[ と書いたり env [ や xargs や find などの外部コマンドから呼び出すと /bin/[ が実行されてしまいます。シェルスクリプト以外の言語から [ を呼び出しても /bin/[ が実行されます。
それにしても /bin/[ を削除してトラブルになったとかいう話を何度か聞いたことがあるのですがそれは一体いつ起きた話なんでしょうか?なんか逸話だけが生き残ってる気がします。そういうトラブルが過去になかったとは思いませんが、その話を今も聞くとそれもう 40 年ぐらい前の昔話してるよねと思ってしまいます。実際に問題なることはないと知っていて言ってるのなら良いのですが、どうも今も /bin/[ を呼び出していると思っていそうな気がします。
そもそも /bin/[ が存在しない環境がある
私が実際に確認しているのは Solaris 10、11 です。歴史的な UNIX の正統後継の一つとも言える Solaris で /bin/[ がないのはちょっと意外でした。Linux (Ubuntu、Debian、CentOS 等)ではありますし、もちろん macOS や FreeBSD にもあります。
[ "x$var" = "xval" ]
シェルスクリプト用の有名な Lint ツールである ShellCheck の開発者であるコアラマン(あえてカタカナ)こと Vidar Holen さんはこれを変数の前に x と書くことから x-hack と呼んでいるようです(x という文字を使う必要はないので他にも _ 等の文字が使われたりします)。この項目の内容は Vidar Holen さんのブログ記事「What exactly was the point of [ “x$var” = “xval” ]?」を参考にしており、そちらのほうが詳しいので参照してください。
x-hack が必要だった理由
そもそもなぜこの x-hack が必要だったかと言うと昔の一部の [ コマンド実装にバグがあったからです。[ コマンドの制限で x をつける以外に誤動作させない方法がないんだとか、なんたらインジェクションが起きないようにするためのセキュリティ対策なんだとかそんな話ではありません。Vidar Holen さんはそのようなバグを抱えた [ コマンドの実装は消えてしまったから「POSIX 準拠シェルを使う場合に x-hack を使う価値は完全に 0 である。x なしによる比較は 100% の確率で動作する」とブログで強く主張しています(意味がなくなった無駄な作業はやめるべきですよね)。
For any POSIX compliant shell, the value of the x-hack is exactly zero: this comparison works without the x 100% of the time.
このバグの原因を厳密に書くと大変なので正確ではないことを承知で書くと、文字列と演算子を正しく区別できていないからです。例えば以下の文はどこが文字列でどこが演算子でどこが条件式の終了でしょうか?
[ "!" ] "=" -eq "]"
途中で条件式が終わってるように見えます。"!" と "=" と最後の "]" はダブルクォートで括られてる文字です。-eq は - で始まってるから演算子です。そもそもシェルスクリプトではダブルクォートで括るのと括らないので違いはありません。全部同じ文字です。こんなの正しく文字列と演算子を区別することなんてできません。そう思ってしまうかもしれません。
実はそうではありません。[ コマンドに限って言えば文字列と演算子は正しく区別することができます。しかしそれに気が付かなかった。文字列と演算子を区別するアルゴリズムを正しく実装できなかった。Bourne シェル時代に仕様が明確に文書化されてなかった。そういった理由で一部の古いシェルでは正しく動く実装がされなかったのだと思います。
POSIX による引数解析の定義
さてどうやって文字列と演算子を見分けるのでしょうか? POSIX では [ ] の解釈が明確に定義されており、そこに曖昧さはありません。一見複雑そうに見えるこの問題を解決する鍵は POSIX [ では 4 つの引数(最後の ] は含まない)を超える引数は未定義であるということです。
このヒントでわかったかもしれませんが、引数の数で比較対象の文字列と演算子を区別することができます。4 つを超える引数は未定義なので考えるパターンは、引数の数が 0、1、2、3、4 の 5パターンだけです。
# 引数が 0 個
[ ] # 必ず false
# 引数が 1 個
[ "$str" ] # 文字列の長さが 0 か それ以上か
# 引数が 2 個
[ ! "$str" ] # 1 つ目の引数が ! なら上記の否定
[ -e "$file" ] # それ以外は 1 つめの引数は演算子(演算子でなければエラー)
# 引数が 3 個
[ ! -e "$file" ] # 1 つ目の引数が ! なら 上記の否定
[ "$value" -gt 0 ] # それ以外は 2 つ目の引数が演算子(演算子でなければエラー)
# 引数が 4 個
[ ! "$value" -gt 0 ] # 1 つ目の引数が ! なら 上記の否定(それ以外はエラー)
# 引数が 5 個以上は未定義
# POSIX では -a (AND) や -o (OR) 自体が未定義(非推奨)なのでこれ以上の引数はありえない
ということで引数の数から文字列と演算子を正しく区別することができます。オプションと違って - で始まる文字列かどうかで演算子かどうかの区別をする必要もありません。
補足 x-hack と関係はないですが zsh 5.x (最新の 5.8 含む) には [ ! -o ] の終了ステータス 0 がになる(バグ)が存在するので [ ! "$str" ] は少し注意が必要です。zsh に対応させたい場合は代わりに ! [ "$str" ] または [ -n "$str" ] を使うことを推奨します。
補足2 例えば [ ! $str = val ] のように変数をダブルクォートで括っていない場合は引数がずれます。これをもって文字列と演算子の区別がつかない事があるのでは?と思うかもしれませんが、はそもそも変数をダブルクォートで括らないことが問題です。ダブルクォートしていない場合は、値にスペースや * が入ってる場合にもっと大変なことになるので問題外です(そのため公開時に補足し忘れていました)。詳しくは シェルスクリプトの変数はダブルクォートしなければいけない!という話 を参照してください。
不具合があったシェル
詳しい話は Vidar Holen さんのブログに丸投げします。
すべての Bourne シェル
Bourne シェルは POSIX シェル登場以前の古いシェルで POSIX シェルに準拠していません。Bourne シェルは算術式展開($((1 + 2))) や一部のパラメータ展開(${dir%/})に対応しておらずいくつかの仕様も POSIX シェルと異なり x-hack 以上に大きな問題があるため、もはや使うものではありません。Bourne シェルと POSIX シェルの違いについては「BourneシェルとBourneシェル系(bash等のPOSIXシェル)の違いについて」を参照してください。
古い POSIX シェルの一部
- ksh88 (1988?)
- Solaris 10 (2009) の
/usr/xpg4/bin/sh - Solaris 11 (2011) の
/usr/sunos/bin/ksh
- Solaris 10 (2009) の
- GNU bash 1.14 (1996)
- bash 2.0 (1996) で修正
- GNU 版の
/bin/[も同様
- dash 0.5.4 (2009)
- Busybox ash 1.22.1 (2014)
- 私の調査に基づく
- zsh 5.2 (2015)
- zsh 5.3 系で修正
不具合があった環境例
- Debian 8 (2020 年 6 月 30 日 サポート終了)
- 公式パッケージで zsh 5.0 が提供
- Solaris 10 (2021 年 1 月 Extended Support Ends サポート終了)
-
/bin/sh- Bourne シェル (SVR4) -
/usr/xpg4/bin/sh- ksh88 (11/16/88i)
-
- Solaris 11 (2024 年 11 月 Extended Support Ends サポート終了予定)
- 注意
/bin/shは ksh 93u+、/usr/bin/bashは 4.4.19 なので事実上問題はありません -
/usr/sunos/bin/sh- Bourne シェル? (ここに レガシー Bourne シェルのパスとして書いてあるのですが私の手元の環境には存在しませんでした) -
/usr/sunos/bin/ksh- ksh88 (11/16/88i) (旧バージョン デフォルトでパスは通っていません)
- 注意
不具合がない環境例
- SLS (1992) - 初期の Linux ディストリ
- FreeBSD 1.0 (1993)
x-hack はもはや不要
以上の内容から、x-hack は現在ではほぼ不要になっています。必要なのは Busybox ash(古い組み込み機器でシェルスクリプト動かす人います?) と zsh 5.2(zsh でシェルスクリプト動かしてますか? サポートはもう終了してませんか?)を使っている人ぐらいわけです。
これをもって ShellCheck では v0.7.2 (2021-04-19) より x-hack を使用していると、もはや必要ないと SC2268 の警告が表示されるようになりました。(残念ながら x 以外の文字を使っている場合は効果がないようです。)
#!/bin/sh
var="val"
[ "x$var" = "xval" ]
In script.sh line 4:
[ "x$var" = "xval" ]
^-----^ SC2268: Avoid x-prefix in comparisons as it no longer serves a purpose.
Did you mean:
[ "$var" = "val" ]
For more information:
https://www.shellcheck.net/wiki/SC2268 -- Avoid x-prefix in comparisons as ...
ちなみに私はオジサンなので ShellSpec を可能な限り古いシェルにも対応させたい(古い環境でシェルスクリプトをテストしてから移行できるようにするため)ので一部の箇所で x-hack を使っています。
[ は test の別名ではない
しばしば [ は test の別名だよと言わたりしますし、まあ一緒だよね?と私も思っていたりしますが、そんな人でも少し考えれば [ -e /tmp ] を test -e /tmp ] と書くとエラーになるという事に気づくと思います。[ には最後の ] 必要ですが、test には最後の ] をつけてはいけません。
$ [ -e /tmp
bash: [: missing `]'
$ test -e /tmp ]
bash: test: /tmp: binary operator expected
/bin/test の方がファイルサイズが小さいのはなぜ?
さてこちらに関して Vidar Holen さんは「なぜ(GNU 版の)/bin/test は /bin/[ よりも 4キロバイトもファイルサイズが小さいのか?」というというなかなかユニークな記事を書かれています。記事を読む前にその理由を考えてみてください。
- ヒント 1: たかが最後の
]のチェックだけでそんなにファイルサイズは必要になりません - ヒント 2:
[には]という引数が必須です - ヒント 3: GNU
testは GNU 自身のとあるガイドラインに準拠することができません
その理由は「Why is /usr/bin/test 4kiB smaller than /usr/bin/[ ?」から!
-a (AND) や -o (OR) は非推奨
-a や -o は POSIX では現在非推奨扱いで、次の POSIX issue 8 では削除されます。とは言え実際のシェルの実装が削除するとは思えませんので今までのコードが動かなくなることはないでしょう。ただし新たに使うのはやめたほうが良いです。
実際の所 [ 条件1 -a 条件2 ] の代わりに [ 条件1 ] && [ 条件2 ]、[ 条件1 -o 条件2 ] の代わりに [ 条件1 ] || [ 条件2 ] と書くことが出来るので -a や -o は必要ありません。
これについては「シェルスクリプトの [ -a (AND) と -o (OR) ] は非推奨だかんね」で記事にしています。今思うと、この記事にまとめるべきだったかもしれませんが、気づきにくいかつ重要な話でもあるので良しとしましょう。
ファイルの存在チェックは -a ではなく -e
-a は ファイルの存在チェックを行う演算子でもあります。この使い方も非推奨です。代わりに -e を使います。AND の -a と紛らわしいということで POSIX では規定されませんでした。まあ -a は覚えにくいしこれを使ってる人なんていないですよね?
さいごに
シェルスクリプトは他の言語に比べて安定しており大きく変化することはありませんが、それでもゆっくりと変わり続けてます。昔は常識でも今は当てはまらないことだってあります。「昔の雑学」を披露するのは良いですが「昔はこうだったけど今の実際はこうなってるんだよ」とまで言えるように知識をアップデートしましょう。それと時代が変わって無駄になった作業をするのはやめましょう(と言いつつしてるのは私なんだよなぁ、ksh88 や bash 2.03 対応とかw)。