envsubstの本来の使い方はテンプレートエンジンではなくシェルスクリプト用の国際化機能

## はじめに

`envsubst` コマンドはシェルスクリプトから使える簡易的なテンプレートエンジンであるかのように紹介されていますが、よく調べるとなぜこのような仕様になっているんだ？と疑問になるような仕様の機能を持っています。その理由は `envsubst` コマンドが本来 `gettext.sh` による国際化機能（翻訳処理）に伴う変数展開を安全に行うために作られたコマンドだからです。それを知らずに `envsubst` だけを見ていても使い方や仕様の意味はよくわからないでしょう。この記事では `envsubst` コマンドの奇妙な仕様はどのように使うもので、なぜこのような仕様になったのかについて説明します。

## envsubt コマンドの使い方

### 基本機能（変数の展開）

`envsubt` コマンドは実際にはテンプレートエンジンではないのですが、次のような環境変数の値を文字列に埋め込むといった使い方をよく見かけます。

```txt:template.txt
Hello ${NAME}
Your home directory is ${HOME}.
```

```console
$ export NAME="Koichi"
$ envsubst < template.txt
Hello Koichi
Your home directory is /home/koichi.
```

埋め込む変数の書式には `$VAR` と `${VAR}` だけが使えます。シェルの `${VAR:-}` のようなものは使えません。テンプレートエンジンとしての使い方はこれだけです。条件分岐やループと言った機能はありません。ループがない理由は環境変数を配列にできないので仕方ないのかもしれませんが、条件分岐ぐらいあっても良さそうです。しかしありません。なんて低機能なテンプレートエンジンなんだ！と思うかもしれませんが、それは `envsubt` コマンドの本来の機能には必要ないものだからです。必要のない機能を追加したりしません。

### --variables オプション（変数名の抽出）

これは、引数で指定された文字列の中から、変数名を取り出すための引数です。例えばこのような動作をします。

```console
$ envsubst --variables 'foo ${NAME} bar $HOME baz'
NAME
HOME
```

どういう動作をするかはわかります。しかしなんのために使うのかさっぱりわかりません。ドキュメントを見ても動作しか書いていないので読んだだけでは使い方はわからないと思います。`--variables` オプションのもう一つ奇妙なところは、文字列を標準入力ではなく引数で与えるところです。`--variables` を指定しない場合は標準入力から与えるのですが、こちらは引数で与えます。そして標準入力の内容は無視されます。

### envsubst の引数（展開する変数の制限）

`envsubst` コマンドに渡した引数から、テンプレートで展開する変数を制限することができます。環境変数を展開する時に余計な変数、例えば環境変数に入れているシークレットアクセスキーなどが展開されてしまったら困るので、そういうのを防ぐためだろうなというのは想像できます。

```console
$ export NAME="Koichi"
$ envsubst < template.txt
Hello Koichi
Your home directory is /home/koichi.

$ envsubst < template.txt '$NAME'
Hello Koichi.
Your home directory is ${HOME}.
```

機能は理解できますが、奇妙なのは引数の形式です。なぜ `NAME` ではなく `$NAME` なのでしょうか？ 普通に考えたら `envsubst NAME VAR1 VAR2` のような引数にすると思います。しかし `envsubst` の指定の仕方は `envsubst '$NAME $VAR1 $VAR2'` です。しかも `envsubst 'foo $NAME bar ${VAR1} baz $VAR2'` のように余計な文字が入っていても無視されて `$VAR` または `${VAR}` という書式になっているところだけを解釈します。なぜこんな面倒な解釈をしているのでしょうか？

## envsubst の本来の使い方

### `gettext` と `eval_gettext` の話

`envsubst` の奇妙な使い方を理解するには、それが本来どこに使われているかを知らなければなりません。`envsubst` は GNU の国際化（多言語・翻訳・日本語化）機能である GNU gettext プロジェクトに含まれているツールです。そしてシェルスクリプト用の翻訳ライブラリ `gettext.sh` の中で使われています。

`gettext.sh` はバージョン 0.21.1 でコメント・空行込でも 135 行、コメント・空行を除くと 59 行しかなく、本質的な処理をしている関数の処理はそれぞれ 1 行しかありません。簡単なシェルスクリプトなので読むのをおすすめしますと言いたいところですが、別に読まなくてもコードはここに引用してしまうものが全てだったりします。ただコメントは重要なのでやっぱり読むのをおすすめします。GNU gettext がインストールされていれば、おそらく `/usr/bin/gettext.sh` か `/usr/local/bin/gettext.sh` にあるでしょう。

まず `eval_gettext` 関数で `envsubst` がどのように使われているかコードを見てます。見ての通りたったの一行で、ここで `envsubst --variables "$1"` と `envsubst "$1"` が両方とも使われています。

```sh
eval_gettext () {
  gettext "$1" | (export PATH `envsubst --variables "$1"`; envsubst "$1")
}
```

話をすすめるには `eval_gettext` と `gettext` が何かを説明しなければならないでしょう。ここまでの話で想像がつくかと思いますが、このコマンドは文字列の翻訳を行っています。翻訳機能に関してはこの記事の対象ではないので詳しくは説明しませんが、以下のような処理を行うということを知っていれば十分だと思います。

