f

2016-12-15

How to initialize POSIX shell script

#posixismadvent この記事はPOSIX原理主義Advent Calendarの15日目だ。

POSIX原理主義によるシェルスクリプトの実行環境の最善と思われる初期化方法を解説する。結論としては,シェルスクリプトの冒頭に常に以下のコードを記述すれば,安全でより互換性が高くなる。

set -eu
umask 0022
export LC_ALL='C' PATH="$(command -p getconf PATH):$PATH"

Introduction

POSIX原理主義の解説書である「Windows/Mac/UNIX すべてで20年動くプログラムはどう書くべきか」(以下「すべてで20年動く」と略記)のp. 37「1-9 環境変数などの初期化」において,シェルスクリプトの環境変数などの初期化方法として以下のコードが記述されている。

set -u
umask 0022
PATH='/usr/bin:/bin'
IFS=$(printf ' \t\n_'); IFS=${IFS%_}
export IFS LC_ALL=C LANG=C PATH

初めて見たときはとても参考になった。しかし,POSIX規格について勉強しているとこの方法に疑問を持つようになった。

  • PATH変数がPOSIXに規定のない/usr/bin:/binで決め打ち。
  • IFSの初期化方法が汚い。
  • ロケールであるLC_ALLLANGが重複。

シェルスクリプトの初期化は重要なテーマだ。というのも,環境変数を変えられてしまうとプログラムの挙動が変わってしまうというのは脆弱性の元だからだ。特に,サーバーサイドのシェルスクリプトを書く場合にクリティカルとなる。

POSIX規格を徹底的に見直すことで,より望ましい初期化方法を発見した。

set -eu
umask 0022
unset IFS
export LC_ALL='C' PATH="$(command -p getconf PATH):$PATH"

それぞれの意味の確認もこめて説明してく。

set -eu

このコマンドでは,2個のオプションを有効にしている。

setコマンドのオプションの説明
-e
while, until, if, &&, ||など条件判定以外でコマンドが失敗したら強制終了
-u
未定義の変数にアクセスしたら強制終了

開発中のデバッグのしやすさと,予想外の事態での安全性を考えて,基本的にはこれらのオプションを有効にしておいたほうがいいだろう。

ただし,set -eを付けるかどうかは議論がわかれる。というのも,正常な処理だがexitステータスに非0を返す場合がありうるからだ。例えば,grepコマンド。検索フォームから入力されたデータを受け付けてgrepコマンドで検索を書ける場合,何もヒットしなくてもそれは正常だ。

filer1 | filter2 | ... | grep '(HOGEHOGE)'

しかし,grepコマンドでは何もヒットしなければ,失敗扱いにしてしまい,set -eを有効にしていればそこで強制終了してしまう。grepコマンドの最後に| catなどの無害なコマンドをおけば,set -eの強制終了は免れることはできる。しかし,これを理由に余計なコードを書くのは本末転倒となる。おそらく,「すべてで20年動く」の著者の松浦さんはこの点を懸念してset -eを記載しなかったのだろう。

そのため,set -eについては以下の方針をとるのがよいと考えた。

  • 安全・デバッグのため,常にset -eを付ける。
  • 必要に応じて,set -eは外す。

2017-01-22追記開始

やはり,set -eは常に付けるべきだと考えなおした。理由は以下2点だ。

  1. grepコマンドの挙動として,ヒットしなければエラーにするのが自然。
  2. ()でサブシェルとしてグループ化したときにexit 1で実行元も終了させるため。

まず,1点目はgrepコマンドの挙動に関する理由だ。grepコマンドはパターンをファイルから検索するコマンド。検索はパターンの存在/不在を期待して行うので,あれば成功,なければ失敗とするのが自然な挙動だ。検索フォームで検索をかけて見つからなかったときにエラーにしてほしくないというのは,検索フォームの一部の実装者の都合の話であって,「検索」自体にまで拡大して適用すべき考えではない。

実際に,以下のように文字列がヒットするかどうかの条件判定にも使われるし,それを想定したような-qオプションまである。

echo "$STR" | grep -q target && echo ok

grepの検索時の終了ステータスが,みつからなくてもエラーにならない場合,常に標準出力が空かどうかの判定が必要になる。これは都合が悪い。

