Due PDF scritti dallo stesso codice fatture a un secondo di distanza quasi non sono mai lo stesso file. Un confronto binario inciampa prima nel dizionario Info, dove /CreationDate è scivolata di un secondo, poi nel pacchetto XMP, poi al /ID del trailer. Accendete AES-256 e il confronto crolla su quasi ogni stream, perché la chiave di cifratura del file è stata appena generata. Mascherare le date prima del confronto ripara le prime due differenze e nessuna delle altre. Un test su golden file diventa affidabile solo quando ogni valore che il writer prende da qualche altro luogo rispetto al documento è fissato nel punto in cui viene letto
Quattro valori che non sono mai venuti dal documento
HotPDF Delphi Component, come qualsiasi writer PDF conforme, riempie quattro tipi di valore da fuori del contenuto di pagina: l'orologio, l'identificatore del documento, la sorgente casuale crittografica e l'ordine in memoria delle voci dei dizionari. ISO 32000-1 ha una ragione per ognuno
- L'orologio. Il dizionario Info porta
/CreationDatee/ModDate(ISO 32000-1 §14.3.3, Tabella 317) come stringheD:YYYYMMDDHHmmSScon suffisso di fuso orario (§7.9.4), e il pacchetto XMP ripete lo stesso istante comexmp:CreateDateexmp:ModifyDate. Tutti vengono resi da un'unica data di creazione che il componente prende daNowquando viene costruito - L'identificatore. L'array
/IDdel trailer (§14.4) contiene un identificatore permanente e un identificatore di modifica. Per default il primo è un MD5 del nome file più l'ora corrente al millisecondo, e il secondo fa l'hash di quel valore insieme al tick count di sistema - I byte casuali. Un documento AES-256 richiede una chiave di cifratura del file di 32 byte, un sale di validazione di 8 byte e un sale di chiave di 8 byte per
/U, un'altra coppia per/O, quattro byte di riempimento dentro/Perms, e un vettore di inizializzazione di 16 byte davanti a ogni stringa e stream cifrato. ISO 32000-2 §7.6.4.4.7 vuole i sali casuali. RC4 e AES-128 derivano la loro chiave dalle password e dal primo elemento/ID(ISO 32000-1 §7.6.3.3), quindi un identificatore fresco da solo basta a ri-cifrare l'intero file - L'ordine. Un dizionario PDF è una mappa non ordinata (§7.3.7). Un writer che percorre la sua lista in memoria emette le chiavi nell'ordine in cui il codice le ha aggiunte, così due percorsi di codice che costruiscono lo stesso dizionario di risorse in sequenza diversa producono un file legale ma testualmente diverso
Tutto il resto segue già dall'input: i content stream, i dati immagine compressi e gli offset dei riferimenti incrociati si ripetono per le stesse chiamate di disegno. Persino l'unico campo che sembra casuale non lo è: il prefisso di sei lettere di un sottoinsieme di font incorporato (ISO 32000-1 §9.6.4, la parte ABCDEF+ di ABCDEF+Arial) è calcolato dall'insieme di glifi che il sottoinsieme conserva, così lo stesso testo dà lo stesso prefisso a ogni esecuzione. Il rumore sta nei metadati e nel livello di sicurezza, ed è per questo che una singola proprietà può toglierlo
Cosa fissa ReproducibleOutput
Impostare ReproducibleOutput := True prima di BeginDoc sostituisce ciascuna delle quattro sorgenti con un valore fisso, negli stessi percorsi di codice che altrimenti andrebbero all'orologio o al generatore casuale. Non c'è nessun passaggio di pulizia dopo. La proprietà vale False per default, e niente nella libreria la accende per te
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'golden-invoice.pdf';
Pdf.ReproducibleOutput := True; // deve essere impostata prima di BeginDoc
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
Sotto il flag, BeginDoc fissa la data di creazione a 1 gennaio 2026, 00:00 ora locale, e poiché sia le date Info sia le date XMP vengono rese da quel solo valore, un solo assegnamento le copre tutte. Lo stesso ramo semina l'identificatore interno del documento da una stringa costante invece che dal nome file e dall'orologio. Quando il file viene scritto, il /ID del trailer è calcolato come MD5 su un'esportazione canonica dell'intero grafo di oggetti in cui le cifre di ogni stringa data D: vengono prima azzerate, così una data non può reintrodursi di nascosto nell'hash. Entrambi gli elementi di /ID ricevono quel valore. Un documento caricato che cifrate senza mai chiamare BeginDoc (impostate ActivateProtection dopo LoadFromFile) riceve lo stesso identificatore derivato dal contenuto
I byte casuali sono sostituiti da costanti. La chiave del file AES-256, tutti e quattro i sali e il riempimento di /Perms vengono riempiti con $5A, e ogni vettore di inizializzazione CBC, per le stringhe e gli stream AES-128 come per AES-256, diventa il pattern 14 * (1 + I) per il byte I. Quel pattern è non zero apposta. Un IV tutto zero è la scelta debole da manuale per CBC, e una suite di regressione non ha alcun interesse a normalizzarlo. Con chiave, sali e vettori fissi, /U, /UE, /O, /OE, /Perms e ogni stream cifrato escono identici alla seconda esecuzione
Infine, SaveToStream accende DeterministicDictionaryOrder ogni volta che il flag riproducibile è impostato, e lo fa prima di scegliere tra il layout linearizzato, a object stream e classico, quindi anche l'output con LinearizeOutput o UseObjectStreams viene ordinato. Le chiavi sono ordinate per i loro byte grezzi, una chiave prefisso di un'altra viene prima, e la posizione originale risolve i pareggi. È l'ordinamento che usa il writer diagnostico, descritto nell'articolo su come modificare a mano un PDF e ripararlo dopo; il flag riproducibile prende in prestito l'ordinamento e nessuna del layout in testo semplice di quel writer. Accendere DeterministicDictionaryOrder da solo è una mezza misura. Le chiavi si allineano, ma le date Info ed entrambe le stringhe /ID del trailer portano ancora l'orologio, quindi due salvataggi continuano a differire negli stessi quattro punti
Un test che non può passare per fortuna
La data viene fissata in BeginDoc perché il costruttore gira prima che il chiamante abbia avuto la benché minima possibilità di impostare la proprietà. La sequenza normale è Create, poi ReproducibleOutput := True, poi BeginDoc, e solo l'ultimo vede il flag con il suo valore definitivo. Quell'ordinamento ha una conseguenza diretta sulla progettazione dei test. Due salvataggi che cadono nello stesso secondo dell'orologio scrivono la stessa stringa D: per caso, così un test veloce passa con o senza la data fissata, e poi fallisce mesi dopo su un agente di build più lento
La correzione costa poco: aspettate 1100 ms tra i due salvataggi così la data attraversa con certezza un confine di secondo, eseguite il caso per output normale, AES-128 e AES-256 con password vere sulle varianti cifrate, e riportate il primo offset di byte divergente anziché un nudo vero o falso. Un offset punta a un oggetto. Un booleano punta al file intero
uses
System.SysUtils, System.IOUtils, HPDFDoc;
function SaveOnce(const Target: string; Encrypt: Boolean;
KeyType: THPDFKeyType): TBytes;
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := Target;
Pdf.ReproducibleOutput := True;
Pdf.VerifySavedOutput := True; // ricaricamento severo subito dopo EndDoc
if Encrypt then
begin
Pdf.OwnerPassword := 'owner';
Pdf.UserPassword := 'user';
Pdf.CryptKeyLength := KeyType; // aes128 or aes256
Pdf.ActivateProtection := True;
end;
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
Pdf.EndDoc;
finally
Pdf.Free;
end;
Result := TFile.ReadAllBytes(Target);
end;
// Un vostro helper: -1 se uguali, altrimenti il primo offset divergente
function FirstDifference(const A, B: TBytes): Integer;
var
I, N: Integer;
begin
N := Length(A);
if Length(B) < N then
N := Length(B);
for I := 0 to N - 1 do
if A[I] <> B[I] then
Exit(I);
if Length(A) <> Length(B) then
Result := N
else
Result := -1;
end;
procedure CheckReproducible(Encrypt: Boolean; KeyType: THPDFKeyType);
var
A, B: TBytes;
Offset: Integer;
begin
A := SaveOnce('run-a.pdf', Encrypt, KeyType);
Sleep(1100); // forza un secondo di data diverso
B := SaveOnce('run-b.pdf', Encrypt, KeyType);
Offset := FirstDifference(A, B);
if Offset >= 0 then
raise Exception.CreateFmt('Saves differ at byte offset %d', [Offset]);
end;
Chiamate CheckReproducible(False, aes128), CheckReproducible(True, aes128) e CheckReproducible(True, aes256); il tipo di chiave viene ignorato nell'esecuzione in chiaro. VerifySavedOutput si guadagna il posto in questo test. Con esso attivo, EndDoc ricarica il file appena scritto in una nuova istanza di THotPDF con il recupero dei riferimenti incrociati disattivato, usando la password utente quando il documento è protetto, e solleva un'eccezione se quel caricamento fallisce. Una modifica che rendesse l'output stabile e illeggibile in una volta sola passerebbe liscia su un diff verde; il ricaricamento la ferma
Un PDF cifrato riproducibile è ancora sicuro?
No. Un documento cifrato sotto ReproducibleOutput non ha alcuna protezione reale, e il flag va tenuto spento per qualsiasi cosa esca dalla directory dei test. La chiave del file AES-256 sono trentadue byte di $5A. La password continua a presidiare i contenitori /UE e /OE, ma ciò che incartano è una costante pubblicata, quindi chi la conosce può decifrare ogni content stream senza password. I sali fissi tolgono anche l'unicità per file su cui ISO 32000-2 §7.6.4.4.7 conta per evitare che la stessa password produca la stessa stringa /U in due file. L'articolo sulla configurazione AES-256 descrive cosa offrono le proprietà di cifratura con la sorgente casuale intatta; sotto il flag riproducibile nulla di questo si applica. Tenete la proprietà dietro un valore di configurazione solo-test, non su un'impostazione raggiungibile da un percorso di codice di produzione
AES-GCM è trattato diversamente. Con CryptKeyLength := aesgcm (solo PDF 2.0), il componente attinge il prefisso nonce di 8 byte dalla sorgente casuale di sistema a prescindere da ciò che dice ReproducibleOutput, e poi conta in avanti per ogni stringa o stream cifrato. È il comportamento giusto. GCM va in crisi gravemente quando un nonce si ripete sotto la stessa chiave: due ciphertext rivelano lo XOR dei loro plaintext, e un attaccante può recuperare abbastanza da fabbricare tag di autenticazione. Una chiave fissata più un nonce fissato renderebbero quella ripetizione certa su ogni file prodotto dalla suite. I golden file cifrati usano quindi aes128 o aes256, che girano in modalità CBC
L'identificatore è un compromesso più morbido. ISO 32000-1 §14.4 intende che il secondo elemento /ID cambi a ogni modifica, e un salvataggio riproducibile scrive lo stesso valore in entrambi gli slot. Poiché quel valore fa l'hash del grafo di oggetti, contenuti diversi ottengono comunque identificatori diversi. Un reader che fondi una cache su /ID, un archivio di annotazioni o un sidecar di dati di form per esempio tratterà comunque due salvataggi riproducibili dello stesso contenuto come un solo file. In una suite di test è esattamente ciò che volete
Input che restano fuori dal writer
ReproducibleOutput copre l'entropia che il writer introduce da solo. Qualche altro input arriva dalla macchina di build o da operazioni che registrano per definizione un nuovo momento
- Il suffisso di fuso orario. Ogni stringa data PDF finisce con l'offset UTC locale, così la stessa data fissata si legge
D:20260101000000+08'00'su un agente di build eD:20260101000000-05'00'su un altro. I golden file tengono tra esecuzioni sulla stessa macchina, o tra macchine che condividono un fuso. Se i file viaggiano tra uffici, fissate il fuso orario degli agenti - Aggiornamenti incrementali.
SaveIncrementalUpdateconserva l'identificatore permanente della sorgente e costruisce un nuovo identificatore di modifica a partire dal precedente, dal percorso di destinazione, dal tick count e dall'ora corrente. Una sezione aggiunta è per definizione una nuova modifica. Confrontate riscritture complete, non delta aggiunti - La scorciatoia passthrough.
SaveLoadedDocumentdi norma copia byte per byte una sorgente non modificata e non cifrata invece di serializzarla di nuovo. Sotto il flag il file viene sempre riscritto così le regole di ordinamento e identificatore si applicano, il che rende il salvataggio più lento e mai una copia dell'input. Confrontatelo con un precedente salvataggio riproducibile, mai con l'originale - Le date che aggiungete voi.
AddDocumentAttachmenttimbra il/Params/CreationDatee/ModDatedel file incorporato (ISO 32000-1 §7.11.4) con il momento della chiamata, e una firma registra il suo tempo di firma in/M. Quelle date descrivono eventi richiesti dal chiamante, quindi il flag le lascia in pace. Tenete allegati e firme fuori dai casi a livello di byte, o mascherate quelle stringhe nel confronto - I font installati. Un sottoinsieme TrueType viene tagliato dal file di font installato sulla macchina che esegue il test. Due agenti con release diverse di Arial incorporano programmi di glifi diversi per lo stesso testo, e il prefisso del sottoinsieme può cambiare con loro. Spedite i file di font con l'immagine di build
I documenti caricati seguono le stesse regole. Impostate la proprietà prima del caricamento, fate le vostre modifiche, e salvate tramite SaveLoadedDocument; l'output è una riscrittura normalizzata che potete impegnare come riferimento per l'esecuzione successiva
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.ReproducibleOutput := True; // impostare prima del caricamento
if Pdf.LoadFromFile('incoming.pdf') <= 0 then
raise Exception.Create('incoming.pdf did not load');
// ... modifiche in prova ...
Pdf.SaveLoadedDocument('normalized.pdf'); // riscrittura completa, chiavi ordinate
finally
Pdf.Free;
end;
end;
I salvataggi identici byte per byte smascherano anche un controllo di conformità che sfarfalla. Una volta che il writer è stabile, un controllo che passa su un'esecuzione e fallisce sulla successiva sta reagendo al grafo di oggetti che il test costruisce, come un dizionario diretto consegnato a due padri, quindi ispezionate l'input del test prima del validatore. Un diff strutturale dal catalogo in giù mostra quale oggetto si è mosso tra le due esecuzioni
Le proprietà ReproducibleOutput, DeterministicDictionaryOrder, VerifySavedOutput e di cifratura usate sopra fanno parte dell'HotPDF Delphi Component, che viene distribuito sia per Delphi sia per C++Builder