参照元

非常に重要な情報なので記録したい

Windows のファイルのコピーは、驚くほど奥が深い。

Windows で、ファイルやディレクトリのコピーなど、ファイル操作のコードを書くときは、決して油断してはならない。UNIX の開発者が Windows の世界にいざ足を踏み入れるときなど、Windows の素人は、以下のすべての点について、当然、万全の注意を払わなければ、大変なひどい目に遭うのである。

(1)♪ 当然、ファイルやディレクトリのパス文字列は 260 文字を超える可能性があるのだから、当然、先頭に謎の呪文である "\?\" という文字列を付加する必要がある。これにより最大約 32767 文字までのパス名を取り扱えるようにすることを忘れるな。

(2)♪ 当然、コピー先パスにすでにファイルが存在しており、かつ、ファイルが「読み取り専用」属性になっている可能性があるのだから、十分警戒しなければならない。もし「読み取り専用」属性になっていた場合、上書きすることはできない。ファイルを書き込み可能モードで開こうとする前に、属性を解除することを忘れるな。

(3)♪ 当然、コピー元ファイルには代替ストリーム (Alternate Stream) が含まれている可能性がある。代替ストリームとは、あの Windows でよく見られる、Web ブラウザでダウンロードしたファイルをダブルクリックすると「本当に開きますか?」と確認画面を出す機能の裏側で利用されている、1 つのファイルの本体データのほかに任意の個数の隠しデータを保存する機能である。昔の Mac でいうリソースフォークである。代替ストリームは 1 つのファイルに何百個も付いている可能性があるし、サイズも制限がないが、これを忘れずにコピーしなければならない。なお、あるファイルに付いている代替ストリームの一覧を列挙する API は「FindFirstStreamW」であるが、素人の中には、これを使ってただただ安心している者が多い。しかし、これには重大なワナがある。(11) で述べる。

(4)♪ 当然、コピー元のファイルやディレクトリの「作成日時」、「更新日時」、「アクセス日時」をコピー先にコピーすることを怠ってはならない。ただし、ファイルシステムの種類によって、これらの日時の精度が異なるので、これらの情報に基づくファイル同期コードを書くときには、ある程度の時差を許容するコードを書くことを忘れるな。そうしなければ、毎回、日時が変化しているように見えてしまうのである。

(5)♪ 当然、ファイルコピー時にコピー元ファイルの属性ビットをコピー先にコピーしたからといって、それだけで油断してはならない。ファイルコピーにおける各種の書き込み操作で、コピー先ファイルに「アーカイブ属性」が自動的に付いてしまうことがある。各種操作をした後、最後に「アーカイブ属性」をもう一度確認してコピーすることを忘れるな。

(6)♪ 当然、ファイルの属性をコピーするとき、単なる「属性フラグ」では操作することができない、「特殊な API でしか読み書きできない特殊な属性フラグ」が元ファイルや元ディレクトリに設定されているかどうか、チェックしなければならない。具体的には、圧縮ファイル属性、「シンボリックリンク属性」、「ジャンクション属性」である。

(7)♪ 当然、元ファイルまたは元ディレクトリは、シンボリックリンクである可能性があるから、コピー時は必要に応じてこれを再現しなければならない。なんと、Windows にはシンボリックリンク的な実装として、「シンボリックリンク」と「ジャンクション」の 2 つがある。そして、これを見分ける方法は通常のファイル API には存在せず、通常のファイル API を用いるとリンク先のファイルやディレクトリが透過的に見えてしまう。そこで、プロは、DeviceIoControl API を用いて「シンボリックリンク」または「ジャンクション」の生のメタデータにアクセスするのである。

(8)♪ 当然、元ファイルまたはディレクトリには「NTFS 圧縮属性」が指定されている可能性があるから、コピー先に対してもこの属性を再現する必要がある。そうしなければ、保存先のディレクトリの属性が適用されてしまう。ところが、この属性は標準のファイル API では設定することができず、なんと DeviceIoControl で特殊な方法を用いて属性を設定する必要があるのである。そして、圧縮属性は、すでにデータがあるファイルに対して適用しようとすると、大変長い時間がかかる上に、Windows のバグ (仕様 ?) として、CancelIo というキャンセル用 API が効かない (つまり、一度圧縮を開始してしまうと、Windows を強制シャットダウンするしか、圧縮処理を中断できない) という問題があるので、十分に警戒をしなければならない。

