مقاله فنی

مستحکم‌سازی بایندینگ کامپوننت PDFium: رابط باینری اپلیکیشن (ABI) و ایمنی حافظه

یک بایندینگ پاسکال روی یک کتابخانه C مانند یک کد پاسکال معمولی خوانده می‌شود. شما یک متد را فراخوانی می‌کنید، یک رکورد پس می‌گیرید، و چیزی که به آن حافظه اختصاص داده‌اید را آزاد می‌کنید. مشکل اینجاست که PDFium یک کتابخانه C و C++ با قرارداد فراخوانی (calling convention) خاص خود، عرض‌های صحیح خاص خود و قوانین خاص خود درباره اینکه چه کسی مالک حافظه است و چه کسی آن را آزاد می‌کند، می‌باشد. هیچ‌یک از این موارد به خودی خود از مرز زبان عبور نمی‌کند. هر یک از آن قراردادها باید به صورت دستی در اعلان‌های پاسکال بازنویسی شوند و یک کلمه اشتباه، یک فراخوانی تمیز را به یک خرابی پشته (stack corruption)، یک آفست کوتاه شده یا یک آزادسازی دوگانه (double free) تبدیل می‌کند. بررسی نسخه v1.61.0 از یک بایندینگ کامپوننت PDFium، یک نقص از هر نوع را نشان داد. مرور آن‌ها ارزش دارد زیرا این موارد مختص این بایندینگ نیستند. آن‌ها خطرات همیشگی قرار دادن هر API زبان C در پوشش دلفی یا لازاروس هستند

cdecl بخشی از نوع تابع است، نه یک تزئین

کتابخانه PDFium به زبان C کامپایل شده است. در Win32 خروجی‌های آن و مهم‌تر از آن، کال‌بک‌هایی که فراخوانی می‌کند از قرارداد فراخوانی cdecl استفاده می‌کنند. تحت cdecl، فراخوانی‌کننده (caller) پس از بازگشت فراخوانی، پشته را پاک‌سازی می‌کند. پیش‌فرض بومی دلفی register است و استاندارد C در Win32 برای کال‌بک‌ها در برخی کتابخانه‌ها stdcall است، که در آن فراخوانی‌شونده (callee) پشته را پاک‌سازی می‌کند. وقتی یک ساختار، یک اشاره‌گر تابع را به PDFium می‌دهد و شما cdecl را روی نوع آن اشاره‌گر فراموش می‌کنید، دو طرف درباره اینکه چه کسی اشاره‌گر پشته را تنظیم کند با هم اختلاف پیدا می‌کنند. هر دو آن را اصلاح می‌کنند، یا هیچ‌کدام اصلاح نمی‌کنند، و اشاره‌گر پشته به اندازه آرگومان‌ها در هر بار فراخوانی جابجا می‌شود