2点目はより実務的な理由だ。シェルではコマンドをグループ化させるときに波括弧と{}と丸括弧()が使える。丸括弧を使えば,そのグループはサブシェルとして実行させる。サブシェルとなるため,変数のスコープを限定でき,親への影響を減らせるので役に立つ。しかし,サブシェルであるがゆえに,exitの挙動が波括弧と異なる。

例えば,エラーが発生したときにメッセージを表示するような関数・コマンドを書くとき,以下のように最後にexit 1でエラーにして終了させる。

exit_try_help()(
MESSAGE="Try command --help"
echo "$MESSAGE" >&2
exit 1
)

波括弧で囲っている場合,親が実行元となるのでそのままプログラム全体が終了となる。しかし,丸括弧で囲っている場合,そのサブシェルだけが終了する。エラー処理の意図として,そこでプログラム全体を終了させなくてはならない。しかし,丸括弧で囲っている場合は,さらに自分で関数の終了ステータスをみてexitするコードが必要になる。

始めから波括弧で囲っておけばよいという話かもしれないが,丸括弧を使えば変数のスコープを限定できるので,できれば活用したい。このときに,set -eを適用させておけば,例え丸括弧でサブシェルとして起動したとしても,関数の終了ステータスを見て親も終了させることができる。

上記2点の理由から,setコマンドのオプションには-uだけでなく-e常につけるべきと判断した。

2017-01-22追記終了

2017-01-29追記開始

では,grepの検索結果として0件があり得る場合はどうするばいいか?その場合は,上記で記載した通り,エラー処理を書けばいい。検索結果が0件の場合に何もしたくなければ,文字通り何もしないコマンド:を実行すればいい。

filter1 | filter2 | grep || :

| catを最後に付ける場合は,常に無関係な内容が標準出力に流れてしまい,やり方としてよくない。これに対して,|| :を付ける場合は,失敗したときにだけしか影響を与えず,文字通り何も処理を行わないので,grepに限らずあらゆるコマンドに適用できる。

このset -eについては1点注意すべきことがある。それは,bash 3系では機能しない場面があることだ。

例えば,以下のようにパイプで繋いだ上でのwhileなどの制御構造内でexit 1した場合や,(){}でグループ化した中でexit 1場合に,set -eの機能により親シェルが終了しない。

#!/bin/bash

set -e

echo 1 | while read line; do
  exit 1 # not exit
  echo 'echo 1 | while'
done

while [ $((i+=1)) -lt 3 ]; do
  exit 1 # exit
  echo 'while'
done

exit 1 ## exit
echo 'echo 1 | exit 1'

(exit 1) ## not exit
echo 'echo 1 | (exit 1)'

echo 1 | { exit 1; } ## not exit
echo 'echo 1 | { exit 1; }'

{ exit 1; } ## exit
echo '{ exit 1; }'

これはbash 3系固有の挙動であり,3.0と3系最新の3.2.57で確認できた。この他のdashやzsh,kshでは問題なかった。

この現象は以下で最初に報告された。

この原因については,以下のツイートで解説されている。

bash 3ができたのは2000年代前半であり,現在のPOSIXのベースとなっている2008年の改訂内容(POSIX:2008,SUSv4相当)とは異なっている。当時のPOSIX規格(POSIX:2004,SUSv3相当)では,set-eオプションはsimple commandが失敗したときに作用することとされている。

-e
When this option is on, if a simple command fails for any of the reasons listed in Consequences of Shell Errors or returns an exit status value >0, and is not part of the compound list following a while, until, or if keyword, and is not a part of an AND or OR list, and is not a pipeline preceded by the ! reserved word, then the shell shall immediately exit.
set - XCU - POSIX:2004

なお,POSIX:2008ではこのsimple commandsの縛りがなくなっており,単にcommandsとされている。

パイプを使って,制御構造やグループ化コマンドを使うと,simple commandsの条件から外れるため,-eの効果を受けなくなったのだと思われる。

このbash 3のset -eの挙動をカバーするなら,以下3点のアプローチが考えられる。

  1. set -eを使わない
  2. (exit 1)を諦める
  3. 制御構造や複合文((){})でのexit 1に注意する