(9)♪ 当然、元ファイルは NTFS の暗号化ファイルシステム (EFS) によって暗号化されている可能性がある。そして、暗号化がされている場合、① ファイルをコピーしようとするユーザーがその秘密キーを有しているケース、② 秘密キーは有していないが管理者権限を有しているケース、③ 秘密キーは有していないし管理者権限も有していないケース、の 3 通りが考えられる。そして、ユーザーの希望により、A ファイルを一度メモリ上で復号化し、再暗号化して保存して欲しい場合、B ファイルを復号化せず、暗号化された生ストリームのままビット列としてコピーしてほしい場合、C ファイルは復号化し、コピー先では平文ファイルとしてほしい場合、の 3 通りが考えられる。この 3 × 3 = 9 通りのすべてのパターンで、可能な限り正しくファイルをコピーすることが、プロには求められる。② のケースでは、ReadEncryptedFileRaw を用いて、鍵を持っていなくても、暗号化された生の物理的なビットデータのコピーが可能であるから、プロはこれを活用するべきである。

(10)♪ 当然、元ファイルまたは元ディレクトリ、または先ファイル・先ディレクトリへのアクセスの際に、NTFS で ACL が設定されていることから、アクセス権が無い可能性がある。これはローカルの場合も、ネットワークファイル共有上の UNC パスにアクセスする際にも、同様である。しかしながら、ユーザーは一般的にファイルをバックアップ / 復元するためにコピーをするのであるから、当然、NTFS によるローカルまたはリモートのアクセス制限は無視してコピーしなければならない。このようなことは一般的には禁止されているが、AdjustTokenPrivileges などの API を用いてバックアップ特殊権限を有効化した後、CreateFile API において FILE_FLAG_BACKUP_SEMANTICS を指定してファイルやディレクトリを開くことにより、NTFS の ACL を完全に無視してファイルの読み書きが可能になるのである。Windows のプロは、当然、このようなことをするコードを、無意識に書くことができるはずである。

(11)♪ 当然、(3) で説明した代替ストリーム付きファイルのコピー処理を行なおうとする際に、コピー元ファイルが NTFS によってアクセス制限されている場合は、(3) で説明したアクセス権限無視の特殊モードを有効化する必要があるが、なんと、(3) で説明した FindFirstStreamW API を用いた代替ストリーム一覧の列挙処理においては、NTFS のアクセス権限を無視して列挙ができない。これは明らかに Windows のバグ (仕様 ?) であり、全くけしからんことである。Win32 SDK の API ドキュメントを見渡したが、NTFS のアクセス権限を無視した代替ストリームの列挙を可能とする API は 1 つも存在しなかった。それでは Windows 標準の ntbackup (Windows XP まで付いていた) はどのようにして NTFS のアクセス権限を無視した代替ストリームのコピーをしているのか? また、「BackupExec」などの市販ソフトではどのように対応しているのか? ここで Windows のプロ必携の逆アセンブラの出番である。調査したところ、なんと、非公開 DLL「ntdll.dll」の「NtQueryInformationFile」を用いて、NTFS 権限を無視した代替ストリームの列挙が可能な機能が実装されていたのである。これを用いることで、ようやくファイルのバックアップモードにおける代替ストリームの正しいバックアップが可能になるのである。

(12)♪ 当然、コピー元のパーティションと、コピー先のパーティションとが、異なる NTFS クラスタサイズでフォーマットされている可能性に注意する必要がある。実装上の都合により、NTFS クラスタサイズによって、最大ファイルサイズが異なる。たとえば、64KB クラスタの場合、実験したところ、1 個あたり約 17TB のファイルが最大サイズであり、これを超えると書き込みがうまくできないことがあることが分かっている。プロはこのような点にも気を配らなければならない。

(13)♪ 当然、元ファイルは NTFS スパースファイル (Sparse File) である可能性があり、この場合は、コピー先ファイルもスパースファイルとすることが、ユーザー万人が期待することである。そこで、Windows のプロは、DeviceIoControl を用いて、スパース領域ブロックを列挙し、正しくこれをコピー先ファイルに再現することを忘れることがない。なお、Linux などの生易しい OS の生易しい ext4 などとは異なり、単に seek をしてファイルポインタを飛ばしたとしても自動的にスパースファイルにしてくれることはなく、まず DeviceIoControl で特殊な操作をしなければ決してスパースファイルにはなってくれないのであるから、十分注意すること。

