مقال تقني

FillChar على نتيجة دالة تسرّب السلاسل النصية في Object Pascal

دالة في Delphi أو FPC تُعيد سجلًا لا تحصل على Result جديد وممسوح في كل استدعاء. يبدأ متغير Result الخفي ذاك صفرًا مرة واحدة بالضبط، ولا شيء يعيد مسحه تلقائيًا بين الاستدعاءات، بحيث يكون مسحه عند الدخول مهمة الدالة نفسها. افعل ذلك المسح بـFillChar(Result, SizeOf(Result), 0) وستكتب الدالة، من الاستدعاء الثاني فصاعدًا، فوق مرجع سلسلة نصية أو مصفوفة ديناميكية حية بدلًا من تحريره، ما يُيتِّم أيًّا كانت كتلة الكومة التي كان يشير إليها ذلك المرجع

السيناريو الذي يعضّ فيه هذا مبتذل. تفتح عملية دفعية كومة من ملفات PDF من طرف ثالث وتجتاز كل تعليق توضيحي على كل صفحة، ساحبة نص التعليق إلى سجل تدقيق. لا شيء في تلك الحلقة يبدو خطيرًا: كل استدعاء دالة عادية تُعيد سجلًا عاديًا، لا مؤشرات في الأفق، لا شيء يشبه إدارة الذاكرة اليدوية على الإطلاق. عد المراجع داخل سجل قاعدة محاسبة عادية في Object Pascal، لا خاصية غريبة محددة بمكتبة واحدة، وأي قاعدة شيفرة Delphi أو FPC تمزج FillChar مع أنواع سجلات تحمل سلاسل نصية أو مصفوفات ديناميكية معرَّضة للعيب نفسه

لماذا يسرّب FillChar على نتيجة سجل سلاسل نصية؟

يسرّب FillChar السلاسل النصية لأنه لا يعرف نوع البيانات التي يكتب فوقها. يعمل FillChar(X, Count, Value) على أي متغير على الإطلاق: يأخذ كتلة غير مُنمَّطة من Count بايت ويختم كل واحدة منها بـValue، وهذا هو العقد بأكمله. هذا بالضبط ما يجعل FillChar سريعًا وعام الغرض، لأنه لا يفحص أبدًا نوع X ولا يتفرع أبدًا بناءً على معنى البايتات الكامنة. حقل من نوع UnicodeString أو WideString داخل سجل ليس الأحرف نفسها؛ إنه مؤشر إلى كتلة كومة تحمل عداد مراجع قبل بيانات الأحرف. يرى FillChar حفنة من البايتات صادف أنها تحمل قيمة مؤشر ويكتب فوقها بصفر تمامًا كما يكتب فوق حقل Integer أو Double. يختفي المؤشر، ولا يُلمَس أبدًا عداد المراجع الذي كان يجب إنقاصه أولًا، وتبقى الكتلة التي كان يشير إليها مخصصة دون أي شيء متبقٍ يشير إليها

كيف يتتبع المترجم السلاسل النصية والمصفوفات الديناميكية داخل سجل

يسمّي Object Pascal نوعًا مُدارًا عندما يتعين على المترجم تشغيل شيفرة إضافية للحفاظ على صحته عبر التعيين والخروج من النطاق. تتأهل أنواع السلاسل الطويلة مثل AnsiString وUnicodeString وWideString، وكذلك المصفوفات الديناميكية، والواجهات، وأنواع Variant، إلى جانب أي سجل أو مصفوفة ثابتة الحجم تحتوي أحد تلك كحقل. لكل حقل مُدار، يُصدر المترجم بصمت المحاسبة التي كانت لتكون متعبة وسهلة الخطأ يدويًا: زيادة عداد مراجع عند التعيين، وإنقاصه عندما يُكتب فوق المتغير الحامل أو يخرج عن النطاق، وتحرير الكتلة الكامنة بمجرد أن يصل ذلك العداد إلى صفر. تلك الآلية هي سبب عدم تخصيص شيفرة Pascal العادية أو تحرير string يدويًا أبدًا، وسبب كون تعيين مصفوفة ديناميكية إلى أخرى عملية رخيصة وآمنة بدلًا من حلقة نسخ يدوية. System.Default وFinalize هما الطريقتان الموثَّقتان لاستدعاء منطق التحرير نفسه عند الطلب، وهما ما يجب أن تستدعيه شيفرة مسح سجل بدلًا من ملء ذاكرة خام

type
  TLineItem = record
    Description: string;  // managed: reference-counted
    Quantity: Integer;    // unmanaged: plain ordinal
  end;

function GetLineItem(Index: Integer): TLineItem;
begin
  FillChar(Result, SizeOf(Result), 0);  // clears bytes, not the reference
  Result.Quantity := Source[Index].Qty;
  Result.Description := Source[Index].Text;
