مقال تقني

كود Delphi يعمل بالصدفة: خمسة عيوب نقل إلى FPC

اكتشفت PDF Library for Delphi خمسة عيوب في كود فك الترميز أثناء نقل كود CCITT و TIFF و PNG و Flate ومخازن البث المؤقتة إلى Free Pascal، وكل واحد منها كان قد اجتاز حزمة اختبارات Delphi كاملة لسنوات. لم يكن أي منها عيب مترجم. كان كل واحد منها كود Pascal صادف أن Delphi نفّذه بشكل صحيح بسبب تفصيلة تنفيذية: وسيط نتيجة خفي كان يحاكي مصفوفة المستدعي، وفرع خارج النطاق لم يقرأ أحد بعده أبداً، ومخزن مؤقت بطول صفر كان حارسه الوحيد مفتاح فحص النطاق، وإزاحة واحدة الأساس لم يمررها مسار كود واحد بقيمة 1 سوى مرة، وعقد TStream.Read لا تمارسه التدفقات في الذاكرة أصلاً. غيّر المترجم، أو أطعم الكود نفسه ملفاً تالفاً، فيتوقف صدفة النجاح عن الصمود

فيما يلي الشكل المحدد لكل عيب، والإصلاح، والانضباط الذي خرج منه: المصدر نفسه يتعين عليه الآن أن ينتج دلالات مستند مطابقة على المترجمين معاً، وملف تضمين اختبارات يتحقق من ذلك. غطت المقالة الشقيقة عن تقوية محلل PDF بلغة Pascal ضد الملفات الخبيثة عرض الأعداد الصحيحة وعمق العودية والمخازن غير المهيأة. أما هذه فتتناول صنف فشل مختلفاً: كود كان خاطئاً طوال الوقت ومترجم يغطي عليه بهدوء

لماذا تعمل دالة تُرجع مصفوفة ديناميكية بدون SetLength على Delphi؟

لأن Delphi يمرر متغير المستدعي نفسه في صورة وسيط النتيجة الخفي، فالدالة التي لا تخصص نتيجتها أبداً تستطيع مع ذلك أن تكتب داخل مصفوفة خصصها المستدعي. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray هي جدول البحث في السطر المرجعي في قلب فك الترميز ثنائي الأبعاد Group 3 و Group 4: مع الموضع الحالي a0 ولون التشغيلة الحالية تبحث في عناصر التغيير بالسطر السابق، أي b1 و b2 في مخطط الترميز ثنائي الأبعاد ITU-T T.4 و T.6، وتعيدهما كمصفوفة من خانتين. الدالة الأصلية كانت تكتب Result[0] و Result[1] ولا تستدعي SetLength على Result إطلاقاً

يفترض أن يسبب ذلك خطأ عند أول كتابة، وهو ما يحدث فعلاً على Free Pascal. أما على Delphi فلم يحدث قط، لأن موقعَي الاستدعاء في فاكّي الترميز يبدوان هكذا: تُعلن b: TCCITTIntegerArray، وتشغّل SetLength(b, 2) مرة واحدة قبل حلقة المسح، ثم داخل الحلقة تعيّن b := GetNextChangingElement(a0, IsWhite) وتقرأ b[0] و b[1]. دليل لغة Delphi ينص على أن الدالة التي نتيجتها سلسلة طويلة أو مصفوفة ديناميكية أو غيرها من الأنواع المُدارة تستقبل تلك النتيجة كوسيط var إضافي، وعملياً يمرر المترجم عنوان هدف الإسناد. إذن Result داخل الدالة هو b نفسها، طويلة بخانتين أصلاً، وكل كتابة تهبط في ذاكرة يملكها المستدعي. Free Pascal يسلم الدالة مصفوفة nil جديدة ويعيّنها إلى b بعد ذلك، وهو قراءة العقد الذي كان يجب أن يُكتب الكود على أساسه منذ البداية

انحراف فك ترميز CCITT في PDFlibPas: يمرر Delphi مصفوفة المستدعي b في صورة وسيط Result الخفي من نوع var لـ GetNextChangingElement فتهبط الكتابات في ذاكرة يملكها المستدعي ويحتفظ البحث الفائت بالقيم السابقة، بينما يسلم Free Pascal الدالة مصفوفة nil جديدة يجب أن يحدد حجمها حارس Length عبر SetLength قبل أول كتابة
يحاكي Delphi مصفوفة المستدعي في صورة وسيط Result الخفي فتهبط الكتابات غير المحمية في ذاكرة مملوكة، بينما يصل Free Pascal بـ nil وسطر الحارس الواحد يحول الخطأ إلى السلوك المقصود دون تغيير مسار فك الترميز على Delphi

