HFS+のファイル名を比較する厳密な方法

Macユーザーなら、HFS+の勝手なファイル名の「正規化」には悩まされてますよね!

まず浅い理解として、HFS+はファイル名をNFDに正規化する。次に、HFS+は互換漢字を統合漢字にはぜず、それ以外をNFDに正規化する。まあそれで合ってるんですけれど、実際にはそんな簡単な話ではありません。
この記事では、カーネルが行なっているファイル名の変換の再現に挑戦します。

ソースコード

こういうものは一次ソースを見るのが一番です。外から挙動を推測したって見落としは出ます。Darwinカーネルオープンソースになってますので以下で見ることができます。
ファイルアクセスは、Darwinカーネルの中のDarwinカーネルであるXNUというモジュールが担当してます。

http://www.opensource.apple.com/source/xnu/

うろうろ

まず、ファイルアクセスAPIからやって来るHFS+のドライバの入り口はどこでしょうか?
hfs_vnops.cにある関数テーブルがそれっぽいですが、このファイルではファイル名の処理はしていません。ところどころで、cat_なんとかいう関数を呼んでますので、多分これでしょう。
というわけでhfs_catalog.cに進みます。色々やってますが、ファイル名の処理としてはbuildkeyが怪しい。utf8_decodestrという「いかにもそれっぽい」関数を呼んでいます。

utf8_decodestr(..., ':', UTF_ESCAPE_ILLEGAL | UTF_DECOMPOSED);

':'は、実は':'と'/'は逆に記録されているというヤツでしょう。Finderではファイル名に'/'を使えますが、POSIX APIからは':'としてアクセスできます。
プログラム中では(Cocoaのよくわからないレイヤーを使わない限り)POSIX APIからは'/'は区切り、':'はファイル名に使える文字で一貫してますから、これは豆知識として頭の片隅に押しやってしまいましょう。

もうひとつ重要なのが、CompareCatalogKeys(から呼ばれているFastRelString)とCompareExtendedCatalogKeys(から呼ばれているFastUnicodeCompare)です。
buildkeyでUTF-8で渡されたファイル名を内部名にデコードして、で、このデコードされた名前がそのままファイル名になるのですが、HFS+には正規化(モドキ)の他にもうひとつ、大文字/小文字を無視するという特徴があります。
こっちの方は大文字/小文字の区別を行うフォーマットもある(ディスクユーティリティでフォーマットできる)のですが、使われているのは大文字/小文字を無視するフォーマットのほうです。

で、まあ見ていくと、FastRelStringは、所謂Pascal文字列のcase insensitive比較で、文字コードmac romanっぽいです。互換用?
FastUnicodeCompareがUnicodecase insensitive比較です。

これで、再現するべきことはわかりました。
utf8_decodestrはファイル名のデコードを行う関数です。この関数の動作を追うことで、openやなんかのシステムコールに渡したファイル名が実際にどういう名前でディスク上に記録されるかがわかります。
FastUnicodeCompareはファイル名の比較を行う関数です。この関数の動作を追うことで、大文字/小文字が同一視される条件がわかります。

Unicodeテーブル

先にテーブルについて。
Unicodeというのは文字の並びに規則性のないコード体系で、扱うためにはテーブルが必要です。普通にUnicodeの処理を行うだけなら、http://www.unicode.org/からUCDを持ってくれば、いや、その辺のUnicodeのライブラリを使ってしまえばいいのですが、事はそう簡単ではありません。
ソースコードのCopyrightに書かれた年を見ればわかりますが、HFS+が実装されたのは2000年前後です。テーブルのバージョンを合わせないと挙動が変わってしまいます。

というわけで、XNUのソースコードに含まれるUnicodeテーブルをそのまま使いましょう。一番確実です。

正規化用 http://www.opensource.apple.com/source/xnu/xnu-1504.15.3/bsd/vfs/vfs_utfconvdata.h
比較用 http://www.opensource.apple.com/source/xnu/xnu-1504.15.3/bsd/hfs/hfscommon/Unicode/UCStringCompareData.h

探し回っているうちに圧縮されたバージョンの比較用テーブルも見つけました。内容は同じですが展開が必要です。

比較用(圧縮版) http://www.opensource.apple.com/source/boot/boot-132/i386/libsaio/hfs_CaseTables.h

