Teknisk artikel

Delphi-kode, der virker ved et tilfælde: Fem porteringsfejl

Da PDF Library for Delphi fik sin CCITT-, TIFF-, PNG-, Flate- og stream-buffer-kode op at køre under Free Pascal, fandt den fem decoder-defekter, og alle fem havde bestået den fulde Delphi-testsuite i årevis. Ingen af dem var en compiler-bug. Hver enkelt var Pascal, som Delphi tilfældigvis eksekverede korrekt på grund af en implementeringsdetalje: en skjult result-parameter, der aliaserede callerens array, en out-of-range-gren, som ingen nogensinde læste forbi, en buffer med længde nul, hvis eneste værn var en range-check-switch, en 1-baseret offset, som kun én kodevej nogensinde gav som 1, og en TStream.Read-kontrakt, som in-memory-streams aldrig udsætter for en prøve. Skift compileren, eller giv den samme kode en misdannet fil, og tilfældet holder op med at holde

Nedenstående er den præcise form af hver enkelt, fixene og den disciplin, der faldt ud af det: den samme kildekode skal nu producere den samme dokumentsemantik på begge compilere, og en test-include tjekker, at den gør. Søsterartiklen om at hærde en Pascal-PDF-parser mod ondsindede filer tog sig af integer-bredde, rekursionsdybde og uinitialiserede buffere. Denne handler om en anden fejlsklasse: kode, der havde taget fejl hele vejen, mens en compiler i stilhed dækkede over den

Hvorfor virker en funktion, der returnerer et dynamisk array, uden SetLength under Delphi?

Fordi Delphi giver callerens egen variabel med som den skjulte result-parameter, så en funktion, der aldrig allokerer sit result, alligevel kan skrive i et array, som calleren har allokeret. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray er reference-line-opslaget i hjertet af todimensional Group 3- og Group 4-dekodning: givet den aktuelle position a0 og farven på det aktuelle run søger den i den forrige scanlines changing elements, altså b1 og b2 i ITU-T T.4- og T.6's todimensionelle kodeskema, og returnerer dem som et array med to pladser. Den oprindelige funktion skrev Result[0] og Result[1] og kaldte overhovedet aldrig SetLength på Result

Det burde fejle ved første skrivning, og under Free Pascal gør det det også. Under Delphi gjorde det det aldrig, fordi begge call sites i decoderen ser sådan ud: erklær b: TCCITTIntegerArray, kør SetLength(b, 2) én gang før scanline-løkken, og tildel så inde i løkken b := GetNextChangingElement(a0, IsWhite) og læs b[0] og b[1]. Delphis sprogguide slår fast, at en funktion, hvis result er en long string, et dynamisk array eller en anden managed type, modtager dette result som en ekstra var-parameter, og i praksis giver compileren adressen på assignment-target videre. Altså er Result inde i funktionen b selv, allerede to elementer lang, og hver skrivning lander i hukommelse, som calleren ejer. Free Pascal giver funktionen et friskt nil-array og tildeler det bagefter til b, hvilket er den læsning af kontrakten, som koden burde have været skrevet efter i første omgang

PDFlibPas CCITT-dekodningsdivergens: Delphi giver caller-arrayet b med som den skjulte var Result i GetNextChangingElement, så skrivninger lander i hukommelse, calleren ejer, og et missed opslag bevarer de forrige værdier, mens Free Pascal giver funktionen et friskt nil-array, som Length-værnet skal sætte størrelsen på med SetLength, før første skrivning
Delphi aliaserer caller-arrayet som den skjulte Result-parameter, så uværnede skrivninger stadig lander i ejet hukommelse, mens Free Pascal ankommer med nil, og værnet på én linje forvandler fejlen til tilsigtet adfærd uden at røre Delphi-dekodevejen

Aliaseringsmekanismen bar også på en semantik, som decoderen er afhængig af. Result[0] tildeles kun, når skanningen finder et element større end a0, og Result[1] kun, når der findes et element efter det, så ved et miss beholder pladserne det, forrige iteration efterlod i b. Den oplagte fiks, at allokere to pladser og nulstille dem ved hvert kald, ville have ødelagt den carry-over og ændret det dekodede output under Delphi. Fixet, der shippede, er derfor et værn i stedet for en nulstilling: under Delphi er det dead code, og dekodevejen forbliver byte for byte, som den var, og under Free Pascal forvandler det fejlen til tilsigtet adfærd. Den asymmetri er hele pointen, for fixet skulle være en no-op på den compiler, hvor koden allerede producerede verificeret output

Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
  IsWhite: Boolean): TCCITTIntegerArray;
