awk の -v オプションに潜む罠 〜任意の値を渡す時はエスケープ処理が必要です！〜

## はじめに

`awk -v` は「エスケープシーケンス」と「正規表現定数（gawk のみ）」の二種類の特殊な解釈処理を行うという仕様があります。`-v` オプションは awk スクリプトに値を渡す時に使うオプションですが任意の値を渡す場合は注意が必要です。この記事ではこのオプションに潜んでいる罠についての詳細とその回避方法について解説します。

**解説はいらないから[回避方法](#回避方法)だけ知りたいという方はこちら。でも[重要](#重要-なぜ--v-はこのような設計なのか)も読んで欲しい。**

## 前提知識

### 注意 awk はシェルスクリプト言語ではありません

まず最初に一つ頭に入れて欲しいことは awk はシェルスクリプト言語の一部ではないということです。Perl や Python と同じで独立した別の言語です（[POSIX awk](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/awk.html) では **the awk programming language** と書かれています）。そのため awk スクリプトの中ではシェルスクリプトのルールは一切当てはまりません。似ている機能や同じ機能があったとしてもたまたまそうなっているだけです。例えば awk の中で使う `printf` ステートメントとシェルスクリプトの `printf` コマンドは同じ名前でも全く別物で一部の機能は同じですが異なる機能も持っています。この記事の `-v` オプションのエスケープにまつわる仕様も awk の問題です。シェルスクリプト側のエスケープの話は一切関係ありません。これをしっかり区別できてないと、違う話を混ぜてしまい話をややこしくしてしまうので重要なことです。

### awk 実装の種類

この記事はこれらの awk で確認しています。下記のバージョン番号に書いているバージョンで動作確認しています。

- [nawk](https://github.com/onetrueawk/awk) 
  - UNIX System V 版: 別名 One true awk / BWK awk
  - macOS、FreeBSD、NetBSD、OpenBSD、Solaris 11 の `/usr/xpg4/bin/awk`
  - Debian 系のパッケージ・コマンド名は `original-awk`
  - バージョン番号: `--version` / OpenBSD は `-V` （例 `awk version 20200816`）
- [gawk](https://www.gnu.org/software/gawk/): 
  - GNU 版: GNU awk
  - RedHat 系 Linux
  - バージョン番号: `--version` （例 `GNU Awk 5.1.0, API: 3.0 (GNU MPFR 4.1.0, GNU MP 6.2.1)`）
- [mawk](https://invisible-island.net/mawk/):
  - Debian 系 Linux
  - バージョン番号: `-Wversion` (例 `mawk 1.3.4 20200120`)
- [Busybox awk](https://www.busybox.net/):
  - Busybox に内蔵された awk
  - バージョン番号: `--help` (例 `BusyBox v1.30.1 (Ubuntu 1:1.30.1-4ubuntu6.3) multi-call binary.`)

以下の awk は対象外です。`-v` オプションもなく引数の形式も異なります。

- awk （旧版）
  - Version 7 Unix 版: 初期の awk で関数が使えない
  - Solaris 11 のデフォルト (`/usr/bin/awk`) おそらく互換性の理由

## `-v` オプションに潜む罠

### 1. エスケープシーケンスを解釈する

エスケープシーケンスとはエスケープ文字から始まる特定の文字列のことで awk においてエスケープ文字とはバックスラッシュのことです。

さて awk の `-v` オプションはシェルスクリプトなどから値を渡すための機能なのですが、awk の `-v` オプションの罠とは「渡された値のエスケープシーケンスを awk が解釈するのでシェルスクリプトから渡した文字がそのまま渡らない」ということです。この問題は awk の `-v` オプションの仕様なので、他のコマンドに渡す時やシェルスクリプト内部の処理（例えばシェル関数の引数や変数への代入）には全く関係ありません。具体的に言うと（他のコマンドと同様に） awk にはエスケープ文字はそのまま渡されていますが、渡されたあとで awk が自身のの定めるエスケープシーケンスを解釈して（エスケープ文字を消してから）awk の変数に入れています。

例えばバックスラッシュが含まれた文字列 awk スクリプトに渡すことこのようなエラーがでます。

```sh
$ bs='[ \ ]'
$ awk -v bs="$bs" 'BEGIN{ print bs }'
awk: 警告: エスケープシーケンス `\ ' は ` ' と同等に扱われます
[  ]

$ LANG=C awk -v bs="$bs" 'BEGIN{ print bs }' # 英語だと
awk: warning: escape sequence `\ ' treated as plain ` '
[  ]
```

このエラーの意味は `\ ` （`\` + スペース）というエスケープシーケンスは存在しないからエスケープ文字だけが取り除かれスペースとみなされているということです。`\t` のように存在するエスケープシーケンスであれば警告は表示されずにタブに置き換わります。

```sh
$ bs='[\t]'
$ awk -v bs="$bs" 'BEGIN{ print bs }'
[        ]
# 注意 分かりづらいのでタブ文字をスペース 8 文字に置き換えています。
```

こうなるのは awk の `-v` オプションの仕様なので、環境変数や引数で渡した場合タブに置き換わったりしません。

```sh
$ export BS='[ \ ]'
$ awk 'BEGIN{ print "BS: " ENVIRON["BS"]; print "ARG1: " ARGV[1]; }' "$BS"
BS: [ \ ]
ARG1: [ \ ]
```

この動作については [POSIX awk](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/awk.html) の所に書いてあるのですが断片すぎかつ awk の構文ぐらい知ってるよな的な感じの文章なので少し読むのが大変です。

> -v  assignment
> 
> assignment
> ...
> The characters following the <equals-sign> shall be interpreted as if they appeared in the awk program preceded and followed by a double-quote ( '"') character, as a STRING token (see Grammar), except that if the last character is an unescaped <backslash>, it shall be interpreted as a literal <backslash> rather than as the first character of the sequence "\"". The variable shall be assigned the value of that STRING token and, if appropriate, shall be considered a numeric string (see Expressions in awk), the variable shall also be assigned its numeric value. Each such variable assignment shall occur just prior to the processing of the following file, if any.

要するに、awk の `-v` オプションというのは awk の変数に直接格納するものではなく awk コードに埋め込まれる文字列のような処理を行うということのようです。つまり次のようなことです。

```sh
$ awk -v bs='[\n]' 'BEGIN{ print bs }'
[
]

↑は↓とだいたい同じ

# awk のダブルクォートはバッククォートを使った C 言語風のエスケープシーケンス（\n 等）を解釈する
$ awk 'BEGIN { bs="[\n]" } BEGIN{ print bs }'
[
]

# 補足 print が \n を解釈してるのでは？と疑う人のために
# bs 変数の中にすでに改行が入ってるので printf でそのまま出力したとしても改行が含まれています
$ awk 'BEGIN { bs="[\n]" } BEGIN{ printf "%s", bs; print }'
[
]
```

"だいたい"とわざわざ言ってるのは、少し例外処理があるからで `-v` オプションの終わりが `\` で終わる場合は、`BEGIN { bs="\" }` ではなくリテラルの `\` として扱われると書いてあるからです。各実装によって次のような違いがありました。

```console
$ gawk -v bs='a\' 'BEGIN{print bs "" }'
a

$ mawk -v bs='a\' 'BEGIN{print bs "" }'
a\

$ original-awk -v bs='a\' 'BEGIN{print bs "" }'
a\
```

「リテラルの `\` として扱われる」に解釈の幅があるような気がしますが、この点に関しては gawk は POSIX に準拠してないということでしょうか？ 単に後続の `"` を食ってシンタックスエラーにならない（場合によっては脆弱性にはならない）という意味なだけかもしれません。もっとも普通は値の最後を `\` で終わらたりはしなのでこの違いが処理に大きく影響することはないでしょう。ちなみに `--posix` や `--traditional` オプションを付けても同じでした。

### 2. 正規表現定数を解釈する (gawk のみ)

gawk 4.2.0 (2017-10) では強い型付き正規表現定数 (Strongly Typed Regexp Constants) と呼ばれる正規表現定数を変数に代入する構文が追加されています。この構文も `-v` オプションで使用することができます。正規表現定数は正規表現を `@/` と `/` で括った `@/正規表現/` という構文で指定します。

```sh
# gawk 4.2.0 以上では正規表現定数として扱われる
$ gawk -v re="@/abc/" 'BEGIN { print re }'
abc

# 他の awk 実装は対応していない
$ mawk -v re="@/abc/" 'BEGIN { print re }'
@/abc/
```

## 回避方法

回避方法は大きく `-v` オプションを使わない方法と使う方法があり使わない方法はいずれもエスケープは不要です。`-v` オプションを使う場合はエスケープ処理を行うことで安全にデータを渡すことができます。なおいろんな方法がありますが、これらの使い分けについては注意が必要なので[重要](#重要-なぜ--v-はこのような設計なのか)も参照してください。

### 1. 標準入力またはファイルでデータを渡す

実際の所、渡す値が「データ」であるならば標準入力またはファイルで渡すのが一番正攻法でしょう。

```sh
var='@/[ \ ]/'

# echo だとシェルによってはバックスラッシュを解釈することがあるので
# バックスラッシュが入っているような文字列の確認には printf を使う必要がある
printf '%s\n' "$var" | awk '{ print $0 }'
```

ファイルを使う場合

```sh
var='@/[ \ ]/'
printf '%s\n' "$var" > /tmp/data.txt
awk '{ print $0 }' /tmp/data.txt
```

### 2. 環境変数を使う

データの受け渡しに環境変数を使うのは役割として少しおかしいですが、それでも使うことはできますしかなり簡単に実装することができます。

```sh
export var='@/[ \ ]/'
awk 'BEGIN { print ENVIRON["var"] }'
```

### 3. 引数を使う

awk の引数を（ファイルではなく）ただの値として使うこともできます。エスケープシーケンスを解釈するのは `-v` オプションの仕様なので引数には関係ありません。

```sh
var='@/[ \ ]/'
awk 'BEGIN { print ARGV[1] }' "$var"
```

ただし同時に標準入力（またはファイル）からデータを読み込む場合は注意が必要です。引数は本来ファイル名として扱われるので何かしらの方法で、引数を取り除かなければいけません。

```sh
var='@/[ \ ]/'
echo "data" | awk 'BEGIN { print ARGV[1] } { print $0 }' "$var"
# "$var" をファイル名として扱おうとするので次のようなエラーがでる
# awk: cmd. line:1: fatal: cannot open file `@/[ \ ]/' for reading: No such file or directory
```

### 4. 呼び出し側（シェルスクリプト）でエスケープ処理を行う

`-v` オプションで任意の値を渡す時は、呼び出し側でエスケープ処理を行います。呼び出し側の多くはシェルスクリプトであることが多いので、ここではシェルスクリプトでの実装方法を紹介します。エスケープ処理自体は他の言語でも同じなのでシェルスクリプト版と同様の処理を実装すればよいでしょう

考え方自体は簡単で、エスケープシーケンスを解釈してしまう問題に対してはバックスラッシュ自体を（バックスラッシュで）エスケープするだけです。つまりバックスラッシュを二重にするだけで回避できます。大抵の言語では置換関数を一つ呼び出すだけで実装できるでしょう。正規表現定数を解釈してしまう問題に対しては最初の `@` をエスケープシーケンス `\100` に置き換えるのが一番簡単な方法でしょう。

```sh
var='@/[ \ ]/'          # awk に渡したい文字列
awkv_escape var "$var"  # awk の -v オプションのためにエスケープを行う。実装は下記参照
printf '%s\n' "$var"    # => \100/[ \\ ]/ （@ が \100 に、\ が \\ にそれぞれエスケープされる）

# awk が \100 と \\ のエスケープシーケンスの解釈を行って元の値に戻してから var 変数に代入する
awk -v var="$var" 'BEGIN{ print var }' # => @/[ \ ]/
```

#### 4.1 bash、ksh、mksh、zsh、yash、BusyBox ash 用

このパラメータ置換（全置換）を用いてエスケープする方法です。外部コマンドの呼び出しを行わないのでもっとも高速な実装です。ただしPOSIX 準拠に近いシェルである dash（Ubuntu / Debian の `/bin/sh`） 等では動作しないので注意してください。bash、ksh、mksh、zsh、yash、BusyBox ash (>=1.15.0? - 2009-08-23) で動作します。

```sh
awkv_escape() {
  set -- "$1" "${2//\\/\\\\}"
  case $2 in (@*) set -- "$1" "\\100${2#?}"; esac
  eval "$1=\$2"
}
```

`eval` が嫌いな方は `eval` の代わりに参照変数（`typeset -n`）や `printf -v VAR` を使って実装すると良いです。ただしシェルに依存するので注意してください。

#### 4.2 POSIX 準拠シェル 用

すべての POSIX シェルで動作する方法です。パラメータ置換を使った方法より遅いですが文字列が短ければ（数 KB レベルにならな限り）十分速いです。

```sh
awkv_escape() {
  set -- "$1" "$2\\" ""
  while [ "$2" ]; do
    set -- "$1" "${2#*\\}" "$3${2%%\\*}\\\\"
  done
  set -- "$1" "${3%??}"
  case $2 in (@*) set -- "$1" "\\100${2#?}"; esac
  eval "$1=\$2"
}
```

#### 4.3 sed コマンド版

POSIX 準拠で行数は短いですが、外部コマンド呼び出しとなるので遅いです。おすすめはしませんがどうしても短いほうが良いという方はどうぞ。

```sh
awkv_escape() {
  set -- "$1" "$(printf '%s_' "$2" | sed 's/\\/\\\\/g; s/^@/\\100')"
  eval "$1=\${2%_}" # 末尾の改行が消えるため _ をつけて処理してから削除する
}
```

#### 4.4 正規表現定数処理の別解 

正規表現定数が使えるのは gawk だけで gawk には `--posix` (POSIX 準拠) / `--traditional`（nawk 相当）オプションがあり、これを使うことで正規表現定数機能を無効にすることができます。これらを使うと上記のコードから `@/.../` に関する処理を減らすことができます。（1 〜 2 行減らせるぐらいの効果しかありませんが。）

## 重要 なぜ `-v` はこのような設計なのか？

この記事では awk の罠という形で書きましたが、こういう設計になってるのは理由あるはずです。おそらく本来は `-v` オプションは任意の値を渡すためのものとして設計されてないのだと思います。そうでなければ GNU awk が正規表現定数などといういかにも問題が発生しそうな機能を追加するはずがありません。ということは `-v` オプションでデータを渡すというのは、そもそも awk の使い方として間違っていると考えられます。

### `-v` オプション・環境変数・引数の使い分け

原則の話をすると awk においてデータとは標準入力またはファイルから入力するものです。awk はそのデータを処理するフィルタコマンドです。そのような使い方ではなく例えば `BEGIN` だけで処理が完結するようなものは（私は禁止するつもりはありませんが） awk の本来の用途ではありません。awk コマンドの引数は（必要な場合は）ファイル名の一覧を渡すのが主な用途です。これは [POSIX awk](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/awk.html) のドキュメントを見てもその事がわかります。重要な部分を簡略化して引用します。

> ```
> SYNOPSIS
>   awk [-F sepstring] [-v assignment]... program [argument...]
>   awk [-F sepstring] -f progfile [-f progfile]... [-v assignment]... [argument...]
>
> OPTIONS
>   -F  sepstring
>   -f  progfile
>   -v  assignment
>
> OPERANDS
>   program
>   argument
>     file
>     assignment
> ```

注目すべきは OPERANDS (オプション以外の引数) の使い方です。program と argument (file / assignment) としっかり書かれています。つまり program と assignment 以外は file なわけです。実際には引数でオプションやファイル以外の任意のデータを渡すことができますがそれは想定してない使い方なのでしょう。また環境変数はその名の通りで環境の情報を暗黙的に渡すものであるためデータを渡す候補から外れます。

この原則に置いて awk スクリプトの動作を制御するにはどうすればよいでしょうか？言い換えると awk スクリプトに対してコマンドで言う `-` で始まるオプションのようなものを渡したい場合にどうするか？という話です。おそらくそれが `-v` オプションの役目なのでしょう。つまり任意のデータを渡すのではなく（シェルスクリプトから）決まった値を渡すだけなのでエスケープ処理は不要なのです。

とはいえ `-v` オプションを使用して任意のデータを渡したい場合もあると思います。その場合にはこの記事のようなエスケープ処理が必要になってきます。

## さいごに

実はこの問題に関する記事は前に「[awkをプログラミング言語として使う時の技術](https://qiita.com/ko1nksm/items/16ca4f4546c1ea8e97d7)」で書いているのですが、別記事を書いている途中に GNU awk 4.2 以上では正規表現定数のエスケープも必要ということに気づいたので、加筆して新たに書き直したものです。前の記事は awk をプログラミング言語として使うことを前提としてるので、こちらの記事のほうがより一般的になってるのではないかと思います。

`awkv_escape` 関数もそのまま使えるようにしたので必要な方はそのまま or 適当に書き換えて使用してください。単純なコードですしライセンスを主張するつもりはありません（[CC0](https://creativecommons.org/publicdomain/zero/1.0/)）。