PDF Library for Delphi izpis funkcije RepairQDFFile objavi skozi notranji pisec TPDFQDFFileWriter, ki cilja nikoli ne odpre za pisanje: popravljeni bajti gredo v izključno ustvarjeno začasno datoteko v isti mapi, datoteka se izplakne in zapre, šele nato pa se preimenuje čez cilj s MoveFileExW v sistemu Windows oziroma rename(2) v POSIX. Če karkoli spodleti pred preimenovanjem, cilj obdrži vsak bajt, ki ga je imel, klicatelj pa vidi LastErrorCode 305. Popravljanje dokumenta v pomnilniku je lažja polovica funkcije za popravljanje. Spraviti rezultat na disk, ne da bi uporabniku kdaj ostala datoteka z nič bajti ali napol zapisana, je polovica, o kateri govori ta članek
Zakaj lahko popravilo, ki spodleti, vseeno uniči ciljno datoteko?
Ker je bil vrstni red operacij napačen. Pred različico v3.539.13 je RepairQDFFile izhod odprl s PLCreateFileStream(OutputFileName, fmCreate) in ta tok nato predal razčlenjevalniku. fmCreate ob odprtju odreže vsebino, zato je bil cilj že izpraznjen, ko je skeniranje QDF odločilo, da vhoda ni mogoče popraviti. Popravljanje na mestu, kjer sta InputFileName in OutputFileName ista pot, je zavrnjen vhod spremenilo v izgubljeno datoteko. Razčlenjevalnik se je sam obnašal lepo: nizkonivojska funkcija PDFQDFRepair pusti ciljni tok nedotaknjen, ko zavrne dvoumne oznake. Ta zaščita je bila preprosto nepomembna, ker je javni API datoteko odrezal en klic prej
Popravek v v3.539.13 je popravljanje prestavil v TMemoryStream in izhod odprl šele, ko je PDFQDFRepair uspel. To zapre luknjo ob spodletelem razčlenjevanju in nič drugega. Faza pisanja je bila še vedno fmCreate, ki mu sledi CopyFrom, zato so poln disk, kršitev souporabe na sredi poti ali izjema med odrezanjem in zadnjim WriteBuffer še vedno pustili poškodovan cilj. Popravljanje najprej v pomnilniku ščiti pred slabim vhodom. Objava na disk potrebuje svojo mejo in različici v3.539.14 in v3.539.15 sta jo zgradili
// v3.539.12: cilj je odrezan, preden je vhod preverjen
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
if PDFQDFRepair(Source, Output, QDFError) then // prepozno za "ne"
Result := 1;
finally
Output.Free;
end;
// v3.539.15: popravi v pomnilniku, nato bajte predaj pisalcu objave
Repaired := TMemoryStream.Create;
try
if not PDFQDFRepair(Source, Repaired, QDFError) then
Exit; // cilj nikoli odprt
Writer := TPDFQDFFileWriter.Create;
try
Writer.Save(Repaired, OutputFileName);
Result := 1;
finally
Writer.Free;
end;
finally
Repaired.Free;
end;
Kaj atomska objava pravzaprav zagotavlja?
TPDFQDFFileWriter.Save zagotavlja, da je ciljna pot bodisi cela stara datoteka bodisi cela nova datoteka in nikoli mešanica, in to za vsako napako, ki jo knjižnica sama lahko opazi. Pisec to naredi v štirih korakih, od katerih vsak odkloni nadaljevanje, če prejšnji ni končan. Najprej razreši cilj z GetFullPathNameW, ki ga pokliče dvakrat in medpomnilnik dodeli po vrnjeni dolžini, namesto da bi predpostavil MAX_PATH, tako da dolge poti niso tiho odrezane. Nato v ciljni mapi ustvari začasno datoteko z imenom .pdflib-qdf- plus GUID plus .tmp, in sicer s CreateFileW in CREATE_NEW v sistemu Windows oziroma open(2) z O_CREAT or O_EXCL in načinom 0600 v POSIX. Obe zastavici dosežeta, da ustvarjanje spodleti, če ime že obstaja, zato si dva procesa, ki tekmujeta za isti GUID, ne moreta deliti ročaja. Tretjič popravljeni tok prekopira v kosih po 64 KiB skozi WriteBuffer, ki ob kratkem zapisu sproži izjemo, namesto da bi vrnil število, ki ga nihče ne preveri, nato pa pokliče FlushFileBuffers ali fsync(2) in zapre ročaj. Četrtič 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 dovoli kopiranja med nosilci ali najprej brisanja cilja
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 tisto mesto, kjer večina doma narejenih rutin za »varno shranjevanje« tiho odpove. MoveFileExW z MOVEFILE_REPLACE_EXISTING zamenja cilj v eni sami datotečnosistemski operaciji na istem nosilcu. Pisec namenoma izpusti MOVEFILE_COPY_ALLOWED, ker se premik med nosilci izrodi v kopiranje in nato brisanje, kar je natanko tisto neatomsko zaporedje, ki se mu celotna zasnova izogiba. Ker začasna datoteka leži v ciljni mapi, je po zasnovi na ciljnem nosilcu. Pisec tudi nikoli najprej ne izbriše stare datoteke; par brisanje-in-nato-preimenovanje ima okno, v katerem pot sploh ne obstaja, in zrušitev znotraj tega okna izgubi dokument. MOVEFILE_WRITE_THROUGH zahteva, da se klic ne vrne, dokler preimenovanje ni doseglo diska, kar gre skupaj z izrecno izplaknitvijo podatkov. V POSIX rename(2) že zagotavlja, da novo ime atomsko zamenja vsako obstoječo datoteko, enaka umestitev v mapo pa poskrbi, da ne spodleti z EXDEV. Čiščenje je simetrično. Začasno ime se odstrani v bloku finally na vsaki poti, kar je ob uspehu prazna operacija, ker ga je preimenovanje že porabilo, ob spodletelosti pa odstrani delno datoteko, da se v mapi ne nabirajo ostanki .tmp. Regresijski test v Tests\QDFFileRegression.inc preverja natanko to: po vsaki vbrizgani napaki se bajti cilja ujemajo z izvirnikom, bajti vira se ujemajo z izvirnikom, v mapi pa ni ničesar razen obeh fiksnih datotek
Zakaj začasna datoteka v sistemu Windows sprosti dovoljenja?
Datoteka, ustvarjena z ničelnim varnostnim deskriptorjem, svoj DACL podeduje od nadrejene mape in ne od datoteke, ki jo bo zamenjala. To je prava privzeta vrednost za povsem nov dokument in napačna za popravljanje na mestu. Recimo, da je operater contract.pdf zaklenil na en sam račun z zaščitenim, nepodedovanim DACL. Začasna datoteka ob njej podeduje širša dovoljenja mape in ko se enkrat preimenuje čez contract.pdf, preimenovana datoteka nosi široki DACL, ker varnost NTFS potuje z objektom datoteke in ne z imenom. Popravilo uspe, bajti so pravi, nadzor dostopa, ki ga je operater nastavil, pa je tiho izginil. Nič v vrnjeni vrednosti na to ne namigne
PDF Library for Delphi zato prebere DACL cilja, preden ustvari začasno datoteko, in ga poda kot argument lpSecurityAttributes funkciji CreateFileW, tako da je nova datoteka rojena z dovoljenji stare in preimenovanje ne spremeni ničesar, kar bi operater opazil. Branje uporabi GetFileSecurityW z DACL_SECURITY_INFORMATION, medpomnilnik pa dimenzionira po rezultatu ERROR_INSUFFICIENT_BUFFER iz prvega klica. Trije pogoji dosežejo, da pisec raje odpove varno, kot da ugiba. Če DACL ni mogoče prebrati, se objava ustavi z EWriteError, kar javni API preslika v 305. Če se deskriptor vrne brez nastavljenega SE_DACL_PRESENT, se objava prav tako ustavi, ker bi predaja takega deskriptorja v CreateFileW jedru dovolila, da se zateče k privzetemu DACL procesa in spremeni semantiko dostopa, ne da bi kdo to zahteval. In če cilj nosi FILE_ATTRIBUTE_ENCRYPTED, pisec zavrne povsem: začasna datoteka bi bila v čistem besedilu in preimenovanje datoteke v čistem besedilu čez z EFS zaščiteno objavi nešifrirano zamenjavo za nekaj, kar je uporabnik izbral za šifriranje na ravni datotečnega sistema. EFS ni povezan s standardnimi varnostnimi obravnavalniki PDF, ki so tema članka o nalaganju šifriranih dokumentov, način odpovedi pa je ista vrsta tihega poslabšanja
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, nato preberi le njegov del DACL
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 v CreateFileW / CREATE_NEW
end;
Eno podrobnost iz regresije velja imeti v mislih, če pišete podoben test sami. Za izdelavo omejene fiksne datoteke test uporabi DACL, ki dovoljuje le lastnika, in mora v kontroli deskriptorja izrecno nastaviti SE_DACL_PROTECTED; samo prenos zaščitene zastavice v argumentu SecurityInformation funkcije SetFileSecurityW nezaščitenega deskriptorja ne spremeni v zaščitenega. Trditev po tem je, da objavljena datoteka še vedno poroča zaščiteni bit in izrecni, ne-ničelni DACL, tako za ločeno izhodno pot kot za popravljanje čez samo izvorno datoteko
Katera koda LastErrorCode vam pove, kaj je spodletelo?
RepairQDFFile ob uspehu vrne 1 in ob vsaki napaki 0, LastErrorCode pa pove, katera faza je odklonila. Vir, ki ga ni mogoče prebrati, vključno s tistim, ki ga drug proces drži z izključno ključavnico, sporoči 401; branje je zdaj ovito tako, da se izjema med vhodom preslika v 401, namesto da bi se prelila v napako pisanja. Neveljavna ali dvoumna struktura QDF, kot je podvojena oznaka toka za isti objekt, sporoči PDFLIB_ERROR_QDF_REPAIR, to je 107, cilj pa je nedotaknjen, ker pisec ni bil nikoli ustvarjen. Vse po popravilu, od ustvarjanja začasne datoteke do izplaknitve in preimenovanja, sporoči PDFLIB_ERROR_QDF_WRITE, to je 305. Regresija preizkusi realistične primere: cilj, ki ga je odprl drug ročaj brez souporabe brisanja, cilj samo za branje, manjkajočo ciljno mapo in vsako od treh faz pisca, ki spodleti z vbrizganjem. V vseh teh je vrnjena vrednost 0, koda 305, po tem pa ne obstaja noben nov ali delen cilj. Splošna navada brati kodo in ne le vrnjene vrednosti je ista, kot je opisana v članku o diagnosticiranju tihih odpovedi v knjižnici
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
// Popravljanje na mestu: ista pot je vhod in izhod
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;
Kje se jamstvo ustavi
Pisec obljublja skladnost proti napakam, ki jih proces vidi, in je pošten glede tistih, ki jih ne more. Če je proces ubit med ustvarjanjem začasne datoteke in preimenovanjem, blok finally nikoli ne steče in v mapi ostane datoteka .pdflib-qdf-<GUID>.tmp; cilj je še vedno nedotaknjen, kar je lastnost, ki šteje, ostanke pa morate pometi sami. Izpad napajanja je prav tako zunaj obljube: podatki so izplaknjeni in preimenovanje je prehodno na disk, kar je največ, kar sme zahtevati knjižnica v uporabniškem načinu, pisec pa ne izvede fsync nad vnosom v mapi in ne daje nobene obljube o trajnosti povrh tega, kar ponuja datotečni sistem. Drugega pisca, ki bi cilj spreminjal sočasno, se ne zazna, ker se DACL in atributi preberejo, preden je začasna datoteka ustvarjena, in se ob preimenovanju nič ne preveri znova. Uspešno preimenovanje pa ustvari novo identiteto datoteke, zato nadomestni podatkovni tokovi in običajni atributi, kot sta arhivski ali skriti bit stare datoteke, ne preživijo; namenoma se prenese le DACL
Ožja meja je, kateri API sploh uporablja to pot. Skozi TPDFQDFFileWriter gre samo RepairQDFFile. SaveQDFToFile in ConvertFileToQDF svoj izhod še vedno odpreta s PLCreateFileStream(FileName, fmCreate) in pretvorbo QDF pretakata naravnost vanj, enako kot inkrementalna pot, opisana v članku o dodajanju posodobitev v tok, ki piše v tisti tok, ki ji ga podaste. Ta dva klica izdelujeta nov pripomoček za razhroščevanje iz dokumenta, ki je bil že naložen in preverjen, zato luknja ob spodletelem razčlenjevanju zanju ni nikoli veljala, ne podedujeta pa tudi objave, ki temelji na preimenovanju. Tega članka ne berite kot »vsak izvoz QDF je atomski«. To so ena sama izhodna vrata, tista, katerih vhod je nezaupanja vredna, ročno urejena datoteka in katerih izhod je redno ista pot, in prav ta kombinacija si je prislužila dodatni mehanizem. Vbrizgavanje napak, ki vse to dokazuje, je poceni, ker so tri faze pisca, WriteData, Flush in Publish, virtual. Testni podrazred eno od njih prepiše tako, da sproži izjemo, ko se je resnično delo že začelo, pokliče Save na popravljenem toku in zahteva, da se izjema razširi, da se bajti vira in cilja niso spremenili in da nobena začasna datoteka ni ostala. Noben globalni datotečni API ni pripet, nobena resnična uporabnikova datoteka ni dotaknjena, tri faze pa se preslikajo ena na ena v tri načine, kako lahko objava v proizvodnji spodleti: disk se napolni, izplaknitev je zavrnjena ali pa je preimenovanje zavrnjeno, ker cilj drži nekdo drug
RepairQDFFile, njegov pisec atomske objave in preostali potek razhroščevanja QDF so del PDF Library for Delphi, ob obnovi medsebojnih sklicev, inkrementalnih posodobitvah in šifriranju, ki so obravnavani drugje na tem blogu