cmd.exe のコマンドラインの仕様を解析してみた

cmd.exe の引数の扱いがあまりにもカオスだったのでちょっと頑張って調べてみた。
本来ならここは公式の資料に当たるのが正しいアプローチだと思うけど、どうしても公式の資料が見つからなかったので、色々試して推測してみることに。
断片的な資料は見付けたけど、完全じゃない。一応URL貼っておく。Windows Server 2003 のヘルプだけど、恐らくそんなに変わらないと思う。
コマンド シェルの概要
コマンド リダイレクト演算子を使用する

なので、以下で述べる内容は間違いを含む可能性があります。というか正確さは一切保証されないのであしからず。

検証方法

以下のような引数をただ表示するだけの簡単な C のプログラムを用意した。仮に args.exe とでもしておく。

#include <stdio.h>

int main(int argc, char const* argv[]) {
  int i;
  for (i = 1; i < argc; i++) {
    printf("%s\n", argv[i]);
  }
  return 0;
}

こいつを使って 1 つずつ引数を与えて実行しては結果を見てふむふむした。

結論

途中の紆余曲折も書こうかと思ったけど誰も興味ないと思うのでいきなり結論から。私なりに辿りついた結論なので、本当にこうなっているのかどうかは知らない。

コマンドラインの解釈は 4 つのフェーズに分かれる。

1. 環境変数の展開

まず最初に、環境変数が展開される。

  • 環境変数名を % で囲むと展開される。
    • 例: %PATH%
  • 環境変数が存在しない場合は % の表記がそのまま残る。空文字列になったりはしない。
  • 環境変数名は大文字と小文字は区別しない。
  • 環境変数名はこの段階で判断されるので、最終的に消える文字を中に入れておくことで無理矢理展開を防ぐことができる。
    • 例: %^PATH% (^ は フェーズ 2 で削除される)
2. コマンドラインの解釈

次に、コマンドライン全体の解釈をする。全体はパイプやリダイレクトなどもあるため、単一のコマンドとは限らない。
以下のルールに従って解釈される。

  • ^ は、^ 自身を取り除いて次の文字を通常の文字として扱う。
    • つまりエスケープ文字。& | < > ( ) % " ^ などの特殊文字を通常の文字として扱うようにする。
    • ^ 自身を表すにはエスケープして ^^ とする。
    • 通常の文字に付けた場合は単に ^ が取り除かれる。
  • ダブルクォートで囲まれた部分では特殊文字は無効になる。
    • ダブルクォートは閉じられている必要はない。
    • 複数の組みができることもある。
      • 別の見方をすると、ダブルクォートは特殊文字を解釈するかどうかのON/OFFを切り替えるスイッチと考えることもできる。
    • ダブルクォートの開始の " はその直前ではまだ特殊文字は有効なので ^ でエスケープできるが、終了の " は特殊文字は無効化されているので ^ も無効化されておりエスケープできない。
3. 特殊文字の処理

コマンドライン特殊文字を使うことで複数のコマンドを含めることができる。以下のもので 2. でエスケープされていなかったものは特殊文字として処理される。

&
複数のコマンドを区切る。1つ目のコマンドが実行された後に2つ目のコマンドが実行される。
&&
1つ目のコマンドを実行し、成功した場合(終了コードが 0 の場合)のみ2つ目のコマンドを実行する。
||
1つ目のコマンドを実行し、失敗した場合のみ2つ目のコマンドを実行する。
|
1つ目のコマンドの出力を2つ目のコマンドの入力として実行する。
<
コマンドの入力をファイルから読み取る。
>
コマンドの出力をファイルへ書き出す。
>>
コマンドの出力をファイルへ追記する。
( )
複数のコマンドをまとめる。入出力がまとめてできる。入れ子が可能。
  • ( はコマンドの先頭に当たる部分にある場合のみ解釈される。
    • コマンドの先頭とはつまり、コマンドラインの先頭か、& && | || の直後。
    • それ以外の場所では引数の一部の通常文字として扱われる。
    • 入れ子が可能なので、閉じ括弧 ) は対応するものが探される。もちろんエスケープされた ) は無視する。
    • 閉じ括弧が見付からない場合は続けて入力を促される。
  • 有り得ない場所に特殊文字が来た場合は、「コマンドの構文が誤っています。」や、「{特殊文字} の使い方が誤っています。」({特殊文字}は具体的な特殊文字文字)と言ったエラーになる。
    • 多すぎる ) や コマンドの先頭の特殊文字、入出力先のないリダイレクト等。
4. 個々のコマンドの解釈

コマンドは以下のルールに従って複数の文字列に分割される。最初の文字列がコマンド、残りが引数になる。

