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

Ошибки проверки диапазона в библиотеках PDF для Delphi

Программа на Delphi останавливается с ERangeError и голым сообщением «Range check error», причём часто лишь в одной из двух конфигураций сборки. Delphi возбуждает это исключение, когда код, скомпилированный с включённой проверкой диапазона ({$R+}), индексирует массив или строку за пределами границ либо сохраняет значение, не влезающее в целевой порядковый тип. Исправление почти всегда принадлежит тому месту, где был вычислен неверный индекс или значение, а это редко та строка, на которой останавливается отладчик

Что означает «Range check error» в Delphi?

ERangeError порождается проверками, которые компилятор вставляет при включённой проверке диапазона. С {$R+} (длинная форма {$RANGECHECKS ON}, или Project Options › Building › Delphi Compiler › Compiling › Range checking) каждый индекс массива и строки сравнивается с объявленными границами, а каждое присваивание меньшему порядковому типу, поддиапазону или перечислению — с диапазоном этого типа. Состояние по умолчанию — {$R-}, и код, скомпилированный так, вообще не содержит проверок, поэтому запись за границей просто попадает в соседнюю память. Провалившаяся проверка возбуждает ERangeError с ресурсной строкой «Range check error», если подключен SysUtils; программа, которая никогда не использует SysUtils, завершается с runtime error 201. Ошибка времени компиляции E1012 (Constant expression violates subrange bounds) покрывает только индексы, которые компилятор видит как константы; всё, что вычисляется во время выполнения, зависит от {$R+}. Переключатель работает и на уровне юнита: предкомпилированные DCU хранят состояние, с которым были собраны, а {$R-} в начале юнита переопределяет опцию проекта для этого юнита

Проверка переполнения — отдельный переключатель с отдельным исключением. {$Q+} ({$OVERFLOWCHECKS ON}) проверяет целочисленную арифметику вроде +, -, *, Inc и Dec и возбуждает EIntOverflow, когда результат не влезает; {$R+} проверяет индексы и присваивания. В малых типах они встречаются. Арифметика Byte вычисляется как Integer, поэтому B := B + 1 при B = 255 даёт 256 без всякого переполнения, и проверку диапазона проваливает само сохранение обратно в Byte. Inc(B) на том же значении — операция с проверкой переполнения: с {$Q+} она возбуждает EIntOverflow, а при одном лишь {$R+} молча заворачивается в 0. Какие конфигурации имеют эти переключатели включёнными, зависит от версии Delphi и от того, как создан проект. Прочитайте фактические значения в Project Options для каждой конфигурации сборки, прежде чем доверять теории о том, почему ошибка появляется только в одной сборке

Типичные причины с минимальными репродукторами

Большинство ошибок проверки диапазона в реальном коде Delphi укладываются в несколько шаблонов, у каждого из которых есть маленькое механическое исправление. Массивы — самый частый случай. Статический array[0..9] принимает индексы от 0 до 9, динамический массив или TBytes — 0..Length(A) - 1, а строки индексируются с единицы, поэтому пишите циклы через Low и High вместо захардкоженных пределов. Ломается код на пустом случае. High пустого динамического массива равен -1, поэтому цикл for I := 0 to High(A) со счётчиком Integer корректно не выполнится ни разу, тогда как A[High(A)] или A[Count - 1] ради «последнего элемента» читают индекс -1. Смежные случаи ведут себя иначе:

  • Контейнерные классы проверяют свои индексы сами, в любом состоянии {$R}: TList и TStringList возбуждают EListError или EStringListError, а TList<T> — EArgumentOutOfRangeException
  • Счётчики, приходящие как Cardinal или DWORD из заголовка файла или API, опасны при нуле: то, во что вычисляется Count - 1, зависит от того, как компилятор типизирует выражение; держите счётчики циклов как Integer, проверьте счётчик один раз, преобразуйте его и явно тестируйте Count > 0
procedure ArrayRepros(const Data: TBytes; Buf: Pointer);
var
  Last: Byte;
  P: PByte;
  I: Integer;
