技術記事

DelphiでのPDF修復出力の原子性:renameとDACLの安全性

QDFファイルを手で編集し、同じオブジェクトに2つ目のストリームマーカーを忍ばせて、いちばん自然に書けるDelphiコードでインプレース修復を実行する。修復ステップは破損に気づき、拒否します。あとでファイルを見ると0バイトです。TFileStream.Create(OutputFileName, fmCreate)が、バリデータが入力を見るより1行前に、まさにそのパスを切り詰めたからです。メモリ上でバイトを修復するのは仕事の簡単な半分です。ゼロ長、半分書きかけ、パーミッションの間違ったファイルを1つも残さずにディスクへ戻すのがもう半分で、そちらには専用の失敗モードがあります

インプレース修復はなぜファイルを空にするのか

インプレース修復がファイルを空にするのは、操作の順序が間違っているからです。fmCreateはオープン時に切り詰めるので、オープンの後に来る拒否は何であれ遅すぎます。InputFileNameとOutputFileNameが同じパスのとき、拒否された入力は失われた文書になります。注意深いパーサーでは救えません。あいまいなマーカーを拒否するとき出力ストリームに触れないようにすることはできますが、呼び出し側は1つ前の呼び出しでターゲットをすでに破壊しています

// 素朴な版:何も検証される前に出力先が切り詰められる
Input := TMemoryStream.Create;
try
  Input.LoadFromFile(InputFileName);                       // OutputFileNameと同じパス
  Output := TFileStream.Create(OutputFileName, fmCreate);  // この時点でファイルは0バイト
  try
    if not MyRepairQDF(Input, Output) then  // 自前のバリデータ:拒否するにはもう遅い
      raise Exception.Create('QDF structure rejected');
  finally
    Output.Free;
  end;
finally
  Input.Free;
end;

次に試したくなるのは、TMemoryStreamへ修復し、修復が成功した後にだけ出力を開く方法です。これで塞がれるのは悪い入力の穴だけで、ほかは何も塞がりません。書き出しフェーズは依然として切り詰めてからコピーなので、ディスク満杯、途中の共有違反、切り詰めと最後のWriteBufferの間の例外は、今も宛先を傷つけたままにします。パースと公開は別々の境界で、それぞれに専用の保護が必要です

PDF Library for Delphiでは、TPDFlib.RepairQDFFile(InputFileName, OutputFileName)が両方を扱います。入力を読み、完全にメモリ内で修復し、QDF構造が拒否されたときは宛先に何も開かずに早く戻ります。成功した修復だけが公開へ進み、公開は宛先を書き込み用に開くことすらありません

DelphiでQDF修復を公開する3つの方法:fmCreateで出力を開くと、修復が入力を拒否する前に切り詰められる。先にTMemoryStreamへ修復する方法は悪い入力にしか効かない。RepairQDFFileは修復済みバイトを一時ファイルとアトミックなrenameを通して公開する
パースの安全性と公開の安全性は別の境界です。メモリ先行の修復は悪い入力から守り、ライターが存在するのは、ディスク満杯や書き出し途中の失敗が宛先を傷つけたままにしないためです

修復済みバイトから宛先までの4ステップ

RepairQDFFileは、プロセス自身が観測できるすべての失敗について、宛先パスが完全な旧ファイルか完全な新ファイルのどちらかを保持することを保証します。そこへは4ステップで到達し、どのステップも前のステップが終わらない限り始まりません。宛先はGetFullPathNameWでフルパスに解決されます。必要な長さを知るために1回、ちょうどそのサイズのバッファにもう1回呼び、長いパスがMAX_PATHで切り詰められないようにします。次に、.pdflib-qdf-+GUID+.tmpという名前の一時ファイルを、宛先ディレクトリに作成します。WindowsではCreateFileWとCREATE_NEW、POSIXではopen(2)とO_CREAT or O_EXCL、モード0600です。どちらのフラグも、名前が既存なら作成を失敗させます。たまたま同じGUIDを引いた2つのプロセスが1つのハンドルを共有するには至りません

