cronのコマンド記法を理解する

2021/03/07に公開

crontab における % のエスケープ

crontabの6つ目のフィールドにコマンドと引数を記載すると、指定したタイミングでそれが実行されるが、例えば

* * * * * echo $(date +%T)

のように書くとうまく動かない。実際やってみると以下のエラーになる。

Subject: Cron <vagrant@vagrant> echo $(date +

/bin/sh: 1: Syntax error: end of file unexpected (expecting ")")

エラーを解消するには、% を \ でエスケープすればいい。

* * * * * echo $(date +\%T)

こう書いた場合は

Subject: Cron <vagrant@vagrant> echo $(date +%T)

13:20:01

となり、/bin/sh -c 'echo $(date +%T)' と同じ動作になる。

問題と解決方法は知っていたのだが、ふと、

  • 何故 % はエスケープしないといけないのだろう?
  • エスケープしなかった場合に何故そのようなエラーになるのだろう?

という疑問について深く考えぬまま過ごしていたことに気づいたので調べてみた。

マニュアルより

crontab(5) より

  • 改行文字か最初の%までがコマンドとして /bin/sh によって実行される
  • \でエスケープされていない%は改行に置き換えられる
  • 最初の%より後ろの全てのデータは標準入力としてコマンドに送られる

とある。ふむふむ。

試してみる

コマンドとしてcatを指定すれば標準入力の内容が標準出力に出るので

$ crontab -l
* * * * * cat%1st line%2nd line%3rd line

として実行結果を見る。

Subject: Cron <vagrant@vagrant> cat

1st line
2nd line
3rd line

確かに一つ目の%までをコマンドとして、それより後の部分で%を改行に置き換えたものが実行されているようだ。

次に標準入力の内容を詳しく見るために実行するコマンドをodにする。

$ crontab -l
* * * * * od -c -t x1%1st line%2nd line%3rd line

実行結果は

Subject: Cron <vagrant@vagrant> od -c -t x1

0000000   1   s   t       l   i   n   e  \n   2   n   d       l   i   n
         31  73  74  20  6c  69  6e  65  0a  32  6e  64  20  6c  69  6e
0000020   e  \n   3   r   d       l   i   n   e  \n
         65  0a  33  72  64  20  6c  69  6e  65  0a
0000033

となり、確かに最初の%の直後からが標準入力として扱われていて、以降の%は改行(\n)に置き換わっていること、末尾にも改行(\n)が入っていることがわかる。

エラーを再現してみる

最初に書いたエラーになるケースで実行されるコマンドは echo $(date + で、標準入力として T)\n が与えられているはず。やってみると

$ /bin/sh -c 'echo $(date +' < <(printf 'T)\n')
/bin/sh: 1: Syntax error: end of file unexpected (expecting ")")

として再現できる。

実装も確認

手元の Ubuntu 18.04 に入ってる cron は Vixie cron だった。同じものかどうかは確認してしてないが

https://github.com/svagner/vixie-cron/

にソースコードがあったのでそれを読んでみた。

コマンド

最初の%までをパースする処理はdo_command.c#L216

for (input_data = p = e->cmd; (ch = *input_data);
    input_data++, p++) {
	if (p != input_data)
		*p = ch;
	if (escaped) {
		if (ch == '%' || ch == '\\')
			*--p = ch;
		escaped = FALSE;
		continue;
	}
	if (ch == '\\') {
		escaped = TRUE;
		continue;
	}
	if (ch == '%') {
		*input_data++ = '\0';
		break;
	}
}
*p = '\0';

入力データを以下の手順でin-placeで処理している。

  • 一文字取り出し、ポインタ p の指す値をその文字で上書き (ポインタが同一ならスキップ)
  • escaped フラグが立っていて、% か \ が来たら手前の \ は捨てる(出力先のポインタを一つ戻して上書き)。それ以外なら通常通り処理し、次の文字へ。
  • escaped フラグが立っていない状態で \ が来たら escaped フラグを立てて次の文字へ
  • escaped フラグが立っていない状態で % が来たらループを抜ける
  • ヌル文字(\0)で終端

つまり、エスケープの仕方は

  • % -> %
  • \ -> \ (ただし、末尾以外でかつ次の文字が \ と % のいずれでもなければエスケープ不要)

であり、\ の表現方法が複数ある。

標準入力

最初の%より後をパースする処理はdo_command.c#L433

/* translation:
 *      \% -> %
 *      %  -> \n
 *      \x -> \x        for all x != %
 */
while ((ch = *input_data++) != '\0') {
	if (escaped) {
		if (ch != '%')
			putc('\\', out);
	} else {
		if (ch == '%')
			ch = '\n';
	}

	if (!(escaped = (ch == '\\'))) {
		putc(ch, out);
		need_newline = (ch != '\n');
	}
}
if (escaped)
	putc('\\', out);
if (need_newline)
	putc('\n', out);

コマンドの末尾に%があればその次の文字以降を

  • escaped フラグが立っていて % 以外が来たら \ を出力
  • escaped フラグが立ってなくて % が来たら改行(\n)に置き換え
  • \ かどうかで escaped フラグを設定
  • \ 以外なら、その文字を出力し、改行以外なら need_newline フラグを立てる

と処理し、最後まで行ったら

  • escaped フラグが立っていれば \ を出力
  • need_newline フラグが立っていれば改行(\n)を出力

して終わる。つまり、末尾に必ず改行が出力されること(末尾の%の有無が結果に影響しない。末尾に改行を含まないものは表現不能)を除けばエスケープは一意に定まる。

標準入力の使い道

引数ではなく標準入力でコマンドの動作を制御する場合に使うことはできそうだが、

  • %が改行に変換されるという記法が特殊
  • 末尾の改行無しが表現できない

というデメリットがあり、一方で、意図せずコマンド引数の部分で % のエスケープを忘れる事故は起こりがちなので、エスケープされてない % を検出するような linter をかけて、禁じちゃうというのも手かなと思った。

Discussion