PDF Library for Delphi هنگام بالا آوردن کد CCITT و TIFF و PNG و Flate و stream-buffer خود روی Free Pascal پنج نقص در decoderها پیدا کرد و هر پنجتا سالها از کل سویت تست Delphi سربلند بیرون آمده بودند. هیچکدام باگ کامپایلر نبود. هر پنجتا پاسکالی بودند که Delphi تصادفاً درست اجرایش میکرد، فقط به خاطر یک جزئیات پیادهسازی: یک پارامتر نتیجهٔ پنهان که به آرایهٔ caller alias میشد، یک شاخهٔ خارج از محدوده که هیچکس هرگز آنطرفش را نخوانده بود، یک بافر با طول صفر که تنها نگهبانش یک سوییچ range-check بود، یک offset یک-پایه که فقط یک مسیر کد آن را 1 پاس میداد، و یک قرارداد TStream.Read که streamهای درون-حافظهای هرگز محکش را نمیزنند. کامپایلر را عوض کن، یا همان کد را با یک فایل بدشکل تغذیه کن، و تصادف دیگر برقرار نمیماند
در ادامه شکل دقیق هر یک، fix آن و انضباطی که از آن زاده شد میآید: یک منبع واحد باید روی هر دو کامپایلر همان معناشناسی سند را تولید کند و یک include تست این را بررسی میکند. مقالهٔ خواهر دربارهٔ سختسازی یک parser پاسکالی PDF در برابر فایلهای مخرب عرض عدد صحیح، عمق بازگشت و بافرهای مقداردهینشده را پوشش میداد. این یکی دربارهٔ کلاس شکست دیگری است: کدی که از همان اول غلط بود و کامپایلر بیصدا پوششش میداد
چرا یک تابع که آرایهٔ دینامیک برمیگرداند روی Delphi بدون SetLength کار میکند؟
چون Delphi متغیر خودِ caller را به عنوان پارامتر نتیجهٔ پنهان پاس میدهد، پس تابعی که هرگز برای نتیجهٔ خود تخصیص نمیدهد هم میتواند در آرایهای بنویسد که caller تخصیص داده. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray همان جستجوی خط مرجع در قلب decode دوبعدی Group 3 و Group 4 است: با گرفتن موقعیت فعلی a0 و رنگ run جاری، عناصر متغیر scanline قبلی یعنی b1 و b2 در طرح کدگذاری دوبعدی ITU-T T.4 و T.6 را میگردد و آنها را به صورت یک آرایهٔ دو-خانهای برمیگرداند. تابع اصلی Result[0] و Result[1] را مینوشت و اصلاً هیچوقت SetLength را روی Result صدا نمیزد
این باید از همان اولین نوشتن fault بدهد و روی Free Pascal میدهد. روی Delphi هرگز نداد، چون هر دو call site در decoder اینطور به نظر میرسند: b: TCCITTIntegerArray اعلام میشود، یک بار پیش از حلقهٔ scanline دستور SetLength(b, 2) اجرا میشود، و بعد داخل حلقه b := GetNextChangingElement(a0, IsWhite) نسبت داده میشود و b[0] و b[1] خوانده میشوند. راهنمای زبان Delphi میگوید تابعی که نتیجهاش یک long string یا آرایهٔ دینامیک یا هر نوع managed دیگری است، آن نتیجه را به صورت یک پارامتر var اضافی میگیرد و در عمل کامپایلر آدرس هدف انتساب را پاس میدهد. پس Result داخل تابع خودِ b است، از قبل دو عنصری، و هر نوشتن در حافظهای فرود میآید که caller مالکش است. Free Pascal یک آرایهٔ nil تازه به تابع میدهد و بعد آن را به b نسبت میدهد، که همان قرائتی از قرارداد است که کد باید از همان اول بر مبنایش نوشته میشد
این alias یک معناشناسی هم با خود داشت که decoder به آن وابسته است. Result[0] فقط وقتی نسبت داده میشود که جستجو عنصری بزرگتر از a0 پیدا کند و Result[1] فقط وقتی که عنصری بعد از آن وجود داشته باشد؛ پس در یک ناکامی، خانهها هر چه تکرار قبلی در b گذاشته را نگه میدارند. fix بدیهی، یعنی تخصیص دو خانه و صفر کردنشان در هر فراخوانی، آن انتقال را از بین میبرد و خروجی decode روی Delphi را عوض میکرد. fixی که منتشر شد یک نگهبان است، نه یک ریست: روی Delphi کد مرده است و مسیر decode موبهمو همان چیزی میماند که بود، و روی Free Pascal یک fault را به رفتار موردنظر تبدیل میکند. همین عدمتقارن تمام نکته است، چون fix باید روی کامپایلری no-op میبود که کد از قبل داشت خروجی تأییدشده تولید میکرد
Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
IsWhite: Boolean): TCCITTIntegerArray;
Begin
// Delphi با آرایهٔ دو-عنصری caller که به عنوان Result alias شده به اینجا میرسد،
// پس اینجا no-op است. FPC با nil میرسد.
If (Length(Result) < 2) Then
SetLength(Result, 2);
...
// Result[0] / Result[1] هنوز فقط در حالت hit نوشته میشوند، پس یک miss
// مقادیر تکرار قبلی را دقیقاً مثل قبل نگه میدارد
End;
شمارندهای که از دادهاش بیشتر عمر کرد: ورودی directory در TIFF
وقتی یک آرایه را بیاعتبار میکنی، باید شمارندهاش را هم در همان دستور بیاعتبار کنی، وگرنه کدی که هرگز خود آرایه را نمیبیند آن شمارنده را باور میکند. یک ورودی directory فایل تصویر TIFF (در TIFF 6.0 §2، همان چیدمان 12-byte ای tag و type و count و value-یا-offset) یک شمارندهٔ 32-bit را مستقیم از فایل حمل میکند و PDF Library for Delphi هر یک را از طریق PopDE: TTIFFEntry میخواند؛ رکوردی با Tag و TagType و Length و Offset و آرایههای decode شدهٔ IntegerValues و DoubleValues. کد اصلی بررسی میکرد که آیا Offset + TypeSize * Length از انتهای فایل رد میشود یا نه و اگر میشد، هر دو آرایه را با طول صفر تنظیم میکرد. اما Result.Length را روی همان مقدار داخل فایل رها میگذاشت
از آنجا دو چیز خراب شد. تابع با یک fallback تمام میشود که میگوید «اگر Length صفر است، به ورودی یک عنصر صفر-مقدار بده» تا callerها همیشه بتوانند عنصر صفر را بخوانند. چون Length در مسیر خارج-از-محدوده هرگز پاک نمیشد، آن fallback هرگز برای همان یک موردی که برایش وجود داشت عمل نکرد. و callerها هم عنصر صفر را بیقیدوشرط میخوانند: Width و Height و BitsPerSample و PhotometricInterpretation و FillOrder و SamplesPerPixel و RowsPerStrip و یک دوجین دیگر E.IntegerValues[0] را برمیدارند، و جدولهای strip هم Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4) را اجرا میکنند که یعنی Length ضرب در چهار byte از آرایهای کپی میکند که هیچ byte ای ندارد. یک آرایهٔ پاکشده با شمارندهٔ زنده قطعاً خطرناکتر از یک آرایهٔ بررسینشده است، چون آن بررسینشده حداقل همان byteهایی را دارد که ادعا میکند
مشکل دوم ترتیب بود. هر دو فراخوانی SetLength پیش از تست محدوده و با اندازهگیری از شمارندهٔ فایل اجرا میشدند، پس یک ورودی خصمانه میتوانست پیش از حتی یک بررسی اعتبارسنجی، تخصیصی چند-گیگابایتی طلب کند. روی Delphi استثنای حاصل را یک handler بالاتر در مسیر بارگذاری تصویر میگرفت و فایل صرفاً بارگذاری نمیشد؛ همین باعث شد کسی متوجه نشود. آنچه واقعاً رخ میداد یک رویداد out-of-memory بود که خودِ فایل انتخابش کرده بود. fix تخصیص را به بعد از تست میبرد و کاری میکند که شمارنده همراه داده سفر کند
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;
هیچ بخشی از این fix وابسته به کامپایلر نیست و همین باعث میشود جایاش در این فهرست باشد. نقص روی Delphi به همان دلیلی نهفته بود که روی Free Pascal نهفته بود: هیچ فایل تستی ورودی directory ای نداشت که به بعد از انتهای فایل اشاره کند. پورت آن را رو نکرد؛ خواندن کد با این پرسش که «Delphi اینجا چه کاری برایم میکند که خودم نمیکنم» رو کرد
وقتی IHDR یک PNG نوع رنگی را ادعا میکند که فرمت تعریفش نکرده چه میشود؟
PDF Library for Delphi حالا تصویر را پیش از اجرای row filterها رد میکند؛ پیش از v3.539.2 یک scanline صفر-byte ای حساب میکرد و یک بافر خالی به حلقههای unfilter میداد. ISO 15948 §11.2.2 چانک IHDR را تعریف میکند و جدول 11.1 همان شش ترکیب قانونی color type و bit depth را فهرست میکند: grayscale در 1 و 2 و 4 و 8 یا 16 بیت، indexed color در 1 و 2 و 4 یا 8، و truecolor و grayscale با alpha و truecolor با alpha در 8 یا 16. TPNGReader فیلدهای compression method و filter method در IHDR را اعتبارسنجی میکرد و FColorType و bit depth را دستنخورده رد میکرد
کد row filter همهچیز را از یک Case FColorType Of اندازه میگیرد که هر color type را به یک تعداد مؤلفه نگاشت میکند. یک color type بیرون از آن ششتا در شاخهٔ Else میافتد، جایی که SourceComponents صفر است، پس ScanlineByteCount صفر میشود، پس بلافاصله بعد از SetLength(PreviousScanline, 0) نوبت FillChar(PreviousScanline[0], ScanlineByteCount, 0) میرسد. ایندکس کردن عنصر صفر یک آرایهٔ دینامیک خالی یعنی آدرسی که از nil حساب شده. با range checking خاموش، یک fill صفر-byte ای از آن آدرس یک no-op بیصدا است و decoder از میان سطرهایی که وجود ندارند رد میشود؛ با range checking روشن، روی اولین تصویر یک ERangeError میگیری؛ و فراخوانیهای Move که پشتش میآیند یک قدم تا access violation فاصله دارند. اینکه کدامیک را میگیری به کامپایلر و سوییچهای build بستگی دارد، نه به چیزی که decoder تصمیم گرفته باشد — و همین نشانهٔ آن است که decoder اصلاً تصمیمی نگرفته بود
fix همان جدول spec است، اعمالشده در همان جایی که بقیهٔ فیلدهای 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 را پاک میکند و تصویر رد میشود، با عرض و ارتفاع دستنخورده برای عیبیابی. یک چانک pHYs کوتاهتر از نُه byte اش هم در همان پاس بسته شد، چون خوانندهٔ DPI عناصر S[1] تا S[8] رشتهای را ایندکس میکرد که چانک کوتاه خالیاش گذاشته بود
یک offset یک-پایه که مثل اشارهگر صفر-پایه رفتار شد
InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString یک StartPos یک-پایه میگیرد، چون ورودیاش یک AnsiString است و پیادهسازی Delphi ورودی zlib را با @Input[StartPos] آدرس میدهد. پیادهسازی Free Pascal که بر پایهٔ paszlib نوشته شده تا هر دو هدف ویندوزی فشردهسازی را استاتیک لینک کنند، next_in را روی PAnsiChar(Input) + StartPos و avail_in را روی Length(Input) - StartPos میگذاشت. این حساب اشارهگر است و صفر-پایه. مقدار 1 را پاس بده — که برای این تابع یعنی «از ابتدا شروع کن» — و build مربوط به FPC از byte دوم شروع به inflate میکند و یک byte پیش از انتها میایستد
دلیل جان سالم به در بردنش این بود که تنها callerی که بیشتر تستها به آن میرسند InflateStr است که 0 پاس میدهد. صفر تصادفاً همان offset صفر-پایهٔ درست است، پس هر دو build روی هر فراخوانی سادهٔ InflateStr و هر تستی که از آن میگذشت توافق داشتند. TPDFDocument.DecodeAllStreams — همان روتیینی که SaveQDFToFile و ConvertFileToQDF برای بازکردن streamهای تک-FlateDecode به شکل خوانا استفاده میکنند — مقدار 1 را پاس میدهد. روی build مربوط به FPC، هدر zlib که جا افتاده بود inflate را به شکست میکشاند، اما stream همچنان برای byteهایی که بررسی کرده بود یک Consumed ناصفر گزارش میداد، پس DecodeAllStreams آن payload خالی را یک decode موفقیتآمیز گرفت و هر stream محتوا را با رشتهٔ خالی جایگزین کرد. QDF حاصل تعداد صفحهٔ درست، ساختار معتبر و صفر محتوای صفحه داشت؛ یعنی فایلی که در هر viewerی بیخطا باز میشود و هیچ چیزی نشان نمیدهد
// شاخهٔ FPC در InflateStrFromPosition، پس از v3.539.16.
// StartPos مثل شاخهٔ Delphi یک-پایه است؛ اول کلمپش کن، بعد
// دقیقاً یک بار، در همان مرز، به 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;
رگرسیونی که از آن محافظت میکند کوچکترین رگرسیون ممکن است: یک payload را deflate کن، آن را از موقعیت 0 و از موقعیت 1 inflate کن و تأیید کن که هر دو همان payload را برمیگردانند و هر دو Consumedی برابر طول کامل stream گزارش میکنند. یک stream مطابق RFC 1950 یک هدر دو-byte ای و یک trailer چهار-byte ای Adler-32 دارد، پس off-by-one در هر کدام از دو سر یک خرابی ظریف نیست، یک stream است که یا شروع نمیشود یا تمام نمیشود. درس مربوط به مرز است، نه zlib: وقتی پارامتر یک تابع در یک مبنای ایندکس تعریف شده و پیادهسازی زیرین از مبنای دیگر استفاده میکند، تبدیل باید دقیقاً در یک خط باشد و تست باید تابع را با مقداری صدا بزند که آن دو مبنا را از هم جدا میکند
چرا یک TStream.Read کوتاه به معنای پایان stream نیست؟
چون TStream.Read مجاز است به هر دلیلی که بخواهد کمتر از مقدار درخواستی byte برگرداند و تنها برگرداندن 0 به معنای نبودن چیز دیگری است. TMemoryStream و TFileStream روی دیسک محلی تقریباً همیشه کل درخواست را پر میکنند، و همین باعث میشود کدی که «کمتر از درخواستم برگرداند» را پایان-فایل میگیرد از هر تستی که با آنها کار میکند قبول بیرون بیاید. streamهای شبکهای و streamهای دیکامپرس و هر descendant از TStream که مشتری نوشته باشد میتوانند وقتی 64000 byte درخواست میکنی دو byte برگردانند و هنوز گیگابایتها پشتشان باشد
TPLBuffer همان readerی است که هر parserی در PDF Library for Delphi از آن میگذرد و میتواند یک AnsiString یا یک اشارهگر یا یک آرایهٔ byte یا یک TStream را بپیچد. چهار کوری جستجوگرش، یعنی DistanceToByte و DistanceToOtherByte و DistanceToAnyByte و DistanceToOtherBytes که همه Int64 برمیگردانند، منبع را در بلوکهای 64 KB برای یافتن یک جداکننده میخوانند و بدون حرکت دادن موقعیت منطقی گزارش میدهند چقدر فاصله دارد. هر حلقه با Until ReadCount < BlockSize تمام میشد. برای آن سه منبع درون-حافظهای این درست است، چون ReadIntoBuffer همیشه بلوک کامل را تا آخرین بلوک تحویل میدهد. برای منبع streamای یعنی اسکن از همان نخستین خواندن کوتاه دست میکشد، جداکننده را غایب گزارش میکند، و tokenizer بالای آن تصمیم میگیرد که شیء همانجا تمام میشود که نمیشود
// 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 نباید reader را حرکت دهد
End;
تستی که این را پین میکند یک descendant از TMemoryStream است که override متد Read اش هر درخواست را به دو byte محدود میکند. رشتهٔ aaaaaX را در آن بپیچ، موقعیت بافر را روی 1 بگذار، و هر چهار کوری باید فاصلهٔ 4 تا X را گزارش کنند، بعدش موقعیت را روی 1 رها کنند و برای byte ی که آنجا نیست -1 برگردانند. پیش از fix، کوری نخست دو byte را دید، نتیجه گرفت stream ته کشیده و -1 برگرداند. finally همانقدر مهم است که شرط حلقه: یک Exit از داخل اسکن همان مسیر موفقیت معمول است و موقعیت منطقی باید در آن مسیر هم برگردانده شود، نه فقط وقتی حلقه تا انتها میرود
یک منبع، دو کامپایلر، یک مجموعه assertion
انضباطی که از این پنجتا بیرون آمد این است که «build مربوط به Delphi قبول میشود» شاهدی دربارهٔ Delphi است، نه دربارهٔ منبع. از v3.539.16 به بعد، سویت DUnitX در Delphi و سویت کنسولی Free Pascal هر دو همان Tests\CrossCompilerSemantics.inc را شامل میشوند؛ یک روتیین واحد به نام RunCrossCompilerFileSemantics که از طریق TPDFlib یک سند دو-صفحهای با محتوای فشرده میسازد، ذخیرهاش میکند، دوباره از طریق SaveQDFToFile به صورت QDF ذخیرهاش میکند، QDF را با RepairQDFFile ترمیم میکند، فایل ساده را با AES-128 از طریق EncryptFile و یک permission mask از EncodePermissions رمزنگاری میکند، و بعد هر artifact را دوباره بارگذاری میکند و روی هر دو کامپایلر همان چیزها را assertion میکند: تعداد صفحه 2 است، عنوان باقی میماند، متن صفحهٔ دوم از فایلهای ساده و ترمیمشده و رمزنگاریشده سالم استخراج میشود، رمز عبور اشتباه با یک LastErrorCode ناصفر رد میشود، EncryptionStrength برابر 128 است، EncryptionAlgorithm برابر 2 است، و بیتهای مجوز بهتفکیک از GetUserPermissions دقیقاً همانطور که encode شده بودند برمیگردند
مقایسه عمداً نرمالشده است، نه byte-to-byte. رمزنگاری saltهای تصادفی میکشد و نویسنده شناسههای سند را تخصیص میدهد، پس از دو build انتظار نمیرود فایلهای یکسان تولید کنند؛ از آنها انتظار میرود فایلهایی تولید کنند که یک معنا دارند و assertionها هم در همان سطح نوشته شدهاند. پای QDF بهطور خاص به خاطر همان باگ offset آنجاست: یک QDF با دو صفحه و بدون محتوا از تست تعداد صفحه قبول میشود و در تست استخراج متن رد میشود، و این ماتریس دومی را assertion میکند. هر fix آیندهای که روی یک کامپایلر no-op و روی دیگری تغییر رفتار باشد — که چهار مورد از آن پنجتای بالا را توصیف میکند — حالا باید پیش از انتشار دو بار همان assertionها را رد کند
نیمهٔ مربوط به زمان لینک در همین پورت، یعنی توافق دادن objectهای OMF دلفی با انتظارات COFF در Free Pascal، داستان خودش را در لینک کردن objectهای FPC Win32 از OMF به COFF دارد، و سختسازی ساختاری همان reader TIFF در برابر BigTIFF و فایلهای tiled در یادداشتهای decoder داخلی TIFF آمده است. decoderهای این مقاله و تست بین-کامپایلری که حالا زیرشان نشسته در PDF Library for Delphi برای Delphi و C++Builder و Free Pascal عرضه میشوند، جایی که از یک منبع انتظار میرود روی هر کامپایلری که هدف گرفته همان نتیجه را به دست آورد، نه اینکه یکی از آنها لطفش کند