كان للمحاكاة دلالة يعتمد عليها فاكّ الترميز أيضاً. لا تُعيّن Result[0] إلا إذا وجد المسح عنصراً أكبر من a0، ولا تُعيّن Result[1] إلا إذا وُجد عنصر بعده، ففي حالة الفوات تحتفظ الخانتان بما تركته التكرارة السابقة في b. الإصلاح البديهي، تخصيص خانتين وتصفيرهما في كل استدعاء، كان سيهدم هذا الانتقال ويغيّر المخرج المفكوك على Delphi. أما الإصلاح الذي شُحن فهو حارس بدل إعادة الضبط: على Delphi هو كود ميت ويبقى مسار فك الترميز بايتاً ببايت كما كان، وعلى Free Pascal يحوّل الخطأ إلى السلوك المقصود. تلك اللامتماثلية هي الجوهر كله، لأنه كان لا بد أن يكون الإصلاح بلا أثر على المترجم الذي كان الكود فيه ينتج أصلاً مخرجاً موثقاً بالاختبارات

Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
  IsWhite: Boolean): TCCITTIntegerArray;
Begin
  // يصل التنفيذ هنا على Delphi ومصفوفة المستدعي ثنائية الخانات محاكاة
  // للـ Result، فهذا سطر بلا أثر هناك. أما FPC فيصل بـ nil.
  If (Length(Result) < 2) Then
    SetLength(Result, 2);
  ...
  // ما زالت Result[0] / Result[1] تكتبان عند الإصابة فقط، فالفوات
  // يحتفظ بقيم التكرارة السابقة تماماً كما كانت
End;

عدّاد عاش أطول من بياناته: مدخل دليل صور TIFF

عندما تُبطل مصفوفة عليك إبطال عدّادها في نفس العبارة، وإلا صدّقه كود لا يرى المصفوفة أبداً. مدخل دليل ملف صورة TIFF (TIFF 6.0 §2، تخطيط الـ 12 بايت للوسم والنوع والعدّاد والقيمة أو الإزاحة) يحمل عدّاداً من 32 بتاً مسحوباً مباشرة من الملف، وتقرأ PDF Library for Delphi كل مدخل عبر PopDE: TTIFFEntry، سجل يحمل Tag و TagType و Length و Offset ومصفوفتَي IntegerValues و DoubleValues المفكوكتين. كان الكود الأصلي يفحص ما إذا كان Offset + TypeSize * Length يتجاوز نهاية الملف، فإن تجاوزها صفّر طول المصفوفتين. وترك Result.Length على قيمته القادمة من الملف

ومن هناك وقع خطآن. تنتهي الدالة بمسار احتياطي ينص على أنه إذا كان Length صفراً يُعطى المدخل عنصراً واحداً بقيمة صفر حتى يستطيع المستدعون قراءة العنصر صفر دائماً. ولأن Length لم يُمسح قط على مسار خارج النطاق، لم يشتغل ذلك المسار الاحتياطي أبداً في الحالة الوحيدة التي وُجد من أجلها. والمستدعون يقرؤون العنصر صفر فعلاً وبلا شرط: Width و Height و BitsPerSample و PhotometricInterpretation و FillOrder و SamplesPerPixel و RowsPerStrip وعشرات أخرى تأخذ E.IntegerValues[0]، وجداول الشرائط تنفذ Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4) فتنسخ Length مرات أربعة بايتات من مصفوفة لا تملك شيئاً. مصفوفة ممسوحة بعدّاد حي أخطر قطعاً من مصفوفة غير مفحوصة، لأن غير المفحوصة تحمل على الأقل البايتات التي تدّعيها

المشكلة الثانية كانت الترتيب. استدعاءا SetLength كانا يجريان قبل اختبار النطاق، وبحجم مأخوذ من عدّاد الملف، فيمكن لمدخل عدائي أن يطلب تخصيصاً بعدة غيغابايتات قبل أي فحص صلاحية واحد. على Delphi كان الاستثناء الناتج يلتقطه معالج أعلى في مسار تحميل الصور فيفشل الملف في التحميل فحسب، ولذلك لم يلاحظه أحد؛ ما حدث فعلاً كان حدث نفاد ذاكرة اختاره الملف. الإصلاح ينقل التخصيص إلى بعد الاختبار ويجعل العدّاد يتنقل مع البيانات

