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

Delphi проти FPC: 4 приховані пастки PDF-коду у збірках PDFium

Один і той самий вихідний код Object Pascal може поводитися по-різному в Delphi та FPC/Lazarus у чотирьох аспектах, які неодноразово зачіпають код компонента PDFium: FPC видаляє тимчасові записи результатів функцій до завершення перевірки членства in, dcc32 постачається з вимкненою перевіркою діапазонів, через що вихід за межі масиву тихо зчитує сміття, лише Delphi 13 приймає присвоєння анонімного array of Byte до TBytes без приведення типів, а конкатенація AnsiString у Delphi може спотворити байти з кодом $80 або вище через приховану конвертацію в кодову сторінку. Кожен із цих випадків призводить до того, що тести проходять успішно на одному компіляторі і зазнають невдачі, або, що ще гірше, виконуються тихо і неправильно на іншому

Якщо ви вперше налаштовуєте проект під два компілятори, посібник з перегляду для Lazarus та FPC описує ідеальний шлях: пакети, шляхи пошуку та виведення вікна рендерингу на екран. Ця стаття є протилежністю підручника. Це список речей, з якими ми зіткнулися після того, як ідеальний шлях запрацював, коли CI був успішним під FPC, успішним під Delphi, а потім зміна, яка пройшла на одному боці, призвела до вибуху на іншому. Кожна пастка нижче походить від реального збою в тестовому наборі PDFiumPas або його демо-версіях, причому судово-медична експертиза на рівні комітів узагальнена в мінімальне відтворення, першопричину та виправлення, яке ми стандартизували

Чому набір зчитується як порожній під FPC, але не в Delphi?

Версія в одне речення: FPC може завершити життєвий цикл тимчасової змінної, що зберігає результат запису функції, до завершення виразу, який зчитує поле цього результату, тому вираз X in Func().Issues може перевіряти членство щодо вже видаленого набору, тоді як еквівалентний вираз у Delphi працює. Наші тести відповідності PDF/E зіткнулися з цим у своїй першій версії. Валідатор повертає запис, поле Issues якого є набором прапорців порушень, а твердження містили виклик безпосередньо

// Unreliable under FPC: the function-result record temporary
// can be released before the 'in' test reads Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);

// Reliable on both compilers: pin the result to a local first
var
  Vr: TPdfEValidationResult;
begin
  Vr := ValidateAnsi(Pdf);
  AssertTrue(pveiLzwUsed in Vr.Issues);
end;

Вбудована форма зчитувала набір як порожній під FPC, тому кожне твердження, яке очікувало на прапорець, зазнавало невдачі, тоді як ідентична збірка в Delphi проходила успішно. Першопричина полягає в різниці того, як два компілятори керують часом життя тимчасових результатів функцій у великих виразах: Delphi зберігає тимчасову змінну живою до кінця інструкції, тоді як видалення тимчасового запису у FPC може випереджати оператор членства в наборі, який все ще його зчитує. Ми вже документували таку поведінку раніше в коментарі до помічника FlagPresent у тестовому модулі PDF/A, а потім знову допустили цю помилку при написанні нових тестів з нуля, що показує, наскільки природною виглядає зламана форма. Виправлення є механічним і його варто прийняти як загальне правило: ніколи не зв'язуйте доступ до поля або тест набору безпосередньо з викликом функції, яка повертає запис; спочатку присвойте результат локальній змінній, а потім зчитуйте поле. Це коштує одного рядка і усуває цілий клас нестабільності, залежної від компілятора

Чому Delphi приймає індекс масиву, який FPC відмовляється компілювати?

Версія в одне речення: dcc32 компілює вихід за межі індексу у фіксованому масиві і, за умови вимкненої за замовчуванням перевірки діапазонів, зчитує або записує сусідню пам'ять під час виконання без будь-яких помилок, тоді як FPC відхиляє такий індекс на етапі компіляції. Компонент PDFium оголошує чотири точки як 1-індексований масив, TQuadrilateralPoint = array [1..4] of TPdfPoint, відповідно до того, як зазвичай нумеруються записи QuadPoints у PDF. Демо-версія, яка заповнювала його за допомогою стандартного циклу з нуля, працювала місяцями в Delphi

