Technical Article

تحصين ربط PDFium VCL: سلامة واجهة ثنائي التطبيق (ABI) والذاكرة

يُقرأ ربط Pascal لمكتبة C مثل كود Pascal العادي. تستدعي طريقة ما، فتحصل على سجل، ثم تحرر ما خصصته. المشكلة هي أن PDFium عبارة عن مكتبة C و C++ لها اتفاقية استدعاء خاصة بها، وعروض أعداد صحيحة خاصة بها، وقواعدها الخاصة حول من يملك الذاكرة ومن يحررها. لا شيء من هذا يعبر حدود اللغة بمفرده. يجب إعادة صياغة كل من هذه العقود يدوياً في إعلانات Pascal، وتؤدي كلمة واحدة خاطئة إلى تحويل استدعاء نظيف المظهر إلى تلف في المكدس، أو إزاحة مقتطعة، أو تحرير مزدوج. كشفت عملية تدقيق v1.61.0 لربط PDFium VCL عن عيب واحد من كل نوع. وهي تستحق المراجعة لأنها ليست خاصة بهذا الربط. إنها المخاطر الدائمة لتغليف أي واجهة برمجة تطبيقات C في Delphi أو Lazarus.

cdecl هو جزء من نوع الدالة، وليس زخرفة

تعد مكتبة PDFium لغة C مجمعة. في Win32، تستخدم صادراتها، والأهم من ذلك، الاستدعاءات التي تستدعيها، اتفاقية استدعاء cdecl. تحت cdecl، يقوم المستدعي بتنظيف المكدس بعد عودة الاستدعاء. افتراضي Delphi الأصلي هو register، ومعيار Win32 C للاستدعاءات هو stdcall في بعض المكتبات، حيث يقوم المستدعى بالتنظيف بدلاً من ذلك. عندما تسلم بنية ما مؤشر دالة إلى PDFium وتنسى cdecl في نوع هذا المؤشر، يختلف الطرفان حول من يقوم بضبط مؤشر المكدس. فإما أن يقوم كلاهما بإصلاحه، أو لا يفعل أي منهما، وينحرف مؤشر المكدس بمقدار حجم الوسائط عند كل استدعاء.

السبب في صعوبة العثور على هذا العيب هو أن الضرر غير محلي. يعود الاستدعاء التالف ويبدو جيداً. يظهر عدم المحاذاة لاحقاً، في دالة أخرى غير ذات صلة يقع إطارها الآن على مؤشر مكدس منحرف ببضع بايتات، ويظهر ذلك كقراءة عشوائية، أو عنوان إرجاع سيئ، أو انهيار مع تتبع خلفي يشير إلى مكان بعيد تماماً عن الاستدعاء الذي أخطأت فيه بالفعل. تعد تعبئة النماذج هي المكان الكلاسيكي الذي يلدغ فيه هذا العيب، لأن واجهة تعبئة النماذج عبارة عن سجل مليء بالاستدعاءات التي يستدعيها PDFium. أحدها، FFI_OpenFile، يسلم PDFium دالة سيستدعيها لفتح ملف خارجي، معلن عنها كـ function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. الـ cdecl الزائد في النهاية هو النقطة التي تستحق النسخ. احذفه وسيستمر الكود في التجميع والربط والتشغيل بشكل طبيعي حتى يستدعي PDFium الدالة. تنتمي الاتفاقية إلى نوع الدالة نفسه. إنها ليست سكر اختيارياً، ولن يحذرك المترجم عند فقدانها لأن نوع الدالة البسيط هو نوع Pascal قانوني تماماً. الدفاع الوحيد هو معاملة اتفاقية الاستدعاء كحقل إلزامي لكل توقيع مستورد وكل استدعاء تمرره إلى الخارج.

size_t هو بعرض المؤشر، وفي FPC Win64 يعني ذلك 64 بت

العيب الثاني هو عدم تطابق عرض عدد صحيح يظهر فقط في هدف واحد. يتم تعريف size_t في لغة C ليكون واسعاً بما يكفي لاستيعاب أي حجم كائن، وهو ما يعني في النظام 64 بت عدداً صحيحاً غير موقع 64 بت. تتحدث واجهات التحميل التدريجي لـ PDFium بإزاحات بايت من نوع size_t. يحمل سجل FX_FILEAVAIL الخاص بموفر الإتاحة استدعاء IsDataAvail الذي يستدعيه PDFium بإزاحة وحجم، ويتلقى استدعاء AddSegment لسجل FX_DOWNLOADHINTS نفس الشيء. كلا المعلمتين هما size_t.

