技術記事

再現可能ビルドのためのDelphi決定論的PDF ID

losLab PDF Libraryは、SetDeterministicDocumentID(1)を呼び出せば、同一の入力に対してバイト単位で同一のPDF出力を生成できる。デフォルトでは、トレーラーの/ID配列は壁時計時刻のMD5ダイジェストであり、同じジェネレーターを2回実行すると少なくともそのバイト部分は異なる。決定論的モードでは、代わりに/IDを安定したシードから導出し、再現可能ビルドを回復する

この症状は普通、誰も探しに行く前にCIで表面化する。テンプレートは変わっていない、入力レコードも変わっていない、フォントも変わっていない、それでもパイプラインを走らせるたびに生成されたPDFのハッシュが変わる。ビルドキャッシュは決してヒットしない。コンテンツアドレス指定ストレージは、夜間ビルドごとに新しいブロブを蓄積していく。誰も触っていないファイルでバイトレベルの回帰diffが光る。diffを実際のバイトまで追いかけると、ほとんど常に、ファイルのトレーラーに座っている同じ一握りの16進数字にたどり着く

トレーラーID配列は何のためにあるのか

トレーラーの/IDはファイルの識別マーカーであり、内容のチェックサムではない。ISO 32000-1 §14.4は、これを2つのバイト文字列の配列として定義している:最初の要素はドキュメント作成時に割り当てられる永続的な識別子で、その後のあらゆる編集を通じて生き残ることを意図しており、2番目の要素はファイルが変更されるたびにライターが更新する変化する識別子である。両者を合わせることで、2つのファイルが1つのドキュメントの改訂版なのか、それとも無関係な2つのファイルなのかをシステムが判断できる。§7.5.5は、トレーラーが/Encryptを持つ場合には必ず/IDも持たなければならないため、実質的にこのエントリを必須にしている

この値をどう計算するかについて仕様は何も定めていない。推奨されるのは、現在時刻、ファイルパス、ファイルサイズ、ドキュメント情報辞書といったものの組み合わせのダイジェストであり、壁時計時刻は結果を一意にする材料だ。それはまさに識別のために欲しい特性であり、まさに再現性を破壊する特性でもある。だからこそ、これは暗黙の挙動変更ではなく明示的なスイッチであるべきなのだ

なぜ同じビルドが毎回異なるPDFを生成するのか

デフォルトの識別子は生成された瞬間から導出されるからだ。歴史的に、losLab PDF Libraryは/ID文字列を現在のタイムスタンプのMD5から構築していたので、1秒違いで2回作成されたドキュメントは、ファイル内の他のすべてのバイトが同一であっても、2つの異なる永続識別子を持つことになる。下流にかかるコストは実際のものだ:ハッシュでアーティファクトをキー付けするビルドシステムはPDF生成ステップを再利用できず、重複排除するオブジェクトストアはドキュメントごとに1コピーではなくビルドごとに1コピーを保持し、バイナリdiffを見るレビュアーは、残りのdiffを信用する前に唯一の変更がノイズであることを証明しなければならない。決定論的な/ID生成は、そのノイズを取り除くために存在しており、オブジェクトストリームとクロスリファレンスストリームに関するノートで説明されているレイアウト安定性の取り組みと同じ精神に基づいている

再現可能な識別子への切り替え

決定論的モードはドキュメントごとのオプトインであり、デフォルトでオフになっているため、明示的に要求するまで既存の出力は変わらない。SetDeterministicDocumentIDは0か1を受け付け、値が受理されれば1、範囲外なら0を返す。GetDeterministicDocumentIDは現在の状態を報告する。SetDocumentIDSeedは他のすべてに優先する明示的なシード文字列を供給し、空のシードを渡すと導出シードに戻る。GetDocumentFileIDは保存後に/ID[0]を読み戻すので、ログに記録したりアサートしたりできる

var
  Lib: TPDFlib;
  FileID: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed('invoice-4471-rev3');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Invoice 4471');
    Lib.SaveToFile('invoice.pdf');
    FileID := Lib.GetDocumentFileID;   // identical on every run
  finally
    Lib.Free;
  end;
end;

更新は保存時に行われ、フラグを切り替えた時ではないので、ドキュメントビルドの後半で決定論的モードを有効にしても効果はある。それはまた、変更されたシードが次のフル保存時にファイルに反映されることも意味する:シードAを設定して保存し、シードBを設定して保存すると、2つのファイルは異なる識別子を持ち、シードAに戻せば元の値に戻る。ドキュメントが請求書番号、レコードの改訂、gitコミット識別子といった自然な安定キーを持っている場合は常に、明示的なシードが正しい選択である。なぜなら識別子を付随的なメタデータから切り離すからだ

シードを指定しない場合、どこから来るのか

明示的なシードなしでは、losLab PDF Libraryは同一の再生成間で不変であるべきドキュメント状態からシードを導出する:PDFバージョンヘッダー、ページ数、そしてドキュメント情報辞書のすべてのエントリだ。文字列と名前の値はそのまま取られ、他のオブジェクト型はシリアライズされた形式で寄与し、全体が/ID文字列にハッシュされる。重要な帰結は、CreationDateModDateが情報辞書の一部であり、したがって設計上シードの一部にもなるということだ。2回の実行が同じ識別子を得るのは、実際に同じドキュメントメタデータを生成した場合だけである