修復済みストリームは64 KiBチャンクでWriteBufferを通してコピーされます。誰も確認しないカウントを返す代わりに、短い書き込みでraiseします。FlushFileBuffersかfsync(2)がデータを押し下げ、ハンドルは閉じられます。クローズは意図的にraiseしません。フラッシュが成功した後のクローズ失敗はデータへの脅威ではなく、そこで例外を上げると、診断する人の目から本当の書き込みやフラッシュのエラーを隠してしまうからです。最後のステップはrenameです

PDF Library for DelphiでRepairQDFFileが出力を公開するのに使う4ステップ:GetFullPathNameWで2回パスを解決し、CREATE_NEWかO_EXCLで.pdflib-qdfの一時ファイルを作って競合するプロセスがハンドルを共有できないようにし、64 KiBのWriteBufferチャンクでコピーしてフラッシュし、それからREPLACE_EXISTINGとWRITE_THROUGH付きのMoveFileExW
各ステップは前のステップが終わらなければ先へ進まず、一時ファイルは構成上宛先ボリュームに住み、削除先行の窓は存在せず、finallyの中のクリーンアップが.tmpの残骸を残しません

renameは、自作のセーブセーフルーチンのほとんどが壊れる場所です。Windowsでは一時ファイルがMOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH付きのMoveFileExWでターゲットを置き換えます。同一ボリューム上では1つのファイルシステム操作です。MOVEFILE_COPY_ALLOWEDは意図的に外しています。ボリュームを跨ぐ移動は黙ってコピーしてから削除に劣化します。設計全体が避けようとしているまさにその非アトミックな列で、マシンが落ちたとき半分だけ終わった「成功した」コピーを見るより、拒否されたrenameのほうがよほどマシです。一時ファイルは宛先ディレクトリに座っているので、どのみち宛先ボリューム上にあります。旧ファイルを先に削除することもありません。削除してからrenameのペアは、パスが存在しない窓を残し、その中でのクラッシュは文書を失います。MOVEFILE_WRITE_THROUGHは、renameがディスクへ届く前に呼び出しが戻らないようにします。POSIXではrename(2)がすでに既存の名前をアトミックに置き換え、同一ディレクトリ配置がEXDEVを避けます

クリーンアップはすべての経路でfinallyブロックの中を走ります。成功したrenameの後は一時名がもう存在しないので削除は何もせず、失敗の後は部分的なファイルを取り除きます。ディレクトリは呼び出し前とまったく同じファイル構成で終わります

Windowsで一時ファイルがパーミッションを緩めてしまうのはなぜか

nilセキュリティ記述子で作られたファイルは、親ディレクトリからDACLを継承します。置き換えられるファイルに発言権はありません。真新しい文書ならそのデフォルトは正しい。インプレース修復では間違っています。1つのアカウントに保護された非継承DACLでcontract.pdfを固定した運用担当者を想像してください。その隣の素の一時ファイルはディレクトリの広いパーミッションを拾い、renameの後、公開されたcontract.pdfはその広いDACLを運びます。NTFSのセキュリティは名前ではなくファイルオブジェクトに付いているからです。バイトは正しく、戻り値は1です。運用担当者が設定したアクセス制限は、ただ消えています

そこでRepairQDFFileは一時ファイルを作る前に宛先のDACLを読み、lpSecurityAttributes経由でCreateFileWへ渡します。新しいファイルは旧ファイルのパーミッションで生を始め、renameは運用担当者が気づくような変更を何ももたらしません。読み取りはDACL_SECURITY_INFORMATION付きのGetFileSecurityWで、バッファは最初の呼び出しのERROR_INSUFFICIENT_BUFFER回答からサイズを取ります。宛先がまだ存在しないとき、つまり別の出力パスの通常ケースでは、記述子は渡されず、新しいファイルはほかの新規ファイルと同じくディレクトリから継承します

QDF公開ライターが一時ファイルを作る前に出力先のDACLをコピーする理由。nilの記述子ではディレクトリのより広いパーミッションを継承し、改名がアクセス権を黙って広げてしまいます。そこでGetFileSecurityWがDACLを読み、SE_DACL_PRESENTビットの欠落やEFS属性があれば公開を305で停止し、CreateFileWは古いパーミッションで生まれます
NTFSのセキュリティは名前ではなくファイルオブジェクトに付いて回ります。読み取った記述子をlpSecurityAttributesとして渡せば、改名しても運用担当者が設定したものは何も変わりません。そしてどのゲートも推測せずフェイルクローズします

