PDF Library for Delphi publică rezultatul lui RepairQDFFile printr-un writer intern, TPDFQDFFileWriter, care nu deschide niciodată destinația pentru scriere: octeții reparați merg într-un fișier temporar creat în mod exclusiv în același director, fișierul este flush-uit și închis, și abia apoi este redenumit peste țintă cu MoveFileExW pe Windows sau rename(2) pe POSIX. Dacă ceva eșuează înainte de redenumire, destinația păstrează fiecare octet pe care îl avea, iar apelantul vede LastErrorCode 305. Repararea unui document în memorie este jumătatea ușoară a unei funcții de reparare. Aducerea rezultatului pe disc fără a lăsa vreodată utilizatorul cu un fișier de lungime zero sau pe jumătate scris este jumătatea despre care este articolul de față
De ce poate o reparație eșuată să distrugă totuși fișierul țintă?
Pentru că ordinea operațiilor era greșită. Înainte de v3.539.13, RepairQDFFile deschidea ieșirea cu PLCreateFileStream(OutputFileName, fmCreate) și apoi preda stream-ul acela parserului. fmCreate trunchiază la deschidere, așa că până când scanarea QDF decidea că intrarea nu este reparabilă, destinația era deja golită. Reparația în loc, unde InputFileName și OutputFileName sunt aceeași cale, transforma o intrare respinsă într-un fișier pierdut. Parserul în sine era bine comportat: funcția de nivel jos PDFQDFRepair lasă stream-ul țintă neatins când respinge marcaje ambigue. Protecția aceea era pur și simplu irelevantă, pentru că API-ul public trunchiase fișierul cu un apel mai devreme
Reparația din v3.539.13 a mutat reparația într-un TMemoryStream și a deschis ieșirea abia după ce PDFQDFRepair reușise. Asta astupă gaura de parsare-de-eșec și nimic altceva. Faza de scriere era tot fmCreate urmat de CopyFrom, așa că un disc plin, o violare de partajare la jumătatea scrierii sau o excepție între trunchiere și ultimul WriteBuffer lăsau tot o destinație deteriorată. Repararea cu prioritate în memorie protejează împotriva intrării proaste. Publicarea pe disc are nevoie de propria graniță, iar v3.539.14 și v3.539.15 au construit una
// v3.539.12: destinația este trunchiată înainte ca intrarea să fie validată
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
if PDFQDFRepair(Source, Output, QDFError) then // prea târziu ca să mai spui nu
Result := 1;
finally
Output.Free;
end;
// v3.539.15: repară în memorie, apoi predă octeții writer-ului de publicare
Repaired := TMemoryStream.Create;
try
if not PDFQDFRepair(Source, Repaired, QDFError) then
Exit; // destinația nu a fost niciodată deschisă
Writer := TPDFQDFFileWriter.Create;
try
Writer.Save(Repaired, OutputFileName);
Result := 1;
finally
Writer.Free;
end;
finally
Repaired.Free;
end;
Ce garantează de fapt publicarea atomică?
TPDFQDFFileWriter.Save garantează că calea de destinație este fie fișierul vechi complet, fie fișierul nou complet, niciodată un amestec, pentru fiecare eșec pe care librăria însăși îl poate observa. Writer-ul face asta în patru pași care refuză fiecare să continue dacă pasul anterior nu s-a terminat. Întâi rezolvă destinația cu GetFullPathNameW, apelându-l de două ori și alocând buffer-ul după lungimea întoarsă în loc să presupună MAX_PATH, caile lungi nu sunt tăiate în tăcere. Al doilea creează un fișier temporar numit .pdflib-qdf- plus un GUID plus .tmp în directorul destinație, folosind CreateFileW cu CREATE_NEW pe Windows și open(2) cu O_CREAT or O_EXCL și mod 0600 pe POSIX. Ambele flag-uri fac crearea să eșueze dacă numele există deja, așa că două procese care se întrec pe același GUID nu pot împărți un handle. Al treilea copiază stream-ul reparat în bucăți de 64 KiB prin WriteBuffer, care ridică excepție la o scriere scurtă în loc să întoarcă un număr pe care nu îl verifică nimeni, apoi apelează FlushFileBuffers sau fsync(2) și închide handle-ul. Al patrulea redenumește
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
// Nu permite copierea între volume și nu șterge destinația mai întâi
if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
raise EWriteError.Create('Unable to publish QDF output');
end;
Pasul de redenumire este locul unde cele mai multe rutine de „salvare sigură” făcute în casă se strică în tăcere. MoveFileExW cu MOVEFILE_REPLACE_EXISTING înlocuiește ținta într-o singură operație de sistem de fișiere pe același volum. Writer-ul lasă deliberat deoparte MOVEFILE_COPY_ALLOWED, pentru că o mutare între volume degenerează în copiere-apoi-ștergere, exact secvența neatomică pe care tot designul există ca să o evite. Din moment ce fișierul temporar stă în directorul destinație, este pe volumul destinației prin construcție. Writer-ul nu șterge niciodată nici fișierul vechi mai întâi; o pereche șterge-apoi-redenumește are o fereastră în care calea nu există deloc, iar un crash în acea fereastră pierde documentul. MOVEFILE_WRITE_THROUGH cere apelului să nu se întoarcă până când redenumirea nu a ajuns pe disc, ceea ce se împerechează cu flush-ul explicit al datelor. Pe POSIX, rename(2) garantează deja că noul nume înlocuiește atomic orice fișier existent, iar plasarea în același director îl ferește de eșecul cu EXDEV. Curățarea este simetrică. Numele temporar este șters într-un bloc finally pe fiecare cale, ceea ce în caz de succes este un no-op pentru că redenumirea l-a consumat deja, iar în caz de eșec îndepărtează fișierul parțial, ca directorul să nu adune resturi .tmp. Regresia din Tests\QDFFileRegression.inc verifică exact asta: după fiecare eșec injectat, octeții destinației corespund originalului, octeții sursei corespund originalului, iar directorul nu conține nimic în afară de cele două fixture-uri
De ce slăbește un fișier temporar permisiunile pe Windows?
Un fișier creat cu un descriptor de securitate nil își moștenește DACL-ul de la directorul părinte, nu de la fișierul pe care urmează să îl înlocuiască. Acesta este implicitul corect pentru un document complet nou și cel greșit pentru o reparație în loc. Să presupunem că un operator a limitat contract.pdf la un singur cont, cu un DACL protejat și ne moștenit. Un fișier temporar alături de el moștenește permisiunile mai largi ale directorului, iar odată ce este redenumit peste contract.pdf, fișierul redenumit cară DACL-ul larg, pentru că securitatea NTFS călătorește cu obiectul de fișier, nu cu numele. Reparația reușește, octeții sunt corecți, iar controlul de acces configurat de operator a dispărut în tăcere. Nimic din valoarea întoarsă nu sugerează asta
PDF Library for Delphi citește deci DACL-ul destinației înainte de a crea fișierul temporar și îl transmite ca argument lpSecurityAttributes lui CreateFileW, așa că fișierul nou se naște cu permisiunile fișierului vechi, iar redenumirea nu schimbă nimic ce ar observa operatorul. Citirea folosește GetFileSecurityW cu DACL_SECURITY_INFORMATION, dimensionând buffer-ul după rezultatul ERROR_INSUFFICIENT_BUFFER al primului apel. Trei condiții fac writer-ul să eșueze închis, în loc să ghicească. Dacă DACL-ul nu poate fi citit, publicarea se oprește cu un EWriteError, pe care API-ul public îl mapează la 305. Dacă descriptorul se întoarce fără SE_DACL_PRESENT setat, publicarea se oprește și ea, pentru că transmiterea unui astfel de descriptor lui CreateFileW ar lăsa kernelul să cadă pe DACL-ul implicit al procesului și ar schimba semantica de acces fără ca nimeni să fi cerut asta. Iar dacă ținta cară FILE_ATTRIBUTE_ENCRYPTED, writer-ul refuză direct: fișierul temporar ar fi în clar, iar redenumirea unui fișier în clar peste unul protejat de EFS publică o înlocuire necriptată a ceva ce utilizatorul a ales să cripteze la nivel de sistem de fișiere. EFS nu are legătură cu handler-ele standard de securitate PDF, care fac subiectul articolului despre încărcarea documentelor criptate, dar modul de eșec este același tip de degradare tăcută
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');
// dimensionează descriptorul, apoi citește doar porțiunea DACL din el
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; // predat lui CreateFileW / CREATE_NEW
end;
Un detaliu din regresie merită ținut minte dacă vă scrieți singur un test similar. Pentru a construi fixture-ul restricționat, testul aplică un DACL doar pentru proprietar și trebuie să seteze SE_DACL_PROTECTED în controlul descriptorului în mod explicit; simpla transmitere a flag-ului de protecție în argumentul SecurityInformation al lui SetFileSecurityW nu transformă un descriptor neprotejat într-unul protejat. Aserțiunea de după este că fișierul publicat raportează în continuare bitul de protecție și un DACL explicit, nenul, atât pentru o cale de ieșire separată, cât și pentru reparația peste fișierul sursă însuși
Care LastErrorCode vă spune ce a eșuat?
RepairQDFFile întoarce 1 la succes și 0 la orice eșec, iar LastErrorCode spune care etapă a refuzat. O sursă care nu poate fi citită, inclusiv una ținută de alt proces cu o blocare exclusivă, raportează 401; citirea este acum împachetată astfel încât o excepție în timpul citirii se mapează la 401 în loc să se scurgă în eroarea de scriere. O structură QDF invalidă sau ambiguă, precum un marcaj de stream duplicat pentru același obiect, raportează PDFLIB_ERROR_QDF_REPAIR, care este 107, iar destinația nu a fost atinsă pentru că writer-ul nu a fost niciodată construit. Tot ce urmează după reparație, de la crearea fișierului temporar până la flush și redenumire, raportează PDFLIB_ERROR_QDF_WRITE, care este 305. Regresia exersează cazurile realiste: o destinație deschisă de alt handle fără partajare de ștergere, o destinație doar-citire, un director de destinație lipsă și fiecare dintre cele trei etape ale writer-ului eșuând prin injecție. În toate, întoarcerea este 0, codul este 305, iar după aceea nu există nicio țintă nouă sau parțială. Obișnuința generală de a citi codul, nu doar valoarea întoarsă, este aceeași descrisă în articolul despre diagnosticarea eșecurilor silențioase din librărie
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
// Reparație în loc: aceeași cale este intrare și ieșire
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;
Unde se oprește garanția
Writer-ul promite consistență față de eșecurile pe care procesul le poate vedea și este onest în privința celor pe care nu le poate vedea. Dacă procesul este omorât între crearea fișierului temporar și redenumire, blocul finally nu rulează niciodată și un fișier .pdflib-qdf-<GUID>.tmp rămâne în director; destinația este tot intactă, care este proprietatea care contează, dar resturile sunt ale dumneavoastră de măturat. Pierderea alimentării este și ea în afara promisiunii: datele sunt flush-uite și redenumirea este write-through, ce e mai bun poate o librărie în mod utilizator să ceară, dar writer-ul nu face fsync pe intrarea de director și nu pretinde durabilitate peste ce oferă sistemul de fișiere. Un al doilea writer care modifică destinația concurent nu este detectat, pentru că DACL-ul și atributele sunt citite înainte de crearea fișierului temporar și nimic nu le reverifică la momentul redenumirii. Iar o redenumire reușită creează o identitate de fișier nouă, așa că fluxurile de date alternative și atributele obișnuite precum bitul de arhivă sau cel ascuns de pe fișierul vechi nu supraviețuiesc; doar DACL-ul este dus mai departe în mod deliberat
Granița mai îngustă este care API folosește de fapt calea asta. Doar RepairQDFFile trece prin TPDFQDFFileWriter. SaveQDFToFile și ConvertFileToQDF își deschid în continuare ieșirea cu PLCreateFileStream(FileName, fmCreate) și transmit conversia QDF direct în ea, la fel cum calea incrementală descrisă în articolul despre adăugarea de actualizări într-un stream scrie în orice stream îi dați. Cele două apeluri produc un artefact nou de depanare pornind de la un document care a fost deja încărcat și validat, așa că gaura de parsare-de-eșec nu li s-a aplicat niciodată, dar nu moștenesc nici publicarea bazată pe redenumire. Nu citiți articolul acesta ca „fiecare export QDF este atomic”. Este o singură ieșire, aceea a cărei intrare este un fișier nedemn de încredere editat manual și a cărei ieșire este de regulă aceeași cale, iar combinația aceea este ce i-a câștigat mecanismul suplimentar. Injecția de defecte care dovedește toate acestea este ieftină pentru că cele trei etape ale writer-ului, WriteData, Flush și Publish, sunt virtual. Subclasa de test suprascrie una dintre ele ca să ridice excepție după ce munca reală a început, apelează Save pe un stream reparat și asertează că excepția se propagă, că octeții sursei și ai destinației sunt neschimbați și că nu rămâne niciun fișier temporar. Nu este agățat niciun API global de fișiere, nu este atins niciun fișier real al utilizatorului, iar cele trei etape corespund unu-la-unu celor trei feluri în care o publicare poate eșua în producție: discul se umple, flush-ul este respins sau redenumirea este refuzată pentru că altcineva ține ținta
API-ul RepairQDFFile, writer-ul lui de publicare atomică și restul fluxului de depanare QDF fac parte din PDF Library for Delphi, alături de recuperarea referințelor încrucișate, actualizările incrementale și funcțiile de criptare acoperite în alte părți ale acestui blog