PDF Library for Delphi публикува изхода на RepairQDFFile през вътрешен writer, TPDFQDFFileWriter, който никога не отваря дестинацията за писане: ремонтните байтове отиват в изключително създаден временен файл в същата директория, файлът се изплаква (flush) и затваря, и едва тогава се преименува върху целта с MoveFileExW на Windows или rename(2) на POSIX. Ако нещо се провали преди преименуването, дестинацията пази всеки байт, който е имала, а извикващият вижда LastErrorCode 305. Ремонтът на документ в паметта е лесната половина на repair функционалността. Получаването на резултата на диска, без някога да оставите потребителя с файл с нулева дължина или наполовина записан, е половината, за която е тази статия
Защо ремонт, който се проваля, все пак може да унищожи целевия файл?
Защото редът на операциите беше грешен. Преди v3.539.13, RepairQDFFile отваряше изхода с PLCreateFileStream(OutputFileName, fmCreate) и после подаваше този stream на парсера. fmCreate отрязва файла (truncate) при отваряне, така че когато QDF скенът реши, че входът не е ремонтируем, дестинацията вече беше изпразнена. Ремонт на място, при който InputFileName и OutputFileName са един и същ път, превръщаше отхвърлен вход в изгубен файл. Самият парсер се държеше прилично: нисконивовата функция PDFQDFRepair пази целевия stream нетрогнат, когато отхвърли двусмислени маркери. Тази защита просто беше без значение, защото публичният API беше отрязал файла едно извикване по-рано
Поправката в v3.539.13 премести ремонта в TMemoryStream и отваряше изхода едва след като PDFQDFRepair е успял. Това затваря дупката за провален parse и нищо друго. Фазата на писане все още беше fmCreate последвано от CopyFrom, така че свършен диск, sharing violation по средата или изключение между отрязването и последния WriteBuffer все пак оставяха повредена дестинация. Ремонт първо в паметта предпазва от лош вход. Публикацията на диска се нуждае от собствена граница, и v3.539.14 и v3.539.15 изградиха такава
// v3.539.12: дестинацията се отрязва (truncate) преди входът да е валидиран
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
if PDFQDFRepair(Source, Output, QDFError) then // твърде късно за отказ
Result := 1;
finally
Output.Free;
end;
// v3.539.15: ремонт в паметта, после байтовете отиват при публикуващия writer
Repaired := TMemoryStream.Create;
try
if not PDFQDFRepair(Source, Repaired, QDFError) then
Exit; // дестинацията не е отваряна
Writer := TPDFQDFFileWriter.Create;
try
Writer.Save(Repaired, OutputFileName);
Result := 1;
finally
Writer.Free;
end;
finally
Repaired.Free;
end;
Какво всъщност гарантира атомичната публикация?
TPDFQDFFileWriter.Save гарантира, че пътят на дестинацията е или пълният стар файл, или пълният нов файл, никога смесица, за всеки провал, който самата библиотека може да наблюдава. Writer-ът го прави в четири стъпки, всяка от които отказва да продължи, ако предишната не е приключила. Първо resolve-ва дестинацията с GetFullPathNameW, викайки го два пъти и заделяйки буфера от върнатата дължина вместо да предполага MAX_PATH, така че дългите пътища не се отрязват тихо. Второ създава временен файл с име .pdflib-qdf- плюс GUID плюс .tmp в директорията на дестинацията, чрез CreateFileW с CREATE_NEW на Windows и open(2) с O_CREAT or O_EXCL и режим 0600 на POSIX. И двата флага карат създаването да се провали, ако името вече съществува, така че два процеса, надпревариращи се за същия GUID, не могат да споделят handle. Трето копира ремонтния stream на 64 KiB парцели през WriteBuffer, който вдига изключение при къс запис вместо да върне бройка, която никой не проверява, после вика FlushFileBuffers или fsync(2) и затваря handle-а. Четвърто преименува
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
// Без copy между томове и без изтриване на дестинацията предварително
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 е мястото, където повечето домашно приготвени „safe save" рутини тихо се чупят. MoveFileExW с MOVEFILE_REPLACE_EXISTING заменя целта в една файлова операция на същия том. Writer-ът нарочно пропуска MOVEFILE_COPY_ALLOWED, защото местенето между томове се деградира до copy-then-delete, което е точно неатомичната последователност, за чието избягване съществува целият дизайн. Тъй като временният файл живее в директорията на дестинацията, той е на тома на дестинацията по конструкция. Writer-ът също никога не изтрива стария файл първо; двойката delete-then-rename има прозорец, в който пътят изобщо не съществува, и crash в този прозорец губи документа. MOVEFILE_WRITE_THROUGH моли извикването да не се връща, докато преименуването не стигне диска, което се съчетава с изричния flush на данните. На POSIX rename(2) вече гарантира, че новото име атомично заменя всеки съществуващ файл, а същото разположение в директорията го пази от провал с EXDEV. Чистенето е симетрично. Временното име се маха в finally блок на всеки път, което при успех е no-op, защото rename-ът вече го е погълнал, а при провал маха частичния файл, така че директорията не трупа .tmp отломки. Регресията в Tests\QDFFileRegression.inc проверява точно това: след всеки вмъкнат провал байтовете на дестинацията пасват на оригинала, байтовете на източника пасват на оригинала, а директорията не съдържа нищо освен двата fixture
Защо временен файл разхлабва разрешенията на Windows?
Файл, създаден с nil security descriptor, наследява DACL-а си от родителската директория, не от файла, който предстои да замени. Това е правилната стойност по подразбиране за съвсем нов документ и грешната за ремонт на място. Да речем, че оператор е заключил contract.pdf до един-единствен акаунт с protected, не-наследен DACL. Временен файл до него наследява по-широките разрешения на директорията, и щом бъде преименуван върху contract.pdf, преименуваният файл носи широкия DACL, защото NTFS сигурността пътува с файловия обект, не с името. Ремонтът успява, байтовете са верни, а контролът на достъпа, който операторът е настроил, тихо изчезва. Нищо във върнатата стойност не подсказва това
Затова PDF Library for Delphi чете DACL-а на дестинацията преди да създаде временния файл и го подава като аргумент lpSecurityAttributes на CreateFileW, така че новият файл се ражда с разрешенията на стария, а rename-ът не променя нищо, което операторът би забелязал. Четенето ползва GetFileSecurityW с DACL_SECURITY_INFORMATION, оразмерявайки буфера от резултата ERROR_INSUFFICIENT_BUFFER на първото извикване. Три условия карат writer-а да проваля затворено (fail closed) вместо да гадае. Ако DACL-ът не може да бъде прочетен, публикацията спира с EWriteError, което публичният API мапва към 305. Ако дескрипторът се върне без вдигнат SE_DACL_PRESENT, публикацията също спира, защото подаването на такъв дескриптор на CreateFileW би позволило на ядрото да се върне към process default DACL и да промени семантиката на достъпа, без никой да го е искал. А ако целта носи FILE_ATTRIBUTE_ENCRYPTED, writer-ът отказва категорично: временният файл щеше да е plaintext, и преименуването на plaintext файл върху EFS-защитен публикува нешифровано заместване на нещо, което потребителят е избрал да шифрова на ниво файлова система. EFS няма нищо общо с PDF standard security handler-ите, които са темата на статията за зареждане на криптирани документи, но модусът на провал е същият вид тихо понижение
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');
// оразмери дескриптора, после прочети само 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; // подава се на CreateFileW / CREATE_NEW
end;
Един детайл от регресията си струва да се пази в глава, ако сами пишете подобен тест. За да построи ограничения fixture, тестът прилага owner-only DACL и трябва да вдигне SE_DACL_PROTECTED в descriptor control-а изрично; голото подаване на protected флага в аргумента SecurityInformation на SetFileSecurityW не превръща незащитен дескриптор в защитен. Асершънът после е, че публикуваният файл все още докладва protected бита и изричен, non-null DACL, и за отделен изходен път, и за ремонт върху самия изходен файл
Кой LastErrorCode ви казва какво се е провалило?
RepairQDFFile връща 1 при успех и 0 при всеки провал, а LastErrorCode казва кой етап е отказал. Източник, който не може да бъде прочетен, включително такъв, който друг процес държи с изключителен lock, докладва 401; четенето вече е обвито така, че изключение по време на входа мапва към 401 вместо да протича към write грешката. Невалидна или двусмислена QDF структура, като дублиран stream маркер за един и същ обект, докладва PDFLIB_ERROR_QDF_REPAIR, което е 107, а дестинацията не е пипната, защото writer-ът никога не е бил конструиран. Всичко след ремонта, от създаването на временния файл през flush и rename, докладва PDFLIB_ERROR_QDF_WRITE, което е 305. Регресията упражнява реалистичните случаи: дестинация, отворена от друг handle без delete sharing, read-only дестинация, липсваща дестинационна директория, и всеки от трите writer етапа, провален чрез инжекция. Във всички тях връщането е 0, кодът е 305 и след това не съществува нова или частична цел. Общият навик да четете кода, а не само върнатата стойност, е същият, описан в статията за диагностициране на тихи провали в библиотеката
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
// Ремонт на място: един и същ път е вход и изход
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;
Къде спира гаранцията
Writer-ът обещава консистентност срещу провали, които процесът може да види, и е честен за тези, които не може. Ако процесът бъде убит между създаването на временния файл и rename-а, finally блокът никога не се изпълнява и .pdflib-qdf-<GUID>.tmp файл остава в директорията; дестинацията все още е цяла, което е свойството, което има значение, но отломките са ваши за метене. Прекъсването на захранването също е извън обещанието: данните са изплакнати и rename-ът е write-through, което е най-доброто, което user-mode библиотека може да поиска, но writer-ът не прави fsync на directory entry-то и не заявява издръжливост над това, което файловата система дава. Втори writer, който модифицира дестинацията едновременно, не се открива, защото DACL-ът и атрибутите се четат преди създаването на временния файл и нищо не ги проверява пак при rename-а. И успешният rename създава нова файлова идентичност, така че alternate data stream-овете и обикновените атрибути като archive или hidden бита на стария файл не оцеляват; само DACL-ът се пренася нарочно
По-тясната граница е кой API изобщо ползва този път. Само RepairQDFFile минава през TPDFQDFFileWriter. SaveQDFToFile и ConvertFileToQDF все още отварят изхода си с PLCreateFileStream(FileName, fmCreate) и леят QDF конверсията направо в него, по същия начин, по който инкременталният път, описан в статията за добавяне на актуализации към stream, пише в какъвто и да е stream, който му подадете. Тези два извика произвеждат нов артефакт за дебъгване от документ, който вече е зареден и валидиран, така че дупката за провален parse никога не се е отнасяла за тях, но те не наследяват и rename-базираната публикация. Не четете тази статия като „всяко QDF експортиране е атомично". Това е един изход — онзи, чийто вход е недоверен, ръчно редактиран файл, а чийто изход рутинно е същият път — и точно тази комбинация му извоюва допълнителната механика. Fault инжекцията, която доказва всичко това, е евтина, защото трите етапа на writer-а, WriteData, Flush и Publish, са virtual. Тестовият subclass override-ва един от тях да вдигне изключение, след като истинската работа е започнала, вика Save върху ремонтен stream и асертира, че изключението се разпространява, че байтовете на източника и дестинацията са непроменени и че не остава временен файл. Не се закача глобален file API, не се пипа истински потребителски файл, а трите етапа се мапват едно към едно на трите начина, по които публикация може да се провали в продукция: дискът се пълни, flush-ът е отказан, или rename-ът е отказан, защото някой друг държи целта
API-ят RepairQDFFile, неговият атомичен публикуващ writer и останалата част от QDF дебъг работния процес са част от PDF Library for Delphi, наред с cross-reference възстановяването, инкременталната актуализация и криптирането, покрити другаде в този блог