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

Ατομική δημοσίευση επισκευής PDF στο Delphi: rename, DACL

Το PDF Library for Delphi δημοσιεύει την έξοδο του RepairQDFFile μέσω ενός εσωτερικού writer, του TPDFQDFFileWriter, που δεν ανοίγει ποτέ τον προορισμό για γράψιμο: τα επιδιορθωμένα bytes μπαίνουν σε προσωρινό αρχείο αποκλειστικά δημιουργημένο στον ίδιο κατάλογο, το αρχείο γίνεται flush και κλείνει, και μόνο τότε μετονομάζεται πάνω στον στόχο με MoveFileExW σε Windows ή rename(2) σε POSIX. Αν κάτι αποτύχει πριν το rename, ο προορισμός κρατά κάθε byte που είχε, και ο caller βλέπει LastErrorCode 305. Η επισκευή εγγράφου στη μνήμη είναι το εύκολο μισό μιας λειτουργίας επισκευής. Το να βγεις με το αποτέλεσμα στον δίσκο χωρίς ποτέ να αφήσεις τον χρήστη με αρχείο μηδενικού μήκους ή μισογραμμένο είναι το μισό για το οποίο μιλάει αυτό το άρθρο

Γιατί μπορεί μια αποτυχημένη επισκευή να καταστρέψει το αρχείο-στόχο;

Επειδή η σειρά των λειτουργιών ήταν λάθος. Πριν το v3.539.13, το RepairQDFFile άνοιγε την έξοδο με PLCreateFileStream(OutputFileName, fmCreate) και μετά παρεδίδε εκείνο το stream στον parser. Το fmCreate κόβει στο άνοιγμα, οπότε τη στιγμή που η σάρωση QDF αποφάσιζε ότι η είσοδος δεν ήταν επισκευάσιμη, ο προορισμός είχε ήδη αδειάσει. Η επισκευή επί τόπου, όπου InputFileName και OutputFileName είναι η ίδια διαδρομή, μετέτρεπε μια απορριφθείσα είσοδο σε χαμένο αρχείο. Ο parser ο ίδιος ήταν κόσμιος: η χαμηλού επιπέδου συνάρτηση PDFQDFRepair αφήνει το stream στόχο ανέγγιχτο όταν απορρίπτει αμφίσημα markers. Εκείνη η προστασία ήταν απλώς άσχετη, επειδή το δημόσιο API είχε κόψει το αρχείο μία κλήση νωρίτερα

Το fix του v3.539.13 μετέφερε την επισκευή σε TMemoryStream και άνοιγε την έξοδο μόνο αφού το PDFQDFRepair είχε πετύχει. Αυτό κλείνει την τρύπα αποτυχίας ανάλυσης και τίποτα άλλο. Η φάση γραφής ήταν ακόμα fmCreate ακολουθούμενο από CopyFrom, οπότε μια κατάσταση full disk, μια παραβίαση κοινής χρήσης στη μέση, ή μια εξαίρεση ανάμεσα στην περικοπή και το τελευταίο WriteBuffer άφηνε ακόμα κατεστραμμένο προορισμό. Η επισκευή με τη μνήμη πρώτα προστατεύει από κακή είσοδο. Η δημοσίευση στον δίσκο θέλει το δικό της όριο, και τα v3.539.14 και v3.539.15 έχτισαν ένα

Πώς το RepairQDFFile στο PDF Library for Delphi σταμάτησε να καταστρέφει τον δικό του στόχο: το v3.539.12 άνοιγε την έξοδο με PLCreateFileStream και fmCreate, που κόβει πριν προλάβει το PDFQDFRepair να απορρίψει την είσοδο, το v3.539.13 επισκεύαζε πρώτα σε TMemoryStream, και το v3.539.15 παραδίδει τα bytes στο TPDFQDFFileWriter για ατομική δημοσίευση
Το fix αποτυχίας ανάλυσης και το fix δημοσίευσης είναι διαφορετικά όρια: η επισκευή με τη μνήμη πρώτα προστατεύει από κακή είσοδο, ενώ ο writer υπάρχει ώστε ένας γεμάτος δίσκος ή μια αποτυχία στη μέση μιας γραφής να μην μπορεί πια να αφήσει τον προορισμό κατεστραμμένο
// v3.539.12: ο προορισμός περικόπτεται πριν επικυρωθεί η είσοδος
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
  if PDFQDFRepair(Source, Output, QDFError) then   // πολύ αργά για να πεις όχι
    Result := 1;
