PDF Library for Delphi публикует результат RepairQDFFile через внутренний писатель TPDFQDFFileWriter, который никогда не открывает приёмник на запись: починенные байты уходят в эксклюзивно созданный временный файл в том же каталоге, файл сбрасывается на диск и закрывается, и только после этого он переименовывается поверх цели через MoveFileExW в Windows или rename(2) в POSIX. Если что-то падает до переименования, приёмник сохраняет все свои байты, а вызывающий видит LastErrorCode 305. Починить документ в памяти — это лёгкая половина функции починки. Добиться того, чтобы результат оказался на диске и пользователь никогда не остался с файлом нулевой длины или наполовину записанным, — та половина, о которой эта статья
Почему падающая починка всё равно может уничтожить целевой файл?
Потому что порядок операций был неверным. До v3.539.13 RepairQDFFile открывал вывод через PLCreateFileStream(OutputFileName, fmCreate) и затем передавал этот поток парсеру. fmCreate обрезает файл при открытии, так что к моменту, когда сканирование QDF решало, что вход не подлежит починке, приёмник уже был пуст. Починка на месте, когда InputFileName и OutputFileName — один и тот же путь, превращала отклонённый вход в потерянный файл. Сам парсер вёл себя прилично: низкоуровневая функция PDFQDFRepair оставляет целевой поток нетронутым, когда отклоняет неоднозначные маркеры. Но эта защита была просто неважна, потому что публичный API обрезал файл на один вызов раньше
Исправление в v3.539.13 перенесло починку в TMemoryStream и открывало вывод только после того, как PDFQDFRepair завершился успешно. Это закрывает дыру с отказом разбора и ничего больше. Фаза записи по-прежнему была fmCreate с последующим CopyFrom, так что переполненный диск, нарушение совместного доступа на середине или исключение между обрезанием и последним WriteBuffer всё равно оставляли испорченный приёмник. Починка сначала в памяти защищает от плохого входа. Публикации на диск нужна собственная граница, и её выстроили v3.539.14 и v3.539.15
// v3.539.12: приёмник обрезается до проверки входа
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
if PDFQDFRepair(Source, Output, QDFError) then // сказать «нет» уже поздно
Result := 1;
finally
Output.Free;
end;
// v3.539.15: починка в памяти, затем байты уходят писателю публикации
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 гарантирует, что по пути приёмника лежит либо полностью старый файл, либо полностью новый, но никогда их смесь, — для любого сбоя, который библиотека способна заметить сама. Писатель делает это в четыре шага, и каждый отказывается продолжаться, если предыдущий не завершился. Сначала он разрешает путь приёмника через GetFullPathNameW, вызывая её дважды и выделяя буфер по возвращённой длине, а не полагаясь на MAX_PATH, чтобы длинные пути не обрезались молча. Затем он создаёт во временном каталоге приёмника временный файл с именем .pdflib-qdf- плюс GUID плюс .tmp, используя CreateFileW с CREATE_NEW в Windows и open(2) с O_CREAT or O_EXCL и режимом 0600 в POSIX. Оба флага заставляют создание провалиться, если имя уже существует, так что два процесса, гонящихся за одним GUID, не могут разделить дескриптор. Потом он копирует починенный поток блоками по 64 КБ через WriteBuffer, который возбуждает исключение при короткой записи вместо возврата счётчика, которого никто не проверяет, после чего вызывает FlushFileBuffers или fsync(2) и закрывает дескриптор. И четвёртым шагом переименовывает
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
// Не допускаем копирование между томами и не удаляем приёмник заранее
if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
raise EWriteError.Create('Unable to publish QDF output');
end;
Именно на шаге переименования большинство самодельных «безопасных сохранений» тихо ломаются. MoveFileExW с MOVEFILE_REPLACE_EXISTING заменяет цель одной файловой операцией в пределах тома. Писатель намеренно не указывает MOVEFILE_COPY_ALLOWED, потому что перемещение между томами вырождается в копирование с последующим удалением — ровно та неатомарная последовательность, ради избегания которой всё и затевалось. Поскольку временный файл лежит в каталоге приёмника, он по построению на том же томе. Писатель также никогда не удаляет старый файл первым: у пары «удалить, потом переименовать» есть окно, в котором пути не существует вообще, и падение внутри этого окна теряет документ. MOVEFILE_WRITE_THROUGH требует, чтобы вызов не возвращался, пока переименование не дошло до диска, что сочетается с явным сбросом данных. В POSIX rename(2) и так гарантирует, что новое имя атомарно заменяет любой существующий файл, а размещение в том же каталоге не даёт ему упасть с EXDEV. Очистка симметрична. Временное имя удаляется в блоке finally на каждом пути, что при успехе ничего не делает, поскольку переименование его уже поглотило, а при сбое убирает частичный файл, чтобы каталог не накапливал мусор .tmp. Регрессионный тест в Tests\QDFFileRegression.inc проверяет ровно это: после каждого внедрённого сбоя байты приёмника совпадают с исходными, байты источника совпадают с исходными, а в каталоге нет ничего, кроме двух фикстур
Почему временный файл ослабляет права доступа в Windows?
Файл, созданный с пустым дескриптором безопасности, наследует свой DACL от родительского каталога, а не от файла, который он вот-вот заменит. Это правильное умолчание для совершенно нового документа и неправильное для починки на месте. Предположим, администратор ограничил contract.pdf одной учётной записью с защищённым, ненаследуемым DACL. Временный файл рядом унаследует более широкие права каталога, и как только он будет переименован поверх contract.pdf, переименованный файл понесёт широкий DACL, потому что безопасность NTFS путешествует вместе с файловым объектом, а не с именем. Починка проходит, байты верны, а настроенный администратором контроль доступа молча исчез. Ничто в возвращаемом значении на это не намекает
Поэтому PDF Library for Delphi читает DACL приёмника до создания временного файла и передаёт его как аргумент lpSecurityAttributes в CreateFileW, так что новый файл рождается с правами старого, и переименование не меняет ничего, что администратор мог бы заметить. Чтение использует GetFileSecurityW с DACL_SECURITY_INFORMATION, выставляя размер буфера по результату ERROR_INSUFFICIENT_BUFFER первого вызова. Три условия заставляют писателя отказать, а не гадать. Если DACL прочитать не удаётся, публикация останавливается с EWriteError, который публичный API отображает в 305. Если дескриптор возвращается без выставленного SE_DACL_PRESENT, публикация тоже останавливается, потому что передача такого дескриптора в CreateFileW позволила бы ядру откатиться к DACL процесса по умолчанию и изменить семантику доступа без чьей-либо просьбы. А если цель несёт FILE_ATTRIBUTE_ENCRYPTED, писатель отказывается сразу: временный файл был бы открытым текстом, а переименование файла с открытым текстом поверх защищённого EFS публикует незашифрованную замену того, что пользователь решил шифровать на уровне файловой системы. EFS не связана со стандартными обработчиками безопасности PDF, которым посвящена статья о загрузке зашифрованных документов, но режим отказа здесь — такое же тихое понижение
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;
Одну деталь из регрессионного теста стоит держать в голове, если вы будете писать похожий тест сами. Чтобы собрать ограниченную фикстуру, тест применяет DACL только для владельца и обязан явно выставить SE_DACL_PROTECTED в управляющих битах дескриптора; простая передача флага защиты в аргументе SecurityInformation вызова SetFileSecurityW не превращает незащищённый дескриптор в защищённый. Проверка после этого утверждает, что опубликованный файл по-прежнему сообщает защищённый бит и явный, непустой DACL — и для отдельного пути вывода, и для починки поверх самого исходного файла
Какой LastErrorCode говорит, что именно упало?
RepairQDFFile возвращает 1 при успехе и 0 при любом сбое, а LastErrorCode говорит, какой этап отказал. Источник, который не удаётся прочитать, в том числе тот, что удерживает другой процесс с эксклюзивной блокировкой, сообщает 401; чтение теперь обёрнуто так, чтобы исключение на входе отображалось в 401, а не протекало в ошибку записи. Неверная или неоднозначная структура QDF, например дублированный маркер потока для одного объекта, сообщает PDFLIB_ERROR_QDF_REPAIR, то есть 107, и приёмник не тронут, потому что писатель вообще не создавался. Всё после починки, от создания временного файла до сброса и переименования, сообщает PDFLIB_ERROR_QDF_WRITE, то есть 305. Регрессионный тест упражняет реалистичные случаи: приёмник, открытый другим дескриптором без совместного удаления, приёмник только для чтения, отсутствующий каталог приёмника и каждый из трёх этапов писателя, падающий через внедрение. Во всех них возврат равен 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;
Где кончается гарантия
Писатель обещает согласованность против сбоев, которые процесс способен увидеть, и честно говорит о тех, которые не способен. Если процесс убьют между созданием временного файла и переименованием, блок finally не выполнится, и в каталоге останется файл .pdflib-qdf-<GUID>.tmp; приёмник при этом цел — а это и есть важное свойство, — но мусор убирать вам. Потеря питания тоже вне обещания: данные сброшены, а переименование идёт с записью насквозь, что лучшее, о чём может попросить библиотека пользовательского режима, но писатель не делает fsync для записи каталога и не даёт никаких гарантий долговечности сверх того, что предоставляет файловая система. Второй писатель, меняющий приёмник параллельно, не обнаруживается, потому что DACL и атрибуты читаются до создания временного файла, и на момент переименования никто их не перепроверяет. А успешное переименование создаёт новую идентичность файла, поэтому альтернативные потоки данных и обычные атрибуты вроде архивного или скрытого бита у старого файла не переживают его; намеренно переносится только DACL
Более узкая граница — это то, какой API вообще идёт этим путём. Через TPDFQDFFileWriter проходит только RepairQDFFile. SaveQDFToFile и ConvertFileToQDF по-прежнему открывают свой вывод через PLCreateFileStream(FileName, fmCreate) и пишут преобразование QDF прямо в него — так же, как инкрементальный путь, описанный в статье про добавление обновлений в поток, пишет в тот поток, который вы ему дали. Эти два вызова создают новый отладочный артефакт из документа, который уже загружен и проверен, так что дыра с отказом разбора к ним никогда не относилась, но и публикацию через переименование они не наследуют. Не читайте эту статью как «любой экспорт QDF атомарен». Это один выход — тот, на входе которого недоверенный, правленный руками файл, а на выходе регулярно тот же самый путь, и именно это сочетание заслужило ему дополнительную машинерию. Внедрение сбоев, доказывающее всё это, стоит дёшево, потому что три этапа писателя — WriteData, Flush и Publish — объявлены virtual. Тестовый наследник переопределяет один из них так, чтобы возбудить исключение после начала реальной работы, вызывает Save для починенного потока и утверждает, что исключение распространяется, что байты источника и приёмника не изменились и что временного файла не осталось. Ни один глобальный файловый API не подменяется, ни один реальный пользовательский файл не трогается, а три этапа отображаются один в один на три способа, которыми публикация может провалиться в продакшене: диск заполнился, сброс отклонён, переименование отвергнуто, потому что кто-то другой держит цель
API RepairQDFFile, его писатель атомарной публикации и остальной рабочий процесс отладки QDF входят в PDF Library for Delphi вместе с восстановлением перекрёстных ссылок, инкрементальными обновлениями и шифрованием, о которых рассказано в других материалах этого блога