Artykuł techniczny

Zabezpieczanie powiązań VCL dla PDFium

Powiązanie Pascala nad biblioteką C wygląda jak zwykły Pascal. Wywołujesz metodę, dostajesz rekord, zwalniasz to, co zaalokowałeś. Problem w tym, że PDFium to biblioteka C i C++ z własną konwencją wywołań, własnymi szerokościami liczb całkowitych i własnymi zasadami tego, kto jest właścicielem pamięci i kto ją zwalnia. Żadne z tego nie przechodzi przez granicę języków samo z siebie. Każdy z tych kontraktów trzeba ręcznie zapisywać na nowo w deklaracjach Pascala, a jedno złe słowo zamienia dobrze wyglądające wywołanie w uszkodzenie stosu, obcięty offset albo podwójne zwolnienie. Każdy z opisanych poniżej kontraktów pokazuje klasyczny błąd, jaki się przy nim popełnia, oraz kształt, który w jego miejsce przybiera powiązanie PDFium Component. Warto je prześledzić, bo nie są one specyficzne dla tego powiązania. To stałe zagrożenia przy opakowywaniu dowolnego API C w Delphi lub Lazarusie

cdecl jest częścią typu funkcji, a nie ozdobą

PDFium to skompilowany C. Na Win32 jego eksporty, a co ważniejsze, wywołania zwrotne, które wywołuje, używają konwencji wywołań cdecl. W konwencji cdecl to wywołujący sprząta stos po powrocie z wywołania. Natywnym domyślnym ustawieniem Delphi jest register, a standardem C dla wywołań zwrotnych na Win32 jest w niektórych bibliotekach stdcall, gdzie zamiast tego sprząta wywoływany. Gdy struktura przekazuje PDFium wskaźnik do funkcji, a ty zapomnisz o cdecl w typie tego wskaźnika, obie strony nie zgadzają się co do tego, kto dostosowuje wskaźnik stosu. Obie go poprawiają albo żadna tego nie robi, a wskaźnik stosu dryfuje o rozmiar argumentów przy każdym wywołaniu

Powodem, dla którego ten błąd tak łatwo umyka, jest to, że szkody są nielokalne. Uszkodzone wywołanie wraca i wygląda dobrze. Niewspółosiowość ujawnia się później, w jakiejś niezwiązanej funkcji, której ramka ląduje teraz na wskaźniku stosu przesuniętym o kilka bajtów, i objawia się dzikim odczytem, złym adresem powrotu albo awarią ze śladem stosu wskazującym daleko od wywołania zwrotnego, które faktycznie zepsułeś. Wypełnianie formularzy to klasyczne miejsce, gdzie to gryzie, bo interfejs wypełniania formularzy to rekord pełen wywołań zwrotnych, do których PDFium dzwoni z powrotem. Jedno z nich, FFI_OpenFile, wręcza PDFium funkcję, którą będzie wywoływał, by otworzyć plik zewnętrzny, zadeklarowaną jako function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Końcowe cdecl to istota sprawy. Pomiń je, a kod nadal się kompiluje, nadal się linkuje i nadal działa aż do momentu, gdy PDFium wywoła tę funkcję. Konwencja należy do samego typu funkcji. Nie jest opcjonalnym dodatkiem, a kompilator nie ostrzeże o jej braku, bo zwykły typ funkcji jest w pełni legalnym typem Pascala. Jedyną obroną jest traktowanie konwencji wywołań jako obowiązkowego pola każdej importowanej sygnatury i każdego wywołania zwrotnego przekazywanego na zewnątrz

Diagram zestawiający wywołanie zwrotne cdecl utrzymujące w równowadze stos należący do wywołującego w PDFium z brakującym cdecl dryfującym wskaźnikiem stosu ku odległej awarii
cdecl należy do typu funkcji, a jego porzucenie znosi wskaźnik stosu, aż jakiś niezwiązany frame się zawali

size_t ma szerokość wskaźnika, a na FPC Win64 oznacza to 64 bity

