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

Delphi-код, що працює випадково: п'ять багів портування FPC

PDF Library for Delphi знайшла п'ять дефектів декодерів, коли переносила свій код CCITT, TIFF, PNG, Flate та буфера потоків на Free Pascal, і кожен із них роками проходив повний набір тестів Delphi. Жоден не був багом компілятора. Усі п'ять були Pascal-кодом, який Delphi виконував правильно лише завдяки деталі реалізації: прихований параметр результату, що аліасив масив виклику, гілка поза діапазоном, повз яку ніхто ніколи не читав далі, буфер нульової довжини, єдиним захистом якого був перемикач перевірки діапазону, 1-based зсув, який лише один шлях коду передавав як 1, і контракт TStream.Read, який in-memory потоки ніколи не перевіряють. Змініть компілятор або дайте тому самому коду пошкоджений файл — і випадковість перестає триматися

Далі — конкретна форма кожного з них, виправлення та дисципліна, що з цього виросла: той самий код тепер має давати ту саму семантику документа на обох компіляторах, і test include це перевіряє. Сусідня стаття про зміцнення парсера PDF на Pascal проти зловмисних файлів розглядала ширину цілих, глибину рекурсії та неініціалізовані буфери. Ця — про інший клас збоїв: код, який був неправильним від початку, а компілятор тихо його прикривав

Чому функція, що повертає динамічний масив, працює на Delphi без SetLength?

Бо Delphi передає власну змінну виклику як прихований параметр результату, тож функція, яка жодного разу не виділяє пам'ять під свій результат, усе одно може писати в масив, який виділив виклик. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray — це пошук по reference line у самому серці двовимірного декодування Group 3 і Group 4: за поточною позицією a0 і кольором поточного серію він шукає changing elements попереднього рядка, ті 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 каже, що функція, результат якої — long string, динамічний масив або інший керований тип, отримує цей результат як додатковий параметр var, і на практиці компілятор передає адресу цілі присвоєння. Тож Result усередині функції — це сам b, уже довжиною в два елементи, і кожен запис лягає в пам'ять, якою володіє виклик. Free Pascal віддає функції свіжий nil-масив і вже потім присвоює його b — і саме так слід було читати контракт, під який цей код мав бути написаний від початку

Розбіжність декодування CCITT у PDFlibPas: Delphi передає масив виклику b як прихований var Result у GetNextChangingElement, тож записи лягають у пам'ять, якою володіє виклик, а промах пошуку зберігає попередні значення, тоді як Free Pascal віддає функції свіжий nil-масив, який перед першим записом має розмірити перевірка Length через SetLength
Delphi аліасить масив виклику як прихований параметр Result, тож незахищені записи все одно лягають у власну пам'ять, а Free Pascal приходить із nil, і однорядковий захист перетворює збій на заплановану поведінку, не змінюючи шлях декодування на Delphi

Аліасинг ніс і семантику, на яку декодер покладається. Result[0] присвоюється лише тоді, коли скан знаходить елемент, більший за a0, а Result[1] — лише коли після нього є ще один елемент, тож при промаху слоти зберігають те, що попередня ітерація залишила в b. Очевидне виправлення — виділити два слоти й обнуляти їх на кожному виклику — знищило б це перенесення й змінило б декодований вивід на Delphi. Те, що пішло в реліз, — це захист замість скидання: на Delphi він є мертвим кодом, і шлях декодування лишається байт у байт тим самим, а на Free Pascal перетворює збій на заплановану поведінку. Саме в цій асиметрії вся суть, бо виправлення мусило бути no-op на тому компіляторі, де код уже давав перевірений результат

Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
  IsWhite: Boolean): TCCITTIntegerArray;
Begin
  // Delphi приходить сюди з двоелементним масивом виклику, аліасеним
  // як Result, тож тут це no-op. FPC приходить із nil.
  If (Length(Result) < 2) Then
    SetLength(Result, 2);
  ...
  // Result[0] / Result[1] усе ще пишуться лише при влучанні, тож промах
  // зберігає значення попередньої ітерації точно як раніше
End;

Лічильник, що пережив свої дані: елемент каталогу TIFF

Коли ви знецінюєте масив, лічильник треба знецінити тим самим оператором — інакше лічильнику повірить код, який самого масиву вже не бачить. Елемент каталогу зображення TIFF (TIFF 6.0 §2, 12-байтний layout із tag, type, count і value-or-offset) несе 32-бітний лічильник прямо з файлу, і PDF Library for Delphi читає кожен із них через PopDE: TTIFFEntry — record із Tag, TagType, Length, Offset та декодованими масивами IntegerValues і DoubleValues. Оригінальний код перевіряв, чи Offset + TypeSize * Length виходить за кінець файлу, і якщо так — виставляв обом масивам нульову довжину. А Result.Length лишався зі значенням із файлу

