Техническая статья

Детерминированный PDF ID в Delphi для воспроизводимых сборок

losLab PDF Library умеет выдавать побайтово идентичный PDF для идентичных входных данных, если вызвать SetDeterministicDocumentID(1). По умолчанию массив /ID трейлера — это дайджест MD5 от системных часов, поэтому два запуска одного и того же генератора отличаются как минимум этими байтами. Детерминированный режим выводит /ID из стабильного seed вместо этого, что восстанавливает воспроизводимость сборки

Симптом обычно проявляется в CI ещё до того, как кто-то начинает его искать. Шаблон не менялся, входная запись не менялась, шрифты не менялись, а сгенерированный PDF при каждом прогоне конвейера хешируется по-разному. Кэши сборки никогда не срабатывают. Контент-адресуемое хранилище накапливает свежий blob для каждой ночной сборки. Побайтовые регрессионные диффы срабатывают на файлах, которые никто не трогал. Проследив дифф до реальных байтов, почти всегда обнаруживаешь одну и ту же горстку шестнадцатеричных цифр, сидящую в трейлере файла

Для чего нужен массив ID трейлера

Трейлерный /ID — это маркер идентичности файла, а не контрольная сумма содержимого. ISO 32000-1 §14.4 определяет его как массив из двух байтовых строк: первый элемент — постоянный идентификатор, присваиваемый при создании документа и рассчитанный на то, чтобы пережить каждую последующую правку, а второй элемент — изменяемый идентификатор, который писатель обновляет при каждой модификации файла. Вместе они позволяют системе решить, являются ли два файла ревизиями одного документа или двумя разными документами. §7.5.5 делает эту запись фактически обязательной, поскольку трейлер обязан нести /ID, если он также несёт /Encrypt

В спецификации ничего не сказано о том, как вычислять значение. Рекомендация — дайджест таких вещей, как текущее время, путь к файлу, размер файла и словарь информации о документе, а системные часы — тот ингредиент, что делает результат уникальным. Именно это свойство и нужно для идентичности, и именно оно разрушает воспроизводимость, поэтому это должен быть явный переключатель, а не молчаливое изменение поведения

Почему одна и та же сборка каждый раз даёт разный PDF?

Потому что идентификатор по умолчанию выводится из момента генерации. Исторически losLab PDF Library строила строки /ID из MD5 от текущей метки времени, поэтому документ, созданный дважды с интервалом в секунду, несёт два разных постоянных идентификатора, даже если все остальные байты файла идентичны. Издержки для потребителей реальны: система сборки, ключующая артефакты по хешу, никогда не может переиспользовать шаг генерации PDF, дедуплицирующее объектное хранилище держит одну копию на сборку вместо одной копии на документ, а рецензент, глядя на бинарный дифф, вынужден доказывать, что единственное изменение — это шум, прежде чем доверять остальной части диффа. Детерминированная генерация /ID существует, чтобы устранить этот шум, в том же духе, что и работа по стабильности раскладки, описанная в заметках о потоках объектов и потоках перекрёстных ссылок

Переход на воспроизводимый идентификатор

Детерминированный режим включается по желанию, для каждого документа отдельно, и по умолчанию выключен, так что существующий вывод не меняется, пока вы его не запросите. SetDeterministicDocumentID принимает 0 или 1 и возвращает 1, когда значение принято, 0 при любом выходе за диапазон; GetDeterministicDocumentID сообщает текущее состояние. SetDocumentIDSeed задаёт явную строку seed, которая имеет приоритет над всем остальным, а передача пустого seed возвращает вывод к производному seed. GetDocumentFileID считывает /ID[0] после сохранения, чтобы можно было залогировать его или проверить утверждением

var
  Lib: TPDFlib;
  FileID: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed('invoice-4471-rev3');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Invoice 4471');
    Lib.SaveToFile('invoice.pdf');
    FileID := Lib.GetDocumentFileID;   // identical on every run
  finally
    Lib.Free;
  end;
end;

Обновление происходит в момент сохранения, а не при переключении флага, поэтому включение детерминированного режима на позднем этапе построения документа всё равно возымеет действие. Это также означает, что изменённый seed попадает в файл при следующем полном сохранении: задайте seed A, сохраните, задайте seed B, сохраните — и два файла получат разные идентификаторы, а восстановление seed A восстановит исходное значение. Явный seed — правильный выбор всякий раз, когда у документа есть естественный стабильный ключ, например номер счёта, ревизия записи или идентификатор коммита git, поскольку это отвязывает идентификатор от случайных метаданных

Откуда берётся seed, если вы его не задали?

Без явного seed losLab PDF Library выводит его из состояния документа, которое должно быть неизменным между идентичными повторными генерациями: заголовка версии PDF, числа страниц и каждой записи словаря информации о документе. Строковые и именные значения берутся дословно, значения других типов объектов вносят свою сериализованную форму, и всё это хешируется в строки /ID. Важное следствие в том, что CreationDate и ModDate входят в словарь информации и, соответственно, по замыслу входят в seed. Два прогона получают одинаковый идентификатор только тогда, когда они действительно производят одинаковые метаданные документа

