Tehnični članak

Koda, ki v Delphiju deluje po naključju: pet napak FPC

PDF Library for Delphi je med prenosom kode za CCITT, TIFF, PNG, Flate in medpomnilnike tokov na Free Pascal odkril pet napak v dekoderjih, in vsaka od njih je leta prestajala celoten testni niz Delphija. Nobena ni bila napaka prevajalnika. Vsaka je bila Pascal, ki ga je Delphi slučajno izvajal pravilno zaradi podrobnosti izvedbe: skriti rezultatni parameter, ki je bil alias klicateljevega niza, veja zunaj obsega, mimo katere ni nihče nikoli bral, medpomnilnik dolžine nič, katerega edini varoval je bilo stikalo za preverjanje obsega, odmik z osnovo 1, ki ga je kot 1 posredovala samo ena pot kode, in pogodba TStream.Read, ki je medpomnilniki v pomnilniku nikoli ne preizkusijo. Zamenjajte prevajalnik ali isto kodo nahranite s pokvarjeno datoteko in naključje preneha držati

Sledi konkretna oblika vsake od njih, popravek in disciplina, ki je iz tega nastala: ista izvorna koda mora zdaj na obeh prevajalnikih dati isto semantiko dokumenta, to pa preverja vključena testna datoteka. Sorodni članek o utrjevanju pascalovskega razčlenjevalnika PDF proti zlonamernim datotekam je obravnaval širino celih števil, globino rekurzije in neinicializirane medpomnilnike. Ta pa govori o drugi vrsti odpovedi: kodi, ki je bila ves čas napačna, prevajalnik pa jo je tiho pokrival

Zakaj funkcija, ki vrača dinamični niz, v Delphiju deluje brez SetLength?

Ker Delphi kot skriti rezultatni parameter posreduje kar klicateljevo lastno spremenljivko, zato lahko funkcija, ki svojega rezultata nikoli ne alocira, vseeno piše v niz, ki ga je alociral klicatelj. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray je iskanje po referenčni vrstici v jedru dvodimenzionalnega dekodiranja Group 3 in Group 4: glede na trenutni položaj a0 in barvo trenutnega niza poišče menjajoče se elemente prejšnje vrstice po vrsticah, b1 in b2 dvodimenzionalne sheme kodiranja ITU-T T.4 in T.6, ter ju vrne kot niz z dvema mestoma. Prvotna funkcija je pisala v Result[0] in Result[1] in na Result sploh ni nikoli poklicala SetLength

To bi moralo pasti že ob prvem pisanju, in na Free Pascalu res pade. V Delphiju ni nikoli, ker sta obe klicni mesti v dekoderju takšni: deklarirajte b: TCCITTIntegerArray, enkrat pred zanko po vrsticah zaženite SetLength(b, 2), nato pa znotraj zanke dodelite b := GetNextChangingElement(a0, IsWhite) ter berite b[0] in b[1]. Vodnik po jeziku Delphi pravi, da funkcija, katere rezultat je dolg niz, dinamični niz ali drug upravljani tip, ta rezultat prejme kot dodaten parameter var, v praksi pa prevajalnik posreduje naslov cilja dodelitve. Result znotraj funkcije je torej kar b sam, že dolg dva elementa, in vsak zapis pristane v pomnilniku, ki ga ima v lasti klicatelj. Free Pascal funkciji preda svež ničeln niz in ga nato dodeli v b, kar je tisto branje pogodbe, po katerem bi morala biti koda napisana že od začetka

Razhajanje pri dekodiranju CCITT v PDFlibPas: Delphi posreduje klicateljev niz b kot skriti var Result funkcije GetNextChangingElement, zato zapisi pristanejo v pomnilniku, ki ga ima v lasti klicatelj, zgrešeno iskanje pa obdrži prejšnje vrednosti, medtem ko Free Pascal funkciji preda svež ničeln niz, ki ga mora varovalo Length pred prvim zapisom razširiti s SetLength
Delphi klicateljev niz uporabi kot skriti parameter Result, zato nezaščiteni zapisi vseeno pristanejo v lastnem pomnilniku, Free Pascal pa prispe z nil in enovrstično varovalo spremeni napako v predvideno vedenje, ne da bi spremenilo pot dekodiranja v Delphiju