Далі все пішло не так із двох причин. Функція завершується резервною гілкою: «якщо Length дорівнює нулю, дай елементу одне нульове значення», щоб виклики завжди могли прочитати нульовий елемент. Але оскільки Length на шляху поза діапазоном ніколи не очищався, ця гілка не спрацьовувала саме в тому єдиному випадку, для якого існувала. І виклики справді читають нульовий елемент, безумовно: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip і ще десяток місць беруть E.IntegerValues[0], а таблиці strip роблять Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), копіюючи Length разів по чотири байти з масиву, у якому немає нічого. Очищений масив із живим лічильником строго небезпечніший за неперевірений, бо неперевірений принаймні містить ті байти, на які заявляє

Друга проблема — порядок. Два виклики SetLength виконувалися до перевірки діапазону й розмір брали з лічильника у файлі, тож ворожий елемент міг замовити виділення пам'яті на кілька гігабайт ще до першої перевірки валідності. На Delphi виняток, що виникав, ловився обробником вище в шляху завантаження зображення, і файл просто не завантажувався — саме тому ніхто не помітив; насправді ж відбувалася подія out-of-memory, яку обрав сам файл. Виправлення переносить виділення після перевірки й змушує лічильник подорожувати разом із даними

Зміцнення елемента каталогу TIFF у PDFlibPas: 12-байтний елемент несе лічильник із файлу, зламаний порядок виділяв масиви за цим лічильником до перевірки діапазону й лишав Result.Length живим після їх очищення, а виправлений порядок спершу перевіряє арифметику Int64 проти довжини файлу, тож лічильник очищається разом із масивами
Виділення до перевірки діапазону дозволяло ворожому лічильнику замовити гігабайти й лишало живий лічильник на спорожненому масиві, тож виправлення спершу перевіряє зсув і очищає Result.Length тим самим оператором, що й масиви
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 робить тут за мене те, чого я не роблю сам»

Що буде, коли PNG IHDR заявляє тип кольору, якого формат не визначає?

Тепер PDF Library for Delphi відкидає зображення до того, як запускаються row-фільтри; до v3.539.2 вона обчислювала scanline нульової довжини й віддавала циклам unfilter порожній буфер. ISO 15948 §11.2.2 визначає чанк IHDR, а Table 11.1 перелічує шість легальних комбінацій типу кольору та бітової глибини: grayscale на 1, 2, 4, 8 або 16 бітах, indexed color на 1, 2, 4 або 8, і truecolor, grayscale з alpha та truecolor з alpha на 8 або 16. TPNGReader перевіряв поля compression method і filter method у IHDR, а FColorType і бітову глибину пропускав недоторканими

Код row-фільтра розмірює все з Case FColorType Of, який зіставляє кожному типу кольору кількість компонентів. Тип кольору поза цими шістьма потрапляє в гілку Else, де SourceComponents дорівнює 0, отже ScanlineByteCount дорівнює 0, отже за SetLength(PreviousScanline, 0) одразу йде FillChar(PreviousScanline[0], ScanlineByteCount, 0). Індексація нульового елемента порожнього динамічного масиву — це адреса, обчислена від nil. Із вимкненою перевіркою діапазону нульове заповнення за цією адресою — тихий no-op, і декодер крокує далі крізь рядки, яких не існує; з увімкненою — це ERangeError на першому ж зображенні; а Move, що йдуть за цим, за крок від access violation. Що саме ви отримаєте, залежить від компілятора й від build-перемикачів, а не від рішення декодера, і це і є ознака того, що декодер нічого не вирішував узагалі

Виправлення — це таблиця зі специфікації, застосована там, де решту полів 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] у рядку, який короткий чанк залишив порожнім

1-based зсув, з яким повелися як з 0-based вказівником

InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString приймає StartPos як 1-based, бо її вхід — це AnsiString, і реалізація на Delphi адресує вхід zlib як @Input[StartPos]. Реалізація на Free Pascal, написана під paszlib, щоб обидві цілі Windows лінкували компресію статично, виставляла next_in у PAnsiChar(Input) + StartPos, а avail_in — у Length(Input) - StartPos. Це арифметика вказівників, і вона 0-based. Передайте 1 — а саме це й означає «почати з початку» для цієї функції — і збірка FPC почне розпаковувати з другого байта й зупиниться за байт до кінця

