PDF Library for Delphi, keldama savo CCITT, TIFF, PNG, Flate ir srauto buferio kodą į Free Pascal, rado penkis dekoderių defektus, ir kiekvienas jų ilgus metus buvo praėjęs visą Delphi testų rinkinį. Nė vienas nebuvo kompiliatoriaus klaida. Kiekvienas buvo Pascal kodas, kurį Delphi tiesiog vykdė teisingai dėl vienos implementacijos smulkmenos: paslėptas rezultato parametras, sutapatinęs kviečiančiojo masyvą, šaka už intervalo ribų, per kurią niekas niekada neskaitė toliau, nulinio ilgio buferis, kurio vienintelė sargyba buvo range-check jungiklis, 1 pagrindo poslinkis, kurį tik vienas kodo kelias kada nors perdavė kaip 1, ir TStream.Read kontraktas, kurio atmintyje esantys srautai niekada neišbando. Pakeiskite kompiliatorių arba paduokite tam pačiam kodui sugadintą failą – ir atsitiktinumas nustoja galioti
Toliau aprašoma konkreti kiekvieno jų forma, pataisa ir disciplina, kuri iš to išėjo: tas pats šaltinis dabar turi duoti tą pačią dokumentų semantiką abiejuose kompiliatoriuose, ir tai tikrinantis testų include failas. Gretimas straipsnis apie Pascal PDF analizatoriaus kietinimą prieš kenkėjiškus failus nagrinėjo sveikojo skaičiaus plotį, rekursijos gylį ir neinicializuotus buferius. Šis – apie kitą nesėkmių klasę: kodą, kuris visada buvo neteisingas, o kompiliatorius jį tyliai dengė
Kodėl funkcija, grąžinanti dinaminį masyvą, Delphi veikia be SetLength?
Nes Delphi perduoda patį kviečiančiojo kintamąjį kaip paslėptą rezultato parametrą, tad funkcija, kuri niekada nealokuoja savo rezultato, vis tiek gali rašyti į masyvą, kurį alokavo kviečiantysis. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray yra atskaitos eilutės paieška dvimačio Group 3 ir Group 4 dekodavimo širdyje: gavusi dabartinę poziciją a0 ir dabartinio ruožo spalvą, ji ieško ankstesnės skenavimo eilutės kintančių elementų – ITU-T T.4 ir T.6 dvimačio kodavimo schemos b1 ir b2 – ir grąžina juos kaip dviejų vietų masyvą. Pirminė funkcija rašė Result[0] ir Result[1] ir apskritai nekvietė SetLength su Result
Tai turėtų lūžti jau pirmo įrašymo metu, ir Free Pascal taip ir atsitinka. Delphi taip niekada neatsitiko, nes abi kvietimo vietos dekoderyje atrodo taip: deklaruojamas b: TCCITTIntegerArray, prieš skenavimo eilučių kilpą vieną kartą įvykdomas SetLength(b, 2), o kilpos viduje priskiriama b := GetNextChangingElement(a0, IsWhite) ir skaitoma b[0] bei b[1]. Delphi kalbos vadovas teigia, kad funkcija, kurios rezultatas yra ilga eilutė, dinaminis masyvas ar kitas valdomas tipas, tą rezultatą gauna kaip papildomą var parametrą, o praktiškai kompiliatorius perduoda priskyrimo tikslo adresą. Taigi Result funkcijos viduje yra pats b, jau dviejų elementų ilgio, ir kiekvienas įrašymas nusileidžia į atmintį, kuri priklauso kviečiančiajam. Free Pascal funkcijai paduoda šviežią nil masyvą ir jį priskiria b po to, ir tai yra tas kontrakto skaitymas, pagal kurį kodą reikėjo rašyti nuo pat pradžių
Sutapatinimas nešė ir semantiką, nuo kurios dekoderis priklauso. Result[0] priskiriamas tik tada, kai skenavimas randa elementą, didesnį už a0, o Result[1] – tik kai po jo yra dar vienas elementas, tad nepavykus paieškai vietos išlaiko tai, ką ankstesnė iteracija paliko b. Akivaizdi pataisa – alokuoti dvi vietas ir kiekvieną kvietimą jas nulininti – būtų sunaikinusi tą pernešimą ir pakeitusi dekoduotą išvestį Delphi. Išleista pataisa yra sargyba, o ne nulinimas: Delphi ji yra negyvas kodas ir dekodavimo kelias lieka baitas į baitą toks, koks buvo, o Free Pascal ji paverčia lūžimą numatytu elgesiu. Būtent tame asimetriškume ir esmė, nes pataisa turėjo būti no-op tame kompiliatoriuje, kuriame kodas jau davė patikrintą išvestį
Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
IsWhite: Boolean): TCCITTIntegerArray;
Begin
// Delphi čia ateina su kviečiančiojo dviejų elementų masyvu, sutapatintu
// su Result, tad čia tai yra no-op. FPC ateina su nil.
If (Length(Result) < 2) Then
SetLength(Result, 2);
...
// Result[0] / Result[1] vis dar rašomi tik pataikius, tad nepataikymas
// palieka ankstesnės iteracijos reikšmes lygiai taip pat kaip anksčiau
End;
Skaičius, kuris pergyveno savo duomenis: TIFF katalogo įrašas
Nukenksmindami masyvą, jo skaičių turite nukenksminti tame pačiame sakinyje, kitaip tą skaičių patikės kodas, kuris paties masyvo niekada nemato. TIFF paveikslėlio failo katalogo įrašas (TIFF 6.0 §2, 12 baitų išdėstymas: tag, tipas, skaičius ir reikšmė-ar-poslinkis) neša 32 bitų skaičių tiesiai iš failo, ir PDF Library for Delphi kiekvieną jų skaito per PopDE: TTIFFEntry – įrašą su Tag, TagType, Length, Offset bei dekoduotais IntegerValues ir DoubleValues masyvais. Pirminis kodas tikrino, ar Offset + TypeSize * Length neišbėga už failo pabaigos, ir jei išbėgdavo, abu masyvus nustatydavo į nulinį ilgį. Result.Length jis palikdavo tokią, kokia atėjo iš failo
Nuo tos vietos sugriuvo du dalykai. Funkcija baigiasi atsarginiu keliu, kuris skelbia „jei Length lygi nuliui, duok įrašui vieną nulinės reikšmės elementą“, kad kviečiantieji visada galėtų perskaityti nulinį elementą. Kadangi už intervalo ribų esančiame kelyje Length niekada nebuvo išvaloma, tas atsarginis kelias niekada nesuveikė būtent tam vienam atvejui, kuriam egzistavo. O kviečiantieji nulinį elementą skaito besąlygiškai: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip ir dar tuzinas kitų ima E.IntegerValues[0], o juostų lentelės atlieka Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4) – nukopijuodamos Length kartų po keturis baitus iš masyvo, kuriame jų nėra. Išvalytas masyvas su gyvu skaičiumi yra griežtai pavojingesnis už nepatikrintą, nes nepatikrintasis bent jau laiko tuos baitus, kuriuos teigia turįs
Antroji problema buvo tvarka. Du SetLength kvietimai vykdavo prieš intervalo patikrinimą ir būdavo matuojami pagal failo skaičių, tad priešiškas įrašas galėjo pareikalauti kelių gigabaitų alokacijos dar prieš vienintelį pagrįstumo patikrinimą. Delphi kilusi išimtis būdavo pagaunama aukščiau paveikslėlio įkėlimo kelyje esančio apdorojimo bloko, ir failas tiesiog neįsikeldavo – štai kodėl niekas nepastebėjo; iš tikrųjų įvykdavo failo pasirinktas atminties trūkumo įvykis. Pataisa perkelia alokaciją po patikrinimo ir leidžia skaičiui keliauti kartu su duomenimis
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
> Length(Source);
If OutOfRange Then
Begin
Result.Length := 0; // skaičius keliauja kartu su reikšmėmis
SetLength(Result.IntegerValues, 0);
SetLength(Result.DoubleValues, 0);
End
Else
Begin
SetLength(Result.IntegerValues, Result.Length); // tik dabar
SetLength(Result.DoubleValues, Result.Length);
End;
// ... vėliau esamas atsarginis kelias pagaliau pasiekia atvejį, kuriam buvo skirtas:
If (Result.Length = 0) Then
Begin
SetLength(Result.IntegerValues, 1);
Result.IntegerValues[0] := 0;
End;
Šioje pataisoje nieko nėra specifinio konkrečiam kompiliatoriui – būtent todėl ji ir priklauso šiam sąrašui. Defektas Delphi buvo latentinis dėl tos pačios priežasties, dėl kurios buvo latentinis Free Pascal: joks testų failas neturėjo katalogo įrašo, rodančio už failo pabaigos. Perkėlimas jo neatskleidė. Jį atskleidė kodo skaitymas su klausimu „ką Delphi čia padaro už mane, ko aš nedarau pats“
Kas atsitinka, kai PNG IHDR deklaruoja spalvų tipą, kurio formatas neapibrėžia?
PDF Library for Delphi dabar atmeta paveikslėlį dar prieš paleisdama eilučių filtrus; iki v3.539.2 ji apskaičiuodavo nulinio baito skenavimo eilutę ir atfiltravimo kilpoms paduodavo tuščią buferį. ISO 15948 §11.2.2 apibrėžia IHDR gabalą, o 11.1 lentelė išvardija šešis teisėtus spalvų tipo ir bitų gylio derinius: pilkieji atspalviai su 1, 2, 4, 8 arba 16 bitų, indeksuota spalva su 1, 2, 4 arba 8, bei tikrosios spalvos, pilkieji su alfa ir tikrosios spalvos su alfa su 8 arba 16. TPNGReader tikrindavo IHDR glaudinimo metodo ir filtro metodo laukus, o FColorType ir bitų gylį praleisdavo nepaliestus
Eilučių filtro kodas viską matuoja pagal Case FColorType Of, kuris kiekvieną spalvų tipą susieja su komponentų skaičiumi. Spalvų tipas, kurio nėra tarp šešių, patenka į Else šaką, kur SourceComponents yra 0, tad ScanlineByteCount yra 0, tad po SetLength(PreviousScanline, 0) iškart eina FillChar(PreviousScanline[0], ScanlineByteCount, 0). Nulinio elemento indeksavimas tuščiame dinaminiame masyve yra adresas, apskaičiuotas iš nil. Kai range check išjungtas, nulinio baito užpildymas per tą adresą yra tyli no-op, ir dekoderis žygiuoja toliau per eilutes, kurių nėra; kai range check įjungtas, tai yra ERangeError jau pirmame paveikslėlyje; o Move kvietimai, einantys po to, yra per žingsnį nuo prieigos pažeidimo. Kurį variantą gausite, priklauso nuo kompiliatoriaus ir kompiliavimo jungiklių, o ne nuo to, ką dekoderis nusprendė, ir būtent tai išduoda, kad dekoderis nusprendė visai nieko
Pataisa yra lentelė iš specifikacijos, taikoma ten, kur kiti IHDR laukai jau buvo tikrinami: COLOR_GRAYSCALE priima FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE priima [1, 2, 4, 8], o COLOR_RGB, COLOR_GRAYSCALEALPHA ir COLOR_RGBALPHA priima [8, 16]; visa kita išvalo ValidImage, ir paveikslėlis atmetamas, o jo plotis ir aukštis lieka nepaliesti diagnostikai. Devynių baitų nesiekiantis pHYs gabalas buvo uždarytas tame pačiame praėjime, nes DPI skaitytuvas indeksavo S[1] iki S[8] eilutėje, kurią trumpasis gabalas buvo palikęs tuščią
1 pagrindo poslinkis, traktuojamas kaip 0 pagrindo rodyklė
InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString ima 1 pagrindo StartPos, nes jos įvestis yra AnsiString, o Delphi implementacija adresuoja zlib įvestį kaip @Input[StartPos]. Free Pascal implementacija, parašyta prieš paszlib, kad abu Windows taikiniai glaudinimą susietų statiškai, nustatydavo next_in į PAnsiChar(Input) + StartPos, o avail_in į Length(Input) - StartPos. Tai rodyklės aritmetika, ir ji yra 0 pagrindo. Perdavus 1 – o būtent tai šiai funkcijai reiškia „pradėk nuo pradžios“ – FPC rinkinys pradeda išpūtimą nuo antro baito ir sustoja vienu baitu prieš pabaigą
Jis išliko todėl, kad vienintelis kviečiantysis, kurį pasiekia dauguma testų, yra InflateStr, o jis perduoda 0. Nulis atsitiktinai yra teisingas 0 pagrindo poslinkis, tad abu rinkiniai sutarė kiekviename paprastame InflateStr kvietime ir kiekviename teste, ėjusiame per jį. TPDFDocument.DecodeAllStreams – rutina, kurią SaveQDFToFile ir ConvertFileToQDF naudoja vieno FlateDecode srautus išplėsti į skaitomą formą – perduoda 1. FPC rinkinyje praleista zlib antraštė privertė išpūtimą nepavykti, bet zlib srautas vis tiek pranešė ne nulinį Consumed už tuos baitus, kuriuos buvo apžiūrėjęs, tad DecodeAllStreams priėmė tuščią turinį kaip sėkmingą dekodavimą ir kiekvieną turinio srautą pakeitė tuščia eilute. Gautas QDF turėjo teisingą puslapių skaičių, galiojančią struktūrą ir jokio puslapių turinio – tai failas, kuris kiekvienoje peržiūros programoje atsidaro be klaidos ir nieko nerodo
// FPC InflateStrFromPosition šaka, po v3.539.16.
// StartPos yra 1 pagrindo kaip ir Delphi šakoje; jį reikia apriboti, tada
// konvertuoti į 0 pagrindo rodyklės poslinkį lygiai vieną kartą, ties riba.
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;
Jį sauganti regresija yra mažiausia įmanoma: suslėgus turinį, jį išpūsti nuo 0 pozicijos ir nuo 1 pozicijos bei patikrinti, kad abu grąžina tą patį turinį ir abu praneša Consumed, lygų visam srauto ilgiui. RFC 1950 srautas turi dviejų baitų antraštę ir keturių baitų Adler-32 pabaigos žymą, tad vienu baitu suklysta riba bet kuriame gale yra ne subtilus sugadinimas, o srautas, kuris arba nepaleidžiamas, arba nebaigiamas. Pamoka čia apie ribą, o ne apie zlib: kai funkcijos parametras apibrėžtas vienu indekso pagrindu, o po juo esanti implementacija naudoja kitą, konvertavimas priklauso lygiai vienai eilutei, o testas turi iškviesti ją su reikšme, kuri atskiria abu pagrindus
Kodėl trumpas TStream.Read nėra srauto pabaiga?
Nes TStream.Read leidžiama grąžinti mažiau baitų, nei paprašyta, dėl bet kokios jai patogios priežasties, ir tik 0 reiškia, kad daugiau nieko nėra. TMemoryStream ir TFileStream vietiniame diske beveik visada užpildo prašymą – štai kodėl kodas, traktuojantis „grąžino mažiau, nei prašiau“ kaip failo pabaigą, praeina kiekvieną testą, kuris juos naudoja. Tinklu paremti srautai, dekompresijos srautai ir bet kuris kliento parašytas TStream palikuonis gali grąžinti du baitus, kai paprašyta šešiasdešimt keturių tūkstančių, ir vis tiek turėti gigabaitus už jų
TPLBuffer yra skaitytuvas, per kurį eina kiekvienas PDF Library for Delphi analizatorius, ir jis gali apgaubti AnsiString, rodyklę, baitų masyvą arba TStream. Jo keturi skenavimo užklausimai – DistanceToByte, DistanceToOtherByte, DistanceToAnyByte ir DistanceToOtherBytes, visi grąžinantys Int64 – skaito šaltinį 64 KB blokais, ieškodami skyriklio, ir praneša, kiek jis toli, nejudindami loginės pozicijos. Kiekviena kilpa baigdavosi Until ReadCount < BlockSize. Trims atmintyje esantiems šaltiniams tai teisinga, nes ReadIntoBuffer visada paduoda visą bloką iki paskutinio. Srauto šaltiniui tai reiškia, kad skenavimas pasiduoda po pirmo trumpo skaitymo, praneša skyriklį kaip nesantį, o virš jo esantis žetonizatorius nusprendžia, kad objektas baigiasi ten, kur jis nesibaigia
// TPLBuffer.DistanceToByte, kilpa po v3.539.6.
// Nulis yra vienintelis duomenų pabaigos signalas, kurį apibrėžia 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; // žvilgsnis neturi pajudinti skaitytuvo
End;
Testas, kuris tai prisega, yra TMemoryStream palikuonis, kurio Read perrašymas kiekvieną prašymą apriboja dviem baitais. Apgaubkite juo eilutę aaaaaX, nustatykite buferio poziciją į 1, ir visi keturi užklausimai turi pranešti 4 atstumą iki X, po to palikti poziciją ties 1 ir pranešti -1 baitui, kurio ten nėra. Prieš pataisą pirmasis užklausimas pamatydavo du baitus, nuspręsdavo, kad srautas išsemtas, ir grąžindavo -1. finally svarbus tiek pat, kiek kilpos sąlyga: Exit iš skenavimo vidaus yra įprastas sėkmės kelias, ir loginė pozicija turi būti atkurta ir tame kelyje, o ne tik tada, kai kilpa apsuka iki galo
Vienas šaltinis, du kompiliatoriai, vienas tvirtinimų rinkinys
Disciplina, kuri iš šių penkių išėjo, yra ta, kad „Delphi rinkinys praeina“ yra įrodymas apie Delphi, o ne apie šaltinį. Nuo v3.539.16 Delphi DUnitX rinkinys ir Free Pascal konsolės rinkinys abu įtraukia tą patį Tests\CrossCompilerSemantics.inc – vieną rutiną RunCrossCompilerFileSemantics, kuri per TPDFlib pastato dviejų puslapių dokumentą su suslėgtu turiniu, jį išsaugo, dar kartą išsaugo kaip QDF per SaveQDFToFile, sutaiso QDF su RepairQDFFile, užšifruoja paprastą failą AES-128 per EncryptFile ir leidimų kaukę iš EncodePermissions, o tada iš naujo įkelia kiekvieną artefaktą ir tvirtina tuos pačius dalykus abiejuose kompiliatoriuose: puslapių skaičius yra 2, pavadinimas išlieka, antro puslapio tekstas ištraukiamas nepažeistas iš paprasto, sutaisyto ir užšifruoto failo, neteisingas slaptažodis atmetamas su ne nuliniu LastErrorCode, EncryptionStrength yra 128, EncryptionAlgorithm yra 2, o atskiri leidimų bitai iš GetUserPermissions grįžta lygiai taip, kaip buvo užkoduoti
Palyginimas sąmoningai normalizuotas, o ne baitas į baitą. Šifravimas traukia atsitiktines druskas, o rašytojas priskiria dokumentų identifikatorius, tad iš abiejų rinkinių nesitikima identiškų failų; iš jų tikimasi failų, kurie reiškia tą patį, ir tvirtinimai suformuluoti būtent tame lygyje. QDF dalis yra konkrečiai dėl poslinkio defekto: QDF su dviem puslapiais ir be turinio praeina puslapių skaičiaus patikrinimą ir krinta teksto ištraukimo patikrinime, o matrica tvirtina antrąjį. Bet kokia būsima pataisa, kuri viename kompiliatoriuje yra no-op, o kitame – elgesio pokytis (o tai apibūdina keturias iš penkių aukščiau), dabar turi praeiti tuos pačius tvirtinimus du kartus, kol bus išleista
Susiejimo laiko pusė to paties perkėlimo – kaip suderinti Delphi OMF objektus su Free Pascal COFF lūkesčiais – yra atskira istorija straipsnyje FPC Win32 OMF ir COFF objektų susiejimas, o to paties TIFF skaitytuvo struktūrinis kietinimas prieš BigTIFF ir tiled failus – straipsnyje įtaisyto TIFF dekoderio pastabos. Šiame straipsnyje aprašyti dekoderiai ir po jais dabar esantis kryžminio kompiliavimo testas platinami PDF Library for Delphi, skirtoje Delphi, C++Builder ir Free Pascal, kur iš to paties šaltinio tikimasi tokio pat rezultato kiekviename kompiliatoriuje, o ne vieno iš jų dovanoto