Техническа статия

Delphi код, който работи по случайност: пет FPC бъга

PDF Library for Delphi откри пет дефекта в декодерите си, докато вдигаше CCITT, TIFF, PNG, Flate и stream-буферния си код върху Free Pascal, и всеки един от тях беше минал чисто през пълния Delphi тестов пакет с години. Нито един не е бъг на компилатора. Всякият е Pascal, който Delphi случайно се оказа изпълняващ коректно благодарение на детайл от имплементацията: скрит резултатен параметър, който прави alias на масива на извикващия, клон извън диапазона, който никой никога не е чел нататък, нулево-дълъг буфер, чиято единствена защита е ключът за range check, 1-базиран offset, който само един кодов път подаваше като 1, и договор на TStream.Read, който in-memory stream-овете никога не упражняват. Сменете компилатора, или нахранете същия код с повреден файл, и случайността спира да държи

Това, което следва, е конкретната форма на всеки от тях, поправката и дисциплината, изтекла от тях: един и същ изходен код сега трябва да дава едни и същи семантики на документа и на двата компилатора, и тестов include проверява, че го прави. Сестринската статия за втвърдяване на Pascal PDF парсер срещу зловредни файлове покри ширината на целите числа, дълбочината на рекурсията и неинициализираните буфери. Тази е за друг клас провали: код, който е бил грешен от самото начало, и компилатор, който тихо го прикриваше

Защо функция, връщаща динамичен масив, работи без SetLength на Delphi?

Защото Delphi подава собствената променлива на извикващия като скрит result параметър, така че функция, която никога не алокира резултата си, пак може да пише в масив, алокиран от извикващия. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray е търсенето по reference-ред в сърцето на двумерното Group 3 и Group 4 декодиране: при подадени текуща позиция a0 и цвят на текущия run, тя претърсва changing елементите на предишния scanline — b1 и b2 на двумерната схема на кодиране по ITU-T T.4 и T.6 — и ги връща като масив от два слота. Оригиналната функция пишеше Result[0] и Result[1] и изобщо не извикваше SetLength върху Result

Това би гръмнало при първия запис, и на Free Pascal наистина гръмва. На Delphi никога не е гръмвало, защото и двете места на извикване в декодера изглеждат така: декларирате b: TCCITTIntegerArray, пускате SetLength(b, 2) веднъж преди цикъла по scanline, после вътре в цикъла правите b := GetNextChangingElement(a0, IsWhite) и четете b[0] и b[1]. Delphi езиковото ръководство казва, че функция с резултат long string, динамичен масив или друг managed тип получава този резултат като допълнителен var параметър, а на практика компилаторът подава адреса на целта на присвояването. Тоест Result вътре във функцията е самото b, вече с два елемента, и всеки запис попада в памет, която извикващият притежава. Free Pascal подава на функцията свеж nil масив и го присвоява на b след това, което е четенето на договора, по който кодът е трябвало да е писан още в началото

Дивергенция при CCITT декодиране в PDFlibPas: Delphi подава масива b на извикващия като скрит var Result на GetNextChangingElement, така че записите попадат в памет, притежавана от извикващия, и пропуснато търсене запазва предишните стойности, докато Free Pascal подава на функцията свеж nil масив, който guard-ът по Length трябва да оразмери със SetLength преди първия запис
Delphi прави alias на масива на извикващия като скрит Result параметър, така че и незащитените записи попадат в притежавана памет, докато Free Pascal идва с nil и едноредовият guard превръща грешката в планирано поведение, без да пипа Delphi пътя на декодиране

Alias-ът носеше и семантика, от която декодерът зависи. Result[0] се присвоява само когато сканирането намери елемент, по-голям от a0, а Result[1] — само когато след него има още елемент, така че при пропуск слотовете запазват това, което предишната итерация е оставила в b. Очевидната поправка — алокирай два слота и ги нулирай на всяко извикване — би унищожила това пренасяне и би променила декодирания изход на Delphi. Пуснатата поправка е guard вместо reset: на Delphi е мъртъв код и декодерният път остава байт по байт същият, а на Free Pascal превръща грешката в планираното поведение. Тази асиметрия е целият смисъл, защото поправката е трябвало да е no-op на компилатора, където кодът вече даваше проверен изход

Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
  IsWhite: Boolean): TCCITTIntegerArray;
