PDF Library for Delphiは、RepairQDFFileの出力を内部のライターTPDFQDFFileWriterを通して公開します。このライターは出力先を書き込み用に開くことが一切ありません。修復後のバイト列は同じディレクトリに排他的に作成した一時ファイルへ書き込まれ、そのファイルをフラッシュして閉じ、その後ではじめて、WindowsではMoveFileExW、POSIXではrename(2)で出力先へ上書き改名します。renameより前に何かが失敗すれば、出力先は元のバイト列を1バイトも失わず、呼び出し側にはLastErrorCodeの305が返ります。文書をメモリ上で修復するのは、修復機能の簡単な半分です。その結果をディスクに書き出すとき、長さ0や書きかけのファイルをユーザーに一切残さないこと――本記事が扱うのはこの半分です
失敗した修復がなぜ出力先を壊しうるのか
操作の順序が間違っていたからです。v3.539.13より前、RepairQDFFileはPLCreateFileStream(OutputFileName, fmCreate)で出力を開き、そのストリームをパーサに渡していました。fmCreateは開いた時点で切り詰めるので、QDFスキャンが入力を修復不能と判断したときには、出力先はすでに空になっていました。InputFileNameとOutputFileNameが同じパスであるその場修復では、拒否された入力が失われたファイルになってしまいました。パーサ自体は行儀がよく、低レベルのPDFQDFRepair関数は曖昧なマーカーを拒否するときに対象ストリームに手を付けません。しかしその保護は無意味でした。公開APIがその1つ前の呼び出しでファイルを切り詰めてしまっていたからです
v3.539.13の修正は、修復をTMemoryStreamの中で行い、PDFQDFRepairが成功した後にだけ出力を開くようにしました。これで塞がったのは解析失敗の穴だけで、それ以外ではありません。書き込み段階は依然としてfmCreateとそれに続くCopyFromだったので、ディスク満杯、途中での共有違反、あるいは切り詰めと最後のWriteBufferの間の例外が、壊れた出力先をそのまま残していました。メモリ先行の修復が守るのは不正な入力に対してです。ディスクへの公開にはそれ専用の境界が必要で、v3.539.14とv3.539.15がそれを築きました
// v3.539.12:入力を検証する前に出力先が切り詰められる
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
if PDFQDFRepair(Source, Output, QDFError) then // もう断れない
Result := 1;
finally
Output.Free;
end;
// v3.539.15:メモリ上で修復し、そのバイト列を公開ライターに渡す
Repaired := TMemoryStream.Create;
try
if not PDFQDFRepair(Source, Repaired, QDFError) then
Exit; // 出力先は一度も開かれない
Writer := TPDFQDFFileWriter.Create;
try
Writer.Save(Repaired, OutputFileName);
Result := 1;
finally
Writer.Free;
end;
finally
Repaired.Free;
end;
アトミックな公開は実際に何を保証するのか
TPDFQDFFileWriter.Saveは、ライブラリ自身が観測できるあらゆる失敗について、出力先のパスが完全な旧ファイルか完全な新ファイルのどちらかであり、混ざった状態にはならないことを保証します。ライターはこれを4つの段階で行い、各段階は前の段階が完了していなければ先へ進むことを拒否します。まずGetFullPathNameWで出力先を解決します。2回呼び出し、MAX_PATHを決め打ちするのではなく返された長さでバッファを確保するので、長いパスが黙って切られることはありません。次に、出力先のディレクトリに.pdflib-qdf-+GUID+.tmpという名前の一時ファイルを作成します。WindowsではCREATE_NEW付きのCreateFileW、POSIXではO_CREAT or O_EXCLとモード0600付きのopen(2)を使います。どちらのフラグも、名前がすでに存在すれば作成を失敗させるので、同じGUIDで競合する2つのプロセスがハンドルを共有することはできません。3番目に、修復済みストリームをWriteBufferで64 KiBずつコピーします。これは誰も見ないカウントを返すのではなく、短い書き込みで例外を送出します。そのうえでFlushFileBuffersまたはfsync(2)を呼び、ハンドルを閉じます。4番目に改名します
procedure TPDFQDFFileWriter.Flush(Target: TStream);
begin
if not FlushFileBuffers(THandleStream(Target).Handle) then
raise EWriteError.Create('Unable to flush QDF output');
end;
procedure TPDFQDFFileWriter.Publish(const TempFileName, FileName: WideString);
begin
// ボリュームをまたぐコピーも、先に出力先を削除することも許さない
if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
raise EWriteError.Create('Unable to publish QDF output');
end;
改名の段階こそ、自家製の「安全な保存」ルーチンのほとんどが静かに壊れる場所です。MOVEFILE_REPLACE_EXISTING付きのMoveFileExWは、同一ボリューム上でファイルシステム操作1回で出力先を置き換えます。ライターはMOVEFILE_COPY_ALLOWEDを意図的に外しています。ボリュームをまたぐ移動はコピーしてから削除する形に格下げされ、それはまさにこの設計全体が避けようとしている非アトミックな手順だからです。一時ファイルは出力先のディレクトリにあるので、構造上かならず出力先のボリューム上にあります。またライターは、先に古いファイルを削除することもありません。削除してから改名する組にはパスがまったく存在しない窓が生じ、その窓の中でクラッシュすれば文書が失われます。MOVEFILE_WRITE_THROUGHは、改名がディスクに到達するまで呼び出しを返さないよう要求します。これはデータの明示的なフラッシュと対になります。POSIXではrename(2)が、新しい名前が既存のファイルをアトミックに置き換えることをすでに保証しており、同じディレクトリに置くことでEXDEVで失敗することもありません。後始末は対称です。一時ファイルの名前はどの経路でもfinallyブロックで削除します。成功時にはrenameがすでに消費しているのでno-opになり、失敗時には書きかけのファイルを削除するので、ディレクトリに.tmpの残骸がたまりません。Tests\QDFFileRegression.incのリグレッションはまさにこれを検査します。注入したあらゆる失敗の後で、出力先のバイト列は元と一致し、入力元のバイト列も元と一致し、ディレクトリには2つのフィクスチャ以外に何もありません
Windowsで一時ファイルがパーミッションを緩めてしまうのはなぜか
セキュリティ記述子をnilにして作成したファイルは、これから置き換えるファイルからではなく、親ディレクトリからDACLを継承します。これは真新しい文書には正しい既定であり、その場修復には誤った既定です。運用担当者がcontract.pdfを、保護された継承なしのDACLで単一アカウントだけに絞り込んでいるとします。その隣に作られた一時ファイルはディレクトリのより広いパーミッションを継承し、それがcontract.pdfに上書き改名されると、改名後のファイルは広いDACLを引きずります。NTFSのセキュリティは名前ではなくファイルオブジェクトに付いて回るからです。修復は成功し、バイト列も正しく、そして運用担当者が設定したアクセス制御は黙って消えています。戻り値にはそれを匂わせるものが何もありません
そこでPDF Library for Delphiは、一時ファイルを作成する前に出力先のDACLを読み、それをlpSecurityAttributes引数としてCreateFileWに渡します。こうして新しいファイルは古いファイルのパーミッションで生まれ、改名しても運用担当者が気づくような変化は起きません。読み取りにはDACL_SECURITY_INFORMATION付きのGetFileSecurityWを使い、最初の呼び出しのERROR_INSUFFICIENT_BUFFERの結果からバッファのサイズを求めます。3つの条件で、ライターは推測するのではなくフェイルクローズします。DACLを読めなければ、公開はEWriteErrorで停止し、公開APIはそれを305に対応付けます。取得した記述子にSE_DACL_PRESENTが立っていなければ、公開はここでも停止します。そのような記述子をCreateFileWに渡すと、カーネルがプロセスの既定DACLにフォールバックし、誰も望んでいないのにアクセスのセマンティクスが変わってしまうからです。そして出力先がFILE_ATTRIBUTE_ENCRYPTEDを持つ場合、ライターはきっぱり拒否します。一時ファイルは平文になり、平文ファイルをEFSで保護されたファイルに上書き改名することは、ユーザーがファイルシステムレベルで暗号化することを選んだものを、暗号化されていない置き換えとして公開してしまうからです。EFSは、暗号化された文書の読み込みの記事で扱っているPDF標準セキュリティハンドラとは無関係ですが、失敗の形は同じ種類の静かな格下げです
Attributes := GetFileAttributesW(PWideChar(Destination));
if Attributes <> INVALID_FILE_ATTRIBUTES then
begin
if (Attributes and FILE_ATTRIBUTE_ENCRYPTED) <> 0 then
raise EWriteError.Create('QDF replacement of an EFS encrypted file is not supported');
// 記述子のサイズを求め、そのうちDACLの部分だけを読む
if not GetFileSecurityW(PWideChar(Destination), DACL_SECURITY_INFORMATION,
@Security[0], SecuritySize, SecuritySize) then
raise EWriteError.Create('Unable to read QDF destination permissions');
if not QDFGetSecurityDescriptorControl(@Security[0], Control, Revision) or
((Control and SE_DACL_PRESENT) = 0) then
raise EWriteError.Create('QDF destination has no explicit DACL');
SecurityAttributes.lpSecurityDescriptor := @Security[0];
SecurityPointer := @SecurityAttributes; // CreateFileW / CREATE_NEW に渡す
end;
同じようなテストを自分で書くなら、リグレッションから1つ覚えておく価値のある細部があります。制限付きのフィクスチャを組むには、所有者だけのDACLを適用し、記述子の制御にSE_DACL_PROTECTEDを明示的に設定しなければなりません。SetFileSecurityWのSecurityInformation引数に保護フラグを渡すだけでは、保護されていない記述子が保護されたものになるわけではありません。その後のアサーションは、公開されたファイルが保護ビットと明示的でnullでないDACLを依然として報告することです。別の出力パスに書いた場合も、入力元ファイル自体を上書き修復した場合も同様です
どのLastErrorCodeが何の失敗を教えるのか
RepairQDFFileは成功時に1、失敗時には0を返し、LastErrorCodeがどの段階が拒否したかを教えます。読み取れない入力元は、他のプロセスが排他ロックで握っている場合も含めて401を報告します。読み取りはラップされ、入力中の例外は書き込みエラーに漏れ出すのではなく401に対応付けられます。同じオブジェクトに対するストリームマーカーの重複など、不正または曖昧なQDF構造はPDFLIB_ERROR_QDF_REPAIR、つまり107を報告します。ライターが一度も構築されていないので、出力先には手が付いていません。修復より後のすべて、つまり一時ファイルの作成からフラッシュ、改名まではPDFLIB_ERROR_QDF_WRITE、つまり305を報告します。リグレッションが試すのは現実的なケースです。削除共有なしで他のハンドルが開いている出力先、読み取り専用の出力先、存在しない出力先ディレクトリ、そして注入によって失敗させたライターの3段階それぞれです。どのケースでも戻り値は0、コードは305で、その後には新規の、あるいは書きかけの出力先は存在しません。戻り値だけでなくコードを読むという一般的な習慣は、ライブラリの無言の失敗を診断する記事で説明しているものと同じです
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
// その場修復:同じパスが入力であり出力
if Pdf.RepairQDFFile('edited.qdf.pdf', 'edited.qdf.pdf') = 1 then
Log('published; the previous bytes were replaced in one rename')
else
case Pdf.LastErrorCode of
401: Log('could not read the input; it was not modified');
107: Log('QDF structure rejected; the destination was never opened');
305: Log('write, flush or replace failed; the destination still holds its old bytes');
end;
finally
Pdf.Free;
end;
end;
保証はどこで止まるのか
ライターが約束するのは、プロセスから見える失敗に対する一貫性であり、見えない失敗については正直です。一時ファイルを作成してから改名するまでの間にプロセスが強制終了されると、finallyブロックは走らず、.pdflib-qdf-<GUID>.tmpファイルがディレクトリに残ります。出力先は無傷のままで、これが重要な性質ですが、残骸を掃除するのはこちら側の仕事です。電源断も約束の外です。データはフラッシュされ、改名もwrite-throughで、これはユーザーモードのライブラリが要求できる最善ですが、ライターはディレクトリエントリをfsyncしておらず、ファイルシステムが提供する以上の永続性を主張しません。出力先を同時に変更する2つ目のライターは検出しません。DACLと属性は一時ファイルを作成する前に読み取られ、改名の時点でそれらを再検査するものがないからです。また改名が成功するとファイルの同一性が新しくなるので、代替データストリームや、古いファイルのアーカイブビット・隠しビットといった通常の属性は引き継がれません。意図的に引き継ぐのはDACLだけです
さらに狭い境界は、そもそもどのAPIがこの経路を使うかです。TPDFQDFFileWriterを通るのはRepairQDFFileだけです。SaveQDFToFileとConvertFileToQDFは今もPLCreateFileStream(FileName, fmCreate)で出力を開き、QDF変換をそのまま流し込みます。ストリームに更新を追記する記事で説明したインクリメンタル経路が、渡されたストリームに書き込むのと同じです。この2つの呼び出しは、すでに読み込まれ検証済みの文書から新しいデバッグ用の成果物を作るものなので、解析失敗の穴はそもそも当てはまりませんが、renameベースの公開も引き継いでいません。本記事を「すべてのQDFエクスポートがアトミックだ」と読まないでください。これは1つの出口にすぎません。入力が信頼できない手編集のファイルで、出力が日常的に同じパスになる出口であり、その組み合わせが余分な仕掛けを勝ち取った理由です。これらすべてを証明するフォールトインジェクションが安く済むのは、ライターの3段階であるWriteData、Flush、Publishがvirtualだからです。テスト用の派生クラスがそのうち1つをオーバーライドして、実際の処理が始まった後に例外を送出させ、修復済みストリームに対してSaveを呼び、例外が伝播すること、入力元と出力先のバイト列が変わらないこと、一時ファイルが残らないことをアサートします。グローバルのファイルAPIは一切フックせず、実在するユーザーのファイルにも触れません。そしてこの3段階は、本番で公開が失敗する3つの形に1対1で対応します。ディスクが満杯になる、フラッシュが拒否される、他の誰かが出力先を握っていて改名が拒否される、の3つです
RepairQDFFileのAPI、そのアトミック公開ライター、そしてQDFデバッグワークフローの残りは、PDF Library for Delphiの一部であり、このブログの他の場所で扱っているクロスリファレンス復旧、インクリメンタル更新、暗号化の機能と並んでいます