Drugim zagrożeniem jest niezgodność szerokości liczby całkowitej, która pojawia się tylko na jednym celu. C-owe size_t jest zdefiniowane jako na tyle szerokie, by pomieścić rozmiar dowolnego obiektu, co na platformie 64-bitowej oznacza 64-bitową liczbę całkowitą bez znaku. Interfejsy ładowania progresywnego PDFium operują bajtowymi offsetami size_t. Rekord FX_FILEAVAIL dostawcy dostępności niesie wywołanie zwrotne IsDataAvail, które PDFium wywołuje z offsetem i rozmiarem, a wywołanie zwrotne AddSegment z rekordu FX_DOWNLOADHINTS dostaje to samo. Oba parametry to size_t

IsDataAvail = function(
  pThis       : PFX_FILEAVAIL;
  offset, size: size_t): FPDF_BOOL; cdecl;

AddSegment = procedure(
  pThis       : PFX_DOWNLOADHINTS;
  offset, size: size_t); cdecl;

Jeśli zadeklarujesz te offsety jako typ 32-bitowy, powiązanie działa na Win32 i na Delphi Win64, a potem po cichu psuje się na FPC i Lazarusie Win64. Przyczyna jest subtelna. Na FPC Win64 NativeUInt jest prawdziwym 64-bitowym typem o szerokości wskaźnika, a size_t jest do niego aliasowany. Powiązanie ma w sekcji typów komentarz ostrzegający dokładnie przed przesłanianiem NativeUInt na FPC, bo przedefiniowanie go tam na 32-bitowy alias zmusiłoby size_t do 32 bitów i uszkodziło każdy parametr size_t przekazywany do biblioteki albo przez nią zapisywany. 64-bitowy offset trafiając do 32-bitowego parametru traci górną połowę. Dla małego pliku każdy offset mieści się w 32 bitach i wszystko jest dobrze. Dla dużego pliku, w chwili gdy offset przekroczy linię czterech gigabajtów, obcięta wartość wskazuje gdzieś zupełnie indziej, PDFium pyta, czy dostępny jest zły zakres bajtów, a ładowanie progresywne utyka albo czyta śmieci. Pomyłka pozostaje niewidoczna, dopóki plik nie urośnie do odpowiednich rozmiarów, a platforma docelowa nie będzie tą, na której size_t faktycznie się poszerza

Diagram PDFium Component: pięciogigabajtowy offset size_t przetrwa jako pełna wartość 64-bitowa, ale zawija się do około jednego gigabajta, gdy 32-bitowy alias zwęża parametr na FPC Win64
Parametr size_t przymuszony do 32 bitów obcina offsety poza czterema gigabajtami i wysyła ładowanie progresywne w zły zakres bajtów

Wyjątek Pascala nigdy nie może rozwijać się przez ramkę C

Trzecia klasa dotyczy modelu wyjątków, którego C nie ma. Gdy PDFium wywołuje jedno z twoich wywołań zwrotnych, twój kod Pascala działa wewnątrz stosu ramek C i C++, które nic nie wiedzą o maszynerii wyjątków Delphi. Jeśli twoje wywołanie zwrotne zgłosi wyjątek i pozwoli mu się propagować, rozwija się on przez ramki, które nigdy nie były zbudowane do rozwijania. Własne sprzątanie PDFium się nie uruchamia, jego wewnętrzne niezmienniki pozostają w połowie zaktualizowane, a proces jest teraz w stanie, którego biblioteka nigdy nie przewidziała. Kontraktem dla tych wywołań zwrotnych jest kod powrotu, a nie wyjątek

Dwa wywołania zwrotne czynią to konkretnym. FPDF_FILEWRITE to ujście, do którego PDFium zapisuje zapisywany dokument, a FPDF_FILEACCESS to źródło, z którego odczytuje dokument wejściowy. Oba są tutaj zaimplementowane nad delphijskim TStream, i oba mogą zawieść tak, jak zawodzi każdy strumień: dysk się zapełnia, strumień jest zamykany pod tobą, odczyt wychodzi poza koniec. Wywołanie zwrotne zapisu opakowuje swój zapis do strumienia i zamienia każdą porażkę w kod porażki PDFium, zamiast pozwolić jej uciec

function WriteBlock(
  pThis: PFPDF_FILEWRITE;
  pData: Pointer;
  Size : LongWord): Integer; cdecl;