1と2は確実だが,それだとbash 3のためだけに利便性が損なわれる。3の方法に従い注意するのがよいだろう。また,set -eだけに頼るのではなく,クリティカルな部分ではきちんと専用のエラー処理・終了処理を書くべきだろう。

2017-01-29追記終了

また,setコマンドはシェルのオプションとしても指定できるので,shebangに以下のように記述してもよい。

#!/bin/sh -eu

umask 0022

umaskコマンドの実行自体に異論はない。このコマンドで新しく生成するファイルのアクセス権を変更できる。事前に作成するファイルのアクセス権を設定しておかないと,アクセス権を変更するまでの間にわずかに第三者にファイルの改ざんを許すことになってしまう。

Ubuntu 16.04では0002となっていた。

umask
0002
echo a >a.dat
umask 0022
echo b >b.dat
ls -l
-rw-rw-r-- 1 senooken 2 2016-12-14 21:42 a.dat
-rw-r--r-- 1 senooken 2 2016-12-14 21:42 b.dat

Ubuntu 16.04のデフォルト状態(0002)では,自分の作成したファイルであっても同じグループのユーザーが勝手に書き込むことができてしまう。umask 0022で書き込みできるのは自分だけにしたほうが無難だろう。

また,個人情報のように機密性の高い情報を扱う場合は,umask 0077のようによりアクセス制限を厳しくすることを検討してもよいだろう。

unset IFS

IFS変数は,forやread,$@$*などの区切り文字を指定する。何かと重宝する。例えば,IFS=,として,カンマ区切りのデータをforで1個ずつ処理することができる。IFS変数の期待される初期値は <space> <tab> <newline>だ。

「すべてで20年動く」の本では以下のようにしてIFSを初期化していた。

IFS=$(printf ' \t\n_'); IFS=${IFS%_}

また,POSIX規格で例示される初期化方法は,以下の通り素直に文字を記入している。

IFS='
'
#    The preceding value should be <space><tab><newline>.
#    Set IFS to its default value.

「すべてで20年動く」のやり方はごちゃごちゃしすぎている。また,POSIX規格のサンプルは改行をそのまま入れるため見た目が悪い。

両者のアプローチの通り,直接IFS変数に値を入れてももちろんいい。しかし,POSIX規格でIFS変数の値は未設定なら標準の値(スペース,タブ,改行)が設定されることが保証されている。

If IFS is not set, it shall behave as normal for an unset variable, except that field splitting by the shell and line splitting by the read utility shall be performed as if the value of IFS is <space> <tab> <newline>; see Field Splitting.
2.5.3 Shell Variables - Shell Command Language
1. If the value of IFS is a <space>, <tab>, and <newline>, or if it is unset, any sequence of <space>, <tab>, or <newline> characters at the beginning or end of the input shall be ignored and any sequence of those characters within the input shall delimit a field.
2.6.5 Field Splitting - Shell Command Language

よって,単純に以下のようにIFSを解除するだけで,POSIX規格に準拠して安全にIFS変数を初期化できる。

unset IFS

同じ結果が得られるのなら,よりシンプルなこの方法が優れているだろう。

2016-12-16追記:

他の指摘を受けて改めてIFS変数についてPOSIX規格を確認していたら,見落としを発見した。

IFS
A string treated as a list of characters that is used for field splitting, expansion of the '*' special parameter, and to split lines into fields with the read utility. If the value of IFS includes any bytes that do not form part of a valid character, the results of field splitting, expansion of '*', and use of the read utility are unspecified.
If IFS is not set, it shall behave as normal for an unset variable, except that field splitting by the shell and line splitting by the read utility shall be performed as if the value of IFS is <space> <tab> <newline>; see Field Splitting.

The shell shall set IFS to <space> <tab> <newline> when it is invoked.
2.5.3 Shell Variables - Shell Command Language

つまり,IFS変数はシェルの起動時にスペース,タブ,改行で自動で初期化されることがPOSIX規格で保証されるので,そもそもスクリプトでの初期化は不要だった。

export LC_ALL='C'

