PDF Library for Delphi objavljuje izlaz funkcije RepairQDFFile kroz interni writer, TPDFQDFFileWriter, koji nikad ne otvara odredište za pisanje: popravljeni bajtovi idu u ekskluzivno stvorenu privremenu datoteku u istom direktoriju, datoteka se flusha i zatvori, i tek se onda preimenuje preko cilja s MoveFileExW na Windowsu ili rename(2) na POSIX-u. Ako bilo što padne prije preimenovanja, odredište zadržava svaki bajt koji je imalo, a pozivatelj vidi LastErrorCode 305. Popravak dokumenta u memoriji laka je polovica značajke popravka. Smjestiti rezultat na disk, a da korisnik nikad ne ostane s datotekom od nula bajtova ili napola zapisanom, polovica je o kojoj je ovaj članak
Zašto popravak koji padne ipak može uništiti ciljnu datoteku?
Zato što je redoslijed operacija bio pogrešan. Prije v3.539.13 RepairQDFFile otvarao je izlaz s PLCreateFileStream(OutputFileName, fmCreate) i zatim predavao taj stream parseru. fmCreate skraćuje datoteku pri otvaranju, pa je u trenutku kad je QDF skeniranje zaključilo da ulaz nije popravljiv odredište već bilo ispražnjeno. Popravak na mjestu, gdje su InputFileName i OutputFileName ista putanja, pretvarao je odbijeni ulaz u izgubljenu datoteku. Sam parser ponašao se dobro: niskorazinska funkcija PDFQDFRepair ostavlja ciljni stream netaknutim kad odbije dvosmislene markere. Ta zaštita bila je jednostavno irelevantna, jer je javni API skratio datoteku jedan poziv ranije
Popravak iz v3.539.13 premjestio je popravak u TMemoryStream i otvarao izlaz tek nakon što je PDFQDFRepair uspio. To zatvara rupu s neuspjehom parsiranja i ništa drugo. Faza pisanja i dalje je bila fmCreate nakon kojeg slijedi CopyFrom, pa je stanje punog diska, dijeljenje koje prekine upis na pola ili iznimka između skraćivanja i posljednjeg WriteBuffer i dalje ostavljala oštećeno odredište. Popravak s memorijom na prvom mjestu štiti od lošeg ulaza. Objavljivanje na disk treba vlastitu granicu, a v3.539.14 i v3.539.15 su je izgradile
// v3.539.12: odredište se skraćuje prije nego se ulaz validira
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
if PDFQDFRepair(Source, Output, QDFError) then // prekasno za reći ne
Result := 1;
finally
Output.Free;
end;
// v3.539.15: popravak u memoriji, pa predaj bajtove writeru za objavljivanje
Repaired := TMemoryStream.Create;
try
if not PDFQDFRepair(Source, Repaired, QDFError) then
Exit; // odredište nikad nije otvoreno
Writer := TPDFQDFFileWriter.Create;
try
Writer.Save(Repaired, OutputFileName);
Result := 1;
finally
Writer.Free;
end;
finally
Repaired.Free;
end;
Što atomsko objavljivanje zapravo jamči?
TPDFQDFFileWriter.Save jamči da je odredišna putanja ili cijela stara datoteka ili cijela nova datoteka, nikad mješavina, za svaki neuspjeh koji sama biblioteka može opaziti. Writer to radi u četiri koraka koji svaki odbija nastaviti ako prethodni nije dovršen. Prvo razrješava odredište s GetFullPathNameW, pozivajući ga dvaput i alocirajući buffer iz vraćene duljine umjesto da pretpostavi MAX_PATH, pa se duge putanje ne režu potiho. Drugo stvara privremenu datoteku nazvanu .pdflib-qdf- plus GUID plus .tmp u odredišnom direktoriju, koristeći CreateFileW s CREATE_NEW na Windowsu i open(2) s O_CREAT or O_EXCL i modom 0600 na POSIX-u. Obje zastavice čine da stvaranje padne ako ime već postoji, pa dva procesa koja se utrkuju na istom GUID-u ne mogu dijeliti handle. Treće kopira popravljeni stream u blokovima od 64 KiB kroz WriteBuffer, koji podiže iznimku na kratkom upisu umjesto da vrati brojač koji nitko ne provjerava, 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 ni brisanje odredišta prvo
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 mjesto je na kojem se većina domaćih "safe save" rutina tiho slomi. MoveFileExW s MOVEFILE_REPLACE_EXISTING zamjenjuje cilj u jednoj datotečno-sustavskoj operaciji na istom volumenu. Writer namjerno izostavlja MOVEFILE_COPY_ALLOWED, jer se prelazak preko volumena degradira u kopiraj-pa-obriši, što je upravo neatomski slijed koji cijeli dizajn postoji da izbjegne. Budući da privremena datoteka živi u odredišnom direktoriju, ona je po konstrukciji na odredišnom volumenu. Writer također nikad ne briše staru datoteku prvo; par obriši-pa-preimenuj ima prozor u kojem putanja uopće ne postoji, a pad unutar tog prozora gubi dokument. MOVEFILE_WRITE_THROUGH traži da poziv ne vrati rezultat dok preimenovanje nije stiglo na disk, što se uparuje s izričitim flushom podataka. Na POSIX-u rename(2) već jamči da novo ime atomski zamjenjuje svaku postojeću datoteku, a isti smještaj u direktoriju čuva ga od pada s EXDEV. Čišćenje je simetrično. Privremeno ime uklanja se u finally bloku na svakom putu, što je pri uspjehu no-op jer ga je preimenovanje već potrošilo, a pri neuspjehu uklanja djelomičnu datoteku pa se u direktoriju ne gomila .tmp smeće. Regresijski test u Tests\QDFFileRegression.inc provjerava točno to: nakon svakog ubrizganog neuspjeha bajtovi odredišta odgovaraju izvornima, bajtovi izvora odgovaraju izvornima, a direktorij ne sadrži ništa osim tih dvaju uzoraka
Zašto privremena datoteka na Windowsu popušta dopuštenja?
Datoteka stvorena s nil sigurnosnim deskriptorom nasljeđuje svoj DACL od nadređenog direktorija, a ne od datoteke koju će zamijeniti. To je ispravna zadana vrijednost za posve nov dokument i pogrešna za popravak na mjestu. Pretpostavimo da je operater zaključao contract.pdf na jedan jedini račun sa zaštićenim DACL-om koji se ne nasljeđuje. Privremena datoteka pokraj njega nasljeđuje šira dopuštenja direktorija, a kad se preimenuje preko contract.pdf, preimenovana datoteka nosi široki DACL, jer NTFS sigurnost putuje s objektom datoteke, a ne s imenom. Popravak uspije, bajtovi su ispravni, a kontrola pristupa koju je operater konfigurirao tiho je nestala. Ništa u povratnoj vrijednosti na to ne upućuje
PDF Library for Delphi zato čita DACL odredišta prije stvaranja privremene datoteke i predaje ga kao argument lpSecurityAttributes funkciji CreateFileW, pa se nova datoteka rađa s dopuštenjima stare i preimenovanje ne mijenja ništa što bi operater primijetio. Čitanje koristi GetFileSecurityW s DACL_SECURITY_INFORMATION, dimenzionirajući buffer iz rezultata ERROR_INSUFFICIENT_BUFFER prvog poziva. Tri uvjeta čine da writer padne zatvoreno umjesto da nagađa. Ako se DACL ne može pročitati, objavljivanje staje s EWriteError, što javni API preslikava u 305. Ako se deskriptor vrati bez postavljenog SE_DACL_PRESENT, objavljivanje također staje, jer bi predaja takvog deskriptora funkciji CreateFileW pustila kernel da se vrati na zadani DACL procesa i promijeni semantiku pristupa bez da je itko to tražio. A ako cilj nosi FILE_ATTRIBUTE_ENCRYPTED, writer odbija odmah: privremena datoteka bila bi čisti tekst, a preimenovanje datoteke s čistim tekstom preko EFS-zaštićene objavljuje nekriptiranu zamjenu nečega što je korisnik odlučio šifrirati na razini datotečnog sustava. EFS nije povezan s PDF standardnim security handlerima, koji su tema članka o učitavanju šifriranih dokumenata, ali način kvara isti je tip tihog degradiranja
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');
// dimenzioniraj deskriptor, pa pročitaj samo njegov DACL dio
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; // predano CreateFileW / CREATE_NEW
end;
Jednu pojedinost iz regresijskog testa vrijedi imati na umu ako sami pišete sličan test. Za izgradnju restriktivnog uzorka test primjenjuje DACL samo za vlasnika i mora izričito postaviti SE_DACL_PROTECTED u kontroli deskriptora; samo predavanje zaštićene zastavice u argumentu SecurityInformation funkcije SetFileSecurityW ne pretvara nezaštićeni deskriptor u zaštićeni. Tvrdnja nakon toga je da objavljena datoteka i dalje prijavljuje zaštićeni bit i izričit, ne-null DACL, i za zasebnu izlaznu putanju i za popravak preko same izvorne datoteke
Koji LastErrorCode govori što je palo?
RepairQDFFile vraća 1 pri uspjehu i 0 pri svakom neuspjehu, a LastErrorCode kaže koja je faza odbila. Izvor koji se ne može pročitati, uključujući onaj koji drugi proces drži pod ekskluzivnom bravom, prijavljuje 401; čitanje je sada umotano tako da se iznimka tijekom ulaza preslikava u 401 umjesto da procure u grešku pisanja. Neispravna ili dvosmislena QDF struktura, poput dupliciranog markera streama za isti objekt, prijavljuje PDFLIB_ERROR_QDF_REPAIR, što je 107, a odredište nije dirano jer writer nikad nije konstruiran. Sve nakon popravka, od stvaranja privremene datoteke preko flusha do preimenovanja, prijavljuje PDFLIB_ERROR_QDF_WRITE, što je 305. Regresijski test vježba one realne: odredište koje je otvorio drugi handle bez dijeljenja za brisanje, odredište samo za čitanje, nedostajući odredišni direktorij, i svaku od triju writer faza koja pada kroz ubacivanje. U svima njima povratna vrijednost je 0, kod je 305, i nakon toga ne postoji ni novi ni djelomični cilj. Opća navika čitanja koda, a ne samo povratne vrijednosti, ista je ona opisana u članku o dijagnosticiranju tihih neuspjeha u biblioteci
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
// Popravak na mjestu: ista putanja je 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;
Gdje jamstvo prestaje
Writer obećava konzistentnost prema neuspjesima koje proces može vidjeti, i iskren je oko onih koje ne može. Ako se proces ubije između stvaranja privremene datoteke i preimenovanja, finally blok nikad se ne izvrši i u direktoriju ostane datoteka .pdflib-qdf-<GUID>.tmp; odredište je i dalje netaknuto, što je svojstvo koje je važno, ali smeće je vaše da ga počistite. Nestanak napajanja također je izvan obećanja: podaci su flushani i preimenovanje je write-through, što je najviše što biblioteka u korisničkom modu može tražiti, ali writer ne radi fsync direktorijskog unosa i ne daje nikakvu tvrdnju o trajnosti povrh onoga što daje datotečni sustav. Drugi writer koji istodobno mijenja odredište ne otkriva se, jer se DACL i atributi čitaju prije stvaranja privremene datoteke i ništa ih ne provjerava ponovno u trenutku preimenovanja. A uspješno preimenovanje stvara novi identitet datoteke, pa alternativni tokovi podataka i obični atributi poput archive ili hidden bita na staroj datoteci ne preživljavaju; namjerno se prenosi samo DACL
Uža granica je koji API uopće koristi taj put. Samo RepairQDFFile ide kroz TPDFQDFFileWriter. SaveQDFToFile i ConvertFileToQDF i dalje otvaraju svoj izlaz s PLCreateFileStream(FileName, fmCreate) i streamaju QDF pretvorbu izravno u njega, na isti način na koji inkrementalni put opisan u članku o dodavanju ažuriranja u stream piše u koji god stream mu predate. Ta dva poziva proizvode novi artefakt za debugging iz dokumenta koji je već učitan i validiran, pa se rupa s neuspjehom parsiranja nikad nije odnosila na njih, ali ni oni ne nasljeđuju objavljivanje temeljeno na preimenovanju. Ne čitajte ovaj članak kao "svaki QDF izvoz je atomski". To je jedan izlaz, onaj čiji je ulaz nepouzdana, ručno uređena datoteka i čiji je izlaz rutinski ista putanja, i upravo mu je ta kombinacija priskrbila dodatni mehanizam. Ubacivanje kvara koje sve to dokazuje jeftino je jer su tri faze writera, WriteData, Flush i Publish, virtual. Testna podklasa nadjačava jednu od njih da podigne iznimku nakon što je pravi posao počeo, poziva Save nad popravljenim streamom i tvrdi da se iznimka propagira, da su bajtovi izvora i odredišta nepromijenjeni i da nijedna privremena datoteka nije ostala. Nijedan globalni file API nije zakvačen, nijedna stvarna korisnička datoteka nije dirana, a tri faze preslikavaju se jedan na jedan na tri načina na koja objavljivanje može pasti u produkciji: disk se napuni, flush se odbije ili se preimenovanje odbije jer netko drugi drži cilj
API RepairQDFFile, njegov writer za atomsko objavljivanje i ostatak QDF debug workflowa dio su PDF Library for Delphi, uz značajke oporavka cross-referencea, inkrementalnog ažuriranja i enkripcije obrađene drugdje na ovom blogu