Технічна стаття

Атомарна публікація ремонту PDF у Delphi і захист DACL

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 її збудували

Як RepairQDFFile у PDF Library for Delphi перестав знищувати власну ціль: v3.539.12 відкривав вихід через PLCreateFileStream і fmCreate, що обрізає файл до того, як PDFQDFRepair встигає відкинути вхід, v3.539.13 спершу ремонтував у TMemoryStream, а v3.539.15 віддає байти TPDFQDFFileWriter для атомарної публікації
Виправлення невдалого розбору й виправлення публікації — це різні межі: ремонт із пам'яттю на першому місці захищає від поганого входу, а письменник існує, щоб переповнений диск чи збій посеред запису більше не могли лишити ціль зіпсованою
// 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) і закриває дескриптор. Четвертим він перейменовує

Чотири атомарні кроки TPDFQDFFileWriter.Save у PDF Library for Delphi: двічі розв'язати шлях через GetFullPathNameW, створити тимчасовий файл .pdflib-qdf із CREATE_NEW або O_EXCL, щоб процеси, які змагаються, не поділили дескриптор, скопіювати блоками по 64 КБ через WriteBuffer і скинути на диск, а потім MoveFileExW з REPLACE_EXISTING і WRITE_THROUGH
Кожен крок відмовляється йти далі, доки попередній не завершився, тимчасовий файл за побудовою лежить на тому самому томі, вікна з видаленням цілі першим не існує, а очищення у finally не лишає по собі сміття .tmp
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 на кожному шляху, що при успіху є no-op, бо перейменування вже його спожило, а при відмові видаляє частковий файл, щоб директорія не накопичувала сміття .tmp. Регресія в Tests\QDFFileRegression.inc перевіряє саме це: після кожної ін'єкованої відмови байти цілі збігаються з оригіналом, байти джерела збігаються з оригіналом, а директорія не містить нічого, крім двох фікстур

Чому тимчасовий файл послаблює дозволи на Windows?

Файл, створений із nil-дескриптором безпеки, успадковує свій 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, яким присвячена стаття про завантаження зашифрованих документів, але режим відмови тут — такий самий тихий відкат до слабшого захисту

Чому письменник публікації QDF копіює DACL цілі до створення тимчасового файлу: nil-дескриптор успадкував би ширші дозволи директорії й перейменування тихо розширило б доступ, тож GetFileSecurityW читає DACL, відсутній біт SE_DACL_PRESENT або атрибут EFS зупиняє публікацію з 305, а CreateFileW створює файл уже зі старими дозволами
Безпека NTFS подорожує з файловим об'єктом, а не з ім'ям: передача прочитаного дескриптора як lpSecurityAttributes робить перейменування непомітним для налаштувань адміністратора, а кожна перевірка відмовляє закрито, а не вгадує
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; ціль усе ще ціла, і це властивість, яка має значення, але прибирати це сміття вам. Втрата живлення теж поза обіцянкою: дані скинуто, а перейменування виконано з наскрізним записом — це максимум, який може просити бібліотека в user mode, але письменник не робить 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, поряд із відновленням cross-reference, інкрементальним оновленням і шифруванням, описаними в інших місцях цього блогу