Begin
  // Delphi стига дотук с двуелементния масив на извикващия, alias-нат
  // като Result, така че там това е no-op. FPC пристига с nil.
  If (Length(Result) < 2) Then
    SetLength(Result, 2);
  ...
  // Result[0] / Result[1] продължават да се пишат само при попадение,
  // така че пропускът пази стойностите от предишната итерация както преди
End;

Бройка, надживяла данните си: записът в TIFF директорията

Когато инвалидирате масив, трябва да инвалидирате и бройката му в същото изложение, иначе бройката ще бъде повярвана от код, който никога не вижда масива. Записът в TIFF image file directory (TIFF 6.0 §2, 12-байтовото подреждане на tag, type, count и value-or-offset) носи 32-битова бройка направо от файла, и PDF Library for Delphi чете всеки през PopDE: TTIFFEntry — record с Tag, TagType, Length, Offset и декодираните масиви IntegerValues и DoubleValues. Оригиналният код проверяваше дали Offset + TypeSize * Length излиза зад края на файла и ако да, настройваше двата масива на нулева дължина. Result.Length обаче оставяше със стойността от файла

Оттам нататък се объркаха две неща. Функцията завършва с fallback, който чете „ако Length е нула, дай на записа един нулев елемент", за да могат извикващите винаги да четат елемент нула. Тъй като Length никога не беше чистена на пътя извън диапазона, този fallback не се включваше точно за единствения случай, за който съществува. А извикващите наистина четат елемент нула, безусловно: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip и още дузина вземат E.IntegerValues[0], а таблиците със strip-ове правят Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4) — копират Length пъти по четири байта от масив, който няма нито един. Изчистен масив с жива бройка е строго по-опасен от непроверен, защото непровереният поне държи байтовете, за които твърди, че държи

Вторият проблем беше подредбата. Двете SetLength извиквания се изпълняваха преди теста за диапазон, оразмерени по бройката от файла, така че враждебен запис можеше да поиска алокация от няколко гигабайта преди първата проверка за валидност. На Delphi полученото изключение се прихващаше от handler по-нагоре по пътя на зареждане на изображението и файлът просто не се зареждаше — затова никой не го забеляза; реално се случи out-of-memory събитие, което самият файл беше избрал. Поправката мести алокацията след теста и кара бройката да пътува заедно с данните

Втвърдяване на записа в TIFF директория в PDFlibPas: 12-байтовият запис носи бройка, взета от файла, счупената подредба алокираше масивите по тази бройка преди теста за диапазон и оставяше Result.Length жив след изчистването им, а поправената подредба първо тества Int64 аритметиката срещу дължината на файла, така че бройката се изчиства заедно с масивите
Алокирането преди теста за диапазон позволи враждебна бройка да поиска гигабайти и остави жива бройка върху изпразнен масив, затова поправката първо тества offset-а и изчиства Result.Length в същото изложение като масивите
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
              > Length(Source);
If OutOfRange Then
Begin
  Result.Length := 0;              // бройката тръгва заедно със стойностите
  SetLength(Result.IntegerValues, 0);
  SetLength(Result.DoubleValues, 0);
End
Else
Begin
  SetLength(Result.IntegerValues, Result.Length);  // чак сега
  SetLength(Result.DoubleValues, Result.Length);
End;
// ... по-късно съществуващият fallback най-после стига до своя случай:
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

Нищо в тази поправка не е специфично за компилатора, и точно затова има място в списъка. Дефектът беше латентен на Delphi по същата причина, по която беше латентен на Free Pascal: нито един тестов файл нямаше запис в директорията, сочащ зад края на файла. Портът не го извади наяве. Извади го четенето на кода с въпроса „какво прави Delphi вместо мен тук"

Какво става, когато PNG IHDR твърди тип цвят, който форматът не дефинира?

PDF Library for Delphi сега отхвърля изображението, преди да се пуснат row филтрите; преди v3.539.2 изчисляваше scanline от нула байта и подаваше на unfilter циклите празен буфер. ISO 15948 §11.2.2 дефинира chunk-а IHDR, а таблица 11.1 изброява шестте легални комбинации от тип цвят и битова дълбочина: grayscale на 1, 2, 4, 8 или 16 бита, indexed цвят на 1, 2, 4 или 8, и truecolor, grayscale с alpha и truecolor с alpha на 8 или 16. TPNGReader валидираше полетата compression method и filter method на IHDR и пропускаше FColorType и битовата дълбочина нетрогнати