Lib.SetDeterministicDocumentID(1);
// No SetDocumentIDSeed: the seed is derived from document state,
// so the timestamps in the Info dictionary have to be pinned.
Lib.SetInformation(2, 'Quarterly Report');        // Title
Lib.SetInformation(5, 'reporting-service 4.2');   // Creator
Lib.SetInformation(7, 'D:20260101000000Z');       // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z');       // ModDate
Lib.SaveToFile('report.pdf');

Фиксация ModDate ключом 8 выполняет двойную работу, и именно на этом люди спотыкаются. Один лишь детерминированный /ID не делает файл побайтово идентичным, потому что путь сохранения проставляет ModDate текущим временем, если только вызывающий код явно его не задал. Установка ключа 8 помечает значение как заданное вызывающим кодом и подавляет эту простановку. Если вам нужен воспроизводимый файл, а не просто воспроизводимый идентификатор, относитесь к меткам времени в метаданных как ко входным данным сборки: выводите их из исходной записи или из фиксированной эпохи, но никогда из Now

Почему перезапись ID ломает зашифрованный PDF?

Потому что в зашифрованном документе /ID[0] — это не просто метаданные, это ключевой материал. ISO 32000-1 §7.6.3.3, алгоритм 2, подаёт первый элемент идентификатора файла в вычисление ключа шифрования для стандартного обработчика безопасности ревизий с 2 по 4, наряду с дополненным паролем, значением /O и битами разрешений. Производный ключ затем формирует строку проверки /U, которую читатель проверяет при открытии, а ключ файла выводится и кэшируется при вызове Encrypt или при загрузке зашифрованного документа, и то и другое происходит до сохранения. Перезапись идентификатора во время сохранения поэтому выпустила бы структурно корректный файл, чья проверка /U провалилась бы при повторном открытии: не тонкое повреждение, а документ, который никто не сможет открыть, включая вас. Именно поэтому детерминированное обновление ограничено документами, не несущими состояния шифрования, и именно поэтому зашифрованный документ сохраняет тот /ID, который у него уже был, независимо от детерминированного режима, и настройка просто не действует на этом пути. Соответствующая обработка ревизий и семантика разрешений разобраны в статье о проверке шифрования и разрешений PDF. Отметим также, что путь восстановления шифрования обновляет только /ID[1], изменяемый идентификатор, ровно так, как и задумано в §14.4

Почему инкрементные сохранения сохраняют исходный идентификатор

Вторая граница — режим дописывания. Инкрементное обновление оставляет каждый предыдущий байт файла нетронутым и записывает новую ревизию после него, а постоянство /ID[0] согласно §14.4 как раз и сообщает потребителю, что новая ревизия принадлежит тому же документу, что и старая. Перезапись его разорвала бы эту связь, противоречила бы уже присутствующим в файле ревизиям и помешала бы семантике подписей, поскольку подпись покрывает диапазон байтов конкретной ревизии конкретного документа. Поэтому losLab PDF Library обновляет детерминированный идентификатор только при полных сохранениях и никогда во время дописывания, что сохраняет гарантию, описанную в статье об инкрементных обновлениях PDF и дописывании в поток

Единая точка формирования идентификатора

Вся генерация /ID в losLab PDF Library теперь проходит через единую внутреннюю процедуру, NewFileIDString, что и делает детерминированный переключатель надёжным, а не заплаткой на одном пути кода. Создание пустого документа, ленивое создание отсутствующего массива /ID по требованию и путь восстановления отпечатка шифрования — все вызывают её, так что есть ровно одно место, где системные часы могли бы просочиться обратно. Это также означает, что будущие варианты, например идентификатор, зависящий от содержимого, — это изменение одной функции, а не аудит всего сериализатора

function BuildQuote(const Seed: WideString): AnsiString;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed(Seed);
    Lib.SetInformation(7, 'D:20260101000000Z');
    Lib.SetInformation(8, 'D:20260101000000Z');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Quote 8812');
    Result := Lib.SaveToString;
  finally
    Lib.Free;
  end;
end;

// Regression guard: two independent builds, one byte sequence.
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
  WriteLn('reproducible')
else
  WriteLn('nondeterminism leaked into the output');

Встройте эту проверку в свой набор тестов, прежде чем полагаться на воспроизводимый вывод где-либо ещё, потому что она громко падает в тот момент, когда какая-нибудь новая функция вновь вносит метку времени. В остальном воспроизводимость — это свойство, которое незаметно распадается со временем, а одно утверждение над двумя сохранениями в памяти почти ничего не стоит запускать при каждой сборке

Показанный здесь API детерминированного идентификатора поставляется вместе с losLab PDF Library для Delphi и C++Builder, наряду с полным справочником по информации о документе, шифрованию и инкрементному сохранению