Ta alias je nosil tudi semantiko, na katero se dekoder zanaša. Result[0] se dodeli le, kadar skeniranje najde element, večji od a0, Result[1] pa le, kadar za njim obstaja še en element, zato ob zgrešitvi mesti obdržita tisto, kar je v b pustila prejšnja iteracija. Očiten popravek, alocirati dve mesti in ju ob vsakem klicu počistiti, bi to podajanje uničil in spremenil dekodirani izhod v Delphiju. Popravek, ki je šel v izdajo, je varovalo namesto ponastavitve: v Delphiju je to mrtva koda in pot dekodiranja ostane bajt za bajt takšna, kot je bila, na Free Pascalu pa napako spremeni v predvideno vedenje. Prav ta asimetrija je bistvo, saj je moral biti popravek na prevajalniku, kjer je koda že dajala preverjen izhod, prazna operacija

Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
  IsWhite: Boolean): TCCITTIntegerArray;
Begin
  // Delphi prispe sem s klicateljevim dvoelementnim nizom pod
  // imenom Result, zato je to tam prazna operacija. FPC prispe z nil.
  If (Length(Result) < 2) Then
    SetLength(Result, 2);
  ...
  // Result[0] / Result[1] se še vedno pišeta le ob zadetku, zato
  // zgrešitev obdrži vrednosti prejšnje iteracije natanko kot prej
End;

Števec, ki je preživel svoje podatke: vnos v imeniku TIFF

Ko razveljavite niz, morate v isti izjavi razveljaviti še njegov števec, sicer bo števcu verjela koda, ki niza nikoli ne vidi. Vnos v imeniku slikovne datoteke TIFF (TIFF 6.0 §2, 12-bajtna postavitev oznake, tipa, števca in vrednosti oziroma odmika) prinaša 32-bitni števec naravnost iz datoteke, PDF Library for Delphi pa vsakega prebere skozi PopDE: TTIFFEntry, zapis s polji Tag, TagType, Length, Offset ter dekodiranima nizoma IntegerValues in DoubleValues. Prvotna koda je preverila, ali Offset + TypeSize * Length sega čez konec datoteke, in če je, je oba niza nastavila na dolžino nič. Result.Length pa je pustila pri vrednosti iz datoteke

Od tam sta šli stvari narobe v dve smeri. Funkcija se konča z rezervno potjo, ki pravi »če je Length nič, daj vnosu en element z vrednostjo nič«, da lahko klicatelji vedno preberejo element nič. Ker Length na poti zunaj obsega ni bil nikoli počiščen, ta rezervna pot nikoli ni nastopila prav v tistem primeru, za katerega je obstajala. In klicatelji element nič res berejo, brez pogoja: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip in še ducat drugih vzame E.IntegerValues[0], tabele pasov pa naredijo Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4) in prepišejo Length krat štiri bajte iz niza, ki jih nima. Počiščen niz z živim števcem je strogo nevarnejši od nepreverjenega, saj nepreverjeni vsaj vsebuje bajte, ki jih obljublja

Druga težava je bila vrstni red. Klica SetLength sta se izvedla pred preverjanjem obsega in sta se dimenzionirala po števcu iz datoteke, zato je lahko sovražen vnos zahteval alokacijo več gigabajtov, še preden je bila opravljena ena sama kontrola veljavnosti. V Delphiju je nastalo izjemo ujel obravnavalnik višje na poti nalaganja slike in datoteka preprosto ni bila naložena, zato tega ni nihče opazil; v resnici pa je šlo za dogodek pomanjkanja pomnilnika, ki ga je izbrala datoteka. Popravek premakne alokacijo za preverjanje in poskrbi, da števec potuje skupaj s podatki

