مقال تقني

تعلّق محلل PDF في Delphi: المؤشر الذي لا يتحرك أبداً

يتعلّق المحلل على ملف PDF تالف عندما يقرأ فرع ما بايتاً، ويرفض التعامل معه، ويعود دون تحريك المؤشر. لا ترى حلقة المتصل أي تقدم ولا أي سبب للتوقف، لذا تدور إلى الأبد. شحنت HotPDF ذلك العيب بالضبط في ثلاثة محللات مستقلة، كلها قابلة للوصول من مدخلات دون الخمسين بايتاً

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

لماذا يكلف ملف من أربعين بايتاً أكثر من قنبلة فك ضغط؟

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

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

محلل الكائنات: فاصل تعبير لم يستهلكه أحد

في HPDFParser، تبدأ دالة THPDFSpanObjectParser.ParseValue برفض تسلسلي بايتين صراحة. بموجب القسم 7.3 من ISO 32000-1، لا يمكن أن تبدأ القيمة بـ ] أو بـ >>، لأنهما يغلقان حاوية بدل فتح قيمة، لذا تعيد ParseValue nil لكلاهما دون استهلاكهما. هذا هو العقد الصحيح، وهو يضع عبء التقدم كله على المتصل. كرّمت فرع المصفوفة نصفه: عند نتيجة nil يفحص ] ويتركه في مكانه ليمرر الإغلاق في الجولة التالية، ويتخطى كل شيء آخر — إلا أن الفحص كُتب مصادفة بحيث يُترك >> الشارد في مكانه أيضاً. لذا يتعلّق [ >> ]، لأن ParseArray تطلب قيمة فتحصل على nil ولا تقدم شيئاً وتطلب مجدداً. كان لفرع القاموس الحفرة المرآتية، ويتعلّق << /A ] >> بنفس الطريقة. كلا المدخلين صالح بما يكفي للوصول إلى المحلل وقصير بما يكفي ليتسع لتغريدة

مخطط عيب محلل الكائنات في HotPDF حيث ترفض ParseValue فاصلي الإغلاق دون استهلاكهما: يتسامح فرع المصفوفة مع قوس إغلاق لكنه يترك إغلاقاً مزدوجاً شارداً في مكانه فيدور مثال المصفوفة إلى الأبد، ولفرع القاموس الحفرة المرآتية، ويخطو الإصلاح صراحة فوق التسلسل الذي لا تستطيع الحلقة التعامل معه
إعادة nil لبايت لم يطالب به أحد هو العقد الصحيح، وهو يضع عبء التقدم كله على حلقة المتصل
uses
  HPDFObjs, HPDFParser;

procedure ProbeParserStalls;
var
  Stats: THPDFParserViewStatistics;
  Arr: THPDFArrayObject;
  Dict: THPDFDictionaryObject;
begin
  // قبل الإصلاح، لم يعُد أي من الاستدعاءين التاليين أبداً
  Arr := HPDFParserParseSimpleArrayWithStatistics('[ >> ]', Stats);
  Arr.Free;
  WriteLn('array errors=', Stats.ErrorCount, ' valid=', Stats.Valid);

  Dict := HPDFParserParseSimpleDictionaryWithStatistics('<< /A ] >>', Stats);
  Dict.Free;
  WriteLn('dict errors=', Stats.ErrorCount, ' valid=', Stats.Valid);
end;

الإصلاح ليس بارعاً، وهذه هي النقطة: على مسار nil أن يقرر، صراحة، أي التسلسلين غير المستهلكين تستطيع الحلقة الحالية التعامل معه، ويتخطى الآخر

Value := ParseValue(Depth + 1);
if Value = nil then
begin
  RecordError;
  // تعيد ParseValue nil لكلا ']' و '>>' دون استهلاكهما.
  // تنهي ']' المصفوفة في الجولة التالية، لكن '>>' الشارد
  // داخل مصفوفة يجب تخطيه أو لا يتقدم التحليل أبداً
  if (FPosition < FLength) and StartsWithPair('>', '>') then
    Inc(FPosition, 2)
  else if (FPosition < FLength) and (CharAt(FPosition) <> ']') then
    Inc(FPosition);
  Continue;
