مقال تقني

دلفي مقابل FPC: 4 فخاخ كود PDF خفية في بناءات PDFium

يمكن أن يتصرف نفس مصدر Object Pascal بشكل مختلف تحت دلفي و FPC/Lazarus بأربع طرق تضرب كود مكون PDFium بشكل متكرر: فيقوم FPC بالتخلص من نتائج السجلات المؤقتة للدوال قبل أن ينتهي اختبار عضوية in من قراءتها، ويشحن dcc32 مع تعطيل فحص النطاق افتراضيًا بحيث تقرأ فهارس المصفوفة الخارجة عن الحدود بيانات غير صالحة (garbage) بصمت، ويقبل Delphi 13 فقط تعيين array of Byte مجهول لـ TBytes بدون تحويل، ويمكن أن يؤدي دمج سلاسل AnsiString في دلفي إلى تدمير بايتات عند أو أعلى من $80 من خلال جولة ذهاب وعودة لصفحة أكواد خفية. وينتج عن كل منها مجموعة اختبارات تكون خضراء على أحد المترجمات وحمراء، أو والأسوأ من ذلك، خاطئة بصمت، على الآخر

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

لماذا تقرأ المجموعة كفارغة تحت FPC ولكن ليس في دلفي؟

الملخص في جملة واحدة: قد ينهي FPC المتغير المؤقت الذي يحمل نتيجة سجل الدالة قبل اكتمال تعبير يقرأ حقلاً من هذه النتيجة، لذا يمكن لـ X in Func().Issues اختبار العضوية مقابل مجموعة تم تحريرها بالفعل بينما يعمل التعبير المقابل في دلفي. وواجهت اختبارات مطابقة PDF/E الخاصة بنا هذا في نسختها الأولى. ويرجع المدقق سجلاً يكون حقل Issues الخاص به عبارة عن مجموعة من أعلام الانتهاك، وأدرجت التأكيدات الاستدعاء

// Unreliable under FPC: the function-result record temporary
// can be released before the 'in' test reads Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);

// Reliable on both compilers: pin the result to a local first
var
  Vr: TPdfEValidationResult;
begin
  Vr := ValidateAnsi(Pdf);
  AssertTrue(pveiLzwUsed in Vr.Issues);
end;

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

لماذا يقبل دلفي فهرس مصفوفة يرفض FPC ترجمته؟

الملخص في جملة واحدة: يترجم dcc32 فهرسًا خارج النطاق إلى مصفوفة ذات حدود ثابتة، ومع تعطيل فحص النطاق الافتراضي، يقرأ أو يكتب في الذاكرة المجاورة في وقت التشغيل دون أي خطأ، بينما يرفض FPC نفس الفهرس في وقت الترجمة. ويعلن مكون PDFium عن النقاط الرباعية كمصفوفة تبدأ من 1، TQuadrilateralPoint = array [1..4] of TPdfPoint، بمطابقة كيفية ترقيم إدخالات QuadPoints لـ PDF عادةً. وعمل عرض توضيحي ملأها بالحلقة الانعكاسية التي تبدأ من 0 لشهور تحت دلفي

var
  I: Integer;
begin
  for I := 0 to 3 do                       // wrong: the array is [1..4]
    Data.AttachmentPoints[I] := Corner[I]; // dcc32 default: compiles, index 0
                                           // silently touches adjacent memory
                                           // FPC: compile-time range check error
  for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
    Data.AttachmentPoints[I] := Corner[I - 1];  // correct on both compilers
end;

كان بناء دلفي نتيجة إيجابية خاطئة: فمع إيقاف فحص النطاق، وهو الافتراضي لـ dcc32، استقر الفهرس 0 على أي حقل يسبق المصفوفة في السجل، وبدا العرض التوضيحي قيد التشغيل. وأنتج نقل نفس العرض التوضيحي إلى Lazarus خطأ فوريًا في فحص النطاق في وقت الترجمة من FPC، وكشف تصحيح الفهرس عن خطأ ثانٍ أعمق في مسار تعليقات المكتبة والذي كانت القراءات غير الصالحة تحجبه، وهو الخطأ الذي تم تشريحه في مقال تعليقات النقاط الرباعية. وخرج درسان من ذلك الحادث. أولاً، فضل Low() و High() على الحدود الحرفية كلما لم يكن نوع المصفوفة مبنيًا على أساس 0. ثانياً، عامل ترجمة FPC، أو على الأقل بناء دلفي واحد مع تمكين {$R+}، كبوابة تشغيل أولى إلزامية لأي عرض توضيحي أو اختبار جديد: فافتراضيات dcc32 لن تخبرك عن هذه الفئة من الأخطاء، والبرنامج الذي يعمل ليس دليلاً على صحته