دلیل دشواری در یافتن این نقص این است که آسیب آن محلی نیست. فراخوانیِ خراب بازمی‌گردد و درست به نظر می‌رسد. عدم تطابق بعداً در یک تابع نامرتبط که اکنون فریم آن روی یک اشاره‌گر پشته قرار دارد که چند بایت اختلاف دارد، ظاهر می‌شود و خود را به صورت یک خواندن غیرمجاز، یک آدرس بازگشت اشتباه، یا یک کرش با ردیابی پشته (backtrace) نشان می‌دهد که هیچ ربطی به کال‌بکی که شما به اشتباه تنظیم کرده‌اید، ندارد. پر کردن فرم، مکان کلاسیکی است که این مشکل در آن بروز می‌کند، زیرا رابط پر کردن فرم، یک رکورد پر از کال‌بک است که PDFium به آن‌ها فراخوانی بازگشتی انجام می‌دهد. یکی از آن‌ها، FFI_OpenFile، یک تابع را به PDFium می‌دهد که آن را برای باز کردن یک فایل خارجی فراخوانی می‌کند، که به این صورت اعلان شده است: function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. کلمه cdecl در انتها نکته‌ای است که باید کپی شود. اگر آن را حذف کنید، کد همچنان کامپایل می‌شود، همچنان لینک می‌شود و تا زمانی که PDFium تابع را فراخوانی می‌کند، اجرا می‌شود. این قرارداد به خود نوع تابع تعلق دارد. این یک افزودنی دلخواه نیست و کامپایلر زمانی که گم شده باشد به شما هشدار نمی‌دهد زیرا یک نوع تابع ساده یک نوع پاسکال کاملاً معتبر است. تنها راه دفاع، در نظر گرفتن قرارداد فراخوانی به عنوان یک فیلد اجباری برای هر امضای وارد شده و هر کال‌بکی است که به بیرون ارسال می‌کنید

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 و دلفی Win64 کار می‌کند، سپس به طور بی‌صدا در FPC و Lazarus Win64 خراب می‌شود. علت آن ظریف است. در FPC Win64، NativeUInt یک نوع 64 بیتی واقعی و هم‌عرض با اشاره‌گر است، و size_t نام مستعاری برای آن است. این بایندینگ دارای یک نظر (comment) در بخش انواع است که دقیقاً در مورد سایه انداختن روی NativeUInt در FPC هشدار می‌دهد، زیرا تعریف مجدد آن به یک نام مستعار 32 بیتی در آنجا، size_t را به 32 بیت مجبور می‌کند و هر پارامتر size_t ارسال شده یا نوشته شده توسط کتابخانه را خراب می‌کند. یک آفست 64 بیتی که به یک پارامتر 32 بیتی می‌رسد، نیمه بالایی خود را از دست می‌دهد. برای یک فایل کوچک هر آفست در 32 بیت جا می‌شود و هیچ مشکلی وجود ندارد. برای یک فایل بزرگ، لحظه‌ای که یک آفست از مرز چهار گیگابایت عبور می‌کند، مقدار کوتاه شده به جای کاملاً دیگری اشاره می‌کند، PDFium می‌پرسد که آیا محدوده بایت اشتباهی در دسترس است یا خیر، و بارگذاری تدریجی متوقف می‌شود یا داده‌های زباله را می‌خواند. این نقص تا زمانی که فایل به اندازه کافی بزرگ نباشد و پلتفرم هدف همان پلتفرمی نباشد که در آن size_t واقعاً گسترده شده است، نامرئی است

یک استثنای پاسکال هرگز نباید در یک فریم C باز شود

دسته سوم درباره مدل استثنا (exception) است که زبان C فاقد آن است. هنگامی که PDFium یکی از کال‌بک‌های شما را فراخوانی می‌کند، کد پاسکال شما درون پشته‌ای از فریم‌های C و C++ اجرا می‌شود که هیچ چیزی درباره مکانیسم استثنای دلفی نمی‌دانند. اگر کال‌بک شما یک استثنا ایجاد کند و اجازه دهد که استثنا منتشر شود، در میان فریم‌هایی باز می‌شود که هرگز برای باز شدن (unwind) ساخته نشده‌اند. پاک‌سازی خود PDFium اجرا نمی‌شود، ثابت‌های داخلی آن نیمه به‌روزرسانی شده باقی می‌مانند و پردازش اکنون در وضعیتی است که کتابخانه هرگز پیش‌بینی نکرده بود. قرارداد برای این کال‌بک‌ها یک کد بازگشتی (return code) است، نه یک استثنا

دو کال‌بک این موضوع را ملموس می‌کنند. FPDF_FILEWRITE مقصدی است که PDFium یک سند ذخیره‌شده را در آن می‌نویسد و FPDF_FILEACCESS منبعی است که یک سند ورودی را از آن می‌خواند. هر دو در اینجا روی یک 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 خالی بدون ایجاد مجدد خطا (re-raise) برای یک برنامه‌نویس پاسکال که آموزش دیده است هرگز استثناها را نادیده نگیرد، اشتباه به نظر می‌رسد، و در پاسکال معمولی هم اشتباه است. در مرز ABI این شکل صحیح است، زیرا تنها مقدار ایمنی که می‌توان به فراخوانی‌کننده C بازگرداند، یک کد وضعیت است که می‌داند چگونه آن را تفسیر کند. شکست همچنان منتشر می‌شود، فقط از طریق مقدار بازگشتی، و کد فراخوانی‌کننده بالاتر از کتابخانه، زمانی که کنترل به سمت پاسکال حصار بازگشت، آن را به عنوان EPdfError نمایان می‌کند

آزادسازی دوگانه (Double free) در مسیر خطا پنهان می‌شود