finally
  Output.Free;
end;

// v3.539.15: επισκευή στη μνήμη, μετά δίνεις τα bytes στον writer δημοσίευσης
Repaired := TMemoryStream.Create;
try
  if not PDFQDFRepair(Source, Repaired, QDFError) then
    Exit;                                          // ο προορισμός δεν άνοιξε ποτέ
  Writer := TPDFQDFFileWriter.Create;
  try
    Writer.Save(Repaired, OutputFileName);
    Result := 1;
  finally
    Writer.Free;
  end;
finally
  Repaired.Free;
end;

Τι εγγυάται στην πραγματικότητα η ατομική δημοσίευση;

Το TPDFQDFFileWriter.Save εγγυάται ότι η διαδρομή προορισμού είναι είτε το πλήρες παλιό αρχείο είτε το πλήρες νέο αρχείο, ποτέ μείγμα, για κάθε αποτυχία που μπορεί η ίδια η βιβλιοθήκη να παρατηρήσει. Ο writer το κάνει σε τέσσερα βήματα που το καθένα αρνείται να προχωρήσει εκτός αν το προηγούμενο τελείωσε. Πρώτα επιλύει τον προορισμό με GetFullPathNameW, καλώντας το δύο φορές και δεσμεύοντας τον buffer από το επιστραφέν μήκος αντί να υποθέσει MAX_PATH, ώστε τα μακριά paths να μην κόβονται σιωπηλά. Δεύτερο δημιουργεί προσωρινό αρχείο με όνομα .pdflib-qdf- συν GUID συν .tmp στον κατάλογο προορισμού, χρησιμοποιώντας CreateFileW με CREATE_NEW σε Windows και open(2) με O_CREAT or O_EXCL και mode 0600 σε POSIX. Και οι δύο σημαίες κάνουν τη δημιουργία να αποτύχει αν το όνομα υπάρχει ήδη, οπότε δύο processes που τρέχουν πάνω στο ίδιο GUID δεν μπορούν να μοιραστούν handle. Τρίτο αντιγράφει το επιδιορθωμένο stream σε chunks των 64 KiB μέσω WriteBuffer, που πετάει σε σύντομο γράψιμο αντί να επιστρέψει μετρητή που κανείς δεν ελέγχει, μετά καλεί FlushFileBuffers ή fsync(2) και κλείνει το handle. Τέταρτο μετονομάζει

Τα τέσσερα ατομικά βήματα του TPDFQDFFileWriter.Save στο PDF Library for Delphi: επίλυση της διαδρομής δύο φορές με GetFullPathNameW, δημιουργία του προσωρινού αρχείου .pdflib-qdf με CREATE_NEW ή O_EXCL ώστε τρέχοντας processes να μη μοιράζονται handle, αντιγραφή σε chunks WriteBuffer των 64 KiB και flush, μετά MoveFileExW με REPLACE_EXISTING και WRITE_THROUGH
Κάθε βήμα αρνείται να προχωρήσει εκτός αν το προηγούμενο τελείωσε, το προσωρινό αρχείο μένει στον volume προορισμού εκ κατασκευής, ένα παράθυρο διαγραφής-πρώτα δεν υπάρχει ποτέ, και ο καθαρισμός σε finally δεν αφήνει πίσω σκουπίδια .tmp
procedure TPDFQDFFileWriter.Flush(Target: TStream);
begin
  if not FlushFileBuffers(THandleStream(Target).Handle) then
    raise EWriteError.Create('Unable to flush QDF output');
end;

