Teknisk artikkel

Delphi-kode som virker ved et uhell: fem FPC-porteringsfeil

PDF Library for Delphi fant fem dekoderfeil mens CCITT-, TIFF-, PNG-, Flate- og strømbuffer-koden ble portert til Free Pascal, og alle fem hadde passert hele Delphi-testpakken i årevis. Ingen av dem var en kompilatorfeil. Hver av dem var Pascal som Delphi tilfeldigvis kjørte korrekt på grunn av en implementasjonsdetalj: en skjult resultatparameter som aliaset kallerens array, en gren utenfor gyldig område som ingen noen gang leste forbi, en buffer med lengde null der den eneste vakten var en range-check-bryter, en 1-basert offset som bare én kodevei noen gang sendte inn som 1, og en TStream.Read-kontrakt som strømmer i minnet aldri utfordrer. Bytt kompilator, eller gi den samme koden en ødelagt fil, og tilfeldigheten slutter å holde

Det som følger, er den konkrete formen på hver av dem, fiksen, og disiplinen som kom ut av det: samme kildekode må nå gi samme dokumentsemantikk på begge kompilatorer, og en test-include sjekker at den gjør det. Søsterartikkelen om herding av en Pascal PDF-parser mot ondsinnede filer dekket heltallsbredde, rekursjonsdybde og uinitialiserte buffere. Denne handler om en annen feilklasse: kode som var feil hele tiden og hadde en kompilator som stille dekket over det

Hvorfor fungerer en funksjon som returnerer et dynamisk array, uten SetLength på Delphi?

Fordi Delphi sender kallerens egen variabel inn som den skjulte resultatparameteren, slik at en funksjon som aldri allokerer resultatet sitt, likevel kan skrive inn i et array som kalleren har allokert. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray er referanselinje-oppslaget i kjernen av todimensjonal Group 3- og Group 4-dekoding: gitt gjeldende posisjon a0 og fargen på gjeldende run, søker den i forrige scanlines changing elements, b1 og b2 i ITU-T T.4 og T.6 sitt todimensjonale kodingsskjema, og returnerer dem som et array med to plasser. Den opprinnelige funksjonen skrev Result[0] og Result[1] og kalte aldri SetLength på Result i det hele tatt

Det burde feile på den første skrivingen, og på Free Pascal gjør det det. På Delphi gjorde det aldri det, fordi begge kallstedene i dekoderen ser slik ut: deklarer b: TCCITTIntegerArray, kjører SetLength(b, 2) én gang før scanline-løkken, og tilordner så inne i løkken b := GetNextChangingElement(a0, IsWhite) og leser b[0] og b[1]. Delphis språkguide sier at en funksjon der resultatet er en lang streng, et dynamisk array eller en annen managed type, mottar resultatet som en ekstra var-parameter, og i praksis sender kompilatoren adressen til tilordningsmålet. Så Result inne i funksjonen er b selv, allerede to elementer langt, og hver skriving lander i minne som kalleren eier. Free Pascal gir funksjonen et ferskt nil-array og tilordner det til b etterpå, som er den lesningen av kontrakten koden burde vært skrevet mot i utgangspunktet

PDFlibPas CCITT-dekoding divergerer: Delphi sender kallerens array b inn som den skjulte var-parameteren Result i GetNextChangingElement, slik at skrivinger lander i minne kalleren eier og et bom på oppslaget beholder de forrige verdiene, mens Free Pascal gir funksjonen et ferskt nil-array som Length-vakten må dimensjonere med SetLength før den første skrivingen
Delphi aliaser kallerens array som den skjulte Result-parameteren, så uvoktede skrivinger lander likevel i eid minne, mens Free Pascal ankommer med nil og én-linjers-vakten gjør feilen om til tilsiktet oppførsel uten å endre Delphis dekodevei

Aliasingen bar også med seg en semantikk som dekoderen er avhengig av. Result[0] tilordnes bare når søket finner et element større enn a0, og Result[1] bare når det finnes et element etter det, så ved et bom beholder plassene det forrige iterasjon lot ligge i b. Den åpenbare fiksen, å allokere to plasser og nullstille dem ved hvert kall, ville ha ødelagt denne videreføringen og endret dekodet utdata på Delphi. Fiksen som ble levert, er en vakt i stedet for en nullstilling: på Delphi er den død kode og dekodeveien forblir byte for byte den samme, og på Free Pascal gjør den en feil om til tilsiktet oppførsel. Den asymmetrien er hele poenget, siden fiksen måtte være en no-op på den kompilatoren der koden allerede produserte verifisert utdata

Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
  IsWhite: Boolean): TCCITTIntegerArray;
