Technischer Artikel

Atomarer PDF-Repair-Output in Delphi: Rename und DACL

Die PDF Library for Delphi veröffentlicht die Ausgabe von RepairQDFFile über einen internen Writer, TPDFQDFFileWriter, der das Ziel nie zum Schreiben öffnet: Die reparierten Bytes landen in einer exklusiv angelegten temporären Datei im selben Verzeichnis, die Datei wird geflusht und geschlossen, und erst dann wird sie per MoveFileExW unter Windows oder rename(2) unter POSIX über das Ziel umbenannt. Scheitert irgendetwas vor dem Rename, behält das Ziel jedes Byte, das es hatte, und der Aufrufer sieht LastErrorCode 305. Ein Dokument im Speicher zu reparieren ist die einfache Hälfte einer Repair-Funktion. Das Ergebnis so auf die Platte zu bekommen, dass der Benutzer nie mit einer Null-Byte- oder halbgeschriebenen Datei zurückbleibt, ist die Hälfte, um die es in diesem Artikel geht

Warum kann eine Reparatur, die scheitert, trotzdem die Zieldatei zerstören?

Weil die Reihenfolge der Operationen falsch war. Vor v3.539.13 öffnete RepairQDFFile die Ausgabe mit PLCreateFileStream(OutputFileName, fmCreate) und reichte diesen Stream dann an den Parser weiter. fmCreate kürzt beim Öffnen auf null, also war das Ziel bereits geleert, wenn der QDF-Scan entschied, die Eingabe sei nicht reparabel. In-place-Reparatur, bei der InputFileName und OutputFileName derselbe Pfad sind, machte aus einer abgelehnten Eingabe eine verlorene Datei. Der Parser selbst benahm sich korrekt: Die Low-Level-Funktion PDFQDFRepair lässt den Ziel-Stream unangetastet, wenn sie mehrdeutige Marker zurückweist. Dieser Schutz war schlicht irrelevant, denn die öffentliche API hatte die Datei einen Aufruf zuvor bereits gekürzt

Der Fix in v3.539.13 verlagerte die Reparatur in einen TMemoryStream und öffnete die Ausgabe erst, nachdem PDFQDFRepair erfolgreich war. Das schließt das Parse-Failure-Loch und sonst nichts. Die Schreibphase blieb fmCreate gefolgt von CopyFrom, also hinterließ eine volle Platte, eine Sharing-Verletzung auf halbem Weg oder eine Exception zwischen Kürzung und letztem WriteBuffer weiterhin ein beschädigtes Ziel. Reparatur-zuerst-im-Speicher schützt vor schlechter Eingabe. Die Publikation auf die Platte braucht ihre eigene Grenze, und v3.539.14 und v3.539.15 haben eine gebaut

Wie RepairQDFFile in der PDF Library for Delphi aufhörte, sein eigenes Ziel zu zerstören: v3.539.12 öffnete die Ausgabe mit PLCreateFileStream und fmCreate, das abschneidet, bevor PDFQDFRepair die Eingabe ablehnen kann, v3.539.13 reparierte zuerst in einen TMemoryStream, und v3.539.15 übergibt die Bytes zur atomaren Publikation an TPDFQDFFileWriter
Der Parse-Failure-Fix und der Publikations-Fix sind verschiedene Grenzen: Reparatur-zuerst-im-Speicher schützt vor schlechter Eingabe, während der Writer existiert, damit eine volle Platte oder ein Scheitern auf halbem Schreibweg das Ziel nicht mehr beschädigt zurücklassen kann
// v3.539.12: Das Ziel wird abgeschnitten, bevor die Eingabe validiert ist
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
  if PDFQDFRepair(Source, Output, QDFError) then   // zu spät für ein Nein
    Result := 1;
finally
  Output.Free;
end;

// v3.539.15: im Speicher reparieren, dann die Bytes an den Publikations-Writer übergeben
Repaired := TMemoryStream.Create;
try
  if not PDFQDFRepair(Source, Repaired, QDFError) then
    Exit;                                          // Ziel wurde nie geöffnet
  Writer := TPDFQDFFileWriter.Create;
  try
    Writer.Save(Repaired, OutputFileName);
    Result := 1;
  finally
    Writer.Free;
  end;
finally
  Repaired.Free;
end;

Was garantiert die atomare Publikation tatsächlich?

