Műszaki cikk

Atomi PDF-javítás Delphiben: átnevezés és DACL-védelem

A PDF Library for Delphi a RepairQDFFile kimenetét egy belső írón, a TPDFQDFFileWriter-en keresztül teszi közzé, amely soha nem nyitja meg írásra a célt: a javított bájtok egy kizárólagosan létrehozott ideiglenes fájlba kerülnek ugyanabban a könyvtárban, a fájl flush-elődik és lezárul, és csak ezután neveződik át a célra MoveFileExW-vel Windowson vagy rename(2)-vel POSIX-on. Ha bármi elbukik az átnevezés előtt, a cél megtartja minden bájtját, amije volt, a hívó pedig LastErrorCode 305-öt lát. Egy dokumentum javítása memóriában egy javítási funkció könnyű fele. Az eredményt úgy kijuttatni a lemezre, hogy soha ne maradjon a felhasználónál nulla hosszúságú vagy félig írt fájl, az a fele, amiről ez a cikk szól

Miért pusztíthatja el egy elbukó javítás is a célfájlt?

Mert rossz volt a műveletek sorrendje. A v3.539.13 előtt a RepairQDFFile a PLCreateFileStream(OutputFileName, fmCreate)-kel nyitotta meg a kimenetet, majd azt a streamet adta át a parsernek. Az fmCreate megnyitáskor levágja a fájlt, így mire a QDF-vizsgálat eldöntötte, hogy a bemenet nem javítható, a cél már ki is ürült. A helyben javítás, amikor az InputFileName és az OutputFileName ugyanaz az útvonal, egy visszautasított bemenetből elveszett fájlt csinált. Maga a parser jól viselkedett: az alacsony szintű PDFQDFRepair függvény érintetlenül hagyja a célstreamet, amikor elutasítja a kétértelmű markereket. Ez a védelem egyszerűen irreleváns volt, mert a nyilvános API egy hívással korábban már levágta a fájlt

A v3.539.13 javítása a javítást egy TMemoryStream-be vitte, és a kimenetet csak azután nyitotta meg, hogy a PDFQDFRepair sikerrel járt. Ez betömi a parse-hiba lyukát, és semmi mást. Az írási fázis továbbra is fmCreate, majd CopyFrom volt, így egy megtelt lemez, egy félúton jött megosztási ütközés, vagy egy kivétel a levágás és az utolsó WriteBuffer között továbbra is sérült célt hagyott maga után. A memória-először javítás a rossz bemenet ellen véd. A lemezre való közzétételnek saját határvonala van, és a v3.539.14 meg a v3.539.15 épített egyet

Hogyan hagyta abba a PDF Library for Delphi RepairQDFFile-ja a saját célja elpusztítását: a v3.539.12 a kimenetet PLCreateFileStream-mel és fmCreate-tel nyitotta meg, ami levág a PDFQDFRepair bemenet-elutasítása előtt, a v3.539.13 előbb TMemoryStreambe javított, a v3.539.15 pedig átadja a bájtokat a TPDFQDFFileWriternek atomi közzétételre
A parse-hiba javítása és a közzététel javítása két különböző határvonal: a memória-először javítás a rossz bemenet ellen véd, az író pedig azért létezik, hogy egy megtelt lemez vagy egy félúton elbukó írás ne hagyhassa sérülten a célt
// v3.539.12: a cél levágódik, mielőtt a bemenet validálódna
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
  if PDFQDFRepair(Source, Output, QDFError) then   // túl késő nemet mondani
    Result := 1;
finally
  Output.Free;
end;

// v3.539.15: javítás memóriában, majd a bájtok átadása a közzétételi írónak
Repaired := TMemoryStream.Create;
try
  if not PDFQDFRepair(Source, Repaired, QDFError) then
    Exit;                                          // a cél soha nem nyílt meg
  Writer := TPDFQDFFileWriter.Create;
  try
    Writer.Save(Repaired, OutputFileName);
    Result := 1;
  finally
    Writer.Free;
  end;
finally
  Repaired.Free;
end;

Mit garantál valójában az atomi közzététel?

