Artykuł techniczny

Kod Delphi działający przypadkiem: pięć błędów portu na FPC

PDF Library for Delphi znalazła pięć defektów dekoderów, przenosząc swój kod CCITT, TIFF, PNG, Flate i buforów strumieni na Free Pascala, a każdy z nich latami przechodził pełny zestaw testów Delphi. Żaden nie był błędem kompilatora. Każdy był Pascalem, który Delphi wykonywało poprawnie tylko dzięki szczegółowi implementacji: ukryty parametr wyniku, który aliasował tablicę wywołującego, gałąź poza zakresem, za którą nikt nigdy nie czytał dalej, bufor o zerowej długości, którego jedynym zabezpieczeniem był przełącznik range check, przesunięcie liczone od 1, które tylko jedna ścieżka kodu kiedykolwiek podała jako 1, oraz kontrakt TStream.Read, którego strumienie w pamięci nigdy nie ćwiczą. Zmień kompilator albo podaj temu samemu kodowi uszkodzony plik, a przypadek przestaje działać

Dalej opisuję konkretny kształt każdego z nich, poprawkę i dyscyplinę, która z tego wyszła: ten sam kod źródłowy ma teraz dawać tę samą semantykę dokumentu pod oboma kompilatorami, a wspólny plik testów tego pilnuje. Pokrewny artykuł o utwardzaniu parsera PDF w Pascalu przeciw złośliwym plikom omawiał szerokość typów całkowitych, głębokość rekurencji i niezainicjowane bufory. Ten jest o innej klasie awarii: o kodzie, który był błędny od zawsze, a kompilator po cichu go krył

Dlaczego funkcja zwracająca tablicę dynamiczną działa na Delphi bez SetLength?

Bo Delphi przekazuje jako ukryty parametr wyniku zmienną samego wywołującego, więc funkcja, która nigdy nie alokuje wyniku, i tak potrafi pisać do tablicy zaalokowanej przez wołającego. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray to wyszukiwanie w linii referencyjnej w samym sercu dwuwymiarowego dekodowania Group 3 i Group 4: mając bieżącą pozycję a0 i kolor bieżącego ciągu, przeszukuje elementy zmieniające poprzedniej linii skanowania, czyli b1 i b2 z dwuwymiarowego schematu kodowania ITU-T T.4 i T.6, i zwraca je jako dwuelementową tablicę. Oryginalna funkcja pisała do Result[0] i Result[1], a SetLength na Result nie wołała ani razu

To powinno wysypać się przy pierwszym zapisie i na Free Pascalu tak się dzieje. Na Delphi nigdy, bo oba miejsca wywołania w dekoderze wyglądają tak: deklarują b: TCCITTIntegerArray, raz przed pętlą po liniach skanowania wołają SetLength(b, 2), a potem w pętli przypisują b := GetNextChangingElement(a0, IsWhite) i czytają b[0] oraz b[1]. Przewodnik po języku Delphi mówi, że funkcja, której wynikiem jest długi łańcuch, tablica dynamiczna albo inny typ zarządzany, dostaje ten wynik jako dodatkowy parametr var, a w praktyce kompilator przekazuje adres celu przypisania. Więc Result wewnątrz funkcji to samo b, już dwuelementowe, i każdy zapis ląduje w pamięci należącej do wywołującego. Free Pascal podaje funkcji świeżą, nilową tablicę i przypisuje ją do b dopiero potem, co jest tym odczytaniem kontraktu, przeciw któremu ten kod powinien był powstać od początku

Rozbieżność dekodowania CCITT w PDFlibPas: Delphi przekazuje tablicę b wywołującego jako ukryty parametr var Result funkcji GetNextChangingElement, więc zapisy lądują w pamięci należącej do wołającego, a nietrafione wyszukiwanie zachowuje poprzednie wartości, natomiast Free Pascal podaje funkcji świeżą nilową tablicę, którą zabezpieczenie Length musi powiększyć przez SetLength przed pierwszym zapisem
Delphi aliasuje tablicę wywołującego jako ukryty parametr Result, więc niezabezpieczone zapisy i tak trafiają do własnej pamięci, a Free Pascal przychodzi z nil i jednowierszowe zabezpieczenie zamienia awarię w zamierzone zachowanie, nie ruszając ścieżki dekodowania na Delphi

