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 — і саме так слід було читати контракт, під який цей код мав бути написаний від початку
Аліасинг ніс і семантику, на яку декодер покладається. 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, яку обрав сам файл. Виправлення переносить виділення після перевірки й змушує лічильник подорожувати разом із даними
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 завжди віддає повний блок, крім останнього. Для джерела-потоку це означає, що скан здається на першому ж короткому читанні, повідомляє, що роздільника немає, і токенізатор вище вирішує, що об'єкт закінчується там, де він не закінчується
// 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, де той самий код має заслужити той самий результат на кожному компіляторі, під який його зібрано, а не отримати його в подарунок від одного