Utrjevanje vnosov imenika TIFF v PDFlibPas: 12-bajtni vnos prinaša števec iz datoteke, pokvarjeni vrstni red je nize alociral po tem števcu pred preverjanjem obsega in po čiščenju pustil Result.Length živ, popravljeni vrstni red pa najprej preveri aritmetiko Int64 proti dolžini datoteke, zato se števec počisti skupaj z nizi
Alokacija pred preverjanjem obsega je sovražnemu števcu dovolila zahtevati gigabajte in na izpraznjenem nizu pustila živ števec, zato popravek najprej preveri odmik in počisti Result.Length v isti izjavi kot niza
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
              > Length(Source);
If OutOfRange Then
Begin
  Result.Length := 0;              // števec gre skupaj z vrednostmi
  SetLength(Result.IntegerValues, 0);
  SetLength(Result.DoubleValues, 0);
End
Else
Begin
  SetLength(Result.IntegerValues, Result.Length);  // šele zdaj
  SetLength(Result.DoubleValues, Result.Length);
End;
// ... pozneje obstoječa rezervna pot končno doseže primer, za katerega je bila:
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

Pri tem popravku ni nič odvisno od prevajalnika, in prav zato spada na ta seznam. Napaka je v Delphiju mirovala iz istega razloga kot na Free Pascalu: nobena testna datoteka ni imela vnosa v imeniku, ki bi kazal čez konec datoteke. Prenos je ni razkril. Razkrilo jo je branje kode z vprašanjem, kaj tukaj Delphi naredi namesto mene

Kaj se zgodi, ko PNG IHDR navede barvni tip, ki ga format ne definira?

PDF Library for Delphi zdaj sliko zavrne, preden se zaženejo filtri vrstic; pred različico v3.539.2 je izračunal vrstico z nič bajti in zankam za odfiltriranje predal prazen medpomnilnik. ISO 15948 §11.2.2 definira kos IHDR, tabela 11.1 pa našteva šest veljavnih kombinacij barvnega tipa in bitne globine: sivine pri 1, 2, 4, 8 ali 16 bitih, indeksirane barve pri 1, 2, 4 ali 8 ter prave barve, sivine z alfa in prave barve z alfa pri 8 ali 16. TPNGReader je preveril polji načina stiskanja in načina filtra v IHDR, FColorType in bitno globino pa je prepustil nespremenjena

Koda za filtriranje vrstic vse dimenzionira iz stavka Case FColorType Of, ki vsak barvni tip preslika v število komponent. Barvni tip zunaj teh šestih pade v vejo Else, kjer je SourceComponents enak 0, zato je ScanlineByteCount 0, zato SetLength(PreviousScanline, 0) takoj sledi FillChar(PreviousScanline[0], ScanlineByteCount, 0). Indeksiranje elementa nič praznega dinamičnega niza je naslov, izračunan iz nil. Z izklopljenim preverjanjem obsega je polnjenje nič bajtov po tem naslovu tiho prazna operacija in dekoder koraka naprej skozi vrstice, ki ne obstajajo; z vklopljenim preverjanjem obsega je to ERangeError že pri prvi sliki; klici Move, ki sledijo, pa so en korak od kršitve dostopa. Katero od tega dobite, je odvisno od prevajalnika in stikal gradnje, ne od česarkoli, kar je dekoder odločil, in prav to je znak, da dekoder ni odločil ničesar

Popravek je tabela iz specifikacije, uporabljena tam, kjer so se druga polja IHDR že preverjala: COLOR_GRAYSCALE sprejme FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE sprejme [1, 2, 4, 8], COLOR_RGB, COLOR_GRAYSCALEALPHA in COLOR_RGBALPHA pa sprejmejo [8, 16]; vse drugo počisti ValidImage in slika je zavrnjena, pri čemer širina in višina ostaneta nedotaknjeni za diagnostiko. Kos pHYs, krajši od svojih devetih bajtov, je bil zaprt v istem prehodu, saj je bralnik DPI indeksiral S[1] do S[8] niza, ki ga je kratek kos pustil praznega

Odmik z osnovo 1, obravnavan kot kazalec z osnovo 0

InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString sprejme StartPos z osnovo 1, ker je njen vhod AnsiString in izvedba v Delphiju naslavlja vhod zlib kot @Input[StartPos]. Izvedba za Free Pascal, napisana proti paszlib, da oba cilja Windows stiskanje povezujeta statično, je next_in nastavila na PAnsiChar(Input) + StartPos in avail_in na Length(Input) - StartPos. To je kazalčna aritmetika in ta ima osnovo 0. Posredujte 1, kar za to funkcijo pomeni začetek na prvem bajtu, in gradnja FPC začne razširjati pri drugem bajtu ter se ustavi en bajt pred koncem

Preživelo je zato, ker je edini klicatelj, do katerega seže večina testov, InflateStr, ta pa posreduje 0. Nič je slučajno pravi odmik z osnovo 0, zato sta se gradnji ujemali pri vsakem navadnem klicu InflateStr in pri vsakem testu, ki je šel skozenj. TPDFDocument.DecodeAllStreams, rutina, ki jo SaveQDFToFile in ConvertFileToQDF uporabljata za razširitev tokov z enim samim FlateDecode v berljivo obliko, posreduje 1. V gradnji FPC je izpuščena glava zlib povzročila neuspeh razširjanja, a je tok zlib za obravnavane bajte vseeno sporočil neničelni Consumed, zato je DecodeAllStreams prazno koristno obremenitev vzel kot uspešno dekodiranje in vsak tok vsebine nadomestil s praznim nizom. Nastali QDF je imel pravo število strani, veljavno strukturo in nič vsebine strani, kar je datoteka, ki se v vsakem pregledovalniku odpre brez napake in ne pokaže ničesar

// Veja FPC funkcije InflateStrFromPosition, po v3.539.16.
// StartPos ima osnovo 1 kot v veji Delphi; omejite ga in nato
// pretvorite v odmik kazalca z osnovo 0 natanko enkrat, na meji.
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, ki to varuje, je najmanjši mogoči: stisnite koristno obremenitev, jo razširite s položaja 0 in s položaja 1 ter zahtevajte, da oba vrneta isto obremenitev in da oba sporočita Consumed, enak celotni dolžini toka. Tok RFC 1950 ima dvobajtno glavo in štiribajtni zaključek Adler-32, zato napaka za en na katerem koli koncu ni subtilna okvara, ampak tok, ki se bodisi ne zažene bodisi ne zaključi. Nauk je o meji, ne o zlib: kadar je parameter funkcije določen v eni indeksni osnovi, izvedba pod njo pa uporablja drugo, pretvorba spada v natanko eno vrstico, test pa jo mora poklicati z vrednostjo, ki obe osnovi razlikuje

Zakaj kratko branje TStream.Read ni konec toka?

Ker sme TStream.Read vrniti manj bajtov, kot je bilo zahtevanih, iz kakršnegakoli razloga, in samo vrnjena vrednost 0 pomeni, da ni ničesar več. TMemoryStream in TFileStream na lokalnem disku zahtevo skoraj vedno izpolnita, zato koda, ki »vrnil je manj, kot sem zahteval« obravnava kot konec datoteke, prestane vsak test, ki ju uporablja. Tokovi na omrežju, tokovi za razširjanje in vsak potomec TStream, ki ga je napisal kupec, lahko ob zahtevi po štiriinšestdeset tisoč bajtih vrnejo dva bajta in imajo za sabo še vedno gigabajte

TPLBuffer je bralnik, skozi katerega gre vsak razčlenjevalnik v PDF Library for Delphi, in lahko ovije AnsiString, kazalec, niz bajtov ali TStream. Njegove štiri poizvedbe skeniranja, DistanceToByte, DistanceToOtherByte, DistanceToAnyByte in DistanceToOtherBytes, ki vse vračajo Int64, berejo vir v blokih po 64 KB in iščejo ločilo ter sporočijo, kako daleč je, ne da bi premaknile logični položaj. Vsaka zanka se je končala z Until ReadCount < BlockSize. Za tri vire v pomnilniku je to pravilno, saj ReadIntoBuffer vedno dostavi celoten blok do zadnjega. Za vir toka pa to pomeni, da skeniranje ob prvem kratkem branju obupa, sporoči, da ločila ni, tokenizator nad njim pa odloči, da se objekt konča tam, kjer se ne