コードに推測させる代わりに公開を止める条件が3つあり、それぞれLastErrorCode 305を報告します:

  • DACLが読めない。たとえば呼び出しアカウントに宛先へのREAD_CONTROLがない場合です
  • 返された記述子にSE_DACL_PRESENTが立っていない。そんな記述子をCreateFileWに渡すと、WindowsはプロセスのデフォルトDACLへフォールバックし、誰も頼んでいないアクセス意味論の変更が起きます
  • 宛先がFILE_ATTRIBUTE_ENCRYPTEDを持つ。一時ファイルは平文になり、EFS保護されたファイルの上へrenameすると、ユーザーが暗号化することを選んだものの非暗号化置換を公開することになります

EFSはファイルシステムの機能で、PDFのセキュリティハンドラとは無関係です。そちらは暗号化文書のローディングの記事で扱っています。それでも失敗の形は同じ種類の静かな降格で、拒否するのが正しいデフォルトです。EFS共有の上でたまにnoと言う修復はサポートチケットです。ユーザーが暗号化したファイルを復号する修復はインシデントです

DACL保持のテストを自分で書くなら、1つのWindowsの細部で間違えやすい点があります。制限付きテストファイルを作るとき、オーナー専用DACLを適用し、記述子コントロールにSE_DACL_PROTECTEDを明示的にセットします。SetFileSecurityWへPROTECTED_DACL_SECURITY_INFORMATIONを渡しても、非保護の記述子は保護されたものに変わりません。その後、公開されたファイルが依然として保護ビットと明示的で非nullのDACLを報告することを、別出力パスの場合とソース自身への修復の場合の2回確認します

どのLastErrorCodeが何の失敗を教えるのか

RepairQDFFileは成功で1、失敗で0を返し、TPDFlib.LastErrorCodeが拒否した段階を名指します。存在しない入力、開けない入力、2 GB超の入力は401を報告し、読み取り中の例外も書き出しコードへ漏れる代わりに401へ写像されます。無効またはあいまいなQDF構造、例の重複ストリームマーカーなどはPDFLIB_ERROR_QDF_REPAIR(107)を報告します。宛先は開かれてすらいません。修復の後のすべて、パス解決からフラッシュとrenameまではPDFLIB_ERROR_QDF_WRITE(305)を報告します。現実的な書き込み失敗はすべて305に着地します。delete共有なしで別ハンドルが保持する宛先、読み取り専用の宛先、存在しない宛先ディレクトリ、そして上記のDACLとEFSの拒否です。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');
        PDFLIB_ERROR_QDF_REPAIR:
          Log('QDF structure rejected; the destination was never opened');
        PDFLIB_ERROR_QDF_WRITE:
          Log('write, flush or replace refused; the destination keeps its old bytes');
      end;
  finally
    Pdf.Free;
  end;
end;

305の経路は、ファイルAPIをフックせずとも自分のテストプロジェクトから簡単に再現できます。DelphiのfmShareDenyNoneは読み書き共有付き、しかしFILE_SHARE_DELETEなしでファイルを開きます。Windowsはそういう保持のされ方をしたファイルの上へのrenameを拒否します。そういうハンドルを呼び出し中開いたままにすると、renameはデータが書かれてフラッシュされた後の最後の最後で失敗します。一番ちゃんと処理されてほしいケースです

var
  Before: TBytes;
  Holder: TFileStream;
  Search: TSearchRec;
begin
  Before := TFile.ReadAllBytes(Target);   // System.IOUtils
  Holder := TFileStream.Create(Target, fmOpenRead or fmShareDenyNone);
  try
    Assert(Pdf.RepairQDFFile(Source, Target) = 0);
    Assert(Pdf.LastErrorCode = PDFLIB_ERROR_QDF_WRITE);
  finally
    Holder.Free;
  end;
  // これらのチェックは自前テスト向けの提案であり、ライブラリ呼び出しではない
  Assert(BytesEqual(Before, TFile.ReadAllBytes(Target)));  // BytesEqual:自前のヘルパー
  // 一時ファイルが残っていてはならない
  if FindFirst(ExtractFilePath(Target) + '.pdflib-qdf-*.tmp',
    faAnyFile, Search) = 0 then
  try
    Assert(False, 'temporary file left behind: ' + Search.Name);
  finally
    FindClose(Search);
  end;