Begin
  // Delphi ankommer hertil med callerens to-elementers array aliaseret
  // som Result, så det er en no-op der. FPC ankommer med nil.
  If (Length(Result) < 2) Then
    SetLength(Result, 2);
  ...
  // Result[0] / Result[1] skrives stadig kun ved et hit, så et miss
  // bevarer forrige iterations værdier præcis som før
End;

En tælling, der overlevede sine data: TIFF directory-entryen

Når du ugyldiggør et array, skal du ugyldiggøre dets tælling i samme statement, ellers kommer tællingen til at blive troet af kode, der aldrig ser arrayet. En TIFF image file directory-entry (TIFF 6.0 §2, 12-byte-layoutet af tag, type, count og value-eller-offset) bærer en 32-bit count lige ud af filen, og PDF Library for Delphi læser hver enkelt gennem PopDE: TTIFFEntry, en record med Tag, TagType, Length, Offset samt de dekodede arrays IntegerValues og DoubleValues. Den oprindelige kode tjekkede, om Offset + TypeSize * Length løb forbi filens slutning, og gjorde den det, satte den begge arrays til længde nul. Den efterlod Result.Length med værdien fra filen

To ting gik galt derfra. Funktionen slutter med en fallback, hvis regel er, at et entry med Length nul skal give ét element med værdien nul, så callers altid kan læse element nul. Fordi Length aldrig blev ryddet på out-of-range-vejen, faldt den fallback aldrig ind i netop det tilfælde, den eksisterede for. Og callerne læser element nul, betingelsesløst: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip og et dusin flere tager E.IntegerValues[0], og strip-tabellerne laver Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4) og kopierer Length gange fire bytes ud af et array, der ikke har nogen. Et ryddet array med en levende tælling er strengt taget farligere end et utjekket, for det utjekkede holder i det mindste de bytes, det påstår

Det andet problem var rækkefølgen. De to SetLength-kald kørte, før range-testen, og størrelsessatte ud fra filens count, så et fjendtligt entry kunne bede om en allokering på flere gigabyte, inden én eneste gyldighedstjek. Under Delphi blev den resulterende exception fanget af en handler længere oppe i image-loading-vejen, og filen fejlede bare at loade, hvilket er grunden til, at ingen bemærkede det; hvad der faktisk skete, var en out-of-memory-hændelse, som filen selv valgte. Fixet flytter allokeringen til efter testen og får tællingen til at rejse med dataene

TIFF directory entry-hærdning i PDFlibPas: 12-byte-entryen bærer en fil-leveret count, den ødelagte rækkefølge allokerede arrays ud fra den count før range-testen og efterlod Result.Length i live efter at have ryddet dem, og den faste rækkefølge tester Int64-aritmetikken mod fillængden først, så counten ryddes sammen med arraysene
At allokere før range-testen lod en fjendtlig count bede om gigabyte og efterlod en levende count på et tømt array, så fixet tester offsetten først og rydder Result.Length i samme statement som arraysene
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
              > Length(Source);
If OutOfRange Then
Begin
  Result.Length := 0;              // counten rejser med værdierne
  SetLength(Result.IntegerValues, 0);
  SetLength(Result.DoubleValues, 0);
End
Else
Begin
  SetLength(Result.IntegerValues, Result.Length);  // først nu
  SetLength(Result.DoubleValues, Result.Length);
End;
// ... senere når den eksisterende fallback endelig det tilfælde, den var til for:
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

Intet ved dette fix er compilerspecifikt, og det er netop derfor, det hører hjemme på listen. Defekten var latent under Delphi af samme grund, som den var latent under Free Pascal: ingen testfil havde et directory-entry, der pegede forbi filens slutning. Porteringen afslørede den ikke. Det gjorde at læse koden med spørgsmålet "hvad gør Delphi for mig her, som jeg ikke gør selv"

Hvad sker der, når en PNG IHDR påstår en color type, som formatet ikke definerer?

PDF Library for Delphi afviser nu billedet, før row-filtrene kører; før v3.539.2 beregnede den en scanline på nul bytes og gav unfilter-løkkerne en tom buffer. ISO 15948 §11.2.2 definerer IHDR-chunken, og Tabel 11.1 lister de seks lovlige kombinationer af color type og bit depth: grayscale ved 1, 2, 4, 8 eller 16 bits, indexed color ved 1, 2, 4 eller 8, og truecolor, grayscale med alpha og truecolor med alpha ved 8 eller 16. TPNGReader validerede IHDRs felter for compression method og filter method og lod FColorType og bit depth passere urørt igennem