begin
  // Пустой массив: High(Data) = -1, поэтому это читает Data[-1]
  Last := Data[High(Data)];               // ERangeError при {$R+}

  // Исправление: проверьте длину, прежде чем просить последний элемент
  if Length(Data) > 0 then
    Last := Data[High(Data)];

  // TByteArray объявлен как array[0..32767] of Byte, поэтому при {$R+}
  // любой индекс выше 32767 проваливается, даже если Buf держит мегабайты
  I := 40000;
  Last := PByteArray(Buf)^[I];            // ERangeError при I = 40000

  // Исправление: индексируйте реальный буфер или используйте арифметику PByte
  P := PByte(Buf);
  Inc(P, I);
  Last := P^;                             // проверки диапазона нет, валидируйте I сами
end;

Случай с PByteArray ловит даже опытных разработчиков, потому что объявленная граница не имеет ничего общего с буфером. И SysUtils Delphi, и Free Pascal объявляют TByteArray как array[0..32767] of Byte — окно в 32 КБ, оставшееся от 16-битных времён. При {$R+} проверка сравнивает ваш индекс с 32767, поэтому валидное смещение в поток на 100 КБ возбуждает ERangeError; при {$R-} проверки нет, и индекс за реальным концом буфера не ловится никогда. Индексация самого TBytes даёт проверку по фактической длине, что делает её лучшим выбором по умолчанию. Арифметика PByte проверкой диапазона не покрыта вообще — сопровождайте её явной проверкой размера буфера

Присваивания порядковых значений — вторая большая семья. Присваивание 256 в Byte, 70000 в Word или 13 в поддиапазон 1..12 проваливает проверку, как и присваивание отрицательного Integer в Cardinal. Delphi вычисляет смешанные знаковые и беззнаковые 32-битные операнды как Int64, поэтому сама арифметика обычно корректна, а ошибка проявляется на сохранении, что указывает на переменную со слишком узким типом. Жёсткие приведения типов — дыра в этой сети: Byte(I), Cardinal(I) и TMyEnum(I) не генерируют проверку диапазона и молча обрезают или переинтерпретируют значение. Когда 32-битное значение из файла выбирает член перечисления, сравните его как Cardinal с Ord(High(TMyEnum)) до приведения

var
  B: Byte;
  C: Cardinal;
  I: Integer;
  Addr: Integer;
  Obj: TObject;
begin
  B := 255;
  B := B + 1;            // вычисляется как Integer 256, сохранение падает: ERangeError

  I := -1;
  C := I;                // отрицательное в Cardinal: ERangeError
  C := Cardinal(I);      // жёсткое приведение: без проверки, C = 4294967295

  // Win64: указатели 8 байт, Integer остаётся 4
  Obj := TObject.Create;
  Addr := NativeInt(Obj);  // Integer: падает выше 2 ГБ (Cardinal: выше 4 ГБ)
  // Исправление: держите значения размером с указатель в NativeInt / NativeUInt
end;

// Арифметика по модулю 2^32, одинаково ведущая себя в Delphi и FPC
function AddMod32(A, B: Cardinal): Cardinal; inline;
begin
  Result := Cardinal((UInt64(A) + UInt64(B)) and $FFFFFFFF);
end;

Win64 добавляет указательный вариант той же ошибки. Pointer, NativeInt, TComponent.Tag (с XE2 это NativeInt), LPARAM и WPARAM — все 8 байтов на Win64, тогда как Integer, Longint и Cardinal остаются 4. Сохранение адреса через проверяемое присваивание возбуждает ERangeError, как только адрес выходит за диапазон целевого типа: выше 2 ГБ для Integer, выше 4 ГБ для Cardinal. Сохранение через жёсткое приведение обрезает адрес и позже даёт access violation. Небольшие тестовые программы часто выделяют память низко в адресном пространстве, поэтому этот класс ошибок обычно всплывает в долгоживущих процессах, а не в юнит-тестах. Объявляйте всё, что хранит указатель, хэндл или параметр сообщения, как NativeInt или NativeUInt. Ловушки Longint и указателей на Linux64 развивают ту же логику для LP64-целей, где размер меняет сам Longint

Почему ошибка появляется только в Debug или только под FPC?

