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
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
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
// 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