Begin
  // Delphi ankommer hit med kallerens to-element-array aliasert
  // som Result, så dette er en no-op der. FPC ankommer med nil.
  If (Length(Result) < 2) Then
    SetLength(Result, 2);
  ...
  // Result[0] / Result[1] skrives fortsatt bare ved treff, så et bom
  // beholder forrige iterasjons verdier nøyaktig som før
End;

En teller som overlevde dataene sine: TIFF-katalogoppføringen

Når du ugyldiggjør et array, må du ugyldiggjøre telleren i samme setning, ellers vil telleren bli trodd på av kode som aldri ser arrayet. En TIFF image file directory-oppføring (TIFF 6.0 §2, 12-byte-layouten med tag, type, count og value-or-offset) bærer en 32-bits teller rett fra filen, og PDF Library for Delphi leser hver av dem gjennom PopDE: TTIFFEntry, en record med Tag, TagType, Length, Offset og de dekodede IntegerValues- og DoubleValues-arrayene. Den opprinnelige koden sjekket om Offset + TypeSize * Length gikk forbi slutten av filen, og satte i så fall begge arrayene til lengde null. Den lot Result.Length stå på verdien fra filen

To ting gikk galt derfra. Funksjonen avsluttes med en fallback som sier at hvis Length er null, skal oppføringen få ett element med verdien null, slik at kallere alltid kan lese element null. Fordi Length aldri ble nullstilt på utenfor-området-veien, utløste den fallbacken aldri for det ene tilfellet den fantes for. Og kallerne leser element null, ubetinget: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip og et dusin til henter E.IntegerValues[0], og strip-tabellene gjør Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), som kopierer Length ganger fire byte ut av et array som ikke har noen. Et tømt array med en levende teller er farligere enn et ukontrollert et, for det ukontrollerte holder i det minste på bytene det påstår at det har

Det andre problemet var rekkefølgen. De to SetLength-kallene kjørte før områdetesten, dimensjonert fra filens teller, så en ondsinnet oppføring kunne be om en allokering på flere gigabyte før en eneste gyldighetssjekk. På Delphi ble unntaket som fulgte, fanget av en handler lenger oppe i bildeinnlastingsveien, og filen lot seg rett og slett ikke laste, som er grunnen til at ingen la merke til det; det som faktisk skjedde, var en out-of-memory-hendelse som filen selv valgte. Fiksen flytter allokeringen etter testen og lar telleren følge med dataene

Herding av TIFF-katalogoppføringen i PDFlibPas: den 12 byte store oppføringen bærer en teller levert av filen, den ødelagte rekkefølgen allokerte arrayer fra den telleren før områdetesten og lot Result.Length leve videre etter at arrayene var tømt, og den fiksede rekkefølgen tester først Int64-aritmetikk mot fillengden slik at telleren tømmes sammen med arrayene
Å allokere før områdetesten lot en ondsinnet teller be om gigabyte og etterlot en levende teller på et tømt array, så fiksen tester offseten først og tømmer Result.Length i samme setning som arrayene
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
              > Length(Source);
If OutOfRange Then
Begin
  Result.Length := 0;              // telleren følger med verdiene
  SetLength(Result.IntegerValues, 0);
  SetLength(Result.DoubleValues, 0);
End
Else
Begin
  SetLength(Result.IntegerValues, Result.Length);  // først nå
  SetLength(Result.DoubleValues, Result.Length);
End;
// ... senere: den eksisterende fallbacken når endelig tilfellet den var for:
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

Ingenting ved denne fiksen er kompilatorspesifikt, som er det som gjør at den hører hjemme på denne listen. Defekten lå latent på Delphi av samme grunn som den lå latent på Free Pascal: ingen testfil hadde en katalogoppføring som pekte forbi slutten av filen. Porteringen avslørte den ikke. Å lese koden med spørsmålet om hva Delphi gjør for meg her som jeg ikke gjør selv, gjorde det

Hva skjer når en PNG-IHDR påstår en fargetype formatet ikke definerer?

PDF Library for Delphi avviser nå bildet før radfiltrene kjører; før v3.539.2 regnet den ut en scanline på null byte og ga unfilter-løkkene en tom buffer. ISO 15948 §11.2.2 definerer IHDR-chunken, og tabell 11.1 lister de seks lovlige kombinasjonene av fargetype og bitdybde: gråtoner ved 1, 2, 4, 8 eller 16 biter, indeksert farge ved 1, 2, 4 eller 8, og truecolor, gråtoner med alfa og truecolor med alfa ved 8 eller 16. TPNGReader validerte feltene for kompresjonsmetode og filtermetode i IHDR og slapp FColorType og bitdybden gjennom urørt