Кодът на row филтрите оразмерява всичко по Case FColorType Of, който мапва всеки тип цвят към брой компоненти. Тип цвят извън шестте попада в Else клона, където SourceComponents е 0, следователно ScanlineByteCount е 0, следователно веднага след SetLength(PreviousScanline, 0) идва FillChar(PreviousScanline[0], ScanlineByteCount, 0). Индексиране на елемент нула на празен динамичен масив е адрес, изчислен от nil. С изключен range check fill-ът от нула байта през този адрес е тих no-op и декодерът марширува напред през редове, които не съществуват; с включен е ERangeError още на първото изображение; а Move извикванията след него са на крачка от access violation. Кое от трите ще получите зависи от компилатора и от build ключовете, а не от каквото и да е решил декодерът — и точно това е признакът, че декодерът изобщо не е решил

Поправката е таблицата от спецификацията, приложена там, където останалите полета на IHDR вече се проверяваха: COLOR_GRAYSCALE приема FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE приема [1, 2, 4, 8], а COLOR_RGB, COLOR_GRAYSCALEALPHA и COLOR_RGBALPHA приемат [8, 16]; всичко останало чисти ValidImage и изображението се отказва с запазени width и height за диагностика. Chunk pHYs, по-къс от деветте си байта, беше затворен в същия пас, защото четецът на DPI индексираше S[1] до S[8] на стринг, който късият chunk беше оставил празен

1-базиран offset, третиран като 0-базиран указател

InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString приема 1-базиран StartPos, защото входът ѝ е AnsiString и Delphi имплементацията адресира zlib входа като @Input[StartPos]. Free Pascal имплементацията, написана срещу paszlib, за да линква и двата Windows таргета компресията статично, настройваше next_in на PAnsiChar(Input) + StartPos и avail_in на Length(Input) - StartPos. Това е указателна аритметика, и тя е 0-базирана. Подайте 1 — което е значението на „почни от началото“ за тази функция — и FPC build-ът започва да inflate-ва от втория байт и спира един байт преди края

Причината да оцелее е, че единственият извикващ, до който повечето тестове стигат, е InflateStr, който подава 0. Нула се оказа коректният 0-базиран offset, така че двата build-а се съгласяваха на всяко обикновено InflateStr извикване и на всеки тест, минаващ през него. TPDFDocument.DecodeAllStreams — рутината, с която SaveQDFToFile и ConvertFileToQDF разгръщат stream-ове с един-единствен FlateDecode в четима форма — подава 1. На FPC build-ът пропускът на zlib header-а караше inflate-а да се провали, но zlib stream-ът все пак докладваше ненулево Consumed за байтовете, които беше прегледал, така че DecodeAllStreams приемаше празния payload за успешен decode и заменяше всеки content stream с празен стринг. Полученият QDF имаше правилен брой страници, валидна структура и никакво съдържание на страниците — файл, който се отваря без грешка във всеки viewer и не показва нищо

// FPC клон на InflateStrFromPosition, след v3.539.16.
// StartPos е 1-базиран както в Delphi клона; затисни го, после
// превърни го в 0-базиран pointer offset точно веднъж, на границата.
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;

Регресионният тест, който това заземява, е възможно най-малкият: deflate-нете payload, inflate-нете го от позиция 0 и от позиция 1 и проверете, че и двете връщат същия payload и че двете докладват Consumed, равен на пълната дължина на stream-а. RFC 1950 stream има двубайтов header и четирибайтов Adler-32 trailer, така че off-by-one на някой от двата края не е фина развалена структура, а stream, който или не успява да тръгне, или не успява да свърши. Урокът е за границата, не за zlib: когато параметърът на функция е дефиниран в една индексна база, а имплементацията отдолу ползва другата, преобразуването принадлежи на точно един ред, и тест трябва да го извика със стойността, различаваща двете бази

Защо къс TStream.Read не е краят на stream-а?

Защото TStream.Read има право да върне по-малко байтове от поисканите по каквато и да е причина, и само връщането на 0 значи, че няма повече. TMemoryStream и TFileStream на локален диск почти винаги изпълняват заявката изцяло, затова код, който третира „върна по-малко, отколкото поисках" като край на файла, минава всеки тест, ползващ тях. Stream-ове върху мрежа, декомпресионни stream-ове и всеки TStream наследник, написан от клиент, може да върне два байта при заявка за шестдесет и четири хиляди и пак да има гигабайти след тях