Ошибка проверки диапазона, появляющаяся лишь в одной сборке, почти всегда — реальный выход за границы, который другая сборка не проверяет. Release-сборки с {$R-} прогоняют тот же ошибочный индекс без жалоб и либо читают чужую память, либо портят её, что хуже исключения. Free Pascal добавляет свои переключатели. {$mode delphi} меняет синтаксис и семантику языка, но не включает никаких проверок; проверка диапазона под FPC приходит из {$R+} или опции командной строки -Cr, а проверка переполнения — из -Co, которую Lazarus показывает под Project Options › Compiler Options › Debugging. FPC к тому же вычисляет целочисленные выражения в родном целом размере или больше, поэтому выражение, молча заворачивающееся в 32-битном Delphi, может дать значение выше High(Cardinal) на 64-битной FPC-цели, и сохранение его обратно в Cardinal проваливает проверку. Мы наткнулись ровно на это в собственном коде SHA-256: сложения по модулю 232, записанные как простые суммы Cardinal, возбуждали Range check error в проверяемой FPC-сборке. Суммирование в UInt64 с маскированием, как делает AddMod32 выше, сохраняет вывод побайтно идентичным на обоих компиляторах, не выключая проверки для всего юнита. Больше таких ловушек переносимости собрано в статье о коде Delphi, который работает под FPC лишь по случайности

Заставляем отладчик остановиться на виновной строке

Быстрейший способ найти ошибку проверки диапазона — дать IDE-отладчику остановиться на ней и пройти вверх по стеку вызовов к коду, породившему неверное значение. Исключение отмечает, где сработала проверка. Причина обычно сидит на несколько фреймов ниже:

  1. Запустите конфигурацию Debug с включённой проверкой диапазона и убедитесь, что ERangeError не попал в список игнорируемых на странице Language Exceptions настроек отладчика
  2. Когда диалог сообщает «raised exception class ERangeError with message 'Range check error'», выберите Break и откройте окно Call Stack; фреймы под верхним показывают, откуда пришёл индекс или значение
  3. Если верхние фреймы внутри RTL или VCL, включите «Use debug .dcus», чтобы эти фреймы разрешились в исходники
  4. Поставьте условную точку останова на присваивании, вычисляющем индекс (например Index < 0), чтобы поймать ошибку на шаг раньше исключения
  5. Для ошибок, возникающих только на машине клиента, поставляйте сборку с включённой проверкой диапазона и логгером стека вроде madExcept, EurekaLog или JCL Debug, который пишет тот же стек вызовов в лог-файл

Стоит ли выключать проверку диапазона?

Держите проверку диапазона включённой в каждой Debug- и CI-сборке и выключайте только локально, вокруг замеренного горячего цикла, чьи границы вы валидируете сами. Глобальное выключение не убирает баг — оно убирает отчёт о нём. Классический пример — процедура hex-форматирования, вызывающая SetLength(Result, 16) и пишущая символы с 17-го по 24-й: при {$R+} она падает сразу, при {$R-} пишет восемь символов за концом строки при каждом вызове, в какую-то кучную память следом, и падает где-то постороннем гораздо позже. Когда процедура действительно слишком горячая для проверок, ограничьте изменение правильно. {$R-} с последующим {$R+} выглядит аккуратно, но включает проверку до конца юнита, даже если в проекте она была выключена, поэтому сохраняйте и восстанавливайте прежнее состояние и держите непроверяемый регион целой маленькой процедурой, чьи вызывающие обеспечивают границы

{$IFOPT R+}
  {$DEFINE RANGECHECKS_WERE_ON}
  {$R-}
{$ENDIF}
procedure XorBlock(Dst, Src: PByte; Count: Integer);
var
  I: Integer;
begin
  // Вызывающие гарантируют, что Count влезает в оба буфера; проверено разово вне цикла
  for I := 0 to Count - 1 do
    Dst[I] := Dst[I] xor Src[I];
end;
{$IFDEF RANGECHECKS_WERE_ON}
  {$UNDEF RANGECHECKS_WERE_ON}
  {$R+}
{$ENDIF}

Ошибки проверки диапазона в коде разбора PDF