Radfilter-koden dimensjonerer alt fra en Case FColorType Of som mapper hver fargetype til et antall komponenter. En fargetype utenfor de seks faller i Else-grenen, der SourceComponents er 0, så ScanlineByteCount er 0, så SetLength(PreviousScanline, 0) etterfølges umiddelbart av FillChar(PreviousScanline[0], ScanlineByteCount, 0). Å indeksere element null i et tomt dynamisk array er en adresse regnet ut fra nil. Med range checking av er en null-byte-fylling gjennom den adressen en stille no-op, og dekoderen marsjerer videre gjennom rader som ikke finnes; med range checking på er det en ERangeError på det første bildet; og Move-kallene som følger, er ett skritt fra en access violation. Hvilket av dem du får, avhenger av kompilatoren og av build-brytere snarere enn av noe dekoderen bestemte, og det er tegnet på at dekoderen aldri bestemte noe i det hele tatt

Fiksen er tabellen fra spesifikasjonen, anvendt der de andre IHDR-feltene allerede ble sjekket: COLOR_GRAYSCALE godtar FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE godtar [1, 2, 4, 8], og COLOR_RGB, COLOR_GRAYSCALEALPHA og COLOR_RGBALPHA godtar [8, 16]; alt annet tømmer ValidImage og bildet avvises med bredde og høyde intakt for diagnostikk. En pHYs-chunk kortere enn de ni bytene sine ble lukket i samme runde, siden DPI-leseren indekserte S[1] til og med S[8] i en streng som den korte chunken hadde latt stå tom

En 1-basert offset behandlet som en 0-basert peker

InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString tar en 1-basert StartPos, fordi inndataene er en AnsiString og Delphi-implementasjonen adresserer zlib-inndataene som @Input[StartPos]. Free Pascal-implementasjonen, skrevet mot paszlib slik at begge Windows-mål lenker kompresjon statisk, satte next_in til PAnsiChar(Input) + StartPos og avail_in til Length(Input) - StartPos. Det er pekeraritmetikk, og den er 0-basert. Send inn 1, som er det start-på-begynnelsen betyr for denne funksjonen, og FPC-bygget begynner å inflate på den andre byten og stopper én byte før slutten

Grunnen til at det overlevde, er at den eneste kalleren de fleste tester når, er InflateStr, som sender 0. Null er tilfeldigvis den korrekte 0-baserte offseten, så de to byggene var enige om hvert eneste vanlige InflateStr-kall og hver test som gikk gjennom det. TPDFDocument.DecodeAllStreams, rutinen som SaveQDFToFile og ConvertFileToQDF bruker til å utvide enkelt-FlateDecode-strømmer til lesbar form, sender 1. På FPC-bygget gjorde den hoppede zlib-headeren at inflate feilet, men zlib-strømmen rapporterte likevel en Consumed ulik null for bytene den hadde undersøkt, så DecodeAllStreams tok den tomme lasten som en vellykket dekoding og erstattet hver innholdsstrøm med en tom streng. Den resulterende QDF-en hadde riktig sidetall, gyldig struktur og ingen sideinnhold, som er en fil som åpner uten feil i alle visningsprogrammer og viser ingenting

// FPC-grenen av InflateStrFromPosition, etter v3.539.16.
// StartPos er 1-basert som i Delphi-grenen; klem den, og konverter
// til en 0-basert pekeroffset nøyaktig én gang, ved grensen.
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;

Regresjonen som vokter den, er den minste tenkelige: deflate en last, inflate den fra posisjon 0 og fra posisjon 1, og hevd at begge returnerer samme last og begge rapporterer Consumed lik hele strømmens lengde. En RFC 1950-strøm har en to-byte-header og en fire-byte Adler-32-trailer, så en off-by-one i hver ende er ikke en subtil korrupsjon, det er en strøm som enten ikke starter eller ikke fullfører. Læren handler om grensen, ikke om zlib: når en funksjons parameter er definert i én indeksbase og implementasjonen under bruker den andre, hører konverteringen hjemme på nøyaktig én linje, og en test må kalle den med verdien som skiller de to basene

Hvorfor er en kort TStream.Read ikke slutten på strømmen?

Fordi TStream.Read har lov til å returnere færre byte enn forespurt av hvilken som helst grunn den selv vil, og bare en returverdi på 0 betyr at det ikke er mer. TMemoryStream og TFileStream på en lokal disk fyller nesten alltid forespørselen, som er grunnen til at kode som behandler returnerte mindre enn jeg ba om som end-of-file, passerer hver test som bruker dem. Nettverksbaserte strømmer, dekompresjonsstrømmer og enhver TStream-etterkommer en kunde har skrevet, kan returnere to byte når de blir bedt om sekstifire tusen og fortsatt ha gigabyte bak seg