Вижило воно тому, що єдиний виклик, до якого доходить більшість тестів, — це InflateStr, а він передає 0. Нуль випадково і є правильним 0-based зсувом, тож обидві збірки сходилися на кожному звичайному виклику InflateStr і на кожному тесті, що через нього проходив. TPDFDocument.DecodeAllStreams — процедура, яку SaveQDFToFile і ConvertFileToQDF використовують, щоб розгорнути потоки з єдиним FlateDecode у читабельну форму, — передає 1. На збірці FPC пропущений заголовок zlib змушував inflate впасти, але потік zlib усе одно повертав ненульовий Consumed за переглянуті байти, тож DecodeAllStreams сприймав порожнє навантаження як успішне декодування й замінював кожен content stream порожнім рядком. Отриманий QDF мав правильну кількість сторінок, валідну структуру й жодного вмісту сторінок — це файл, який відкривається без помилки в будь-якому переглядачі й не показує нічого

// Гілка FPC у InflateStrFromPosition, після v3.539.16.
// StartPos такий самий 1-based, як у гілці Delphi; обмежте його, а потім
// переведіть у 0-based зсув вказівника рівно один раз, на межі.
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. Для трьох in-memory джерел це правильно, бо ReadIntoBuffer завжди віддає повний блок, крім останнього. Для джерела-потоку це означає, що скан здається на першому ж короткому читанні, повідомляє, що роздільника немає, і токенізатор вище вирішує, що об'єкт закінчується там, де він не закінчується

Обробка коротких читань у буфері потоків PDFlibPas: DistanceToByte сканує блоки по 64 КБ, старий цикл вважав Until ReadCount < BlockSize кінцем даних і здавався на першому короткому читанні, а виправлений цикл іде доти, доки ReadCount не дорівнює нулю, знаходить роздільник і відновлює позицію у блоці finally
Потік може повернути два байти на запит у шістдесят чотири тисячі, тож нуль — єдиний сигнал кінця даних, якому скан може довіряти, а блок finally відновлює логічну позицію, коли роздільник знайдено і цикл виходить достроково
// 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, чий override Read обмежує кожен запит двома байтами. Загорніть у нього рядок aaaaaX, виставте позицію буфера в 1 — і всі чотири запити мусять повідомити відстань 4 до X, залишити позицію на 1 після цього й повернути -1 для байта, якого там немає. До виправлення перший запит бачив два байти, робив висновок, що потік вичерпано, і повертав -1. finally тут важить не менше за умову циклу: Exit зсередини скану — це звичайний шлях успіху, і логічну позицію треба відновлювати й на ньому, а не лише коли цикл доходить до кінця

Один код, два компілятори, один набір перевірок

Дисципліна, що виросла з цих п'яти випадків, така: «збірка Delphi проходить» — це свідчення про Delphi, а не про код. Від v3.539.16 і набір DUnitX для Delphi, і консольний набір Free Pascal включають той самий Tests\CrossCompilerSemantics.inc — одну процедуру RunCrossCompilerFileSemantics, яка будує документ на дві сторінки зі стиснутим вмістом через TPDFlib, зберігає його, зберігає його ще раз як QDF через SaveQDFToFile, ремонтує QDF через RepairQDFFile, шифрує звичайний файл AES-128 через EncryptFile і маску дозволів із EncodePermissions, а потім перезавантажує кожен артефакт і перевіряє одне й те саме на обох компіляторах: кількість сторінок — 2, заголовок зберігається, текст другої сторінки витягується неушкодженим зі звичайного, відремонтованого й зашифрованого файлів, неправильний пароль відкидається з ненульовим LastErrorCode, EncryptionStrength дорівнює 128, EncryptionAlgorithm — 2, а окремі біти дозволів із GetUserPermissions повертаються точно такими, як були закодовані

Порівняння навмисно нормалізоване, а не побайтове. Шифрування бере випадкові salt-значення, а записувач призначає ідентифікатори документа, тож від двох збірок не чекають однакових файлів — від них чекають файлів, що означають одне й те саме, і перевірки сформульовані саме на цьому рівні. Гілка QDF тут саме через баг зі зсувом: QDF на дві сторінки без вмісту проходить перевірку кількості сторінок і падає на перевірці витягування тексту, і матриця перевіряє друге. Будь-яке майбутнє виправлення, яке є no-op на одному компіляторі й зміною поведінки на іншому — а це опис чотирьох із п'яти вище, — тепер мусить пройти ті самі перевірки двічі, перш ніж потрапити в реліз

Лінкувальна половина того самого порту — узгодити OMF-об'єкти Delphi з очікуваннями COFF у Free Pascal — має власну історію в лінкуванні статичних об'єктів FPC Win32 з OMF у COFF, а структурне зміцнення того самого читача TIFF проти BigTIFF і тайлових файлів — у нотатках про вбудований декодер TIFF. Декодери з цієї статті та кроскомпіляторний тест, що тепер лежить під ними, постачаються в PDF Library for Delphi для Delphi, C++Builder і Free Pascal, де той самий код має заслужити той самий результат на кожному компіляторі, під який його зібрано, а не отримати його в подарунок від одного