PDF Library for Delphi нашла пять дефектов декодеров, пока её код CCITT, TIFF, PNG, Flate и буфера потоков переводили на Free Pascal, и каждый из них годами проходил полный набор тестов на Delphi. Ни один не был багом компилятора. Каждый был кодом на Pascal, который Delphi случайно исполнял правильно из-за детали реализации: скрытый параметр результата, ссылавшийся на массив вызывающего; ветка вне диапазона, за которую никто никогда не читал дальше; буфер нулевой длины, единственной защитой которого был ключ проверки диапазона; смещение с нумерацией от единицы, которое только один путь в коде когда-либо передавал как 1; и контракт TStream.Read, который потоки в памяти никогда не проверяют. Смените компилятор или скормите тому же коду битый файл — и случайность перестаёт держаться
Дальше — конкретная форма каждого из них, исправление и дисциплина, которая из всего этого выросла: один и тот же исходник теперь должен давать одну и ту же семантику документа на обоих компиляторах, и включаемый файл тестов это проверяет. Соседняя статья про закалку парсера PDF на Pascal против зловредных файлов разбирала разрядность целых, глубину рекурсии и неинициализированные буферы. Эта — про другой класс отказов: код, который был неправильным всё это время, а компилятор тихо его прикрывал
Почему функция, возвращающая динамический массив, работает на Delphi без SetLength?
Потому что Delphi передаёт собственную переменную вызывающего как скрытый параметр результата, так что функция, которая ни разу не выделила память под свой результат, всё равно может писать в массив, выделенный вызывающим. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray — это поиск по опорной строке в самом сердце двумерного декодирования Group 3 и Group 4: по текущей позиции a0 и цвету текущего отрезка он ищет меняющиеся элементы предыдущей строки развёртки — те самые b1 и b2 из схемы двумерного кодирования ITU-T T.4 и T.6 — и возвращает их массивом из двух слотов. Исходная функция писала Result[0] и Result[1] и вообще ни разу не вызывала SetLength для Result
На первой же записи это должно падать, и на Free Pascal оно падает. На Delphi такого никогда не было, потому что оба места вызова в декодере выглядят так: объявляется b: TCCITTIntegerArray, один раз перед циклом по строкам развёртки выполняется SetLength(b, 2), а внутри цикла идёт присваивание b := GetNextChangingElement(a0, IsWhite) и чтение b[0] и b[1]. Руководство по языку Delphi говорит, что функция, результат которой — длинная строка, динамический массив или другой управляемый тип, получает этот результат дополнительным параметром var, а на практике компилятор передаёт адрес цели присваивания. Так что Result внутри функции — это сам b, уже длиной в два элемента, и каждая запись ложится в память, которой владеет вызывающий. Free Pascal выдаёт функции свежий nil-массив и присваивает его в b уже после — а это и есть то чтение контракта, под которое код следовало писать с самого начала
У этого псевдонима была ещё и семантика, на которую декодер опирается. Result[0] присваивается только когда при сканировании находится элемент больше a0, а Result[1] — только когда после него есть ещё элемент, так что при промахе слоты сохраняют то, что осталось в b от предыдущей итерации. Очевидное исправление — выделить два слота и обнулять их при каждом вызове — уничтожило бы этот перенос и изменило декодированный вывод на Delphi. В поставку ушла защита вместо сброса: на Delphi это мёртвый код, и путь декодирования остаётся байт в байт прежним, а на Free Pascal падение превращается в задуманное поведение. Вся суть именно в этой асимметрии, потому что исправление обязано было быть пустышкой на том компиляторе, где код уже давал проверенный вывод
Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
IsWhite: Boolean): TCCITTIntegerArray;
Begin
// Сюда Delphi приходит с двухэлементным массивом вызывающего,
// подставленным как Result, так что здесь это пустышка. FPC приходит с nil.
If (Length(Result) < 2) Then
SetLength(Result, 2);
...
// Result[0] / Result[1] по-прежнему пишутся только при попадании, так что
// при промахе сохраняются значения предыдущей итерации ровно как раньше
End;
Счётчик, переживший свои данные: запись каталога TIFF
Когда вы обнуляете массив, счётчик нужно обнулять тем же оператором, иначе счётчику поверит код, который самого массива уже не видит. Запись каталога образа TIFF (TIFF 6.0 §2, 12-байтовая раскладка tag, type, count и value-or-offset) несёт 32-битный счётчик прямо из файла, и PDF Library for Delphi читает каждую через PopDE: TTIFFEntry — запись с полями Tag, TagType, Length, Offset и разобранными массивами IntegerValues и DoubleValues. Исходный код проверял, не выходит ли Offset + TypeSize * Length за конец файла, и если выходит — выставлял оба массива нулевой длины. А Result.Length оставлял тем значением, что пришло из файла
Дальше ломалось две вещи. Функция заканчивается запасным вариантом, который читается как «если Length равен нулю, дать записи один нулевой элемент», чтобы вызывающие всегда могли прочитать нулевой элемент. Поскольку на пути выхода за диапазон Length никогда не обнулялся, этот запасной вариант не срабатывал ровно в том единственном случае, для которого и существовал. А вызывающие читают нулевой элемент безусловно: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip и ещё десяток берут E.IntegerValues[0], а таблицы полос делают Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), копируя Length раз по четыре байта из массива, в котором нет ни одного. Обнулённый массив с живым счётчиком строго опаснее непроверенного: непроверенный хотя бы содержит те байты, на которые заявляет права
Вторая проблема была в порядке операций. Оба вызова SetLength шли до проверки диапазона и брали размер из счётчика файла, так что враждебная запись могла запросить выделение в несколько гигабайт ещё до единой проверки на валидность. На Delphi возникшее исключение перехватывал обработчик выше по пути загрузки изображения, и файл просто не загружался — поэтому никто и не замечал; на самом деле это было событие нехватки памяти, которое выбирал файл. Исправление переносит выделение после проверки и заставляет счётчик ехать вместе с данными
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
> Length(Source);
If OutOfRange Then
Begin
Result.Length := 0; // счётчик уходит вместе со значениями
SetLength(Result.IntegerValues, 0);
SetLength(Result.DoubleValues, 0);
End
Else
Begin
SetLength(Result.IntegerValues, Result.Length); // и только теперь
SetLength(Result.DoubleValues, Result.Length);
End;
// ... позже существующий запасной вариант наконец доходит до своего случая:
If (Result.Length = 0) Then
Begin
SetLength(Result.IntegerValues, 1);
Result.IntegerValues[0] := 0;
End;
В этом исправлении нет ничего от конкретного компилятора, и именно поэтому оно попало в этот список. На Delphi дефект был скрыт по той же причине, что и на Free Pascal: ни один тестовый файл не содержал записи каталога, указывающей за конец файла. Порт его не вскрыл. Вскрыло чтение кода с вопросом «что Delphi делает здесь за меня, чего я не делаю сам»
Что происходит, когда IHDR в PNG заявляет тип цвета, которого формат не определяет?
Теперь PDF Library for Delphi отвергает такое изображение до запуска построчных фильтров; до v3.539.2 она вычисляла строку развёртки нулевой длины и подсовывала циклам разфильтровывания пустой буфер. ISO 15948 §11.2.2 определяет чанк IHDR, а таблица 11.1 перечисляет шесть допустимых сочетаний типа цвета и битовой глубины: оттенки серого на 1, 2, 4, 8 или 16 битах, индексированный цвет на 1, 2, 4 или 8 и truecolor, оттенки серого с альфой и truecolor с альфой на 8 или 16. TPNGReader проверял поля метода сжатия и метода фильтрации в IHDR, а FColorType и битовую глубину пропускал нетронутыми
Код построчных фильтров вычисляет все размеры из Case FColorType Of, который отображает каждый тип цвета в количество компонент. Тип цвета вне этих шести попадает в ветку Else, где SourceComponents равно 0, значит ScanlineByteCount равно 0, значит за SetLength(PreviousScanline, 0) немедленно следует FillChar(PreviousScanline[0], ScanlineByteCount, 0). Индексация нулевого элемента пустого динамического массива — это адрес, вычисленный от nil. С выключенной проверкой диапазона заполнение нулевой длины по этому адресу — тихая пустышка, и декодер шагает дальше по строкам, которых не существует; с включённой это ERangeError на первом же изображении; а следующие за ним вызовы Move в одном шаге от нарушения доступа. Что именно вы получите, зависит от компилятора и ключей сборки, а не от решений декодера, и это и есть признак того, что декодер не решал вообще ничего
Исправление — это таблица из спецификации, применённая там, где остальные поля IHDR уже проверялись: COLOR_GRAYSCALE принимает FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE принимает [1, 2, 4, 8], а COLOR_RGB, COLOR_GRAYSCALEALPHA и COLOR_RGBALPHA принимают [8, 16]; всё остальное сбрасывает ValidImage, и изображение отклоняется, сохраняя ширину и высоту для диагностики. Чанк pHYs короче своих девяти байт закрыли в том же проходе: читатель DPI индексировал S[1]…S[8] в строке, которую короткий чанк оставил пустой
Смещение с нумерацией от единицы, с которым обошлись как с указателем от нуля
InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString принимает StartPos с нумерацией от единицы, потому что на входе у него AnsiString, и реализация на Delphi адресует вход zlib как @Input[StartPos]. Реализация на Free Pascal, написанная под paszlib, чтобы обе цели под Windows линковали сжатие статически, выставляла next_in в PAnsiChar(Input) + StartPos, а avail_in — в Length(Input) - StartPos. Это арифметика указателей, и она от нуля. Передайте 1, то есть ровно то, что для этой функции значит «начать с начала», и сборка на FPC начинает распаковку со второго байта и обрывает её за байт до конца
Выжило это потому, что единственный вызывающий, до которого добирается большинство тестов, — это InflateStr, а он передаёт 0. Ноль случайно оказывается правильным смещением от нуля, так что обе сборки совпадали на каждом обычном вызове InflateStr и на каждом тесте, который через него проходил. А TPDFDocument.DecodeAllStreams — процедура, которой SaveQDFToFile и ConvertFileToQDF разворачивают потоки с одиночным FlateDecode в читаемый вид, — передаёт 1. В сборке на FPC пропущенный заголовок zlib приводил к отказу распаковки, но поток zlib всё равно возвращал ненулевой Consumed по просмотренным байтам, так что DecodeAllStreams принимал пустую полезную нагрузку за успешное декодирование и заменял каждый поток содержимого пустой строкой. В получившемся QDF было правильное число страниц, валидная структура и полное отсутствие содержимого страниц — файл, который открывается без ошибок в любом просмотрщике и не показывает ничего
// Ветка FPC в InflateStrFromPosition, после v3.539.16.
// StartPos, как и в ветке Delphi, нумеруется с единицы; обрезаем его,
// а в смещение указателя от нуля переводим ровно один раз, на границе.
If (StartPos < 1) Then
StartPos := 1;
If (Length(Input) = 0) Or (StartPos > Length(Input)) Then
Exit;
...
strm.next_in := Pointer(PAnsiChar(Input) + StartPos - 1);
strm.avail_in := Length(Input) - StartPos + 1;
Регрессионный тест, который это стережёт, — самый маленький из возможных: сжать нагрузку, распаковать её с позиции 0 и с позиции 1 и убедиться, что обе вернут одинаковую нагрузку и обе сообщат Consumed, равный полной длине потока. У потока RFC 1950 есть двухбайтовый заголовок и четырёхбайтовый трейлер Adler-32, так что ошибка на единицу с любого конца — это не тонкая порча, а поток, который либо не запускается, либо не завершается. Урок здесь про границу, а не про zlib: когда параметр функции определён в одной системе индексации, а реализация под ним использует другую, перевод должен жить ровно в одной строке, и тест обязан вызвать функцию со значением, которое эти системы различает
Почему короткое чтение TStream.Read — это не конец потока?
Потому что TStream.Read имеет право вернуть меньше запрошенного по любой причине, и только возврат 0 означает, что больше ничего нет. TMemoryStream и TFileStream на локальном диске почти всегда выполняют запрос целиком, поэтому код, который считает «вернулось меньше, чем я просил» концом файла, проходит все тесты, использующие их. Потоки поверх сети, потоки распаковки и любой наследник TStream, написанный заказчиком, могут вернуть два байта на запрос в шестьдесят четыре тысячи и иметь за собой ещё гигабайты
TPLBuffer — это читатель, через который проходит каждый парсер в PDF Library for Delphi, и он умеет оборачивать AnsiString, указатель, массив байт или TStream. Его четыре поисковых запроса — DistanceToByte, DistanceToOtherByte, DistanceToAnyByte и DistanceToOtherBytes, все возвращающие Int64, — читают источник блоками по 64 КБ в поисках разделителя и сообщают, насколько он далеко, не двигая логическую позицию. Каждый цикл заканчивался на Until ReadCount < BlockSize. Для трёх источников в памяти это верно, поскольку ReadIntoBuffer всегда отдаёт полный блок, кроме последнего. Для источника-потока это означает, что поиск сдаётся на первом же коротком чтении, объявляет разделитель отсутствующим, и токенизатор выше решает, что объект кончается там, где он не кончается
// TPLBuffer.DistanceToByte, цикл после v3.539.6.
// Ноль — единственный признак конца данных, который определяет TStream.Read.
TempPosition := FPosition;
Try
Repeat
ReadCount := ReadIntoBuffer(@TempBuffer[0], BlockSize);
For TestPos := 0 To ReadCount - 1 Do
If TempBuffer[TestPos] = Value Then
Begin
Result := TotalSkipped + TestPos;
Exit;
End;
Inc(TotalSkipped, ReadCount);
Until ReadCount = 0;
Finally
FPosition := TempPosition; // подглядывание не должно двигать читателя
End;
Тест, который это фиксирует, — наследник TMemoryStream, чей переопределённый Read ограничивает каждый запрос двумя байтами. Оберните в него строку aaaaaX, выставьте позицию буфера в 1 — и все четыре запроса должны сообщить расстояние 4 до X, оставить позицию после себя равной 1 и вернуть -1 для байта, которого там нет. До исправления первый запрос видел два байта, заключал, что поток исчерпан, и возвращал -1. Блок finally здесь значит не меньше условия цикла: Exit изнутри сканирования — это обычный путь успеха, и логическую позицию нужно восстанавливать и на нём, а не только когда цикл досчитывает до конца
Один исходник, два компилятора, один набор проверок
Дисциплина, которая выросла из этих пяти случаев, такова: «сборка на Delphi проходит» — это свидетельство о Delphi, а не об исходнике. С версии v3.539.16 и набор Delphi DUnitX, и консольный набор Free Pascal включают один и тот же Tests\CrossCompilerSemantics.inc — единственную процедуру RunCrossCompilerFileSemantics, которая собирает двухстраничный документ со сжатым содержимым через TPDFlib, сохраняет его, сохраняет его ещё раз как QDF через SaveQDFToFile, чинит QDF через RepairQDFFile, шифрует обычный файл алгоритмом AES-128 через EncryptFile и маску прав из EncodePermissions, а затем перезагружает каждый артефакт и проверяет одно и то же на обоих компиляторах: число страниц равно 2, заголовок сохраняется, текст второй страницы извлекается нетронутым из обычного, починенного и зашифрованного файлов, неверный пароль отклоняется с ненулевым LastErrorCode, EncryptionStrength равен 128, EncryptionAlgorithm равен 2, а отдельные биты прав из GetUserPermissions возвращаются ровно такими, какими были закодированы
Сравнение намеренно нормализованное, а не побайтовое. Шифрование тянет случайные соли, а писатель назначает идентификаторы документа, так что от двух сборок не ждут одинаковых файлов; от них ждут файлов, которые означают одно и то же, и проверки сформулированы на этом уровне. Ветка с QDF здесь именно из-за бага со смещением: QDF с двумя страницами и без содержимого проходит проверку числа страниц и падает на проверке извлечения текста, и матрица утверждает именно второе. Любое будущее исправление, которое пустышка на одном компиляторе и смена поведения на другом — а это описание четырёх из пяти выше, — теперь должно дважды пройти одни и те же проверки, прежде чем уйти в поставку
Вторая половина того же порта, на этапе линковки — заставить OMF-объекты Delphi и ожидания COFF от Free Pascal договориться, — это отдельная история в статье про линковку статических объектов FPC Win32 из OMF в COFF, а структурная закалка того же читателя TIFF против BigTIFF и тайловых файлов — в заметках о встроенном декодере TIFF. Декодеры из этой статьи и кросс-компиляторный тест, который теперь стоит под ними, поставляются в составе PDF Library for Delphi для Delphi, C++Builder и Free Pascal, где один и тот же исходник должен заслужить один и тот же результат на каждом компиляторе, а не получить его в подарок от одного