IsDataAvail = function(
  pThis       : PFX_FILEAVAIL;
  offset, size: size_t): FPDF_BOOL; cdecl;

AddSegment = procedure(
  pThis       : PFX_DOWNLOADHINTS;
  offset, size: size_t); cdecl;

إذا أعلنت عن هذه الإزاحات كنوع 32 بت، فإن الربط يعمل في Win32 و Delphi Win64، ثم يتعطل بصمت في FPC و Lazarus Win64. السبب دقيق. في FPC Win64، يعد NativeUInt نوعاً حقيقياً بعرض المؤشر 64 بت، و size_t هو اسم مستعار له. يحتوي الربط على تعليق في قسم النوع يحذر بدقة من تظليل NativeUInt في FPC، لأن إعادة تعريفه إلى اسم مستعار 32 بت هناك من شأنه إجبار size_t على 32 بت وإفساد كل معلمة size_t يتم تمريرها إلى المكتبة أو كتابتها بواسطتها. تفقد الإزاحة 64 بت التي تصل إلى معلمة 32 بت نصفها العلوي. بالنسبة لملف صغير، تتناسب كل إزاحة في 32 بت ولا يوجد شيء خاطئ. بالنسبة لملف كبير، في اللحظة التي تتجاوز فيها الإزاحة خط الأربعة جيجابايت، تشير القيمة المقتطعة إلى مكان آخر تماماً، ويسأل PDFium عما إذا كان نطاق البايت الخاطئ متاحاً، ويتوقف التحميل التدريجي أو يقرأ بيانات تالفة. العيب غير مرئي حتى يصبح الملف كبيراً بما يكفي ويكون الهدف هو الهدف الذي اتسع فيه size_t بالفعل.

يجب ألا يتم فك استثناء Pascal أبداً عبر إطار C

الفئة الثالثة تتعلق بنموذج الاستثناءات، وهو ما لا تملكه لغة C. عندما يستدعي PDFium أحد استدعاءاتك، يتم تشغيل كود Pascal الخاص بك داخل مكدس من إطارات C و C++ التي لا تعرف شيئاً عن آلية استثناءات Delphi. إذا أثار الاستدعاء الخاص بك استثناء وسمح له بالانتشار، فإنه يتم فكه عبر إطارات لم يتم بناؤها أبداً ليتم فكها. لا يتم تشغيل عملية التنظيف الخاصة بـ PDFium، وتُترك متغيراته الداخلية نصف محدثة، وتصبح العملية الآن في حالة لم تتوقعها المكتبة أبداً. العقد لهذه الاستدعاءات هو رمز إرجاع، وليس استثناءً.

هناك استدعاءان يجعلان هذا ملموساً. FPDF_FILEWRITE هو الحوض الذي يكتب فيه PDFium مستنداً محفوظاً، و FPDF_FILEACCESS هو المصدر الذي يقرأ منه مستنداً مدخلاً. كلاهما مطبق هنا عبر Delphi TStream، ويمكن لكليهما الفشل بالطريقة التي يفشل بها أي تدفق: امتلاء القرص، إغلاق التدفق تحتك، قراءة تتجاوز النهاية. يغلف استدعاء الكتابة كتابة التدفق الخاصة به ويحول أي فشل إلى رمز فشل PDFium بدلاً من تركه يهرب.

function WriteBlock(
  pThis: PFPDF_FILEWRITE;
  pData: Pointer;
  Size : LongWord): Integer; cdecl;
begin
  // PDFium treats any non-1 return as a write failure. A Pascal exception
  // must not unwind through this cdecl/C++ frame, so trap it and report
  // failure instead.
  Result := 0;
  try
    PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
    Result := 1;
  except
  end;
end;

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

التحرير المزدوج يختبئ في مسار الخطأ

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

