Articol tehnic

Delphi vs FPC: 4 capcane ascunse în codul PDF din build-urile PDFium

Același cod sursă Object Pascal se poate comporta diferit în Delphi și FPC/Lazarus în patru moduri care afectează frecvent codul componentei PDFium: FPC elimină înregistrările temporare rezultate din funcții înainte ca un test de apartenență in să le citească complet, dcc32 este livrat cu verificarea limitelor dezactivată, astfel încât indicii de tablou în afara limitelor citesc date eronate în mod silențios, doar Delphi 13 acceptă atribuirea unui tablou anonim array of Byte la TBytes fără conversie, iar concatenarea AnsiString din Delphi poate distruge octeți egali sau mai mari de $80 printr-o conversie implicită a paginii de cod. Fiecare dintre acestea generează un set de rezultate corect pe un compilator și greșit, sau mai rău, eronat în mod silențios, pe celălalt

Dacă configurați un proiect cu două compilatoare pentru prima dată, ghidul vizualizatorului Lazarus și FPC acoperă calea optimă: pachete, căi de căutare și afișarea unei ferestre de randare. Acest articol este opusul unui tutorial. Reprezintă lista problemelor pe care le-am întâlnit după ce calea optimă a funcționat, când testarea CI a fost corectă sub FPC, corectă sub Delphi, iar apoi o modificare ce a trecut pe o parte a eșuat pe cealaltă. Fiecare capcană de mai jos provine dintr-un eșec real în setul de teste PDFiumPas sau din demo-urile sale, analizele la nivel de commit fiind sintetizate într-o reproducere minimă, cauza principală și remedierea standardizată

De ce un set este citit ca fiind gol sub FPC, dar nu și în Delphi?

Pe scurt: FPC poate finaliza variabila temporară care stochează rezultatul de tip înregistrare al unei funcții înainte ca o expresie care citește un câmp al acelui rezultat să se fi încheiat, astfel încât testul X in Func().Issues poate verifica apartenența în raport cu un set deja eliberat, în timp ce expresia echivalentă din Delphi funcționează. Testele noastre de conformitate PDF/E au întâlnit această problemă in prima lor versiune. Validatorul returnează o înregistrare a cărei proprietate Issues este un set de marcaje de încălcare, iar instrucțiunile assert conțineau apelul direct în linie (inlined)

Diagramă arătând că Delphi menține temporar în viață o înregistrare de rezultat de funcție PDFium până se termină instrucțiunea, în timp ce FPC o eliberează înainte ca operatorul in să citească mulțimea Issues, astfel încât aserțiunea trece doar sub Delphi
Delphi ține înregistrarea-rezultat de funcție temporară vie până când instrucțiunea se termină, în timp ce FPC o poate elibera înainte ca operatorul in să citească mulțimea Issues
// Nesigur sub FPC: temporarul de record al rezultatului funcției
// poate fi eliberat înainte ca testul 'in' să citească Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);

// Fiabil pe ambii compilatori: fixați mai întâi rezultatul într-un local
var
  Vr: TPdfEValidationResult;
begin
  Vr := ValidateAnsi(Pdf);
  AssertTrue(pveiLzwUsed in Vr.Issues);
end;

Forma directă citea setul ca fiind gol sub FPC, astfel încât fiecare instrucțiune assert care se aștepta la un marcaj a eșuat, în timp ce build-ul identic din Delphi a fost corect. Cauza principală este o diferență în modul în care cele două compilatoare gestionează durata de viață a obiectelor temporare rezultate din funcții în cadrul expresiilor mari: Delphi menține obiectul temporar activ până la sfârșitul instrucțiunii, în timp ce eliminarea de către FPC a înregistrării temporare poate intra în conflict cu operatorul de apartenență la set care încă o citește. Documentasem deja același comportament anterior într-un comentariu la funcția FlagPresent din unitatea de testare PDF/A, dar am reintrodus eroarea scriind teste noi de la zero, ceea ce arată cât de natural pare forma incorectă. Remedierea este simplă și merită adoptată ca regulă generală: nu asociați niciodată un acces la câmp sau un test de set direct unui apel de funcție care returnează o înregistrare; atribuiți mai întâi rezultatul unei variabile locale, apoi citiți câmpul. Acest lucru costă o singură linie și elimină o întreagă categorie de probleme dependente de compilator

De ce acceptă Delphi un index de tablou pe care FPC refuză să îl compileze?