تقوية مدخل دليل TIFF في PDFlibPas: يحمل المدخل ذو الـ 12 بايت عدّاداً من الملف، والترتيب المعيب كان يخصص المصفوفات من ذلك العدّاد قبل اختبار النطاق ويترك Result.Length حياً بعد مسحهما، والترتيب المصلح يختبر حساب Int64 مقابل طول الملف أولاً فيُمسح العدّاد مع المصفوفتين معاً
التخصيص قبل اختبار النطاق سمح لعدّاد عدائي بطلب غيغابايتات وترك عدّاداً حياً على مصفوفة مفرغة، لذا يختبر الإصلاح الإزاحة أولاً ويمسح 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;
// ... لاحقاً، يصل المسار الاحتياطي القائم أخيراً إلى الحالة التي وُجد من أجلها:
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

لا شيء في هذا الإصلاح خاص بمترجم بعينه، وهذا ما يجعله يستحق مكانه في هذه القائمة. كان العيب كامناً على Delphi لنفس السبب الذي جعله كامناً على Free Pascal: لا ملف اختبار واحد كان فيه مدخل دليل يشير إلى ما بعد نهاية الملف. النقل لم يكشفه. قراءة الكود بسؤال "ماذا يفعل Delphi نيابة عني هنا وأنا لا أفعلها بنفسي" هي التي كشفته

ماذا يحدث عندما يدّعي IHDR في PNG نوع ألوان لا تعرفه الصيغة؟

ترفض PDF Library for Delphi الآن الصورة قبل تشغيل مرشحات الصفوف؛ وقبل v3.539.2 كانت تحسب سطر مسح بطول صفر بايت وتسلّم حلقات إزالة الترشيح مخزناً فارغاً. يعرّف ISO 15948 §11.2.2 كتلة IHDR ويسرد الجدول 11.1 التركيبات الست المشروعة من نوع الألوان وعمق البت: رمادي عند 1 أو 2 أو 4 أو 8 أو 16 بتاً، ولون مفهرس عند 1 أو 2 أو 4 أو 8، و truecolor ورمادي مع alpha و truecolor مع alpha عند 8 أو 16. كانت TPNGReader تتحقق من حقلَي طريقة الضغط وطريقة الترشيح في IHDR وتمرر FColorType وعمق البت كما هما دون مساس

يحدد كود مرشحات الصفوف كل الأحجام من Case FColorType Of تربط كل نوع ألوان بعدد مكونات. نوع ألوان خارج الستة يسقط في فرع Else حيث SourceComponents تساوي 0، فيصبح ScanlineByteCount صفراً، فيتلو SetLength(PreviousScanline, 0) فوراً FillChar(PreviousScanline[0], ScanlineByteCount, 0). فهرسة العنصر صفر من مصفوفة ديناميكية فارغة هي عنوان محسوب من nil. مع إيقاف فحص النطاق تكون التعبئة ذات البايت الصفري عبر ذلك العنوان عملية صامتة بلا أثر ويمضي فاكّ الترميز في صفوف غير موجودة؛ ومع تشغيل فحص النطاق تحصل على ERangeError عند أول صورة؛ واستدعاءات Move التي تليه على بُعد خطوة من مخالفة وصول. أي واحدة من تلك تحصل عليها يتحدد بالمترجم ومفاتيح البناء لا بأي قرار اتخذه فاكّ الترميز، وهذا هو المؤشر على أن فاكّ الترميز لم يقرر شيئاً أصلاً

الإصلاح هو الجدول الآتي من المواصفة، مطبقاً حيث كانت حقول 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 أقصر من بايتاتها التسع في نفس الدفعة، لأن قارئ DPI كان يفهرس S[1] حتى S[8] من سلسلة تركتها الكتلة القصيرة فارغة

إزاحة واحدة الأساس عوملت كإشارة صفرية الأساس

تأخذ InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString قيمة StartPos واحدة الأساس، لأن مدخلها AnsiString وتنفيذ Delphi يعنون مدخل zlib كـ @Input[StartPos]. أما تنفيذ Free Pascal، المكتوب على paszlib حتى يربط كلا هدفَي Windows الضغط سكونياً، فعيّن next_in إلى PAnsiChar(Input) + StartPos و avail_in إلى Length(Input) - StartPos. ذلك حساب إشارات وهو صفر الأساس. مرر 1، وهو ما تعنيه "ابدأ من البداية" لهذه الدالة، فيبدأ بناء FPC النفخ من البايت الثاني ويتوقف بايتاً واحداً قبل النهاية