「すべてで20年動く」のp. 41-43「1-16 ロケール」で解説されているように,一部のコマンドはロケール系環境変数(Internationalization Variables)の値によって挙動が変化してしまう。例えば,LANG=ja_JP.UTF-8であれば,dateコマンドの出力結果が日本語となったり,joinやsortコマンドの列区切りに全角空白が加わってしまう。これを防ぐために,ロケールを固定する。

LC_から始まるロケール変数は時間や金額などいくつも存在するが,一括で設定できる3種類の変数が存在する。

  • LANG
  • LC_ALL
  • LANGUAGE

この内,LANGとLC_ALLはPOSIXで定義されており,LANGUAGEはGNU gettextで定義されている。LANGUAGEコマンドは主にコマンドのヘルプの言語などで使われる。LANGとLC_ALLよりも優先順位が高い。

これらの変数は以下の関係がある。

一括設定ロケール変数
LANG
LC_ALLを含め,値の設定されていないLC_変数についてはLANGの値を利用
LC_ALL
全LC_変数とLANGの値を上書き
LANGUAGE
LANGまたはLC_ALLの値が'C'でなければ,LANGUAGEの値を利用

つまり,基本的に以下の順番の優先順位となる。

  1. LANGUAGE
  2. LC_ALL
  3. LANG

LC_ALLやLANGの値がCの場合のときだけ,例外的にLANGUAGEよりもLC_ALLやLANGの値が優先される。

それぞれの根拠は以下の通り。

LANG
This variable shall determine the locale category for native language, local customs, and coded character set in the absence of the LC_ALL and other LC_* (LC_COLLATE, LC_CTYPE, LC_MESSAGES, LC_MONETARY, LC_NUMERIC, LC_TIME) environment variables. This can be used by applications to determine the language to use for error messages and instructions, collating sequences, date formats, and so on.
8.2 Internatinalization Variables - XBD
LC_ALL
The value of this variable overrides the LC_* variables and LANG, as described in XBD Environment Variables.
2.5.3 Shell Variables - Shell Command Language

GNU gettext gives preference to LANGUAGE over LC_ALL and LANG for the purpose of message handling, but you still need to have LANG (or LC_ALL) set to the primary language; this is required by other parts of the system libraries.


Note: The variable LANGUAGE is ignored if the locale is set to ‘C’. In other words, you have to first enable localization, by setting LANG (or LC_ALL) to a value other than ‘C’, before you can use a language priority list through the LANGUAGE variable.

2.3.3 Specifying a Priority List of Languages - GNU gettext utilities

ロケールの値には以下で記載されている通り,CまたはPOSIXを指定することで,POSIXロケールとなりPOSIX規格で規定される標準的なコマンドの振る舞いにできる。

If the locale value is "C" or "POSIX", the POSIX locale shall be used and the standard utilities behave in accordance with the rules in POSIX Locale for the associated category.

8.2 Internationalization Variables - XBD

「すべてで20年動く」のp. 43ではGNU gettextのLANGUAGE変数の設定を上書きするために,LANGとLC_ALLの値にCを設定しないといけないと勘違いしている。実際のところ,マニュアルや規格を確認すればわかる通り,単にLC_ALL='C'と記述するだけで全てのロケールをPOSIXロケールに設定できる。

また,このp. 43とp. 37ではもう一つ誤りがある。それはexportコマンドにより変数を環境変数として再設定している点だ。POSIX規格に書かれている通り,IFS,LC_ALL,PATH変数は環境変数の値を初期値としてもつただのシェル変数だ。

Variables shall be initialized from the environment (as defined by XBD Environment Variables and the exec function in the System Interfaces volume of POSIX.1-2008) and can be given new values with variable assignment commands.
2.5.3 Shell Variables - Shell Command Language

したがって,設定を有効にするためにexportコマンドを実行する必要はなく,単に値を変更すれば即座に適用される。

2016-12-16追記:

LC_ALLとPATH変数についてはこれは間違いだった。exportコマンドにより,これらは環境変数として設定しなければならない。