Row-filter-koden størrelsessætter alt ud fra en Case FColorType Of, der mapper hver color type til et antal komponenter. En color type uden for de seks ryger i Else-grenen, hvor SourceComponents er 0, så ScanlineByteCount er 0, så SetLength(PreviousScanline, 0) efterfølges øjeblikkeligt af FillChar(PreviousScanline[0], ScanlineByteCount, 0). At indeksere element nul i et tomt dynamisk array er en adresse beregnet ud fra nil. Med range checking slået fra er en fill på nul bytes gennem den adresse en lydløs no-op, og decoderen marcherer videre gennem rækker, der ikke findes; med range checking slået til er det en ERangeError på det første billede; og de Move-kald, der følger efter, er ét skridt fra en access violation. Hvilken af dem du får, afhænger af compileren og af build-switches snarere end af noget, decoderen besluttede, og det er det afslørende: Decoderen besluttede aldrig noget som helst

Fixet er tabellen fra specifikationen, anvendt dér, hvor de andre IHDR-felter allerede blev tjekket: COLOR_GRAYSCALE accepterer FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE accepterer [1, 2, 4, 8], og COLOR_RGB, COLOR_GRAYSCALEALPHA og COLOR_RGBALPHA accepterer [8, 16]; alt andet rydder ValidImage, og billedet nægtes med width og height intakt til diagnostik. En pHYs-chunk kortere end sine ni bytes blev lukket i samme gennemløb, eftersom DPI-læseren indekserede S[1] til S[8] i en streng, som den korte chunk havde efterladt tom

En 1-baseret offset behandlet som en 0-baseret pointer

InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString tager en 1-baseret StartPos, fordi dens input er en AnsiString, og Delphi-implementeringen adresserer zlib-inputtet som @Input[StartPos]. Free Pascal-implementeringen, skrevet op mod paszlib, så begge Windows-targets kan linke kompression statisk, satte next_in til PAnsiChar(Input) + StartPos og avail_in til Length(Input) - StartPos. Det er pointer-aritmetik, og den er 0-baseret. Giv den 1, hvilket er, hvad "start i begyndelsen" betyder for denne funktion, og FPC-builden begynder at inflate ved den anden byte og stopper én byte før enden

Grunden til, at den overlevede, er, at den eneste caller, de fleste tests når, er InflateStr, som giver 0. Nul er tilfældigvis den korrekte 0-baserede offset, så de to builds var enige om hvert almindeligt InflateStr-kald og hver test, der gik gennem den. TPDFDocument.DecodeAllStreams, rutinen som SaveQDFToFile og ConvertFileToQDF bruger til at folde enkelte FlateDecode-streams ud i læsbar form, giver 1. På FPC-builden fik det oversprungne zlib-header inflate til at fejle, men zlib-streamen rapporterede stadig en Consumed forskellig fra nul for de bytes, den havde betragtet, så DecodeAllStreams tog den tomme payload som en succesfuld dekodning og erstattede hver eneste content stream med en tom streng. Den resulterende QDF havde det rigtige sideantal, gyldig struktur og intet sideindhold, hvilket er en fil, der åbner uden fejl i alle viewers og ikke viser noget

// FPC-grenen af InflateStrFromPosition, efter v3.539.16.
// StartPos er 1-baseret ligesom Delphi-grenen; clamp den, og konvertér
// til en 0-baseret pointer-offset præcis én gang, ved grænsen.
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;

Regressionstesten, der låser det fast, er den mindst mulige: deflate en payload, inflate den fra position 0 og fra position 1, og assert at begge returnerer den samme payload, og at begge rapporterer en Consumed lig den fulde streamlængde. En RFC 1950-stream har en header på to bytes og en Adler-32-trailer på fire bytes, så en off-by-one i den ene ende er ikke en subtil korruption, det er en stream, der enten ikke kan komme i gang eller ikke kan slutte. Lektien handler om grænsen, ikke om zlib: når en funktions parameter er defineret i ét index base, og implementeringen nedenunder bruger det andet, hører konverteringen hjemme på præcis én linje, og en test skal kalde den med den værdi, der adskiller de to baser

Hvorfor er en kort TStream.Read ikke streamens slutning?

Fordi TStream.Read har lov til at returnere færre bytes, end der blev bedt om, af enhver grund, den lyster, og kun en returværdi på 0 betyder, at der ikke kommer mere. TMemoryStream og TFileStream på en lokal disk fylder næsten altid forespørgslen, og det er derfor, kode, der behandler "fik mindre, end jeg bad om" som end-of-file, består hver eneste test, der bruger dem. Netværksbårne streams, dekomprimeringsstreams og enhver TStream-efterkommer, en kunde har skrevet, kan returnere to bytes, når der bedes om 64.000, og stadig have gigabyte bag efter