A TPDFQDFFileWriter.Save garantálja, hogy a célútvonal vagy a teljes régi fájl, vagy a teljes új fájl, soha nem a kettő keveréke, minden olyan hibára, amit maga a könyvtár meg tud figyelni. Az író ezt négy lépésben teszi, amelyek mindegyike megtagadja a továbblépést, hacsak az előző be nem fejeződött. Először feloldja a célt a GetFullPathNameW-vel, kétszer hívva meg, és a buffert a visszaadott hosszból allokálva a MAX_PATH feltételezése helyett, így a hosszú útvonalak nem vágódnak le csendben. Másodszor létrehoz egy ideiglenes fájlt .pdflib-qdf- plusz egy GUID plusz .tmp néven a cél könyvtárában, Windowson CreateFileW-t használva CREATE_NEW-vel, POSIX-on open(2)-t O_CREAT or O_EXCL-lel és 0600 módban. Mindkét flag elbuktatja a létrehozást, ha a név már létezik, így két, ugyanazon a GUID-on versenyző folyamat nem tud osztozni egy handle-en. Harmadszor átmásolja a javított streamet 64 KiB-os darabokban a WriteBuffer-en keresztül, ami rövid írásnál kivételt dob ahelyett hogy olyan darabszámot adna vissza, amit senki nem ellenőriz, majd meghívja a FlushFileBuffers-t vagy az fsync(2)-t, és lezárja a handle-t. Negyedszer átnevez

A TPDFQDFFileWriter.Save négy atomi lépése a PDF Library for Delphi-ben: az útvonal kétszeri feloldása GetFullPathNameW-vel, a .pdflib-qdf ideiglenes fájl létrehozása CREATE_NEW vagy O_EXCL-lel, hogy a versenyző folyamatok ne osztozhassanak handle-en, másolás 64 KiB-os WriteBuffer-darabokban és flush, majd MoveFileExW REPLACE_EXISTING és WRITE_THROUGH flagekkel
Minden lépés megtagadja a továbblépést, hacsak az előző be nem fejeződött, az ideiglenes fájl konstrukció szerint a cél kötetén él, törlés-először ablak soha nem létezik, a finally-ben való takarítás pedig nem hagy maga után .tmp törmeléket
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 engedj kötetek közötti másolást, és ne töröld előbb a célt
  if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
    MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
    raise EWriteError.Create('Unable to publish QDF output');
end;

Az átnevezés lépése az, ahol a legtöbb házilag írt „biztonságos mentés” rutin csendben eltörik. A MoveFileExW a MOVEFILE_REPLACE_EXISTING-gel egyetlen fájlrendszer-műveletben cseréli le a célt ugyanazon a köteten. Az író szándékosan kihagyja a MOVEFILE_COPY_ALLOWED-ot, mert a kötetek közötti mozgatás másolás-majd-törlés formára degradálódik, ami pontosan az a nem atomi sorozat, aminek elkerülésére az egész terv létezik. Mivel az ideiglenes fájl a cél könyvtárában él, konstrukció szerint a cél kötetén van. Az író soha nem törli először a régi fájlt; egy törlés-majd-átnevezés párnak van egy ablaka, amiben az útvonal egyáltalán nem létezik, és egy összeomlás ebben az ablakban elveszíti a dokumentumot. A MOVEFILE_WRITE_THROUGH azt kéri, hogy a hívás ne térjen vissza, amíg az átnevezés el nem érte a lemezt, ami párosul az adat explicit flush-elésével. POSIX-on a rename(2) már garantálja, hogy az új név atomikusan lecseréli a meglévő fájlt, és ugyanaz a könyvtárba helyezés megóvja az EXDEV-vel való elbukástól. A takarítás szimmetrikus. Az ideiglenes nevet minden útvonalon egy finally blokk távolítja el, ami siker esetén no-op, mert az átnevezés már elfogyasztotta, hiba esetén pedig eltávolítja a részleges fájlt, hogy a könyvtár ne gyűjtsön .tmp törmeléket. A Tests\QDFFileRegression.inc regressziója pontosan ezt ellenőrzi: minden befecskendezett hiba után a cél bájtjai megegyeznek az eredetivel, a forrás bájtjai megegyeznek az eredetivel, a könyvtár pedig semmi mást nem tartalmaz a két fixture-ön kívül

Miért lazítja meg egy ideiglenes fájl a jogosultságokat Windowson?

