Egy C-könyvtárhoz készített Pascal kötés (binding) úgy olvasható, mint a hétköznapi Pascal. Meghív egy metódust, visszakap egy rekordot, felszabadítja, amit lefoglalt. A gond az, hogy a PDFium egy C és C++ könyvtár a saját hívási konvenciójával, saját egész-szélességeivel, valamint saját szabályaival arra vonatkozóan, hogy ki birtokolja a memóriát és ki szabadítja fel azt. Ebből semmi sem lépi át magától a nyelvhatárt. Minden egyes szerződést kézzel kell megismételni a Pascal deklarációkban, és egyetlen rossz szó a tiszta kinézetű hívást verem-korrupcióvá (stack corruption), csonkolt eltolássá vagy többszörös felszabadítássá (double free) változtatja. A PDFium VCL kötés v1.61.0-s ellenőrzése mindegyik típusból talált egy-egy hibát. Érdemes végigmenni rajtuk, mert nem korlátozódnak erre a kötésre. Ezek a C API-k Delphi vagy Lazarus alatt történő becsomagolásának állandó veszélyei.
A cdecl a függvénytípus része, nem dekoráció
A PDFium lefordított C kód. Win32 rendszeren az exportált függvényei és – ami még fontosabb – az általa meghívott visszahívások (callbacks) a cdecl hívási konvenciót használják. A cdecl szerint a hívó tisztítja meg a vermet (stack), miután a hívás visszatér. A Delphi natív alapértelmezése a register, a visszahívások Win32 C szabványa pedig egyes könyvtárakban az stdcall, ahol a hívott fél tisztít helyette. Amikor egy struktúra átad a PDFium-nak egy függvénymutatót, és Ön elfelejti a cdecl-t a mutató típusánál, a két oldal nem fog egyetérteni abban, hogy ki módosítsa a veremmutatót. Vagy mindkettő megteszi, vagy egyik sem, és a veremmutató minden egyes hívásnál elcsúszik az argumentumok méretével.
Amiért ezt a hibát nehéz megtalálni, az az, hogy a kár nem lokális. A korrupt hívás visszatér és jónak tűnik. Az eltolódás később jelentkezik egy teljesen független függvényben, amelynek kerete (frame) most egy olyan veremmutatón ül, amely néhány bájttal arrébb van, és ez hibás olvasásként, rossz visszatérési címként vagy olyan összeomlásként nyilvánul meg, amelynek veremkövetése (backtrace) egyáltalán nem mutat arra a visszahívásra, amelyet valójában elrontott. Az űrlapkitöltés (form-fill) a klasszikus hely, ahol ez lecsap, mert az űrlapkitöltő felület tele van olyan visszahívásokkal, amelyeket a PDFium hív vissza. Az egyik ilyen, a FFI_OpenFile, átad a PDFium-nak egy függvényt, amelyet a külső fájl megnyitásához fog meghívni, így deklarálva: function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. A záró cdecl az a pont, amit érdemes lemásolni. Drop it and the code still compiles, still links, and still runs right up until PDFium calls the function. A konvenció magához a függvénytípushoz tartozik. Ez nem opcionális díszítés, és a fordító sem fog figyelmeztetni a hiányára, mert egy egyszerű függvénytípus teljesen legális Pascal típus. Az egyetlen védelem az, ha a hívási konvenciót minden importált szignatúra és kifelé átadott visszahívás kötelező mezőjeként kezeljük.
A size_t mutató-szélességű, és FPC Win64 esetén ez 64 bitet jelent
A második hiba egy egész-szélesség eltolódás, amely csak egyetlen célplatformon jelentkezik. A C-féle size_t úgy van meghatározva, hogy elég széles legyen bármely objektum méretének megtartásához, ami egy 64 bites platformon 64 bites előjel nélküli egészet jelent. A PDFium progresszív betöltési felületei size_t bájteltolásokban beszélnek. A rendelkezésre állást biztosító FX_FILEAVAIL rekord hordoz egy IsDataAvail visszahívást, amelyet a PDFium egy eltolással és egy mérettel hív meg, és az FX_DOWNLOADHINTS rekord AddSegment visszahívása ugyanezt kapja. Mindkét paraméter size_t.
IsDataAvail = function(
pThis : PFX_FILEAVAIL;
offset, size: size_t): FPDF_BOOL; cdecl;
AddSegment = procedure(
pThis : PFX_DOWNLOADHINTS;
offset, size: size_t); cdecl;
Ha ezeket az eltolásokat 32 bites típusnak deklarálja, a kötés működik Win32-en és Delphi Win64-en, majd csendben elromlik FPC és Lazarus Win64 alatt. Az ok szubtilis. FPC Win64-en a NativeUInt egy valódi mutató-szélességű 64 bites típus, és a size_t ennek az álneve (alias). A kötésnek van egy megjegyzése a típus-szekcióban, amely kifejezetten óva int a NativeUInt árnyékolásától FPC-n, mert ott egy 32 bites álnévre való átdefiniálás 32 bitre kényszerítené a size_t-t, és megrontana minden size_t paramétert, amelyet átadnak a könyvtárnak vagy amelyet az kiír. Egy 32 bites paraméterhez érkező 64 bites eltolás elveszíti a felső felét. Egy kis fájl esetén minden eltolás belefér 32 bitbe, és semmi gond sincs. Egy nagy fájl esetén abban a pillanatban, amikor az eltolás átlépi a négy gigabájtos határt, a csonkolt érték teljesen máshova mutat, a PDFium azt kérdezi, hogy a rossz bájttartomány elérhető-e, és a progresszív betöltés elakad vagy szemetet olvas. A hiba láthatatlan, amíg a fájl nem elég nagy, és a célpont nem az a platform, ahol a size_t ténylegesen kiszélesedett.
Egy Pascal kivétel soha nem tekeredhet le egy C kereten keresztül
A harmadik osztály a kivétel-modellről (exception model) szól, amellyel a C nem rendelkezik. Amikor a PDFium meghívja az egyik visszahívását, az Ön Pascal kódja olyan C és C++ keretek (frames) vermében fut, amelyek semmit sem tudnak a Delphi kivételkezelő mechanizmusáról. Ha a visszahívása kivételt vált ki, és hagyja azt továbbterjedni, akkor olyan kereteken keresztül tekeredik le (unwinds), amelyeket soha nem úgy építettek fel, hogy letekercselhetők legyenek. A PDFium saját takarítása nem fut le, a belső invariánsai félig frissítve maradnak, és a folyamat most olyan állapotba kerül, amelyre a könyvtár soha nem számított. Ezen visszahívások szerződése egy visszatérési kód, nem pedig egy kivétel.
Két visszahívás teszi ezt konkréttá. A FPDF_FILEWRITE az a nyelő (sink), amelybe a PDFium a mentett dokumentumot írja, a FPDF_FILEACCESS pedig az a forrás, amelyből a bemeneti dokumentumot olvassa. Mindkettő egy Delphi TStream felett van megvalósítva, és mindkettő meghiúsulhat úgy, ahogy bármely adatfolyam: megtelik a lemez, az adatfolyam bezárul Ön alatt, vagy az olvasás túlnyúlik a végén. Az írási visszahívás becsomagolja az adatfolyam-írást, és minden hibát a PDFium hibakódjává alakít, ahelyett, hogy hagyná azt kiszökni.
function WriteBlock(
pThis: PFPDF_FILEWRITE;
pData: Pointer;
Size : LongWord): Integer; cdecl;
begin
// PDFium treats any non-1 return as a write failure. A Pascal exception
// must not unwind through this cdecl/C++ frame, so trap it and report
// failure instead.
Result := 0;
try
PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
Result := 1;
except
end;
end;
Az olvasási oldal ugyanezt teszi: a meghiúsult olvasás nullát jelent, hogy megfeleljen az FPDF_FILEACCESS szerződésnek, ahelyett, hogy kivételt váltana ki a határon keresztül. Egy üres except blokk továbbdobás (re-raise) nélkül helytelennek tűnik egy olyan Pascal programozó számára, akit arra tanítottak, hogy soha ne nyelje el a kivételeket, és a hétköznapi Pascalban ez valóban rossz is. Az ABI határon viszont ez a helyes forma, mert az egyetlen biztonságos érték, amelyet vissza lehet adni a C hívónak, egy olyan státuszkód, amelyet az tud értelmezni. A hiba továbbra is terjed, csak éppen a visszatérési értéken keresztül, és a könyvtár feletti hívó kód EPdfError-ként hozza azt a felszínre, amint a vezérlés visszatér a kerítés Pascal oldalára.
A többszörös felszabadítás elrejtőzik a hibaútvonalon
A negyedik hiba a tulajdonjog (ownership). A PDFium dokumentum-kezelőt (handle) a könyvtár nyitja meg, és pontosan egyszer kell bezárni a FPDF_CloseDocument segítségével. A veszély egy olyan hibaútvonal, amely felszabadít egy olyan kezelőt, amelyet egy második takarítás is birtokol. Képzeljen el egy olyan rutint, amely létrehoz egy csomagoló objektumot (wrapper object), hozzárendel egy frissen megnyitott dokumentum-kezelőt, majd további beállításokat végez, amelyek meghiúsulhatnak. Ha a beállítás kivételt dob, egy korai visszatérési kezelő, amely meghívja a FPDF_CloseDocument-et a nyers kezelőn, bezárja azt, majd a csomagoló objektum saját destruktora újra bezárja azt, amikor az objektum felszabadul. A kezelő kétszer szabadul fel, ami definiálatlan viselkedés és valószínűleg összeomlást okoz.
Az audit ezt egy kilövési (imposition) stílusú importálási útvonalon találta meg, amely egy TPdf-et épít fel egy már megnyitott kezelő köré. A javítás az, hogy a tulajdonjog átruházása legyen az egyetlen igazságforrás. Amint a kezelőt hozzárendelik a csomagoló mezőjéhez, a csomagoló birtokolja azt, és a hibaútvonalon az egyetlen takarítás a csomagoló felszabadítása. A csomagoló destruktora meghívja a FPDF_CloseDocument-et Ön helyett, így a második kifejezett bezárás duplán szabadítaná fel ugyanazt a dokumentumot. A javított hibakezelő felszabadítja az objektumot és újra kiváltja a kivételt, így pontosan egyetlen út vezet a bezáráshoz.
Result := TPdf.Create(nil);
try
Result.FDocument := NewDoc; // Result now owns the handle
Result.InitializeFormFill;
Result.ReloadPage;
except
// Result.Free closes the handle. A second FPDF_CloseDocument(NewDoc)
// here would double-free the same PDFium document.
Result.Free;
raise;
end;
A felügyelt rekordoknak és az exportokkal teli könyvtárnak is kifejezett lebontásra van szüksége
Ennek a kötésnek a segédfüggvényei közül sok olyan rekordot ad vissza, amely WideString-et vagy dinamikus tömböt tartalmaz. Ezek referencia-számlált mezők, és a fordító rejtett könyvelést bocsát ki a számlálóik fenntartására. A C-ből átvett ösztön az, hogy a friss rekordot a FillChar(Result, SizeOf(Result), 0) paranccsal töröljük. Ez nullákat bélyegez a rekordon belüli felügyelt referenciára anélkül, hogy előbb csökkentené a számlálót. A fordító egyetlen rejtett ideiglenes változót használ újra a függvény eredményéhez a ciklus iterációi során, így a második iterációban a FillChar felülír egy élő karakterlánc-mutatót, amely soha nem lett felszabadítva, és az általa mutatott karakterlánc kiszivárog. Hívja meg a függvényt egy ciklusban ezer annotáció felett, és ezer karakterláncot szivárogtat el.
A javítás az, hogy hagyjuk a nyelvet törölni a rekordot úgy, ahogyan ő tudja, a Default(T) segítségével, amely felszabadít minden felügyelt mezőt, mielőtt leoltaná azt.
// Default() instead of FillChar: the compiler reuses one hidden temp for
// the function result across loop iterations, so FillChar would zero live
// WideString pointers without releasing them.
Result := Default(TPdfAnnotation);
Egy kapcsolódó tulajdonjogi probléma él a könyvtárbetöltési határon. Ez a kötés több száz függvénymutatót old fel a PDFium DLL-ből a GetProcAddress segítségével egy LoadLibrary után. Ha egy szükséges exportált függvény hiányzik, a részben kötött állapot veszélyes: mutatók tucatjai érvényesek, a többi nil vagy elavult, és bármely későbbi hívás rajtuk keresztül egy olyan modulba ugrik, amely már lehet, hogy fel lett szabadítva. A kötés ezt úgy kezeli, hogy betölti a könyvtárat, és végrehajt egy teljes ClearAllBindings-et, amely minden importált mutatót visszaállít nil-re, amikor egy szükséges export feloldása meghiúsul. Ezt követően egyetlen függvénymutató sem lóg be egy felszabadított modulba, és a későbbi hívás tisztán meghiúsul egy nil-mutató ellenőrzéssel ahelyett, hogy felszabadított kódba ágazna el.
A csomagoló az a hely, ahol négy szerződést kézzel kell megismételni
Ezen öt hiba egyike sem egzotikus. Ezek egy C API feletti vékony Pascal réteg előre látható hibamódjai, és azért csoportosulnak, mert az a réteg pontosan az a hely, ahol négy különálló szerződést újra deklarálni kell. A hívási konvenciót minden visszahívásnál cdecl-nek kell írni. Az egész-szélességnek egyeznie kell a size_t-vel azon az egyetlen célplatformon, ahol az ténylegesen kiszélesedik. A kivétel-modellt visszafejtési kódokká kell alakítani minden olyan visszahívásnál, amely elhagyja a Pascal-t. Minden kezelő és minden felügyelt mező tulajdonjogát egyszer kell meghatározni, és be kell tartani minden útvonalon, beleértve azokat a hibaútvonalakat is, amelyeket a gyártásig senki sem tesztel. Hagyjon ki bármelyiket, és olyan hibát kap, amelynek tünete távol jelentkezik a kiváltó okától, ami ezt a kategóriát drágává teszi. Az ellenőrzés értéke kevésbé az egyes javításokban rejlett, mint inkább abban, hogy ezek mindegyikét saját diszciplínaként kezeltük a teljes kötés ellenőrzése során.
If you want to see the binding doing real work rather than guarding its edges, the render-cache and zoom techniques in our note on render-cache and zoom performance show the rendering path, and the cross-compiler walkthrough in building a Lazarus and FPC viewer is the place the Win64 size_t behavior described here actually matters. Both build on the same memory-safety and ABI work that ships in the PDFium Component for Delphi, Lazarus, and C++Builder, alongside the rendering, text-extraction, and form APIs covered elsewhere on this blog.