تعيين TBytes الذي يقبله Delphi 13 فقط

الملخص في جملة واحدة: تعيين حقل معلن كـ array of Byte مجهول لمتغير TBytes يترجم في Delphi 13 (إصدار المترجم 37.0) ولكنه يفشل في Delphi 12 Athens وكل إصدار سابق مع الخطأ E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. وهذا ليس انقسامًا بين دلفي و FPC بقدر ما هو انقسام بين دلفي وماضيها، ولكنه يضرب نفس قاعدة الكود متعددة المترجمات بنفس الطريقة: فيقبل المترجم الأحدث بصمت بنية يرفضها كل شيء آخر

type
  TValidator = class
  private
    FBuffer: array of Byte;   // anonymous dynamic array type
  end;

var
  OrigBytes: TBytes;
begin
  OrigBytes := FBuffer;          // Delphi 13 only; E2010 on Delphi 12
                                 // Athens and earlier
  OrigBytes := TBytes(FBuffer);  // compiles everywhere; same byte layout,
                                 // safe hard cast
end;

لقد شحنا هذا بالضبط في برنامج فرعي للتحقق، تم تطويره واختباره محليًا على Delphi 13، حيث تم قبول التحويل الضمني بصمت. ويخدم برنامج التثبيت كامل المصدر مستخدمي Delphi 12 والأقدم بأعداد كبيرة، وبالنسبة لهم لم تترجم الوحدة ببساطة. والإصلاح الهيكلي هو إما التحويل الصلب الموضح أعلاه، وهو آمن لأن array of Byte المجهول و TBytes يشتركان في تخطيط مصفوفة ديناميكية متطابق، أو الأفضل من ذلك، الإعلان عن الحقل كنوع مسمى مثل TBytes في المقام الأول حتى لا ينشأ أي تحويل على الإطلاق. وإصلاح العملية يهم أكثر: فالبنية التي تترجم على أحدث أداة لديك لا تثبت شيئًا عن المترجمات الأقدم التي يقوم مستخدموك بتشغيلها بالفعل، وهذا النوع من التراجع غير مرئي حتى تقوم بالبناء ضد كل إصدار مدعوم. وتترجم نصوص الإصدار الخاصة بنا الآن المكتبة عبر مصفوفة المترجمات الكاملة لأن بناء 37.0 المحلي لا يمكنه التقاط تساهل خاص بالإصدار 13 فقط

بايت AnsiString الذي يختفي على جهاز ويندوز صيني

الملخص في جملة واحدة: دمج بايت خام عند أو أعلى من $80 في سلسلة AnsiString باستخدام + يمكن أن يستبدل ذلك البايت بصمت بـ ? ($3F) تحت دلفي، لأن التعبير يأخذ جولة ذهاب وعودة ضمنية من AnsiString إلى UnicodeString إلى AnsiString عبر صفحة أكواد النظام. ولقد وجدنا هذا من خلال اختبار PDF/A الذي يبني اسمًا يحتوي على بايت $FE معزول، وهو ليس بايت بادئة UTF-8 قانوني أبدًا، للتحقق من أن أعلام المدقق تسمي الأسماء التي ليست UTF-8 صالحة وفقًا للبند 6.1.8 من معيار ISO 19005-2

var
  BadName: AnsiString;
begin
  // On Delphi with a multi-byte system code page (observed on CP936),
  // the concatenation round-trips through UnicodeString and $FE, which
  // is not a valid CP936 sequence, comes back as '?' ($3F)
  BadName := '/Bad' + AnsiChar($FE) + 'Name';

  // Safe: build with an ASCII placeholder, then patch the byte in place;
  // indexed assignment into a settled AnsiString does not round-trip
  BadName := '/Bad' + #1 + 'Name';
  BadName[5] := AnsiChar($FE);