end;

0だけを信じるのでなくコードを読む習慣については、サイレント失敗の診断の記事がAPIの残りの部分で同じパターンを歩きます

保証が止まる場所

この約束が覆うのは、プロセスが見られる失敗です。一時ファイルの作成とrenameの間でプロセスが殺されると、finallyブロックは走らず、.pdflib-qdf-<GUID>.tmpファイルがディレクトリに残ります。宛先はまだ無傷で、それが大事な点ですが、残骸の掃除は自分の仕事です。停電はファイルシステムの問題です。データはフラッシュされ、renameはライトスルーですが、POSIXではディレクトリエントリは別個には同期されません。耐久性は、その上にファイルシステムが提供するもの次第です

成功したrenameは新しいファイルオブジェクトも生み出し、旧オブジェクトのいくつかの性質は同行しません。DACLは意図的に運ばれます。所有権は違います。読まれるのはDACL_SECURITY_INFORMATIONだけなので、公開されたファイルの所有者は修復を実行したアカウントです。一時ファイルはFILE_ATTRIBUTE_NORMALで作られるので、隠し、アーカイブなどの属性ビットは新しく始まり、旧ファイルの代替データストリームは消えます。POSIXでは公開されたファイルは一時ファイルの0600モードを保ちます。グループに読み取りが必要なら、あとでchmodしてください。宛先の属性とDACLは一時ファイルが存在する前に読まれるので、同じパスへの2つ目のライターの調停は自分の仕事です

SaveQDFToFileとConvertFileToQDFは直接書く

RepairQDFFileは、一時ファイルを通して公開する唯一のQDFエントリポイントです。SaveQDFToFileとConvertFileToQDFは出力をfmCreateで開き、変換をそのままそこへストリームします。ストリームへの更新追記の記事のインクリメンタル経路が、渡されたストリームへ書くのと同じ要領です。この分割は意図的です。あの2つの呼び出しは、すでにロードと検証を済ませた文書を新しいデバッグ成果物に変えます。途中で拒否され得る信頼できない入力はなく、出力パスも通常は新しいファイルです。RepairQDFFileは手で編集された信頼できないファイルを受け取り、日常的に同じパスへ向け直されます。その組み合わせこそが、余分な仕掛けを正当化します

QDFダンプにアトミックな置き換えが欲しいときは、自分の短いラッパーで足ります:

// 自前のヘルパー:ターゲットの隣にQDFを書き、1回のrenameで置き換える
function SaveQDFReplacing(Pdf: TPDFlib; const FileName: string): Boolean;
var
  ID: TGUID;
  TempName: string;
begin
  CreateGUID(ID);
  TempName := ExtractFilePath(ExpandFileName(FileName)) +
    '.myapp-qdf-' + GUIDToString(ID) + '.tmp';
  try
    Result := (Pdf.SaveQDFToFile(TempName) = 1) and
      MoveFileExW(PWideChar(WideString(TempName)), PWideChar(WideString(FileName)),
        MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH);
  finally
    if FileExists(TempName) then
      DeleteFile(TempName);   // 何かが失敗したときだけ存在する
  end;
end;

この版は、RepairQDFFileが行うDACLコピーと明示的なデータフラッシュを省きます。スクラッチやデバッグフォルダに着地するQDFダンプには、それが適量の仕掛けだと思います。ダンプがロックダウンされたパーミッションのファイルを置き換えるなら、修復経路を通すか、セキュリティ記述子を自分でコピーしてください

アトミックな修復出力、QDF変換呼び出し、上記のエラーコードは、すべてPDF Library for Delphiに同梱されます。ここにあるほかの記事で扱ったクロスリファレンス復旧、インクリメンタル更新、暗号化対応の隣です