Код, разбирающий PDF-файлы, особенно уязвим, потому что почти каждое число, используемое как индекс, приходит из самого файла. Байтовые смещения в таблице перекрестных ссылок, значения /Length потоков, ширины полей в массиве /W потока перекрестных ссылок (ISO 32000-1 §7.5.8), счётчики объектов и записи /ByteRange в подписях — всё это недоверенные данные в повреждённом или враждебном файле. Наивная проверка вроде if A + B > Length(Data) проваливается против подстроенного /ByteRange [9223372036854775807 1 0 0]: при выключенной проверке переполнения A + B заворачивается в отрицательный Int64, тест проходит, а Data[A] падает позже. Тестируйте каждое значение против оставшейся длины ((A >= 0) and (B >= 0) and (A <= Len) and (B <= Len - A)), чтобы ни одно сложение не могло завернуться. Составные проверки прячут ещё одну ловушку. Delphi вычисляет and слева направо с коротким замыканием, но тест одной верхней границы не отвергает отрицательный индекс, потому что -1 < Length(FPages) — это True; знаковую нижнюю границу нужно проверять раньше верхней

// Выглядит безопасно, но отрицательный PageIndex проходит тест верхней границы:
//   if FDocOpen and (PageIndex < Length(FPages)) and
//      (FPages[PageIndex].PageObj <> nil) then ...

if (PageIndex >= 0) and (PageIndex < Length(FPages)) then
begin
  if FDocOpen and (FPages[PageIndex].PageObj <> nil) then
    Result := FPages[PageIndex].PageObj
  else
    Result := nil;
end
else
  raise ERangeError.CreateFmt(
    'Page index %d is out of range (0..%d)',
    [PageIndex, Length(FPages) - 1]);
Диаграмма потока ошибки проверки диапазона в Delphi в PDF-библиотеке: отрицательный DestIndex обходит одностороннюю проверку границ и возбуждает ERangeError при доступе к массиву, в сравнении с исправленным порядком guard, безопасно срабатывающим на коротком замыкании
При $R+ RTL проверяет каждый индекс во время выполнения, поэтому отрицательный индекс переживает голую проверку верхней границы и срабатывает на самом доступе к массиву. Проверка знаковой нижней границы первой позволяет короткозамкнутому вычислению отвергнуть его до касания любого элемента

Проверка останавливает падение, но не объясняет, почему индекс был неверным, а в коде обработки страниц обычная причина — несовпадение двух порядков. ISO 32000-1 §7.7.3 определяет порядок страниц через массивы Kids дерева страниц; номера объектов и байтовые смещения о нём ничего не говорят, и объект 20 может держать страницу 1, тогда как объект 3 — страницу 2. Код, строящий список страниц сканом объектов в порядке файла, работает на каждом документе, чей генератор записал страницы по порядку, и ломается на любом другом — например, на отредактированном файле с добавленной позже титульной страницей. Индексы, вычисленные вызывающими в порядке отображения, попадают не на ту страницу или за конец списка. Кейс про порядок страниц PDF показывает, как подтвердить несовпадение через qpdf и перестроить список страниц из дерева страниц

Диаграмма Delphi: порядок хранения PDF-объектов, обнаруженный сканированием перекрёстных ссылок, против порядка отображения, восстановленного обходом массива Kids дерева страниц от каталога
Последовательные сканы нумеруют страницы в том порядке, какой выбрал райтер, тогда как зрители следуют массиву Kids дерева страниц. Построение массива страниц из обхода каталога держит индексы вызывающего выровненными и в диапазоне

Чтобы вытрясти такие ошибки из собственного парсера, соберите его с {$R+} и {$Q+} и прогоните по разнообразному корпусу: файлы из Word, LaTeX, прошивок сканеров, утилит разрезания и склейки плюс несколько намеренно обрезанных. Каждый файл, возбуждающий исключение, — точка данных о предположении, которое делает ваш код, поэтому исправьте предположение один раз вместо добавления специального случая под конкретный генератор, который выстрелит не вовремя на следующем источнике. Для кода, вызывающего PDF-библиотеку, считайте количество страниц библиотеки авторитетным. В HotPDF Delphi PDF component LoadFromFile возвращает количество страниц загруженного документа, а LoadedPageCount сообщает его после; индексы страниц следуют порядку отображения дерева страниц, поэтому 0..LoadedPageCount - 1 — безопасный диапазон для любого индекса, выведенного из внешних данных