begin
  // PDFium traktuje każdy wynik różny od 1 jako porażkę zapisu. Wyjątek Pascala
  // nie może rozwijać się przez tę ramkę cdecl/C++, więc przechwyć go i zgłoś
  // porażkę zamiast tego.
  Result := 0;
  try
    PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
    Result := 1;
  except
  end;
end;

Strona odczytu robi to samo: nieudany odczyt zgłasza zero, by pasować do kontraktu FPDF_FILEACCESS, zamiast zgłaszać wyjątek przez granicę. Goły except bez ponownego zgłoszenia wygląda źle dla programisty Pascala wyszkolonego, by nigdy nie połykać wyjątków, i w zwykłym Pascalu jest to złe. Na granicy ABI jest to poprawny kształt, bo jedyną bezpieczną wartością do oddania wywołującemu z C jest kod stanu, który ten potrafi zinterpretować. Porażka nadal się propaguje, tyle że przez wartość powrotu, a kod wywołujący nad biblioteką wynosi ją na powierzchnię jako EPdfError, gdy tylko sterowanie wróci na pascalową stronę płotu

Podwójne zwolnienie ukrywa się na ścieżce błędu

Czwartym zagrożeniem jest własność. Uchwyt dokumentu PDFium jest otwierany przez bibliotekę i musi zostać zamknięty dokładnie raz, przez FPDF_CloseDocument. Niebezpieczeństwem jest ścieżka błędu, która zwalnia uchwyt, do którego prawa ma też druga rutyna sprzątająca. Wyobraź sobie procedurę, która tworzy obiekt opakowujący, przypisuje mu świeżo otwarty uchwyt dokumentu, a potem robi jeszcze konfigurację, która może się nie powieść. Jeśli konfiguracja rzuci wyjątkiem, obsługa z wczesnym powrotem wywołująca FPDF_CloseDocument na surowym uchwycie zamknie go, a potem destruktor samego obiektu opakowującego zamknie go jeszcze raz przy zwalnianiu obiektu. Uchwyt zostaje zwolniony dwa razy, co jest zachowaniem niezdefiniowanym i pewnym kandydatem na awarię

Wzorzec ten ma największe znaczenie na ścieżce importu w stylu impozycji, która buduje TPdf wokół już otwartego uchwytu. Lekarstwem jest uczynienie przekazania własności jedynym źródłem prawdy. Gdy uchwyt zostanie przypisany do pola opakowania, to opakowanie staje się jego właścicielem, a jedynym sprzątaniem na ścieżce błędu jest zwolnienie opakowania. Destruktor opakowania wywołuje za ciebie FPDF_CloseDocument, więc drugie jawne zamknięcie podwójnie zwolniłoby ten sam dokument. Obsługa błędu poniżej zwalnia obiekt i ponownie zgłasza wyjątek, a ścieżka do zamknięcia jest dokładnie jedna

Diagram wadliwej ścieżki błędu zamykającej uchwyt dokumentu PDFium dwa razy obok poprawki z pojedynczym właścicielem, gdzie zwolnienie wrappera zamyka uchwyt dokładnie raz
Przeniesienie uchwytu do owijki czyni jej destruktor jedyną drogą do FPDF_CloseDocument
Result := TPdf.Create(nil);
try
  Result.FDocument := NewDoc;   // Result jest teraz właścicielem uchwytu
  Result.InitializeFormFill;
  Result.ReloadPage;
except
  // Result.Free zamyka uchwyt. Drugie FPDF_CloseDocument(NewDoc)
  // tutaj podwójnie zwolniłoby ten sam dokument PDFium.
  Result.Free;
  raise;
end;

Zarządzane rekordy i biblioteka pełna eksportów — oba wymagają jawnego sprzątania

