Τεχνικό Άρθρο

Αναπαραγώγιμη έξοδος PDF στο Delphi: saves πανομοιότυπα byte

Το HotPDF Delphi Component παράγει έξοδο PDF πανομοιότυπη byte σε saves όταν η ιδιότητα ReproducibleOutput είναι True: καρφώνει το Info /CreationDate και /ModDate σε σταθερή ημερομηνία, αντικαθιστά το document identifier του ρολογιού με seeded ή από-περιεχόμενο-παραγόμενο hash, αντικαθιστά σταθερές για κάθε τυχαίο byte που θα τραβούσαν αλλιώς οι διαδρομές κρυπτογράφησης AES, και ταξινομεί κάθε dictionary που σειριοποιεί. Η σημαία υπάρχει για regression suites και σύγκριση build artifacts, όχι για έγγραφα παραγωγής, και οι λόγοι εκείνου του ορίου είναι το ενδιαφέρον κομμάτι. Το σενάριο που κινεί τη λειτουργία είναι ένα golden-file test. Αποδίδεις ένα τιμολόγιο, κάνεις commit το PDF, και ισχυρίζεσαι ότι το build του αύριο παράγει τα ίδια bytes. Δεν τα παράγει ποτέ. Το αρχείο ανοίγει μια χαρά σε κάθε viewer, το κείμενο είναι πανομοιότυπο, το page tree είναι πανομοιότυπο, και το diff αναβοσβήνει ακόμα σε τέσσερις ή πέντε θέσεις. Όποιος έχει προσπαθήσει να βάλει generator PDF κάτω από byte-level regression test έχει χτυπήσει αυτόν τον τοίχο, και το fix δεν είναι «κόψε τις χρονικές σημάνσεις» αλλά μια ακριβής αποτίμηση κάθε σημείου όπου ο writer συμβουλεύεται κάτι άλλο εκτός από το ίδιο το έγγραφο

Γιατί διαφέρουν δύο saves του ίδιου PDF;

Δύο saves του ίδιου εγγράφου διαφέρουν επειδή ένας PDF writer, και το HotPDF συμπεριλαμβάνεται, συμβουλεύεται τέσσερις πηγές εντροπίας που δεν έχουν καμία σχέση με το περιεχόμενο των σελίδων: το ρολόι τοίχου, το document identifier, τον κρυπτογραφικό γεννήτρια τυχαίων αριθμών, και τη σειρά μνήμης των εγγραφών dictionary. Καθεμία είναι νόμιμη από μόνη της. Το ISO 32000-1 τα θέλει εκεί. Απλώς κάνουν το αρχείο συνάρτηση του πότε και πού γράφτηκε και όχι του τι περιέχει

  • Το ρολόι. Το Info dictionary κουβαλάει /CreationDate και /ModDate (ISO 32000-1 §14.3.3, Πίνακας 317) ως strings D:YYYYMMDDHHmmSS με κατάληξη ζώνης ώρας (§7.9.4), και το packet XMP επαναλαμβάνει την ίδια στιγμή ως xmp:CreateDate και xmp:ModifyDate. Το HotPDF σημαίνει και τα δύο από το FCreationDate, που ο constructor αρχικοποιεί σε Now, οπότε τα δύο saves διαφέρουν στο δευτερόλεπτο που γράφτηκαν
  • Το identifier. Ο πίνακας /ID του trailer (ISO 32000-1 §14.4) κρατά μόνιμο identifier και identifier τροποποίησης. Η προεπιλεγμένη συνταγή του HotPDF κάνει hash το όνομα αρχείου μαζί με την τρέχουσα ώρα μέχρι το millisecond για το πρώτο στοιχείο, και κάνει hash εκείνο συν το GetTickCount για το δεύτερο. Δύο identifiers, δύο φρέσκες τιμές σε κάθε run
  • Τα τυχαία bytes. Η standard ασφάλεια εξαρτάται από το identifier και από γνήσια τυχαιότητα. Για AES-256 το κλειδί κρυπτογράφησης αρχείου, τα salts επικύρωσης και κλειδιού, και κάθε vector αρχικοποίησης CBC τραβιούνται από την τυχαία πηγή του συστήματος (το ISO 32000-2 §7.6.4.4.7 απαιτεί τυχαία salts). Επειδή τα /U, /UE, /O και /OE υπολογίζονται όλα από εκείνα τα bytes, ένα κρυπτογραφημένο έγγραφο αλλάζει στο σύνολό του ακόμα κι όταν το plaintext δεν αλλάζει. Οι παλαιότεροι αλγόριθμοι διπλώνουν το πρώτο στοιχείο /ID μέσα στο κλειδί (ISO 32000-1 §7.6.3.3, §7.6.3.4), οπότε ένα φρέσκο identifier από μόνο του αρκεί για να ξανακλειδώσει το αρχείο
  • Η σειρά. Ένα PDF dictionary είναι αταξινόμητη αντιστοίχιση, και ένας writer που περπατάει την in-memory λίστα του εκπέμπει κλειδιά με σειρά εισαγωγής. Οποιαδήποτε διαδρομή κώδικα που χτίζει resource dictionary με διαφορετική αλληλουχία, ή φορτωμένο έγγραφο που αναλύθηκε από διαφορετική διάταξη, παράγει ένα νόμιμο αλλά κειμενικά διαφορετικό αρχείο