end;

شيء آخر يستحق الانتباه هنا. THPDFSpanObjectParser.RecordError فقط تزيد ErrorCount في THPDFParserViewStatistics؛ ولا تقصّ شيئاً. يضبط المحلل HPDFParserMaximumDepth على 64 لتحديد التداخل، لكن لا سقف مكافئ على عدد الأخطاء، والعدّاد الذي لا يقرؤه أحد أبداً هو قاطع دارة مفقود. حتى مع استعادة التقدم، مدخل ينتج خطأ لكل بايت سيمضغ كائناً كبيراً بمعدل يستحق خروجاً مبكراً لا اجتيازاً كاملاً. افحص Valid في سجل الإحصائيات بعد كل تحليل وعامل ErrorCount الكبير كسبب للتوقف عن الوثوق بالكائن، لا مجرد تشخيص

نفس الشكل في مُرمّز CMap وقارئ XFDF

حمل HPDFCMapReader.NextToken العيب ذاته في فرعه الاحتياطي. تعيد الدالة TCMapTok وتتعامل مع < و ( و / بفروع مخصصة؛ كل شيء آخر ينسحب إلى "اقرأ حتى مسافة أو فاصل تعبير". عندما يكون البايت البادئ نفسه فاصل تعبير من مجموعة القسم 7.2.2 في ISO 32000-1، تعمل تلك الحلقة صفر مرة، ويعود الرمز فارغاً، ويعيد المتصل طلب نفس الرمز مجدداً. من الأحرف الستة التي يمكنها الهبوط هناك، [ فقط كان يعترضه مبكراً التعامل مع bfrange، لذا كان أي ) أو > أو ] أو { أو } شارد في أي CMap ToUnicode كافياً لتعليق استخراج النص

// كلمة مفتاحية أو رقم: اقرأ حتى مسافة أو فاصل تعبير
StartP := P;
while (P <= Len) and (not IsCMapWhiteSpace(Text[P]))
  and (not IsCMapDelimiter(Text[P])) do
  Inc(P);
// فاصل لا يطالبه أي من الفروع أعلاه (مثل ')' أو '>' أو
// '[' أو ']' أو '{' أو '}') كان سيترك P حيث هو ويعيد كلمة
// مفتاحية فارغة، فيطلب المتصل نفس الرمز إلى الأبد
if P = StartP then
  Inc(P);
AKeyword := Copy(Text, StartP, P - StartP);
Result := ctKeyword;

كان لجهة XFDF تنويع بأصل مختلف ونفس الأثر. تضبط HPDFXFDFXml.ParseElement Result := P في الأعلى، بينما ما زال P يشير إلى < الافتتاحي، وعندها فقط تزيد مؤشرها المحلي، لذا كل خروج مبكر يلي يعيد موضع الدخول ما لم يكتب Result أولاً. إعلان doctype بموجب القسم 2.8 من XML 1.0، أو أي شيء آخر دون اسم وسم قابل للتحليل مثل <1a>، سلك خروجاً لم يحدّث Result أبداً، وتلقت حلقة العناصر الابن الموضع الذي مررته. كان لحلقة السمات ثلاثة من هذه أيضاً، لأن ReadName وتخطي المسافات والتعامل مع = كلها ترفض بايتاً ليس حرف اسم ولا مسافة ولا أحد = و > و /، ولم يستهلكه أي منها. نقل إصلاح CDATA المجاور مسؤولية بدل إضافة حارس: قسم CDATA بموجب القسم 2.7 من XML 1.0 يبدأ بـ <، لذا كانت حلقة الأبناء تسلّمه إلى ParseElement، التي تقرأ بعد ذلك اسم وسم من محتوى القسم. التعرف عليه في حلقة الأبناء بـ IsCDATAStart ونسخ البايتات إلى مخزن النص حتى IsCDATAEnd هو التقسيم الصحيح، وكان التخطي القديم أيضاً قصيراً ببايت واحد، لأن العلامة ثمانية بايتات تُعد من !

أعداد معلنة تعيش أطول من البيانات

