PDF Library for Delphi objavljuje izlaz funkcije RepairQDFFile kroz interni writer, TPDFQDFFileWriter, koji nikada ne otvara odredište za pisanje: popravljeni bajtovi idu u ekskluzivno kreiran privremeni fajl u istom direktorijumu, fajl se flush-uje i zatvara, i tek onda se preimenuje preko cilja sa MoveFileExW na Windows-u ili rename(2) na POSIX-u. Ako bilo šta padne pre preimenovanja, odredište zadržava svaki bajt koji je imalo, a pozivalac vidi LastErrorCode 305. Popravka dokumenta u memoriji je laka polovina funkcije popravke. Smeštanje rezultata na disk bez toga da korisnik ikada ostane sa fajlom nulte dužine ili upola upisanim je polovina o kojoj je ovaj članak
Zašto popravka koja padne ipak može da uništi ciljni fajl?
Zato što je redosled operacija bio pogrešan. Pre v3.539.13, RepairQDFFile je otvarao izlaz sa PLCreateFileStream(OutputFileName, fmCreate) i zatim taj stream predavao parseru. fmCreate skraćuje fajl pri otvaranju, pa je u trenutku kada je QDF skeniranje odlučilo da ulaz nije popravljiv, odredište već bilo ispražnjeno. Popravka na mestu, gde su InputFileName i OutputFileName ista putanja, pretvarala je odbijen ulaz u izgubljen fajl. Sam parser se ponašao dobro: niskonivojska funkcija PDFQDFRepair ostavlja ciljni stream netaknutim kada odbije dvosmislene markere. Ta zaštita je bila prosto irelevantna, jer je javni API skratio fajl jedan poziv ranije
Popravka u v3.539.13 premestila je popravku u TMemoryStream i otvarala izlaz tek pošto PDFQDFRepair uspe. To zatvara rupu pri neuspehu parsiranja i ništa drugo. Faza upisa je i dalje bila fmCreate praćen CopyFrom, pa bi pun disk, sharing violation na pola puta, ili izuzetak između skraćivanja i poslednjeg WriteBuffer i dalje ostavili oštećeno odredište. Popravka prvo u memoriji štiti od lošeg ulaza. Objavljivanje na disk zahteva sopstvenu granicu, i v3.539.14 i v3.539.15 su je izgradile
// v3.539.12: odredište se skraćuje pre nego što je ulaz validiran
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
if PDFQDFRepair(Source, Output, QDFError) then // prekasno za "ne"
Result := 1;
finally
Output.Free;
end;
// v3.539.15: popravka u memoriji, pa bajtovi idu writer-u za objavljivanje
Repaired := TMemoryStream.Create;
try
if not PDFQDFRepair(Source, Repaired, QDFError) then
Exit; // odredište nikada nije otvoreno
Writer := TPDFQDFFileWriter.Create;
try
Writer.Save(Repaired, OutputFileName);
Result := 1;
finally
Writer.Free;
end;
finally
Repaired.Free;
end;
Šta atomsko objavljivanje zapravo garantuje?
TPDFQDFFileWriter.Save garantuje da je putanja odredišta ili ceo stari fajl ili ceo novi fajl, nikada mešavina, za svaki neuspeh koji sama biblioteka može da primeti. Writer to radi u četiri koraka od kojih svaki odbija da nastavi ako prethodni nije završen. Prvo razrešava odredište sa GetFullPathNameW, pozivajući je dvaput i alocirajući bafer iz vraćene dužine umesto da pretpostavi MAX_PATH, pa se duge putanje ne seku tiho. Drugo kreira privremeni fajl pod imenom .pdflib-qdf- plus GUID plus .tmp u direktorijumu odredišta, koristeći CreateFileW sa CREATE_NEW na Windows-u i open(2) sa O_CREAT or O_EXCL i modom 0600 na POSIX-u. Oba flag-a čine da kreiranje padne ako ime već postoji, pa dva procesa koja se takmiče oko istog GUID-a ne mogu da dele handle. Treće kopira popravljeni stream u blokovima od 64 KiB kroz WriteBuffer, koji podiže grešku pri kratkom upisu umesto da vrati broj koji niko ne proverava, zatim poziva FlushFileBuffers ili fsync(2) i zatvara handle. Četvrto preimenuje
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
// Ne dopuštaj kopiranje preko volumena niti prvo brisanje odredišta
if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
raise EWriteError.Create('Unable to publish QDF output');
end;
Korak preimenovanja je mesto gde većina kućnih „bezbednih čuvanja“ tiho pukne. MoveFileExW sa MOVEFILE_REPLACE_EXISTING zamenjuje cilj u jednoj fajl-sistemskoj operaciji na istom volumenu. Writer namerno izostavlja MOVEFILE_COPY_ALLOWED, jer se premeštanje preko volumena degradira u kopiranje-pa-brisanje, što je upravo nearitmički niz koji ceo dizajn postoji da izbegne. Pošto privremeni fajl živi u direktorijumu odredišta, on je po konstrukciji na volumenu odredišta. Writer takođe nikada ne briše stari fajl prvi; par brisanje-pa-preimenovanje ima prozor u kojem putanja uopšte ne postoji, i pad unutar tog prozora gubi dokument. MOVEFILE_WRITE_THROUGH traži da poziv ne vrati odgovor dok preimenovanje nije stiglo na disk, što se spaja sa eksplicitnim flush-om podataka. Na POSIX-u rename(2) već garantuje da novo ime atomski zamenjuje svaki postojeći fajl, a isto smeštanje u isti direktorijum čuva ga od pada sa EXDEV. Čišćenje je simetrično. Privremeno ime se uklanja u finally bloku na svakoj putanji, što je pri uspehu no-op jer ga je preimenovanje već potrošilo, a pri neuspehu uklanja delimični fajl pa se u direktorijumu ne skuplja .tmp ostatak. Regresija u Tests\QDFFileRegression.inc proverava upravo to: posle svakog injektiranog neuspeha bajtovi odredišta se poklapaju sa originalom, bajtovi izvora se poklapaju sa originalom, a direktorijum ne sadrži ništa osim dva fikstura
Zašto privremeni fajl oslabi dozvole na Windows-u?
Fajl kreiran sa nil security descriptor-om nasleđuje svoj DACL od roditeljskog direktorijuma, a ne od fajla koji treba da zameni. To je ispravan podrazumevani izbor za potpuno nov dokument i pogrešan za popravku na mestu. Pretpostavite da je administrator zaključao contract.pdf na jedan jedini nalog sa zaštićenim DACL-om koji se ne nasleđuje. Privremeni fajl pored njega nasleđuje šire dozvole direktorijuma, i kada se jednom preimenuje preko contract.pdf, preimenovani fajl nosi širi DACL, jer NTFS bezbednost putuje sa fajl objektom, a ne sa imenom. Popravka uspeva, bajtovi su ispravni, a kontrola pristupa koju je administrator podesio tiho nestaje. Ništa u povratnoj vrednosti na to ne ukazuje
PDF Library for Delphi zato čita DACL odredišta pre kreiranja privremenog fajla i prosleđuje ga kao argument lpSecurityAttributes funkciji CreateFileW, pa se novi fajl rađa sa dozvolama starog fajla i preimenovanje ne menja ništa što bi administrator primetio. Čitanje koristi GetFileSecurityW sa DACL_SECURITY_INFORMATION, dimenzionišući bafer iz rezultata ERROR_INSUFFICIENT_BUFFER prvog poziva. Tri uslova čine da writer padne zatvoreno umesto da pogađa. Ako se DACL ne može pročitati, objavljivanje se zaustavlja uz EWriteError, koji javni API mapira u 305. Ako descriptor dođe bez postavljenog SE_DACL_PRESENT, objavljivanje se takođe zaustavlja, jer bi prosleđivanje takvog descriptor-a funkciji CreateFileW pustilo kernel da se vrati na podrazumevani DACL procesa i promeni semantiku pristupa bez da je iko to tražio. I ako cilj nosi FILE_ATTRIBUTE_ENCRYPTED, writer odbija odmah: privremeni fajl bio bi čist tekst, a preimenovanje fajla čistog teksta preko EFS-zaštićenog objavljuje nešifrovanu zamenu za nešto što je korisnik odabrao da šifruje na nivou fajl sistema. EFS nema veze sa standardnim PDF security handler-ima, koji su tema članka o učitavanju šifrovanih dokumenata, ali je način neuspeha ista vrsta tihog snižavanja
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');
// dimenzioniši descriptor, pa pročitaj samo njegov DACL deo
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; // predaje se CreateFileW / CREATE_NEW
end;
Jedan detalj iz regresije vredi imati na umu ako sami pišete sličan test. Da bi napravio restriktivni fikstur, test primenjuje DACL samo za vlasnika i mora eksplicitno da postavi SE_DACL_PROTECTED u kontroli descriptor-a; samo prosleđivanje zaštićenog flag-a u argumentu SecurityInformation funkcije SetFileSecurityW ne pretvara nezaštićeni descriptor u zaštićeni. Tvrdnja posle toga je da objavljeni fajl i dalje prijavljuje zaštićeni bit i eksplicitan, ne-nil DACL, i za odvojenu izlaznu putanju i za popravku preko samog izvornog fajla
Koji LastErrorCode govori šta je palo?
RepairQDFFile vraća 1 pri uspehu i 0 pri svakom neuspehu, a LastErrorCode kaže koja je faza odbila. Izvor koji se ne može pročitati, uključujući onaj koji neki drugi proces drži pod ekskluzivnim lock-om, prijavljuje 401; čitanje je sada obavijeno tako da izuzetak pri ulazu ide u 401 umesto da iscuri u grešku upisa. Nevalidna ili dvosmislena QDF struktura, kao što je duplirani stream marker za isti objekat, prijavljuje PDFLIB_ERROR_QDF_REPAIR, što je 107, a odredište nije dirano jer writer nikada nije konstruisan. Sve posle popravke, od kreiranja privremenog fajla preko flush-a do preimenovanja, prijavljuje PDFLIB_ERROR_QDF_WRITE, što je 305. Regresija vežba one realistične: odredište koje je otvorio drugi handle bez delete sharing-a, odredište samo za čitanje, nepostojeći direktorijum odredišta, i svaku od tri writer faze koja pada kroz injekciju. U svima njima povratna vrednost je 0, kod je 305, i posle toga ne postoji ni nov ni delimičan cilj. Opšta navika čitanja koda umesto samo povratne vrednosti ista je ona opisana u članku o dijagnostikovanju tihih neuspeha u biblioteci
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
// Popravka na mestu: ista putanja je i ulaz i izlaz
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;
Gde se garancija zaustavlja
Writer obećava konzistentnost prema neuspesima koje proces može da vidi, i iskren je prema onima koje ne može. Ako je proces ubijen između kreiranja privremenog fajla i preimenovanja, finally blok se nikada ne izvrši i u direktorijumu ostane .pdflib-qdf-<GUID>.tmp fajl; odredište je i dalje netaknuto, što je svojstvo koje je važno, ali ostatke morate sami da pometete. Gubitak napajanja je takođe van obećanja: podaci su flush-ovani a preimenovanje je write-through, što je najbolje što biblioteka u korisničkom režimu može da traži, ali writer ne radi fsync direktorijumskog unosa i ne daje nikakvu tvrdnju o trajnosti povrh onoga što fajl sistem pruža. Drugi writer koji istovremeno menja odredište nije detektovan, jer se DACL i atributi čitaju pre kreiranja privremenog fajla i ništa ih ne proverava ponovo u trenutku preimenovanja. A uspešno preimenovanje stvara novi identitet fajla, pa alternativni data stream-ovi i obični atributi kao što je archive ili hidden bit na starom fajlu ne preživljavaju; samo se DACL namerno prenosi
Uža granica je koji API uopšte koristi tu putanju. Samo RepairQDFFile prolazi kroz TPDFQDFFileWriter. SaveQDFToFile i ConvertFileToQDF i dalje otvaraju svoj izlaz sa PLCreateFileStream(FileName, fmCreate) i strimuju QDF konverziju pravo u njega, isto kao što inkrementalna putanja opisana u članku o dodavanju izmena u stream piše u koji god stream joj date. Ta dva poziva proizvode nov artefakt za debug iz dokumenta koji je već učitan i validiran, pa se rupa pri neuspehu parsiranja nikada nije ni odnosila na njih, ali ni ona ne nasleđuju objavljivanje zasnovano na preimenovanju. Ne čitajte ovaj članak kao „svaki QDF izvoz je atomski“. To su jedna izlazna tačka, ona čiji je ulaz nepoverljiv, ručno uređivan fajl i čiji je izlaz rutinski ista putanja, i upravo je ta kombinacija zaslužila dodatnu mašineriju. Injektiranje grešaka koje dokazuje sve ovo je jeftino jer su tri faze writer-a, WriteData, Flush i Publish, virtual. Test podklasa prepisuje jednu od njih da podigne grešku pošto je pravi posao već počeo, poziva Save nad popravljenim stream-om, i tvrdi da se izuzetak propagira, da su bajtovi izvora i odredišta nepromenjeni, i da nijedan privremeni fajl nije ostao. Nijedan globalni fajl API nije kačen, nijedan pravi korisnički fajl nije diran, a tri faze se preslikavaju jedan-na-jedan na tri načina na koja objavljivanje može da padne u produkciji: disk se napuni, flush je odbijen, ili je preimenovanje odbijeno jer neko drugi drži cilj
API RepairQDFFile, njegov writer za atomsko objavljivanje i ostatak QDF debug radnog toka deo su PDF Library for Delphi, uz funkcije oporavka cross-reference tabela, inkrementalnih ažuriranja i šifrovanja obrađene na drugim mestima na ovom blogu