end;

على نظام ويندوز صيني يقوم بتشغيل صفحة الأكواد 936، لم تحتوي السلسلة المدمجة على $FE على الإطلاق، لذلك لم تبلغ المكتبة عن شيء بشكل صحيح وتحول الاختبار إلى اللون الأحمر بينما بدا كأنه خطأ في المكتبة. ولم تكن المكتبة خاطئة أبدًا: فحزام FPC الذي غذى ملف PDF يحتوي بالفعل على بايت $FE حصل على العلم المتوقع. وحدث الفساد داخل ملف دلفي التنفيذي للاختبار أثناء تقييم تعبير السلسلة، لأن نموذج سلسلة Unicode أولاً لدلفي يحول تعبيرات AnsiString المختلطة عبر UnicodeString، و $FE ليس بايت بادئة صالحًا في CP936 لذا فإن جولة الذهاب والعودة تستبدله. وكن صادقًا بشأن الحدود هنا: في صفحة أكواد غربية أحادية البايت مثل CP1252 ينجو التعبير نفسه عادةً، وهو بالضبط السبب في اختفاء هذا الخطأ في معظم أجهزة التطوير وظهوره فقط في أنظمة شرق آسيا أو مشغلات CI المحلية. والقاعدة التي اعتمدناها: لا تبنِ أبدًا متجهات اختبار ثنائية تحتوي على بايتات عند أو أعلى من $80 عن طريق دمج AnsiString؛ فإما أن ترقع البايتات في مكانها بعد استقرار السلسلة، كما هو موضح أعلاه، أو تبني المتجه في TBytes من البداية

ما الذي يجب أن يتحقق منه سير عمل المترجم المزدوج افتراضيًا

أربعة فخاخ، نمط واحد: يخبرك كل مترجم عن مجموعة فرعية مختلفة من أخطائك. فاصطاد تحليل نطاق وقت الترجمة لـ FPC فهرسًا خارج الحدود قام dcc32 بتشغيله بصمت لشهور، وكشف نموذج سلسلة Unicode لـ dcc32 عن اعتماد على صفحة الأكواد لا يثيره أبدًا بناء FPC خالص موجه للبايتات. والنتيجة العملية هي أنه لا يوجد خط أنابيب أخضر كافٍ بمفرده. والترجمة المتقاطعة ليست مجرد علامة اختيار لقابلية النقل، بل هي محلل ثابت ثانٍ ونموذج وقت تشغيل ثانٍ مطبقين على نفس المصدر، بنفس روح فحوصات الحدود الدفاعية في مقال تقوية أمان واجهة ABI والذاكرة

القواعد القائمة التي نتجت عن هذه الحوادث قصيرة بما يكفي لحفظها. ثبت سجلات نتائج الدوال في متغير محلي قبل قراءة الحقول. وقم بتكرار المصفوفات ذات الحدود الثابتة باستخدام Low() و High()، وقم بتشغيل بناء واحد على الأقل خاضع لفحص النطاق أو FPC قبل الوثوق بأي عرض توضيحي جديد. وقم بتحويل حقول المصفوفات الديناميكية المجهولة صراحةً، أو أعلن عنها بأنواع مسمية، وابنِ مصفوفة المترجم الكاملة قبل الإصدار. واحتفظ بالبايتات العالية الخام خارج دمج AnsiString تمامًا. ولا يكلف أي من هذا جهدًا ملموسًا بمجرد أن تصبح عادات، ويغلق كل منها وضع فشل لا يمكن لسير عمل المترجم الواحد رؤيته هيكليًا

تم العثور على جميع المشكلات الأربع وإصلاحها في سياق صيانة مكون PDFium، والذي يشحن نفس مصدر Object Pascal لدلفي و C++Builder و FPC/Lazarus ويقوم بتشغيل مجموعات المطابقة والتراجع الخاصة به على كل من سلاسل الأدوات تلك، وبالتالي فإن الفخاخ الموجودة في هذا المقال محمية بالاختبارات وليس بالذاكرة