أظهر إصدار لاحق النمط نفسه من الاتجاه المعاكس، وهو التنويع الأكثر احتمالاً للوجود في كودك. ترويسة قسم فرعي للمراجع المتقاطعة بموجب القسم 7.5.4 من ISO 32000-1 زوج first count، وحلق كلا محللي المراجع المتقاطعة التقليدية في HotPDF for I := 0 to Count - 1 فوقها، مستخدمين Continue لا Break عندما يفشل إدخال في التحليل. يبدو ذلك تسامحاً مع سطر سيئ واحد. ليس كذلك: يعيد HPDFReadLine نصاً فارغاً دون تقدم بمجرد نفاد البيانات، ويرفض HPDFParseTraditionalXrefEntry الإدخال الفارغ فوراً، لذا كل تكرار متبقٍ من العدد المعلن دوران خالص. الحساب قاسٍ — الترويسة xref\n0 2000000000\ntrailer زائد غلاف أدنى نحو أربعين بايتاً، وتشتري عشرات الثواني من نواة مشغولة دون أي فحص إلغاء في الحلقة في أي مكان. كانت الإشارة جالسة في نفس الوحدة، لأن HPDFRevisionParseTraditionalXRef استخدم دائماً Exit في ذلك الموضع. عندما يحلل دالتان نفس القواعد ويختلفان حول ما إذا كان الإدخال الفاشل ينهي القسم الفرعي، فإحداهما مفترق توقف عن تلقي الإصلاحات، نفس البصمة التي تظهر في معالجة المراجع المتقاطعة الهجينة حيث يصف جدول تقليدي وتدفق نفس الكائنات

مخطط قسم فرعي تقليدي للمراجع المتقاطعة في HotPDF تعلن ترويسته 2000000000 إدخال لنحو أربعين بايتاً من الملف: يعيد HPDFReadLine نصاً فارغاً دون تقدم بمجرد نفاد البيانات، ويبقي Continue كل تكرار متبقٍ يدور، وتكسر الحلقة المصلحة عند أول إدخال سيئ بسقف Stream.Size مقسوماً على 8
العدد المعلن ادعاء عن الملف لا عن البيانات، لذا يجب أن ينهي الإدخال الفاشل القسم الفرعي بدل تخطي تكرار
if not HPDFParseIntegerPair(Line, First, Count) then
  Exit;
for I := 0 to Count - 1 do
begin
  Entry := HPDFReadLine(XrefData, P);
  // عدد معلن أكبر من الجدول كان سيُبقي هذا الدوران
  // هنا دون بيانات متبقية للقراءة
  if not HPDFParseTraditionalXrefEntry(Entry, EntryOffset, GenNum, InUse) then
    Break;
  // First يأتي من ترويسة القسم الفرعي، لذا يلتف المجموع
  // لبداية معلنة قرب حد Integer، ويرفض مساعد النمو
  // بصمت فهرساً سالباً
  if (First < 0) or (Int64(First) + I > MaxInt) then
    Break;
  ObjNumber := First + I;
  if (ObjNumber < 0) or (Int64(ObjNumber) > Stream.Size div 8) then
    Break;
  HPDFEnsureDirectXrefLength(State, ObjNumber);
  // ... سجّل الإدخال ...
end;

الحدان بعد Break ينتميان إلى نفس الإصلاح. يرفض HPDFEnsureDirectXrefLength تنمية المصفوفة لرقم كائن سالب ولا يقول شيئاً عنه، ويلتف First + I لبداية معلنة قرب حد Integer، لذا يصل فهرس سالب إلى الكتابات أدناه. سقف رقم الكائن Stream.Size div 8 هو الجواب العام الرخيص، إذ لا يمكن لأي جدول مراجع متقاطعة تقليدي وصف كائنات أكثر مما يتسع له الملف، وأي ترويسة تدّعي غير ذلك تصف ملفاً غير موجود. إذا كنت تصلح يدوياً جدولاً تالفاً، ينطبق المنطق نفسه من الجانب الآخر، وهو المجال المغطى في الملاحظات عن تشخيص وإصلاح ملفات PDF المحررة يدوياً

كيف تثبت أن إصلاح التعلّق يعمل فعلاً؟