سبب بقائه أن المستدعي الوحيد الذي تصل إليه معظم الاختبارات هو InflateStr الذي يمرر 0. والصفر يصادف أنه الإزاحة الصفرية الأساس الصحيحة، فاتفق البناءان على كل استدعاء InflateStr عارٍ وعلى كل اختبار مرّ عبره. أما TPDFDocument.DecodeAllStreams، الروتين الذي يستخدمه SaveQDFToFile و ConvertFileToQDF لتوسيع تدفقات FlateDecode المفردة إلى صورة مقروءة، فيمرر 1. على بناء FPC جعل الترويسة الزائدة عن zlib النفخ يفشل، لكن تدفق zlib أبلغ مع ذلك عن Consumed غير صفري للبايتات التي فحصها، فأخذت DecodeAllStreams الحمولة الفارغة على أنها فك ترميز ناجح واستبدلت كل تدفق محتوى بسلسلة فارغة. جاء QDF الناتج بعدد صفحات صحيح وبنية سليمة وبلا محتوى صفحات، وهو ملف يفتح دون خطأ في كل عارض ولا يعرض شيئاً

// فرع FPC من InflateStrFromPosition، بعد v3.539.16.
// StartPos واحدة الأساس كفرع Delphi؛ قيّدها ثم حوّلها
// إلى إزاحة إشارة صفرية الأساس مرة واحدة بالضبط، عند الحد.
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;

اختبار الانحدار الذي يحرسه هو أصغر ما يمكن: اضغط حمولة، وانفخها من الموضع 0 ومن الموضع 1، وأكد أن كليهما يعيد الحمولة نفسها ويعلنان Consumed مساوياً لطول التدفق الكامل. تدفق RFC 1950 له ترويسة من بايتين وذيل Adler-32 من أربعة بايتات، فانحراف بمقدار واحد عند أي من الطرفين ليس تلفاً خفياً، بل تدفق يفشل في البدء أو في الانتهاء. الدرس يتعلق بالحد لا بـ zlib: عندما يكون وسيط الدالة معرفاً بأساس فهارس واحد والتنفيذ تحته يستخدم الآخر، فالتحويل يعود إلى سطر واحد بالضبط، وعلى الاختبار أن يستدعيها بالقيمة التي تميّز الأساسين

لماذا ليست قراءة TStream.Read القصيرة نهاية التدفق؟

لأن TStream.Read يُسمح له أن يعيد بايتات أقل من المطلوب لأي سبب يشتهي، وأن الإرجاع 0 وحده يعني أنه لا مزيد. TMemoryStream و TFileStream على قرص محلي يلبيان الطلب في الأغلب دائماً، ولهذا يجتاز الكود الذي يعامل "أعاد أقل مما طلبت" كنهاية ملف كل اختبار يستخدمهما. أما التدفقات المدعومة بالشبكة وتدفقات فك الضغط وأي سليل TStream كتبه عميل فيمكن أن تعيد بايتين عند طلب أربعة وستين ألفاً وما زال خلفها غيغابايتات

TPLBuffer هو القارئ الذي تمر به كل محللات PDF Library for Delphi، ويستطيع أن يلف AnsiString أو إشارة أو مصفوفة بايتات أو TStream. استعلاماته المسحية الأربعة، DistanceToByte و DistanceToOtherByte و DistanceToAnyByte و DistanceToOtherBytes، وكلها تعيد Int64، تقرأ المصدر في كتل من 64 KB باحثة عن فاصل وتعلن بُعده دون تحريك الموضع المنطقي. كل حلقة كانت تنتهي بـ Until ReadCount < BlockSize. للمصادر الثلاثة في الذاكرة ذلك صحيح، لأن ReadIntoBuffer تسلّم الكتلة كاملة حتى الأخيرة. أما لمصدر التدفق فهذا يعني أن المسح يستسلم عند أول قراءة قصيرة، ويبلّغ عن غياب الفاصل، ويقرر المحلل المعجمي فوقه أن الكائن ينتهي حيث لا ينتهي

