Műszaki cikk

Delphi vs FPC: 4 rejtett PDF kódcsapda a PDFium build-ekben

Ugyanaz az Object Pascal forrás négyféleképpen is viselkedhet másképp Delphi és FPC/Lazarus alatt, ami ismételten megnehezíti a PDFium Component kódjának dolgát: az FPC az in tagsági teszt befejezése előtt felszabadítja a függvényeredmény rekordok ideiglenes példányait, a dcc32 alapértelmezetten kikapcsolt tartományellenőrzéssel érkezik, így a tömbökön kívüli indexek csendben szemetet olvasnak be, csak a Delphi 13 fogadja el az anonim array of Byte hozzárendelését a TBytes-hoz típuskonverzió nélkül, a Delphi AnsiString összefűzése pedig elpusztíthatja a $80-as értékű vagy a feletti bájtokat egy rejtett kódlap-konverzión keresztül; mindegyik olyan tesztkészletet eredményez, amely az egyik fordítónál zöld, a másiknál pedig piros, vagy még rosszabb: csendben hibás

Ha először állít fel kétfordítós projektet, a Lazarus és FPC nézegető bemutatója leírja az alapvető lépéseket: csomagokat, keresési útvonalakat és a renderelési ablak képernyőre varázsolását; ez a cikk a bemutató ellentéte; ez azon dolgok listája, amelyekkel a sikeres indulás után találkoztunk, amikor a CI zöld volt FPC alatt, zöld Delphi alatt, majd az egyik oldalon átmenő változtatás a másikon felrobbant; minden alábbi csapda a PDFiumPas tesztkészletének vagy bemutatóinak valós hibájából származik, a végrehajtott javítások pedig minimális reprodukcióra, a gyökeres okra és az általunk szabványosított javításra korlátozódnak

Miért olvasódik be a halmaz üresként FPC alatt, de Delphi-ben nem?

Az egymondatos verzió: az FPC véglegesítheti a függvény rekord-eredményét tároló ideiglenes változót még azelőtt, hogy az eredmény mezőjét olvasó kifejezés befejeződne, így a X in Func().Issues tagságvizsgálatot futtathat egy már felszabadított halmazzal szemben, miközben a megfelelő Delphi kifejezés működik; a PDF/E megfelelőségi tesztjeink az első verziónál futottak bele ebbe; a validátor egy olyan rekordot ad vissza, amelynek Issues mezője szabálysértési jelzők halmaza, az állítások (assertions) pedig beágyazták a hívást

// Nem megbízható FPC alatt: a függvényeredmény rekord ideiglenes példánya
// felszabadulhat azelőtt, hogy az 'in' teszt beolvasná az Issues-t
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);

// Mindkét fordítónál megbízható: rögzítse az eredményt először egy helyi változóba
var
  Vr: TPdfEValidationResult;
begin
  Vr := ValidateAnsi(Pdf);
  AssertTrue(pveiLzwUsed in Vr.Issues);
end;

A beágyazott forma üresként olvasta a halmazt FPC alatt, így minden olyan állítás, amely jelzőt várt, meghiúsult, miközben a teljesen azonos Delphi build sikeresen lefutott; a probléma gyökere a két fordító közötti különbség a függvényeredmény ideiglenes példányainak élettartam-kezelésében nagyobb kifejezéseken belül: a Delphi a kifejezés végéig életben tartja az ideiglenest, míg az FPC rekord-ideiglenesének eltávolítása megelőzheti a halmaztagsági operátort, amely még mindig azt olvassa; ugyanezt a viselkedést már dokumentáltuk egyszer korábban a PDF/A tesztegység FlagPresent segédfüggvényének megjegyzésében, majd a semmiből írt új tesztek írásakor mégis újra bevezettük a hibát, ami megmutatja, mennyire természetesnek tűnik a hibás forma; a javítás mechanikus, és érdemes általános szabályként elfogadni: soha ne láncoljon mező-hozzáférést vagy halmaztesztet közvetlenül a rekordot visszaadó függvényhívásra; először rendelje hozzá az eredményt egy helyi változóhoz, majd olvassa be a mezőt; ez egyetlen sorba kerül, és megszünteti a fordítófüggő bizonytalanságok egész osztályát

Miért fogad el a Delphi olyan tömbindexet, amelyet az FPC nem hajlandó lefordítani?

Az egymondatos verzió: a dcc32 lefordítja a fix határokú tömbön kívüli indexet, és az alapértelmezetten kikapcsolt tartományellenőrzésével futásidőben hiba nélkül olvassa vagy írja a szomszédos memóriát, míg az FPC ugyanezt az indexet fordítási időben elutasítja; a PDFium Component a négyszögpontokat 1-es alapú tömbként deklarálja, TQuadrilateralPoint = array [1..4] of TPdfPoint, ami megfelel a PDF QuadPoints bejegyzések szokásos számozásának; a Delphi alatt hónapokig működött egy bemutató, amely a megszokott 0-s alapú ciklussal töltötte fel

