Technický článek

Deterministické PDF ID v Delphi pro reprodukovatelné buildy

losLab PDF Library dokáže vytvořit bajtově identický výstup PDF pro identický vstup, jakmile zavoláte SetDeterministicDocumentID(1). Ve výchozím nastavení je pole trailer /ID digest MD5 z hodin stěny, takže dva běhy stejného generátoru se liší přinejmenším v těchto bajtech. Deterministický režim místo toho odvozuje /ID ze stabilního seedu, což obnovuje reprodukovatelné buildy

Symptom se obvykle objeví v CI ještě dřív, než jej někdo začne hledat. Šablona se nezměnila, vstupní záznam se nezměnil, fonty se nezměnily, a vygenerovaný PDF se přesto hashuje jinak při každém běhu pipeline. Cache buildů nikdy nezasáhne. Content addressable storage nabírá čerstvý blob za každý noční build. Rozdíly na úrovni bajtů se rozsvítí u souborů, kterých se nikdo nedotkl. Sledujte rozdíl až ke skutečným bajtům a téměř vždy je to stejná hrstka hexadecimálních číslic sedících v trailer souboru

K čemu slouží pole ID trailer

Trailer /ID je značka identity souboru, ne kontrolní součet obsahu. ISO 32000-1 §14.4 jej definuje jako pole dvou bajtových řetězců: první prvek je trvalý identifikátor přiřazený při vytvoření dokumentu a má přežít každou pozdější úpravu, a druhý prvek je měnící se identifikátor, který writer obnovuje při každé úpravě souboru. Společně umožňují systému rozhodnout, zda jsou dva soubory revizemi jednoho dokumentu, nebo dvěma nesouvisejícími dokumenty. §7.5.5 dělá tuto položku prakticky povinnou, protože trailer musí nést /ID, kdykoli nese i /Encrypt

Nic ve specifikaci neříká, jak hodnotu spočítat. Doporučením je digest z věcí jako aktuální čas, cesta k souboru, velikost souboru a slovník informací o dokumentu, a hodiny stěny jsou tou přísadou, která výsledek dělá jedinečným. To je přesně vlastnost, kterou chcete pro identitu, a přesně vlastnost, která ničí reprodukovatelnost, a proto to musí být explicitní přepínač, ne tichá změna chování

Proč stejný build vyprodukuje pokaždé jiné PDF?

Protože výchozí identifikátor je odvozen z okamžiku generování. Historicky losLab PDF Library stavěla řetězce /ID z MD5 aktuálního časového razítka, takže dokument vytvořený dvakrát o sekundu později nese dva různé trvalé identifikátory, i když je každý další bajt v souboru identický. Náklady po proudu jsou reálné: build systém, který klíčuje artefakty podle hashe, nikdy nemůže znovu použít krok PDF, deduplikující úložiště objektů drží jednu kopii za build místo jedné kopie za dokument, a recenzent dívající se na binární diff musí dokázat, že jedinou změnou je šum, ještě než uvěří zbytku diffu. Deterministické generování /ID existuje proto, aby tento šum odstranilo, ve stejném duchu jako práce na stabilitě rozvržení popsaná v poznámkách o object streamech a cross reference streamech

Přepnutí na reprodukovatelný identifikátor

Deterministický režim je opt-in, na úrovni dokumentu, a ve výchozím nastavení vypnutý, takže existující výstup zůstává nezměněný, dokud si o něj sami nepožádáte. SetDeterministicDocumentID přijímá 0 nebo 1 a vrací 1, byla-li hodnota přijata, 0 pro cokoli mimo rozsah; GetDeterministicDocumentID hlásí aktuální stav. SetDocumentIDSeed dodává explicitní řetězec seedu, který vyhrává nad vším ostatním, a předání prázdného seedu se vrátí k odvozenému seedu. GetDocumentFileID po uložení zpětně přečte /ID[0], takže jej můžete zalogovat nebo na něj asertovat

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;

Obnovení se děje v okamžiku uložení, ne když přepnete příznak, takže zapnutí deterministického režimu pozdě během sestavování dokumentu se přesto projeví. To také znamená, že změněný seed se do souboru dostane při dalším plném uložení: nastavte seed A, uložte, nastavte seed B, uložte, a oba soubory nesou různé identifikátory, zatímco obnovení seedu A obnoví původní hodnotu. Explicitní seed je správná volba vždy, když má váš dokument přirozený stabilní klíč, jako je číslo faktury, revize záznamu nebo identifikátor git commitu, protože odděluje identifikátor od nahodilých metadat

Odkud pochází seed, když žádný nedodáte?