Οι τέσσερις πηγές εντροπίας που κάνουν δύο saves HotPDF ενός εγγράφου να διαφέρουν: το FCreationDate σημαμένο από Now τροφοδοτεί τις ημερομηνίες D: και το packet XMP, ο /ID του trailer κάνει hash όνομα αρχείου, ρολόι και GetTickCount, το AES τραβάει κλειδί υλικό από την τυχαία πηγή του συστήματος, και τα dictionaries σειριοποιούνται με σειρά εισαγωγής μνήμης
Κάθε πηγή είναι νόμιμη από μόνη της και το ISO 32000-1 τα θέλει εκεί, μαζί όμως μετατρέπουν το αρχείο σε συνάρτηση του πότε και πού γράφτηκε και όχι του τι περιέχει

Τι καρφώνει το ReproducibleOutput;

Θέτοντας ReproducibleOutput := True πριν το BeginDoc ή πριν το SaveLoadedDocument αντικαθιστά καθεμία από τις τέσσερις πηγές με σταθερή τιμή, και το κάνει μέσα στις ίδιες διαδρομές κώδικα που θα έφταναν αλλιώς για το ρολόι ή τη γεννήτρια τυχαίων, οπότε δεν χρειάζεται ξεχωριστό πέρασμα καθαρισμού. Πρόσεξε τι λείπει από τη λίστα παραπάνω: το περιεχόμενο. Fonts, page streams, δεδομένα εικόνων και ο πίνακας cross-reference είναι ήδη ντετερμινιστικά για την ίδια είσοδο· ο θόρυβος μένει εξ ολοκλήρου στα metadata και στο επίπεδο ασφαλείας, γι' αυτό μία στοχευμένη ιδιότητα μπορεί να τον αφαιρέσει. Η ιδιότητα προεπιλέγει False και τίποτα στη βιβλιοθήκη δεν την ανάβει για σένα

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'golden-invoice.pdf';
    Pdf.ReproducibleOutput := True;     // πριν το BeginDoc
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Μέσα στο BeginDoc ο αναπαραγώγιμος branch αναθέτει FCreationDate := EncodeDate(2026, 1, 1) και σπέρνει το document identifier με MD5CalcString('HotPDF-reproducible-seed') αντί για το digest ονόματος-αρχείου-συν-ρολογιού. Εκείνη η μοναδική ανάθεση καλύπτει και τις δύο ημερομηνίες Info και τις δύο ημερομηνίες XMP, επειδή και οι τέσσερις αποδίδονται από το ίδιο πεδίο. Όταν το αρχείο τελικά γράφεται, το BuildDocumentIdentifiers ζητά από το ComputeCanonicalDocumentIdentifier το trailer identifier: εξάγει όλο τον γράφο objects σε canonical σειρά, μηδενίζει τα ψηφία οποιουδήποτε string ημερομηνίας D: βρει ώστε οι χρονικές σημάνσεις να μη μπορούν να διαρρεύσουν πίσω μέσα από το hash, και παίρνει το MD5 του αποτελέσματος. Και τα δύο στοιχεία του /ID λαμβάνουν εκείνη την τιμή. Το ίδιο από-περιεχόμενο-παραγόμενο identifier χρησιμοποιείται όταν κρυπτογραφείται φορτωμένο έγγραφο χωρίς να περάσει ποτέ από BeginDoc, που είναι η περίπτωση του ActivateProtection σε αρχείο που άνοιξες με LoadFromFile