procedure TPDFQDFFileWriter.Publish(const TempFileName, FileName: WideString);
begin
  // Δεν επιτρέπεις αντιγραφή μεταξύ volumes ούτε διαγραφή πρώτα του προορισμού
  if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
    MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
    raise EWriteError.Create('Unable to publish QDF output');
end;

Το βήμα rename είναι εκεί όπου οι περισσότερες σπιτικές ρουτίνες «ασφαλούς αποθήκευσης» χαλάγανε σιωπηλά. Το MoveFileExW με MOVEFILE_REPLACE_EXISTING αντικαθιστά τον στόχο σε μία λειτουργία filesystem στον ίδιο volume. Ο writer αφήνει σκόπιμα εκτός το MOVEFILE_COPY_ALLOWED, επειδή μια μετακίνηση μεταξύ volumes υποβαθμίζεται σε copy-then-delete, που είναι ακριβώς η μη ατομική ακολουθία για την αποφυγή της οποίας υπάρχει όλος αυτός ο σχεδιασμός. Εφόσον το προσωρινό αρχείο μένει στον κατάλογο προορισμού, είναι στον volume προορισμού εκ κατασκευής. Ο writer επίσης δεν διαγράφει ποτέ πρώτα το παλιό αρχείο· ένα ζεύγος delete-then-rename έχει ένα παράθυρο μέσα στο οποίο η διαδρομή δεν υπάρχει καθόλου, και ένα crash μέσα σε εκείνο το παράθυρο χάνει το έγγραφο. Το MOVEFILE_WRITE_THROUGH ζητά από την κλήση να μην επιστρέψει μέχρι το rename να έχει φτάσει στον δίσκο, που ζευγαρώνει με το ρητό flush των δεδομένων. Σε POSIX, το rename(2) εγγυάται ήδη ότι το νέο όνομα αντικαθιστά ατομικά οποιοδήποτε υπάρχον αρχείο, και η ίδια τοποθέτηση σε κατάλογο το κρατά από το να αποτύχει με EXDEV. Ο καθαρισμός είναι συμμετρικός. Το προσωρινό όνομα αφαιρείται σε block finally σε κάθε διαδρομή, που σε επιτυχία είναι no-op επειδή το rename το έχει ήδη καταναλώσει, και σε αποτυχία αφαιρεί το μερικό αρχείο ώστε ο κατάλογος να μη συσσωρεύει σκουπίδια .tmp. Το regression στο Tests\QDFFileRegression.inc ελέγχει ακριβώς αυτό: μετά από κάθε χορηγημένη αποτυχία, τα bytes του προορισμού ταιριάζουν με τα πρωτότυπα, τα bytes της πηγής ταιριάζουν με τα πρωτότυπα, και ο κατάλογος δεν περιέχει τίποτα εκτός από τα δύο fixtures

Γιατί ένα προσωρινό αρχείο χαλαρώνει δικαιώματα σε Windows;

Ένα αρχείο δημιουργημένο με κενό security descriptor κληρονομεί το DACL του από τον γονικό κατάλογο, όχι από το αρχείο που πρόκειται να αντικαταστήσει. Αυτή είναι η σωστή προεπιλογή για ολοκαίνουργιο έγγραφο και η λάθος για επισκευή επί τόπου. Υπέθεσε ότι ένας διαχειριστής έχει κλειδώσει το contract.pdf σε έναν μόνο λογαριασμό με προστατευμένο, μη κληρονομούμενο DACL. Ένα προσωρινό αρχείο δίπλα του κληρονομεί τα φαρδύτερα δικαιώματα του καταλόγου, και μόλις μετονομαστεί πάνω στο contract.pdf το μετονομασμένο αρχείο κουβαλάει το φαρδύ DACL, επειδή η ασφάλεια NTFS ταξιδεύει με το file object, όχι με το όνομα. Η επισκευή πετυχαίνει, τα bytes είναι σωστά, και ο έλεγχος πρόσβασης που ρύθμισε ο διαχειριστής έχει εξαφανιστεί σιωπηλά. Τίποτα στην επιστρεφόμενη τιμή δεν το υπονοεί