end;

var
  Item: TLineItem;
  I: Integer;
begin
  for I := 0 to High(Source) do
  begin
    Item := GetLineItem(I);  // second pass onward: leaks the prior Description
    Log.Add(Item.Description);
  end;
end;

لماذا لا يبدأ التسريب إلا في الاستدعاء الثاني؟

الاستدعاء الأول في حلقة غير ضار دائمًا، وهذا بالضبط ما يجعل هذا العيب سهل التفويت في الاختبار. يبدأ متغير محلي من نوع سجل مُدار صفرًا، ولا شيء يعيد مسحه تلقائيًا بين تمريرة حلقة والتالية، بحيث في المرة الأولى التي تُعيّن فيها حلقة قيمة إرجاع دالة إلى ذلك المتغير، يظل حقل Description أو ContentsText الخاص به nil. يكتب FillChar فوق nil بصفر، ما لا يغيّر شيئًا فيما يتعلق بعداد المراجع، ويعود الاستدعاء بشكل يبدو صحيحًا تمامًا. الاستدعاء الثاني مختلف: يحمل المتغير المحلي نفسه بالفعل أيًّا كان ما كتبه الاستدعاء الأول فيه، وتُكتب Result الجديدة للاستدعاء مباشرة في التخزين نفسه بدلًا من ذاكرة جديدة وفارغة. يمسح FillChar في أعلى ذلك الاستدعاء الثاني حقلًا لم يعد nil، وكل ما بعد نمط البايت ذاك خاطئ بصمت من تلك اللحظة فصاعدًا. اختبار يستدعي الدالة مرة واحدة ويفحص النتيجة لن يرى المشكلة أبدًا؛ فقط حلقة، أو أي مسار شيفرة يستدعي الدالة بشكل متكرر مقابل الوجهة نفسها، يكشفها

تسريب حقيقي: التعليقات التوضيحية، والإشارات المرجعية، وسجلات الروابط

شحن PDFiumPas هذا العيب بالضبط قبل الإصدار 1.56.4، في ثلاث دوال تُعيد كل منها سجلًا يحمل حقلًا مُدارًا واحدًا على الأقل: يُعيد قارئ التعليقات التوضيحية على مستوى الصفحة TPdfAnnotation يحمل سلسلتي ContentsText وAuthorText، ويُعيد قارئ الإشارات المرجعية TBookmark يحمل سلسلة Title، ويُعيد قارئ تعليقات الروابط التوضيحية TLinkAnnotation يحمل سلسلة ActionPath ومصفوفة ديناميكية Points. افتتحت الدوال الثلاث بالشكل نفسه المُبيَّن أدناه: امسح Result بـFillChar خام، ثم املأ الحقول واحدًا تلو الآخر من بيانات الصفحة الكامنة. اجتياز كل تعليق توضيحي على صفحة واحدًا تلو الآخر، الطريقة العادية لبناء قائمة تدقيق أو لوحة مراجعة، استدعى قارئ التعليقات التوضيحية في حلقة وسرّب نص التعليق التوضيحي السابق في كل تمريرة بعد الأولى؛ ملف PDF مصمَّم بعدد كبير غير عادي من التعليقات التوضيحية الحاملة للنص يمكن أن ينمّي ذاكرة عملية طويلة التشغيل طالما استمرت تلك العملية في العمل. مسّ الإصلاح سطرًا واحدًا في كل دالة: استبدال FillChar(Result, SizeOf(Result), 0) بـResult := Default(TPdfAnnotation) كان كافيًا، لأن تعيين Default لسجل مُدار يشغّل تسلسل التحرير-ثم-المسح العادي الخاص بالمترجم بدلًا من ملء ذاكرة خام

function GetPageAnnotation(Page: FPDF_PAGE; Index: Integer): TPdfAnnotation;
var
  Annotation: FPDF_ANNOTATION;
  ContentLength: LongWord;
begin
  Annotation := FPDFPage_GetAnnot(Page, Index);
  FillChar(Result, SizeOf(Result), 0);   // clears bytes, not a live reference
  Result.Subtype := DecodeAnnotationSubtype(FPDFAnnot_GetSubtype(Annotation));
  ContentLength := FPDFAnnot_GetStringValue(Annotation,
    FPDFANNOT_TEXTTYPE_Contents, nil, 0);
  if ContentLength >= 4 then
  begin
    SetLength(Result.ContentsText, ContentLength div 2 - 1);
    FPDFAnnot_GetStringValue(Annotation, FPDFANNOT_TEXTTYPE_Contents,
      Pointer(Result.ContentsText), ContentLength);
  end;
end;

الخطر نفسه خلف معامل var

