Odborný článok

Atomický výstup opravy PDF v Delphi: rename a DACL

PDF Library for Delphi publikuje výstup RepairQDFFile cez interný writer TPDFQDFFileWriter, ktorý cieľ nikdy neotvára na zápis: opravené bajty idú do exkluzívne vytvoreného dočasného súboru v tom istom adresári, súbor sa flushne a zatvorí, a až potom sa premenuje na cieľ cez MoveFileExW na Windows alebo rename(2) na POSIX. Ak pred premenovaním čokoľvek zlyhá, cieľ si podrží každý bajt, ktorý mal, a volajúci vidí LastErrorCode 305. Opraviť dokument v pamäti je tá ľahšia polovica funkcie na opravu. Dostať výsledok na disk bez toho, aby používateľovi niekedy zostal nulový alebo napoly zapísaný súbor, je tá polovica, o ktorej je tento článok

Prečo oprava, ktorá zlyhá, dokáže zničiť cieľový súbor?

Pretože poradie operácií bolo nesprávne. Pred verziou v3.539.13 RepairQDFFile otváral výstup cez PLCreateFileStream(OutputFileName, fmCreate) a ten stream potom odovzdal parseru. fmCreate pri otvorení skracuje, takže v čase, keď QDF sken rozhodol, že vstup nie je opraviteľný, bol cieľ už vyprázdnený. In-place oprava, kde InputFileName a OutputFileName sú tá istá cesta, premenila odmietnutý vstup na stratený súbor. Samotný parser sa správal dobre: nízkoúrovňová funkcia PDFQDFRepair necháva cieľový stream nedotknutý, keď odmietne nejednoznačné markery. Tá ochrana bola jednoducho irelevantná, pretože verejné API súbor skrátilo o jedno volanie skôr

Oprava vo v3.539.13 presunula opravu do TMemoryStream a výstup otvorila až po tom, čo PDFQDFRepair uspel. Tým sa zavrela diera pri zlyhaní parsovania a nič iné. Fáza zápisu bola stále fmCreate nasledované CopyFrom, takže plný disk, sharing violation v polovici alebo výnimka medzi skrátením a posledným WriteBuffer stále nechali cieľ poškodený. Oprava najprv v pamäti chráni pred zlým vstupom. Publikovanie na disk potrebuje vlastnú hranicu a v3.539.14 a v3.539.15 ju postavili

Ako RepairQDFFile v PDF Library for Delphi prestal ničiť svoj vlastný cieľ: v3.539.12 otváral výstup cez PLCreateFileStream a fmCreate, čo skracuje súbor skôr, než PDFQDFRepair stihne vstup odmietnuť, v3.539.13 opravoval najprv do TMemoryStream a v3.539.15 odovzdáva bajty TPDFQDFFileWriter na atomické publikovanie
Oprava zlyhania parsovania a oprava publikovania sú dve rôzne hranice: oprava najprv v pamäti chráni pred zlým vstupom, kým writer existuje preto, aby plný disk alebo zlyhanie v polovici zápisu už nemohlo nechať cieľ poškodený
// v3.539.12: cieľ sa skráti skôr, než sa vstup validuje
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
  if PDFQDFRepair(Source, Output, QDFError) then   // už príliš neskoro povedať nie
    Result := 1;
finally
  Output.Free;
end;

// v3.539.15: oprava v pamäti, potom bajty odovzdaj publikačnému writeru
Repaired := TMemoryStream.Create;
try
  if not PDFQDFRepair(Source, Repaired, QDFError) then
    Exit;                                          // cieľ sa nikdy neotvoril
  Writer := TPDFQDFFileWriter.Create;
  try
    Writer.Save(Repaired, OutputFileName);
    Result := 1;
  finally
    Writer.Free;
  end;
finally
  Repaired.Free;
end;

Čo atomické publikovanie vlastne garantuje?