(14)♪ 当然、元ファイルまたは元ディレクトリの NTFS セキュリティ属性は、新たなファイルまたはディレクトリにコピーされることをユーザーが期待している場合は、そのコピーを忠実に実行しなければならない。ここで、NTFS のファイルまたはディレクトリには、① 複雑なアクセス制御リスト (ACL)、② 所有者データ、③ (UI 上では表示されないが NTFS 上には存在する) グループ所有者データ、④ 監査設定、の 4 つが存在しているので、これらを漏れなくコピーする必要がある。① 複雑なアクセス制御リスト (ACL) は、アクセス制御エントリ (ACE) のリストであるが、それぞれの ACE には、継承関係があり、「このオブジェクトのみ」、「このオブジェクトおよびサブオブジェクト」、「このオブジェクトおよびサブオブジェクトおよびその下部のすべてのサブオブジェクト」の 3 種類が存在する。また、上位の親オブジェクトからの継承を受け入れるかどうかのフラグが存在する。これらに十分注意して NTFS ACL をコピー先で再現すること。なお、ネットワーク上の共有フォルダにおいては、ローカルマシンで存在しない SID のユーザーやグループに対する ACL が設定されていることもあるが、それも正しくコピー先で再現しなければ、ユーザーの期待を裏切ることになるであろう。

(15)♪ 当然、上記すべてのコピー処理は、非同期に (つまり、いつでも呼び出し元ユーザーによって取消し可能な状態で) 行なわなければならない。大きなファイルのコピーには時間がかかるものであり、ユーザーはいつでもキャンセルしたいと思うのである。そのためには、通常の方法で ReadFile, WriteFile, DeviceIoControl を呼んではならない。Windows のプロだけが利用できるあの素晴らしいオーバーラップド構造体を設定した I/O 完了ポートを用いてファイルコピーを実装する必要があるのである。このようにすれば、複数のファイル操作を並列して実行できるし、いつでも CancelIo を用いて途中でキャンセルすることができる。なお、CancelIo を無視する Windows API もあるから (例: NTFS 圧縮処理)、警戒を忘れてはならない。

(16)♪ 当然、ユーザーがファイルコピーを途中でキャンセルした場合は、中途半端な状態のファイルが見えないように、いったんコピー中の宛先ファイルを削除し、元のファイルをコピー前に戻さなければならない。元のファイルをコピー前の状態に戻すための良い方法は、コピーを開始する前に、まず、宛先ファイルを、同一のパーティション上の別の場所に移動しておき (この際のコストはほぼ 0 である)、キャンセルが発生したら再度移動をして元の場所に戻す、コピーが完了したらその移動先ファイルを削除する、という方法である。ただし、これらの一連の動作はアトミックでないので、プロセスがクラッシュしたり、ユーザーによって強制終了されたり、またコンピュータが停止したりした場合に、不整合が発生してしまう可能性がある。これを避けるためには、Transactional NTFS というものを使うと良いらしい。しかしながら十分長けた Windows のプロであってもそこまで踏み込んでいる例は少ない。

(17)♪ 当然、コピー元ファイルが、排他モードで別プロセスによって開かれている場合がある。この場合は、ファイルを開こうとすると失敗する。また、コピー元ファイルがデータベースや VM エンジンなどによって常時読み書きされている場合がある。この場合は、ファイルを開くことができても、読み取り中に一貫性が失われる。このような問題に対処するために、プロが書くソフトウェアは、当然、Volume Shadow Copy (VSS) の COM API を用いて NTFS のスナップショットを作成し、そのスナップショット上のファイルを読み取るべきである。しかしながら十分長けた Windows のプロであってもそこまで踏み込んでいる例は少ない。

(18)♪ 当然、ユーザーは Windows 95 ~ Windows 10 のすべての Windows 上で、プログラムを実行する可能性があるのである。ところで、上記の各種 API は、それぞれ、特定のバージョンの Windows 以降で実装されたものが多いので、プログラムを書く際には、呼び出そうとしている機能がこの Windows でサポートされているかどうか注意深く検査する必要がある。また、実行しようとしているユーザー権限にも注意しなければならない。さらに、一部のバージョンでは Unicode 版 API が利用不可能なので、文字コード変換を実施しなければならない。Windows 9x を切り捨てるということも十分検討に値するのであるが、未だ Windows 9x は色々なシステム (特に産業用システムなど) で現役で利用されているのであり、「ファイルバックアッププログラム」というものは、まさにそのようなシステムを含めたすべてのシステムで動作しないと意味がないのであるから、これらの古いバージョンの Windows を安易に切り捨てることなど、できないのである。