Pe scurt: dcc32 compilează un index în afara limitelor într-un tablou cu dimensiuni fixe și, cu verificarea implicită a limitelor dezactivată, citește sau scrie memoria adiacentă la rulare fără nicio eroare, în timp ce FPC respinge același index la compilare. Componenta PDFium declară punctele patrulatere ca un tablou indexat de la 1, TQuadrilateralPoint = array [1..4] of TPdfPoint, corespunzând modului în care sunt de regulă numerotate intrările QuadPoints în formatul PDF. Un demo care îl completa folosind o buclă cu indexarea de la 0 a funcționat luni de zile în Delphi

Diagramă PDFium Component a unei matrice de quad-points PDF cu bază 1, în care indicele 0 sub dcc32 atinge silențios câmpul adiacent al înregistrării, în timp ce FPC oprește aceeași buclă cu o eroare de verificare de interval la compilare
Cu verificarea de interval dezactivată la dcc32, indexul 0 aterizează discret pe câmpul de înregistrare vecin, în timp ce FPC respinge aceeași buclă la compilare
var
  I: Integer;
begin
  for I := 0 to 3 do                       // greșit: tabloul este [1..4]
    Data.AttachmentPoints[I] := Corner[I]; // dcc32 implicit: compilează, index 0
                                           // atinge în tăcere memoria adiacentă
                                           // FPC: eroare de verificare a intervalului la compilare
  for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
    Data.AttachmentPoints[I] := Corner[I - 1];  // corect pe ambii compilatori
end;

Build-ul din Delphi a fost un fals pozitiv: cu verificarea limitelor oprită, adică starea implicită dcc32, indexul 0 a accesat câmpul care preceda tabloul în înregistrare, iar demo-ul părea să funcționeze. Portarea aceluiași demo în Lazarus a generat imediat o eroare de verificare a limitelor la compilare din partea FPC, iar corectarea indexului a scos la iveală o a doua eroare în calea de adnotare a bibliotecii pe care citirile eronate o mascau, cea analizată în articolul despre adnotarea punctelor patrulatere. Două lecții au rezultat din acest incident. În primul rând, preferați Low() și High() în locul limitelor fixe atunci când tipul tabloului nu este definit cu indexarea de la 0. În al doilea rând, tratați compilarea FPC sau cel puțin un build Delphi cu directiva {$R+} activată ca pe o etapă obligatorie pentru orice demo sau test nou: setările implicite ale dcc32 nu vă vor semnala această categorie de erori, iar rularea programului nu reprezintă o dovadă a corectitudinii sale

Atribuirea TBytes pe care doar Delphi 13 o acceptă

Pe scurt: atribuirea unui câmp declarat ca array of Byte anonim la o variabilă TBytes este compilată în Delphi 13 (versiunea de compilator 37.0), dar eșuează în Delphi 12 Athens și în versiunile anterioare cu eroarea E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Această diferență nu este una între Delphi și FPC, ci una între Delphi și versiunile sale anterioare, dar afectează codul în același mod: noul compilator acceptă în mod silențios o structură pe care restul versiunilor o resping

type
  TValidator = class
  private
    FBuffer: array of Byte;   // tip de tablou dinamic anonim
  end;

var
  OrigBytes: TBytes;
begin
  OrigBytes := FBuffer;          // doar Delphi 13; E2010 pe Delphi 12
                                 // Athens și mai vechi
  OrigBytes := TBytes(FBuffer);  // compilează peste tot; aceeași dispunere de octeți,
                                 // conversie forțată sigură
end;

Am livrat exact această structură într-o rutină de validare, dezvoltată și testată local în Delphi 13, unde conversia implicită a fost acceptată. Însă kitul de instalare cu codul sursă deservește un număr mare de utilizatori de Delphi 12 și versiuni anterioare, iar pentru aceștia unitatea nu a putut fi compilată. Soluția este fie conversia directă prezentată mai sus — sigură deoarece tabloul anonim array of Byte și TBytes partajează aceeași structură de tablou dinamic — fie, de preferat, declararea inițială a câmpului cu un tip numit precum TBytes pentru a evita conversiile. Soluția de proces este mai importantă: o structură compilată pe cel mai nou set de instrumente nu garantează funcționarea pe compilatoarele mai vechi utilizate de utilizatori, iar această categorie de regresie este invizibilă până când nu compilați codul pe toate versiunile acceptate. Scripturile noastre de lansare compilează acum biblioteca pe întreaga matrice de compilatoare, tocmai pentru că un build local 37.0 nu poate depista o permisiune specifică versiunii 13

Octetul AnsiString care dispare pe un sistem Windows în chineză

