Artykuł techniczny

Deterministyczny PDF ID w Delphi dla powtarzalnych buildów

Wygeneruj tę samą fakturę dwa razy z tego samego szablonu i tych samych danych, potem zdiffuj oba PDF-y. Dwa miejsca się różnią: łańcuchy hex w tablicy /ID przyczepu i łańcuch ModDate w słowniku informacji o dokumencie, oba wzięte z zegara. W losLab PDF Library SetDeterministicDocumentID(1) plus przypięty ModDate czyni pliki identycznymi bajt w bajt, dla dokumentu bez XMP UUID-ów per przebieg i załączników

Oczywista pierwsza próba to przypięcie dat i ponowne uruchomienie. CreationDate i ModDate teraz się zgadzają, a hasze nadal się różnią, już tylko w dwóch 32-cyfrowych łańcuchach hex blisko samego końca pliku, wewnątrz /ID [<…> <…>]. Ten identyfikator jest liczony osobno od metadanych i dopóki nie przestanie zależeć od czasu zapisu, cache buildów kluczowany hashem pliku nigdy nie trafia, a content addressable store trzyma jeden blob na przebieg zamiast jednego na dokument

Po co jest tablica ID w przyczepie

Przyczepowy /ID identyfikuje plik; nie liczy sumy kontrolnej jego treści. ISO 32000-1 §14.4 definiuje go jako tablicę dwóch łańcuchów bajtowych. Pierwszy to identyfikator trwały, nadawany przy tworzeniu dokumentu i mający przetrwać każdą późniejszą edycję. Drugi to identyfikator zmienny, który writer odświeża przy każdej modyfikacji pliku. System zarządzania dokumentami porównuje parę, żeby rozstrzygnąć, czy dwa pliki to rewizje jednego dokumentu, czy dwa niepowiązane dokumenty. Tabela 15 w §7.5.5 czyni ten wpis wymaganym, gdy przyczep niesie /Encrypt, i opcjonalnym inaczej, a §14.4 zauważa, że jego pominięcie może zawadzić przepływom pracy polegającym na tożsamości pliku

Specyfikacja nigdy nie mówi, jak policzyć tę wartość. Sugestia to skrót MD5 nad rzeczami takimi jak bieżący czas, ścieżka pliku, rozmiar pliku i wartości w słowniku informacji, a zegar jest składnikiem, który czyni wynik unikalnym. Unikalność to dokładnie to, czego chcesz od znacznika tożsamości, i dokładnie to, co wywala powtarzalność. Domyślnie losLab PDF Library podąża za tą sugestią w jej najprostszej formie, MD5 bieżącego znacznika czasu, więc dwa dokumenty stworzone sekundę odstępu dostają różne identyfikatory trwałe, nawet gdy każdy inny bajt się zgadza

Diagram porównujący identyczne uruchomienia budowania Delphi, których domyślne identyfikatory trailer PDF oparte na zegarze ściennym się różnią, z bajt w bajt identycznym deterministycznym wyjściem
Domyślny id traileru wplata zegar systemowy w tożsamość pliku, podczas gdy tryb deterministyczny wywodzi ją ze stabilnego seeda

Włączanie powtarzalnego identyfikatora

Tryb deterministyczny to przełącznik per dokument i jest domyślnie wyłączony, więc istniejące wyjście nie zmienia się, dopóki o to nie poprosisz. SetDeterministicDocumentID przyjmuje 0 albo 1 i zwraca 1, gdy wartość została przyjęta, 0 dla czegokolwiek innego; GetDeterministicDocumentID raportuje bieżące ustawienie. SetDocumentIDSeed dostarcza jawny łańcuch seeda, a z seedem na miejscu identyfikator to MD5 tego łańcucha, zapisany w oba elementy tablicy. GetDocumentFileID czyta /ID[0] z powrotem po zapisie, co jest wygodne do logowania albo do asercji w teście

var
  Lib: TPDFlib;
  FileID: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);        // zaraz po Create
    Lib.SetDocumentIDSeed('invoice-4471-rev3');
    Lib.SetInformation(7, 'D:20260101000000Z');   // CreationDate
    Lib.SetInformation(8, 'D:20260101000000Z');   // ModDate
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Invoice 4471');
    Lib.SaveToFile('invoice.pdf');
    FileID := Lib.GetDocumentFileID;          // ta sama wartość przy każdym przebiegu
  finally
    Lib.Free;
  end;
end;

Identyfikator jest liczony w chwili zapisu. Przerzucenie przełącznika późno w budowie dokumentu nadal działa, a zmieniony seed dociera do pliku przy następnym pełnym zapisie: zapisz z seedem A, zapisz znowu z seedem B, i oba wyjścia niosą różne identyfikatory, podczas gdy powrót do seeda A daje ci znowu oryginalną wartość