بتشغيل الكود القديم، لا بإعادة قراءته. التعلّق من فئات العيوب القليلة التي يمكن فيها التحقق من الإصلاح بإشارة ثنائية دون أي أدوات قياس، لأن العملية إما تعود وإما لا تعود، والتفكير في تدفق التحكم هو بالضبط المهارة التي أنتجت العيب أصلاً. الآليات تستحق النسخ. استخرج مراجعة ما قبل الإصلاح للوحدات المغيَّرة إلى دليل مؤقت، ثم اترجم مجساً صغيراً بمسار بحث الوحدات الذي يضع ذلك الدليل أمام المكتبة الحقيقية: dcc32 -U<scratchdir>;<Lib> probe.dpr. يأخذ المترجم المصادر القديمة لأي شيء يجده أولاً والمصادر الحالية لكل شيء آخر، لذا يجرب مجس واحد المحلل القديم ضد بقية المكتبة. شغّله تحت مهلة — أربعون ثانية كريمة — وعامل رمز الخروج 124 كالتأكيد. ثم اسحب الوحدات من الدليل المؤقت واحدة تلو الأخرى وأعد البناء. تكررت التعلقات الثلاثة بهذه الطريقة، كل واحد منسوب إلى وحدة محددة، وعادت الثلاثة بمجرد عودة المصدر المصلح إلى المسار، بتكلفة ترجمة واحدة لكل وحدة

فخان يستحقان المعرفة قبل المحاولة. مسار بحث الوحدات متسامح بصمت: سلّم -U مساراً بنمط يونكس من شل، مثل $PWD، ويتجاهل المترجم ذلك الدليل دون كلمة، ما يعني أن مجسك تُرجم ضد المصادر الحالية ونجح للسبب الخاطئ. حوّل المسار إلى صيغة Windows الأصلية أولاً. الثاني أن الدليل المؤقت لا يحمل ملفات الإدراج، لذا تحتاج أيضاً إلى -I<Lib> من أجل HotPDF.inc وإلا فشل البناء بطريقة تبدو مشكلة مصدر لا مشكلة مسار. وكن صادقاً حول ما لا تعطيكه الطريقة: تؤكد أن مدخلاً محدداً توقف عن التعلّق على بناء محدد، ولا تقول شيئاً عن المدخلات التي لم تفكر فيها. عيوب الصفر تقدم تُعثر عليها بالاختبار التشويشي أو بقراءة كل فرع من كل مُرمّز، لا باختبارات انحدار مكتوبة بعد وقوع الأمر

ما الذي تدققه في محللاتك الخاصة

القاعدة القابلة لإعادة الاستخدام عقد، لا بند قائمة فحص. أي دالة تقرأ من مؤشر مشترك يجب، على كل مسار بما فيه مسارات الفشل، إما أن تستهلك بايتاً واحداً على الأقل أو تعيد قيمة لا يمكن للمتصل أن يخطئها بـ "حاول مجدداً". خلط هذين المعنيين في nil واحد أو رمز فارغ واحد هو ما يحوّل الرفض إلى تعلّق. لذا دقق للشكل لا للعرض. ابحث عن كل حلقة يعتمد إنهاؤها على مؤشر تملكه دالة أخرى، واسأل ماذا يحدث عندما ترفض تلك الدالة البايت الحالي. ابحث عن كل فرع احتياطي مكتوب كـ "اقرأ حتى X" واسأل ماذا يفعل عندما يجلس المؤشر بالفعل على X. ابحث عن كل حلقة محدودة بعدد جاء من الملف لا من البيانات، واجعل العنصر الفاشل ينهي الحلقة بدل تخطي تكرار. وحيث يُحلل نفس النحو في مكانين، قارن بينهما، لأن أحدهما سيكون قد تلقى إصلاحاً لم يتلقَّه الآخر. وضع فحص إلغاء داخل أي حلقة يمكن أن تعمل بعدد تكرارات يقدمه المتصل، بحيث يكون لدى المشغّل ما يسحبه عندما يظهر التالي من هذه في الإنتاج

المحللون الموصوفون هنا يشحنون في مكوّن HotPDF الحالي لـ Delphi لـ Delphi و C++Builder، مع سجلات الإحصائيات وحدود العمق مكشوفة بحيث يمكن لتحصينك الخاص البناء عليها