HotPDF Delphi Component produkuje bajtovo identický výstup PDF naprieč uloženiami, keď je vlastnosť ReproducibleOutput nastavená na True: pripne /CreationDate a /ModDate v Info na pevný dátum, nahradí identifikátor dokumentu odvodený od hodín seedovaným alebo z obsahu odvodeným hashom, dosadí konštanty za každý náhodný bajt, ktorý by inak AES šifrovacie cesty vyžrebovali, a zoradí každý slovník, ktorý serializuje. Príznak existuje pre regresné sady a porovnávanie build artefaktov, nie pre produkčné dokumenty, a dôvody tej hranice sú tá zaujímavá časť. Scenár, ktorý tú funkciu poháňa, je golden-file test. Vykreslíte faktúru, commitnete PDF a overíte, že zajtrajší build vyprodukuje tie isté bajty. Nikdy nevyprodukuje. Súbor sa v každom viewer-i otvorí v poriadku, text je identický, page tree je identický a diff aj tak svieti na štyroch či piatich miestach. Každý, kto skúsil postaviť PDF generátor pod bajtový regresný test, na tú stenu narazil, a opravou nie je "odstráň časové značky", ale presné zúčtovanie každého miesta, kde writer siaha po niečom inom než po samotnom dokumente
Prečo sa dve uloženia toho istého PDF líšia?
Dve uloženia toho istého dokumentu sa líšia preto, že PDF writer, vrátane HotPDF, siaha po štyroch zdrojoch entropie, ktoré s obsahom stránok nemajú nič spoločné: hodiny, identifikátor dokumentu, kryptografický generátor náhodných čísel a poradie položiek v slovníku v pamäti. Každý z nich je sám o sebe legitímny. ISO 32000-1 ich tam chce. Jednoducho robia zo súboru funkciu toho, kedy a kde bol zapísaný, namiesto toho, čo obsahuje
- Hodiny. Slovník Info nesie
/CreationDatea/ModDate(ISO 32000-1 §14.3.3, tabuľka 317) ako reťazceD:YYYYMMDDHHmmSSs príponou časového pásma (§7.9.4) a XMP paket opakuje ten istý okamih akoxmp:CreateDateaxmp:ModifyDate. HotPDF oba orazítkuje zFCreationDate, ktoré konstruktor inicializuje naNow, takže dve uloženia sa líšia v sekunde, v ktorej boli zapísané - Identifikátor. Pole
/IDv traileri (ISO 32000-1 §14.4) drží trvalý identifikátor a identifikátor modifikácie. Predvolený recept HotPDF hashuje pre prvý element názov súboru spolu s aktuálnym časom až na milisekundy a pre druhý hashuje to plusGetTickCount. Dva identifikátory, dve čerstvé hodnoty pri každom behu - Náhodné bajty. Standard security závisí od identifikátora a od skutočnej náhodnosti. Pri AES-256 sa file encryption key, validačné soli aj kľúčové soli a každý CBC inicializačný vektor ťahajú zo systémového zdroja náhodnosti (ISO 32000-2 §7.6.4.4.7 vyžaduje náhodné soli). Keďže
/U,/UE,/Oa/OEsa všetky počítajú z tých bajtov, šifrovaný dokument sa zmení celý, aj keď sa plaintext nemení. Staršie algoritmy vkladajú prvý element/IDdo kľúča (ISO 32000-1 §7.6.3.3, §7.6.3.4), takže samotný čerstvý identifikátor stačí na prekľúčovanie súboru - Poradie. PDF slovník je neusporiadané mapovanie a writer, ktorý prechádza svoj zoznam v pamäti, vypisuje kľúče v poradí vloženia. Každá cesta kódu, ktorá postaví resource slovník v inom slede, alebo načítaný dokument naparsovaný z iného rozloženia, vyprodukuje legálny, ale textovo odlišný súbor
Čo ReproducibleOutput pripne?
Nastavenie ReproducibleOutput := True pred BeginDoc alebo pred SaveLoadedDocument nahradí každý zo štyroch zdrojov pevnou hodnotou, a to v tých istých cestách kódu, ktoré by inak siahli po hodinách alebo po generátore náhodnosti, takže žiadny samostatný upratovací prechod netreba. Všimnite si, čo v tom zozname vyššie chýba: obsah. Fonty, page streamy, obrazové dáta a cross-reference tabuľka sú pre ten istý vstup už deterministické; šum žije výhradne v metadátach a v bezpečnostnej vrstve, a preto ho jedna cielená vlastnosť dokáže odstrániť. Vlastnosť má predvolene False a nič v knižnici vám ju nezapne
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'golden-invoice.pdf';
Pdf.ReproducibleOutput := True; // pred BeginDoc
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
Vnútri BeginDoc reprodukovateľná vetva priradí FCreationDate := EncodeDate(2026, 1, 1) a identifikátor dokumentu nasype cez MD5CalcString('HotPDF-reproducible-seed') namiesto digestu z názvu súboru a hodín. To jedno priradenie pokrýva oba dátumy v Info aj oba dátumy v XMP, pretože všetky štyri sa renderujú z toho istého poľa. Keď sa súbor nakoniec zapisuje, BuildDocumentIdentifiers si pre identifikátor v traileri vypýta ComputeCanonicalDocumentIdentifier: ten vyexportuje celý objektový graf v kanonickom poradí, vynuluje číslice každého reťazca s dátumom D:, ktorý nájde, aby časové značky nemohli preniknúť späť cez hash, a z výsledku vezme MD5. Oba elementy /ID dostanú tú hodnotu. Ten istý z obsahu odvodený identifikátor sa použije, keď sa načítaný dokument šifruje bez toho, aby prešiel BeginDoc, čo je prípad ActivateProtection na súbore, ktorý ste otvorili cez LoadFromFile
Náhodné bajty sú tá najmenej zjavná substitúcia. Rutina pre AES-256 kľúč obalí svoj zdroj náhodnosti lokálnym helperom, ktorý pod príznakom zavolá FillChar(P^, Count, $5A) pre 32-bajtový file encryption key a pre každú 8-bajtovú soľ, a šifrátory reťazcov a streamov pre AES-128 aj AES-256 prepnú z AESGenerateRandomIV na AESGenerateStaticIV, ktorý inicializačný vektor pre slot I naplní hodnotou 14 * (1 + I). Keď sú kľúč, soli aj vektory všetky pevné, /U, /UE, /O, /OE a každý šifrovaný stream vyjdú pri druhom behu identické. Nakoniec SaveToStream zapne DeterministicDictionaryOrder vždy, keď je reprodukovateľný príznak nastavený, a serializér potom každý slovník pretriedi insertion sortom podľa surových bajtov názvov kľúčov, kratší prefix prvý, s pôvodným indexom ako rozbitím remízy. To je to isté poradie, aké používa diagnostický writer opísaný v článku o ručnej úprave PDF a jeho následnej oprave; reprodukovateľný príznak si požičiava len to poradie, nie zvyšok plain-textového rozloženia toho writera
Prečo pevný dátum aj tak prepúšťal hodiny?
Oprava vo v2.752.2 existuje preto, že pevný dátum vytvorenia sa pôvodne rozhodoval v konstruktore a konštruktor nemôže poznať vlastnosť, ktorú volajúci ešte nenastavil. Bežný sled volaní je Create, potom ReproducibleOutput := True a potom BeginDoc. V čase konštrukcie je FReproducibleOutput stále False, takže FCreationDate dostalo Now a to si podržalo. Identifikátor aj náhodné bajty boli pripnuté správne, takže dva súbory sa zhodovali takmer všade a líšili sa presne v dvoch dátumových reťazcoch a dvoch XMP poliach. Presun priradenia do reprodukovateľnej vetvy BeginDoc, vedľa seedovaného identifikátora, umiestnil rozhodnutie do bodu, kde má vlastnosť svoju konečnú hodnotu
Regresný test, ktorý to minul, má väčšiu cenu než samotná oprava. Dve uloženia, ktoré obe bežia v tej istej sekunde, zapíšu náhodou ten istý reťazec D: a bajtové porovnanie prejde pri chybe, ktorá zlyhá na každom pomalšom stroji. Opravený test spí 1100 ms medzi dvoma uloženiami, aby PDF timestamp zaručene prekročil hranicu sekundy, spúšťa prípad pre plain, AES-128 a AES-256 výstup so skutočnými heslami pri dvoch šifrovaných variantoch a porovnáva dva buffery cez CompareMem, pričom pri zlyhaní hlási prvý odlišný offset, aby diff ukázal na konkrétny objekt a nie na celý súbor. Bajtové porovnanie dokazuje determinizmus a nič iné, takže si nechajte samostatný assert, ktorý znovu načíta šifrovaný výstup s používateľským heslom a prečíta počet strán; zmena, ktorá spraví súbor stabilným a zároveň nečitateľným, nesmie prejsť len na základe zeleného diffu
function SaveOnce(const Target: string): TBytes;
var
Pdf: THotPDF;
Stream: TFileStream;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := Target;
Pdf.ReproducibleOutput := True;
Pdf.OwnerPassword := 'owner';
Pdf.UserPassword := 'user';
Pdf.CryptKeyLength := aes256;
Pdf.ActivateProtection := True;
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
Pdf.EndDoc;
finally
Pdf.Free;
end;
Stream := TFileStream.Create(Target, fmOpenRead or fmShareDenyWrite);
try
SetLength(Result, Stream.Size);
if Stream.Size > 0 then
Stream.ReadBuffer(Result[0], Stream.Size);
finally
Stream.Free;
end;
end;
// v tele testu
A := SaveOnce(PathA);
TThread.Sleep(1100); // vynúť inú sekundu PDF timestampu
B := SaveOnce(PathB);
Assert.AreEqual<Integer>(Length(A), Length(B));
Assert.IsTrue(CompareMem(@A[0], @B[0], Length(A)),
'two saves under ReproducibleOutput must be byte-identical');
Je reprodukovateľné šifrované PDF stále bezpečné?
Nie. Dokument zašifrovaný pod ReproducibleOutput nie je chránený v žiadnom zmysluplnom zmysle a príznak musí byť vypnutý pre všetko, čo opustí testovací adresár. File encryption key pre AES-256 je tridsaťdva bajtov $5A, soli sú osem bajtov $5A a inicializačné vektory sledujú zverejnený aritmetický vzor. Heslo stále stráži obaly /UE a /OE, ale zabalený kľúč je konštanta, takže každý, kto tú konštantu pozná, dešifruje každý content stream úplne bez hesla. To, že soli sú pevné, zároveň odstraňuje jedinečnosť na úrovni dokumentu, na ktorú sa ISO 32000-2 §7.6.4.4.7 spolieha, aby rovnaké heslá nedávali naprieč súbormi rovnaké reťazce /U. Prečítajte si článok o nastavení AES-256, čo šifrovacie vlastnosti sľubujú, keď je zdroj náhodnosti neporušený; pod reprodukovateľným príznakom sú tie sľuby pozastavené
Kompromis pri identifikátore je jemnejší. ISO 32000-1 §14.4 zamýšľa, aby sa druhý element /ID menil pri každej modifikácii, aby nástroje vedeli odlíšiť aktualizovaný súbor od jeho predka, a reprodukovateľné uloženie zapisuje tú istú hodnotu do oboch slotov. Keďže tá hodnota je hash kanonického objektového grafu, dva dokumenty s odlišným obsahom dostanú stále odlišné identifikátory, čo je lepšie než konštanta. Ale seed, ktorý BeginDoc používa na odvodenie kľúča, je ten istý reťazec pre každý dokument na každom stroji, a čitateľ, ktorý sa na rozlíšenie súborov spolieha na /ID, napríklad cache anotácií alebo sidecar s údajmi formulára, zleje dokopy každý reprodukovateľný súbor, ktorý náhodou hashuje rovnako
Čo príznak nepokrýva?
ReproducibleOutput odstraňuje entropiu, ktorú zavádza sám writer; nevie odstrániť entropiu, ktorá prichádza z prostredia alebo cestami kódu, ktoré nekontroluje, a na troch z nich sa ľahko potknete
- Prípona časového pásma.
_DateTimeToPdfDatepripája lokálny UTC offset, takžeD:20260101000000+08'00'na jednom build agentovi aD:20260101000000-05'00'na druhom sú pre ten istý pevný dátum odlišné bajty. Reprodukovateľnosť platí naprieč behmi na jednom stroji alebo naprieč strojmi, ktoré zdieľajú časové pásmo; pripnite pásmo agenta, ak vaše golden files cestujú - Inkrementálne aktualizácie.
SaveIncrementalUpdatepočíta svoj identifikátor modifikácie z cieľovej cesty,GetTickCounta aktuálneho času bez reprodukovateľnej vetvy, pretože inkrementálna sekcia je podľa definície nová modifikácia. Porovnávajte úplné prepisy, nie pripojené delty - Skratka s passthrough.
SaveLoadedDocumentbežne kopíruje nezmenený, nešifrovaný zdrojový súbor bajt po bajte namiesto jeho opätovnej serializácie. Reprodukovateľný príznak tú skratku vypne a vynúti úplný prepis, aby sa uplatnili pravidlá poradia a identifikátora, čo znamená, že reprodukovateľné uloženie načítaného súboru je pomalšie než predvolené a nikdy nie je kópiou vstupu. Porovnávajte ho s predchádzajúcim reprodukovateľným uložením, nikdy s originálom
Ešte jedna lekcia z toho istého release, o tom, čo prechádzajúca kontrola dokazuje a čo nie. Testovacia fixtura pre PDF/X-6 zavolala CharProcs.DeleteValue('A'), čím uvoľnila priamo držaný glyfový stream, a potom znovu vložila ten istý pointer, a oddelene odovzdala jeden priamy ExtGState objekt aj resource slovníku, aj patternu. Validátor konformity na tom use-after-free a dvojitom vlastníctve prechádzal stale, pretože čítal, čo uvoľnená pamäť náhodou obsahovala. Keď štrukturálna kontrola bliká, pozrite sa na vlastníctvo vstupu testu skôr, než sa pozriete na validátor. Reprodukovateľný výstup tú disciplínu zlacňuje: keď sú dve uloženia bajtovo identické, jediným zostávajúcim zdrojom blikania je samotný objektový graf a štrukturálny diff od katalógu nadol ho nájde
Vlastnosti ReproducibleOutput, DeterministicDictionaryOrder a šifrovanie opísané vyššie sú súčasťou štandardného HotPDF Delphi Component pre Delphi a C++Builder a ten istý príznak poháňa aj vlastný regresný korpus knižnice, takže správanie, ktoré dostanete v testovacej sade, je to správanie, s ktorým sa komponenta testuje