Pe scurt: concatenarea unui octet brut egal sau mai mare de $80 într-un AnsiString folosind operatorul + poate înlocui în mod silențios acel octet cu ? ($3F) sub Delphi, deoarece expresia trece printr-o conversie implicită AnsiString -> UnicodeString -> AnsiString prin pagina de cod a sistemului. Am descoperit acest lucru prin intermediul unui test PDF/A care construia un nume ce conținea un octet $FE izolat (care nu este un octet de început valid în UTF-8) pentru a verifica dacă validatorul marchează numele care nu sunt UTF-8 valide conform ISO 19005-2 clauza 6.1.8

Diagramă a tur-returului AnsiString către UnicodeString, care înlocuiește un octet brut $FE cu un semn de întrebare pe o mașină Windows chineză CP936, lângă patch-ul de octeți sigur pe loc în Delphi
Dus-întorsul implicit UnicodeString înlocuiește un octet $FE nemapabil cu $3F sub CP936, astfel încât calea sigură petichește octetul la locul lui
var
  BadName: AnsiString;
begin
  // On Delphi cu o pagină de cod de sistem multioctet (observat pe CP936),
  // concatenarea face un ciclu prin UnicodeString și $FE, care
  // nu este o secvență CP936 validă, revine ca '?' ($3F)
  BadName := '/Bad' + AnsiChar($FE) + 'Name';

  // Sigur: construiți cu un placeholder ASCII, apoi corectați octetul pe loc;
  // atribuirea cu index într-un AnsiString stabil nu face un ciclu de conversie
  BadName := '/Bad' + #1 + 'Name';
  BadName[5] := AnsiChar($FE);
end;

Pe un sistem Windows în limba chineză cu pagina de cod 936, șirul concatenat nu a conținut deloc valoarea $FE, astfel încât biblioteca nu a raportat nicio eroare, iar testul a eșuat, părând o eroare de bibliotecă. Biblioteca a funcționat corect: un test FPC care procesa un fișier PDF ce conținea octetul $FE a returnat rezultatul așteptat. Coruperea s-a produs în interiorul fișierului executabil de test din Delphi în timpul evaluării expresiei de șir, deoarece modelul Unicode din Delphi convertește expresiile AnsiString mixte prin UnicodeString, iar $FE nu este un octet de început valid în CP936, așa că procesul de conversie îl înlocuiește. Trebuie menționat că pe o pagină de cod occidentală pe un singur octet precum CP1252 aceeași expresie de regulă funcționează, motiv pentru care această eroare nu apare pe majoritatea mașinilor de dezvoltare și se manifestă doar pe sisteme din Asia de Est sau pe servere CI localizate. Regula pe care am adoptat-o: nu construiți niciodată vectori binari de test care conțin octeți egali sau mai mari de $80 prin concatenare AnsiString; fie modificați octeții direct după definirea șirului (ca mai sus), fie construiți vectorul direct în TBytes

Ce ar trebui să verifice în mod implicit un proces de lucru cu două compilatoare

Patru capcane, un singur model: fiecare compilator semnalează o categorie diferită de erori. Analiza limitelor din FPC la compilare a depistat un index în afara limitelor pe care dcc32 l-a rulat în mod silențios timp de luni de zile, în timp ce modelul Unicode din Delphi a expus o dependență de pagina de cod pe care un build FPC bazat pur pe octeți nu ar fi declanșat-o. Consecința practică este că nicio testare izolată nu este suficientă. Compilarea încrucișată nu este doar un criteriu de portabilitate, ci reprezintă un al doilea analizator static și un al doilea model de rulare aplicate aceleiași surse, în mod similar verificărilor de siguranță descrise în articolul despre securizarea ABI și a memoriei

Regulile care au rezultat din aceste incidente sunt simplu de reținut: atribuiri rezultatele de tip înregistrare ale funcțiilor unei variabile locale înainte de a citi câmpurile; parcurgeți tablourile cu dimensiuni fixe utilizând Low() și High() și rulați cel puțin un build range-checked sau FPC înainte de a valida orice demo nou; convertiți explicit tablourile dinamice anonime sau declarați-le cu tipuri numite și compilați întreaga matrice înainte de lansare; evitați complet octeții bruți mari la concatenarea AnsiString. Niciuna dintre acestea nu implică eforturi suplimentare odată ce devin obiceiuri, iar fiecare elimină un mod de eșec pe care un proces cu un singur compilator nu îl poate detecta prin structura sa

Toate cele patru probleme au fost identificate și remediate în timpul activităților de întreținere a PDFium Component, care livrează același cod sursă Object Pascal pentru Delphi, C++Builder și FPC/Lazarus și își rulează testele de conformitate și regresie pe fiecare dintre aceste medii, astfel încât problemele din acest articol sunt prevenite prin teste, nu prin memorie