معالجة القراءة القصيرة في مخزن تدفق PDFlibPas: تمسح DistanceToByte كتل 64 KB، وكانت الحلقة القديمة تعامل Until ReadCount أقل من BlockSize كنهاية بيانات وتستسلم عند أول قراءة قصيرة، بينما تشتغل الحلقة المصلحة حتى يساوي ReadCount الصفر وتجد الفاصل ويعاد الموضع في كتلة finally
قد يعيد التدفق بايتين عند طلب أربعة وستين ألفاً، فالصفر هو إشارة نهاية البيانات الوحيدة التي يوثق بها المسح، وتعيد عبارة 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;   // الفحص لا يجوز أن يحرك القارئ
End;

الاختبار الذي يثبّت هذا سليل TMemoryStream يتجاوز فيه Read سقف كل طلب عند بايتين. لف السلسلة aaaaaX فيه، واضبط موضع المخزن على 1، وعلى الاستعلامات الأربعة كلها أن تعلن مسافة 4 إلى X وأن تترك الموضع عند 1 بعد ذلك وأن تعلن -1 عن بايت غير موجود. قبل الإصلاح رأى الاستعلام الأول بايتين واستنتج أن التدفق نُفد وعاد بـ -1. و finally لا تقل أهمية عن شرط الحلقة: Exit من داخل المسح هو مسار النجاح المعتاد، والموضع المنطقي يجب أن يعاد على ذلك المسار أيضاً، لا عندما تكتمل الحلقة فقط

مصدر واحد، مترجمان، مجموعة تأكيدات واحدة

الانضباط الذي خرج من هذه الخمسة أن "بناء Delphi ينجح" دليل عن Delphi لا عن المصدر. منذ v3.539.16 تضم حزمة DUnitX لـ Delphi وحزمة كونسول Free Pascal كلاهما نفس Tests\CrossCompilerSemantics.inc، روتين واحد اسمه RunCrossCompilerFileSemantics يبني مستنداً من صفحتين بمحتوى مضغوط عبر TPDFlib، ويحفظه، ويحفظه ثانية كـ QDF عبر SaveQDFToFile، ويصلح QDF بـ RepairQDFFile، ويشفّر الملف العادي بـ AES-128 عبر EncryptFile وقناع أذونات من EncodePermissions، ثم يعيد تحميل كل مخلّص قائم ويؤكد الأمور نفسها على المترجمين: عدد الصفحات 2، والعنوان ينجو، ونص الصفحة الثانية يستخرج سالماً من الملف العادي والمصلح والمشفر، وكلمة المرور الخاطئة ترفض مع LastErrorCode غير صفري، و EncryptionStrength يساوي 128، و EncryptionAlgorithm يساوي 2، وبِتات الأذونات الفردية من GetUserPermissions تعود تماماً كما شُفرت

المقارنة منطبعة عمداً لا بايتاً ببايت. التشفير يسحب أملاحاً عشوائية والكاتب يعيّن معرفات مستند، فلا يُتوقع من البناءين أن يصدرا ملفين متطابقين؛ بل يُتوقع منهما أن يصدرا ملفين يحملان المعنى نفسه، والتأكيدات مصاغة على ذلك المستوى. وفرع QDF موجود خصيصاً بسبب عيب الإزاحة: QDF بصفحتين وبلا محتوى يجتاز فحص عدد الصفحات ويرسب في فحص استخراج النص، والمصفوفة تؤكد الثاني. أي إصلاح قادم بلا أثر على مترجم وتغيير سلوك على الآخر، وهو وصف أربعة من الخمسة أعلاه، يتعين عليه الآن أن يجتاز التأكيدات نفسها مرتين قبل شحنه

أما النصف الآخر من النقل على مستوى الربط، أي جعل كائنات OMF من Delphi تتوافق مع توقعات COFF لدى Free Pascal، فله حكايته الخاصة في ربط كائنات FPC Win32 من OMF إلى COFF، وتقوية القارئ TIFF نفسه بنيوياً ضد BigTIFF والملفات المبلطة موجودة في ملاحظات فاكّ TIFF المدمج. ففاكّا الترميز في هذه المقالة، واختبار العبور بين المترجمين الذي يجلس تحتهما الآن، يشحنان في PDF Library for Delphi لـ Delphi و C++Builder و Free Pascal، حيث يُتوقع من المصدر نفسه أن يستحق النتيجة نفسها على كل مترجم يستهدفه لا أن يُمنحها من أحدها