Το PDF Library for Delphi διαβάζει επομένως το DACL του προορισμού πριν δημιουργήσει το προσωρινό αρχείο και το περνάει ως όρισμα lpSecurityAttributes στο CreateFileW, ώστε το νέο αρχείο να γεννιέται με τα δικαιώματα του παλιού και το rename να μην αλλάζει τίποτα που θα πρόσεχε ο διαχειριστής. Το διάβασμα χρησιμοποιεί GetFileSecurityW με DACL_SECURITY_INFORMATION, μετρώντας τον buffer από το αποτέλεσμα ERROR_INSUFFICIENT_BUFFER της πρώτης κλήσης. Τρεις συνθήκες κάνουν τον writer να αποτυγχάνει κλειστός αντί να μαντέψει. Αν το DACL δεν μπορεί να διαβαστεί, η δημοσίευση σταματά με EWriteError, που το δημόσιο API αντιστοιχίζει στο 305. Αν ο descriptor επιστρέψει χωρίς set το SE_DACL_PRESENT, η δημοσίευση σταματά επίσης, επειδή το να περάσεις τέτοιο descriptor στο CreateFileW θα άφηνε τον kernel να πέσει πίσω στο προεπιλεγμένο DACL της process και να αλλάξει σημασιολογία πρόσβασης χωρίς κανείς να το ζήτησε. Και αν ο στόχος κουβαλάει FILE_ATTRIBUTE_ENCRYPTED, ο writer αρνείται ευθέως: το προσωρινό αρχείο θα ήταν plaintext, και το να μετονομαστεί plaintext αρχείο πάνω σε EFS-προστατευμένο δημοσιεύει μια μη κρυπτογραφημένη αντικατάσταση κάτι που ο χρήστης διάλεξε να κρυπτογραφήσει στο επίπεδο filesystem. Το EFS δεν έχει σχέση με τους standard security handlers PDF, που είναι το θέμα του άρθρου για το φόρτωμα κρυπτογραφημένων εγγράφων, αλλά η λειτουργία αποτυχίας είναι το ίδιο είδος σιωπηλής υποβάθμισης

Γιατί ο writer δημοσίευσης QDF αντιγράφει το DACL του προορισμού πριν δημιουργήσει το προσωρινό αρχείο του: ένας κενός descriptor θα κληρονομούσε τα φαρδύτερα δικαιώματα του καταλόγου και το rename θα πλάτυνε σιωπηλά την πρόσβαση, οπότε το GetFileSecurityW διαβάζει το DACL, ένα απόν bit SE_DACL_PRESENT ή μια ιδιότητα EFS σταματά τη δημοσίευση με 305, και το CreateFileW γεννιέται με τα παλιά δικαιώματα
Η ασφάλεια NTFS ταξιδεύει με το file object, όχι με το όνομα: το πέρασμα του διαβασμένου descriptor ως lpSecurityAttributes κάνει το rename να μην αλλάζει τίποτα που ρύθμισε ο διαχειριστής, και κάθε πύλη αποτυγχάνει κλειστή αντί να μαντεύει
Attributes := GetFileAttributesW(PWideChar(Destination));
if Attributes <> INVALID_FILE_ATTRIBUTES then
begin
  if (Attributes and FILE_ATTRIBUTE_ENCRYPTED) <> 0 then
    raise EWriteError.Create('QDF replacement of an EFS encrypted file is not supported');
  // μέτρησε τον descriptor, μετά διάβασε μόνο το τμήμα DACL του
  if not GetFileSecurityW(PWideChar(Destination), DACL_SECURITY_INFORMATION,
    @Security[0], SecuritySize, SecuritySize) then
    raise EWriteError.Create('Unable to read QDF destination permissions');
  if not QDFGetSecurityDescriptorControl(@Security[0], Control, Revision) or
     ((Control and SE_DACL_PRESENT) = 0) then
    raise EWriteError.Create('QDF destination has no explicit DACL');
  SecurityAttributes.lpSecurityDescriptor := @Security[0];
  SecurityPointer := @SecurityAttributes;   // παραδίδεται στο CreateFileW / CREATE_NEW
