Eine QDF-Datei von Hand editieren, für dasselbe Objekt einen zweiten Stream-Marker einschleppen, und dann eine In-place-Reparatur mit dem natürlichstmöglichen Delphi-Code fahren. Der Reparatur-Schritt bemerkt den Schaden und weigert sich. Blickt man danach in die Datei, ist sie 0 Bytes groß, denn TFileStream.Create(OutputFileName, fmCreate) hat genau diesen Pfad eine Zeile vor dem Validator gekürzt. Bytes im Speicher zu reparieren ist die einfache Hälfte des Jobs. Sie wieder auf die Platte zu bekommen, ohne jemals eine Null-Byte-, halbgeschriebene oder falsch berechtigte Datei zu hinterlassen, ist die andere Hälfte, und die hat eigene Fehlerarten
Warum leert eine In-place-Reparatur die Datei?
Eine In-place-Reparatur leert die Datei, weil die Reihenfolge der Operationen falsch ist: fmCreate kürzt beim Öffnen auf null, jede Zurückweisung, die nach dem Öffnen kommt, kommt also zu spät. Sind InputFileName und OutputFileName derselbe Pfad, wird aus einer abgelehnten Eingabe ein verlorenes Dokument. Ein sorgfältiger Parser rettet einen hier nicht. Er mag seinen Output-Stream unangetastet lassen, wenn er mehrdeutige Marker zurückweist, aber der Aufrufer hat das Ziel bereits einen Aufruf zuvor zerstört
// Die naheliegende Version: Das Ziel wird gekürzt, bevor irgendetwas validiert ist
Input := TMemoryStream.Create;
try
Input.LoadFromFile(InputFileName); // derselbe Pfad wie OutputFileName
Output := TFileStream.Create(OutputFileName, fmCreate); // die Datei ist jetzt 0 Bytes groß
try
if not MyRepairQDF(Input, Output) then // der eigene Validator: zu spät für ein Nein
raise Exception.Create('QDF structure rejected');
finally
Output.Free;
end;
finally
Input.Free;
end;
Der übliche nächste Versuch ist, in einen TMemoryStream zu reparieren und die Ausgabe erst nach geglückter Reparatur zu öffnen. Das schließt das Bad-Input-Loch und sonst nichts. Die Schreibphase bleibt Kürzen-dann-Kopieren, eine volle Platte, eine Sharing-Verletzung auf halbem Weg oder eine Exception zwischen Kürzung und letztem WriteBuffer hinterlässt also weiterhin ein beschädigtes Ziel. Parse-Sicherheit und Publikations-Sicherheit sind zwei getrennte Grenzen, und jede braucht ihren eigenen Schutz
In der PDF Library for Delphi erledigt TPDFlib.RepairQDFFile(InputFileName, OutputFileName) beides. Es liest die Eingabe, repariert komplett im Speicher und kehrt früh zurück, ohne irgend etwas am Ziel zu öffnen, wenn die QDF-Struktur abgelehnt wird. Erst eine erfolgreiche Reparatur geht weiter zur Publikation, und die Publikation öffnet das Ziel nie zum Schreiben
Vier Schritte zwischen den reparierten Bytes und dem Ziel
RepairQDFFile garantiert, dass der Zielpfad entweder die komplette alte Datei oder die komplette neue Datei hält, für jeden Fehler, den der Prozess selbst beobachten kann. Dorthin gelangt es in vier Schritten, und keiner beginnt, solange der vorherige nicht abgeschlossen ist. Das Ziel wird mit GetFullPathNameW zum vollen Pfad aufgelöst, einmal aufgerufen, um die nötige Länge zu lernen, und noch einmal in einen Buffer genau dieser Größe, sodass ein langer Pfad nicht an MAX_PATH abgeschnitten wird. Danach wird eine temporäre Datei namens .pdflib-qdf- plus GUID plus .tmp im Zielverzeichnis angelegt, mit CreateFileW und CREATE_NEW unter Windows oder open(2) mit O_CREAT or O_EXCL und Mode 0600 unter POSIX. Beide Flags lassen das Anlegen scheitern, wenn der Name bereits existiert. Zwei Prozesse, die zufällig dieselbe GUID ziehen, können sich also nicht in einem Handle wiederfinden
Der reparierte Stream wird dann in 64-KiB-Blöcken durch WriteBuffer kopiert, das bei einem kurzen Schreiben eine Exception wirft, statt einen Zähler zurückzugeben, den niemand prüft. FlushFileBuffers oder fsync(2) drückt die Daten hinunter, und das Handle wird geschlossen. Das Schließen wirft absichtlich nicht: Ist der Flush geglückt, bedroht ein fehlgeschlagenes Close die Daten nicht mehr, und eine Exception dort würde den echten Schreib- oder Flush-Fehler vor dem verdecken, der die Diagnose machen muss. Der letzte Schritt ist der Rename
Beim Rename brechen die meisten selbst gestrickten Safe-Save-Routinen. Unter Windows ersetzt die temporäre Datei das Ziel durch MoveFileExW mit MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH, was auf einem Volume eine einzige Dateisystem-Operation ist. MOVEFILE_COPY_ALLOWED bleibt bewusst außen vor. Ein Cross-Volume-Move degradiert stillschweigend zu Kopieren-dann-Löschen, exakt die nicht-atomare Sequenz, der das ganze Design ausweichen will, und ich sehe einen verweigerten Rename gern eher als eine „erfolgreiche“ Kopie, die halb fertig war, als die Maschine einging. Da die temporäre Datei im Zielverzeichnis sitzt, liegt sie ohnehin auf dem Ziel-Volume. Die alte Datei wird auch nie zuerst gelöscht; ein Löschen-dann-Umbenennen-Paar lässt ein Fenster, in dem der Pfad nicht existiert, und ein Absturz darin verliert das Dokument. MOVEFILE_WRITE_THROUGH hält den Aufruf davon ab zurückzukehren, bevor der Rename die Platte erreicht hat. Unter POSIX ersetzt rename(2) einen bestehenden Namen ohnehin atomar, und die Platzierung im selben Verzeichnis hält es von EXDEV fern
Das Aufräumen läuft auf jedem Pfad in einem finally-Block. Nach einem erfolgreichen Rename existiert der temporäre Name nicht mehr, das Löschen tut also nichts; nach einem Fehlschlag entfernt es die Teil-Datei. Das Verzeichnis enthält hinterher exakt die Dateien, die es vor dem Aufruf hatte
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; die Datei, die sie zu ersetzen im Begriff ist, hat kein Wort mitzureden. Für ein brandneues Dokument ist dieser Default richtig. Für eine In-place-Reparatur ist er falsch. Stellen Sie sich einen Operator vor, der contract.pdf mit einer geschützten, nicht geerbten DACL auf ein einziges Konto eingeschränkt hat. Eine schlichte temporäre Datei daneben übernimmt die weiter gefassten Berechtigungen des Verzeichnisses, und nach dem Rename trägt die veröffentlichte contract.pdf diese weite DACL, denn NTFS-Sicherheit gehört zum Dateiobjekt, nicht zum Namen. Die Bytes stimmen, die Rückgabe ist 1. Die Zugriffsbeschränkung, die der Operator eingerichtet hat, ist schlicht weg
RepairQDFFile liest deshalb die DACL des Ziels, bevor es die temporäre Datei anlegt, und reicht sie über lpSecurityAttributes an CreateFileW durch, sodass die neue Datei mit den Berechtigungen der alten geboren wird und der Rename nichts ändert, was ein Operator bemerken würde. Das Lesen ist GetFileSecurityW mit DACL_SECURITY_INFORMATION, der Buffer bemisst sich aus der ERROR_INSUFFICIENT_BUFFER-Antwort des ersten Aufrufs. Existiert das Ziel noch nicht, was bei einem separaten Ausgabepfad der Normalfall ist, wird kein Deskriptor übergeben, und die neue Datei erbt von ihrem Verzeichnis wie jede andere neue Datei auch
Drei Bedingungen stoppen die Publikation, statt raten zu lassen, und jede meldet LastErrorCode 305:
- Die DACL ist nicht lesbar, etwa weil dem aufrufenden Konto
READ_CONTROLam Ziel fehlt - Der zurückgegebene Deskriptor hat
SE_DACL_PRESENTnicht gesetzt; ein solcher Deskriptor anCreateFileWließe Windows auf die Process-Default-DACL zurückfallen und die Zugriffssemantik ändern, die niemand zu ändern bat - Das Ziel trägt
FILE_ATTRIBUTE_ENCRYPTED; die temporäre Datei wäre Klartext, und sie über eine EFS-geschützte Datei umzubenennen würde eine unverschlüsselte Ersetzung von etwas veröffentlichen, das der Benutzer zu verschlüsseln beschlossen hat
EFS ist ein Dateisystem-Feature und hat mit PDF-Security-Handlern nichts zu tun, deren Thema im Artikel zum Laden verschlüsselter Dokumente steht. Die Fehlerform ist trotzdem dieselbe Art stiller Herabstufung, und sie zurückzuweisen ist der richtige Default. Eine Reparatur, die auf einer EFS-Freigabe gelegentlich Nein sagt, ist ein Support-Ticket. Eine Reparatur, die eine vom Benutzer verschlüsselte Datei entschlüsselt, ist ein Vorfall
Wer selbst einen DACL-Preservation-Test schreibt, macht leicht einen Windows-Fehler. Um die eingeschränkte Testdatei zu bauen, eine Owner-only-DACL anwenden und SE_DACL_PROTECTED in der Deskriptorkontrolle explizit setzen; PROTECTED_DACL_SECURITY_INFORMATION an SetFileSecurityW zu übergeben macht aus einem ungeschützten Deskriptor keinen geschützten. Danach prüfen, dass die veröffentlichte Datei weiterhin das Protected-Bit und eine explizite, nicht-null-DACL meldet, einmal für einen separaten Ausgabepfad und einmal für die Reparatur über die Quelle selbst
Welcher LastErrorCode sagt Ihnen, was gescheitert ist?
RepairQDFFile gibt bei Erfolg 1 zurück und bei Fehlschlag 0, und TPDFlib.LastErrorCode benennt die Stufe, die sich verweigert hat. Eine Eingabe, die nicht existiert, nicht geöffnet werden kann oder größer als 2 GB ist, meldet 401, und eine Exception beim Lesen wird ebenfalls auf 401 gemappt, statt in den Write-Code durchzulecken. Eine ungültige oder mehrdeutige QDF-Struktur, etwa jener doppelte Stream-Marker, meldet PDFLIB_ERROR_QDF_REPAIR (107); das Ziel wurde nie geöffnet. Alles nach der Reparatur, von der Pfadauflösung über Flush und Rename, meldet PDFLIB_ERROR_QDF_WRITE (305). Die realistischen Write-Fehler landen alle auf 305: ein Ziel, das von einem anderen Handle ohne Delete-Sharing gehalten wird, ein schreibgeschütztes Ziel, ein fehlendes Zielverzeichnis, dazu die DACL- und EFS-Zurückweisungen von oben. In jedem 305-Fall bleibt kein neues oder teilgeschriebenes Ziel zurück
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');
PDFLIB_ERROR_QDF_REPAIR:
Log('QDF structure rejected; the destination was never opened');
PDFLIB_ERROR_QDF_WRITE:
Log('write, flush or replace refused; the destination keeps its old bytes');
end;
finally
Pdf.Free;
end;
end;
Den 305-Pfad aus dem eigenen Testprojekt zu reproduzieren geht leicht, ohne irgendeine Datei-API zu hooken. Delphis fmShareDenyNone öffnet eine Datei mit Lese- und Schreib-Sharing, aber ohne FILE_SHARE_DELETE, und Windows weigert sich, irgendetwas über eine so gehaltene Datei umzubenennen. Lässt man so ein Handle über den Aufruf hinweg offen, scheitert der Rename im allletzten Schritt, nachdem die Daten geschrieben und geflusht wurden – genau der Fall, den man am liebsten behandelt sehen will
var
Before: TBytes;
Holder: TFileStream;
Search: TSearchRec;
begin
Before := TFile.ReadAllBytes(Target); // System.IOUtils
Holder := TFileStream.Create(Target, fmOpenRead or fmShareDenyNone);
try
Assert(Pdf.RepairQDFFile(Source, Target) = 0);
Assert(Pdf.LastErrorCode = PDFLIB_ERROR_QDF_WRITE);
finally
Holder.Free;
end;
// Diese Prüfungen sind Vorschläge für den eigenen Test, keine Bibliotheksaufrufe
Assert(BytesEqual(Before, TFile.ReadAllBytes(Target))); // BytesEqual: eigener Helfer
// Keine temporäre Datei darf übrig bleiben
if FindFirst(ExtractFilePath(Target) + '.pdflib-qdf-*.tmp',
faAnyFile, Search) = 0 then
try
Assert(False, 'temporary file left behind: ' + Search.Name);
finally
FindClose(Search);
end;
end;
Für die Angewohnheit, den Code zu lesen statt der 0 allein zu vertrauen, geht der Artikel zur Diagnose stiller Fehler dasselbe Muster durch den Rest der API
Wo die Garantie endet
Das Versprechen deckt Fehler ab, die der Prozess 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 das Zählt, aber den Schutt auszukehren ist Ihre Sache. Stromausfall ist eine Dateisystem-Frage: Die Daten werden geflusht und der Rename ist Write-through, der Verzeichniseintrag wird unter POSIX aber nicht separat gesynct, die Haltbarkeit ist also das, was das Dateisystem obendrauf liefert
Ein erfolgreicher Rename erzeugt außerdem ein neues Dateiobjekt, und ein paar Eigenschaften des alten reisen nicht mit. Die DACL wird bewusst mitgenommen. Ownership nicht, es wird nur DACL_SECURITY_INFORMATION gelesen, die veröffentlichte Datei gehört also dem Konto, das die Reparatur gefahren hat. Die temporäre Datei wird mit FILE_ATTRIBUTE_NORMAL angelegt, Hidden-, Archive- und ähnliche Attribut-Bits starten also neu, und Alternate Data Streams der alten Datei sind weg. Unter POSIX behält die veröffentlichte Datei den 0600-Mode der temporären Datei; braucht eine Gruppe Lesezugriff, chmod hinterher aufrufen. Die Attribute und die DACL des Ziels werden gelesen, bevor die temporäre Datei existiert, einen zweiten Writer auf denselben Pfad zu koordinieren ist also Ihre Sache
SaveQDFToFile und ConvertFileToQDF schreiben direkt
RepairQDFFile ist der einzige QDF-Einstiegspunkt, der über eine temporäre Datei veröffentlicht. SaveQDFToFile und ConvertFileToQDF öffnen ihre Ausgabe mit fmCreate und streamen die Konvertierung direkt hinein, ganz so, wie der inkrementelle Pfad aus dem Artikel zum Anhängen von Updates an einen Stream in jeden Stream schreibt, den man ihm reicht. Die Aufteilung ist Absicht. Diese beiden Aufrufe machen aus einem Dokument, das bereits geladen und validiert wurde, ein neues Debugging-Artefakt, es gibt also keine nicht vertrauenswürdige Eingabe, die auf halbem Weg abgelehnt werden könnte, und ihr Ausgabepfad ist normalerweise eine frische Datei. RepairQDFFile nimmt eine von Hand editierte, nicht vertrauenswürdige Datei und wird routinemäßig wieder auf denselben Pfad gerichtet; diese Kombination rechtfertigt die zusätzliche Maschinerie
Will man für einen QDF-Dump doch den atomaren Ersatz, genügt ein kurzer eigener Wrapper:
// Eigener Helfer: das QDF neben das Ziel schreiben, dann in einem Rename ersetzen
function SaveQDFReplacing(Pdf: TPDFlib; const FileName: string): Boolean;
var
ID: TGUID;
TempName: string;
begin
CreateGUID(ID);
TempName := ExtractFilePath(ExpandFileName(FileName)) +
'.myapp-qdf-' + GUIDToString(ID) + '.tmp';
try
Result := (Pdf.SaveQDFToFile(TempName) = 1) and
MoveFileExW(PWideChar(WideString(TempName)), PWideChar(WideString(FileName)),
MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH);
finally
if FileExists(TempName) then
DeleteFile(TempName); // nur vorhanden, wenn etwas scheiterte
end;
end;
Diese Version überspringt die DACL-Kopie und den expliziten Daten-Flush, die RepairQDFFile ausführt. Für QDF-Dumps, die in einem Scratch- oder Debugging-Verzeichnis landen, ist das meines Erachtens die richtige Maschinerie-Menge; ersetzt der Dump eine Datei mit zugeknöpften Berechtigungen, läuft man ihn durch den Repair-Pfad oder kopiert den Security-Deskriptor selbst
Atomarer Repair-Output, die QDF-Konvertierungsaufrufe und die Fehlercodes von oben stecken alle in der PDF Library for Delphi, neben der Cross-Reference-Recovery, den inkrementellen Updates und der Verschlüsselungs-Unterstützung, die in anderen Beiträgen hier behandelt werden