TPDFQDFFileWriter.Save garantuje, že cieľová cesta je buď celý starý súbor, alebo celý nový súbor, nikdy zmes, a to pri každom zlyhaní, ktoré samotná knižnica dokáže pozorovať. Writer to robí v štyroch krokoch, z ktorých každý odmietne pokračovať, ak predchádzajúci nedobehol. Najprv vyrieši cieľovú cestu cez GetFullPathNameW, pričom ju zavolá dvakrát a buffer alokuje podľa vrátenej dĺžky namiesto predpokladu MAX_PATH, takže dlhé cesty sa potichu neodrežú. Druhý krok vytvorí dočasný súbor s menom .pdflib-qdf- plus GUID plus .tmp v cieľovom adresári, a to cez CreateFileW s CREATE_NEW na Windows a open(2) s O_CREAT or O_EXCL a módom 0600 na POSIX. Oba príznaky spôsobia, že vytvorenie zlyhá, ak už to meno existuje, takže dva procesy pretekajúce o ten istý GUID nemôžu zdieľať handle. Tretí krok kopíruje opravený stream v 64 KiB blokoch cez WriteBuffer, ktorý pri krátkom zápise vyhodí výnimku namiesto toho, aby vrátil počet, ktorý nikto nekontroluje, a potom zavolá FlushFileBuffers alebo fsync(2) a zatvorí handle. Štvrtý krok premenuje

Štyri atomické kroky TPDFQDFFileWriter.Save v PDF Library for Delphi: vyriešiť cestu dvakrát cez GetFullPathNameW, vytvoriť dočasný súbor .pdflib-qdf cez CREATE_NEW alebo O_EXCL, aby pretekajúce procesy nemohli zdieľať handle, kopírovať v 64 KiB blokoch cez WriteBuffer a flushnúť, potom MoveFileExW s REPLACE_EXISTING a WRITE_THROUGH
Každý krok odmietne pokračovať, ak predchádzajúci nedobehol, dočasný súbor je podľa konštrukcie na cieľovom volume, okno s najprv vymazaným cieľom nikdy neexistuje a upratovanie vo finally nezanechá žiadne .tmp trosky
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
  // Nedovoľ kopírovanie medzi volume ani najprv vymazať cieľ
  if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
    MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
    raise EWriteError.Create('Unable to publish QDF output');
end;

Krok premenovania je miesto, kde sa väčšina doma vyrobených "safe save" rutín potichu rozbije. MoveFileExW s MOVEFILE_REPLACE_EXISTING nahradí cieľ v jednej súborovej operácii na tom istom volume. Writer zámerne vynecháva MOVEFILE_COPY_ALLOWED, pretože pohyb medzi volumenmi sa degraduje na kopírovanie a následné mazanie, čo je presne tá neatomická sekvencia, ktorej sa celý dizajn vyhýba. Keďže dočasný súbor žije v cieľovom adresári, je podľa konštrukcie na cieľovom volume. Writer tiež nikdy najprv nemaže starý súbor; dvojica zmazať a premenovať má okno, v ktorom cesta neexistuje vôbec, a pád v tom okne dokument stratí. MOVEFILE_WRITE_THROUGH žiada, aby sa volanie nevrátilo, dokým premenovanie nedosiahne disk, čo sa páruje s explicitným flushom dát. Na POSIX rename(2) už garantuje, že nové meno atomicky nahradí akýkoľvek existujúci súbor, a rovnaké umiestnenie v adresári ho drží od zlyhania s EXDEV. Upratovanie je symetrické. Dočasné meno sa odstráni vo finally bloku na každej ceste, čo je pri úspechu no-op, pretože premenovanie ho už skonzumovalo, a pri zlyhaní odstráni čiastočný súbor, aby sa v adresári nehromadili .tmp trosky. Regresia v Tests\QDFFileRegression.inc kontroluje presne to: po každom injektovanom zlyhaní sa bajty cieľa zhodujú s pôvodnými, bajty zdroja sa zhodujú s pôvodnými a adresár neobsahuje nič okrem tých dvoch fixtúr

Prečo dočasný súbor na Windows uvoľní oprávnenia?

