Teknisk artikel

Deterministisk PDF-ID i Delphi til reproducerbare builds

losLab PDF Library kan producere byte-identisk PDF-output for identisk input, når man kalder SetDeterministicDocumentID(1). Som standard er trailer-arrayet /ID en MD5-digest af vægguret, så to kørsler af den samme generator afviger i mindst de bytes. Deterministisk tilstand udleder i stedet /ID fra et stabilt seed, hvilket genopretter reproducerbare builds

Symptomet dukker som regel op i CI, før nogen går det efter i sømmene. Skabelonen er ikke ændret, inputposten er ikke ændret, skrifttyperne er ikke ændret, og den genererede PDF hasher stadig forskelligt ved hver pipeline-kørsel. Build-caches rammer aldrig. Content-addressable storage samler en frisk blob op pr. natlig build. Byte-niveau regressionsdiffs lyser op på filer, ingen har rørt. Følger man diffen ned til de faktiske bytes, er det næsten altid det samme håndfulde hex-cifre, der sidder i filens trailer

Hvad trailerens ID-array er til

Trailerens /ID er en filidentitetsmarkør, ikke en tjeksum af indholdet. ISO 32000-1 §14.4 definerer den som et array af to byte-strenge: det første element er den permanente identifikator, der tildeles ved dokumentets oprettelse, og som er tænkt til at overleve enhver senere redigering, og det andet element er den skiftende identifikator, som en writer opdaterer hver gang filen ændres. Sammen lader de et system afgøre, om to filer er revisioner af ét dokument eller to urelaterede dokumenter. §7.5.5 gør posten i praksis obligatorisk, da traileren skal bære /ID, når den også bærer /Encrypt

Intet i specifikationen siger, hvordan værdien skal beregnes. Anbefalingen er en digest af ting som det aktuelle tidspunkt, filstien, filstørrelsen og document information-dictionary'et, og vægguret er den ingrediens, der gør resultatet unikt. Det er netop den egenskab, man ønsker for identitet, og netop den egenskab, der ødelægger reproducerbarhed, hvilket er grunden til, at dette skal være en eksplicit switch snarere end en tavs adfærdsændring

Hvorfor producerer den samme build en anden PDF hver gang?

Fordi standardidentifikatoren udledes af genereringsøjeblikket. Historisk byggede losLab PDF Library /ID-strengene ud fra en MD5 af det aktuelle tidsstempel, så et dokument oprettet to gange med et sekunds mellemrum bærer to forskellige permanente identifikatorer, selv når hver eneste anden byte i filen er identisk. Omkostningen nedstrøms er reel: et build-system, der nøgler artefakter efter hash, kan aldrig genbruge et PDF-trin, en dedupleringsstore beholder én kopi pr. build i stedet for én kopi pr. dokument, og en reviewer, der ser på en binær diff, må bevise, at den eneste ændring er støj, før resten af diffen kan stoles på. Deterministisk /ID-generering findes for at fjerne den støj, i samme ånd som stabilitetsarbejdet med layout beskrevet i noterne om object streams og cross-reference-streams

Skift til en reproducerbar identifikator

Deterministisk tilstand er opt-in, pr. dokument, og slået fra som standard, så eksisterende output er uændret, indtil man beder om det. SetDeterministicDocumentID accepterer 0 eller 1 og returnerer 1, når værdien blev accepteret, 0 for alt uden for det interval; GetDeterministicDocumentID rapporterer den aktuelle tilstand. SetDocumentIDSeed leverer en eksplicit seed-streng, der vinder over alt andet, og at sende et tomt seed vender tilbage til det udledte seed. GetDocumentFileID læser /ID[0] tilbage efter gemning, så man kan logge det eller assertere på det

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;

Genopfrisk sker ved gemning, ikke når flaget slås til, så det stadig virker at aktivere deterministisk tilstand sent i en dokumentopbygning. Det betyder også, at et ændret seed når filen ved næste fulde gemning: sæt seed A, gem, sæt seed B, gem, og de to filer bærer forskellige identifikatorer, mens genindsættelse af seed A genopretter den oprindelige værdi. Et eksplicit seed er det rigtige valg, når ens dokument har en naturlig stabil nøgle, som fx et fakturanummer, en post-revision eller en git commit-identifikator, fordi det afkobler identifikatoren fra tilfældige metadata

Hvor kommer seedet fra, når man ikke selv angiver ét?

