مقال تقني

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

يبدو ربط لغة Pascal فوق مكتبة C وكأنه كود Pascal عادي. أنت تستدعي طريقة (method)، وتحصل على سجل (record) كعائد، وتحرر ما خصصته. المشكلة هي أن PDFium هي مكتبة C و C++ لها اصطلاح استدعاء (calling convention) خاص بها، وعروض أعداد صحيحة (integer widths) خاصة بها، وقواعدها الخاصة حول من يمتلك الذاكرة ومن يحررها. لا شيء من هذا يعبر حدود اللغة بمفرده. يجب إعادة صياغة كل عقد من هذه العقود يدوياً في إعلانات Pascal، وكلمة واحدة خاطئة تحول استدعاءً يبدو نظيفاً إلى تلف في المكدس (stack corruption)، أو إزاحة مقتطعة، أو تحرير مزدوج (double free). كشف تدقيق للإصدار v1.61.0 لربط مكون PDFium عن عيب واحد من كل نوع. الأمر يستحق استعراضها لأنها ليست خاصة بهذا الربط فقط. بل إنها المخاطر الدائمة لتغليف أي واجهة برمجة تطبيقات C (C API) في Delphi أو Lazarus

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

PDFium مكتوبة بلغة C المترجمة (compiled C). على أنظمة Win32، تستخدم تصديراتها (exports) والأهم من ذلك استدعاءات الاسترجاع (callbacks) التي تستدعيها اصطلاح الاستدعاء cdecl. تحت cdecl، يقوم المتصل (caller) بتنظيف المكدس بعد عودة الاستدعاء. الوضع الافتراضي الأصلي لـ Delphi هو register، ومعيار C على Win32 للاستدعاءات في بعض المكتبات هو stdcall، حيث يقوم المستدعى (callee) بالتنظيف بدلاً من ذلك. عندما يسلم هيكلٌ لـ PDFium مؤشر دالة (function pointer) وتنسى cdecl في نوع ذلك المؤشر، يختلف الطرفان حول من يضبط مؤشر المكدس (stack pointer). يقوم كلاهما بإصلاحه، أو لا يقوم أي منهما، وينحرف مؤشر المكدس بمقدار حجم المعلمات في كل استدعاء

السبب في صعوبة العثور على هذا العيب هو أن الضرر غير محلي. يعود الاستدعاء التالف ويبدو جيداً. يظهر عدم المحاذاة لاحقاً، في دالة غير ذات صلة يقع إطارها الآن على مؤشر مكدس مزاح بضع بايتات، ويتجلى كقراءة جامحة، أو عنوان إرجاع سيء، أو انهيار (crash) مع تتبع خلفي يشير إلى مكان لا يمت بصلة للاستدعاء الذي أخطأت فيه فعلياً. يُعد ملء النموذج (Form-fill) المكان الكلاسيكي الذي يلدغ فيه هذا الخطأ، لأن واجهة ملء النموذج عبارة عن سجل مليء بالاستدعاءات التي يعاود PDFium الاستدعاء إليها. يقوم أحدهم، وهو FFI_OpenFile، بتسليم PDFium دالة سيستدعيها لفتح ملف خارجي، مصرح عنها كـ function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. الكلمة اللاحقة cdecl هي النقطة التي تستحق النسخ. أسقطها وسيظل الكود يُترجم (compiles)، ويُربط (links)، ويعمل حتى يستدعي PDFium الدالة. ينتمي الاصطلاح إلى نوع الدالة نفسه. إنه ليس مجرد إضافة اختيارية، ولن يحذرك المترجم (compiler) عند فقدانه لأن نوع الدالة البسيط هو نوع 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 هو اسم مستعار (alias) له. يحتوي الربط على تعليق في قسم النوع يحذر تحديداً من تظليل NativeUInt على FPC، لأن إعادة تعريفه لاسم مستعار بحجم 32 بت هناك ستجبر size_t على 32 بت وتفسد كل معلمة size_t تمرر إلى أو تكتب بواسطة المكتبة. الإزاحة بحجم 64 بت التي تصل إلى معلمة بحجم 32 بت تفقد النصف العلوي منها. بالنسبة للملف الصغير، كل إزاحة تتسع في 32 بت ولا يوجد خطأ. بالنسبة للملف الكبير، في اللحظة التي تتخطى فيها الإزاحة خط الأربعة جيجابايت، تشير القيمة المقتطعة إلى مكان آخر تماماً، ويسأل PDFium عما إذا كان نطاق البايت الخاطئ متاحاً، ويتوقف التحميل التدريجي أو يقرأ بيانات عشوائية (garbage). العيب غير مرئي حتى يصبح الملف كبيراً بما يكفي ويكون الهدف هو الهدف الذي يتسع فيه size_t فعلياً

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