Obravnava kratkih branj v medpomnilniku tokov PDFlibPas: DistanceToByte skenira bloke po 64 KB, stara zanka je Until ReadCount < BlockSize obravnavala kot konec podatkov in ob prvem kratkem branju obupala, popravljena zanka pa teče, dokler ReadCount ni enak nič, najde ločilo in položaj obnovi v bloku finally
Tok lahko ob zahtevi po štiriinšestdeset tisoč bajtih vrne dva bajta, zato je nič edini signal konca podatkov, ki mu sme skeniranje zaupati, klavzula finally pa obnovi logični položaj, ko je ločilo najdeno in zanka izstopi predčasno
// TPLBuffer.DistanceToByte, zanka po v3.539.6.
// Nič je edini signal konca podatkov, ki ga 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;   // pokuk ne sme premakniti bralnika
End;

Test, ki to pripne, je potomec TMemoryStream, katerega prepisana metoda Read omeji vsako zahtevo na dva bajta. Ovijte vanj niz aaaaaX, nastavite položaj medpomnilnika na 1, in vse štiri poizvedbe morajo sporočiti razdaljo 4 do X, položaj po tem pustiti pri 1 in vrniti -1 za bajt, ki ga ni. Pred popravkom je prva poizvedba videla dva bajta, sklepala, da je tok izčrpan, in vrnila -1. finally je prav tako pomemben kot pogoj zanke: Exit iz notranjosti skeniranja je običajna pot uspeha in tudi na tej poti je treba obnoviti logični položaj, ne le takrat, ko zanka steče do konca

Ena izvorna koda, dva prevajalnika, en niz trditev

Disciplina, ki je nastala iz teh petih, pravi, da je »gradnja Delphi prestane« dokaz o Delphiju, ne o izvorni kodi. Od različice v3.539.16 tako niz DUnitX za Delphi kot konzolni niz za Free Pascal vključujeta isto datoteko Tests\CrossCompilerSemantics.inc, eno samo rutino RunCrossCompilerFileSemantics, ki skozi TPDFlib zgradi dvostranski dokument s stisnjeno vsebino, ga shrani, znova shrani kot QDF skozi SaveQDFToFile, QDF popravi z RepairQDFFile, navadno datoteko šifrira z AES-128 skozi EncryptFile in masko dovoljenj iz EncodePermissions, nato pa vsak izdelek znova naloži in na obeh prevajalnikih zahteva iste stvari: število strani je 2, naslov preživi, besedilo druge strani se iz navadne, popravljene in šifrirane datoteke izvleče nedotaknjeno, napačno geslo je zavrnjeno z neničelnim LastErrorCode, EncryptionStrength je 128, EncryptionAlgorithm je 2, posamezni biti dovoljenj iz GetUserPermissions pa se vrnejo natanko tako, kot so bili kodirani

Primerjava je namenoma normalizirana, ne bajt za bajt. Šifriranje črpa naključne soli, pisec pa dodeljuje identifikatorje dokumentov, zato enakost datotek ni zahteva; zahteva je, da obe gradnji pomenita isto, in trditve so oblikovane na tej ravni. Del z QDF je tam prav zaradi napake z odmikom: QDF z dvema stranema in brez vsebine prestane preverjanje števila strani in pade na preverjanju izvlečka besedila, matrika pa zahteva to drugo. Vsak prihodnji popravek, ki je na enem prevajalniku prazna operacija in na drugem sprememba vedenja, kar opisuje štiri od petih zgoraj, mora zdaj dvakrat prestati iste trditve, preden gre naprej

Povezovalna polovica istega prenosa, uskladitev delphijevih objektov OMF s pričakovanji COFF v Free Pascalu, je svoja zgodba v povezovanju objektov FPC Win32 OMF v COFF, strukturno utrjevanje istega bralnika TIFF proti BigTIFF in razdeljenim datotekam pa v zapiskih o vgrajenem dekoderju TIFF. Dekoderji iz tega članka in medprevajalniški test, ki zdaj stoji pod njimi, se dobavljajo v PDF Library for Delphi za Delphi, C++Builder in Free Pascal, kjer naj bi ista izvorna koda prislužila enak rezultat na vsakem ciljnem prevajalniku, ne pa da ji ga eden podeli