鷲ノ巣

C# とか PowerShell とか。当ブログの記事は(特に公開直後は)頻繁に改定される場合があることをご了承ください。

Directory.EnumerateFiles メソッドの予期しない挙動について

C# で、あるディレクトリ内のファイルを検索するために、Directory.EnumerateFiles メソッドを使うことができます。
このメソッド、なかなか罠があります。
なお、似たような機能を持つものとして、Directory.GetFiles メソッドというのもあります。
機能的にはほとんど同じで、違いは、GetFiles メソッドはすべてのファイルを列挙し終わってからまとめて返す一方、EnumerateFiles メソッドは列挙しながら随時返してくれるという点です。
EnumerateFiles メソッドの方がパフォーマンスが良いですが、本記事の内容的には、どちらでも同じです。

では本題。

EnumerateFiles のおかしな挙動

EnumerateFiles メソッドのドキュメントには、こんなことが書かれています(機械翻訳がおかしい場所は修正しています)。

注意

.NET Frameworkのみ: searchPatternアスタリスク ワイルドカード文字を使用し、3 文字のファイル拡張子("*.txt" など)を指定すると、このメソッドは、指定した拡張子で始まる拡張子を持つファイルも返します。たとえば、検索パターン "*.xls" は、"book.xls" と "book.xlsx" の両方を返します。この動作は、検索パターンでアスタリスクが使用され、指定されたファイル拡張子が正確に 3 文字である場合にのみ発生します。アスタリスクの代わりに疑問符のワイルドカード文字を使用する場合、このメソッドは、指定されたファイル拡張子と正確に一致するファイルのみを返します。次の表に、.NET Framework におけるこの異常を示します。

ディレクトリ内のファイル 検索パターン .NET 5+ の戻り値 .NET Framework の戻り値
file.ai、file.aif *.ai file.ai file.ai
book.xls、book.xlsx *.xls book.xls book.xls、book.xlsx
file.ai、file.aif ?.ai file.ai file.ai
book.xls、book.xlsx ?.xls book.xls book.xls

? ワイルドカードというのは何ぞやというのは、その上に書かれています。

