Поправьте QDF-файл руками, подсуньте второй маркер потока для того же объекта, затем прогоните починку на месте самым естественным Delphi-кодом, какой вы способны написать. Шаг починки замечает повреждение и отказывается. Когда вы после этого посмотрите на файл, его длина 0 байт, потому что TFileStream.Create(OutputFileName, fmCreate) обрезал тот самый путь строкой раньше, чем валидатор увидел вход. Починить байты в памяти — лёгкая половина работы. Вернуть их на диск, ни на миг не оставив нулевой длины, полузаписанного или с неверными правами файла, — другая половина, и у неё собственные режимы отказа
Почему починка на месте опустошает файл?
Починка на месте опустошает файл из-за неправильного порядка операций: fmCreate обрезает при открытии, так что любой отказ, пришедший после открытия, опоздал. Когда InputFileName и OutputFileName — один путь, отвергнутый вход становится потерянным документом. Аккуратный парсер тут не спасёт. Он может не трогать свой выходной поток, отвергая двусмысленные маркеры, но вызывающий уже уничтожил цель вызовом раньше
// Очевидная версия: приёмник обрезается прежде, чем хоть что-то валидируется
Input := TMemoryStream.Create;
try
Input.LoadFromFile(InputFileName); // тот же путь, что и OutputFileName
Output := TFileStream.Create(OutputFileName, fmCreate); // файл теперь 0 байт
try
if not MyRepairQDF(Input, Output) then // ваш собственный валидатор: слишком поздно отказывать
raise Exception.Create('QDF structure rejected');
finally
Output.Free;
end;
finally
Input.Free;
end;
Обычная следующая попытка — чинить в TMemoryStream и открывать вывод только после успеха починки. Это закрывает дыру плохого входа и больше ничего. Фаза записи всё ещё truncate-then-copy, так что заполненный диск, нарушение совместного доступа на полпути или исключение между обрезкой и последним WriteBuffer всё равно оставляют приёмник повреждённым. Разбор и публикация — две отдельные границы, и каждой нужна своя защита
В PDF Library for Delphi обе границы закрывает TPDFlib.RepairQDFFile(InputFileName, OutputFileName). Он читает вход, чинит целиком в памяти и возвращается раньше, ничего не открыв на приёмнике, если структура QDF отвергнута. К публикации переходит только успешная починка, и публикация вообще не открывает приёмник на запись
Четыре шага между починенными байтами и приёмником
RepairQDFFile гарантирует, что путь приёмника держит либо полный старый файл, либо полный новый, при каждой неудаче, которую процесс сам может наблюдать. Туда ведут четыре шага, и ни один не начинается, пока не завершился предыдущий. Приёмник разрешается в полный путь через 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) продавливает данные вниз, и дескриптор закрывается. Закрытие нарочно не бросает: раз сброс прошёл, неудачное закрытие данным не угрожает, а исключение там спрятало бы реальную ошибку записи или сброса от того, кому её диагностировать. Последний шаг — rename
Именно на rename ломается большинство самодельных safe-save рутин. На Windows временный файл заменяет цель через MoveFileExW с MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH, что на одном томе — единственная файловая операция. MOVEFILE_COPY_ALLOWED не включён нарочно. Межтомовый перенос молча деградирует в copy-then-delete — ровно ту неатомарную последовательность, которой вся конструкция избегает, и я куда скорее хочу увидеть отказанный rename, чем «успешную» копию, остановившуюся на половине в момент падения машины. Поскольку временный файл лежит в каталоге приёмника, он и так на томе приёмника. Старый файл тоже никогда не удаляется первым; пара delete-then-rename оставляет окно, в котором путь не существует, и креш внутри него теряет документ. MOVEFILE_WRITE_THROUGH не даёт вызову вернуться прежде, чем rename дойдёт до диска. На POSIX rename(2) и так атомарно заменяет существующее имя, а размещение в том же каталоге держит его вдали от EXDEV
Очистка идёт в блоке finally на каждом пути. После успешного rename временного имени больше не существует, так что удаление ничего не делает; после неудачи оно убирает частичный файл. Каталог остаётся ровно с теми файлами, что были до вызова
Почему временный файл ослабляет права доступа в Windows?
Файл, созданный с nil-дескриптором безопасности, наследует DACL от родительского каталога; у файла, который он собрался заменить, голоса нет. Для совершенно нового документа этот дефолт верен. Для починки на месте — нет. Представьте оператора, запершего contract.pdf под одной учётной записью защищённым, ненаследуемым DACL. Обычный временный файл рядом подберёт более широкие права каталога, и после rename опубликованный contract.pdf унесёт тот широкий DACL, потому что безопасность NTFS принадлежит файловому объекту, а не имени. Байты корректны, возвращаемое значение 1. Ограничение доступа, которое настроил оператор, просто исчезло
Поэтому RepairQDFFile читает DACL приёмника до создания временного файла и передаёт его в CreateFileW через lpSecurityAttributes, так что новый файл начинается с прав старого, и rename не меняет ничего, что заметил бы оператор. Чтение — GetFileSecurityW с DACL_SECURITY_INFORMATION, с буфером, размер которого взят из ответа ERROR_INSUFFICIENT_BUFFER первого вызова. Когда приёмника ещё нет — обычный случай для отдельного выходного пути — дескриптор не передаётся, и новый файл наследует от каталога, как всякий новый файл
Три условия останавливают публикацию вместо того, чтобы позволить коду гадать, и каждое докладывает LastErrorCode 305:
- DACL не читается, например потому что у вызывающей учётной записи нет
READ_CONTROLна приёмнике - Возвращённый дескриптор не несёт
SE_DACL_PRESENT; отдать такой дескриптор вCreateFileW— позволить Windows откатиться к DACL по умолчанию процесса и поменять семантику доступа, которую никто не просил менять - Приёмник несёт
FILE_ATTRIBUTE_ENCRYPTED; временный файл был бы открытым текстом, и rename его поверх EFS-защищённого файла опубликовал бы незашифрованную замену того, что пользователь нарочно зашифровал
EFS — файловая возможность и не имеет отношения к security handler-ам PDF, о которых статья о загрузке зашифрованных документов. Форма отказа, впрочем, та же тихая понижалка, и отказываться от неё — правильный дефолт. Починка, изредка говорящая нет на EFS-шаре, — тикет в поддержку. Починка, расшифровывающая файл, который пользователь зашифровал, — инцидент
Если пишете собственный тест сохранения DACL, одна деталь Windows легко ошибается. Чтобы построить ограниченный тестовый файл, примените owner-only DACL и явно поставьте SE_DACL_PROTECTED в control дескриптора; передача PROTECTED_DACL_SECURITY_INFORMATION в SetFileSecurityW не превращает незащищённый дескриптор в защищённый. Потом проверьте, что опубликованный файл всё ещё докладывает защищённый бит и явный, ненулевой DACL, — один раз для отдельного выходного пути и один раз для починки поверх самого источника
Какой LastErrorCode говорит, что именно упало?
RepairQDFFile возвращает 1 при успехе и 0 при неудаче, а TPDFlib.LastErrorCode называет этап, который отказал. Вход, которого нет, который не открывается или больше 2 ГБ, докладывает 401, и исключение при чтении тоже отображается в 401 вместо утечки в код записи. Невалидная или двусмысленная структура QDF, вроде того дублированного маркера потока, докладывает PDFLIB_ERROR_QDF_REPAIR (107); приёмник никогда не открывался. Всё после починки, от разрешения пути до сброса и rename, докладывает PDFLIB_ERROR_QDF_WRITE (305). Реалистичные неудачи записи все ложатся на 305: приёмник, удерживаемый другим дескриптором без delete-совместного доступа, приёмник только для чтения, отсутствующий каталог приёмника, плюс отказы DACL и EFS выше. В каждом случае 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');
PDFLIB_ERROR_QDF_REPAIR:
Log('QDF structure rejected; the destination was never opened');
PDFLIB_ERROR_QDF_WRITE:
Log('write, flush or replace refused; the destination keeps its old bytes');
end;
finally
Pdf.Free;
end;
end;
Путь 305 легко воспроизвести из собственного тестового проекта, не хукнув ни одного файлового API. Delphi-режим fmShareDenyNone открывает файл с совместным доступом на чтение и запись, но без FILE_SHARE_DELETE, и Windows отказывается переименовывать поверх файла, удерживаемого так. Держите такой дескриптор открытым через вызов — и rename провалится на самом последнем шаге, после того как данные записаны и сброшены, тот случай, который вам более всего хочется увидеть обработанным
var
Before: TBytes;
Holder: TFileStream;
Search: TSearchRec;
begin
Before := TFile.ReadAllBytes(Target); // System.IOUtils
Holder := TFileStream.Create(Target, fmOpenRead or fmShareDenyNone);
try
Assert(Pdf.RepairQDFFile(Source, Target) = 0);
Assert(Pdf.LastErrorCode = PDFLIB_ERROR_QDF_WRITE);
finally
Holder.Free;
end;
// Эти проверки — предложения для вашего теста, а не вызовы библиотеки
Assert(BytesEqual(Before, TFile.ReadAllBytes(Target))); // BytesEqual: ваш помощник
// Временный файл не должен остаться позади
if FindFirst(ExtractFilePath(Target) + '.pdflib-qdf-*.tmp',
faAnyFile, Search) = 0 then
try
Assert(False, 'temporary file left behind: ' + Search.Name);
finally
FindClose(Search);
end;
end;
О привычке читать код вместо того, чтобы доверять одному нулю, статья о диагностике беззвучных отказов проходит тот же паттерн по остальному API
Где гарантия останавливается
Обещание покрывает неудачи, видимые процессу. Если процесс убит между созданием временного файла и rename, блок finally никогда не выполнится, и файл .pdflib-qdf-<GUID>.tmp останется в каталоге. Приёмник цел, что и важно, но выметать тот мусор — ваша забота. Пропадание питания — вопрос файловой системы: данные сброшены, rename идёт write-through, но запись каталога на POSIX отдельно не синхронизируется, так что долговечность — что там сверху даст файловая система
Успешный rename также порождает новый файловый объект, и кое-какие свойства старого с ним не переезжают. DACL переносится нарочно. Владение — нет, поскольку читается только DACL_SECURITY_INFORMATION, так что опубликованный файл принадлежит учётной записи, прогнавшей починку. Временный файл создаётся с FILE_ATTRIBUTE_NORMAL, значит биты hidden, archive и им подобные стартуют с чистого, а альтернативные потоки данных старого файла исчезли. На POSIX опубликованный файл хранит режим 0600 временного; позовите chmod потом, если группе нужен доступ на чтение. Атрибуты и DACL приёмника читаются до того, как временный файл существует, так что второго писателя на тот же путь координировать вам
SaveQDFToFile и ConvertFileToQDF пишут напрямую
RepairQDFFile — единственная точка входа QDF, публикующая через временный файл. SaveQDFToFile и ConvertFileToQDF открывают вывод через fmCreate и льют конверсию прямо туда, во многом как инкрементальный путь из статьи о добавлении обновлений в поток пишет в какой ни дай поток. Разделение нарочито. Те два вызова превращают уже загруженный и валидированный документ в новый артефакт отладки, так что недоверенного входа, который можно было бы отвергнуть на полпути, нет, и их выходной путь обычно свежий файл. RepairQDFFile принимает рукоредактированный, недоверенный файл и по обыкновению целится обратно в тот же путь; именно то сочетание, которое оправдывает лишнюю механику
Когда для QDF-дампа всё же хочется атомарной замены, хватает короткой обёртки собственных рук:
// Ваш помощник: пишет QDF рядом с целью, затем заменяет её одним rename
function SaveQDFReplacing(Pdf: TPDFlib; const FileName: string): Boolean;
var
ID: TGUID;
TempName: string;
begin
CreateGUID(ID);
TempName := ExtractFilePath(ExpandFileName(FileName)) +
'.myapp-qdf-' + GUIDToString(ID) + '.tmp';
try
Result := (Pdf.SaveQDFToFile(TempName) = 1) and
MoveFileExW(PWideChar(WideString(TempName)), PWideChar(WideString(FileName)),
MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH);
finally
if FileExists(TempName) then
DeleteFile(TempName); // присутствует, только когда что-то провалилось
end;
end;
Эта версия пропускает копирование DACL и явный сброс данных, которые делает RepairQDFFile. Для QDF-дампов, падающих в scratch- или отладочный каталог, я считаю это правильным количеством механики; если дамп заменяет файл с запертыми правами, гоняйте его через починочный путь или копируйте дескриптор безопасности сами
Атомарный вывод починки, вызовы конверсии QDF и коды ошибок выше поставляются в PDF Library for Delphi, рядом с восстановлением перекрёстных ссылок, инкрементальным обновлением и поддержкой шифрования, разобранными в других постах здесь