Aliasowanie niosło też semantykę, na której dekoder polega. Result[0] jest przypisywane tylko wtedy, gdy skan znajdzie element większy od a0, a Result[1] tylko wtedy, gdy po nim jest jeszcze jakiś element, więc przy nietrafionym wyszukiwaniu sloty zachowują to, co poprzednia iteracja zostawiła w b. Oczywista poprawka, czyli zaalokowanie dwóch slotów i zerowanie ich przy każdym wywołaniu, zniszczyłaby to przenoszenie wartości i zmieniła zdekodowane wyjście na Delphi. Poprawka, która weszła, to zabezpieczenie zamiast zerowania: na Delphi jest martwym kodem i ścieżka dekodowania zostaje bajt w bajt taka, jaka była, a na Free Pascalu zamienia awarię w zamierzone zachowanie. Ta asymetria jest tu całą istotą rzeczy, bo poprawka musiała być no-opem pod kompilatorem, który już produkował zweryfikowane wyjście

Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
  IsWhite: Boolean): TCCITTIntegerArray;
Begin
  // Delphi przychodzi tu z dwuelementową tablicą wywołującego
  // zaaliasowaną jako Result, więc to jest tam no-op. FPC przychodzi z nil.
  If (Length(Result) < 2) Then
    SetLength(Result, 2);
  ...
  // Result[0] / Result[1] nadal są zapisywane tylko przy trafieniu,
  // więc nietrafienie zachowuje wartości z poprzedniej iteracji
End;

Licznik, który przeżył swoje dane: wpis katalogu TIFF

Kiedy unieważniasz tablicę, musisz unieważnić jej licznik w tym samym wyrażeniu, inaczej uwierzy w niego kod, który samej tablicy nigdy nie widzi. Wpis katalogu obrazu TIFF (TIFF 6.0 §2, dwunastobajtowy układ pól tag, typ, licznik i wartość-albo-przesunięcie) niesie 32-bitowy licznik wprost z pliku, a PDF Library for Delphi czyta każdy z nich przez PopDE: TTIFFEntry, rekord z polami Tag, TagType, Length, Offset oraz zdekodowanymi tablicami IntegerValues i DoubleValues. Oryginalny kod sprawdzał, czy Offset + TypeSize * Length wychodzi poza koniec pliku, a jeśli tak, ustawiał obie tablice na zerową długość. Result.Length zostawiał na wartości z pliku

Od tego miejsca poszły źle dwie rzeczy. Funkcja kończy się fallbackiem, który mówi: jeśli Length jest zerowe, daj wpisowi jeden element o wartości zero, żeby wywołujący zawsze mogli odczytać element zerowy. Ponieważ Length nigdy nie było czyszczone na ścieżce poza zakresem, ten fallback nigdy się nie odpalał w jedynym przypadku, dla którego istniał. A wywołujący czytają element zerowy bezwarunkowo: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip i tuzin innych bierze E.IntegerValues[0], a tabele pasów robią Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), kopiując Length razy po cztery bajty z tablicy, która nie ma ani jednego. Wyczyszczona tablica z żywym licznikiem jest zdecydowanie groźniejsza niż niesprawdzona, bo ta niesprawdzona przynajmniej trzyma bajty, które deklaruje

Drugim problemem była kolejność. Oba wywołania SetLength biegły przed testem zakresu i brały rozmiar z licznika podanego przez plik, więc wrogi wpis mógł zażądać alokacji wielogigabajtowej, zanim cokolwiek sprawdzono. Na Delphi rzucany przy tym wyjątek łapał handler wyżej na ścieżce wczytywania obrazu i plik po prostu się nie wczytywał, dlatego nikt tego nie zauważył; w rzeczywistości był to out-of-memory, który sam sobie wybrał plik. Poprawka przenosi alokację za test i sprawia, że licznik wędruje razem z danymi

Utwardzanie wpisu katalogu TIFF w PDFlibPas: dwunastobajtowy wpis niesie licznik podany przez plik, błędna kolejność alokowała tablice z tego licznika przed testem zakresu i zostawiała żywe Result.Length po ich wyczyszczeniu, a naprawiona kolejność najpierw testuje arytmetykę Int64 względem długości pliku, więc licznik jest czyszczony razem z tablicami
Alokacja przed testem zakresu pozwalała wrogiemu licznikowi zażądać gigabajtów i zostawiała żywy licznik na opróżnionej tablicy, więc poprawka najpierw testuje przesunięcie i czyści Result.Length w tym samym wyrażeniu co tablice
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
              > Length(Source);