```sh
gettext 'Hello ${name}'
# 日本語の言語ファイルがあれば「こんにちは ${name}」
# なければ元の文字列「Hello ${name}」と出力する

name="Koichi" # 注意 name は export していません
eval_gettext 'Hello ${name}'
# 翻訳文があれば「こんにちは Koichi」
# なければ元の文字列「Hello Koichi」と出力する
# ${name} という文字列を eval_gettext は name 変数の中身に置き換えている
```

`gettext` は GNU gettext に含まれている外部コマンドで文字列の翻訳だけを行います。`eval_gettext` 関数は `gettext.sh` で定義されたシェル関数で `gettext` コマンドを使って翻訳した後、変数を展開する処理を追加で行っています。

### なぜ eval_gettext は変数を展開できるのか？

次に以下のようなコードがなぜ動作するのか？です。この段階ではシェル変数 `name` はエクスポートしてないことに注意してください。

```sh
name="Koichi"
eval_gettext 'Hello ${name}' # => Hello, Koichi
```

気づいている人もいるかも知れませんが `eval` を使えば簡単です。このような感じで動かすことができます。ただしこれは翻訳文にシェルのメタ文字などを含めた場合、色々な問題が発生する危険なコードです。

```sh:eval での簡易実装
eval_gettext () {
  eval "echo $(gettext "$1")"
}
```

`eval` を使った危険性を避けるため、実際のコードは以下のようになっています。

```sh:gettext.sh の一部
eval_gettext () {
  gettext "$1" | (export PATH `envsubst --variables "$1"`; envsubst "$1")
}
```

`'Hello ${name}'` はシングルクォートされているので、関数呼び出しの時点では変数は展開されません。つまり `gettext 'Hello ${name}'` が実行されます。出力結果は（日本語の翻訳文がある場合に）`こんにちは ${name}` で、この文字列に対して `envsubst` コマンドは変数を展開します。ここでは翻訳前の文章ではなく翻訳後の文章に対して `envsubt` で変数展開を行っていることに注意してください。まあ当然と言えば当然ですね。

`envsubst` コマンドは外部コマンドであるため `name` 変数の値を参照するには、`name` が環境変数としてエクスポートされていなければなりません。つまり `export name` を実行しなければいけないということです。しかし一体どの変数をエクスポートすればよいのでしょうか？それを知るには（翻訳前の）`'Hello ${name}'` を読み取って `name` を返す必要があります。`envsubst --variables "$1"` はその処理をするためのものです。

ここでは翻訳前の `'Hello ${name}'` から変数名を取得しなければなりません。なぜなら翻訳文は翻訳ファイルに（おそらく別の人が）書くものなので全く異なる変数名に変えてしまうことが可能だからです。例えば翻訳文を間違えて `こんにちは ${namae}` にしてしまうことは可能です。したがって確実に `name` をエクスポートするには翻訳前の文字列をパースしなければいけません。

```sh
gettext 'Hello ${name}'              # => こんにちは ${name}
envsubst --variables 'Hello ${name}' # => name

# 「export PATH name」を実行する
export PATH `envsubst --variables 'Hello ${name}'`
```

`export` の最初の引数 `PATH` はエクスポートする変数が一つもない場合に `exprot` が引数なしで実行されないようにするためのダミーの環境変数です。`export` を引数なしで実行すると環境変数一覧が出力されてします。

`--variables` はなぜ標準入力ではなく引数を渡すのか？ それはこの時点で翻訳前の文字列を変数で持っているからです。すでに変数で持っているのであればいちいち変数の内容を出力するよりも引数で渡したほうが楽です。一方で翻訳処理自体は `gettext` の出力を入力とするため標準入力を使うのが適しています。

次に `envsubst "$1"` ですが、これは指定した変数名だけを展開するためのものです。例えば環境変数 `LANG` などはデフォルトでエクスポートされているため、対策なしでは翻訳ファイルから参照できてしまいます。それは予期せぬ問題を引き起こす可能性があり、場合によっては脆弱性の原因にもなりかねません。

```sh
export name="Koichi"

echo 'こんにちは ${name} $LANG' | envsubst # 引数なしだと…
# => こんにちは Koichi ja_JP.UTF-8
# 想定しない変数の展開は、予期せぬ問題を引き起こす可能性がある

echo 'こんにちは ${name} $LANG' | envsubst 'Hello ${name}'
# => こんにちは Koichi $LANG
```

ここでも `envsubt` の引数は翻訳前の文字列でなければなりません。理由は同じで翻訳ファイルは他の人が書くものだからです。

コードの意味をわかりやすくまとめるとこのようになります。`envsubst` の奇妙な仕様は引数として与えられるものが翻訳前の文字列だからです。翻訳前の文字列はソースコード（シェルスクリプト）に書かれている文字列なので安全ですが、翻訳後の文字列は翻訳ファイルに誰かが書いた文字列なので危険である、という前提があります。