نقص چهارم مالکیت است. یک هندل سند PDFium توسط کتابخانه باز می‌شود و باید دقیقاً یک بار توسط FPDF_CloseDocument بسته شود. خطر در مسیر خطایی است که هندلی را آزاد می‌کند که یک پاک‌سازی دوم نیز مالک آن است. روالی را تصور کنید که یک آبجکت پوشاننده (wrapper) ایجاد می‌کند، یک هندل سند تازه باز شده را به آن اختصاص می‌دهد و سپس تنظیمات بیشتری را انجام می‌دهد که ممکن است با شکست مواجه شود. اگر تنظیمات استثنا ایجاد کند، یک مدیریت‌کننده بازگشت زودهنگام که FPDF_CloseDocument را روی هندل خام فراخوانی می‌کند، آن را می‌بندد، و سپس مخرب (destructor) خود آبجکت پوشاننده زمانی که آبجکت آزاد می‌شود، دوباره آن را می‌بندد. هندل دو بار آزاد می‌شود، که یک رفتار تعریف‌نشده و احتمالاً یک کرش است

بررسی‌ها این موضوع را در یک مسیر وارد کردن به سبک ایمپوزیشن یافتند که یک 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 یا یک آرایه پویا (dynamic array) است. این‌ها فیلدهای مبتنی بر شمارش مرجع (reference-counted) هستند و کامپایلر حسابداری پنهانی را برای حفظ شمارش آن‌ها صادر می‌کند. غریزه‌ای که از C منتقل شده این است که یک رکورد تازه را با FillChar(Result, SizeOf(Result), 0) پاک کنیم. این کار بدون کاهش مقدار مرجع، صفرها را روی مرجع مدیریت‌شده درون رکورد می‌زند. کامپایلر از یک حافظه موقت پنهان برای نتیجه یک تابع در تکرارهای حلقه مجدداً استفاده می‌کند، بنابراین در تکرار دوم FillChar یک اشاره‌گر رشته زنده را که هرگز رها نشده بود رونویسی می‌کند و رشته‌ای که به آن اشاره می‌کرد نشت (leak) می‌کند. اگر تابع را در حلقه‌ای روی هزار حاشیه‌نویسی (annotation) فراخوانی کنید، هزار رشته نشت می‌کند

راه‌حل این است که اجازه دهیم زبان رکورد را به روشی که می‌شناسد، با 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 با شکست مواجه می‌شود

پوشاننده جایی است که چهار قرارداد به صورت دستی بازنویسی می‌شوند

هیچ‌کدام از این پنج نقص عجیب و غریب نیستند. آن‌ها حالت‌های شکست قابل پیش‌بینی یک لایه نازک پاسکال روی یک API به زبان C هستند و به این دلیل جمع می‌شوند که آن لایه دقیقاً جایی است که چهار قرارداد مجزا باید دوباره اعلان شوند. قرارداد فراخوانی در هر کال‌بک باید با cdecl نوشته شود. عرض عدد صحیح باید با size_t در تنها پلتفرم هدفی که واقعاً در آن گسترده می‌شود مطابقت داشته باشد. مدل استثنا باید به کدهای بازگشتی در هر کال‌بکی که از مرز پاسکال عبور می‌کند تبدیل شود. مالکیت هر هندل و هر فیلد مدیریت‌شده باید یک بار مشخص شود و در هر مسیری، از جمله مسیرهای خطایی که هیچ‌کس تا زمان تولید از آن‌ها استفاده نمی‌کند، رعایت شود. هرکدام را از دست بدهید، با نقصی مواجه می‌شوید که علائم آن دور از علت آن ظاهر می‌شود، که این همان چیزی است که این دسته را پرهزینه می‌کند. ارزش این بررسی کمتر در هر اصلاح واحد بود و بیشتر در برخورد با هر یک از این‌ها به عنوان یک نظم خاص برای بررسی در سراسر بایندینگ بود

اگر می‌خواهید به جای محافظت از لبه‌های آن، انجام یک کار واقعی توسط بایندینگ را ببینید، تکنیک‌های کش رندر و زوم در یادداشت ما در مورد عملکرد کش رندر و زوم مسیر رندرینگ را نشان می‌دهد، و راهنمای کامپایل متقاطع در ساخت یک نمایشگر Lazarus و FPC جایی است که رفتار size_t در Win64 که در اینجا توضیح داده شد واقعاً اهمیت پیدا می‌کند. هر دو بر روی همان کار ایمنی حافظه و ABI ساخته شده‌اند که در کامپوننت PDFium برای دلفی، لازاروس و C++Builder، در کنار رندرینگ، استخراج متن و APIهای فرم که در جاهای دیگر این وبلاگ پوشش داده شده‌اند، ارائه می‌شود