end;

Μία λεπτομέρεια από το regression αξίζει να έχεις κατά νου αν γράψεις μόνο σου παρόμοιο test. Για να χτίσεις το περιορισμένο fixture, το test εφαρμόζει DACL μόνο-ιδιοκτήτη και πρέπει να θέσει SE_DACL_PROTECTED στον έλεγχο descriptor ρητά· το απλό πέρασμα της προστατευμένης σημαίας στο όρισμα SecurityInformation του SetFileSecurityW δεν μετατρέπει απροστάτευτο descriptor σε προστατευμένο. Ο ισχυρισμός μετά είναι ότι το δημοσιευμένο αρχείο αναφέρει ακόμα το προστατευμένο bit και ρητό, μη κενό DACL, τόσο για ξεχωριστή διαδρομή εξόδου όσο και για επισκευή πάνω στο ίδιο το αρχείο πηγής

Ποιο LastErrorCode σου λέει τι απέτυχε;

Το RepairQDFFile επιστρέφει 1 σε επιτυχία και 0 σε οποιαδήποτε αποτυχία, και το LastErrorCode λέει ποια φάση αρνήθηκε. Πηγή που δεν μπορεί να διαβαστεί, συμπεριλαμβανομένης μιας που κρατά άλλη process με αποκλειστικό lock, αναφέρει 401· το διάβασμα τυλίγεται πλέον ώστε μια εξαίρεση στην είσοδο να αντιστοιχίζεται στο 401 αντί να διαρρέει στο error γραφής. Άκυρη ή αμφίσημη δομή QDF, όπως διπλό stream marker για το ίδιο object, αναφέρει PDFLIB_ERROR_QDF_REPAIR, που είναι 107, και ο προορισμός δεν έχει αγγιχτεί επειδή ο writer δεν κατασκευάστηκε ποτέ. Ό,τι μετά την επισκευή, από δημιουργία προσωρινού αρχείου μέχρι flush και rename, αναφέρει PDFLIB_ERROR_QDF_WRITE, που είναι 305. Το regression εξασκεί τα ρεαλιστικά: προορισμό ανοιχτό από άλλο handle χωρίς delete sharing, προορισμό read-only, κατάλογο προορισμού απόντα, και καθεμία από τις τρεις φάσεις του writer να αποτυγχάνει μέσω έγχυσης. Σε όλα η επιστροφή είναι 0, ο κωδικός είναι 305, και κανένας νέος ή μερικός στόχος δεν υπάρχει μετά. Η γενική συνήθεια του διαβάσματος του κωδικού αντί μόνο της επιστροφής είναι η ίδια που περιγράφεται στο άρθρο για τη διάγνωση σιωπηλών αποτυχιών στη βιβλιοθήκη

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    // Επισκευή επί τόπου: η ίδια διαδρομή είναι είσοδος και έξοδος
    if Pdf.RepairQDFFile('edited.qdf.pdf', 'edited.qdf.pdf') = 1 then
      Log('published; the previous bytes were replaced in one rename')
    else
      case Pdf.LastErrorCode of
        401: Log('could not read the input; it was not modified');
        107: Log('QDF structure rejected; the destination was never opened');
        305: Log('write, flush or replace failed; the destination still holds its old bytes');
      end;
  finally
    Pdf.Free;
  end;
end;

Πού σταματά η εγγύηση