If OutOfRange Then
Begin
  Result.Length := 0;              // licznik idzie razem z wartościami
  SetLength(Result.IntegerValues, 0);
  SetLength(Result.DoubleValues, 0);
End
Else
Begin
  SetLength(Result.IntegerValues, Result.Length);  // dopiero teraz
  SetLength(Result.DoubleValues, Result.Length);
End;
// ... dalej istniejący fallback w końcu trafia w przypadek, dla którego powstał:
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

W tej poprawce nie ma nic zależnego od kompilatora i właśnie dlatego należy do tej listy. Defekt był utajony na Delphi z tego samego powodu, z którego był utajony na Free Pascalu: żaden plik testowy nie miał wpisu katalogu wskazującego poza koniec pliku. Port go nie ujawnił. Ujawniło go czytanie kodu z pytaniem, co Delphi robi tu za mnie, czego sam nie robię

Co się dzieje, gdy IHDR w PNG poda typ koloru, którego format nie definiuje?

PDF Library for Delphi odrzuca teraz obraz, zanim ruszą filtry wierszy; przed wersją v3.539.2 liczyła zerobajtową linię skanowania i podawała pętlom odfiltrowującym pusty bufor. ISO 15948 §11.2.2 definiuje chunk IHDR, a Tabela 11.1 wymienia sześć legalnych kombinacji typu koloru i głębi bitowej: odcienie szarości przy 1, 2, 4, 8 albo 16 bitach, kolor indeksowany przy 1, 2, 4 albo 8, oraz truecolor, odcienie szarości z kanałem alfa i truecolor z kanałem alfa przy 8 albo 16. TPNGReader walidował pola compression method i filter method w IHDR, a FColorType i głębię bitową przepuszczał bez zmian

Kod filtrów wierszy wylicza wszystko z Case FColorType Of, który mapuje każdy typ koloru na liczbę komponentów. Typ koloru spoza szóstki wpada do gałęzi Else, gdzie SourceComponents wynosi 0, więc ScanlineByteCount wynosi 0, więc po SetLength(PreviousScanline, 0) natychmiast idzie FillChar(PreviousScanline[0], ScanlineByteCount, 0). Indeksowanie elementu zerowego pustej tablicy dynamicznej to adres policzony od nil. Przy wyłączonym sprawdzaniu zakresu zerobajtowe wypełnienie pod tym adresem jest cichym no-opem i dekoder maszeruje dalej przez wiersze, których nie ma; przy włączonym to ERangeError już na pierwszym obrazie; a wołane zaraz potem Move jest o krok od access violation. To, który z tych wariantów dostaniesz, zależy od kompilatora i przełączników budowania, a nie od czegokolwiek, co zdecydował dekoder, i to jest sygnał, że dekoder nie zdecydował niczego

Poprawką jest tabela ze specyfikacji, zastosowana tam, gdzie pozostałe pola IHDR były już sprawdzane: COLOR_GRAYSCALE przyjmuje FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE przyjmuje [1, 2, 4, 8], a COLOR_RGB, COLOR_GRAYSCALEALPHA i COLOR_RGBALPHA przyjmują [8, 16]; wszystko inne czyści ValidImage i obraz zostaje odrzucony z nienaruszonymi szerokością i wysokością na potrzeby diagnostyki. Chunk pHYs krótszy niż swoje dziewięć bajtów domknięto w tym samym przebiegu, bo czytnik DPI indeksował S[1] do S[8] w łańcuchu, który krótki chunk zostawił pusty

Przesunięcie liczone od 1 potraktowane jak wskaźnik liczony od 0

InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString przyjmuje StartPos liczone od 1, bo jego wejściem jest AnsiString, a implementacja Delphi adresuje wejście zlib jako @Input[StartPos]. Implementacja dla Free Pascala, pisana pod paszlib, żeby oba cele Windows linkowały kompresję statycznie, ustawiała next_in na PAnsiChar(Input) + StartPos, a avail_in na Length(Input) - StartPos. To arytmetyka na wskaźnikach, a ona liczy od 0. Podaj 1, czyli to, co dla tej funkcji znaczy zacznij od początku, a build FPC zaczyna rozpakowywać od drugiego bajtu i kończy bajt przed końcem