理由はシェルスクリプトの起動元(#!/bin/sh)である,shが環境変数としてこれらの変数を参照しているからだ。

特に,LC_ALL変数はほとんどのPOSIX準拠コマンドが環境変数として参照している。

The entire manner in which environment variables described in this volume of POSIX.1-2008 affect the behavior of each utility is described in the ENVIRONMENT VARIABLES section for that utility, in conjunction with the global effects of the LANG, LC_ALL, and [XSI] [Option Start] NLSPATH [Option End] environment variables described in XBD Environment Variables.

1.4 Utility Desciription Defaults - Shell & Command Utilities

例えば,dateコマンドのENVIRONMENT VARIABLESのセクションに環境変数としてLC_ALLやLANGを参照している。実際に以下のようなコマンドを実行すると環境変数が優先されていることがわかる。

#!/bin/sh
export LANG=ja_JP.UTF-8 date LC_ALL=C date (date) sh -c "date"
2016年 12月 16日 金曜日 21:41:26 JST
2016年 12月 16日 金曜日 21:41:26 JST
2016年 12月 16日 金曜日 21:41:26 JST
2016年 12月 16日 金曜日 21:41:26 JST

ここで,LC_ALL=Cexport LC_ALL=Cに変えるだけで,ロケールがサブシェルなどにも反映される。

2016年 12月 16日 金曜日 21:41:49 JST
Fri Dec 16 21:41:49 JST 2016
Fri Dec 16 21:41:49 JST 2016
Fri Dec 16 21:41:49 JST 2016

したがって,LC_ALLはシェル変数ではなく環境変数として設定しなければならない。

また,PATH変数もshだけでなく,typeコマンドのように一部のコマンドはPOSIX規格で環境変数として参照している。多くの環境ではシェル変数としてPATH変数に値を代入した場合にサブシェル(シェルスクリプト内でのsh -cなど)にも引き継がれるのだが,FreeBSDのshでは引き継がれない(FreeBSD 11.0で確認)。exportによりPATHを環境変数としてやればきちんと反映される。

export PATH="$(command -p getconf PATH):$PATH"

PATH変数の初期化は極めて重要だ。例えば,.bashrcの最後にunset PATHを追記されたり,lsという名前でrmコマンドが実行されるなどのコマンド名は見慣れているが中身が全く違う悪意あるプログラムのPATHを先頭に持ってこられたりしたら致命的な問題となる。

「すべてで20年動く」p. 37「1-9 環境変数などの初期化」においては以下のように決め打ちで指定されている。

PATH='/usr/bin:/bin'

これには以下2点の問題がある。

  1. /usr/bin:/binはPOSIXで未保証
  2. /usr/bin:/bin以外にインストールされているPOSIX準拠コマンドが利用不可能

/usr/bin/binにPOSIX準拠コマンドが全て存在しているとは限らない。POSIXではこれらのディレクトリは規定されていないからだ。また,/usr/bin:/binで固定化してしまうと,システム管理者やユーザーが追加でインストールしたPOSIX準拠コマンドを利用できない。例えば,bcコマンドは標準でインストールされてないOSがあり,システム管理者に/usr/local/binなどにインストールされていることも考えられる。

したがって,「すべてで20年動く」のPATH変数の初期化方法はよろしくない

それではどうするのが最善か?実はこのPATH変数を安全に完全に初期化する方法がある。それはcommandコマンドを使う方法だ。初期化するコードは以下となる。

PATH="$(command -p getconf PATH):$PATH"

上記コードは以下の手順でPATH変数を設定している。

commandコマンドによるPATH変数の初期化手順
  1. command -pによりPOSIXで保証される標準PATHからgetconfコマンドを検索
  2. getconfコマンドにより標準PATHの値を取得
  3. コマンド代入$()により,getconfコマンドの出力結果を取得
  4. 既存のPATH変数の直前にgetconfで取得した安全な標準PATHを配置

この方法が優れている理由は以下3点だ。

commandコマンドによるPATH変数の初期化方法の利点
  1. commandコマンドは組み込みであり,PATH変数が未定義でも,同名コマンドがあっても利用可能
  2. commandコマンドの-pオプションで標準のPATHを保証
  3. POSIX規格で例示される公認コード

決め打ちでPATH変数を初期化しない場合,なんらかのコマンドにより標準的なPATH変数の値を取得する必要がある。その目的として使えるコマンドとしてgetconfが存在する。このことは,bashクックブックの「レシピ14.3 安全な $PATHの設定」にも書かれている。

getconfコマンドは設定値を取得するコマンドであり,引数の最大値ARG_MAXなどシステム設定値を取得できる。ただし,getconfコマンドは外部コマンドなので,まずgetconfコマンドを安全に起動する必要がある。そのために,command -pを使う。commandコマンドは-vか-Vオプションをつけなければ,引数に渡されたコマンドを実行するコマンドだ。

1個目の利点だが,commandコマンドはPOSIXで規定されるBuilt-In Utilities(組み込みコマンド)だ。そのため,仮にPATH変数が空でも実行できる。また,POSIXの「2.9 Command Search and Execution - Shell Command Language」を見ればわかる通り,組み込みコマンドは外部コマンドよりも検索優先順位が高い。そのため,同名コマンドがPATHに存在していても必ず本物が優先される。

なお,初期化で既に利用したumaskもbuilt-In utilitiyであり,setとunsetはspecial built-in utility(特殊組み込みコマンド)であり,安全に実行できることが保証されている。

2点目の利点だが,commandコマンドのオプション-pは,標準コマンドの存在が保証されるPATHの標準値からコマンド検索を実行する。したがって,command -pにより実行されるコマンドは安全であることが保証される。これにより,getconfコマンドを安全に起動することができる。

3点目の利点だが,この方法はcommandコマンドのEXAMPLESで例示されておりPOSIX規格公認のPATH変数の初期化方法とみなせる。実際のところ,getconfコマンドはXSHで規定されるようなシステム値(何かの最大値であったり最小値)の取得はできるが,こうした環境変数の取得については「The implementation may add other local values.」とあるように実装依存のところがあった。しかし,POSIX規格で例示されているので安心して利用できる。

commandコマンドのEXAMPLESで例示では,より安全にPATHを設定するためにunaliasunset -f commandなども書かれている。しかし,サブシェルで起動した時点でaliasや関数は初期化されるので,シェルスクリプトの冒頭で書くのならこれらは不要だ。

なお,冒頭でset -uを有効にしたため,万が一PATH変数が設定されていない環境でエラーが出ないように以下のように未定義の場合は空文字を返すようにした。

export PATH="$(command -p getconf PATH):${PATH:-}"

これは,set -uより前にPATHを初期化すれば不要になる。個人的に,umaskやsetの方がシェルそのものの挙動に影響を与え,影響範囲が大きいと判断してこの順番にした。

2016-12-19追記

当初はset -uで万が一PATHが未定義の場合でもエラーで落ちないように${PATH:-}としていたが,やはり通常通りの$PATHとした。

export PATH="$(command -p getconf PATH):$PATH"

理由は以下4点。

  1. 見栄えが悪い。
  2. set -uで落ちないように,ガードするのは本末転倒。
  3. PATH変数が未定義の場合は異常事態なので,そこで止まってほしい。
  4. PATH変数が未定義の場合,sh起動時に実装依存で標準値が設定されていることが多く,未定義なことが考えにくい。

シェルスクリプトの初期化は今後何度も見ることになるため,きれいなコードを維持したい。set -uで落ちることを防ぐという,本末転倒な理由のために汚いコードを残すのは忍びない。だから,POSIX規格で書かれている通りのコードにした。

Conclusion

以上のことから,POSIX原理主義によるシェルスクリプトでは,常に以下のコードを初期化のために冒頭で記述することが望まれる。

set -eu
umask 0022
export LC_ALL='C' PATH="$(command -p getconf PATH):$PATH"

例えば,以下のように関数にまとめてしまってもよいだろう。

#!/bin/sh

init(){
set -eu
umask 0022 export LC_ALL='C' PATH="$(command -p getconf PATH):$PATH"
}

main()(
init
# command
)

main

「徹底的に調べ尽くす」という自分のPOSIX原理主義を実践できた。自分一人ではここまでのことを思いつけなかった。最初にサンプルがあり,そこに疑問をもってPOSIX規格を徹底的に読み込むことで今回の結論が得られた。今回の結論は「巨人の肩の上に立つ」ことで得られたものであり,叩き台として最初に例を挙げてくださった「すべてで20年動く」の著者である松浦さんに感謝したい。

0 件のコメント:

コメントを投稿