dashでシェルスクリプトを動かす場合はIFS変数を初期化した方が良い（親プロセスの値を引き継ぐ不具合）

## はじめに

現在広く使われている dash < 0.5.11 では `IFS` 変数が環境変数として親プロセスでエクスポートされている場合にその値を引き継いで使用してしまうという不具合があります（**最新バージョンでは修正されています**）。この記事ではこの不具合の詳細と回避策を紹介します。

## 不具合

POSIX ではシェルが呼び出された時に `IFS` 変数をスペース・タブ・改行に設定することになっています。（[参考](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_05_03
)）

> The shell shall set IFS to <space> <tab> <newline> when it is invoked.

しかしながら不具合があるシェルでは親プロセスで環境変数として `IFS` 変数が**エクスポートされていると**シェルスクリプトでもその値を使用してしまいます。

```sh
$ ( export IFS=@; sh -c 'echo "[$IFS]"' )
[@]
```

`IFS` 環境変数は単語分割や `read` コマンドなどで単語を分割するために暗黙的に使用されていることがあるため、想定外の値に設定されているとシェルスクリプトが誤動作する可能性があります。

### 不具合があるシェル

この不具合は dash の元となった ash の不具合であるため、dash 以外にも ash 系のシェルで不具合がありました。いずれのシェルでも修正されているようですが、修正時期やサポート期間を考えると不具合があるバージョンはまだ使われていると思われます。特に Debian / Ubuntu で気をつける必要があるでしょう。