Przetrwało to dlatego, że jedynym wołającym, do którego dociera większość testów, jest InflateStr, a on przekazuje 0. Zero przypadkiem jest poprawnym przesunięciem liczonym od 0, więc oba buildy zgadzały się przy każdym zwykłym wywołaniu InflateStr i w każdym teście, który przez nie przechodził. TPDFDocument.DecodeAllStreams, procedura, której SaveQDFToFile i ConvertFileToQDF używają do rozwinięcia strumieni z pojedynczym FlateDecode do czytelnej postaci, przekazuje 1. W buildzie FPC pominięty nagłówek zlib powodował, że inflate się nie udawało, ale strumień zlib wciąż zgłaszał niezerowe Consumed za bajty, które zbadał, więc DecodeAllStreams brał pusty ładunek za udane dekodowanie i podmieniał każdy strumień treści na pusty łańcuch. Powstały QDF miał właściwą liczbę stron, poprawną strukturę i zero treści stron, czyli plik, który otwiera się bez błędu w każdym czytniku i nic nie pokazuje

// Gałąź FPC funkcji InflateStrFromPosition, po v3.539.16.
// StartPos liczone od 1 tak jak w gałęzi Delphi; przytnij je, a potem
// zamień na przesunięcie wskaźnika liczone od 0 dokładnie raz, na granicy.
If (StartPos < 1) Then
  StartPos := 1;
If (Length(Input) = 0) Or (StartPos > Length(Input)) Then
  Exit;
...
strm.next_in  := Pointer(PAnsiChar(Input) + StartPos - 1);
strm.avail_in := Length(Input) - StartPos + 1;

Test regresyjny, który tego pilnuje, jest najmniejszy z możliwych: spakuj ładunek, rozpakuj go od pozycji 0 i od pozycji 1 i sprawdź, że oba zwracają ten sam ładunek i oba zgłaszają Consumed równe pełnej długości strumienia. Strumień RFC 1950 ma dwubajtowy nagłówek i czterobajtową stopkę Adler-32, więc przesunięcie o jeden na którymkolwiek końcu to nie subtelne uszkodzenie, tylko strumień, który albo nie chce się zacząć, albo nie chce się skończyć. Lekcja dotyczy granicy, nie zlib: kiedy parametr funkcji jest zdefiniowany w jednej bazie indeksu, a implementacja pod spodem używa drugiej, konwersja należy do dokładnie jednej linii, a test musi wywołać funkcję z wartością, która te dwie bazy rozróżnia

Dlaczego krótki odczyt TStream.Read nie oznacza końca strumienia?

Bo TStream.Read może zwrócić mniej bajtów, niż poproszono, z dowolnego powodu, jaki mu się podoba, i tylko zwrot 0 znaczy, że dalej nic nie ma. TMemoryStream i TFileStream na lokalnym dysku prawie zawsze wypełniają żądanie i dlatego kod, który traktuje zwrócenie mniejszej liczby bajtów niż żądana jako koniec pliku, przechodzi każdy test, który ich używa. Strumienie sieciowe, strumienie dekompresji i każdy potomek TStream napisany przez klienta potrafią zwrócić dwa bajty, gdy poprosisz o sześćdziesiąt cztery tysiące, i mieć za sobą gigabajty

TPLBuffer to czytnik, przez który przechodzi każdy parser w PDF Library for Delphi, i potrafi opakować AnsiString, wskaźnik, tablicę bajtów albo TStream. Jego cztery zapytania skanujące, DistanceToByte, DistanceToOtherByte, DistanceToAnyByte i DistanceToOtherBytes, wszystkie zwracające Int64, czytają źródło blokami po 64 KB w poszukiwaniu separatora i mówią, jak daleko on jest, nie ruszając pozycji logicznej. Każda pętla kończyła się na Until ReadCount < BlockSize. Dla trzech źródeł w pamięci to poprawne, bo ReadIntoBuffer zawsze dostarcza pełny blok aż do ostatniego. Dla źródła strumieniowego oznacza to, że skan poddaje się przy pierwszym krótkim odczycie, zgłasza separator jako nieobecny, a tokenizer wyżej decyduje, że obiekt kończy się tam, gdzie się nie kończy