var
  I: Integer;
begin
  for I := 0 to 3 do                       // wrong: the array is [1..4]
    Data.AttachmentPoints[I] := Corner[I]; // dcc32 default: compiles, index 0
                                           // silently touches adjacent memory
                                           // FPC: compile-time range check error
  for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
    Data.AttachmentPoints[I] := Corner[I - 1];  // correct on both compilers
end;

Збірка в Delphi була помилково позитивною: з вимкненою перевіркою діапазонів (що є стандартом для dcc32) індекс 0 потрапляв на поле, яке передує масиву в записі, і демо-версія, здавалося, працювала. Портування тієї ж демо-версії на Lazarus призвело до негайної помилки перевірки діапазону при компіляції від FPC, а виправлення індексу виявило другу, глибшу помилку в шляху анотацій бібліотеки, яку маскували зчитування сміття, описану у статті про анотації з QuadPoints. З цього інциденту винесли два уроки. По-перше, надавайте перевагу методам Low() та High() замість літеральних меж, коли тип масиву не є 0-індексованим за побудовою. По-друге, розглядайте компіляцію у FPC або принаймні збірку в Delphi з увімкненим {$R+} як обов'язковий перший крок тестування: налаштування dcc32 за замовчуванням не повідомлять вам про цей клас помилок, а працююча програма не є свідченням її коректності

Присвоєння TBytes, яке приймає лише Delphi 13

Версія в одне речення: присвоєння поля, оголошеного як анонімний array of Byte, до змінної типу TBytes компілюється в Delphi 13 (версія компілятора 37.0), але зазнає невдачі в Delphi 12 Athens та всіх попередніх випусках із помилкою E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Це не стільки розбіжність між Delphi та FPC, скільки розбіжність між Delphi та її власним минулим, але вона так само впливає на мультикомпіляторну кодову базу: найновіший компілятор тихо приймає конструкцію, яку все інше відхиляє

type
  TValidator = class
  private
    FBuffer: array of Byte;   // anonymous dynamic array type
  end;

var
  OrigBytes: TBytes;
begin
  OrigBytes := FBuffer;          // Delphi 13 only; E2010 on Delphi 12
                                 // Athens and earlier
  OrigBytes := TBytes(FBuffer);  // compiles everywhere; same byte layout,
                                 // safe hard cast
end;

Ми випустили саме це у процедурі валідації, розробленій та протестованій локально на Delphi 13, де неявна конвертація була тихо прийнята. Повнофункціональний інсталятор обслуговує користувачів на Delphi 12 і старіших версіях у великій кількості, і для них модуль просто не компілювався. Структурним виправленням є або жорстке приведення типів, показане вище (що є безпечним, оскільки анонімний array of Byte та TBytes мають однакове розташування динамічного масиву), або, що ще краще, оголошення поля як іменованого типу TBytes із самого початку, щоб конвертація взагалі не була потрібна. Процесне виправлення має більше значення: конструкція, яка компілюється на вашому найновішому інструментарії, нічого не доводить для старіших компіляторів, які насправді використовують ваші користувачі, і цей клас регресій є невидимим, поки ви не зберете проект під кожну підтримувану версію. Наші скрипти випуску тепер компілюють бібліотеку по всій матриці компіляторів саме тому, що локальна збірка 37.0 не може виявити специфічне для версії 13 послаблення

Байт AnsiString, який зникає на китайській системі Windows

