PDF Library for Delphi pronašao je pet grešaka u dekoderima dok je svoj CCITT, TIFF, PNG, Flate i stream-buffer kod prebacivao na Free Pascal, a svaka od njih godinama je prolazila cijeli Delphi test suite. Nijedna nije bila bug kompajlera. Svaka je bila Pascal koji je Delphi slučajno izvršavao ispravno zbog pojedinosti implementacije: skriveni result parametar koji je alijasirao pozivateljev niz, grana izvan raspona koju nitko nikad nije čitao dalje, buffer nulte duljine kojemu je jedina zaštita bio range-check prekidač, 1-bazni offset koji je samo jedan put koda ikad proslijedio kao 1, i ugovor TStream.Read koji streamovi u memoriji nikad ne vježbaju. Promijenite kompajler ili istoj kodi date neispravnu datoteku, i slučajnost prestaje držati
Slijedi konkretan oblik svake od njih, popravak i disciplina koja je iz toga proizašla: isti izvorni kod sada mora proizvesti istu semantiku dokumenta na oba kompajlera, a test include provjerava da je tako. Srodni članak o učvršćivanju Pascal PDF parsera protiv zlonamjernih datoteka pokrio je širinu cijelih brojeva, dubinu rekurzije i neinicijalizirane buffere. Ovaj je o drugoj klasi kvara: kodu koji je cijelo vrijeme bio pogrešan, a kompajler ga je tiho pokrivao
Zašto funkcija koja vraća dinamički niz radi bez SetLength na Delphiju?
Zato što Delphi prosljeđuje samu pozivateljevu varijablu kao skriveni result parametar, pa funkcija koja nikad ne alocira svoj rezultat ipak može pisati u niz koji je alocirao pozivatelj. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray je pretraga referentnog retka u srcu dvodimenzionalnog Group 3 i Group 4 dekodiranja: za zadanu trenutačnu poziciju a0 i boju trenutačnog niza traži promjenjive elemente prethodnog scanlinea, b1 i b2 iz ITU-T T.4 i T.6 dvodimenzionalne coding sheme, i vraća ih kao niz s dva slota. Izvorna funkcija pisala je Result[0] i Result[1] i uopće nije pozivala SetLength nad Result
To bi trebalo pasti na prvom upisu, i na Free Pascalu pada. Na Delphiju nikad nije, jer oba pozivna mjesta u dekoderu izgledaju ovako: deklarirajte b: TCCITTIntegerArray, jednom prije petlje po scanlineima pokrenite SetLength(b, 2), a zatim unutar petlje dodijelite b := GetNextChangingElement(a0, IsWhite) i čitajte b[0] i b[1]. Delphi vodič za jezik navodi da funkcija čiji je rezultat long string, dinamički niz ili drugi upravljani tip prima taj rezultat kao dodatni var parametar, a u praksi kompajler prosljeđuje adresu odredišta dodjele. Zato je Result unutar funkcije sam b, već dva elementa dug, i svaki upis završava u memoriji koju posjeduje pozivatelj. Free Pascal funkciji predaje svjež nil niz i dodjeljuje ga u b nakon toga, što je ono čitanje ugovora prema kojem je kod trebalo napisati od početka
Alijasiranje je nosilo i semantiku na kojoj dekoder ovisi. Result[0] dodjeljuje se samo kad skeniranje nađe element veći od a0, a Result[1] samo kad postoji element nakon njega, pa na promašaju slotovi zadržavaju ono što je prethodna iteracija ostavila u b. Očiti popravak — alocirati dva slota i nulirati ih pri svakom pozivu — uništio bi to prenošenje i promijenio dekodirani izlaz na Delphiju. Popravak koji je isporučen je zaštita umjesto resetiranja: na Delphiju je mrtav kod i put dekodiranja ostaje bajt po bajt onakav kakav je bio, a na Free Pascalu kvar pretvara u namjeravano ponašanje. Ta asimetrija je cijela poanta, jer popravak je morao biti no-op na kompajleru gdje je kod već davao provjereni izlaz
Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
IsWhite: Boolean): TCCITTIntegerArray;
Begin
// Delphi stiže ovamo s pozivateljevim nizom od dva elementa alijasiranim
// kao Result, pa je ovo ondje no-op. FPC stiže s nil.
If (Length(Result) < 2) Then
SetLength(Result, 2);
...
// Result[0] / Result[1] i dalje se pišu samo na pogodak, pa promašaj
// zadržava vrijednosti prethodne iteracije točno kao i prije
End;
Brojač koji je nadživio svoje podatke: TIFF unos direktorija
Kad invalidirate niz, morate u istoj naredbi invalidirati i njegov brojač, inače će brojač vjerovati kod koji niz nikad ne vidi. TIFF unos direktorija slike (TIFF 6.0 §2, 12-bajtni raspored tag, type, count i value-or-offset) nosi 32-bitni brojač izravno iz datoteke, a PDF Library for Delphi čita svaki kroz PopDE: TTIFFEntry, zapis s Tag, TagType, Length, Offset te dekodiranim nizovima IntegerValues i DoubleValues. Izvorni kod provjeravao je prelazi li Offset + TypeSize * Length kraj datoteke, i ako prelazi, postavljao je oba niza na nultu duljinu. Result.Length ostavljao je na vrijednosti iz datoteke
Dvije stvari otišle su po zlu odatle. Funkcija završava fallbackom koji kaže "ako je Length nula, daj unosu jedan element s vrijednošću nula", kako bi pozivatelji uvijek mogli čitati element nula. Budući da Length nikad nije bio očišćen na putu izvan raspona, taj fallback nikad se nije aktivirao za jedini slučaj zbog kojeg je postojao. A pozivatelji doista čitaju element nula, bezuvjetno: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip i još desetak njih uzima E.IntegerValues[0], a strip tablice rade Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), kopirajući Length puta po četiri bajta iz niza koji ih nema. Očišćen niz sa živim brojačem strogo je opasniji od neprovjerenog, jer neprovjereni barem drži bajtove koje tvrdi da drži
Drugi problem bio je redoslijed. Dva poziva SetLength izvršavala su se prije provjere raspona, dimenzionirana brojačem iz datoteke, pa je zlonamjerni unos mogao zatražiti alokaciju od više gigabajta prije ijedne provjere valjanosti. Na Delphiju je nastalu iznimku uhvatio rukovatelj više u putu učitavanja slike i datoteka se jednostavno nije učitala, zato nitko nije primijetio; ono što se stvarno dogodilo bio je out-of-memory događaj koji je odabrala datoteka. Popravak premješta alokaciju nakon provjere i čini da brojač putuje s podacima
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
> Length(Source);
If OutOfRange Then
Begin
Result.Length := 0; // brojač ide zajedno s vrijednostima
SetLength(Result.IntegerValues, 0);
SetLength(Result.DoubleValues, 0);
End
Else
Begin
SetLength(Result.IntegerValues, Result.Length); // tek sada
SetLength(Result.DoubleValues, Result.Length);
End;
// ... kasnije, postojeći fallback konačno doseže slučaj za koji je bio:
If (Result.Length = 0) Then
Begin
SetLength(Result.IntegerValues, 1);
Result.IntegerValues[0] := 0;
End;
Ništa u ovom popravku nije specifično za kompajler, i upravo zato pripada na ovaj popis. Greška je bila latentna na Delphiju iz istog razloga iz kojeg je bila latentna na Free Pascalu: nijedna testna datoteka nije imala unos direktorija koji pokazuje iza kraja datoteke. Port ju nije razotkrio. Čitanje koda s pitanjem "što Delphi ovdje radi za mene umjesto mene" jest
Što se dogodi kad PNG IHDR tvrdi color type koji format ne definira?
PDF Library for Delphi sada odbija sliku prije nego se pokrenu row filteri; prije v3.539.2 računao je scanline od nula bajtova i predavao unfilter petljama prazan buffer. ISO 15948 §11.2.2 definira IHDR chunk, a Tablica 11.1 navodi šest zakonitih kombinacija color typea i bit deptha: grayscale na 1, 2, 4, 8 ili 16 bita, indexed color na 1, 2, 4 ili 8, te truecolor, grayscale s alphom i truecolor s alphom na 8 ili 16. TPNGReader validirao je polja compression method i filter method u IHDR-u, a FColorType i bit depth propuštao je netaknute
Kod row filtera sve dimenzionira iz Case FColorType Of koji svaki color type preslikava u broj komponenti. Color type izvan tih šest upada u Else granu, gdje je SourceComponents 0, pa je ScanlineByteCount 0, pa nakon SetLength(PreviousScanline, 0) odmah slijedi FillChar(PreviousScanline[0], ScanlineByteCount, 0). Indeksiranje nultog elementa praznog dinamičkog niza je adresa izračunata iz nil. S isključenom provjerom raspona, popunjavanje od nula bajtova kroz tu adresu tiho je no-op i dekoder maršira kroz retke koji ne postoje; s uključenom provjerom raspona to je ERangeError na prvoj slici; a Move pozivi koji slijede korak su od access violationa. Koje ćete od toga dobiti ovisi o kompajleru i build prekidačima, a ne o bilo čemu što je dekoder odlučio, i to je znak da dekoder uopće nije odlučivao
Popravak je tablica iz specifikacije, primijenjena ondje gdje su se ostala IHDR polja već provjeravala: COLOR_GRAYSCALE prihvaća FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE prihvaća [1, 2, 4, 8], a COLOR_RGB, COLOR_GRAYSCALEALPHA i COLOR_RGBALPHA prihvaćaju [8, 16]; sve ostalo čisti ValidImage i slika se odbija s netaknutom širinom i visinom za dijagnostiku. pHYs chunk kraći od svojih devet bajtova zatvoren je u istom prolazu, jer DPI čitač indeksirao je S[1] do S[8] stringa koji je kratki chunk ostavio praznim
1-bazni offset tretiran kao 0-bazni pokazivač
InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString prima 1-bazni StartPos, jer mu je ulaz AnsiString, a Delphi implementacija adresira zlib ulaz kao @Input[StartPos]. Free Pascal implementacija, pisana prema paszlib kako bi oba Windows targeta statički linkala kompresiju, postavljala je next_in na PAnsiChar(Input) + StartPos i avail_in na Length(Input) - StartPos. To je pointer aritmetika, i ona je 0-bazna. Proslijedite 1, što za ovu funkciju znači "počni od početka", i FPC build počinje inflatati od drugog bajta i zaustavlja se jedan bajt prije kraja
Preživjelo je zato što je jedini pozivatelj do kojeg većina testova stigne InflateStr, koji prosljeđuje 0. Nula je slučajno ispravan 0-bazni offset, pa su se dva builda slagala na svakom običnom InflateStr pozivu i na svakom testu koji je prošao kroz njega. TPDFDocument.DecodeAllStreams, rutina koju SaveQDFToFile i ConvertFileToQDF koriste za razvijanje streamova s jednim FlateDecode u čitljiv oblik, prosljeđuje 1. Na FPC buildu preskočeno zlib zaglavlje učinilo je da inflate padne, ali zlib stream i dalje je prijavljivao Consumed različit od nule za bajtove koje je pregledao, pa je DecodeAllStreams uzeo prazan payload kao uspješno dekodiranje i zamijenio svaki content stream praznim stringom. Dobiveni QDF imao je točan broj stranica, valjanu strukturu i nikakav sadržaj stranice, što je datoteka koja se bez greške otvara u svakom pregledniku i ne prikazuje ništa
// FPC grana funkcije InflateStrFromPosition, nakon v3.539.16.
// StartPos je 1-bazni kao i u Delphi grani; ograničite ga, pa pretvorite
// u 0-bazni pointer offset točno jednom, na granici.
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;
Regresijski test koji to čuva najmanji je mogući: deflatati payload, inflatati ga s pozicije 0 i s pozicije 1, i tvrditi da oba vraćaju isti payload i da oba prijavljuju Consumed jednak punoj duljini streama. RFC 1950 stream ima dvobajtno zaglavlje i četverobajtni Adler-32 trailer, pa off-by-one na bilo kojem kraju nije suptilna korupcija, nego stream koji ili ne uspije startati ili ne uspije završiti. Lekcija je o granici, ne o zlibu: kad je parametar funkcije definiran u jednoj indeksnoj bazi, a implementacija ispod koristi drugu, pretvorba pripada u točno jedan redak, a test je mora pozvati s vrijednošću koja razlikuje te dvije baze
Zašto kratki TStream.Read nije kraj streama?
Zato što TStream.Read smije vratiti manje bajtova od zatraženih iz bilo kojeg razloga, i samo povratna vrijednost 0 znači da više ničega nema. TMemoryStream i TFileStream na lokalnom disku gotovo uvijek ispune zahtjev, zato kod koji "vratio je manje nego što sam tražio" tretira kao kraj datoteke prolazi svaki test koji ih koristi. Streamovi na mreži, dekompresijski streamovi i bilo koji TStream potomak koji je napisao kupac mogu vratiti dva bajta kad se traži šezdeset i četiri tisuće, a da iza njih i dalje stoje gigabajti
TPLBuffer je čitač kroz koji prolazi svaki parser u PDF Library for Delphi, i može omotati AnsiString, pokazivač, byte array ili TStream. Njegova četiri upita za skeniranje, DistanceToByte, DistanceToOtherByte, DistanceToAnyByte i DistanceToOtherBytes, svi koji vraćaju Int64, čitaju izvor u blokovima od 64 KB tražeći delimiter i prijavljuju koliko je daleko bez pomicanja logičke pozicije. Svaka petlja završavala je s Until ReadCount < BlockSize. Za tri izvora u memoriji to je ispravno, jer ReadIntoBuffer uvijek isporuči puni blok do posljednjega. Za izvor tipa stream to znači da skeniranje odustaje na prvom kratkom čitanju, prijavljuje delimiter kao odsutan, a tokenizer iznad njega odlučuje da objekt završava ondje gdje ne završava
// TPLBuffer.DistanceToByte, petlja nakon v3.539.6.
// Nula je jedini signal kraja podataka koji TStream.Read definira.
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; // peek ne smije pomicati čitač
End;
Test koji to prikiva je TMemoryStream potomak čiji Read override ograničava svaki zahtjev na dva bajta. Omotajte string aaaaaX u njega, postavite poziciju buffera na 1, i sva četiri upita moraju prijaviti udaljenost 4 do X, ostaviti poziciju na 1 nakon toga i prijaviti -1 za bajt kojeg nema. Prije popravka prvi upit vidio je dva bajta, zaključio da je stream iscrpljen i vratio -1. finally je jednako važan kao uvjet petlje: Exit iz sredine skeniranja normalan je put uspjeha, i logička pozicija mora se vratiti i na tom putu, ne samo kad petlja odradi do kraja
Jedan izvorni kod, dva kompajlera, jedan skup tvrdnji
Disciplina koja je proizašla iz ovih pet jest da je "Delphi build prolazi" dokaz o Delphiju, ne o izvornom kodu. Od v3.539.16 Delphi DUnitX suite i Free Pascal konzolni suite oba uključuju isti Tests\CrossCompilerSemantics.inc, jednu rutinu, RunCrossCompilerFileSemantics, koja gradi dokument od dvije stranice s komprimiranim sadržajem kroz TPDFlib, sprema ga, pa ga ponovno sprema kao QDF kroz SaveQDFToFile, popravlja QDF s RepairQDFFile, šifrira običnu datoteku AES-128 kroz EncryptFile i masku dopuštenja iz EncodePermissions, a zatim ponovno učitava svaki artefakt i tvrdi iste stvari na oba kompajlera: broj stranica je 2, naslov preživi, tekst druge stranice izvlači se netaknut iz obične, popravljene i šifrirane datoteke, pogrešna lozinka se odbija s LastErrorCode različitim od nule, EncryptionStrength je 128, EncryptionAlgorithm je 2, a pojedinačni bitovi dopuštenja iz GetUserPermissions vraćaju se točno onako kako su kodirani
Usporedba je namjerno normalizirana, a ne bajt po bajt. Enkripcija izvlači slučajne soli, a writer dodjeljuje identifikatore dokumenta, pa se od dva builda ne očekuje da emitiraju identične datoteke; od njih se očekuje da emitiraju datoteke koje znače istu stvar, i tvrdnje su formulirane na toj razini. QDF krak postoji upravo zbog offset buga: QDF s dvije stranice i bez sadržaja prolazi provjeru broja stranica, a pada na provjeri izvlačenja teksta, i matrica tvrdi ovo drugo. Svaki budući popravak koji je no-op na jednom kompajleru, a promjena ponašanja na drugom — što opisuje četiri od ovih pet — sada mora proći iste tvrdnje dvaput prije nego što bude isporučen
Link-time polovica istog porta, usklađivanje Delphi OMF objekata s Free Pascal COFF očekivanjima, zasebna je priča u FPC Win32 OMF na COFF linkanje objekata, a strukturno učvršćivanje istog TIFF čitača protiv BigTIFF-a i tiled datoteka opisano je u bilješkama o ugrađenom TIFF dekoderu. Dekoderi iz ovog članka, i cross-compiler test koji sada stoji pod njima, isporučuju se u PDF Library for Delphi za Delphi, C++Builder i Free Pascal, gdje se od istog izvornog koda očekuje da zasluži isti rezultat na svakom kompajleru koji podržava, a ne da mu ga jedan dodijeli