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
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
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
// 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