🤖

cmd.exe で記号を扱う

2022/02/13に公開

Windows コマンド・インタプリタ

Windows のコマンドラインで正規表現を書こうとしたのですが、思ったのと逆の結果が出てうまくできません。
どうやら記号が正しくエスケープされていないのが原因とまではわかったものの、どうすれば cmd.exe で正しくエスケープできるのかがわからなかったので試行錯誤してまとめました。
Windows Server 2019 で検証しています。

まとめ

  • 一部の記号は実行対象のコマンドごとにエスケープする。
  • 内部コマンドにパイプ (|) を使うときはキャレット (^) を多重エスケープする。
  • 環境変数の遅延展開を使うときはキャレット (^) を二重引用符 (") の中までエスケープする。
記号 DOS互換コマンド バッチファイル C/C++ランタイム 備考
空白 " " ※1 " " ※1 " " 区切り
! ^^! ^^^^^^^^^^! "^!" 遅延評価環境変数
" ^" ^" "\"" 特殊文字無効のトグルスイッチ
% ※2 ※2 ※2 環境変数
& ^& ^^^& ^& "&" 複数コマンド
' ※3 ※3 ※3
( - - - 複数コマンド
) - - - 複数コマンド
+ ※3 ※3 ※3
, "," ※1 "," ※1 ※3 区切り
; ";" ※1 ";" ※1 ※3 区切り
< ^< ^^^< ^< "<" リダイレクト
= "=" ※1 "=" ※1 ※3 区切り
> ^> ^^^> ^> ">" リダイレクト
@ - - - コマンドの非表示
[ ※3 ※3 ※3
\ - - \ "\" "\\" ※4 パス区切り
] ※3 ※3 ※3
^ ^^ ^^^^ ※5 ^^^^ ^^^^^^^^ ※5 ^^ "^" "^^" ※5 エスケープ
` ※3 ※3 ※3
{ ※3 ※3 ※3
| ^| ^^^| ^| "|" パイプ
} ※3 ※3 ※3
~ ※3 ※3 ※3

※1: コマンドが " の除去に対応していれば、パラメータを囲むことで可能。
※2: 環境変数が一致しなければそのままでよい。一致する可能性がある場合は後述。
※3: マニュアルにファイル・ディレクトリ名は引用符が必要と書いてあるが、無くても動く。
※4: \" に先行する場合のみエスケープが必要。
※5: 遅延評価環境変数が指定されている場合は追加のエスケープが必要。

コマンドライン評価

コマンドラインから Windows のプログラムが起動してパラメータを受け取るまでには次の段階があります。

  1. 環境変数による文字列置換
  2. 記号のエスケープ
  3. 複数コマンドの分離
  4. リダイレクトの準備
  5. 環境変数の遅延展開
  6. コマンドラインから %0 の切り出し
  7. プログラム、コマンド・インタプリタの起動
  8. ランタイムによるパラメータ化

環境変数による文字列置換

コマンドラインに二つ以上のパーセント (%) があれば、一つ目と二つ目に囲まれた範囲の文字列を切り出して、一致する環境変数の名前があれば文字列置換を行います。
環境変数名の大文字・小文字の差は無視します。
展開された環境変数の値に % を含む文字列が設定されていても、重ねて展開されることはありません。
環境変数と一致しなければ元の文字列をそのまま残します。
展開に成功すれば次は三つ目と四つ目で、名前が一致しなければ二つ目と三つ目で、と同様に繰り返します。

バッチファイルの場合に限り、%0%9 %* が引数で展開されます。これは環境変数の展開より先に行われます。%%% 一文字に展開されて、どれにも該当しない % は削除されます。

バッチファイル以外の % を文字でエスケープすることはできません。%PATH^% のように後で消去されるエスケープ文字を挟んで環境変数名の一致を阻むか、期待する値が入った環境変数を作成して組み込むことができます。

記号のエスケープ

キャレット (^) はエスケープ文字です。^ が削除される代わりに、その後に続く文字は特殊文字であっても通常の文字として扱います。行末にある場合、コマンドラインを継続することができます。
二重引用符 (") で囲まれた文字列があれば、含まれる特殊文字は通常の文字として扱います。二重引用符の中ではエスケープ文字も無効になるため、二重引用符を含めることはできません。
ただし、% による環境変数の展開は先に行われて抑止できません。
二重引用符が閉じていなくてもエラーにはなりません。その場合はコマンドライン終端までが有効になります。

複数コマンドの分離

エスケープされずに残った & | && || ( ) は複数コマンドの区切り文字として解釈されます。
また、アットマーク (@) は次の閉じ括弧 ) またはコマンドライン終端までを ( ) で囲んだのと同じ作用があります。本来はバッチファイルでエコーバックを無効化するための構文です。
結合の優先度は ( ) > @ > | > & > && > || になります。

リダイレクトの準備

空白文字や = ; , やエスケープされずに残った < > はコマンドラインの区切り文字として解釈されます。
区切り文字の直前に切れ目を入れて複数ブロックに分割したとき、リダイレクト (< >) から始まるブロックはプログラムに渡すコマンドラインからまとめて削除されます。
また、< > の直前が数字一文字 (09) のブロックの場合、その数字もリダイレクトの一部として削除されます。
リダイレクト中に指定されたファイル名の " は削除されます。

環境変数の遅延展開

環境変数の遅延展開が有効になっており、コマンドラインに二つ以上の感嘆符 (!) があれば % と同様に環境変数で展開されます。
なぜか、環境変数の遅延展開が有効な場合、! が一文字でもあるとこの時点でも ^ が削除されます。" に囲まれていても無効化することができません。

バッチファイルはコマンドラインと一致しない場合の挙動が異なります。一つ目と二つ目に囲まれた範囲の文字列を切り出すまでは同じですが、一致しなければ両端の ! を含む文字列を削除して三つ目と四つめに囲まれた文字列に進みます。エスケープされておらず、どれにも該当しない ! は削除されます。

!^ を前置することでエスケープできます。ただし二度評価されるため ^^ と指定しなければいけません。また、期待する値が入った環境変数を作成して組み込み、遅延展開することでエスケープできます。

コマンドラインから %0 の切り出し

空白文字や = ; , は引き続きコマンドラインの区切り文字として解釈されます。
最初のブロックから取り出し、/ の直前までかブロック終端までが実行されるコマンドの指定となります。

内部コマンドを指定する場合に限り判定が追加になり、続く文字が + . : / \ < > [ ] であってもコマンドとして受け付けられます。

プログラム、コマンド・インタプリタの起動

ここからプログラムが起動します。
パイプ (|) で結合されたコマンド・パイプラインの一つが内部コマンドか複数コマンドを含む場合、別のコマンド・インタプリタに引き渡して実行されます。
C:\Windows\system32\cmd.exe /S /D /c" command"
呼び出し先のコマンド・インタプリタでも環境変数の展開やエスケープ文字の処理が実行されるため、呼び出し回数を把握して複数段のエスケープを施さなければ正しい結果になりません。

| の直後に @ がある場合は面白い現象が起きます。@ 以降がひとまとまりとされて、本来パイプラインに入らない & で繋がれたコマンドも一括してコマンド・インタプリタに渡されます。新しいコマンド・インタプリタではまた記号が展開されるため、広範囲で多重エスケープが必要となる場合があります。

ランタイムによるパラメータ化

コマンドラインは区切りなしの文字列として渡され、パラメータとして分割するのは各プログラムの責任となります。
内部コマンドの ECHO, TITLE はコマンドに続く空白なども全て含めた文字列で処理します。
(ただし、ECHO は先頭の記号を自主的に削除しているようです)
バッチファイルのパラメータは空白文字や = ; , による区切りが適用されます。なお、バッチファイルの中では行ごとにコマンドラインと同等の評価が行われるため、%1 などで参照されるパラメータはこの時点でもエスケープ文字が消費されます。
C/C++ランタイムを使ったプログラムは空白文字による区切りが適用されます。" に囲まれた箇所を両端を取り除いた上で一つのパラメータとして扱い、中に \" があれば " として扱うなどの機能を実装しています。

二重引用符のエスケープ

殆どの記号はエスケープ回数にさえ注意すれば ^ を付け足すか " で囲むだけです。
最も悩まされるのは、C/C++ランタイムを使ったプログラムに " 自身を指定したい場合のエスケープではないかと思います。
C/C++ランタイムは \" というエスケープを定義していて " で囲まれた中で自身を表すために使います。コマンド・インタプリタはその意図を理解できず \" で特殊記号無効のトグルスイッチを切り替えてしまうため、C/C++ランタイムは引用符の内側でコマンド・インタプリタでは引用符の外側という状態の相違が起きます。
例えば取得したいパラメータが \^ なら "\^" と書けますが、\"^ に変えるには "\\\"^^" としなければいけません。一文字挿入しようとするだけで広範囲に書き換えが発生します。

エスケープの追加

書き換えのルールは

  1. "\" にエスケープする。
  2. \" の直前に連続する \\\ にエスケープする。
  3. \" から次の \" までの記号を ^ でエスケープする。

最後の項を人に状態管理させるのは現実的でないため、sed スクリプトでエスケープ文字を付与します。
\" を含むパラメータ "\\\"^" から "\\\"^^^" が得られます。

Bash用
sed -e 's/"\([^"]*\)"/\tA\1\tB/g;s/^/\tB/;:a;s/\(\tB[^\t]*\)\([&|<>%^"]\)/\1\tC\2/g;ta;s/^\tB//;s/\tC/^/g;s/\t./\"/g'
Cmd用
sed -e "s/\"\([^^\"]*\)\"/\tA\1\tB/g;s/^^/\tB/;:a;s/\(\tB[^^\t]*\)\([^&^|^<^>^%^^\"]\)/\1\tC\2/g;ta;s/^\tB//;s/\tC/^/g;s/\t./\\\"/g^"

この方法には欠点があり、最後にコマンド・インタプリタの引用符が開いたままだと後続する別のパラメータやコマンドの挙動も影響を受けます。

二重引用符の無効化

" による特殊記号無効のトグルスイッチを無効化します。
全ての特殊記号に ^ を前置します。
上記の例は ^"\\\^"^^^" と書けます。

環境変数の遅延評価

環境変数の遅延評価は記号のエスケープより後に実行されるため、" を差し込んでもトグルスイッチに影響を与えません。
環境変数を作成して組み込み、遅延展開することでエスケープできます。
なお、! を使った環境変数の遅延評価は既定で無効になっています。

SET ^^D=\"

遅延評価があると引用符に囲まれている ^ も削除されることを考慮して、前述のパラメータは "\\!^D!^^" と書けます。

余談

思っていたのと逆の結果が出た原因は、\" の後ろの [^0-9] にエスケープが適用されて [0-9] になっていたからでした。

参考

Cmd | Microsoft Docs
Using command redirection operators | Microsoft Docs
Windows CMD Command Syntax - SS64.com

Discussion