A nil biztonsági leíróval létrehozott fájl a DACL-jét a szülőkönyvtárból örökli, nem attól a fájltól, amit épp le fog cserélni. Ez a helyes alapértelmezés egy vadonatúj dokumentumhoz, és a rossz egy helyben javításhoz. Tegyük fel, hogy egy operátor a contract.pdf-et egyetlen fiókra zárta le egy védett, nem öröklődő DACL-lel. A mellette létrejött ideiglenes fájl a könyvtár szélesebb jogosultságait örökli, és amint átneveződik a contract.pdf fölé, az átnevezett fájl a széles DACL-t hordozza, mert az NTFS biztonsága a fájlobjektummal utazik, nem a névvel. A javítás sikerül, a bájtok helyesek, az operátor által beállított hozzáférés-vezérlés pedig csendben eltűnt. A visszatérési érték semmivel nem sejteti ezt

A PDF Library for Delphi ezért az ideiglenes fájl létrehozása előtt beolvassa a cél DACL-jét, és átadja az lpSecurityAttributes argumentumként a CreateFileW-nek, így az új fájl a régi fájl jogosultságaival születik meg, és az átnevezés semmit nem változtat meg abból, amit az operátor észrevenne. Az olvasás a GetFileSecurityW-t használja DACL_SECURITY_INFORMATION-nal, a buffert az első hívás ERROR_INSUFFICIENT_BUFFER eredményéből méretezve. Három feltétel teszi, hogy az író zárt irányba bukik a találgatás helyett. Ha a DACL nem olvasható, a közzététel egy EWriteError-ral leáll, amit a nyilvános API 305-re képez le. Ha a leíró SE_DACL_PRESENT nélkül jön vissza, a közzététel szintén leáll, mert egy ilyen leírót a CreateFileW-nek átadva a kernel visszaeshet a folyamat alapértelmezett DACL-jére, és megváltoztathatja a hozzáférési szemantikát anélkül, hogy bárki kérte volna. És ha a cél FILE_ATTRIBUTE_ENCRYPTED-et hordoz, az író egyenesen visszautasítja: az ideiglenes fájl nyílt szövegű lenne, egy nyílt szövegű fájlt pedig egy EFS-védett fájl fölé átnevezni egy titkosítatlan helyettesítőt tesz közzé olyasmiből, amit a felhasználó fájlrendszer-szinten titkosítani választott. Az EFS-nek semmi köze a PDF szabványos biztonsági handlereihez, amik a titkosított dokumentumok betöltéséről szóló cikk témája, de a hibaforma ugyanaz a fajta csendes lefokozás

Miért másolja le a QDF közzétételi író a cél DACL-jét az ideiglenes fájl létrehozása előtt: egy nil leíró a könyvtár szélesebb jogosultságait örökölné, és az átnevezés csendben kiszélesítené a hozzáférést, ezért a GetFileSecurityW beolvassa a DACL-t, a hiányzó SE_DACL_PRESENT bit vagy egy EFS attribútum 305-tel leállítja a közzétételt, a CreateFileW pedig a régi jogosultságokkal születik meg
Az NTFS biztonsága a fájlobjektummal utazik, nem a névvel: a beolvasott leíró lpSecurityAttributes-ként való átadása után az átnevezés semmit nem változtat meg abból, amit az operátor beállított, és minden kapu zárt irányba bukik a találgatás helyett
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');
  // méretezd a leírót, majd csak a DACL részét olvasd belőle
  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;   // átadva a CreateFileW / CREATE_NEW hívásnak
end;

A regresszióból egy részlet érdemes megjegyezni, ha magad írsz hasonló tesztet. A korlátozott fixture felépítéséhez a teszt egy csak a tulajdonosra érvényes DACL-t alkalmaz, és a leíró kontrolljában explicit módon be kell állítania a SE_DACL_PROTECTED-et; pusztán a protected flag átadása a SetFileSecurityW SecurityInformation argumentumában nem alakít egy nem védett leírót védetté. Az utána következő állítás pedig az, hogy a közzétett fájl továbbra is jelenti a protected bitet és egy explicit, nem nulla DACL-t, mind külön kimeneti útvonalnál, mind pedig a forrásfájl fölé való javításnál

Melyik LastErrorCode mondja meg, mi bukott el?