Τα τυχαία bytes είναι η λιγότερο προφανής αντικατάσταση. Η ρουτίνα κλειδιού AES-256 τυλίγει την τυχαία πηγή της σε τοπικό helper που, κάτω από τη σημαία, καλεί FillChar(P^, Count, $5A) για το κλειδί κρυπτογράφησης αρχείου 32 bytes και για κάθε salt 8 bytes, και οι κρυπτογραφητές string και stream AES-128 και AES-256 αλλάζουν από AESGenerateRandomIV σε AESGenerateStaticIV, που γεμίζει το vector αρχικοποίησης με 14 * (1 + I) για τη θέση I. Με το κλειδί, τα salts και τα vectors όλα σταθερά, τα /U, /UE, /O, /OE και κάθε κρυπτογραφημένο stream βγαίνουν πανομοιότυπα στο δεύτερο run. Τέλος, το SaveToStream ανάβει το DeterministicDictionaryOrder όποτε η αναπαραγώγιμη σημαία είναι set, και ο serializer τότε ταξινομεί με εισαγωγή κάθε dictionary κατά τα ακατέργαστα bytes των ονομάτων κλειδιών του, σύντομο πρόθεμα πρώτα, με τον αρχικό δείκτη ως σπασίμα ισοβαθμίας. Είναι η ίδια ταξινόμηση που χρησιμοποιεί ο διαγνωστικός writer, περιγεγραμμένη στο άρθρο για το χειροκίνητο edit PDF και την επακόλουθη επισκευή του· η αναπαραγώγιμη σημαία δανείζεται μόνο την ταξινόμηση, όχι την υπόλοιπη plain-text διάταξη εκείνου του writer

Τι καρφώνει το ReproducibleOutput στο HotPDF: η ημερομηνία δημιουργίας γίνεται EncodeDate 2026, 1, 1, το trailer identifier έρχεται από ComputeCanonicalDocumentIdentifier πάνω στον canonical γράφο με μηδενισμένα ψηφία D:, τα κλειδιά και salts AES γεμίζουν με bytes $5A και το AESGenerateStaticIV γεμίζει κάθε θέση, και το DeterministicDictionaryOrder ταξινομεί κάθε dictionary
Οι αντικαταστάσεις τρέχουν στις ίδιες διαδρομές κώδικα που θα έφταναν αλλιώς για το ρολόι ή τη γεννήτρια τυχαίων, οπότε δεν χρειάζεται ξεχωριστό πέρασμα καθαρισμού και τα δύο στοιχεία /ID λαμβάνουν την ίδια από-περιεχόμενο-παραγόμενη τιμή

Γιατί η σταθερή ημερομηνία διέρρεε ακόμα το ρολόι τοίχου;

Το fix v2.752.2 υπάρχει επειδή η σταθερή ημερομηνία δημιουργίας αποφασιζόταν αρχικά στον constructor, και ο constructor δεν μπορεί να ξέρει ιδιότητα που ο caller δεν έχει θέσει ακόμα. Η συνηθισμένη ακολουθία κλήσης είναι Create, μετά ReproducibleOutput := True, μετά BeginDoc. Τη στιγμή της κατασκευής το FReproducibleOutput είναι ακόμα False, οπότε το FCreationDate έλαβε Now και το κράτησε. Το identifier και τα τυχαία bytes καρφώνονταν σωστά, οπότε τα δύο αρχεία συμφωνούσαν σχεδόν παντού και διαφωνούσαν σε ακριβώς δύο strings ημερομηνιών και δύο πεδία XMP. Η μετακίνηση της ανάθεσης στον αναπαραγώγιμο branch του BeginDoc, δίπλα στο seeded identifier, έβαλε την απόφαση στο σημείο όπου η ιδιότητα έχει την τελική της τιμή

