Articol tehnic

ID PDF determinist în Delphi pentru build-uri reproductibile

losLab PDF Library poate produce ieșire PDF identică pe biți pentru intrare identică odată ce apelați SetDeterministicDocumentID(1). Implicit, array-ul /ID din trailer este un digest MD5 al ceasului de sistem, astfel încât două rulări ale aceluiași generator diferă cel puțin în acei octeți. Modul determinist derivă în schimb /ID dintr-un seed stabil, ceea ce restaurează build-urile reproductibile

Simptomul apare de obicei în CI înainte ca cineva să îl caute. Șablonul nu s-a schimbat, înregistrarea de intrare nu s-a schimbat, fonturile nu s-au schimbat, iar PDF-ul generat tot are un hash diferit la fiecare rulare a pipeline-ului. Cache-urile de build nu se lovesc niciodată. Stocarea adresabilă după conținut acumulează un blob nou la fiecare build nocturn. Diferențele de regresie la nivel de octet apar pe fișiere pe care nimeni nu le-a atins. Urmăriți diferența până la octeții reali și este aproape întotdeauna aceeași mână de cifre hexazecimale aflate în trailerul fișierului

La ce servește array-ul ID din trailer

Trailerul /ID este un marcator de identitate al fișierului, nu un checksum al conținutului. ISO 32000-1 §14.4 îl definește ca un array de două șiruri de octeți: primul element este identificatorul permanent atribuit la crearea documentului și menit să supraviețuiască fiecărei editări ulterioare, iar al doilea element este identificatorul schimbător pe care un writer îl reîmprospătează de fiecare dată când fișierul este modificat. Împreună, ele permit unui sistem să decidă dacă două fișiere sunt revizuiri ale unui singur document sau două documente fără legătură. §7.5.5 face intrarea practic obligatorie, deoarece trailerul trebuie să poarte /ID ori de câte ori poartă și /Encrypt

Nimic în specificație nu spune cum se calculează valoarea. Recomandarea este un digest din lucruri precum ora curentă, calea fișierului, dimensiunea fișierului și dicționarul de informații al documentului, iar ceasul de sistem este ingredientul care face rezultatul unic. Aceasta este exact proprietatea pe care o doriți pentru identitate și exact proprietatea care distruge reproductibilitatea, motiv pentru care aceasta trebuie să fie un comutator explicit, nu o schimbare tacită de comportament

De ce același build produce un PDF diferit de fiecare dată?

Pentru că identificatorul implicit este derivat din momentul generării. Istoric, losLab PDF Library construia șirurile /ID dintr-un MD5 al timestamp-ului curent, astfel încât un document creat de două ori la o secundă distanță poartă doi identificatori permanenți diferiți chiar și atunci când fiecare alt octet din fișier este identic. Costul din aval este real: un sistem de build care indexează artefactele după hash nu poate reutiliza niciodată un pas PDF, un depozit de obiecte cu deduplicare păstrează o copie per build în loc de o copie per document, iar un recenzent care se uită la un diff binar trebuie să demonstreze că singura schimbare este zgomot înainte de a avea încredere în restul diff-ului. Generarea deterministă a /ID există pentru a elimina acel zgomot, în același spirit cu lucrul de stabilitate a layout-ului descris în notele despre fluxurile de obiecte și fluxurile cross reference

Trecerea la un identificator reproductibil

Modul determinist este opt in, per document, și dezactivat implicit, astfel încât ieșirea existentă rămâne neschimbată până când îl solicitați. SetDeterministicDocumentID acceptă 0 sau 1 și returnează 1 când valoarea a fost acceptată, 0 pentru orice altceva în afara intervalului; GetDeterministicDocumentID raportează starea curentă. SetDocumentIDSeed furnizează un șir seed explicit care prevalează asupra tuturor celorlalte, iar transmiterea unui seed gol revine la seed-ul derivat. GetDocumentFileID citește /ID[0] după salvare, astfel încât puteți să îl înregistrați sau să faceți o asercțiune pe el

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;

Reîmprospătarea are loc la momentul salvării, nu când comutați flag-ul, astfel încât activarea modului determinist la finalul construirii unui document tot are efect. Aceasta mai înseamnă că un seed schimbat ajunge în fișier la următoarea salvare completă: setați seed-ul A, salvați, setați seed-ul B, salvați, iar cele două fișiere poartă identificatori diferiți, în timp ce restaurarea seed-ului A restaurează valoarea originală. Un seed explicit este alegerea corectă ori de câte ori documentul dumneavoastră are o cheie stabilă naturală, cum ar fi un număr de factură, o revizuire de înregistrare sau un identificator de commit git, deoarece decuplează identificatorul de metadatele incidentale