Bez explicitního seedu losLab PDF Library odvozuje jeden ze stavu dokumentu, který by měl být invariantní napříč identickými regeneracemi: hlavička verze PDF, počet stránek a každá položka ve slovníku informací o dokumentu. Řetězcové a jmenné hodnoty se berou doslovně, ostatní typy objektů přispívají svou serializovanou formou, a celá věc se hashuje do řetězců /ID. Důležitým důsledkem je, že CreationDate a ModDate jsou součástí slovníku informací a tudíž záměrně součástí seedu. Dva běhy získají stejný identifikátor jen tehdy, když skutečně vyprodukují stejná metadata dokumentu

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');

Ukotvení ModDate klíčem 8 slouží dvojímu účelu, a to je část, na které lidé zakopávají. Deterministické /ID samo o sobě nedělá soubor bajtově identickým, protože ukládací cesta razí ModDate aktuálním časem, pokud volající hodnotu explicitně nenastavil. Nastavení klíče 8 označí hodnotu jako dodanou volajícím a toto razítko potlačí. Chcete-li reprodukovatelný soubor, ne pouze reprodukovatelný identifikátor, zacházejte s časovými razítky metadat jako se vstupy buildu: odvoďte je ze zdrojového záznamu nebo z pevné epochy, nikdy z Now

Proč přepsání ID rozbije šifrovaný PDF?

Protože /ID[0] v šifrovaném dokumentu není jen metadata, je to klíčový materiál. ISO 32000-1 §7.6.3.3 Algoritmus 2 vkládá první prvek identifikátoru souboru do výpočtu šifrovacího klíče pro standardní bezpečnostní handler v revizích 2 až 4, spolu s vycpaným heslem, hodnotou /O a bity oprávnění. Odvozený klíč pak produkuje validační řetězec /U, který čtečka kontroluje při otevření, a klíč souboru se odvozuje a ukládá do cache, když zavoláte Encrypt, nebo když se načte šifrovaný dokument, obojí se děje před uložením. Přepsání identifikátoru během uložení by tedy vyprodukovalo strukturálně platný soubor, jehož kontrola /U selže při opětovném otevření: ne subtilní poškození, ale dokument, který nikdo neotevře, včetně vás. Proto je deterministické obnovení omezeno na dokumenty, které nenesou šifrovací stav, a proto si šifrovaný dokument ponechává jakékoli /ID, které už měl, ať už s deterministickým režimem, nebo bez něj, a nastavení na tuto cestu jednoduše nemá žádný vliv. Související zpracování revizí a sémantiku oprávnění popisuje průvodce šifrováním PDF a auditem oprávnění. Všimněte si také, že cesta obnovy šifrování obnovuje pouze /ID[1], identifikátor změny, přesně tak, jak §14.4 zamýšlí

Proč si přírůstková uložení ponechávají původní identifikátor

Druhá hranice je režim připojování. Přírůstková aktualizace ponechává každý dřívější bajt souboru nedotčený a zapisuje novou revizi za něj, a trvalost /ID[0] napříč §14.4 je to, co konzumentovi říká, že nová revize patří ke stejnému dokumentu jako ta stará. Její přepsání by tuto vazbu přerušilo, odporovalo by revizím, které už v souboru sedí, a zasahovalo by do sémantiky podpisů, protože podpis pokrývá rozsah bajtů konkrétní revize konkrétního dokumentu. losLab PDF Library proto obnovuje deterministický identifikátor pouze při plných uloženích a nikdy během režimu připojování, což zachovává neporušenou záruku popsanou v článku o přírůstkových aktualizacích PDF a připojování ke streamu

Jedno hrdlo lahve pro generování identifikátoru

Veškeré generování /ID v losLab PDF Library nyní protéká jednou interní rutinou, NewFileIDString, což je to, co dělá deterministický přepínač důvěryhodným místo záplaty na jedné cestě kódu. Vytvoření prázdného dokumentu, líné vytvoření chybějícího pole /ID na vyžádání a cesta obnovy šifrovacího otisku ji všechny volají, takže existuje přesně jedno místo, kde by mohly hodiny stěny znovu prosáknout zpět. Znamená to také, že budoucí varianty, jako identifikátor odvozený z obsahu, jsou změnou jedné funkce místo auditu celého serializátoru

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');

Toto porovnání zapojte do své testovací sady ještě dřív, než se na reprodukovatelný výstup budete spoléhat kdekoli jinde, protože hlasitě selže v okamžiku, kdy nějaká nová funkce znovu zavede časové razítko. Reprodukovatelnost je vlastnost, která jinak tiše upadá, a jedna asserce nad dvěma uloženími v paměti stojí na spuštění při každém buildu téměř nic

API deterministického identifikátoru zde ukázané je součástí losLab PDF Library pro Delphi a C++Builder, spolu s kompletní referencí informací o dokumentu, šifrování a přírůstkového ukládání