utf8_decodestr

http://www.opensource.apple.com/source/xnu/xnu-1504.15.3/bsd/vfs/vfs_utfconv.c

「古いUnicodeテーブル」を用いて、UTF-8UTF-16に直しつつ、以下の展開を行なっています。

  • UTF-8のillega sequenceは、%XX (XXは16進2桁でA-Fは大文字) としてエスケープ。これはターミナルからでも簡単に試せて楽しいです。
  • decompose。decompose結果も再帰的にdecomposeしてます。互換漢字は最初からテーブルに入ってません。一応比べてみましたが、互換漢字以外は1B06〜1B43も入ってないです。これは単にテーブルが古いためと思われます。
  • 連続するcombining charactersのソート。decomposeの結果出現したものと、最初から文字列中に存在したものを合わせて、combining classで安定ソートを行なってます。これはUnicodeの仕様通りですが、やはりテーブルが古いためcombining characterとして収録されたコードがだいぶ少なく(322しかない。最新のは倍あります)、最新のテーブルを使ってしまうと食い違いが大きくなりそうです。

FastUnicodeCompare

http://www.opensource.apple.com/source/xnu/xnu-1504.15.3/bsd/hfs/hfscommon/Unicode/UnicodeWrappers.c

古いテーブルを用いて、UCS-2(16ビット)の範囲内で、simple case mappings(コードポイント数は変化しない)で小文字にしながらの比較を行なっています。
簡単。(小文字じゃなくてcase foldingすべきでは……なんて言ってもしょうがない)

で、何を使ってどれを再実装すれば?

CoreFoundationにはCFStringGetFileSystemRepresentationという関数があって、utf8_decodestr同様の処理を行なってくれます。

でも大抵それだけでは済まなくて、CFStringGetFileSystemRepresentationの逆変換が欲しくなると思います。主に、NFDモドキにされてしまったファイル名をNFCに戻して、他のOSと相互運用できるようにする、等の用途が考えられます。でも、CoreFoundationはそういった関数は用意してくれていません。
かといって、単にNFC変換を行なってしまうと、互換漢字は潰れるわ、合成除外文字は分解されたままだわで、厳密な逆変換にはなりません。

そこでiconvです。iconvはHFS+のファイル名の変換を"utf8-mac"としてサポートしてます。(Macでもこんな妙な変換はファイル名だけにしか使われてませんので、"utf8-mac"は誤解を生むという議論は既出)
iconvなら、iconv -f utf8-mac -t utf-8 で逆変換もOKという、iconv素晴らしいよiconv。
Frameworkを使いたくない、POSIXの範囲だけでやりたい、等の理由でCoreFoundationをリンクしたくない場合も、iconvなら許せるでしょう、たぶん。

iconvも許せなければ、再実装する羽目になります。

FastUnicodeCompareのほうは、/usr/lib以下の.dylibを調べて回ったのですが、どうも該当するデータは無さそうですので、比較まで行いたければ再実装してやる必要があります。

あとまあ、Macだからって無条件にこういった処理を行うのではなくて、一応statfsぐらいはして、HFS+でなければ素通ししてやるぐらいの余裕は欲しいです。Macでも他のフォーマットを使えるのです。
NTFSを見つけた時のためにLCMapStringを再現する方法は……どなたかお願いします。

立ち返って

こういったファイルシステムの知識を必要とする実装は、厳密な処理をするためには必要ですが、普通はもっと簡単な方法があります。
それは、「ファイルシステムはファイル名を変形することがある(HFS+に限らないしUnicodeに限らない)」という前提を受け入れることと、「ファイル名が変形された場合でも最初の名前でアクセスし続けることはできる」のを利用することです。
例えばファイルの上書き確認であれば、ファイルを列挙してファイル名を確認するみたいな処理はダメで、試しにstatでもしてみてアクセスできるならファイルは存在しているとみなすみたいな処理が良いってことです。
これならFAT16フロッピーディスクを急に使いたくなって、ファイル名が8.3に切り詰められても大丈夫でしょう、やったね!

でも、それだけでは済まない場合も出てくるとは思いますので、そういうときはぜひ、厳密な比較を実装してください。NFD変換なんかで済まさないで。