Uden et eksplicit seed udleder losLab PDF Library ét fra dokumenttilstand, der burde være invariant på tværs af identiske genereringer: PDF-versionshovedet, sideantallet og hver post i document information-dictionary'et. Streng- og navneværdier tages ordret, andre objekttyper bidrager med deres serialiserede form, og det hele hashes ind i /ID-strengene. Den vigtige konsekvens er, at CreationDate og ModDate er en del af informationsdictionary'et og derfor bevidst en del af seedet. To kørsler får kun samme identifikator, når de reelt producerer samme dokumentmetadata

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

At fastlåse ModDate med nøgle 8 gør dobbelt gavn, og det er den del, der fanger folk. Et deterministisk /ID alene gør ikke filen byte-identisk, fordi gemme-stien stempler ModDate med det aktuelle tidspunkt, medmindre den kaldende har sat den eksplicit. At sætte nøgle 8 markerer værdien som leveret af kalderen og undertrykker det stempel. Vil man have en reproducerbar fil frem for blot en reproducerbar identifikator, skal man behandle metadata-tidsstempler som build-input: udled dem fra kildeposten eller fra en fast epoke, aldrig fra Now

Hvorfor ødelægger omskrivning af ID'et en krypteret PDF?

Fordi /ID[0] ikke bare er metadata i et krypteret dokument — det er nøglemateriale. ISO 32000-1 §7.6.3.3 Algoritme 2 fodrer det første element af filidentifikatoren ind i beregningen af krypteringsnøglen for standardsikkerhedshandleren ved revision 2 til 4, sammen med den paddede adgangskode, /O-værdien og tilladelsesbitene. Den udledte nøgle producerer så /U-valideringsstrengen, som en læser tjekker ved åbning, og filnøglen udledes og caches, når man kalder Encrypt, eller når et krypteret dokument indlæses — begge dele sker før gemningen. At omskrive identifikatoren under gemningen ville derfor udsende en strukturelt gyldig fil, hvis /U-tjek fejler ved genåbning: ikke en subtil korruption, men et dokument ingen kan åbne, heller ikke en selv. Det er grunden til, at den deterministiske opdatering er begrænset til dokumenter, der ikke bærer krypteringstilstand, og hvorfor et krypteret dokument beholder hvilket /ID det allerede havde, deterministisk tilstand eller ej, og indstillingen simpelthen ingen effekt har på den sti. Den relaterede revisionshåndtering og tilladelsessemantik dækkes i gennemgangen af audit af PDF-kryptering og tilladelser. Bemærk også, at gendannelsesstien for kryptering kun genopfrisker /ID[1], ændringsidentifikatoren, nøjagtig som §14.4 tilsigter

Hvorfor beholder inkrementelle gemninger den oprindelige identifikator?

Den anden grænse er append-tilstand. En inkrementel opdatering lader hver tidligere byte i filen være urørt og skriver en ny revision efter den, og permanensen af /ID[0] på tværs af §14.4 er det, der fortæller en modtager, at den nye revision hører til samme dokument som den gamle. At omskrive den ville afbryde den kobling, modsige de revisioner, der allerede sidder i filen, og forstyrre signatursemantik, da en signatur dækker et byte-interval af en bestemt revision af et bestemt dokument. losLab PDF Library genopfrisker derfor kun den deterministiske identifikator ved fulde gemninger og aldrig under append-tilstand, hvilket holder garantien beskrevet i artiklen om inkrementelle PDF-opdateringer og append til stream intakt

Ét knudepunkt for identifikatorgenerering

Al /ID-generering i losLab PDF Library kanaliseres nu gennem én enkelt intern rutine, NewFileIDString, som er det, der gør den deterministiske switch troværdig frem for en lap på én kodesti. Oprettelse af tomt dokument, lazy oprettelse af et manglende /ID-array på anmodning, og gendannelsesstien for krypteringsfingeraftrykket kalder alle den, så der er præcis ét sted, hvor vægguret kunne lække tilbage. Det betyder også, at fremtidige varianter, som fx en indholdsudledt identifikator, er en ændring til én funktion frem for en revision af hele serializeren

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

Sæt den sammenligning ind i sin testsuite, før man stoler på reproducerbart output andre steder, for den fejler højlydt i det øjeblik en ny funktion genindfører et tidsstempel. Reproducerbarhed er en egenskab, der ellers stille forfalder, og én enkelt assertion over to in-memory gemninger koster næsten intet at køre ved hver build

Den deterministiske ID-API vist her leveres med losLab PDF Library til Delphi og C++Builder, sammen med den fulde reference for dokumentinformation, kryptering og inkrementel gemning