Версія в одне речення: конкатенація сирого байта з кодом $80 або вище в AnsiString за допомогою + може тихо замінити цей байт на ? ($3F) в Delphi, оскільки вираз виконує неявну конвертацію AnsiString у UnicodeString і назад в AnsiString через системну кодову сторінку. Ми виявили це через тест PDF/A, який створює ім'я, що містить ізольований байт $FE (який ніколи не є коректним першим байтом UTF-8), щоб перевірити, чи валідатор позначає імена, які не є валідними UTF-8 відповідно до ISO 19005-2, пункт 6.1.8

var
  BadName: AnsiString;
begin
  // On Delphi with a multi-byte system code page (observed on CP936),
  // the concatenation round-trips through UnicodeString and $FE, which
  // is not a valid CP936 sequence, comes back as '?' ($3F)
  BadName := '/Bad' + AnsiChar($FE) + 'Name';

  // Safe: build with an ASCII placeholder, then patch the byte in place;
  // indexed assignment into a settled AnsiString does not round-trip
  BadName := '/Bad' + #1 + 'Name';
  BadName[5] := AnsiChar($FE);
end;

На китайській системі Windows із кодовою сторінкою 936 конкатенований рядок взагалі не містив байта $FE, тому бібліотека правильно повідомляла про відсутність прапорця, а тест ставав червоним, виглядаючи як баг бібліотеки. Бібліотека ніколи не була неправою: тестова система FPC, яка отримувала PDF із реальним байтом $FE, давала очікуваний прапорець. Спотворення відбувалося всередині тестового виконуваного файлу Delphi під час обчислення рядкового виразу, оскільки модель рядків Delphi з пріоритетом Unicode конвертує змішані вирази AnsiString через UnicodeString, а $FE не є коректним початковим байтом у CP936, тому конвертація замінює його. Будьте чесними щодо меж: на однобайтовій західній кодовій сторінці, такій як CP1252, той самий вираз зазвичай виживає, через що цей баг ховається на більшості комп'ютерів розробників і з'являється лише на східноазіатських системах або локалізованих CI-серверах. Правило, яке ми прийняли: ніколи не створювати бінарні тестові вектори, що містять байти $80 або вище, за допомогою конкатенації AnsiString; або замінюйте байти на місці після того, як рядок сформовано (як показано вище), або створюйте вектор у TBytes із самого початку

Що робочий процес із двома компіляторами має перевіряти за замовчуванням

Чотири пастки, один шаблон: кожен компілятор повідомляє вам про різний піднабір ваших помилок. Аналіз діапазонів FPC на етапі компіляції виявив вихід за межі індексу, який dcc32 виконував тихо протягом місяців, а юнікодна модель рядків dcc32 виявила залежність від кодовою сторінки, яку чиста байтова збірка FPC ніколи не запускає. Практичний наслідок полягає в тому, що жоден успішний запуск CI окремо не є достатнім. Крос-компіляція — це не просто галочка переносимості, це другий статичний аналізатор і друга модель виконання, застосовані до одного коду, в тому ж дусі, що й оборонні перевірки меж у статті про безпеку пам'яті та зміцнення ABI

Правила, що виникли в результаті цих інцидентів, досить короткі, щоб їх запам'ятати. Присвоюйте записи результатів функцій локальній змінній перед читанням полів. Перебирайте масиви з фіксованими межами за допомогою Low() та High() і запускайте принаймні одну перевірку діапазонів або збірку FPC перед тим, як довіряти коду. Приводьте анонімні динамічні масиви явно або оголошуйте їх з іменованими типами і збирайте всю матрицю компіляторів перед випуском. Повністю уникайте сирих високих байтів у конкатенації AnsiString. Жодне з цих правил не вимагає значних зусиль, коли стає звичкою, і кожне з них закриває режим відмови, який при роботі з одним компілятором структурно неможливо побачити

Усі чотири проблеми були знайдені та виправлені під час супроводу компонента PDFium Component, який постачає один і той самий код Object Pascal для Delphi, C++Builder та FPC/Lazarus і запускає свої набори тестів відповідності та регресії на кожному з цих інструментів, тому пастки в цій статті захищені тестами, а не пам'яттю