Lib.SetDeterministicDocumentID(1);
// No SetDocumentIDSeed: the seed is derived from document state,
// so the timestamps in the Info dictionary have to be pinned.
Lib.SetInformation(2, 'Quarterly Report');        // Title
Lib.SetInformation(5, 'reporting-service 4.2');   // Creator
Lib.SetInformation(7, 'D:20260101000000Z');       // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z');       // ModDate
Lib.SaveToFile('report.pdf');

キー8でModDateを固定することには二重の役目があり、ここが人を引っかける部分だ。決定論的な/IDだけではファイルがバイト単位で同一になるわけではない。呼び出し側が明示的に設定していない限り、保存パスは現在時刻でModDateをスタンプするからだ。キー8を設定すると、その値が呼び出し側から供給されたものとしてマークされ、そのスタンプが抑制される。単に再現可能な識別子ではなく再現可能なファイルが欲しいなら、メタデータのタイムスタンプをビルド入力として扱うべきだ:それらをソースレコードか固定エポックから導出し、決してNowからは導出しない

なぜIDの書き換えは暗号化されたPDFを壊すのか

暗号化されたドキュメントにおいて/ID[0]は単なるメタデータではなく、鍵材料そのものだからだ。ISO 32000-1 §7.6.3.3のAlgorithm 2は、リビジョン2から4の標準セキュリティハンドラーにおける暗号鍵計算に、パディングされたパスワード、/O値、権限ビットと並んでファイル識別子の最初の要素を投入する。導出された鍵は、リーダーが開くときにチェックする/U検証文字列を生成する。ファイル鍵はEncryptを呼び出したとき、または暗号化されたドキュメントを読み込んだときに導出されキャッシュされ、どちらも保存の前に発生する。したがって保存時に識別子を書き換えると、構造的には有効だが再度開いたときに/Uチェックが失敗するファイルを生成してしまう——それは微妙な破損ではなく、あなた自身も含めて誰も開けないドキュメントだ。だからこそ、決定論的な更新は暗号化状態を持っていないドキュメントに限定されており、暗号化されたドキュメントは決定論的モードかどうかにかかわらず既に持っている/IDをそのまま保持し、この設定はそのパスには何の効果も持たない。関連するリビジョンの扱いと権限のセマンティクスは、PDF暗号化と権限監査の解説で扱われている。暗号化復元パスは/ID[1]、つまり変更識別子だけを更新することにも注意してほしい——これはまさに§14.4が意図する通りだ

なぜインクリメンタル保存は元の識別子を保持するのか

2つ目の境界は追記モードだ。インクリメンタル更新はファイルの以前のバイトをすべて変更せずに残し、その後に新しいリビジョンを書き込む。§14.4を通じた/ID[0]の永続性こそが、新しいリビジョンが古いものと同じドキュメントに属していることをコンシューマーに伝える。それを書き換えることはそのリンクを断ち切り、すでにファイル内にあるリビジョンと矛盾し、署名のセマンティクスにも干渉する。署名は特定のドキュメントの特定のリビジョンのバイト範囲をカバーするからだ。したがってlosLab PDF Libraryはフル保存時にのみ決定論的識別子を更新し、追記モード中は決して更新しない。これによりPDFインクリメンタル更新とストリームへの追記の記事で述べられている保証が保たれる

識別子生成の一本化されたチョークポイント

losLab PDF Libraryにおける/ID生成はすべて、現在1つの内部ルーチンNewFileIDStringを通過するようになっており、これが決定論的スイッチを、1つのコードパスへのパッチではなく信頼できるものにしている。空のドキュメント作成、欠落した/ID配列のオンデマンドな遅延作成、そして暗号化フィンガープリント復元パスはすべてこれを呼び出す。つまり壁時計時刻が漏れ戻り得る場所が正確に1つだけになる。これはまた、コンテンツ由来の識別子のような将来のバリエーションが、シリアライザ全体の監査ではなく1つの関数への変更で済むことも意味する

function BuildQuote(const Seed: WideString): AnsiString;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed(Seed);
    Lib.SetInformation(7, 'D:20260101000000Z');
    Lib.SetInformation(8, 'D:20260101000000Z');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Quote 8812');
    Result := Lib.SaveToString;
  finally
    Lib.Free;
  end;
end;

// Regression guard: two independent builds, one byte sequence.
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
  WriteLn('reproducible')
else
  WriteLn('nondeterminism leaked into the output');

他の何かで再現可能な出力に依存する前に、この比較をテストスイートに組み込んでおくべきだ。何か新機能がタイムスタンプを再導入した瞬間に、これは大きな音を立てて失敗する。再現性はそうしなければ静かに劣化していく性質のものであり、メモリ上の2回の保存に対する1つのアサーションは、あらゆるビルドで実行してもほとんどコストがかからない

ここで紹介した決定論的識別子APIは、Delphi・C++Builder向けlosLab PDF Libraryに、ドキュメント情報、暗号化、インクリメンタル保存の完全なリファレンスとともに付属している