Obsługa krótkich odczytów w buforze strumienia PDFlibPas: DistanceToByte skanuje bloki po 64 KB, stara pętla traktowała Until ReadCount < BlockSize jako koniec danych i poddawała się przy pierwszym krótkim odczycie, a naprawiona pętla biegnie aż ReadCount równa się zero, znajduje separator i przywraca pozycję w bloku finally
Strumień może zwrócić dwa bajty, gdy poprosisz o sześćdziesiąt cztery tysiące, więc zero jest jedynym sygnałem końca danych, któremu skan może zaufać, a klauzula finally przywraca pozycję logiczną, gdy separator zostanie znaleziony i pętla wyjdzie wcześniej
// TPLBuffer.DistanceToByte, pętla po v3.539.6.
// Zero to jedyny sygnał końca danych, jaki definiuje TStream.Read.
TempPosition := FPosition;
Try
  Repeat
    ReadCount := ReadIntoBuffer(@TempBuffer[0], BlockSize);
    For TestPos := 0 To ReadCount - 1 Do
      If TempBuffer[TestPos] = Value Then
      Begin
        Result := TotalSkipped + TestPos;
        Exit;
      End;
    Inc(TotalSkipped, ReadCount);
  Until ReadCount = 0;
Finally
  FPosition := TempPosition;   // podgląd nie może ruszać czytnika
End;

Test, który to przypina, to potomek TMemoryStream, którego przeciążone Read ogranicza każde żądanie do dwóch bajtów. Opakuj w nim łańcuch aaaaaX, ustaw pozycję bufora na 1, a wszystkie cztery zapytania muszą zgłosić odległość 4 do X, zostawić po sobie pozycję 1 i zwrócić -1 dla bajtu, którego tam nie ma. Przed poprawką pierwsze zapytanie widziało dwa bajty, uznawało strumień za wyczerpany i zwracało -1. finally ma takie samo znaczenie jak warunek pętli: Exit z wnętrza skanu to normalna ścieżka sukcesu i pozycja logiczna musi być na niej przywrócona tak samo jak przy pełnym przebiegu pętli

Jeden kod źródłowy, dwa kompilatory, jeden zestaw asercji

Dyscyplina, która z tych pięciu przypadków wynikła, mówi, że zdanie build Delphi przechodzi jest dowodem o Delphi, nie o kodzie źródłowym. Od wersji v3.539.16 zestaw Delphi DUnitX i konsolowy zestaw Free Pascala zawierają ten sam plik Tests\CrossCompilerSemantics.inc z jedną procedurą RunCrossCompilerFileSemantics, która buduje dwustronicowy dokument ze skompresowaną treścią przez TPDFlib, zapisuje go, zapisuje ponownie jako QDF przez SaveQDFToFile, naprawia QDF przez RepairQDFFile, szyfruje zwykły plik AES-128 przez EncryptFile i maskę uprawnień z EncodePermissions, a potem wczytuje każdy artefakt na nowo i sprawdza na obu kompilatorach to samo: liczba stron to 2, tytuł przeżywa, tekst drugiej strony wyciąga się bez szwanku ze zwykłego, naprawionego i zaszyfrowanego pliku, błędne hasło jest odrzucane z niezerowym LastErrorCode, EncryptionStrength wynosi 128, EncryptionAlgorithm wynosi 2, a poszczególne bity uprawnień z GetUserPermissions wracają dokładnie takie, jakie zakodowano

Porównanie jest celowo znormalizowane, a nie bajt w bajt. Szyfrowanie losuje sole, a zapisujący przydziela identyfikatory dokumentu, więc nie oczekuje się, że oba buildy wypuszczą identyczne pliki; oczekuje się plików, które znaczą to samo, i asercje są sformułowane na tym poziomie. Noga z QDF jest tam specjalnie przez błąd przesunięcia: QDF z dwiema stronami i bez treści przechodzi sprawdzenie liczby stron i pada na sprawdzeniu ekstrakcji tekstu, a macierz testów wymusza to drugie. Każda przyszła poprawka, która jest no-opem pod jednym kompilatorem, a zmianą zachowania pod drugim, a opisuje to cztery z pięciu powyższych, musi teraz przejść te same asercje dwa razy, zanim trafi do wydania

Druga, linkowana połowa tego samego portu, czyli uzgodnienie obiektów OMF z Delphi z oczekiwaniami COFF Free Pascala, to osobna historia w artykule o linkowaniu obiektów OMF i COFF w FPC na Win32, a strukturalne utwardzenie tego samego czytnika TIFF przeciw BigTIFF i plikom kafelkowym jest w notatkach o wbudowanym dekoderze TIFF. Dekodery z tego artykułu i leżący teraz pod nimi test międzykompilatorowy są w PDF Library for Delphi dla Delphi, C++Buildera i Free Pascala, gdzie ten sam kod źródłowy ma zarabiać na ten sam wynik pod każdym kompilatorem, który obsługuje, a nie dostawać go od jednego z nich