Funkcja Delphi lub FPC, która zwraca rekord, nie dostaje świeżego, wyzerowanego Result przy każdym wywołaniu. Ta ukryta zmienna Result zaczyna jako zero dokładnie raz, i nic nie zeruje jej automatycznie ponownie między wywołaniami, więc jej wyczyszczenie przy wejściu jest własnym zadaniem funkcji. Zrób to czyszczenie za pomocą FillChar(Result, SizeOf(Result), 0), a od drugiego wywołania wzwyż procedura nadpisuje żywe odwołanie do łańcucha znaków lub tablicy dynamicznej zamiast je zwolnić, osierocając jakikolwiek blok sterty, na który to odwołanie wskazywało
Scenariusz, w którym to gryzie, jest przyziemny. Proces wsadowy otwiera stos plików PDF firm trzecich i przechodzi przez każdą adnotację na każdej stronie, wyciągając tekst komentarza do dziennika audytu. Nic w tej pętli nie wygląda niebezpiecznie: każde wywołanie to zwykła funkcja zwracająca zwykły rekord, żadnych wskaźników w zasięgu wzroku, nic, co przypominałoby ręczne zarządzanie pamięcią. Zliczanie odwołań wewnątrz rekordu to zwykła reguła księgowania Object Pascala, nie osobliwość charakterystyczna dla jednej konkretnej biblioteki, a każda baza kodu Delphi lub FPC, która miesza FillChar z typami rekordowymi zawierającymi łańcuchy znaków lub tablice dynamiczne, jest narażona na ten sam defekt
Dlaczego FillChar na wyniku rekordowym wycieka łańcuchy znaków?
FillChar wycieka łańcuchy znaków, ponieważ nie ma pojęcia, jakiego rodzaju danych nadpisuje. FillChar(X, Count, Value) działa na dowolnej zmiennej: bierze nietypowany blok Count bajtów i stempluje każdy z nich wartością Value, i to cały kontrakt. To dokładnie to, co czyni FillChar szybkim i ogólnego przeznaczenia, ponieważ nigdy nie sprawdza typu X i nigdy nie rozgałęzia się w zależności od tego, co znaczą bazowe bajty. Pole UnicodeString lub WideString wewnątrz rekordu to nie same znaki; to wskaźnik do bloku sterty, który niesie licznik odwołań przed danymi znakowymi. FillChar widzi garść bajtów, które akurat trzymają wartość wskaźnika, i nadpisuje je zerem dokładnie tak, jak nadpisałby pole Integer czy Double. Wskaźnik znika, licznik odwołań, który powinien był najpierw zmniejszyć, nigdy nie zostaje dotknięty, a blok, na który wskazywał, siedzi zaalokowany, z niczym już do niego się nie odwołującym
Jak kompilator śledzi łańcuchy znaków i tablice dynamiczne wewnątrz rekordu
Object Pascal nazywa typ zarządzanym, gdy kompilator musi uruchomić dodatkowy kod, aby utrzymać go poprawnie przez przypisanie i wyjście z zakresu. Kwalifikują się do tego długie typy łańcuchowe, takie jak AnsiString, UnicodeString i WideString, a także tablice dynamiczne, interfejsy i Varianty, wraz z dowolnym rekordem lub tablicą stałej wielkości, która zawiera jedno z nich jako pole. Dla każdego pola zarządzanego kompilator po cichu emituje księgowanie, które w przeciwnym razie byłoby żmudne i łatwe do popsucia ręcznie: zwiększ licznik odwołań przy przypisaniu, zmniejsz go, gdy trzymająca go zmienna zostanie nadpisana lub wyjdzie z zakresu, i zwolnij bazowy blok, gdy ten licznik osiągnie zero. Ta maszyneria jest powodem, dla którego zwykły kod Pascala nigdy ręcznie nie alokuje ani nie zwalnia string, i dlaczego przypisanie jednej tablicy dynamicznej do drugiej to tania, bezpieczna operacja, a nie ręczna pętla kopiowania. System.Default i Finalize to dwa udokumentowane sposoby wywołania tej samej logiki zwalniania na żądanie, i to one powinny być wywoływane przez kod czyszczący rekordu zamiast surowego wypełnienia pamięci
type
TLineItem = record
Description: string; // managed: reference-counted
Quantity: Integer; // unmanaged: plain ordinal
end;
function GetLineItem(Index: Integer): TLineItem;
begin
FillChar(Result, SizeOf(Result), 0); // clears bytes, not the reference
Result.Quantity := Source[Index].Qty;
Result.Description := Source[Index].Text;
end;
var
Item: TLineItem;
I: Integer;
begin
for I := 0 to High(Source) do
begin
Item := GetLineItem(I); // second pass onward: leaks the prior Description
Log.Add(Item.Description);
end;
end;
Dlaczego wyciek zaczyna się dopiero od drugiego wywołania?
Pierwsze wywołanie w pętli jest zawsze nieszkodliwe, co jest dokładnie tym, co czyni ten defekt łatwym do przeoczenia podczas testowania. Zmienna lokalna typu rekordowego zarządzanego zaczyna jako zero, i nic nie zeruje jej automatycznie ponownie między jednym przebiegiem pętli a następnym, więc za pierwszym razem, gdy pętla przypisuje wartość zwracaną funkcji do tej zmiennej, jej pole Description czy ContentsText wciąż jest nil. FillChar nadpisuje nil zerem, co niczego nie zmienia, jeśli chodzi o licznik odwołań, a wywołanie kończy się, wyglądając całkowicie poprawnie. Drugie wywołanie jest inne: ta sama zmienna lokalna już trzyma to, co zapisało do niej pierwsze wywołanie, a Result nowego wywołania jest zapisywany bezpośrednio do tego samego magazynu, a nie do świeżej, pustej pamięci. FillChar na początku tego drugiego wywołania zeruje pole, które już nie jest nil, i wszystko poniżej tego wzorca bajtów od tej chwili jest po cichu błędne. Test, który wywołuje funkcję raz i sprawdza wynik, nigdy nie zobaczy problemu; tylko pętla, albo dowolna ścieżka kodu, która wywołuje funkcję wielokrotnie na tym samym miejscu docelowym, ujawnia go
Prawdziwy wyciek: adnotacje, zakładki i rekordy linków
PDFiumPas dostarczał dokładnie ten defekt przed wersją 1.56.4, w trzech funkcjach, z których każda zwraca rekord niosący przynajmniej jedno pole zarządzane: czytnik adnotacji na poziomie strony zwraca TPdfAnnotation niosący łańcuchy ContentsText i AuthorText, czytnik zakładek zwraca TBookmark niosący łańcuch Title, a czytnik adnotacji linków zwraca TLinkAnnotation niosący łańcuch ActionPath i tablicę dynamiczną Points. Wszystkie trzy zaczynały się od tego samego kształtu pokazanego poniżej: wyczyść Result surowym FillChar, a następnie wypełnij pola jedno po drugim z bazowych danych strony. Przechodzenie przez każdą adnotację na stronie po kolei, zwykły sposób budowania listy audytowej lub panelu przeglądu, wywoływało czytnik adnotacji w pętli i wyciekało tekst poprzedniej adnotacji przy każdym przebiegu po pierwszym; PDF spreparowany z niezwykle dużą liczbą adnotacji niosących tekst mógł zwiększać pamięć długo działającego procesu tak długo, jak ten proces działał. Poprawka dotknęła jednej linii w każdej funkcji: zastąpienie FillChar(Result, SizeOf(Result), 0) przez Result := Default(TPdfAnnotation) wystarczyło, ponieważ przypisanie Default do zarządzanego rekordu uruchamia zwykłą sekwencję kompilatora zwolnij-a-potem-wyczyść zamiast surowego wypełnienia pamięci
function GetPageAnnotation(Page: FPDF_PAGE; Index: Integer): TPdfAnnotation;
var
Annotation: FPDF_ANNOTATION;
ContentLength: LongWord;
begin
Annotation := FPDFPage_GetAnnot(Page, Index);
FillChar(Result, SizeOf(Result), 0); // clears bytes, not a live reference
Result.Subtype := DecodeAnnotationSubtype(FPDFAnnot_GetSubtype(Annotation));
ContentLength := FPDFAnnot_GetStringValue(Annotation,
FPDFANNOT_TEXTTYPE_Contents, nil, 0);
if ContentLength >= 4 then
begin
SetLength(Result.ContentsText, ContentLength div 2 - 1);
FPDFAnnot_GetStringValue(Annotation, FPDFANNOT_TEXTTYPE_Contents,
Pointer(Result.ContentsText), ContentLength);
end;
end;
To samo zagrożenie za parametrem var
Czytnik zakładek pokazuje subtelniejszą wersję tego samego problemu, ponieważ rekord, który jest czyszczony przez FillChar, to nie własny Result funkcji, tylko parametr var jedno wywołanie niżej. SetBookmarkData przyjmuje swoje wyjście jako var Data: TBookmark i dawniej czyścił Data na początku swojego ciała za pomocą FillChar; GetBookmark, publiczna funkcja, która faktycznie zwraca TBookmark, wywołuje SetBookmarkData i przekazuje swój własny Result wprost jako ten argument var. Parametr var jest przekazywany przez referencję, więc Data wewnątrz SetBookmarkData i Result wewnątrz GetBookmark to ten sam magazyn pod dwiema nazwami, a jakiekolwiek ryzyko aliasowania dotyczące własnego Result funkcji dotyczy równie bezpośrednio dowolnej procedury pomocniczej, która otrzymuje go przez referencję. Przeglądanie wyłącznie funkcji, które dosłownie deklarują typ zwracany rekordowy, przeocza ten kształt; wyszukiwanie musi podążać też za każdym parametrem var i out, do którego Result jest dalej przekazywany
procedure TPdf.SetBookmarkData(Bookmark: FPDF_BOOKMARK; var Data: TBookmark);
var
BufferSize: LongWord;
begin
Data := Default(TBookmark); // fixed: was FillChar(Data, SizeOf(Data), 0)
Data.Handle := Bookmark;
if Bookmark <> nil then
begin
BufferSize := FPDFBookmark_GetTitle(Bookmark, nil, 0);
if BufferSize >= 4 then
begin
SetLength(Data.Title, BufferSize div 2 - 1);
FPDFBookmark_GetTitle(Bookmark, PWideChar(Data.Title), BufferSize);
end;
end;
end;
function TPdf.GetBookmark(const Title: WString): TBookmark;
begin
CheckActive;
SetBookmarkData(FPDFBookmark_Find(FDocument, PWideChar(Title)), Result);
end;
Kiedy FillChar wciąż jest właściwym wyborem?
FillChar wciąż jest poprawny, i często odrobinę tańszy, dla rekordu zbudowanego wyłącznie z wartości porządkowych, pól zmiennoprzecinkowych, tablic o stałym rozmiarze z tego samego, lub innych zwykłych rekordów zbudowanych z tego samego, ponieważ nie ma w nim niczego, co kompilator musiałby finalizować. Własny typ prostokąta w PDFiumPas to dokładnie ten przypadek: TPdfRectangle niesie cztery pola Double i nic więcej, a wyczyszczenie go przez FillChar niczego nie zwalnia, ponieważ nie ma tam nic zliczanego przez odwołania do zwolnienia. Sprawdzenie, które oddziela te dwa przypadki, jest proste do wypowiedzenia: czy jakiekolwiek pole rekordu, na dowolnej głębokości zagnieżdżenia, ma typ string, AnsiString, WideString, tablicę dynamiczną, interfejs, lub Variant? Rekord może na najwyższym poziomie wyglądać całkowicie liczbowo i wciąż nie przejść tego testu, jeśli jedno z jego pól to samo w sobie rekord chowający łańcuch znaków kilka warstw głębiej, więc sprawdzenie musi podążać za zagnieżdżonymi rekordami do samego końca, zamiast zatrzymać się na najbardziej zewnętrznej liście pól. Audytowanie istniejącej bazy kodu pod kątem tego wzorca jest mechaniczne, a nie wyczerpujące: wyszukaj każde wywołanie FillChar, którego celem jest zmienna rekordowa, a następnie sprawdź listę pól tego rekordu względem powyższej listy typów zarządzanych. Własny audyt v1.56.4 PDFiumPas przeprowadził dokładnie to wyszukiwanie w całej bibliotece i znalazł tę podatność w jednej jednostce; każde inne miejsce wywołania FillChar już czyściło zwykły rekord liczbowy, gdzie FillChar był, i pozostaje, właściwym narzędziem
To samo zachowanie kompilatora, które czyni ponownie użyty Result niebezpiecznym tutaj, napędza też pokrewną rodzinę niezgodności Delphi kontra FPC gdzie indziej w tej bazie kodu; towarzyszący artykuł o pułapkach międzykompilatorowych omawia przypadek, w którym FPC i Delphi nie zgadzają się co do tego, dokładnie kiedy tymczasowy wynik rekordowy jest finalizowany wewnątrz pojedynczego wyrażenia, inny objaw tego samego bazowego faktu, że rekordowy Result funkcji nie zawsze jest świeżym, prywatnym magazynem, na jaki wygląda. Pętla adnotacji użyta jako przykład przewodni w tym artykule też nie jest hipotetyczna: to to samo przechodzenie strona po stronie, które napisałbyś, budując panel przeglądu adnotacji, co jest dokładnie tym kształtem kodu, który po raz pierwszy zamienił jednolinijkowy FillChar w powolny wyciek pamięci
Nic z tego nie wymaga zmiany bibliotek ani tropienia błędu w cudzym skompilowanym kodzie: to własność samego języka Object Pascal, z którą każdy programista Delphi i FPC pracuje codziennie, a poprawka to jedno wywołanie funkcji, gdy już wiesz, czego szukać. Opisane tutaj API adnotacji, zakładek i adnotacji linków są dostarczane jako część komponentu PDFium dla Delphi, C++Buildera i Lazarusa/FPC, obok reszty powierzchni odczytu, renderowania i adnotacji PDF omówionej gdzie indziej na tym blogu