TPLBuffer er leseren hver parser i PDF Library for Delphi går gjennom, og den kan pakke en AnsiString, en peker, et byte-array eller en TStream. De fire skannesøkene, DistanceToByte, DistanceToOtherByte, DistanceToAnyByte og DistanceToOtherBytes, som alle returnerer Int64, leser kilden i 64 KB-blokker på jakt etter et skilletegn og rapporterer hvor langt unna det er uten å flytte den logiske posisjonen. Hver løkke sluttet med Until ReadCount < BlockSize. For de tre kildene i minnet er det korrekt, siden ReadIntoBuffer alltid leverer hele blokken helt til den siste. For strømkilden betyr det at skanningen gir opp ved den første korte lesingen, rapporterer skilletegnet som fraværende, og tokenizeren over bestemmer at objektet slutter der det ikke gjør det

Håndtering av korte lesinger i PDFlibPas-strømbufferen: DistanceToByte skanner 64 KB-blokker, den gamle løkken behandlet Until ReadCount < BlockSize som slutten på dataene og ga opp ved den første korte lesingen, mens den fiksede løkken kjører til ReadCount er null, finner skilletegnet og gjenoppretter posisjonen i en finally-blokk
En strøm kan returnere to byte når den blir bedt om sekstifire tusen, så null er det eneste end-of-data-signalet skanningen kan stole på, og finally-leddet gjenoppretter den logiske posisjonen når skilletegnet blir funnet og løkken avsluttes tidlig
// TPLBuffer.DistanceToByte, løkken etter v3.539.6.
// Null er det eneste end-of-data-signalet TStream.Read definerer.
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;   // en peek må ikke flytte leseren
End;

Testen som fester dette, er en TMemoryStream-etterkommer der Read-overstyringen begrenser hver forespørsel til to byte. Pakk strengen aaaaaX inn i den, sett bufferposisjonen til 1, og alle fire søkene må rapportere avstanden 4 til X, la posisjonen stå på 1 etterpå og rapportere -1 for en byte som ikke finnes. Før fiksen så det første søket to byte, konkluderte med at strømmen var brukt opp og returnerte -1. finally betyr like mye som løkkebetingelsen: en Exit inne fra skanningen er den normale suksessveien, og den logiske posisjonen må gjenopprettes også på den veien, ikke bare når løkken kjører helt til den er ferdig

Én kildekode, to kompilatorer, ett sett med asserts

Disiplinen som kom ut av disse fem, er at Delphi-bygget passerer er bevis om Delphi, ikke om kildekoden. Siden v3.539.16 inneholder både Delphi DUnitX-pakken og Free Pascal-konsollpakken den samme Tests\CrossCompilerSemantics.inc, én enkelt rutine, RunCrossCompilerFileSemantics, som bygger et dokument på to sider med komprimert innhold gjennom TPDFlib, lagrer det, lagrer det igjen som QDF gjennom SaveQDFToFile, reparerer QDF-en med RepairQDFFile, krypterer den vanlige filen med AES-128 gjennom EncryptFile og en tillatelsesmaske fra EncodePermissions, og deretter laster inn alle artefaktene på nytt og hevder det samme på begge kompilatorer: sidetallet er 2, tittelen overlever, teksten på side to ekstraheres intakt fra den vanlige, den reparerte og den krypterte filen, feil passord avvises med en LastErrorCode ulik null, EncryptionStrength er 128, EncryptionAlgorithm er 2, og de enkelte tillatelsesbitene fra GetUserPermissions kommer tilbake nøyaktig som kodet

Sammenligningen er bevisst normalisert snarere enn byte for byte. Kryptering trekker tilfeldige salter, og skriveren tildeler dokumentidentifikatorer, så de to byggene forventes ikke å sende ut identiske filer; de forventes å sende ut filer som betyr det samme, og påstandene er formulert på det nivået. QDF-beinet er der spesifikt på grunn av offset-feilen: en QDF med to sider og ingen innhold passerer en sidetallssjekk og feiler en tekstekstraksjonssjekk, og matrisen hevder den andre. Enhver fremtidig fiks som er en no-op på én kompilator og en atferdsendring på den andre, noe som beskriver fire av de fem over, må nå klarere de samme påstandene to ganger før den slippes

Lenketids-halvdelen av samme porteringsjobb, å få Delphis OMF-objekter og Free Pascals COFF-forventninger til å stemme, er sin egen historie i FPC Win32 OMF til COFF-objektlenking, og den strukturelle herdingen av samme TIFF-leser mot BigTIFF og tiled-filer står i notatene om den innebygde TIFF-dekoderen. Dekoderne i denne artikkelen, og krysskompilator-testen som nå ligger under dem, følger med i PDF Library for Delphi for Delphi, C++Builder og Free Pascal, der den samme kildekoden forventes å fortjene samme resultat på hver kompilator den retter seg mot, i stedet for å få det servert av én