(19)♪♫♪♫♪♫ 最大限の恐怖!当然、Windows API のファイル API の仕様では、ファイルを書き込みモードで開いた後に、UNIX のいわゆる lseek や ftruncate に相当する API である、SetFilePointer および SetEndOfFile で、ファイルのサイズを拡張または縮小することができるのだが、ファイルを拡張する場合には、最大限の用心が必要である。UNIX においては、lseek や ftruncate でファイルを拡張した場合は、拡張領域はゼロクリアされていることが保証されている。ところが、なんと、Windows の場合、API ドキュメントを用心深く目を皿のようにして読んだ方ならば誰でも刮目することが書かれているのである。すなわち、Windows では、ファイルサイズを拡張した場合は、「元の EOF から新しい EOF までの領域は(実際に書き込みが行われた領域を除き)初期化されません。」(SetFilePointer API のドキュメント)、「元の EOF の位置と新しい位置の間にあるファイルの内容は未定義です。」(SetEndOfFile ドキュメント) のとおり、拡張された部分には「未定義のデータ」(!) が含まれる可能性がある仕様となっているのである。つまり、Windows におけるファイル拡張において、拡張領域がゼロクリアされている保証は全く無い。実装上、偶然ゼロクリアされているかも知れないし、「未定義のデータ」(!)、つまり、ゼロでないゴミデータが入っているかも知れないのである。これは誠に困ったことである。ファイルをコピーする際に、コピー元ファイルが巨大なゼロが連続するファイルの場合は、元ファイルのゼロを自動的に検出して、その部分を先ファイルにおいて書き込みスキップすることでパフォーマンスを上げるという作業を行なう必要があるが、その際にファイルサイズの拡張が必要となる。ファイルコピープログラムに限らず、データベースプログラムなど、巨大なデータを扱うプログラムは、データの書き込みを伴わないファイルサイズの拡張処理をひんぱんに利用している。この際、ゼロクリアが行なわれず、「未定義のデータ」(!) が入る可能性があるということになると、ファイルのデータは意図したとおりになっておらず、一貫性が欠如し、内容が破損しているということになる。したがって、Windows では、SetFilePointer や SetEndOfFile だけでは、安全なファイル拡張は不可能である。UNIX から Windows の世界に足を踏み入れた多くの UNIX のプロのプログラマーは、Windows の世界では素人であるので、この Windows の恐怖の API ドキュメントの最大限の恐怖の記述を注意深く警戒してよく読まずに、ゼロクリアしているつもりで「未定義のデータ」(!) が混入するというバグがある Windows のコードを書いているに違いない。Windows のプロは、API 仕様書をますますよく読むと、DeviceIoControl で FSCTL_SET_ZERO_DATA という素晴らしいコントロールコードの存在をついに発見するであろう。このコントロールコードこそが、Windows において、領域拡張後のファイルを、実際の書き込みを伴わずにゼロクリアされることが保証されている唯一の拡張操作と組み合わせて利用される、プロしか知らないコントロールコードなのである。Windows では lseek や ftruncate 的な API を用いてファイル拡張した後には、忘れずに、DeviceIoControl で、FSCTL_SET_ZERO_DATA を使ってゼロクリアをしなければならないのである。これを読んで驚いた方は、最新の Windows 10 の SDK の SetFilePointer および SetEndOfFile の英語版ドキュメントを読んでみるとよい。SetFilePointer の Remarks には "The size of the file does not increase until you call the SetEndOfFile, WriteFile, or WriteFileEx function. A write operation increases the size of the file to the file pointer position plus the size of the buffer written, which results in the intervening bytes uninitialized." 、SetEndOfFile の Remarks には "The SetEndOfFile function can be used to truncate or extend a file. If the file is extended, the contents of the file between the old end of the file and the new end of the file are not defined." という恐怖の記述があるのである。すべての Windows のプロは、一度、これまでに、自分の書いたコードが、ファイルサイズを拡張した後にその拡張した領域に対して WriteFile で実際にゼロを書き込むか、または、DeviceIoControl で FSCTL_SET_ZERO_DATA を用いてゼロクリアを宣言するという動作をし忘れていないか、十分慎重に思い返して、コードを確認してみるとよい。おそらくほとんどのプログラマーは、(当然 UNIX と同様に) ゼロクリアが暗黙で行なわれていると信じて、Windows においてこの恐怖の仕様に対して油断をし、FSCTL_SET_ZERO_DATA などというようなプロしか知らない救世主コントロールコードのことなど調べたこともないはずであろう。♪♫♪♫♪♫

上記のように、Windows でファイルをコピーする場合は、色々な点に気を配らなければならない。上のような複雑な問題 (他にもあるかも知れない) を十分に把握している人たちは、当の Microsoft 社においてももはや少数となっている程度である。そして市販ソフトウェアであっても、フリーソフトウェアであっても、Windows に対応したファイルバックアップソフトウェアでろくなものがほとんど存在しない理由は、上記のようなことすべてに対応しなければ、「正しいファイルのバックアップ」ができないためなのである。あのライセンス料金が高い BackupExec が高い理由は、だいたいは上記のようなヘンテコの機能すべてに対応するための膨大な開発やテストの費用が含まれているからなのであろう。

Last modified: 2020-06-02