Το regression test που το έχασε αξίζει περισσότερο από το fix. Δύο saves που και τα δύο τρέχουν μέσα στο ίδιο δευτερόλεπτο ρολογιού γράφουν την ίδια string D: κατά λάθος, και η σύγκριση bytes περνάει για bug που αποτυγχάνει σε οποιονδήποτε πιο αργό μηχανήμα. Ο διορθωμένος test κοιμάται 1100 ms ανάμεσα στα δύο saves ώστε η χρονική σήμανση PDF να είναι εγγυημένο ότι διασχίζει όριο δευτερολέπτου, τρέχει την περίπτωση για plain, AES-128 και AES-256 έξοδο με πραγματικούς κωδικούς στις δύο κρυπτογραφημένες παραλλαγές, και συγκρίνει τους δύο buffers με CompareMem, αναφέροντας την πρώτη διαφέρουσα offset σε αποτυχία ώστε το diff να δείχνει συγκεκριμένο object αντί για όλο το αρχείο. Μια σύγκριση bytes αποδεικνύει ντετερμινισμό και τίποτα άλλο, οπότε κράτα ξεχωριστό ισχυρισμό που ξαναφορτώνει την κρυπτογραφημένη έξοδο με τον κωδικό χρήστη και διαβάζει πλήθος σελίδων· μια αλλαγή που κάνει το αρχείο σταθερό και αναγνώσιμο ταυτόχρονα δεν πρέπει να γλιστρήσει στη δύναμη ενός πράσινου diff

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;

// στο σώμα του test
A := SaveOnce(PathA);
TThread.Sleep(1100);          // ανάγκασε διαφορετικό δευτερόλεπτο χρονικής σήμανσης PDF
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');

Είναι ακόμα ασφαλές ένα αναπαραγώγιμο κρυπτογραφημένο PDF;

Όχι. Έγγραφο κρυπτογραφημένο κάτω από ReproducibleOutput δεν είναι προστατευμένο με κανένα ουσιαστικό νόημα, και η σημαία πρέπει να είναι off για οτιδήποτε φεύγει από τον κατάλογο tests. Το κλειδί κρυπτογράφησης αρχείου AES-256 είναι τριάντα δύο bytes $5A, τα salts είναι οκτώ bytes $5A, και τα vectors αρχικοποίησης ακολουθούν δημοσιευμένο αριθμητικό μοτίβο. Ο κωδικός εξακολουθεί να φρουρά τα wrappers /UE και /OE, αλλά το τυλιγμένο κλειδί είναι σταθερά, οπότε όποιος ξέρει τη σταθερά μπορεί να αποκρυπτογραφήσει κάθε content stream χωρίς καθόλου κωδικό. Το σταθερό των salts αφαιρεί επίσης τη μοναδικότητα ανά έγγραφο που το ISO 32000-2 §7.6.4.4.7 στηρίζει για να κρατά πανομοιότυπους κωδικούς από το να δίνουν πανομοιότυπα strings /U σε αρχεία. Διάβασε το άρθρο ρύθμισης AES-256 για το τι υπόσχονται οι ιδιότητες κρυπτογράφησης όταν η τυχαία πηγή είναι άθικτη· κάτω από την αναπαραγώγιμη σημαία εκείνες οι υποσχέσεις αναστέλλονται

Ο συμβιβασμός του identifier είναι πιο λεπτός. Το ISO 32000-1 §14.4 προορίζει το δεύτερο στοιχείο /ID να αλλάζει σε κάθε τροποποίηση ώστε τα εργαλεία να ξεχωρίζουν ενημερωμένο αρχείο από τον πρόγονό του, και μια αναπαραγώγιμη αποθήκευση γράφει την ίδια τιμή και στις δύο θέσεις. Επειδή εκείνη η τιμή είναι hash του canonical γράφου objects, δύο έγγραφα με διαφορετικό περιεχόμενο παίρνουν ακόμα διαφορετικά identifiers, που είναι καλύτερο από μια σταθερά. Αλλά ο seed που το BeginDoc χρησιμοποιεί για παράγωγο κλειδιού είναι το ίδιο string για κάθε έγγραφο σε κάθε μηχάνημα, και ένας reader που κλειδώνει πάνω στο /ID για να ξεχωρίζει αρχεία, ένα annotation cache ή ένα form-data sidecar για παράδειγμα, θα μπερδέψει κάθε αναπαραγώγιμο αρχείο που τυχαίνει να έχει το ίδιο hash

Τι δεν καλύπτει η σημαία;