追記:コンパイラによって挙動が微妙に違うということが判明。これはどのレイヤーが処理しているのだろう…。
二重のダブルクォートについて、cl.exe と gcc.exe でコンパイルした場合で扱いが違ったので追記。

  • 基本的にコマンドは連続する空白によって分割される。
  • ダブルクォートで囲まれた部分とその前後は連続した文字列とみなされる。
    • この際、囲っているダブルクォートは削除される。
    • ダブルクォートは閉じられている必要はない。
    • ダブルクォートの前後は、というのは、ダブルクォートによる囲いは任意の位置から開始できることを意味する。
      • 例: abc" de"f → 「abc def」
  • ダブルクォート中で二重のダブルクォート("")が見つかった場合、
    • cl.exe でコンパイルした場合は " の文字になる。
      • 例: "abc"" def → 「abc" def」
      • 例: "print(""hello"")" → 「print("hello")」
    • gcc.exe でコンパイルした場合はクォートを閉じつつ " の文字になる。
      • 例: "abc"" def → 「abc"」「def」
      • 例: "print(""hello"")" → 「print("hello)」 (1番目の "" でクォートが閉じられ、2番目の "" は開いてすぐ閉じるクォートになるため消える)
  • \ は、
    • ダブルクォートの内外を問わず、" の前に \ を前置すると " 自身を表現できる。
    • \\" とすると \" の \ をエスケープしたことになり、 \" になる。この " は特殊文字である。
    • \\\" とすると \\ + \" になり、 \" になる。この " は " 文字自身である。
    • 以下、\ が増える度に上記のようなエスケープを繰り返す。
      • \\\\" → \\" (" は特殊文字)
      • \\\\\" → \\" (" は通常文字)
    • 上記以外の場所にある \、つまり後に " が続かない \ は、いくつ重なっていてもその文字自身になる。
      • 例: \\server\path → \\server\path (そのまま)

注意点

ワイルドカードはない

よく * や ? でパターンにマッチしたファイルを引数に展開することがあるが、cmd.exe にはこういった機能は一切ない。
もし動いているように見えたとしたら、それは各々のコマンドが中で独自に解釈している。
シェルがショボいとその上で動くプログラムが苦労する羽目になる。悲しき世の常。

独自の解釈をするプログラムもある

プログラムの中には実行するコマンドの文字列を読み取って引数を独自に解釈するものもある。
その場合、4. の展開前の文字列をプログラムが直接解釈する。よって、4. で述べたルールは一切適用されない。
例えば ruby.exe などがそうである。ruby.exe は引数のクォートにシングルクォートが使える。

>ruby -e 'puts "Hello, world!"'
Hello, world!

逆に cmd.exe 的にはうまくいくものも動かなかったりする。

>args -e puts" "'Hello," "world!'
-e
puts 'Hello, world!'

>ruby -e puts" "'Hello," "world!'
-e:1: syntax error, unexpected tFID, expecting $end

独自の解釈するのもいいけど最低限の互換性は持たせろよ!

引数をエスケープするには

以上の結論から、任意の文字列を引数として与えたい場合は以下のようにする。

  1. 特殊文字を全て ^ でエスケープする。
    • & | < > ( ) ^ " %
    • % はエスケープしておけば終わりの % にあたる ^ が環境変数の展開を防いでくれる。
  2. " の前にある連続する \ を同数の \ でエスケープする。
  3. 全ての " を \ でエスケープする。
    • " はすでに ^ でエスケープされているが、これを更にエスケープする。つまり \^" になる。
  4. 全体を ^" 〜 ^" で囲む。
    • 単純に "" で囲むと ^ が無効になってしまう。

例:
以下の文字列を1つの引数として与えたいとすると…

puts "Hello, world"

以下のようになる。

^"puts \^"Hello, world\^"^"

ちなみに独自の解釈をするプログラムは、まともな実装なら動いてくれると思うが解釈のしようなんてどうにでもなるので正直なところ完全な対応は不可能。
例えば cmd.exe に組み込みの echo コマンドはダブルクォートなどを一切解釈せずにそのまま出力する。

所感

自分で言うのもなんだけど、結構いい線行ってると思う。
コマンドプロンプトがカオスなのは、別のレイヤーで同じ文字を別の意味で使ってるせいじゃないかと思った。そう、" (ダブルクォート)だ。
フェーズ 2 とフェーズ 4 でエスケープ方法が違う。これが混乱の元ではないかなと。と言うか設計ミスだろこれ。
この辺りを設計した当時の担当者は一体何を考えていたのか。はっきり言って残念すぎる。