TPDFQDFFileWriter.Save garantiert, dass der Zielpfad entweder die komplette alte Datei oder die komplette neue Datei ist, nie eine Mischung – für jeden Fehler, den die Bibliothek selbst beobachten kann. Der Writer tut das in vier Schritten, die sich jeder weigern weiterzumachen, solange der vorherige nicht abgeschlossen ist. Erstens löst er das Ziel mit GetFullPathNameW auf, ruft ihn zweimal auf und dimensioniert den Buffer aus der zurückgegebenen Länge, statt MAX_PATH anzunehmen, sodass lange Pfade nicht stillschweigend abgeschnitten werden. Zweitens legt er eine temporäre Datei namens .pdflib-qdf- plus GUID plus .tmp im Zielverzeichnis an, mit CreateFileW und CREATE_NEW unter Windows und open(2) mit O_CREAT oder O_EXCL und Mode 0600 unter POSIX. Beide Flags lassen das Anlegen scheitern, wenn der Name bereits existiert, sodass zwei Prozesse, die um dieselbe GUID rasen, sich kein Handle teilen können. Drittens kopiert er den reparierten Stream in 64-KiB-Blöcken durch WriteBuffer, das bei einem kurzen Schreiben eine Exception wirft, statt einen Zähler zurückzugeben, den niemand prüft, ruft danach FlushFileBuffers oder fsync(2) und schließt das Handle. Viertens benennt er um

Die vier atomaren Schritte von TPDFQDFFileWriter.Save in der PDF Library for Delphi: Den Pfad zweimal mit GetFullPathNameW auflösen, die .pdflib-qdf-Temp-Datei mit CREATE_NEW oder O_EXCL anlegen, sodass rasende Prozesse sich kein Handle teilen können, in 64-KiB-WriteBuffer-Blöcken kopieren und flushen, dann MoveFileExW mit REPLACE_EXISTING und WRITE_THROUGH
Jeder Schritt weigert sich weiterzumachen, solange der vorherige nicht fertig ist, die temporäre Datei liegt konstruktionsbedingt auf dem Ziel-Volume, ein Erst-löschen-Fenster existiert nie, und das Aufräumen im finally hinterlässt keinen .tmp-Schutt
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
  // Keine Cross-Volume-Kopie erlauben und das Ziel nicht zuerst löschen
  if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
    MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
    raise EWriteError.Create('Unable to publish QDF output');
end;

Am Rename-Schritt brechen die meisten selbst gestrickten „Safe Save“-Routinen stillschweigend. MoveFileExW mit MOVEFILE_REPLACE_EXISTING ersetzt das Ziel in einer einzigen Dateisystem-Operation auf demselben Volume. Der Writer lässt MOVEFILE_COPY_ALLOWED bewusst weg, denn ein Cross-Volume-Move degradiert zu Kopieren-dann-Löschen – exakt die nicht-atomare Sequenz, deren Vermeidung der Sinn des ganzen Designs ist. Da die temporäre Datei im Zielverzeichnis lebt, liegt sie konstruktionsbedingt auf dem Ziel-Volume. Der Writer löscht auch nie zuerst die alte Datei; ein Löschen-dann-Umbenennen-Paar hat ein Fenster, in dem der Pfad überhaupt nicht existiert, und ein Absturz in diesem Fenster verliert das Dokument. MOVEFILE_WRITE_THROUGH bittet den Aufruf, nicht zurückzukehren, bevor der Rename die Platte erreicht hat, was mit dem expliziten Flush der Daten zusammenspielt. Unter POSIX garantiert rename(2) ohnehin, dass der neue Name atomar jede bestehende Datei ersetzt, und dieselbe Verzeichnisplatzierung verhindert ein Scheitern mit EXDEV. Das Aufräumen ist symmetrisch. Der temporäre Name wird auf jedem Pfad in einem finally-Block entfernt, was im Erfolg ein No-op ist, weil der Rename ihn bereits konsumiert hat, und im Fehler die Teil-Datei entfernt, damit sich im Verzeichnis kein .tmp-Schutt ansammelt. Die Regression in Tests\QDFFileRegression.inc prüft genau das: Nach jedem injizierten Fehler stimmen die Ziel-Bytes mit dem Original überein, die Quell-Bytes stimmen mit dem Original überein, und das Verzeichnis enthält nichts außer den beiden Fixtures

Warum lockert eine temporäre Datei unter Windows die Berechtigungen?