ワイルドカード指定子 一致する
*(アスタリスク その位置の 0 個以上の文字
?(クエスチョンマーク その位置の 1 文字だけ

というわけで、.NET Framework の場合、*.txt のように、アスタリスク ワイルドカードが使われ、かつ、指定した拡張子が 3 文字ちょうどの場合、*.txtx のように、拡張子に余計な文字がついているファイル名も返されてしまうという仕様になっています。

検証

C# でコードを書いてもいいのですが、お手軽に PowerShell で試してみましょう。
Windows にプレインストールされている PowerShell 5.1 は .NET Framework 上で、最新の PowerShell 7 .NET 6 上で動くので、比較も簡単です。

github.com

まず、こういう状況を作ります。

PowerShell 5.1 に切り替えて(プロンプトが "PS5 > " になっている点に注目してください)EnumerateFiles を呼び出すと、確かに a.txtx も返されました!

PowerShell 7 では a.txt しか返されません。

余談

PowerShell で touch コマンドが使えるの?」と思ったあなた。これは WSL のコマンドです。WslInterop モジュールをインストールして設定すると使えるようになります。
www.powershellgallery.com
github.com

ドキュメントの嘘

ところで、上の説明をよく見ると、嘘が書いてありますね。
どうして、検索パターンが "?.ai" の時に、file.ai にマッチするのでしょうか。
? は1文字にしかマッチしないので、"?.ai" は、1 文字 + .ai というファイル名にしかマッチしません。

検証


PowerShell 5.1 でも 7 でも(つまり .NET Framework でも .NET 6 でも)"?.ai" は file.ai にはマッチしません。

プル リクエストとその嘘

ちなみに、このドキュメントには修正のプル リクエストが出ているんですけれども、半年くらいマージされずに放置されています。
あと、このプルリクにも嘘があります。
パターン "?ello.txt" が ello.txt と hello.txt にマッチするようなことが書いてありますけれども、hello.txt にしかマッチしません。

検証



? ワイルドカードの謎の挙動

そしてさらに、? ワイルドカードは謎の挙動をします。
たとえば、"??.txt" はどんなファイル名に一致するでしょうか。任意の 2 文字 + .txt にマッチしそうですよね。たとえば、aa.txt とか。実際、これはマッチします。
ところが、ファイル名部分が ? だけでできている場合、なぜか「2 文字以下の任意の文字」にマッチします。つまり、a.txt にもマッチします。
拡張子部分も同様で、"a.???" は a.tx にもマッチします。
ただ、? は「あってもなくてもいい」という意味ではないです。既に見たように、"?ello.txt" は ello.txt にはマッチしませんし、"?a.txt" は a.txt にはマッチしません。
でもなぜか "a.tx?" は a.tx にマッチします。

で、この挙動は .NET 6 でも起きます。
cmd.exe でも起きるので、Windows OS のレベルでの挙動だと思われます。

検証




cmd.exe は画像サイズが大きくなりますのですべてのパターンを載せてはいませんが、同じ結果になります。

ちなみに、PowerShell の ls コマンドでは起きません。ファイル名を検証しているのかもしれませんね。

Linux の ls でも、Windows API を使っていないので、当然起きません。

が、Linux 上の .NET プログラムでは起きます。なぜ。

NTFS 上のフォルダをマウントしているからとかは関係なく、WSL 内のローカル ディレクトリでも起きました。

短いファイル名の罠

GetFiles メソッドも、検索にマッチするファイルは変わらないのですが、こちらのページには固有の記述があります。それは「短いファイル名」に関するものです。

注意

このメソッドは、8.3 ファイル名形式と長いファイル名形式の両方を持つファイル名をチェックするため、"*1*.txt" のような検索パターンで予期しないファイル名が返される場合があります。 たとえば、検索パターン "*1*.txt" を使用すると、同等の 8.3 ファイル名形式が "LONGFI~1.TXT" であるため、"longfilename.txt" が返されます。

Windows では、(特に設定を変更しない限り)すべてのファイルが、MS-DOS に由来する短いファイル名を持ちます。いわゆる「8.3 形式」というやつで、ファイル名 8 文字 + 拡張子 3 文字からなるものです。
で、ファイル名が 8 文字を超えたり、拡張子が 3 文字を超えたりするファイルは、通常の名前と短い名前が別になります。
こういう場合、「ファイル名の先頭 6 文字 + ~ + 連番 + 拡張子の先頭 3 文字」という形式になります。
たとえば、「verylongfilename.txt」というファイルは、ファイル名が 8 文字を超えますので、「VERYLO~1.TXT」というファイル名も持ってしまいます。
「verylong.txtx」みたいなファイルも、拡張子が 3 文字を超えますので、「VERYLO~1.TXT」になってしまいます。
これらが同じディレクトリ内にあると、ファイル名の重複を避けるために連番がインクリメントされて「VERYLO~2.TXT」とかになります。
ファイルが削除された場合にどうなるのかとか、連番が 9 を超えたらどうなるのかとかも気になりますが、ここでは一旦置いておきましょう。

で、.NET Framework の場合、短いファイル名の方も検索対象になります。つまり、"*1.txt" が verylongfilename.txt にマッチしたりします。「ファイル名のどこにも "1" なんて含んでいないのに!」と混乱すること請け合いです。

EnumerateFiles メソッドのページには短いファイル名に関する記述はありませんが、EnumerateFiles メソッドでも同様にマッチします。

なお、注意書きには「.NET Framework のみ」とは書かれていませんが、.NET Core では短いファイル名は検索対象にならないため、このような事象は発生しません。

検証


短いファイル名は、cmd.exe で確認することができます。

実は、最初の「"*.txt" が a.txtx にもマッチしてしまう」という現象の正体はこいつです。a.txtx というファイル名は、拡張子が 3 文字を超えるため、A766A~1.TXT みたいな名前も持っていることになって、*.txt がマッチしてしまうというわけです。

短いファイル名は、fsutil コマンドで削除することができます。

こうすると、もう引っ掛かりません。

おまけ

C# Japan Discord 上で、haxe さんからご指摘頂きました。利便性を考えてのことだと思いますが、そういう '.' の特別扱いが、変な挙動の原因かもしれません

discord.gg

ちなみに、"?????" は a.txt にマッチしないとかいう現象も起こります。このパターンは拡張子のない 5 文字(EnumerateFiles メソッドの場合は 5 文字以下)のファイル名にしかマッチしません。

それから、本記事の内容とは直接関係しませんけれども、秘密のワイルドカード文字は他にもあるみたいです。弁士さんからご教示頂きました。

ご紹介頂いた記事は kenichiuda さんのものですね。ありがとうございます。
qiita.com

まとめ