Przerzucaj jednak przełącznik zaraz po stworzeniu obiektu. Fonty, obrazy, stany grafiki i inne zasoby stron dostają nazwy w słowniku zasobów strony, i przy włączonym trybie deterministycznym te nazwy pochodzą z licznika. Cokolwiek dodanego przed przełącznikiem zachowuje nazwę wygenerowaną losowo, co zostawia identyfikator stabilny, ale zmienia po kilka bajtów w strumieniach treści przy każdym przebiegu

Dlaczego identyfikator nadal się zmienia, gdy seed nie jest ustawiony?

Bo słownik informacji jest częścią hasza. Zostaw seed pusty albo podaj pusty łańcuch do SetDocumentIDSeed, żeby go wyczyścić, a losLab PDF Library wyprowadza identyfikator z samego pliku: przepuszcza zserializowane ciało przez SHA-256 w trakcie zapisu i używa pierwszych 16 bajtów skrótu dla obu elementów /ID. Każda zmiana treści strony zmienia identyfikator, i o to w całości chodzi. Haczyk: CreationDate i ModDate też mieszkają w tym ciele, a rutyny zapisu stemplują ModDate bieżącym czasem, chyba że wołający go ustawił

PDF Library for Delphi: Przepływ decyzyjny pokazujący, że SetDocumentIDSeed ma pierwszeństwo przed pochodnym ziarnem stanu dokumentu używanym dla deterministycznych identyfikatorów PDF
Jawny seed nadpisuje wszystko inne, podczas gdy ścieżka wywodzona skrótuje wpisy słownika info, łącznie z CreationDate i ModDate
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed('');   // wyprowadź /ID z zapisanych bajtów
// Hasz ciała pokrywa słownik Info, więc daty muszą być
// wejściami builda. Klucz 8 blokuje też stemplowanie ModDate przy zapisie.
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');

Klucz 8 pełni tu podwójną rolę i to jest linia, którą ludzie najczęściej pomijają. Ustawienie ModDate przez SetInformation oznacza datę jako dostarczoną przez wołającego i od tej chwili SaveToFile i SaveToString zostawiają ją w spokoju. Bez tego każdy zapis pisze świeży znacznik czasu, haszowane ciało się zmienia, i identyfikator wyprowadzany z treści zmienia się razem z nim. Bierz daty z rekordu źródłowego albo ze stałej epoki. Now nie ma biznesu w powtarzalnym buildzie

Kilka rzeczy jest trzymanych poza haszem celowo. Losowo generowane nazwy zasobów i UUID we wpisie XMP xmpMM:InstanceID są normalizowane przed haszowaniem, więc dwa przebiegi różniące się tylko tymi tokenami dostają ten sam /ID. Bajty pliku nadal różnią się w tych miejscach, więc dokument w stylu PDF/A ze świeżym UUID-em instancji per przebieg daje ci powtarzalny identyfikator, ale nie plik identyczny bajt w bajt. Osadzone załączniki to drugie źródło dryfu: AddAssociatedFileFromString stempluje własny /ModDate załącznika bieżącym czasem, i ta data jest haszowana jak każdy inny bajt ciała

Jawny seed czy hasz treści

Dla artefaktów builda zostawilibyśmy seed pusty. Identyfikator wyprowadzany z treści zmienia się dokładnie wtedy, gdy zmieniają się bajty, więc cache kluczowany na nim nigdy nie poda starego pliku, a ty nie dostajesz drugiego klucza do synchronizacji z danymi. Jawny seed pasuje do innej roboty: archiwum downstream, które traktuje /ID[0] jako tożsamość dokumentu i oczekuje tego samego identyfikatora przez regeneracje tej samej faktury, nawet po zmianie szablonu. To lepiej pasuje do intencji §14.4, w zamian za to, że dwa widocznie różne pliki mogą dzielić identyfikator

Jedna konsekwencja ścieżki treści zasługuje na świadomą decyzję. Gdy wczytujesz istniejący PDF i zapisujesz go w pełni z włączonym trybem deterministycznym, przychodzący /ID jest wyrzucany, a nowy zależy tylko od tego, co zostaje zapisane. Dwa pliki źródłowe różniące się tylko identyfikatorami przyczepu wychodzą z tym samym /ID. Tego chce normalizujący krok builda. Jeśli edytujesz dokument klienta i jego tożsamość ma przetrwać, zostaw tryb deterministyczny wyłączony dla tej roboty albo zapisuj przyrostowo

Dlaczego przepisanie ID psuje zaszyfrowany PDF?