```sh:gettext.sh の一部
eval_gettext () {
  gettext "翻訳前の文字列" | # => 翻訳後の文字列
    (
      # 翻訳前の文字列からエクスポートする変数名を抽出してエクスポート
      export PATH `envsubst --variables "翻訳前の文字列"`

      # 翻訳前の文字列から展開する変数名を抽出して、それだけを変数展開
      envsubst "翻訳前の文字列"
    )
}
```

このように翻訳前の安全な文字列から `envsubst --variables ...` でエクスポートする変数名を抽出する、`envsubst ...` で展開する変数名を抽出する、この二つの処理で抽出した情報を使って、翻訳後の文字列を安全な形で変数を置き換えるようになっています。そのために二回 `envsubst` を実行し、翻訳前の文字列を二回パースしています。`gettext` による翻訳も加えれば三回も翻訳前の文字列を参照しています。理由はわかりましたがなんとも回りくどい仕組みですね。

## さいごに

本来コマンドというのは汎用的に色んな用途で使えるように作るものです。`envsubst` コマンドもテンプレートエンジンと考えれば汎用的に使うことができます。しかし `--variables` オプションや引数の仕様は明らかに `eval_gettext` 関数で使うために設計されています。

`envsubst` の本質はテンプレートエンジンではなく `printf '%s'` 相当です。実際、他の言語では翻訳文に対して `printf` 相当のものを使って値を埋めています。ただシェルの `printf` には参照する引数の位置を指定する書式が使えなかっため `envsubst` が必要だったのです。（注意 ksh、zsh の `printf` にはあります。`printf '%2$s %1$s\n' FOO BAR` を実行してみてください）

実は私も最近まで `envsubst` コマンドをテンプレートエンジンとして見ていました。以前テンプレートエンジンであれば持っているはずの機能を使うにはどうすればいいかを、ドキュメントを読んで調べたのですが、そのような機能がまったくなく、しかもよくわからない機能を持っていてどうしてこのような仕様になっているのか理解できませんでした。

つい最近、シェルスクリプトで国際化するための情報を調べていて、その過程で `gettext.sh` の中で使われていることに気づき、ようやく `envsubst` コマンドの奇妙な仕様について理解することができました。コミットログを見ると `gettext.sh` のために `envsubst` が作られたのは明らかです。

```txt
b800ac7 2003-09-17 Don't mention $"..." bash shorthand any more.
c5bed35 2003-09-17 Document how to internationalize shell scripts.
2551fcd 2003-09-17 Documentation of programs of gettext-runtime package.
0b8caec 2003-09-17 Support for internationalized shell scripts.
b2ab0c4 2003-09-17 Support for internationalized shell scripts: Documentation for envsubst.
9d4b651 2003-09-17 Support for internationalized shell scripts: Shell functions.
↑ ここで gettext.sh のコードが追加されている。
a9a59a4 2003-09-17 Support for internationalized shell scripts: Program to substitute env...
↑ ここで envsubst のコードが追加されている。
1bf5e35 2003-09-16 Support only simple access to shell variables, only ASCII names,...
166ecfb 2003-09-16 Document the shell format strings.
```

正直言って `gettext.sh` の設計は良いとは言えません。`eval_gettext` という名前から推測するに、元は `eval` を使って実装しようと考えていたのではないかと思います。その過程で `eval` は使い方を誤ると危険だからという理由で `envsubst` コマンドを作ったのだと思いますが、設計が `eval` を使った実装に影響されすぎています。翻訳を行うだけなら以下のような仕様のコマンド（`argsubst` コマンド）で十分なはずです。

```sh
eval_gettext () {
  # gettext は「Hello #{1}」から「こんにちは #{1}」を返す
  # argsubt は #{n} を引数の指定した位置の値に展開する
  # 区別しやすく # に変更したが $ のままでもよい
  gettext "$1" | ( shift; argsubst "$@" )
}
eval_gettext 'Hello #{1}' "$name"

# 位置での指定は分かりづらいというのであればこのような仕様も考えられる
eval_gettext () {
  # gettext は「Hello #{name}」から「こんにちは #{name}」を返す
  # argsubt は #{name} を引数で指定した名前の値に展開する
  gettext "$1" | ( shift; argsubst "$@" )
}
eval_gettext 'Hello #{name}' name="$name"
# name="$name" は冗長に思えるが、ソースコードと翻訳ファイルを
# 疎結合にできるというメリットが有る（変数名の変更が翻訳文に影響しない）
```

またテンプレートエンジンとして使うのであれば `envsubst` は以下のような仕様で十分でしょう。

```sh
# 全ての環境変数を展開する場合
envsubst < template.txt

# 展開する変数名を制限する場合
envsubst --allow NAME,VAR1 < template.txt

# 引数で指定する場合（キーのみの場合は環境変数を意味する）
envsubst NAME="$name" VAR1 < template.txt

# --variables オプションは不要
```

翻訳のために `gettext.sh` の内部で使うために作られたことに気づくと、`envsubst` の仕様の意味は理解できますが、それと同時になんとも中途半端な設計だなぁというのが今の私の感想です。簡易的なテンプレートエンジンとして使えるのは、それはそれで便利ですけどね。