var
  I: Integer;
begin
  for I := 0 to 3 do                       // hibás: a tömb [1..4] határok között van
    Data.AttachmentPoints[I] := Corner[I]; // dcc32 alapértelmezés: lefordul, a 0-s index
                                           // csendben érinti a szomszédos memóriát
                                           // FPC: fordítási idejű tartományellenőrzési hiba
  for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
    Data.AttachmentPoints[I] := Corner[I - 1];  // mindkét fordítónál helyes
end;

A Delphi build hamis pozitív volt: a dcc32 alapértelmezett kikapcsolt tartományellenőrzése miatt a 0 index a rekordban a tömböt megelőző bármely mezőre esett, a bemutató pedig futni látszott; ugyanezen bemutató Lazarus-ra történő portolása azonnali fordítási idejű tartományellenőrzési hibát eredményezett az FPC-től, az index javítása pedig feltárta a könyvtár megjegyzés-útvonalának második, mélyebb hibáját, amelyet a szemét-olvasás eltakart, azt, amelyet a négyszögpontos megjegyzésekről szóló cikkben elemeztünk; két tanulság származott az esetből; először is, részesítse előnyben a Low() és High() függvényeket a szó szerinti határokkal szemben, ha a tömbtípus nem 0-s alapú felépítésű; másodszor, kezelje az FPC fordítást, vagy legalább egy engedélyezett {$R+} kapcsolóval ellátott Delphi buildet kötelező első futtatási kapuként minden új bemutató vagy teszt esetében: a dcc32 alapértelmezései nem fognak tájékoztatni az ilyen típusú hibákról, a futó program pedig nem bizonyíték a helyességre

A TBytes hozzárendelés, amelyet csak a Delphi 13 fogad el

Az egymondatos verzió: az anonim array of Byte-ként deklarált mező hozzárendelése egy TBytes változóhoz lefordul Delphi 13-on (37.0-s fordítóverzió), de elbukik Delphi 12 Athens-en és minden korábbi verzión E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array' hibával; ez nem annyira a Delphi-FPC ellentét, mint inkább a Delphi önmagával való szembenállása a múltból, de ugyanúgy sújtja ugyanazt a többfordítós kódbázist: a legújabb fordító csendben elfogad egy olyan konstrukciót, amelyet minden más elutasít

type
  TValidator = class
  private
    FBuffer: array of Byte;   // anonim dinamikus tömb típus
  end;

var
  OrigBytes: TBytes;
begin
  OrigBytes := FBuffer;          // Csak Delphi 13-on; E2010 Delphi 12
                                 // Athens és korábbi verziókon
  OrigBytes := TBytes(FBuffer);  // mindenhol lefordul; azonos bájt-elrendezés,
                                 // biztonságos hard cast
end;

Pontosan ezt adtuk ki egy ellenőrző rutinban, amelyet helyben fejlesztettünk és teszteltünk Delphi 13-on, ahol az implicit konverziót csendben elfogadta; a teljes forráskódú telepítő nagy számban szolgálja ki a Delphi 12-t és régebbi verziókat használókat, náluk pedig az egység egyszerűen nem fordult le; a szerkezeti javítás vagy a fent bemutatott hard cast — ami biztonságos, mivel az anonim array of Byte és a TBytes azonos dinamikus tömb elrendezéssel rendelkezik —, vagy még jobb, ha a mezőt eleve egy nevesített típusként, például TBytes-ként deklaráljuk, így soha nem merül fel konverzió; a folyamat javítása még fontosabb: a legújabb eszközláncon leforduló konstrukció semmit sem bizonyít a felhasználói által ténylegesen futtatott régebbi fordítókról, és a regressziók ezen kiterjedése láthatatlan marad, amíg nem fordít az összes támogatott verzióval szemben; a kiadási szkriptjeink most már a teljes fordítómátrixon fordítják a könyvtárat, éppen azért, mert a helyi 37.0-s build nem tudja elkapni a 13-as verzió kizárólagos engedékenységét

A kínai Windows gépen eltűnő AnsiString bájt

Az egymondatos verzió: a $80 értékű vagy a feletti nyers bájt AnsiString-be történő összefűzése a + operátorral csendben helyettesítheti ezt a bájtot ? ($3F) jellel Delphi alatt, mert a kifejezés implicit módon egy AnsiString -> UnicodeString -> AnsiString átalakítást hajt végre a rendszer kódlapján keresztül; ezt egy olyan PDF/A teszten keresztül fedeztük fel, amely egy izolált $FE bájtot tartalmazó nevet épít fel — ami soha nem érvényes UTF-8 kezdőbájt —, hogy ellenőrizze, a validátor megjelöli-e azokat a neveket, amelyek nem érvényes UTF-8-ak az ISO 19005-2 6.1.8-as szakasza szerint