Eine Datei, die mit nil als Security-Deskriptor angelegt wird, erbt ihre DACL vom übergeordneten Verzeichnis, nicht von der Datei, die sie zu ersetzen im Begriff ist. Das ist der korrekte Default für ein brandneues Dokument und der falsche für eine In-place-Reparatur. Angenommen, ein Operator hat contract.pdf mit einer geschützten, nicht geerbten DACL auf ein einzelnes Konto eingeschränkt. Eine temporäre Datei daneben erbt die weiter gefassten Berechtigungen des Verzeichnisses, und sobald sie über contract.pdf umbenannt wird, trägt die umbenannte Datei die weite DACL, denn NTFS-Sicherheit reist mit dem Dateiobjekt, nicht mit dem Namen. Die Reparatur gelingt, die Bytes stimmen, und die Zugriffskontrolle, die der Operator konfiguriert hat, ist stillschweigend weg. An der Rückgabe deutet nichts darauf hin

Die PDF Library for Delphi liest deshalb die DACL des Ziels, bevor sie die temporäre Datei anlegt, und reicht sie als lpSecurityAttributes-Argument an CreateFileW durch, sodass die neue Datei mit den Berechtigungen der alten geboren wird und der Rename nichts ändert, was der Operator bemerken würde. Das Lesen nutzt GetFileSecurityW mit DACL_SECURITY_INFORMATION und bemisst den Buffer aus dem ERROR_INSUFFICIENT_BUFFER-Ergebnis des ersten Aufrufs. Drei Bedingungen lassen den Writer fail-closed statt raten. Ist die DACL nicht lesbar, stoppt die Publikation mit einem EWriteError, den die öffentliche API auf 305 mappt. Kommt der Deskriptor ohne gesetztes SE_DACL_PRESENT zurück, stoppt die Publikation ebenfalls, denn ein solcher Deskriptor an CreateFileW ließe den Kernel auf die Process-Default-DACL zurückfallen und die Zugriffssemantik ändern, ohne dass jemand darum gebeten hat. Und trägt das Ziel FILE_ATTRIBUTE_ENCRYPTED, weist der Writer rundweg zurück: Die temporäre Datei wäre Klartext, und eine Klartext-Datei über eine EFS-geschützte umzubenennen veröffentlicht eine unverschlüsselte Ersetzung von etwas, das der Benutzer auf Dateisystemebene zu verschlüsseln beschlossen hat. EFS hat mit den PDF-Standard-Security-Handlern nichts zu tun – deren Thema ist der Artikel zum Laden verschlüsselter Dokumente –, aber die Fehlerart ist dieselbe Art stiller Herabstufung

Warum der QDF-Publikations-Writer die Ziel-DACL kopiert, bevor er seine temporäre Datei anlegt: Ein nil-Deskriptor würde die weiter gefassten Berechtigungen des Verzeichnisses erben und der Rename würde den Zugriff stillschweigend weiten, also liest GetFileSecurityW die DACL, ein fehlendes SE_DACL_PRESENT-Bit oder ein EFS-Attribut stoppt die Publikation mit 305, und CreateFileW wird mit den alten Berechtigungen geboren
NTFS-Sicherheit reist mit dem Dateiobjekt, nicht mit dem Namen: Den gelesenen Deskriptor als lpSecurityAttributes zu übergeben lässt den Rename nichts ändern, was der Operator konfiguriert hat, und jedes Gate fällt geschlossen statt zu raten
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');
  // den Deskriptor bemessen, dann nur den DACL-Anteil davon lesen
  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;   // an CreateFileW / CREATE_NEW übergeben
end;

Ein Detail aus der Regression ist es wert, im Kopf behalten zu werden, wenn Sie einen ähnlichen Test selbst schreiben. Um das eingeschränkte Fixture zu bauen, wendet der Test eine Owner-only-DACL an und muss SE_DACL_PROTECTED in der Deskriptorkontrolle explizit setzen; der geschützte Flag im SecurityInformation-Argument von SetFileSecurityW allein macht aus einem ungeschützten Deskriptor keinen geschützten. Die Assertion danach ist, dass die veröffentlichte Datei weiterhin das Protected-Bit und eine explizite, nicht-null-DACL meldet – sowohl für einen separaten Ausgabepfad als auch für die Reparatur über die Quelldatei selbst

Welcher LastErrorCode sagt Ihnen, was gescheitert ist?