TPLBuffer е четецът, през който минава всеки парсер в PDF Library for Delphi, и може да обвива AnsiString, указател, байтов масив или TStream. Четирите му сканиращи заявки — DistanceToByte, DistanceToOtherByte, DistanceToAnyByte и DistanceToOtherBytes, всички връщащи Int64 — четат източника в блокове по 64 KB, търсейки разделител, и докладват на какво разстояние е той, без да местят логическата позиция. Всеки цикъл завършваше с Until ReadCount < BlockSize. За трите in-memory източника това е коректно, защото ReadIntoBuffer винаги доставя пълния блок освен последния. За stream източника това значи, че сканирането се предава при първото късо четене, докладва разделителя като отсъстващ, а токенизаторът над него решава, че обектът свършва там, където не свършва

Обработка на къси четения в stream буфера на PDFlibPas: DistanceToByte сканира блокове по 64 KB, старият цикъл третираше Until ReadCount < BlockSize като край на данните и се предаваше при първото късо четене, а поправеният цикъл върти докато ReadCount стане нула, намира разделителя и възстановява позицията в finally блок
Stream може да върне два байта при заявка за шестдесет и четири хиляди, така че нулата е единственият сигнал за край на данни, на който сканирането може да вярва, а finally клаузата възстановява логическата позиция, когато разделителят е намерен и цикълът излиза рано
// TPLBuffer.DistanceToByte, цикълът след v3.539.6.
// Нулата е единственият сигнал за край на данни, който 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;   // peek не бива да мести четеца
End;

Тестът, който това заземява, е наследник на TMemoryStream, чийто override на Read капсва всяка заявка на два байта. Обвийте в него стринга aaaaaX, сложете позицията на буфера на 1, и и четирите заявки трябва да докладват разстояние 4 до X, да оставят позицията на 1 след това и да върнат -1 за байт, който го няма. Преди поправката първата заявка виждаше два байта, заключаваше, че stream-ът е изчерпан, и връщаше -1. finally е също толкова важен, колкото и условието на цикъла: Exit изходът от вътрешността на сканирането е нормалният успешен път, и логическата позиция трябва да се възстановява и на него, а не само когато цикълът изтича докрай

Един източник, два компилатора, един набор твърдения

Дисциплината, изтекла от тези пет, е, че „Delphi build-ът минава" е доказателство за Delphi, не за изходния код. От v3.539.16 нататък Delphi DUnitX пакетът и Free Pascal конзолният пакет включват един и същ Tests\CrossCompilerSemantics.inc — една-единствена рутина, RunCrossCompilerFileSemantics, която изгражда двустраницен документ с компресирано съдържание през TPDFlib, записва го, записва го отново като QDF през SaveQDFToFile, ремонтира QDF-а с RepairQDFFile, шифрова чистия файл с AES-128 през EncryptFile и маска за права от EncodePermissions, а после презарежда всеки артефакт и твърди едни и същи неща на двата компилатора: броят страници е 2, заглавието оцелява, текстът на втора страница се извлича непокътнат от чистия, ремонтирания и шифрования файл, грешната парола се отхвърля с ненулев LastErrorCode, EncryptionStrength е 128, EncryptionAlgorithm е 2, а отделните битове за права от GetUserPermissions се връщат точно както са били кодирани

Сравнението е нарочно нормализирано, а не байт по байт. Шифроването тегли случайни соли, а writer-ът задава идентификатори на документа, така че от двата build-а не се очаква да изпарат идентични файлове; очаква се да изпарат файлове, значещи едно и също, и твърденията са формулирани на това ниво. QDF кракът е там специално заради бъга с offset-а: QDF с две страници и никакво съдържание минава проверка за брой страници и проваля проверка за извличане на текст, а матрицата твърди втората. Всяка бъдеща поправка, която е no-op на един компилатор и смяна на поведението на другия — а това описва четири от петте по-горе — отсега нататък трябва да мине през същите твърдения два пъти, преди да бъде пусната

Линк-временната половина на същия порт — как Delphi OMF обектите и COFF очакванията на Free Pascal се разберат — е отделна история в FPC Win32 OMF към COFF линкване на обекти, а структурното втвърдяване на същия TIFF четец срещу BigTIFF и tiled файлове е в бележките за вградения TIFF декодер. Декодерите в тази статия и кръстосано-компилаторният тест, който сега стои под тях, се доставят в PDF Library for Delphi за Delphi, C++Builder и Free Pascal, където от един и същ изходен код се очаква да извоюва един и същ резултат на всеки компилатор, а не да му бъде подаряван от единия