وجد التدقيق هذا في مسار استيراد بنمط الفرض (imposition-style) يبني TPdf حول مقبض مفتوح بالفعل. الإصلاح هو جعل نقل الملكية المصدر الوحيد للحقيقة. بمجرد تعيين المقبض لحقل المغلف، يمتلكه المغلف، والتنظيف الوحيد في مسار الخطأ هو تحرير المغلف. يستدعي مدمر المغلف FPDF_CloseDocument نيابة عنك، لذا فإن الإغلاق الصريح الثاني سيؤدي إلى تحرير مزدوج لنفس المستند. يحرر معالج الأخطاء المصحح الكائن ويعيد إثارة الاستثناء، وهناك مسار واحد بالضبط للإغلاق.

Result := TPdf.Create(nil);
try
  Result.FDocument := NewDoc;   // Result now owns the handle
  Result.InitializeFormFill;
  Result.ReloadPage;
except
  // Result.Free closes the handle. A second FPDF_CloseDocument(NewDoc)
  // here would double-free the same PDFium document.
  Result.Free;
  raise;
end;

السجلات المدارة ومكتبة مليئة بالصادرات كلاهما يحتاج إلى تفكيك صريح

الفئة الأخيرة تتعلق بالذاكرة التي يديرها المترجم نيابة عنك، والتي ستفسدها عادة C بصمت. ترجع العديد من الوظائف المساعدة لهذا الربط سجلاً يحتوي على WideString أو مصفوفة ديناميكية. هذه حقول ذات عدد مراجع، ويصدر المترجم مسك دفاتر مخفياً للحفاظ على أعدادها. الغريزة المنقولة من لغة C هي مسح السجل الجديد باستخدام FillChar(Result, SizeOf(Result), 0). يطبع ذلك أصفاراً فوق المرجع المدار داخل السجل دون تقليله أولاً. يعيد المترجم استخدام مؤقت مخفي واحد لنتيجة الدالة عبر تكرارات الحلقة، لذلك في التكرار الثاني يكتب FillChar فوق مؤشر سلسلة نشط لم يتم إصداره أبداً، وتتسرب السلسلة التي أشار إليها. استدعِ الدالة في حلقة عبر ألف تعليق توضيحي وستسرب ألف سلسلة.

الإصلاح هو السماح للغة بمسح السجل بالطريقة التي تعرفها، باستخدام Default(T)، الذي يصدر أي حقل مدار قبل تصفيره.

// Default() instead of FillChar: the compiler reuses one hidden temp for
// the function result across loop iterations, so FillChar would zero live
// WideString pointers without releasing them.
Result := Default(TPdfAnnotation);

تعيش مشكلة ملكية ذات صلة عند حدود تحميل المكتبة. يحل هذا الربط عدة مئات من مؤشرات الوظائف من ملف PDFium DLL باستخدام GetProcAddress بعد LoadLibrary. إذا كانت إحدى الصادرات المطلوبة مفقودة، فإن الحالة المربوطة جزئياً تكون خطيرة: عشرات المؤشرات صالحة، والباقي nil أو قديم، وأي استدعاء لاحق من خلال أحدها يقفز إلى وحدة قد تكون تم إلغاء تحميلها بالفعل. يتعامل الربط مع هذا عن طريق إلغاء تحميل المكتبة وتشغيل ClearAllBindings كامل يعيد ضبط كل مؤشر مستورد إلى nil كلما فشل حل التصدير المطلوب. بعد ذلك، لا يتدلى أي مؤشر دالة في وحدة تم إلغاء تحميلها، ويفشل الاستدعاء اللاحق بشكل نظيف مع فحص مؤشر nil بدلاً من التفرع إلى كود محرر.

المغلف هو المكان الذي تتم فيه إعادة صياغة أربعة عقود يدوياً

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

إذا كنت تريد رؤية الربط وهو يقوم بعمل حقيقي بدلاً من حماية حوافه، فإن تقنيات ذاكرة التخزين المؤقت للرسم والتكبير في ملاحظتنا حول أداء ذاكرة التخزين المؤقت للرسم والتكبير تعرض مسار الرسم، والدليل الإرشادي للمترجم المشترك في بناء عارض Lazarus و FPC هو المكان الذي يهم فيه سلوك size_t في Win64 الموصوف هنا بالفعل. كلاهما يبنى على نفس عمل سلامة الذاكرة و ABI الذي يتم شحنه في مكون PDFium لـ Delphi و Lazarus و C++Builder، جنباً إلى جنب مع واجهات برمجة التطبيقات للرسم واستخراج النصوص والنماذج المغطاة في مكان آخر في هذه المدونة.