Súbor vytvorený s nil security descriptorem dedí svoju DACL z rodičovského adresára, nie zo súboru, ktorý sa chystá nahradiť. To je správne predvolené správanie pre úplne nový dokument a nesprávne pre in-place opravu. Predstavte si, že operátor zamkol contract.pdf na jediný účet s chránenou, nedediacou DACL. Dočasný súbor vedľa neho zdedí širšie oprávnenia adresára a len čo sa premenuje na contract.pdf, premenovaný súbor nesie tú širokú DACL, pretože bezpečnosť NTFS cestuje s objektom súboru, nie s menom. Oprava uspeje, bajty sú správne a prístupová kontrola, ktorú operátor nastavil, je potichu preč. Nič vo vrátenej hodnote na to nenaznačuje

PDF Library for Delphi preto prečíta DACL cieľa pred vytvorením dočasného súboru a odovzdá ju ako argument lpSecurityAttributes do CreateFileW, takže nový súbor sa narodí s oprávneniami starého a premenovanie nezmení nič, čo by operátor zbadal. Čítanie používa GetFileSecurityW s DACL_SECURITY_INFORMATION a buffer dimenzuje podľa výsledku ERROR_INSUFFICIENT_BUFFER z prvého volania. Tri podmienky nútia writer zlyhať zavretý namiesto hádania. Ak sa DACL nedá prečítať, publikovanie sa zastaví s EWriteError, ktorý verejné API mapuje na 305. Ak descriptor príde bez nastaveného SE_DACL_PRESENT, publikovanie sa tiež zastaví, pretože odovzdať taký descriptor do CreateFileW by nechalo kernel spadnúť na predvolenú DACL procesu a zmeniť sémantiku prístupu bez toho, aby o to niekto žiadal. A ak cieľ nesie FILE_ATTRIBUTE_ENCRYPTED, writer odmietne rovno: dočasný súbor by bol plaintext a premenovať plaintextový súbor na EFS chránený znamená publikovať nešifrovanú náhradu niečoho, čo sa používateľ rozhodol šifrovať na úrovni súborového systému. EFS nemá s PDF standard security handlermi nič spoločné, tým sa venuje článok o načítaní šifrovaných dokumentov, ale režim zlyhania je ten istý druh tichého downgrade-u

Prečo publikačný writer QDF kopíruje DACL cieľa pred vytvorením dočasného súboru: nil descriptor by zdedil širšie oprávnenia adresára a premenovanie by potichu rozšírilo prístup, takže GetFileSecurityW prečíta DACL, chýbajúci bit SE_DACL_PRESENT alebo atribút EFS zastaví publikovanie s 305 a CreateFileW sa narodí so starými oprávneniami
Bezpečnosť NTFS cestuje s objektom súboru, nie s menom: odovzdanie prečítaného descriptoru ako lpSecurityAttributes spôsobí, že premenovanie nezmení nič, čo operátor nastavil, a každá stráž zlyhá zavretá namiesto hádania
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');
  // nadimenzuj descriptor a potom z neho prečítaj len časť s 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;   // odovzdané do CreateFileW / CREATE_NEW
end;

Jeden detail z regresie stojí za zapamätanie, ak si podobný test píšete sami. Na postavenie obmedzenej fixtúry test nasadí DACL len pre vlastníka a musí v riadiacej časti descriptoru explicitne nastaviť SE_DACL_PROTECTED; samotné odovzdanie chráneného príznaku v argumente SecurityInformation funkcie SetFileSecurityW z nechráneného descriptoru chránený neurobí. Assert po ňom tvrdí, že publikovaný súbor stále hlási chránený bit a explicitnú, nenulovú DACL, a to pre samostatnú výstupnú cestu aj pre opravu priamo cez zdrojový súbor

Ktorý LastErrorCode vám povie, čo zlyhalo?