- dash （[2018-05 に修正](https://git.kernel.org/pub/scm/utils/dash/dash.git/commit/?id=6458796c79e643503d14e18c611cfbf68c4a8cce)）
  - dash 0.5.10.2 不具合あり、dash 0.5.11.4 不具合なし
  - Debian 10 (2019-07) 不具合あり、Debian 11 (2021-08) 不具合なし
  - Ubuntu 20.10 不具合あり、Ubuntu 21.04 不具合なし
- FreeBSD sh （[2016-10 に修正](https://cgit.freebsd.org/src/commit/bin/sh?id=7cca93e61ab919e2126eab2bc5cf5554b491475e)）
    - FreeBSD 10.4 (2017-10) 不具合あり、FreeBSD 13.0 (2021-04) 不具合なし
    - おそらく FreeBSD 11 (2016-10) か 12 (2018-12) で修正（未確認）
- NetBSD sh ([2016-03 に修正](https://cvsweb.netbsd.org/bsdweb.cgi/src/bin/sh/var.c#rev1.48
)）
  - NetBSD 9.0 (2020-02) 不具合なし
  - おそらく NetBSD 7.1 (2017-03) か NetBSD 8.0 (2018-07) で修正（未確認）
- Busybox ash ([2018-08 に修正](https://git.busybox.net/busybox/commit/shell?id=67dae152f4bf5456e4ea7950f4b55356aa37ec6c)）
  - Busybox 1.29.3 (2018-09) 不具合あり、Busybox 1.30.0 (2018-12) 不具合なし

なお**不具合がない**ことを確認しているシェルは以下のとおりです。いずれも標準パッケージで提供されているバージョンです。

- Debian 2.2.7 (2002) 〜 Debian 10
  - bash >= 2.03
  - mksh >= R28
  - ksh93 >= 93s
  - pdksh >= 5.2.14
  - posh >= 0.3.14
  - yash >= 2.29
  - zsh >= 3.1.9
- Solaris 10 (2005)
  - Bourne シェル SVR4.0 (`/bin/sh`)
  - ksh88 (`/usr/bin/ksh`)
- FreeBSD 13.0
  - sh (`/bin/sh`) ash 系
- NetBSD 9.0
  - sh (`/bin/sh`) ash 系
  - ksh (`/bin/ksh`) pdksh 系
- OpenBSD 6.6
  - sh (`/bin/sh`) pdksh 系
  - ksh (`/bin/ksh`) pdksh 系 （sh と同一バイナリ）

### Bourne シェル

古い Bourne シェルでも同様の不具合があったようです。[The Traditional Bourne Shell Family](https://www.in-ulm.de/~mascheck/bourne/#sunos5) によると以下のように書いてあるので SunOS 5.6 (Solaris 2.6) （1997 年リリース 〜 2006 年サポート終了）で使用されていたバージョンの Bourne シェル SVR4.0 では不具合があったと思われます。

> SunOS 5.7: IFS not inherited from environment anymore

[Various system shells](https://www.in-ulm.de/~mascheck/various/shells/) より AIX 4.3.2 (1998) や HP-UX 10.10 (1995) では `sh` は ksh88f のようなので不具合はなかったと思われますが、これより前の OS や古いバージョンのシェルは 古い Bourne シェルであるため不具合があったと思われます。

つまり商用 Unix の世界では 2000 年代中頃までは `IFS` 変数を初期化する必要があったようです。（いずれも私は実際の環境で確認はしていません。）

## IFS の初期化方法

やり方は色々あり一長一短ですが、メリットとデメリットを交えながらいくつかの紹介します。個人的には 3. か 6. が良いのではないかと思います。

### 1. ソースコードにそのまま書く

```sh
IFS=' 	
'
```

ぱっと見よくわかりませんが `IFS` 変数に、スペース・タブ・改行を代入しています。メリットはどの POSIX シェルでも使えて速いという所です。デメリットは見たまんまですね。ぱっと見よくわかりません。テキストエディタによってはスペースとタブを保存時にどちらかに統一してしまう場合があるので間違えて変更してしまう可能性があるのでおすすめしません。

### 2. `$' \t\n'`

```sh
IFS=$' \t\n'
```

タブ、改行がはっきりと分かる方法です。しかしこの書き方は dash では使えません。bash、mksh、ksh、zsh 等で使える書き方で POSIX での採用が検討されていますが、これが使えるようになったとしても dash (0.5.11 以降）で `IFS` 変数は初期化されているわけで意味がありません。一部のシェルで `IFS` 変数の初期化に使うことはできますが、dash の不具合の回避策にはなりません。

### 3. `IFS=$(printf ' \t\n_')`

`_` なし（`IFS=$(printf ' \t\n')`）でも良さそうに思うかもしれませんが、それだけでは不十分です。なぜならコマンド置換（`$(...)`）を使うと出力の末尾の改行が消えてしまうからです。（参考 [シェルスクリプトにはコマンド出力を変数に入れると末尾の改行が全部消えてしまう罠がある！](https://qiita.com/ko1nksm/items/7c4040c6b6f3fac14db7)）

これを回避するために、出力の末尾に余計な文字をつけておき、それをパラメータ展開で削除する必要があります。

```sh
IFS=$(printf ' \t\n_') && IFS=${IFS%_}
```

### 4. `IFS=$(printf ' \n\t')`

3.の文字の順番を入れ替えたパターンです。末尾が改行ではなくなるので改行文字が消えることはありません。ただし、POSIX の指定通りスペース・タブ・改行の順番で入っている前提で考えてコードを書いてしまう可能性があるためおすすめはしません。注意して使うというのあれば良いとは思います。

```sh
IFS=$(printf ' \n\t')
```

### 5. `unset IFS`

```sh
unset IFS
```

POSIX では `IFS` が `unset` された場合、`IFS` の値にスペース・タブ・改行が入っている**かのような**動きをすると規定されています（実際に `IFS` 変数にスペース・タブ・改行が設定されるわけではない）。

https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_05_03

> 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.

【意訳】 `IFS` が設定されてない場合は `unset` された通常の値と同じように振る舞う。ただし単語分割と `read` コマンドの行分割は `IFS` にスペース・タブ・改行が入っているかのように振る舞う。

そのため `unset IFS` した状態でも単語分割などはデフォルトと同じ動作をしますが、変数は `unset` されている状態であるため、前項と同じく `IFS` にスペース・タブ・改行が入っているという前提のコードを書いてしまった場合に問題が発生するのでおすすめしません。注意して使うというのあれば良いとは思います。

### 6. `eval "$(printf 'IFS=" \t\n"')"`

あまり見かけない方法だと思います。3. に近いですが末尾の 1 文字を削除する必要はありません。それは `eval` する文字列である `printf` の出力が 1. 相当になるため末尾に改行がこないからです。

```sh
$ eval "$(printf 'IFS=" \t\n"')"

$ printf 'IFS=" \t\n"' # 1. 相当の形式で出力される
IFS=" 	
"

# 以下を実行するのとほぼ同じ
$ eval 'IFS=" 	
"'
```

3.と比べて末尾の 1 文字を削除しなくて良い分コードは短くなりますが、代わりに `eval` を使うので少し遅くなり分かりづらい表記になります。ただしその他の特殊な文字を入れる変数も同時に初期化したい時に合わせて短く書くことができコマンド置換の回数を減らすことができます。`eval` は遅いと言ってもコマンド置換よりは速いため、複数の変数を同時に初期化する場合は、この方法の方が速くなります。

```sh
$ eval "$(printf 'IFS=" \t\n" TAB="\t" LF="\n"')"

# 一般的な方法で書くとこうなる
TAB=$(printf '\t')
LF=$(printf '\n_') && LF=${LF%_}
IFS=" ${TAB}${LF}"
```

## zsh 対応

ここまでの話は zsh の話は省略してきました。実は zsh は `IFS` 変数のデフォルト値は、スペース・タブ・改行ではなく、最後にもう一つ NULL 文字（`\0`）が追加されています。zsh はシェル変数に NULL 文字を代入できる唯一の POSIX シェルです。

現在のシェルが zsh かどうかは `ZSH_VERSION` 変数で調べることができます。これもいろんなやり方が考えられますが、例えば `IFS` の初期化処理の最後に次の行を追加するのがシンプルでわかりやすいのではないかと思います。（zsh 3.0 系では`$'...'` に対応していないため使用できませんが古すぎるシェルであるため考慮する必要はないでしょう）

```sh
eval "$(printf 'IFS=" \t\n" TAB="\t" LF="\n"')"
IFS="${IFS}${ZSH_VERSION:+$'\0'}" # この行を追加する
```

## エクスポート属性の削除 （`unexport`）

一般的に `IFS` にエクスポート属性はつけるべきではありませんが、この問題は親プロセスで**エクスポートされている**場合に起ります。つまりすでにエクスポート属性がついてしまっているわけです。そこでエクスポート属性（正確にはすべての属性）を外したい場合に使える `unexport` 関数を紹介します。もちろんすべての POSIX シェルで動作する方法です。一部のシェルではエクスポート属性だけを外すことができますが、その方法を使わない実装です。多少読みづらいのは例のごとく（？）グローバル変数を使わないように実装しているからです。

```sh
unexport() {
  while [ $# -gt 0 ]; do
    eval "set -- \"\${$1}\" \"\$@\"; unset \"$1\"; $1=\$1"
    shift 2
  done
}
unexport IFS FOO BAR BAZ # export コマンドと同じように複数の変数を指定できます

# IFS だけでいいならこれでよい
ifs=$IFS
unset IFS
IFS=$ifs
```

## さいごに

この問題に言及している記事はいくつかありますが、情報が古かったり不正確だったりしたため 2021 年 9 月現在の更新記事としてまとめました。また POSIX で規定されているからと言って、そのとおり実装されているとは限らず、実際のシェルでテストする必要があることを示す一例にもなっています。