var
  BadName: AnsiString;
begin
  // Delphi-ben több-bájtos rendszerkódlappal (megfigyelve CP936-on),
  // az összefűzés a UnicodeString-en keresztül fut le, a $FE pedig, ami
  // nem érvényes CP936 sorozat, '?' ($3F) jellel jön vissza
  BadName := '/Bad' + AnsiChar($FE) + 'Name';

  // Biztonságos: építse fel ASCII helyőrzővel, majd javítsa a bájtot a helyén;
  // a lezárt AnsiString indexelt hozzárendelése nem indít el konverziót
  BadName := '/Bad' + #1 + 'Name';
  BadName[5] := AnsiChar($FE);
end;

A 936-os kódlapot futtató kínai Windows rendszeren az összefűzött karakterlánc egyáltalán nem tartalmazta a $FE bájtot, így a könyvtár helyesen semmit sem jelentett, a teszt pedig elbukott, miközben könyvtárhibának tűnt; a könyvtár soha nem volt hibás: az FPC-s tesztkörnyezet, amely valóban $FE bájtot tartalmazó PDF-et táplált be, megkapta a várt jelzést; a sérülés a Delphi teszt futtatható állományában történt a karakterlánc-kifejezés kiértékelése során, mivel a Delphi Unicode-első karakterlánc-modellje a vegyes AnsiString kifejezéseket a UnicodeString-en keresztül alakítja át, a $FE pedig nem érvényes kezdőbájt a CP936-ban, így a konverzió lecseréli azt; legyünk őszinték a határokkal kapcsolatban: egy egybájtos nyugati kódlapon, például a CP1252-n ugyanaz a kifejezés általában túléli, éppen ezért rejtőzik el ez a hiba a legtöbb fejlesztőgépen, és csak a kelet-ázsiai rendszereken vagy a lokalizált CI futtatókon jelentkezik; a szabály, amit elfogadtunk: soha ne építsen $80-as értékű vagy a feletti bájtokat tartalmazó bináris tesztvektorokat AnsiString összefűzéssel; vagy javítsa a bájtokat a helyükön a karakterlánc lezárása után, a fentiek szerint, vagy eleve építse fel a vektort TBytes-ban

Mit kell ellenőriznie egy kétfordítós munkafolyamatnak alapértelmezés szerint?

Négy csapda, egy minta: minden fordító a hibái más-más részhalmazáról tájékoztat; az FPC fordítási idejű tartományelemzése elcsípett egy határokon kívüli indexet, amelyet a dcc32 hónapokig csendben futtatott, a dcc32 Unicode karakterlánc-modellje pedig feltárt egy kódlap-függőséget, amelyet a tiszta bájt-orientált FPC build soha nem váltott ki; a gyakorlati következmény az, hogy egyik zöld csővezeték sem elegendő önmagában; a keresztfordítás nem csupán hordozhatósági pipa, hanem egy második statikus elemző és egy második futásidejű modell, amelyet ugyanarra a forrásra alkalmaznak, ugyanabban a szellemben, mint a védelmi határellenőrzésekről szóló cikkben

Az ezekből az esetekből adódó állandó szabályok elég rövidek ahhoz, hogy megjegyezzük őket; rögzítse a függvényeredmény rekordokat egy helyi változóba a mezők olvasása előtt; iterálja a fix határokú tömböket a Low() és High() segítségével, és legalább egy tartományellenőrzött vagy FPC buildet futtasson le, mielőtt megbízna bármely új bemutatóban; konvertálja az anonim dinamikus tömbmezőket kifejezetten, vagy deklarálja őket nevesített típusokkal, és a kiadás előtt építse fel a teljes fordítómátrixot; teljesen zárja ki a nyers magas bájtokat az AnsiString összefűzéséből; ezek egyike sem igényel mérhető erőfeszítést, amint szokássá válik, és mindegyik lezár egy olyan hibaállapotot, amelyet az egyfordítós munkafolyamat szerkezetileg nem láthat

Mind a négy problémát megtaláltuk és javítottuk a PDFium Component karbantartása során, amely ugyanazt az Object Pascal forrást szállítja Delphi, C++Builder és FPC/Lazarus rendszerekhez, és futtatja megfelelőségi és regressziós tesztkészleteit mindegyik eszközláncon, így az ebben a cikkben szereplő csapdákat tesztek védik, nem pedig az emlékezet