PDF Library for Delphi publikuje výstup RepairQDFFile přes interní writer TPDFQDFFileWriter, který nikdy neotevře cíl pro zápis: opravené bajty jdou do exkluzivně vytvořeného dočasného souboru ve stejném adresáři, soubor se flushne a zavře a teprve pak se přejmenuje přes cíl pomocí MoveFileExW na Windows nebo rename(2) na POSIXu. Kdykoli cokoli selže před rename, cíl si drží každý bajt, který měl, a volající vidí LastErrorCode 305. Opravit dokument v paměti je ta snadná polovina opravné funkce. Dostat výsledek na disk, aniž by uživatel kdy zůstal s nulovou délkou nebo napůl napsaným souborem, je ta polovina, o které je tenhle článek
Proč může oprava, která selže, pořád zničit cílový soubor?
Protože pořadí operací bylo špatně. Před v3.539.13 otevíral RepairQDFFile výstup přes PLCreateFileStream(OutputFileName, fmCreate) a ten stream pak předával parseru. fmCreate usekne soubor při otevření, takže když se QDF sken rozhodl, že vstup není opravitelný, cíl už byl vyprázdněný. Oprava na místě, kde InputFileName a OutputFileName jsou stejná cesta, měnila odmítnutý vstup na ztracený soubor. Sám parser se choval slušně: nízkoúrovňová funkce PDFQDFRepair nechává cílový stream na pokoji, když odmítá nejednoznačné markery. Ta ochrana byla prostě irelevantní, protože veřejné API useklo soubor o volání dřív
Oprava ve v3.539.13 přesunula repair do TMemoryStream a otevřela výstup až po tom, co PDFQDFRepair uspěl. Tím se zavřela díra selhání parsování a nic jiného. Fáze zápisu byla pořád fmCreate následované CopyFrom, takže plný disk, sharing violation napůl cesty nebo výjimka mezi useknutím a posledním WriteBuffer pořád zanechaly poškozený cíl. Repair nejdřív v paměti chrání proti špatnému vstupu. Publikace na disk potřebuje vlastní hranici a v3.539.14 a v3.539.15 ji postavily
// v3.539.12: cíl se usekne dřív, než se vstup zvaliduje
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
if PDFQDFRepair(Source, Output, QDFError) then // pozdě říct ne
Result := 1;
finally
Output.Free;
end;
// v3.539.15: repair v paměti, pak bajty předat publication writeru
Repaired := TMemoryStream.Create;
try
if not PDFQDFRepair(Source, Repaired, QDFError) then
Exit; // cíl nikdy neotevřen
Writer := TPDFQDFFileWriter.Create;
try
Writer.Save(Repaired, OutputFileName);
Result := 1;
finally
Writer.Free;
end;
finally
Repaired.Free;
end;
Co atomická publikace doopravdy garantuje?
TPDFQDFFileWriter.Save garantuje, že cílová cesta je buď kompletní starý soubor, nebo kompletní nový soubor, nikdy směs, pro každé selhání, které knihovna sama dokáže pozorovat. Writer to dělá ve čtyřech krocích, z nichž každý odmítá pokračovat, dokud předchozí nedoběhl. Nejdřív rozřeší cíl přes GetFullPathNameW, volá ho dvakrát a alokuje buffer z vrácené délky místo předpokladu MAX_PATH, takže dlouhé cesty se potichu neuseknou. Za druhé vytvoří dočasný soubor pojmenovaný .pdflib-qdf- plus GUID plus .tmp v cílovém adresáři, přes CreateFileW s CREATE_NEW na Windows a open(2) s O_CREAT or O_EXCL a módem 0600 na POSIXu. Oba příznaky způsobí, že create selže, pokud jméno už existuje, takže dva procesy závodící o stejný GUID se nemůžou podělit o handle. Za třetí kopíruje opravený stream po 64 KiB blocích přes WriteBuffer, který u krátkého zápisu vyhodí výjimku místo vrácení počtu, který nikdo nekontroluje, pak zavolá FlushFileBuffers nebo fsync(2) a zavře handle. Za čtvrté přejmenuje
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
// Nepovol cross-volume kopii a cíl nemaž předem
if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
raise EWriteError.Create('Unable to publish QDF output');
end;
Rename krok je místo, kde většina domácích „safe save" rutin potichu selhává. MoveFileExW s MOVEFILE_REPLACE_EXISTING vymění cíl jedinou filesystemovou operací na stejném svazku. Writer záměrně vynechává MOVEFILE_COPY_ALLOWED, protože move napříč svazky se degraduje na copy-then-delete, což je přesně ta neatomická sekvence, kvůli níž celý design existuje. Protože dočasný soubor žije v cílovém adresáři, je na cílovém svazku konstrukcí. Writer také nikdy nemaže starý soubor předem; pár delete-then-rename má okno, ve kterém cesta vůbec neexistuje, a pád uvnitř toho okna ztrácí dokument. MOVEFILE_WRITE_THROUGH prosí volání, aby se nevracelo, dokud rename nedosáhne disku, což se páruje s explicitním flushnutím dat. Na POSIXu rename(2) už garantuje, že nové jméno atomicky vymění jakýkoli existující soubor, a stejné umístění v adresáři brání tomu, aby selhal s EXDEV. Úklid je symetrický. Dočasné jméno se odstraňuje v bloku finally na každé cestě, což je při úspěchu no-op, protože rename ho už spotřebovalo, a při selhání odstraní částečný soubor, takže adresář nenashromažďuje .tmp trosky. Regrese v Tests\QDFFileRegression.inc kontroluje přesně to: po každém injektovaném selhání odpovídají bajty cíle originálu, bajty zdroje odpovídají originálu a adresář neobsahuje nic kromě dvou fixture
Proč dočasný soubor uvolňuje oprávnění na Windows?
Soubor vytvořený s nil security deskriptorem dědí svůj DACL z nadřazeného adresáře, ne ze souboru, který se chystá vyměnit. To je správný default pro zbrusu nový dokument a špatný pro opravu na místě. Představte si operátora, který zamkl contract.pdf na jediný účet s chráněným, nezděděným DACL. Dočasný soubor vedle něj dědí širší oprávnění adresáře a jakmile se přejmenuje přes contract.pdf, přejmenovaný soubor nese široký DACL, protože NTFS security cestuje s objektem souboru, ne s jménem. Oprava uspěje, bajty sedí a access control, který operátor nastavil, je potichu pryč. Nic v návratové hodnotě na to nenaznačí
PDF Library for Delphi proto čte DACL cíle před vytvořením dočasného souboru a předává ho jako argument lpSecurityAttributes do CreateFileW, takže nový soubor se rodí s oprávněními starého souboru a rename nezmění nic, čeho by si operátor všiml. Čtení používá GetFileSecurityW s DACL_SECURITY_INFORMATION, velikost bufferu z výsledku ERROR_INSUFFICIENT_BUFFER prvního volání. Tři podmínky dělají z writeru fail closed místo hádání. Pokud se DACL nedá přečíst, publikace se zastaví s EWriteError, který veřejné API mapuje na 305. Pokud deskriptor přijde bez nastaveného SE_DACL_PRESENT, publikace se taky zastaví, protože takový deskriptor předaný do CreateFileW by nechal kernel spadnout na process default DACL a změnit access semantics, aniž by to kdokoli chtěl. A pokud cíl nese FILE_ATTRIBUTE_ENCRYPTED, writer to odmítá rovnou: dočasný soubor by byl plaintext a přejmenování plaintextu přes EFS-chráněný soubor publikuje nešifrovanou náhradu něčeho, co si uživatel zvolil zašifrovat na úrovni souborového systému. EFS nesouvisí s PDF standard security handlery, kterými se zabývá článek o načítání šifrovaných dokumentů, ale failure mód je týž druh tichého downgradeu
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');
// změř deskriptor, pak přečti jen jeho DACL část
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; // předáno CreateFileW / CREATE_NEW
end;
Jeden detail z regrese stojí za zapamatování, pokud si podobný test píšete sami. Aby test postavil restriktivní fixture, aplikuje owner-only DACL a musí explicitně nastavit SE_DACL_PROTECTED v deskriptorovém controlu; pouhé předání protected příznaku v argumentu SecurityInformation SetFileSecurityW nezmění nechráněný deskriptor na chráněný. Assertuje se pak, že publikovaný soubor pořád hlásí protected bit a explicitní, non-null DACL, a to jak pro samostatnou výstupní cestu, tak pro opravu přes samotný zdrojový soubor
Které LastErrorCode vám řekne, co selhalo?
RepairQDFFile vrací 1 při úspěchu a 0 při jakémkoli selhání a LastErrorCode říká, která fáze odmítla. Zdroj, který se nedá přečíst, včetně jednoho drženého jiným procesem exkluzivním zámkem, hlásí 401; čtení je teď zabaleno tak, že výjimka během vstupu mapuje na 401 místo prosakování do write chyby. Neplatná nebo nejednoznačná QDF struktura, třeba duplikovaný stream marker pro tentýž objekt, hlásí PDFLIB_ERROR_QDF_REPAIR, což je 107, a cíl nebyl dotčen, protože writer nebyl nikdy zkonstruován. Všechno po opravě, od vytvoření dočasného souboru přes flush a rename, hlásí PDFLIB_ERROR_QDF_WRITE, což je 305. Regrese protahuje reálné případy: cíl otevřený jiným handlem bez delete sharingu, read-only cíl, chybějící cílový adresář a každou ze tří fází writeru selhávající injekcí. Ve všech je návrat 0, kód 305 a žádný nový ani částečný cíl poté neexistuje. Obecný zvyk číst kód místo jen návratové hodnoty je týž, jaký popisuje článek o diagnostice tichých selhání v knihovně
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
// Oprava na místě: stejná cesta je vstup i 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 garance končí
Writer slibuje konzistenci proti selháním, která proces vidí, a je upřímný k těm, která nevidí. Pokud je proces zabit mezi vytvořením dočasného souboru a rename, blok finally se nikdy nespustí a v adresáři zůstane .pdflib-qdf-<GUID>.tmp soubor; cíl je pořád intaktní, což je vlastnost, na které záleží, ale trosky jsou na vás. Výpadek napájení je mimo slib taky: data jsou flushnutá a rename je write-through, což je nejlepší, o co může user-mode knihovna žádat, ale writer nedělá fsync adresářové položky a nedává žádný durability slib navrch nad to, co poskytuje souborový systém. Druhý writer, který modifikuje cíl současně, se nedetekuje, protože DACL a atributy se čtou před vytvořením dočasného souboru a nic je v momentě rename znovu nekontroluje. A úspěšný rename vytvoří novou identitu souboru, takže alternate data streams a obyčejné atributy jako archive nebo hidden bit na starém souboru nepřežijí; jen DACL se přenáší záměrně
Užší hranice je, které API vůbec tuhle cestu používá. Přes TPDFQDFFileWriter jde jen RepairQDFFile. SaveQDFToFile a ConvertFileToQDF stále otevírají svůj výstup přes PLCreateFileStream(FileName, fmCreate) a streamují QDF konverzi rovnou do něj, stejně jako inkrementální cesta popsaná v článku o přidávání updatů do streamu zapisuje do jakéhokoli streamu, který jí podáte. Ta dvě volání produkují nový debugging artefakt z dokumentu, který už byl načten a validován, takže díra parse selhání se na ně nikdy nevztahovala, ale rename-based publikaci nedědí taky. Nečtěte tenhle článek jako „každý QDF export je atomický". Je to jeden exit, ten, jehož vstup je nedůvěryhodný, ručně editovaný soubor a jehož výstup je rutinně stejná cesta, a ta kombinace si vydělala extra mechaniku. Fault injekce, která tohle všechno dokazuje, je levná, protože tři fáze writeru, WriteData, Flush a Publish, jsou virtual. Testovací subclass overridne jednu z nich tak, aby vyhodila výjimku po tom, co reálná práce začala, zavolá Save na opraveném streamu a assertuje, že výjimka propadne, že bajty zdroje a cíle jsou nezměněné a že žádný dočasný soubor nezbyl. Nehookuje se žádné globální file API, nedotkne se žádný reálný uživatelský soubor a tři fáze se mapují jedna k jedné na tři způsoby, jak publikace může selhat v produkci: disk se naplní, flush se odmítne nebo rename se odmítne, protože cíl drží někdo jiný
API RepairQDFFile, jeho atomický publication writer a zbytek QDF debugging workflowu jsou součástí PDF Library for Delphi, po boku obnovy cross-referencí, inkrementálního updatu a šifrovacích funkcí popsaných jinde na tomhle blogu