يُظهر قارئ الإشارات المرجعية نسخة أدق من المشكلة نفسها، لأن السجل الذي يُمسَح بـFillChar ليس Result الخاص بالدالة نفسها بل معامل var باستدعاء واحد أسفل. تأخذ SetBookmarkData مخرجاتها كـvar Data: TBookmark وكانت تمسح Data في أعلى جسمها بـFillChar؛ تستدعي GetBookmark، الدالة العامة التي تُعيد فعليًا TBookmark، SetBookmarkData وتمرر Result الخاصة بها مباشرة كذلك الوسيط var. يُمرَّر معامل var بالمرجع، بحيث تكون Data داخل SetBookmarkData وResult داخل GetBookmark التخزين نفسه تحت اسمين، وأي مخاطرة تسمية مستعارة (aliasing) تنطبق على Result الخاصة بدالة تنطبق بالقدر نفسه مباشرة على أي روتين مساعد يستقبلها بالمرجع. مراجعة الدوال التي تُعلن حرفيًا نوع إرجاع سجل فقط تفوّت هذا الشكل؛ يجب أن يتتبع البحث أيضًا كل معامل var وout تُوجَّه إليه Result

procedure TPdf.SetBookmarkData(Bookmark: FPDF_BOOKMARK; var Data: TBookmark);
var
  BufferSize: LongWord;
begin
  Data := Default(TBookmark);   // fixed: was FillChar(Data, SizeOf(Data), 0)
  Data.Handle := Bookmark;
  if Bookmark <> nil then
  begin
    BufferSize := FPDFBookmark_GetTitle(Bookmark, nil, 0);
    if BufferSize >= 4 then
    begin
      SetLength(Data.Title, BufferSize div 2 - 1);
      FPDFBookmark_GetTitle(Bookmark, PWideChar(Data.Title), BufferSize);
    end;
  end;
end;

function TPdf.GetBookmark(const Title: WString): TBookmark;
begin
  CheckActive;
  SetBookmarkData(FPDFBookmark_Find(FDocument, PWideChar(Title)), Result);
end;

متى يظل FillChar الاستدعاء الصحيح؟

لا يزال FillChar صحيحًا، وغالبًا أرخص قليلًا، لسجل مبني بالكامل من قيم عددية، أو حقول فاصلة عائمة، أو مصفوفات ثابتة الحجم منها، أو سجلات عادية أخرى مصنوعة من الشيء نفسه، لأنه لا يوجد فيه شيء يحتاج المترجم إنهاءه. نوع المستطيل الخاص بـPDFiumPas نفسه هو بالضبط تلك الحالة: تحمل TPdfRectangle أربعة حقول Double ولا شيء آخر، ومسح واحد بـFillChar لا يحرر شيئًا لأنه لا يوجد شيء معدود المرجع ليُحرَّر. الفحص الذي يفصل بين الحالتين بسيط في صياغته: هل يحمل أي حقل من السجل، على أي عمق تداخل، نوع string أو AnsiString أو WideString أو مصفوفة ديناميكية أو واجهة أو Variant؟ يمكن لسجل أن يبدو عدديًا تمامًا على المستوى الأعلى ولا يزال يفشل ذلك الفحص إذا كان أحد حقوله نفسه سجلًا يدفن سلسلة نصية بضع طبقات أدنى، بحيث يجب أن يتتبع الفحص السجلات المتداخلة بأكملها بدلًا من التوقف عند قائمة الحقول الخارجية. تدقيق قاعدة شيفرة موجودة بحثًا عن هذا النمط ميكانيكي لا مستنفد: ابحث عن كل استدعاء FillChar هدفه متغير سجل، ثم تحقق من قائمة حقول ذلك السجل مقابل قائمة الأنواع المُدارة أعلاه. شغّل تدقيق PDFiumPas الخاص بالإصدار v1.56.4 نفسه ذلك البحث بالضبط عبر المكتبة بأكملها ووجد هذا التعرض في وحدة واحدة؛ كان كل موقع استدعاء FillChar آخر بالفعل يمسح سجلًا عدديًا عاديًا، حيث كان FillChar، ولا يزال، الأداة الصحيحة

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

لا شيء من هذا يتطلب تبديل مكتبات أو ملاحقة خلل في شيفرة مُترجَمة لشخص آخر: إنها خاصية للغة Object Pascal نفسها، يعمل بها كل مطور Delphi وFPC يوميًا، والإصلاح استدعاء دالة واحد بمجرد أن تعرف البحث عنه. واجهات التعليقات التوضيحية، والإشارات المرجعية، وتعليقات الروابط التوضيحية الموصوفة هنا تُشحن كجزء من مكوّن PDFium لـDelphi وC++Builder وLazarus/FPC، إلى جانب بقية سطح قراءة PDF ورسمه وتعليقاته التوضيحية المشروح في مكان آخر من هذه المدونة