Při portu kódu CCITT, TIFF, PNG, Flate a stream bufferů na Free Pascal našla PDF Library for Delphi pět vad dekodérů a každá z nich roky procházela kompletní testovací sadou pro Delphi. Žádná z nich nebyla chyba kompilátoru. Každá byl Pascal, který Delphi zrovna vykonal správně kvůli detailu implementace: skrytý výsledný parametr, který si aliasoval pole volajícího; větev pro hodnotu mimo rozsah, za kterou se nikdo nikdy nedostal; nulově dlouhý buffer, jehož jedinou zábranou byl přepínač range checkingu; offset v bázi 1-based, který jako 1 předávala jen jedna cesta kódu; a kontrakt TStream.Read, který streamy v paměti nikdy nevyzkoušejí. Vyměňte kompilátor nebo hoďte témuž kódu deformovaný soubor a náhoda přestane držet
Dál následuje konkrétní tvar každé z nich, oprava a disciplína, která z nich vzešla: tentýž zdroják teď musí na obou kompilátorech dávat tutéž sémantiku dokumentu a testovací include hlídá, že ho opravdu dává. Sourozenecký článek o kalení parseru PDF v Pascalu proti zlomyslným souborům pokryl šířku integerů, hloubku rekurze a neinicializované buffery. Tady jde o jinou třídu selhání: o kód, který byl špatný odjakživa a kompilátor ho potichu zakrýval
Proč funkce vracející dynamické pole funguje bez SetLength na Delphi?
Protože Delphi předává vlastní proměnnou volajícího jako skrytý výsledný parametr, takže funkce, která svůj výsledek nikdy nealokuje, může i tak zapisovat do pole, které alokoval volající. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray je vyhledání referenční čáry v srdci dvourozměrného dekódování Group 3 a Group 4: pro aktuální pozici a0 a barvu aktuálního běhu projde měnící prvky předchozího scanline — b1 a b2 dvourozměrného kódovacího schématu ITU-T T.4 a T.6 — a vrátí je jako dvouslotové pole. Původní funkce zapisovala do Result[0] a Result[1] a na Result nezavolala SetLength vůbec
To by mělo spadnout na prvním zápisu a na Free Pascalu skutečně spadne. Na Delphi to nespadlo nikdy, protože obě volání v dekodéru vypadají takhle: deklarujte b: TCCITTIntegerArray, jednou před smyčkou scanline zavolejte SetLength(b, 2) a pak uvnitř smyčky přiřaďte b := GetNextChangingElement(a0, IsWhite) a čtěte b[0] a b[1]. Příručka jazyka Delphi praví, že funkce, jejímž výsledkem je long string, dynamické pole nebo jiný managed typ, dostává výsledek jako dodatečný parametr var, a kompilátor v praxi předává adresu cíle přiřazení. Result uvnitř funkce tedy je samotné b, už dvouprvkové, a každý zápis dopadne do paměti, kterou vlastní volající. Free Pascal podá funkci čerstvé pole s nil a přiřadí ho do b až poté — a to je výklad kontraktu, proti kterému měl být kód napsaný od začátku
Aliasing navíc nesl sémantiku, na které dekodér závisí. Result[0] se přiřadí, jen když sken najde prvek větší než a0, a Result[1] jen když existuje ještě prvek za ním, takže při neúspěchu sloty ponechají, co v b zbylo z předchozí iterace. Zjevná oprava — alokovat dva sloty a při každém volání je vynulovat — by ten přenos zničila a na Delphi změnila dekódovaný výstup. Nasazená oprava je hlídka místo resetu: na Delphi je mrtvým kódem a dekódovací cesta zůstává byte za bytem taková, jaká byla, a na Free Pascalu promění pád v zamýšlené chování. Té asymetrie je celý smysl, protože oprava musela být no-op na kompilátoru, na kterém už kód produkoval ověřený výstup
Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
IsWhite: Boolean): TCCITTIntegerArray;
Begin
// Delphi sem dorazí s dvouprvkovým polem volajícího aliasovaným
// jako Result, takže je to tady no-op. FPC dorazí s nil.
If (Length(Result) < 2) Then
SetLength(Result, 2);
...
// Result[0] / Result[1] se nadále zapisují jen při zásahu, takže
// neúspěch ponechá hodnoty z předchozí iterace přesně jako dřív
End;
Počet, který přežil svá data: položka adresáře TIFF
Když zneplatníte pole, musíte ve stejném příkazu zneplatnit i jeho počet, jinak počtu uvěří kód, který pole nikdy nevidí. Položka TIFF image file directory (TIFF 6.0 §2, dvanáctibajtové rozvržení tag, type, count a value-or-offset) nese 32bitový počet přímo ze souboru a PDF Library for Delphi každou čte přes PopDE: TTIFFEntry, záznam s Tag, TagType, Length, Offset a dekódovanými poli IntegerValues a DoubleValues. Původní kód ověřil, jestli Offset + TypeSize * Length nepřesahuje konec souboru, a pokud ano, nastavil oběma polím nulovou délku. Result.Length přitom ponechal na hodnotě ze souboru
Odtud se rozbily dvě věci. Funkce končí fallbackem, který říká „je-li Length nula, dej položce jeden nulový prvek“, aby volající mohli kdykoli číst prvek nula. Protože se Length na cestě mimo rozsah nikdy nevynuloval, tenhle fallback se nespustil právě v jediném případě, pro který existoval. A volající prvek nula čtou opravdu, bezpodmínečně: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip a další tucty bere E.IntegerValues[0] a tabulky stripů dělají Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), tedy kopírují Length krát čtyři bajty z pole, které žádné nemá. Vynulované pole s živým počtem je nebezpečné přísně víc než nekontrolované, protože nekontrolované aspoň drží bajty, které tvrdí, že má
Druhý problém bylo pořadí. Obě volání SetLength běžela před testem rozsahu a velikost si brala ze souborového počtu, takže nepřátelská položka si mohla vyžádat alokaci v řádu gigabajtů dřív, než proběhne jediná kontrola platnosti. Na Delphi výslednou výjimku spolkl handler výše na cestě načítání obrázku a soubor se prostě nepodařilo načíst, a proto si toho nikdo nevšiml; doopravdy se stala událost out-of-memory, kterou si vybral soubor. Oprava posouvá alokaci za test a počet nechává cestovat s daty
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
> Length(Source);
If OutOfRange Then
Begin
Result.Length := 0; // počet odchází s hodnotami
SetLength(Result.IntegerValues, 0);
SetLength(Result.DoubleValues, 0);
End
Else
Begin
SetLength(Result.IntegerValues, Result.Length); // teprve teď
SetLength(Result.DoubleValues, Result.Length);
End;
// ... později konečně stávající fallback dorazí ke svému případu:
If (Result.Length = 0) Then
Begin
SetLength(Result.IntegerValues, 1);
Result.IntegerValues[0] := 0;
End;
Na téhle opravě není nic specifického pro kompilátor, a právě proto patří do tohohle seznamu. Vada byla latentní na Delphi ze stejného důvodu jako na Free Pascalu: žádný testovací soubor neměl položku adresáře ukazující za konec souboru. Port ji neodhalil. Odhalilo čtení kódu s otázkou „co tady za mě dělá Delphi, co nedělám sám“
Co se stane, když PNG IHDR tvrdí barevný typ, který formát nedefinuje?
PDF Library for Delphi teď obrázek odmítne, než se spustí row filtry; před v3.539.2 spočítala nulabajtový scanline a předala unfilter smyčkám prázdný buffer. ISO 15948 §11.2.2 definuje chunk IHDR a tabulka 11.1 uvádí šest legálních kombinací barevného typu a bitové hloubky: grayscale o 1, 2, 4, 8 nebo 16 bitech, indexed color o 1, 2, 4 nebo 8 a truecolor, grayscale s alfou a truecolor s alfou o 8 nebo 16. TPNGReader ověřoval pole kompresní metody a filtrační metody v IHDR a FColorType i bitovou hloubku pustil nedotčené
Kód row filtrů si všechno počítá z Case FColorType Of, který každý barevný typ mapuje na počet komponent. Barevný typ mimo šestici propadne do větve Else, kde je SourceComponents 0, takže ScanlineByteCount je 0, takže na SetLength(PreviousScanline, 0) hned navazuje FillChar(PreviousScanline[0], ScanlineByteCount, 0). Indexování prvku nula prázdného dynamického pole je adresa spočtená z nil. Se vypnutým range checkingem je nulabajtové plnění přes tuhle adresu tichý no-op a dekodér pokračuje řádky, které neexistují; se zapnutým je to ERangeError na prvním obrázku; a volání Move, která následují, jsou krok od access violation. Kterou z těch možností dostanete, rozhodují kompilátor a přepínače buildu, ne nic, co by rozhodl dekodér — a přesně to je známka, že dekodér ve skutečnosti nerozhodl vůbec
Opravou je tabulka ze specifikace, nasazená tam, kde se už ověřovala ostatní pole IHDR: COLOR_GRAYSCALE akceptuje FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE akceptuje [1, 2, 4, 8] a COLOR_RGB, COLOR_GRAYSCALEALPHA a COLOR_RGBALPHA akceptují [8, 16]; cokoliv jiného vynuluje ValidImage a obrázek je odmítnut se zachovanou šířkou a výškou kvůli diagnostice. Chunk pHYs kratší než svých devět bajtů se zavřel ve stejném průchodu, protože čtečka DPI indexovala S[1] až S[8] v řetězci, který krátký chunk nechal prázdný
1-based offset zacházený jako 0-based ukazatel
InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString bere StartPos v bázi 1-based, protože jejím vstupem je AnsiString a implementace pro Delphi adresuje zlib vstup jako @Input[StartPos]. Implementace pro Free Pascal, napsaná nad paszlib, aby oba cíle na Windows linkovaly kompresi staticky, nastavila next_in na PAnsiChar(Input) + StartPos a avail_in na Length(Input) - StartPos. To je pointerová aritmetika a ta je 0-based. Předáte 1 — což pro tuhle funkci znamená „začni na začátku“ — a build pro FPC začne inflatovat na druhém bajtu a skončí o bajt před koncem
Proč to přežilo: jediný volající, kam se většina testů dostane, je InflateStr, který předává 0. A nula náhodou je správný 0-based offset, takže oba buildy si rozuměly u každého obyčejného volání InflateStr a každého testu, který jím prošel. TPDFDocument.DecodeAllStreams, rutina, kterou SaveQDFToFile a ConvertFileToQDF používají k rozbalení streamů s jediným FlateDecode do čitelné podoby, předává 1. Na buildu pro FPC způsobila přeskočená zlib hlavička selhání inflate, ale zlib stream i tak ohlásil nenulové Consumed za bajty, které prohlédl, takže DecodeAllStreams vzal prázdný payload jako úspěšný dekód a nahradil každý content stream prázdným řetězcem. Výsledné QDF mělo správný počet stránek, platnou strukturu a žádný obsah stránek — soubor, který se v každém prohlížeči otevře bez chyby a nezobrazí nic
// Větev FPC ve InflateStrFromPosition, po v3.539.16.
// StartPos je 1-based jako ve větvi Delphi; nejdřív ho ořízněte a pak
// převeďte na 0-based pointerový offset přesně jednou, na hranici.
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;
Regrese, která tohle hlídá, je ta nejmenší možná: deflatujte payload, inflatujte ho z pozice 0 a z pozice 1 a ověřte, že obě volání vrátí tentýž payload a obě hlásí Consumed rovné plné délce streamu. Stream podle RFC 1950 má dvoubajtovou hlavičku a čtyřbajtový trailer Adler-32, takže off-by-one na kterémkoli konci není jemné poškození, ale stream, který buď nenastartuje, nebo nedoběhne. Poučení se týká hranice, ne zlib: když je parametr funkce definovaný v jedné bázi indexů a implementace pod ním používá druhou, konverze patří přesně na jeden řádek a test ho musí zavolat s hodnotou, kterou se obě báze odliší
Proč není krátké TStream.Read konec streamu?
Protože TStream.Read smí vrátit méně bajtů, než bylo žádáno, z jakéhokoli důvodu se mu zlíbí, a jen návrat 0 znamená, že víc toho není. TMemoryStream a TFileStream na lokálním disku žádost skoro vždy zaplní, a proto kód, který bere „vrátilo se míň, než jsem chtěl“ za konec souboru, projde každým testem, který je používá. Streamy nad sítí, dekompresní streamy a kterýkoli potomek TStream, kterého si napsal zákazník, můžou na žádost o šedesát čtyři tisíce vrátit dva bajty a mít za sebou stále gigabajty
TPLBuffer je čtečka, kterou prochází každý parser v PDF Library for Delphi, a umí obalit AnsiString, ukazatel, bajtové pole nebo TStream. Její čtyři skenovací dotazy — DistanceToByte, DistanceToOtherByte, DistanceToAnyByte a DistanceToOtherBytes, všechny vracející Int64 — čtou zdroj v blocích po 64 KB, hledají oddělovač a hlásí, jak je daleko, aniž by hnuly logickou pozicí. Každá smyčka končila Until ReadCount < BlockSize. U tří zdrojů v paměti je to správně, protože ReadIntoBuffer dodává celý blok až do posledního. U streamového zdroje to znamená, že sken kapituluje při prvním krátkém čtení, ohlásí oddělovač jako chybějící a tokenizer nad ním rozhodne, že objekt končí tam, kde nekončí
// TPLBuffer.DistanceToByte, smyčka po v3.539.6.
// Nula je jediný signál konce dat, který TStream.Read definuje.
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 nesmí pohnout čtečkou
End;
Test, který to přibije, je potomek TMemoryStream, jehož přepsaná Read zkracuje každou žádost na dva bajty. Zabalte do něj řetězec aaaaaX, nastavte pozici bufferu na 1 a všechny čtyři dotazy musí hlásit vzdálenost 4 k X, ponechat poté pozici na 1 a pro bajt, který tam není, vrátit -1. Před opravou první dotaz viděl dva bajty, usoudil, že stream došel, a vrátil -1. Finally je stejně důležitý jako podmínka smyčky: Exit zevnitř skenu je normální cesta úspěchu a logická pozice se musí obnovit i na ní, nejen když smyčka doběhne až do konce
Jeden zdroják, dva kompilátory, jedna sada asercí
Disciplína, která z těchhle pěti vzešla, zní: „build pro Delphi prochází“ je důkaz o Delphi, ne o zdrojáku. Od v3.539.16 zahrnují testovací sada DUnitX pro Delphi i konzolová sada pro Free Pascal tentýž Tests\CrossCompilerSemantics.inc, jedinou rutinu RunCrossCompilerFileSemantics, která postaví dvoustránkový dokument s komprimovaným obsahem přes TPDFlib, uloží ho, znovu ho uloží jako QDF přes SaveQDFToFile, QDF opraví pomocí RepairQDFFile, zašifruje nešifrovaný soubor AES-128 přes EncryptFile s maskou oprávnění z EncodePermissions a pak znovu načte každý artefakt a na obou kompilátorech ověří totéž: počet stránek je 2, titulek přežije, text druhé stránky se vytěží bez poškození z nešifrovaného, opraveného i šifrovaného souboru, špatné heslo je odmítnuto s nenulovým LastErrorCode, EncryptionStrength je 128, EncryptionAlgorithm je 2 a jednotlivé bity oprávnění z GetUserPermissions se vrátí přesně tak, jak byly zakódované
Porovnání je záměrně normalizované, ne byte za byte. Šifrování losuje náhodné soli a writer přiděluje identifikátory dokumentu, takže se od obou buildů nečekají identické soubory; čekají se soubory, které znamenají totéž, a aserce jsou formulované přesně na téhle úrovni. Větev QDF je tam kvůli chybě offsetu: QDF se dvěma stránkami a bez obsahu projde kontrolou počtu stránek a propadne kontrolou extrakce textu a matice ověřuje tu druhou. Každá budoucí oprava, která je na jednom kompilátoru no-op a na druhém změnou chování — což popisuje čtyři z pěti výše — teď musí projít stejnými asercemi dvakrát, než se nasadí
Linková polovina téhož portu, dohoda mezi objekty OMF od Delphi a očekáváním COFF na straně Free Pascalu, je samostatný příběh v článku o linkování objektů FPC Win32 z OMF na COFF a strukturální kalení téhož čteče TIFF proti BigTIFF a tiled souborům najdete v poznámkách k vestavěnému dekodéru TIFF. Dekodéry z tohohle článku i cross-kompilátorový test, který teď leží pod nimi, dodává PDF Library for Delphi pro Delphi, C++Builder a Free Pascal, kde se od téhož zdrojáku čeká, že si stejný výsledek na každém cíleném kompilátoru vyslouží sám, místo aby mu ho jeden přisoudil