الفئة الثالثة تتعلق بنموذج الاستثناء (exception model)، والذي تفتقر إليه لغة C. عندما يستدعي PDFium أحد استدعاءاتك (callbacks)، يتم تشغيل كود Pascal الخاص بك داخل مكدس من إطارات C و C++ التي لا تعرف شيئاً عن آلية الاستثناء في Delphi. إذا أثار الاستدعاء الخاص بك استثناءً وسمح له بالانتشار، فإنه يُفك (unwinds) عبر إطارات لم تُبنَ أبداً لتُفك. لا تعمل عملية التنظيف الخاصة بـ PDFium، وتُترك المتغيرات الداخلية (invariants) الخاصة به نصف محدّثة، وتصبح العملية الآن في حالة لم تتوقعها المكتبة أبداً. العقد الخاص بهذه الاستدعاءات هو كود إرجاع، وليس استثناءً

استدعاءان يجعلان هذا ملموساً. FPDF_FILEWRITE هو الحوض (sink) الذي يكتب فيه PDFium مستنداً محفوظاً، و FPDF_FILEACCESS هو المصدر (source) الذي يقرأ منه مستند إدخال. كلاهما مُنفذان هنا فوق كائن TStream الخاص بـ Delphi، وكلاهما يمكن أن يفشل كما يفشل أي تيار (stream): يمتلئ القرص، أو يُغلق التيار من أسفلك، أو تتجاوز القراءة النهاية. يقوم استدعاء الكتابة بتغليف عملية كتابة التيار الخاص به وتحويل أي فشل إلى كود فشل 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 بدلاً من الإثارة (raising) عبر الحدود. إن كتلة except مجردة بدون إعادة إثارة (re-raise) تبدو خاطئة لمبرمج Pascal متدرب على عدم ابتلاع الاستثناءات أبداً، وهي في لغة Pascal العادية كذلك. أما عند حدود ABI فهي الشكل الصحيح، لأن القيمة الوحيدة الآمنة لإعادتها إلى المتصل بلغة C هي رمز حالة (status code) يعرف كيف يفسره. لا يزال الفشل ينتشر، فقط من خلال القيمة المرجعة، ويبرزه كود الاستدعاء الموجود فوق المكتبة كـ EPdfError بمجرد عودة التحكم إلى جانب Pascal من السياج

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

العيب الرابع هو الملكية (ownership). يفتح المكتبة مقبض (handle) مستند PDFium ويجب إغلاقه مرة واحدة بالضبط، بواسطة FPDF_CloseDocument. الخطر يكمن في مسار الخطأ الذي يحرر مقبضاً تملكه أيضاً عملية تنظيف ثانية. تخيل روتيناً ينشئ كائناً غلافاً (wrapper object)، ويعين له مقبض مستند مفتوح حديثاً، ثم يقوم بإعداد إضافي قد يفشل. إذا أثار الإعداد خطأ، فسيقوم معالج العودة المبكرة الذي يستدعي FPDF_CloseDocument على المقبض الخام بإغلاقه، ثم سيغلقه مدمر كائن الغلاف (destructor) مرة أخرى عند تحرير الكائن. يُحرر المقبض مرتين، وهو سلوك غير محدد ويُرجح أن يتسبب في انهيار (crash)

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

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 بصمت. تعيد العديد من الدوال المساعدة في هذا الربط سجلاً (record) يحتوي على WideString أو مصفوفة ديناميكية. تلك هي حقول معدودة المراجع (reference-counted)، ويصدر المترجم مسك دفاتر (bookkeeping) خفي للحفاظ على أعدادها. الغريزة المنقولة من C هي مسح سجل حديث باستخدام FillChar(Result, SizeOf(Result), 0). هذا يختم الأصفار فوق المرجع المدار (managed reference) داخل السجل دون إنقاصه أولاً. يعيد المترجم استخدام متغير مؤقت خفي واحد لنتيجة الدالة عبر تكرارات الحلقة، لذا في التكرار الثاني تقوم 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);

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

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

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

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