RepairQDFFile vracia 1 pri úspechu a 0 pri akomkoľvek zlyhaní a LastErrorCode hovorí, ktorá fáza odmietla. Zdroj, ktorý sa nedá prečítať, vrátane takého, ktorý drží iný proces exkluzívnym zámkom, hlási 401; čítanie je teraz obalené tak, aby sa výnimka počas vstupu mapovala na 401 namiesto pretečenia do chyby zápisu. Neplatná alebo nejednoznačná QDF štruktúra, napríklad duplicitný marker streamu pre ten istý objekt, hlási PDFLIB_ERROR_QDF_REPAIR, čo je 107, a cieľ nebol dotknutý, pretože writer sa nikdy nekonštruoval. Všetko po oprave, od vytvorenia dočasného súboru cez flush až po premenovanie, hlási PDFLIB_ERROR_QDF_WRITE, čo je 305. Regresia precvičuje tie realistické: cieľ otvorený iným handlom bez zdieľania mazania, cieľ len na čítanie, chýbajúci cieľový adresár a každú z troch fáz writera zlyhávajúcu injekciou. Vo všetkých je návratová hodnota 0, kód je 305 a potom neexistuje žiadny nový ani čiastočný cieľ. Všeobecný zvyk čítať kód a nielen návratovú hodnotu je ten istý, aký opisuje článok o diagnostike tichých zlyhaní v knižnici

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    // In-place oprava: tá istá cesta je vstup aj výstup
    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;

Kde záruka končí

Writer sľubuje konzistenciu voči zlyhaniam, ktoré proces vidí, a je poctivý ohľadne tých, ktoré nevidí. Ak sa proces zabije medzi vytvorením dočasného súboru a premenovaním, finally blok sa nikdy nespustí a v adresári zostane súbor .pdflib-qdf-<GUID>.tmp; cieľ je stále neporušený, čo je tá vlastnosť, na ktorej záleží, ale tie trosky sú na vás. Výpadok napájania je mimo sľubu tiež: dáta sú flushnuté a premenovanie je write-through, čo je to najlepšie, o čo user-mode knižnica môže požiadať, ale writer nerobí fsync adresárovej položky a nevyhlasuje žiadny claim o trvanlivosti nad to, čo poskytuje súborový systém. Druhý writer, ktorý súčasne mení cieľ, sa nezachytí, pretože DACL a atribúty sa čítajú pred vytvorením dočasného súboru a nič ich v čase premenovania znova nekontroluje. A úspešné premenovanie vytvorí novú identitu súboru, takže alternate data streams a bežné atribúty ako archive alebo hidden bit na starom súbore neprežijú; zámerne sa prenáša len DACL

Užšia hranica je to, ktoré API túto cestu vôbec používa. Cez TPDFQDFFileWriter ide len RepairQDFFile. SaveQDFToFile a ConvertFileToQDF stále otvárajú svoj výstup cez PLCreateFileStream(FileName, fmCreate) a QDF konverziu streamujú priamo doň, tak isto ako inkrementálna cesta opísaná v článku o pridávaní aktualizácií do streamu zapisuje do akéhokoľvek streamu, ktorý jej podáte. Tie dve volania vyrábajú nový ladiaci artefakt z dokumentu, ktorý už bol načítaný a validovaný, takže diera pri zlyhaní parsovania sa na ne nikdy nevzťahovala, ale nezdedia ani publikovanie založené na premenovaní. Nečítajte tento článok ako "každý QDF export je atomický". Je to jeden východ, ten, ktorého vstupom je nedôveryhodný, ručne upravovaný súbor a ktorého výstupom je rutinne tá istá cesta, a práve tá kombinácia mu vyslúžila tú extra mašinériu. Injektáž chýb, ktorá to všetko dokazuje, je lacná, pretože tri fázy writera, WriteData, Flush a Publish, sú virtual. Testovací potomok prepíše jednu z nich tak, aby vyhodila výnimku po tom, čo sa skutočná práca už začala, zavolá Save na opravenom streame a overí, že výnimka prechádza von, že bajty zdroja aj cieľa sú nezmenené a že neostal žiadny dočasný súbor. Nezachytáva sa žiadne globálne súborové API, nedotkne sa žiadny reálny používateľský súbor a tri fázy sa mapujú jedna na jednu na tri spôsoby, akými publikovanie v produkcii zlyhá: disk sa zaplní, flush je odmietnutý alebo premenovanie je odmietnuté, pretože cieľ drží niekto iný

API RepairQDFFile, jeho writer na atomické publikovanie a zvyšok QDF ladiaceho workflow sú súčasťou PDF Library for Delphi, popri funkciách na obnovu cross-reference, inkrementálne aktualizácie a šifrovanie, ktorým sa venujú iné články na tomto blogu