Το ReproducibleOutput αφαιρεί την εντροπία που εισάγει ο ίδιος ο writer· δεν μπορεί να αφαιρέσει εντροπία που μπαίνει μέσω του περιβάλλοντος ή μέσω διαδρομών κώδικα που δεν ελέγχει, και τρεις από εκείνες είναι εύκολο να σκοντάψεις

  • Η κατάληξη ζώνης ώρας. Το _DateTimeToPdfDate προσθέτει την τοπική offset UTC, οπότε D:20260101000000+08'00' σε έναν build agent και D:20260101000000-05'00' σε άλλον είναι διαφορετικά bytes για την ίδια σταθερή ημερομηνία. Η αναπαραγωγιμότητα ισχύει σε runs στο ίδιο μηχάνημα, ή σε μηχανήματα που μοιράζονται ζώνη ώρας· καρφώσε τη ζώνη του agent αν τα golden αρχεία σου ταξιδεύουν
  • Τα incremental updates. Το SaveIncrementalUpdate υπολογίζει το identifier τροποποίησής του από τη διαδρομή στόχου, το GetTickCount και την τρέχουσα ώρα χωρίς αναπαραγώγιμο branch, επειδή μια incremental ενότητα είναι εξ ορισμού νέα τροποποίηση. Σύγκρινε πλήρεις επανεγγραφές, όχι προστιθέμενα deltas
  • Η συντόμευση passthrough. Το SaveLoadedDocument συνήθως αντιγράφει ένα αμετάβλητο, μη κρυπτογραφημένο αρχείο πηγής byte προς byte αντί να το ξανασειριοποιήσει. Η αναπαραγώγιμη σημαία απενεργοποιεί εκείνη τη συντόμευση και επιβάλλει πλήρη επανεγγραφή ώστε να ισχύουν οι κανόνες ταξινόμησης και identifier, που σημαίνει ότι το αναπαραγώγιμο save φορτωμένου αρχείου είναι πιο αργό από το προεπιλεγμένο και δεν είναι ποτέ αντίγραφο της εισόδου. Κάνε diff απέναντι σε προηγούμενο αναπαραγώγιμο save, ποτέ απέναντι στο πρωτότυπο
Πού σταματούν τα αναπαραγώγιμα saves HotPDF: το _DateTimeToPdfDate εξακολουθεί να προσθέτει την τοπική offset UTC οπότε τα golden αρχεία διαφέρουν σε ζώνες ώρας, το SaveIncrementalUpdate δεν έχει αναπαραγώγιμο branch επειδή ένα delta είναι νέα τροποποίηση, και η συντόμευση passthrough απενεργοποιείται ώστε φορτωμένο αρχείο να ξαναγράφεται πάντα πλήρως
Η αναπαραγωγιμότητα ισχύει σε runs στο ίδιο μηχάνημα ή σε μηχανήματα που μοιράζονται ζώνη, και ένα αναπαραγώγιμο save πρέπει να γίνεται diff απέναντι σε προηγούμενο αναπαραγώγιμο save, ποτέ απέναντι στην πρωτότυπη είσοδο

Ακόμα ένα μάθημα από την ίδια κυκλοφορία, για το τι αποδεικνύει και τι δεν αποδεικνύει ένας έλεγχος που περνάει. Ένα fixture test PDF/X-6 καλούσε CharProcs.DeleteValue('A'), που απελευθέρωνε directly κρατούμενο glyph stream, και μετά επαν-εισήγαγε τον ίδιο pointer, και ξεχωριστά έδινε ένα direct object ExtGState σε resource dictionary και σε pattern ταυτόχρονα. Ο validator συμμόρφωσης περνούσε διαλείποντα πάνω σε εκείνο το use-after-free και διπλή ιδιοκτησία επειδή διάβαζε ό,τι τύχαινε να κρατά η απελευθερωμένη μνήμη. Όταν ένας δομικός έλεγχος τρεμοπαίζει, κοίτα την ιδιοκτησία της εισόδου test πριν κοιτάξεις τον validator. Η αναπαραγώγιμη έξοδος κάνει εκείνη την πειθαρχία φτηνότερη: μόλις δύο saves είναι πανομοιότυπα byte, η μόνη απομένουσα πηγή τρεμοπαίγματος είναι ο ίδιος ο γράφος objects, και ένα δομικό diff από τον κατάλογο και κάτω θα το βρει

Οι ιδιότητες ReproducibleOutput, DeterministicDictionaryOrder και κρυπτογράφησης που περιγράφονται εδώ κυκλοφορούν στο standard HotPDF Delphi Component για Delphi και C++Builder, και η ίδια σημαία κινεί το δικό του regression corpus της βιβλιοθήκης, οπότε η συμπεριφορά που παίρνεις σε test suite είναι η συμπεριφορά με την οποία δοκιμάζεται το component