RepairQDFFile gibt bei Erfolg 1 zurück und bei jedem Fehlschlag 0, und LastErrorCode sagt, welche Stufe sich verweigert hat. Eine nicht lesbare Quelle, eingeschlossen eine, die ein anderer Prozess mit exklusivem Lock hält, meldet 401; der Read ist jetzt verpackt, sodass eine Exception während der Eingabe auf 401 mappt, statt in den Write-Fehler durchzulecken. Eine ungültige oder mehrdeutige QDF-Struktur, etwa ein doppelter Stream-Marker für dasselbe Objekt, meldet PDFLIB_ERROR_QDF_REPAIR, also 107, und das Ziel wurde nicht angefasst, weil der Writer nie konstruiert wurde. Alles nach der Reparatur, von der Anlage der temporären Datei über Flush und Rename, meldet PDFLIB_ERROR_QDF_WRITE, also 305. Die Regression übt die realistischen Fälle: ein Ziel, das von einem anderen Handle ohne Delete-Sharing geöffnet ist, ein schreibgeschütztes Ziel, ein fehlendes Zielverzeichnis und jede der drei Writer-Stufen, die per Injektion scheitert. In allen ist die Rückgabe 0, der Code ist 305, und hinterher existiert kein neues oder teilgeschriebenes Ziel. Die allgemeine Angewohnheit, den Code statt nur der Rückgabe zu lesen, ist dieselbe wie im Artikel zur Diagnose stiller Fehler in der Bibliothek beschrieben

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    // In-place-Reparatur: derselbe Pfad ist Eingabe und Ausgabe
    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;

Wo die Garantie endet

Der Writer verspricht Konsistenz gegen Fehler, die der Prozess sehen kann, und ist ehrlich bei denen, die er nicht sehen kann. Wird der Prozess zwischen dem Anlegen der temporären Datei und dem Rename getötet, läuft der finally-Block nie, und eine Datei .pdflib-qdf-<GUID>.tmp bleibt im Verzeichnis liegen; das Ziel bleibt intakt, was die Eigenschaft ist, die zählt, aber den Schutt auszukehren ist Ihre Sache. Stromausfall liegt ebenfalls außerhalb des Versprechens: Die Daten werden geflusht und der Rename ist Write-through, mehr kann eine User-Mode-Bibliothek verlangen, aber der Writer macht kein fsync auf den Verzeichniseintrag und erhebt keine Haltbarkeitsgarantie über das, was das Dateisystem liefert. Ein zweiter Writer, der das Ziel nebenläufig verändert, wird nicht erkannt, denn DACL und Attribute werden gelesen, bevor die temporäre Datei angelegt wird, und nichts prüft sie zum Rename-Zeitpunkt nach. Und ein erfolgreicher Rename erzeugt eine neue Dateiidentität, also überleben Alternate Data Streams und gewöhnliche Attribute wie das Archive- oder Hidden-Bit der alten Datei nicht; nur die DACL wird bewusst mitgenommen

Die engere Grenze ist, welche API diesen Pfad überhaupt nutzt. Nur RepairQDFFile läuft über TPDFQDFFileWriter. SaveQDFToFile und ConvertFileToQDF öffnen ihre Ausgabe weiterhin mit PLCreateFileStream(FileName, fmCreate) und streamen die QDF-Konvertierung direkt hinein, genauso wie der inkrementelle Pfad aus dem Artikel zum Anhängen von Updates an einen Stream in jeden Stream schreibt, den man ihm reicht. Diese beiden Aufrufe erzeugen ein neues Debugging-Artefakt aus einem Dokument, das bereits geladen und validiert wurde, das Parse-Failure-Loch traf also nie auf sie zu, aber sie erben auch die Rename-basierte Publikation nicht. Lesen Sie diesen Artikel nicht als „jeder QDF-Export ist atomar“. Es ist ein einziger Ausgang, derjenige, dessen Eingabe eine nicht vertrauenswürdige, von Hand editierte Datei ist und dessen Ausgabe routinemäßig derselbe Pfad ist, und diese Kombination hat ihm die zusätzliche Maschinerie eingebracht. Die Fault-Injection, die all das beweist, ist billig, weil die drei Stufen des Writers, WriteData, Flush und Publish, virtual sind. Die Test-Subklasse überschreibt eine davon, um nach dem Beginn der echten Arbeit eine Exception zu werfen, ruft Save auf einem reparierten Stream auf und prüft, dass die Exception durchschlägt, dass Quell- und Ziel-Bytes unverändert sind und dass keine temporäre Datei übrig bleibt. Keine globale Datei-API wird gehookt, keine echte Benutzerdatei wird angefasst, und die drei Stufen mappen eins zu eins auf die drei Arten, wie eine Publikation in Produktion scheitern kann: Die Platte wird voll, der Flush wird abgewiesen, oder der Rename wird verweigert, weil jemand anderes das Ziel hält

Die RepairQDFFile-API, ihr atomarer Publikations-Writer und der Rest des QDF-Debugging-Workflows sind Teil der PDF Library for Delphi, neben den an anderer Stelle dieses Blogs behandelten Funktionen für Cross-Reference-Recovery, inkrementelle Updates und Verschlüsselung