Ostatnia klasa dotyczy pamięci, którą kompilator zarządza w twoim imieniu, a którą nawyk z C cicho uszkodzi. Wiele funkcji pomocniczych tego powiązania zwraca rekord zawierający WideString lub tablicę dynamiczną. To pola zliczane przez odwołania, a kompilator emituje ukrytą księgowość, by utrzymywać ich liczniki. Instynktem przeniesionym z C jest wyczyszczenie świeżego rekordu przez FillChar(Result, SizeOf(Result), 0). To wbija zera na zarządzane odwołanie wewnątrz rekordu, nie zmniejszając go wpierw. Kompilator wielokrotnie używa jednej ukrytej tymczasowej zmiennej dla wyniku funkcji przez iteracje pętli, więc w drugiej iteracji FillChar nadpisuje żywy wskaźnik łańcucha, który nigdy nie został zwolniony, a łańcuch, na który wskazywał, wycieka. Wywołaj tę funkcję w pętli po tysiącu adnotacji, a wyciekniesz tysiąc łańcuchów

Naprawą jest pozwolić językowi wyczyścić rekord w sposób, który zna, za pomocą Default(T), który zwalnia każde zarządzane pole przed jego wyzerowaniem

// Default() zamiast FillChar: kompilator wielokrotnie używa jednej ukrytej
// tymczasowej dla wyniku funkcji przez iteracje pętli, więc FillChar wyzerowałby
// żywe wskaźniki WideString bez ich zwolnienia.
Result := Default(TPdfAnnotation);

Pokrewny problem własności żyje na granicy ładowania biblioteki. To powiązanie rozwiązuje kilkaset wskaźników do funkcji z DLL-a PDFium za pomocą GetProcAddress po LoadLibrary. Jeśli brakuje jednego wymaganego eksportu, częściowo powiązany stan jest niebezpieczny: dziesiątki wskaźników są ważne, reszta jest nil lub nieaktualna, a każde późniejsze wywołanie przez jeden z nich skacze do modułu, który może być już wyładowany. Powiązanie radzi sobie z tym, wyładowując bibliotekę i uruchamiając pełne ClearAllBindings, które resetuje każdy importowany wskaźnik z powrotem do nil za każdym razem, gdy wymagany eksport nie zostanie rozwiązany. Po tym żaden wskaźnik do funkcji nie zwisa do wyładowanego modułu, a późniejsze wywołanie zawodzi czysto przy sprawdzeniu wskaźnika nil, zamiast rozgałęziać się do zwolnionego kodu

Opakowanie to miejsce, gdzie cztery kontrakty są przeformułowywane ręcznie

Żadno z tych pięciu zagrożeń nie jest egzotyczne. To przewidywalne tryby awarii cienkiej warstwy Pascala nad API C, a gromadzą się one właśnie dlatego, że ta warstwa to dokładnie miejsce, w którym cztery odrębne kontrakty trzeba zapisać na nowo. Konwencję wywołań trzeba zapisywać jako cdecl przy każdym wywołaniu zwrotnym. Szerokość typu całkowitego musi pasować do size_t na tej jednej platformie, gdzie faktycznie się poszerza. Model wyjątków trzeba przetłumaczyć na kody powrotu w każdym wywołaniu zwrotnym wychodzącym z Pascala. Własność każdego uchwytu i każdego zarządzanego pola trzeba zadeklarować raz i przestrzegać na każdej ścieżce, w tym na ścieżkach błędu, których nikt nie ćwiczy aż do produkcji. Pomiń którąkolwiek z tych rzeczy, a dostaniesz usterkę, której objaw pojawia się daleko od przyczyny, i to właśnie czyni tę kategorię kosztowną. Wartość leży mniej w którejkolwiek pojedynczej regule, a bardziej w traktowaniu każdej z nich jako osobnej dyscypliny do sprawdzenia w całym powiązaniu

Jeśli chcesz zobaczyć powiązanie wykonujące prawdziwą pracę, a nie strzegące swoich krawędzi, techniki keszowania renderowania i masztabowania w naszej notatce o keszu renderowania i wydajności masztabowania pokazują ścieżkę renderowania, a przewodnik międzykompilatorowy w budowaniu przeglądarki Lazarus i FPC to miejsce, gdzie opisane tutaj zachowanie size_t na Win64 faktycznie ma znaczenie. Oba bazują na tej samej pracy nad bezpieczeństwem pamięci i ABI, która jest dostarczana w komponencie PDFium dla Delphi, Lazarusa i C++Buildera, obok API renderowania, ekstrakcji tekstu i formularzy omówionych gdzie indziej na tym blogu