Ο writer υπόσχεται συνέπεια απέναντι σε αποτυχίες που μπορεί η process να δει, και είναι ειλικρινής για εκείνες που δεν μπορεί. Αν η process σκοτωθεί ανάμεσα στη δημιουργία του προσωρινού αρχείου και το rename, το block finally δεν τρέχει ποτέ και ένα .pdflib-qdf-<GUID>.tmp μένει στον κατάλογο· ο προορισμός παραμένει άθικτος, που είναι η ιδιότητα που μετράει, αλλά τα σκουπίδια είναι δικά σου να σαρώσεις. Η απώλεια ρεύματος είναι επίσης έξω από την υπόσχεση: τα δεδομένα γίνονται flush και το rename είναι write-through, που είναι το καλύτερο που μπορεί να ζητήσει βιβλιοθήκη user-mode, αλλά ο writer δεν κάνει fsync στην εγγραφή του καταλόγου και δεν κάνει καμία δήλωση αντοχής πάνω σε ό,τι δίνει το filesystem. Ένας δεύτερος writer που τροποποιεί τον προορισμό ταυτόχρονα δεν ανιχνεύεται, επειδή το DACL και τα attributes διαβάζονται πριν δημιουργηθεί το προσωρινό αρχείο και τίποτα δεν τα ξαναελέγχει τη στιγμή του rename. Και ένα επιτυχημένο rename δημιουργεί νέα ταυτότητα αρχείου, οπότε τα alternate data streams και τα συνηθισμένα attributes όπως το archive ή hidden bit του παλιού αρχείου δεν επιβιώνουν· μόνο το DACL μεταφέρεται σκόπιμα

Το στενότερο όριο είναι ποιο API χρησιμοποιεί καν αυτή τη διαδρομή. Μόνο το RepairQDFFile περνάει από TPDFQDFFileWriter. Τα SaveQDFToFile και ConvertFileToQDF ανοίγουν ακόμα την έξοδό τους με PLCreateFileStream(FileName, fmCreate) και ρέουν τη μετατροπή QDF κατευθείαν μέσα της, με τον ίδιο τρόπο που η incremental διαδρομή που περιγράφεται στο άρθρο για την προσθήκη updates σε stream γράφει σε όποιο stream της δώσεις. Εκείνες οι δύο κλήσεις παράγουν νέο τεχνουργημένο debugging από έγγραφο που έχει ήδη φορτωθεί και επικυρωθεί, οπότε η τρύπα αποτυχίας ανάλυσης δεν τους εφάρμοσε ποτέ, αλλά δεν κληρονομούν ούτε την δημοσίευση με rename. Μην διαβάσεις αυτό το άρθρο ως «κάθε εξαγωγή QDF είναι ατομική». Είναι μία έξοδος, εκείνη της οποίας η είσοδος είναι αναξιόπιστο, χειρόγραφα επεξεργασμένο αρχείο και της οποίας η έξοδος είναι συνήθως η ίδια διαδρομή, και εκείνος ο συνδυασμός είναι που της κέρδισε τη λογική επιπλέον. Η έγχυση σφαλμάτων που αποδεικνύει όλα αυτά είναι φτηνή επειδή οι τρεις φάσεις του writer, WriteData, Flush και Publish, είναι virtual. Η υποκλάση test override μία από αυτές να πετάει αφού η πραγματική δουλειά έχει ξεκινήσει, καλεί Save σε επιδιορθωμένο stream, και ισχυρίζεται ότι η εξαίρεση διαδίδεται, ότι τα bytes πηγής και προορισμού είναι αμετάβλητα, και ότι κανένα προσωρινό αρχείο δεν μένει. Καμία global file API δεν είναι καρφωμένη, κανένα πραγματικό αρχείο χρήστη δεν αγγίζεται, και οι τρεις φάσεις αντιστοιχίζονται ένα-προς-ένα στους τρεις τρόπους με τους οποίους μπορεί να αποτύχει μια δημοσίευση σε παραγωγή: ο δίσκος γεμίζει, το flush απορρίπτεται, ή το rename αρνείται επειδή κάποιος άλλος κρατά τον στόχο

Το API RepairQDFFile, ο ατομικός writer δημοσίευσής του και η υπόλοιπη ροή εργασίας debugging QDF είναι μέρος του PDF Library for Delphi, μαζί με την ανάκτηση cross-reference, το incremental update και τις λειτουργίες κρυπτογράφησης που καλύπτονται αλλού σε αυτό το blog