Tehnični članak

Atomsko preimenovanje in DACL pri popravilu PDF v Delphiju

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

Kako je RepairQDFFile v PDF Library for Delphi prenehal uničevati svoj lastni cilj: v3.539.12 je izhod odprl s PLCreateFileStream in fmCreate, ki odreže vsebino, preden PDFQDFRepair sploh lahko zavrne vhod, v3.539.13 je popravljal najprej v TMemoryStream, v3.539.15 pa bajte preda TPDFQDFFileWriter za atomsko objavo
Popravek ob spodletelem razčlenjevanju in popravek objave sta različni meji: popravljanje najprej v pomnilniku ščiti pred slabim vhodom, pisec pa obstaja zato, da poln disk ali napaka na sredi pisanja ne moreta več pustiti poškodovanega cilja
// 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

Štirje atomski koraki TPDFQDFFileWriter.Save v PDF Library for Delphi: pot razreši dvakrat z GetFullPathNameW, ustvari začasno datoteko .pdflib-qdf s CREATE_NEW ali O_EXCL, da si procesa v teku ne moreta deliti ročaja, kopira v kosih po 64 KiB skozi WriteBuffer in izplakne, nato pa preimenuje z MoveFileExW z REPLACE_EXISTING in WRITE_THROUGH
Vsak korak odkloni nadaljevanje, če prejšnji ni končan, začasna datoteka po zasnovi leži na ciljnem nosilcu, okno, v katerem bi najprej brisali, nikoli ne obstaja, čiščenje v bloku finally pa za sabo ne pusti nobenih ostankov .tmp
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

Zakaj pisec objave QDF prekopira DACL cilja, preden ustvari začasno datoteko: ničelni deskriptor bi podedoval širša dovoljenja mape in preimenovanje bi tiho razširilo dostop, zato GetFileSecurityW prebere DACL, manjkajoči bit SE_DACL_PRESENT ali atribut EFS ustavi objavo s 305, CreateFileW pa je rojen s starimi dovoljenji
Varnost NTFS potuje z objektom datoteke in ne z imenom: če prebrani deskriptor podamo kot lpSecurityAttributes, preimenovanje ne spremeni ničesar, kar je operater nastavil, vsaka pregrada pa odpove varno in ne ugiba
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