Bo w zaszyfrowanym dokumencie /ID[0] to materiał kluczowy. ISO 32000-1 §7.6.3.3, algorytm 2, wlewa pierwszy element identyfikatora pliku do obliczenia klucza szyfrowania dla standardowego uchwytu bezpieczeństwa w rewizjach od 2 do 4, razem z dopełnionym hasłem, wartością /O i bitami uprawnień. Ten klucz produkuje potem łańcuch /U, który czytnik sprawdza przy otwarciu. Klucz jest wyprowadzany, gdy wołasz Encrypt albo wczytujesz zaszyfrowany plik, przed jakimkolwiek zapisem, więc zamiana identyfikatora w trakcie zapisu wyprodukowałaby dobrze uformowany plik, którego sprawdzenie hasła zawodzi dla wszystkich, łącznie z tobą

losLab PDF Library odświeża więc deterministyczny identyfikator tylko przy niezaszyfrowanych pełnych zapisach. Zaszyfrowany dokument zachowuje cokolwiek /ID już miał, a przełącznik nie ma na tej ścieżce efektu. Ponowne otwarcie takiego pliku hasłem użytkownika działa jak zwykle. Nie wkładaj też zaszyfrowanego wyjścia do porównania bajtów: szyfrowanie AES pod §7.6.2 zaczyna każdy zaszyfrowany łańcuch i strumień losowym 16-bajtowym wektorem inicjalizującym, więc dwa szyfrowania tego samego dokumentu nigdy nie zgadzają się bajt w bajt. Jeśli potrzebujesz tam sprawdzenia regresyjnego, odszyfruj oba i porównaj, co dostajesz z powrotem. Obsługę rewizji i bity uprawnień omawia głębiej przejście przez audyt szyfrowania i uprawnień PDF

PDF Library for Delphi: Mapa zabezpieczeń deterministycznego identyfikatora PDF obejmująca pełne zapisy, dokumenty szyfrowane, aktualizacje przyrostowe i ścieżkę przywracania szyfrowania
Tylko niezaszyfrowane pełne zapisy mogą odświeżyć id, ponieważ zaszyfrowane pliki traktują /ID[0] jako materiał kluczowy, a dołączane rewizje muszą pozostać powiązane

Zapisy przyrostowe zachowują oryginalny identyfikator

Aktualizacja przyrostowa zostawia każdy wcześniejszy bajt pliku na miejscu i dopisuje za nim nową rewizję. Trwałość /ID[0] to to, co mówi konsumentowi, że dopisana rewizja należy do tego samego dokumentu co poprzednie. Przepisanie go zaprzeczyłoby rewizjom już siedzącym w pliku i podkopałoby przepływy pracy z podpisami, bo podpis pokrywa zakres bajtów jednej konkretnej rewizji. Tryb dopisywania nigdy nie dotyka identyfikatora w trybie deterministycznym, co zachowuje w nienaruszonym stanie zachowanie opisane w artykule o przyrostowych aktualizacjach PDF i dopisywaniu do strumienia

Trzymanie wyjścia powtarzalnym w czasie

Każda późniejsza zmiana generatora to szansa, by wpuścić wartość zegara z powrotem do pliku: watermark drukujący czas generacji, ścieżka kodu dopinająca plik, inna opcja zapisu. Opcje zapisu mają znaczenie równie duże jak treść: pakowanie obiektów w skompresowane strumienie obiektów produkuje kompletnie inny układ pliku niż klasyczna tabela odwołań krzyżowych, jak wyjaśniają notatki o strumieniach obiektów i strumieniach odwołań krzyżowych, więc trzymaj te ustawienia stałymi per potok. Najtańsza obrona to test, który buduje ten sam dokument dwa razy w pamięci i porównuje bajty

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;

// Dwa niezależne buildy muszą dać jedną sekwencję bajtów
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
  WriteLn('reproducible')
else
  WriteLn('nondeterminism leaked into the output');

Ten sam harness potrafi porównać szeregowe i równoległe uruchomienie RecompressStreams na tym samym źródle: wczytaj je do dwóch instancji TPDFlib, włącz deterministyczne identyfikatory i przypnij ModDate na obu, ustaw SetOptimizationWorkers(1) na jednym i wyższą liczbę workerów na drugim, i zdiffuj oba wyniki SaveToString. Z zegarem wyjętym z obu plików każda pozostała różnica pochodzi z samej rekompresji

Powtarzalne identyfikatory przyczepu, kontrola metadanych i zapis przyrostowy to wszystko części losLab PDF Library for Delphi i C++Builder, której referencja wylicza też wspomniane tu opcje szyfrowania