De unde vine seed-ul când nu furnizați unul?

Fără un seed explicit, losLab PDF Library derivă unul din starea documentului care ar trebui să fie invariantă între regenerări identice: antetul versiunii PDF, numărul de pagini și fiecare intrare din dicționarul de informații al documentului. Valorile de tip string și name sunt preluate ca atare, alte tipuri de obiecte contribuie cu forma lor serializată, iar totul este hashuit în șirurile /ID. Consecința importantă este că CreationDate și ModDate fac parte din dicționarul de informații și, prin urmare, fac parte din seed prin proiectare. Două rulări câștigă același identificator doar când produc într-adevăr aceleași metadate de document

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

Fixarea ModDate cu cheia 8 are un rol dublu, iar aceasta este partea care îi prinde pe oameni pe picior greșit. Un /ID determinist singur nu face fișierul identic pe biți, deoarece calea de salvare ștampilează ModDate cu ora curentă, cu excepția cazului în care apelantul a setat-o explicit. Setarea cheii 8 marchează valoarea ca furnizată de apelant și suprimă acea ștampilă. Dacă doriți un fișier reproductibil, nu doar un identificator reproductibil, tratați timestamp-urile din metadate ca inputuri de build: derivați-le din înregistrarea sursă sau dintr-un epoch fix, niciodată din Now

De ce rescrierea ID-ului strică un PDF criptat?

Pentru că /ID[0] nu este doar metadate într-un document criptat, este material de cheie. ISO 32000-1 §7.6.3.3 Algoritmul 2 introduce primul element al identificatorului de fișier în calculul cheii de criptare pentru handler-ul de securitate standard la reviziile 2 până la 4, alături de parola completată, valoarea /O și biții de permisiune. Cheia derivată produce apoi șirul de validare /U pe care un cititor îl verifică la deschidere, iar cheia de fișier este derivată și memorată în cache când apelați Encrypt sau când este încărcat un document criptat, ambele întâmplându-se înainte de salvare. Rescrierea identificatorului în timpul salvării ar emite deci un fișier structural valid al cărui verificare /U eșuează la redeschidere: nu o corupere subtilă, ci un document pe care nimeni nu îl poate deschide, inclusiv dumneavoastră. De aceea reîmprospătarea deterministă este restricționată la documentele care nu poartă stare de criptare, și de aceea un document criptat păstrează orice /ID avea deja, mod determinist sau nu, iar setarea pur și simplu nu are efect pe acea cale. Gestionarea reviziilor asociate și semantica permisiunilor sunt acoperite în prezentarea despre criptarea PDF și auditarea permisiunilor. Rețineți de asemenea că, la restaurarea criptării, calea reîmprospătează doar /ID[1], identificatorul de schimbare, exact cum intenționează §14.4

De ce salvările incrementale păstrează identificatorul original

A doua limită este modul append. O actualizare incrementală lasă neatins fiecare octet anterior al fișierului și scrie o revizie nouă după el, iar permanența /ID[0] conform §14.4 este ceea ce spune unui consumator că noua revizie aparține aceluiași document ca cea veche. Rescrierea ei ar rupe acea legătură, ar contrazice reviziile deja aflate în fișier și ar interfera cu semantica semnăturilor, deoarece o semnătură acoperă un interval de octeți al unei revizii specifice a unui document specific. losLab PDF Library reîmprospătează deci identificatorul determinist doar la salvările complete și niciodată în modul append, ceea ce păstrează intactă garanția descrisă în articolul despre actualizările incrementale PDF și append la flux

Un singur punct de trecere pentru generarea identificatorului

Toată generarea /ID din losLab PDF Library trece acum printr-o singură rutină internă, NewFileIDString, ceea ce face comutatorul determinist demn de încredere, nu un petic pe o singură cale de cod. Crearea documentului gol, crearea leneșă a unui array /ID lipsă la cerere și calea de restaurare a amprentei de criptare o apelează pe toate, astfel încât există exact un loc pe unde ceasul de sistem ar putea reintra pe furiș. Aceasta mai înseamnă că variante viitoare, precum un identificator derivat din conținut, sunt o schimbare într-o singură funcție, nu un audit al întregului serializator

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

Conectați această comparație în suita dumneavoastră de teste înainte de a vă baza pe ieșire reproductibilă oriunde altundeva, deoarece eșuează zgomotos în clipa în care o funcționalitate nouă reintroduce un timestamp. Reproductibilitatea este altfel o proprietate care se degradează tacit, iar o singură asercțiune peste două salvări în memorie costă aproape nimic la fiecare build

API-ul de identificator determinist prezentat aici vine cu losLab PDF Library pentru Delphi și C++Builder, alături de referința completă pentru informațiile documentului, criptare și salvare incrementală