TPLBuffer er den reader, hver eneste parser i PDF Library for Delphi går igennem, og den kan pakke en AnsiString, en pointer, et byte-array eller en TStream. Dens fire skanneforespørgsler, DistanceToByte, DistanceToOtherByte, DistanceToAnyByte og DistanceToOtherBytes, alle med returtype Int64, læser kilden i 64 KB-blokke, leder efter en delimiter og rapporterer, hvor langt væk den er, uden at flytte den logiske position. Hver løkke endte med Until ReadCount < BlockSize. For de tre in-memory-kilder er det korrekt, eftersom ReadIntoBuffer altid leverer den fulde blok indtil den sidste. For stream-kilden betyder det, at skanningen giver op ved det første korte read, rapporterer delimitetren som fraværende, og tokenizeren derover beslutter, at objektet slutter, hvor det ikke gør

Håndtering af korte reads i PDFlibPas stream buffer: DistanceToByte skanner 64 KB-blokke, den gamle løkke behandlede Until ReadCount < BlockSize som slutningen på data og gav op ved det første korte read, mens den faste løkke kører, til ReadCount er nul, finder delimitetren og gendanner positionen i en finally-blok
En stream må returnere to bytes, når der bedes om 64.000, så nul er det eneste end-of-data-signal, skanningen må stole på, og finally-klausulen gendanner den logiske position, når delimitetren er fundet, og løkken exit'er tidligt
// TPLBuffer.DistanceToByte, løkken efter v3.539.6.
// Nul er det eneste end-of-data-signal, 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;   // et peek må ikke flytte readeren
End;

Testen, der fastlåser det, er en TMemoryStream-efterkommer, hvis Read-override begrænser hver forespørgsel til to bytes. Pak strengen aaaaaX ind i den, sæt bufferpositionen til 1, og alle fire forespørgsler skal rapportere en afstand på 4 til X, efterlade positionen ved 1 bagefter og rapportere -1 for en byte, der ikke er der. Før fixet så den første forespørgsel to bytes, konkluderede, at streamen var udtømt, og returnerede -1. finally betyder lige så meget som løkkebetingelsen: en Exit inde fra skanningen er det normale succeseventyr, og den logiske position skal gendannes på den vej også, ikke kun når løkken kører færdig

Én kilde, to compilere, ét sæt assertions

Disciplinen, der faldt ud af disse fem, er, at "Delphi-builden består" er bevis om Delphi, ikke om kildeteksten. Siden v3.539.16 inkluderer både Delphi DUnitX-suitten og Free Pascal-konsolsuitten den samme Tests\CrossCompilerSemantics.inc, én enkelt rutine, RunCrossCompilerFileSemantics, som bygger et dokument på to sider med komprimeret indhold gennem TPDFlib, gemmer det, gemmer det igen som QDF gennem SaveQDFToFile, reparerer QDF'en med RepairQDFFile, krypterer den rene fil med AES-128 gennem EncryptFile og en permission-maske fra EncodePermissions, og derefter genindlæser hvert artefakt og assert'er det samme på begge compilere: sideantallet er 2, titlen overlever, side tos tekst ekstraheres intakt fra den rene, den reparerede og den krypterede fil, det forkerte kodeord nægtes med en LastErrorCode forskellig fra nul, EncryptionStrength er 128, EncryptionAlgorithm er 2, og de enkelte permission-bits fra GetUserPermissions kommer tilbage præcis som kodet

Sammenligningen er bevidst normaliseret frem for byte for byte. Kryptering trækker tilfældige salte, og writeren tildeler dokument-id'er, så de to builds forventes ikke at emitte identiske filer; de forventes at emitte filer, der betyder det samme, og assertionsene er formuleret på det niveau. QDF-benet er der specifikt på grund af offset-bugen: en QDF med to sider og intet indhold består et sideantalstjek og stryger ved et tekstekstraktionstjek, og matricen assert'er det sidste. Ethvert fremtidigt fix, der er en no-op på den ene compiler og en adfærdsændring på den anden, hvilket beskriver fire af de fem ovenfor, skal nu forbi de samme assertions to gange, før det shippes

Link-time-halvdelen af samme portering, at få Delphis OMF-objekter og Free Pascals COFF-forventninger til at enes, er sin egen historie i FPC Win32 OMF til COFF objekt-linking, og den strukturelle hærdning af samme TIFF-reader mod BigTIFF og tiled-filer ligger i noterne om den indbyggede TIFF-decoder. Decoderne i denne artikel og den cross-compiler-test, der nu ligger under dem, følger med i PDF Library for Delphi til Delphi, C++Builder og Free Pascal, hvor den samme kilde forventes at tjene sig til det samme resultat på hver compiler, den target'er, i stedet for at få det foræret af én