A RepairQDFFile 1-et ad vissza siker esetén és 0-t bármilyen hiba esetén, a LastErrorCode pedig megmondja, melyik szakasz utasított vissza. Egy beolvashatatlan forrás, beleértve azt is, amit egy másik folyamat kizárólagos zárral tart, 401-et jelent; az olvasás mostantól be van csomagolva, hogy a bemenet közbeni kivétel 401-re képződjön ahelyett, hogy az írási hibába szivárogna. Az érvénytelen vagy kétértelmű QDF-struktúra, például egy duplikált stream-marker ugyanahhoz az objektumhoz, a PDFLIB_ERROR_QDF_REPAIR-t jelenti, ami 107, és a cél érintetlen maradt, mert az író soha nem jött létre. A javítás utáni minden, az ideiglenes fájl létrehozásától a flush-en és az átnevezésen át, a PDFLIB_ERROR_QDF_WRITE-ot jelenti, ami 305. A regresszió a valósághűeket gyakorolja: egy cél, amit egy másik handle tart nyitva törlésmegosztás nélkül, egy csak olvasható cél, egy hiányzó célkönyvtár, és az író mindhárom szakaszának elbuktatása befecskendezéssel. Mindegyikben 0 a visszatérés, 305 a kód, és utána nem létezik sem új, sem részleges célfájl. A kód, nem csak a visszatérési érték olvasásának általános szokása ugyanaz, amit a könyvtár csendes hibáinak diagnosztizálásáról szóló cikk ír le

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    // Helyben javítás: ugyanaz az útvonal a bemenet és a kimenet
    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;

Hol áll meg a garancia

Az író konzisztenciát ígér azokra a hibákra, amiket a folyamat lát, és őszinte azokkal szemben, amiket nem. Ha a folyamatot az ideiglenes fájl létrehozása és az átnevezés között megölik, a finally blokk soha nem fut le, és egy .pdflib-qdf-<GUID>.tmp fájl marad a könyvtárban; a cél továbbra is ép, ami a lényeges tulajdonság, de a törmeléket neked kell eltakarítanod. Az áramszünet szintén kívül esik az ígéreten: az adat flushelve van, az átnevezés write-through, ami a legtöbb, amit egy user-módú könyvtár kérhet, de az író nem fsync-eli a könyvtárbejegyzést, és nem tesz tartóssági állítást a fájlrendszer által nyújtottakon felül. Egy második, a célt egyidejűleg módosító írót nem érzékel, mert a DACL és az attribútumok az ideiglenes fájl létrehozása előtt olvasódnak, és az átnevezés idején semmi nem ellenőrzi újra őket. Egy sikeres átnevezés pedig új fájlidentitást hoz létre, így az alternate data streamek és a szokásos attribútumok, például a régi fájl archive vagy hidden bitje, nem élik túl; szándékosan csak a DACL kerül át

A szűkebb határvonal az, hogy melyik API használja egyáltalán ezt az útvonalat. Csak a RepairQDFFile megy át a TPDFQDFFileWriter-en. A SaveQDFToFile és a ConvertFileToQDF továbbra is a PLCreateFileStream(FileName, fmCreate)-kel nyitja meg a kimenetét, és egyenesen abba streameli a QDF-konverziót, ugyanúgy, ahogy a streamekhez való frissítés-hozzáfűzésről szóló cikk által leírt inkrementális útvonal ír abba a streamba, amit átadsz neki. Ez a két hívás egy új debugging-artefaktumot állít elő egy már betöltött és validált dokumentumból, így a parse-hiba lyuka soha nem is vonatkozott rájuk, de az átnevezésen alapuló közzétételt sem öröklik. Ne olvasd úgy ezt a cikket, hogy „minden QDF-export atomi”. Ez egyetlen kijárat, azé, aminek a bemenete egy nem megbízható, kézzel szerkesztett fájl, a kimenete pedig rutinszerűen ugyanaz az útvonal, és ez a kombináció érdemelte ki a plusz gépezetet. A hibabefecskendezés, ami mindezt bizonyítja, olcsó, mert az író három szakasza, a WriteData, a Flush és a Publish virtual. A teszt leszármazottja felülírja az egyiket, hogy kivételt dobjon, miután a valódi munka elkezdődött, meghívja a Save-ot egy javított streamen, és állítja, hogy a kivétel terjed, hogy a forrás és a cél bájtjai változatlanok, és hogy nem maradt ideiglenes fájl. Semmilyen globális fájl-API nincs beakasztva, egyetlen valódi felhasználói fájl sincs érintve, a három szakasz pedig egy-az-egyben megfelel annak a három módnak, ahogy egy közzététel a produkcióban elbukhat: megtelik a lemez, visszautasítják a flush-t, vagy megtagadják az átnevezést, mert valaki más tartja a célt

A RepairQDFFile API, az atomi közzétételi írója és a QDF debugging-munkafolyamat többi része a PDF Library for Delphi része, a blog más helyein tárgyalt cross-reference helyreállítás, inkrementális frissítés és titkosítási funkciók mellett