مقاله فنی

دلفی در مقابل FPC: ۴ تله پنهان کد PDF در بیلد‌های PDFium

یک سورس یکسان آبجکت پاسکال (Object Pascal) می‌تواند در دلفی و FPC/Lazarus به چهار روش متفاوت رفتار کند که به طور مکرر کدهای کامپوننت PDFium را تحت تاثیر قرار می‌دهد: FPC متغیرهای موقت رکورد خروجی تابع را قبل از پایان خواندن آن‌ها توسط تست عضویت in آزاد می‌کند، کامپایلر dcc32 به طور پیش‌فرض با غیرفعال بودن بررسی محدوده عرضه می‌شود بنابراین اندیس‌های آرایه خارج از محدوده را به صورت خاموش و به عنوان داده‌های زباله می‌خواند، تنها دلفی 13 انتساب یک array of Byte بی‌نام به متغیر TBytes را بدون کست (cast) می‌پذیرد، و الحاق AnsiString در دلفی می‌تواند بایت‌های مساوی یا بالاتر از $80 را از طریق یک تبدیل رفت و برگشت پنهان به صفحه کد (code page) از بین ببرد. هر یک از این موارد مجموعه‌ای را تولید می‌کند که در یک کامپایلر سبز (بدون خطا) و در کامپایلر دیگر قرمز، یا بدتر از آن، به طور پنهانی اشتباه است

اگر برای اولین بار در حال راه‌اندازی یک پروژه با دو کامپایلر هستید، راهنمای گام به گام نمایشگر Lazarus و FPC مسیر هموار را پوشش می‌دهد: بسته‌ها، مسیرهای جستجو و نمایش یک پنجره رندر روی صفحه. اما این مقاله برعکس یک آموزش است. این مقاله‌ای شامل لیستی از مواردی است که پس از کارکردن مسیر هموار با آن‌ها مواجه شدیم، زمانی که CI در FPC سبز بود، در دلفی سبز بود و سپس تغییری که در یک سمت تایید شده بود، در سمت دیگر منفجر شد. هر تله‌ای که در زیر آمده است از یک شکست واقعی در مجموعه تست‌های PDFiumPas یا دموهای آن ناشی می‌شود، که کالبدشکافی سطح کامیت آن به یک بازتولید حداقلی، علت اصلی و اصلاحی که استاندارد کرده‌ایم خلاصه شده است

چرا یک مجموعه در FPC خالی خوانده می‌شود اما در دلفی این‌طور نیست؟

توضیح یک‌جمله‌ای: کامپایلر FPC ممکن است متغیر موقتی را که نتیجه رکورد یک تابع را نگه می‌دارد، قبل از پایان یافتن ارزیابی عبارتی که فیلدی از آن نتیجه را می‌خواند نهایی (آزاد) کند، بنابراین عبارت X in Func().Issues ممکن است عضویت را در یک مجموعه از قبل آزاد شده آزمایش کند در حالی که عبارت معادل در دلفی کار می‌کند. تست‌های انطباق PDF/E ما در اولین نسخه خود با این مشکل مواجه شدند. اعتبارسنج رکوردی را برمی‌گرداند که فیلد Issues آن مجموعه‌ای از پرچم‌های نقض است، و ادعاها (assertions) فراخوانی را به صورت درون‌خطی انجام می‌دادند

// غیرقابل اعتماد در FPC: متغیر موقت رکورد خروجی تابع
// می‌تواند قبل از خوانده شدن Issues توسط تست 'in' آزاد شود
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);

// قابل اعتماد در هر دو کامپایلر: ابتدا نتیجه را به یک متغیر محلی اختصاص دهید
var
  Vr: TPdfEValidationResult;
begin
  Vr := ValidateAnsi(Pdf);
  AssertTrue(pveiLzwUsed in Vr.Issues);
end;

فرم درون‌خطی، مجموعه را در FPC خالی می‌خواند، بنابراین هر ادعایی که انتظار یک پرچم را داشت شکست خورد، در حالی که بیلد مشابه در دلفی پاس شد. علت اصلی تفاوت در نحوه مدیریت طول عمر متغیرهای موقت نتایج توابع در داخل عبارت‌های بزرگتر توسط دو کامپایلر است: دلفی متغیر موقت را تا پایان دستور زنده نگه می‌دارد، در حالی که نحوه حذف رکورد موقت در FPC می‌تواند با عملگر عضویت مجموعه که هنوز در حال خواندن آن است رقابت کند. ما قبلاً همین رفتار را یک بار دیگر در تفسیری روی متد کمکی FlagPresent در واحد تست PDF/A مستند کرده بودیم و در عین حال با نوشتن تست‌های جدید از ابتدا دوباره باگ را ایجاد کردیم، که نشان می‌دهد چقدر فرم خراب طبیعی به نظر می‌رسد. اصلاح کار مکانیکی است و ارزش دارد به عنوان یک قانون کلی پذیرفته شود: هرگز دسترسی به یک فیلد یا تست مجموعه را مستقیماً به فراخوانی تابعی که یک رکورد برمی‌گرداند زنجیره نکنید؛ ابتدا نتیجه را به یک متغیر محلی اختصاص دهید، سپس فیلد را بخوانید. این کار هزینه یک خطی دارد و یک کلاس کامل از ناپایداری‌های وابسته به کامپایلر را حذف می‌کند

چرا دلفی اندیس آرایه‌ای را می‌پذیرد که FPC از کامپایل آن خودداری می‌کند؟

توضیح یک‌جمله‌ای: کامپایلر dcc32 یک اندیس خارج از محدوده را در یک آرایه با محدوده ثابت کامپایل می‌کند و با غیرفعال بودن بررسی محدوده پیش‌فرض خود، در زمان اجرا حافظه مجاور را بدون هیچ خطایی می‌خواند یا می‌نویسد، در حالی که FPC همان اندیس را در زمان کامپایل رد می‌کند. کامپوننت PDFium نقاط چهارضلعی را به عنوان یک آرایه با مبنای 1 تعریف می‌کند، یعنی TQuadrilateralPoint = array [1..4] of TPdfPoint، متناسب با اینکه ورودی‌های QuadPoints در فایل‌های PDF معمولاً چگونه شماره‌گذاری می‌شوند. یک دمو که آن را با حلقه مبتنی بر مبنای صفر پر می‌کرد برای ماه‌ها در دلفی کار می‌کرد

var
  I: Integer;
begin
  for I := 0 to 3 do                       // اشتباه: آرایه [1..4] است
    Data.AttachmentPoints[I] := Corner[I]; // پیش‌فرض dcc32: کامپایل می‌شود، اندیس 0
                                           // به طور خاموش حافظه مجاور را لمس می‌کند
                                           // FPC: خطای بررسی محدوده زمان کامپایل
  for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
    Data.AttachmentPoints[I] := Corner[I - 1];  // در هر دو کامپایلر صحیح است
end;

بیلد دلفی یک نتیجه مثبت کاذب بود: با خاموش بودن بررسی محدوده که پیش‌فرض dcc32 است، اندیس 0 روی هر فیلدی که در رکورد قبل از آرایه قرار دارد می‌نشست و دمو به نظر می‌رسید کار می‌کند. پورت کردن همان دمو برای Lazarus یک خطای بررسی محدوده در زمان کامپایل از طرف FPC ایجاد کرد، و رفع اندیس سپس باگ دوم و عمیق‌تری را در مسیر یادداشت‌گذاری کتابخانه نشان داد که خواندن داده‌های زباله آن را پنهان کرده بود؛ همان موضوعی که در مقاله یادداشت‌گذاری QuadPoints تحلیل شد. دو درس از آن ماجرا حاصل شد: اول، هر زمان که نوع آرایه بر اساس ساختار دارای مبنای صفر نیست، متدهای Low() و High() را بر محدوده‌های صریح ترجیح دهید. دوم، کامپایل با FPC یا حداقل یک بیلد دلفی با فعال بودن {$R+} را به عنوان یک دروازه اجباری برای اولین اجرای هر دمو یا تست جدید در نظر بگیرید: تنظیمات پیش‌فرض dcc32 این کلاس از باگ‌ها را به شما نخواهند گفت، و برنامه‌ای که اجرا می‌شود دلیلی بر صحت عملکرد آن نیست

انتساب TBytes که فقط دلفی ۱۳ می‌پذیرد

توضیح یک‌جمله‌ای: انتساب فیلدی که به عنوان یک array of Byte بی‌نام تعریف شده است به یک متغیر TBytes در دلفی 13 (نسخه کامپایلر 37.0) کامپایل می‌شود، اما در دلفی 12 Athens و تمام نسخه‌های قبلی با خطای E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array' شکست می‌خورد. این مورد یک جدایی بین دلفی و FPC نیست، بلکه تفاوتی بین دلفی و نسخه‌های قبلی خود دلفی است، اما به همان روش کدهای چندکامپایلره را تحت تاثیر قرار می‌دهد: جدیدترین کامپایلر بی‌پرواترین شکل ساختی را که همه کامپایلرهای دیگر رد می‌کنند به آرامی می‌پذیرد

type
  TValidator = class
  private
    FBuffer: array of Byte;   // نوع آرایه پویا بی‌نام
  end;

var
  OrigBytes: TBytes;
begin
  OrigBytes := FBuffer;          // فقط دلفی 13؛ خطای E2010 در دلفی 12
                                 // Athens و نسخه‌های قدیمی‌تر
  OrigBytes := TBytes(FBuffer);  // در همه‌جا کامپایل می‌شود؛ چیدمان بایت یکسان،
                                 // کست سخت‌افزاری ایمن
end;

ما دقیقاً همین مورد را در یک روال اعتبارسنجی که به صورت محلی در دلفی 13 توسعه یافته و آزمایش شده بود ارسال کردیم، جایی که تبدیل ضمنی بدون مشکل پذیرفته شده بود. نصب‌کننده فول‌سورس به تعداد زیادی از کاربران دلفی 12 و نسخه‌های قدیمی‌تر خدمات می‌دهد و برای آن‌ها این واحد به سادگی کامپایل نشد. راه حل ساختاری یا کست سخت‌افزاری نشان داده شده در بالا است که ایمن است زیرا یک array of Byte بی‌نام و TBytes چیدمان آرایه پویای یکسانی دارند، یا بهتر از آن، تعریف فیلد به عنوان یک نوع نامگذاری‌شده مانند TBytes در وهله اول است تا اصلاً نیازی به تبدیل نباشد. راه حل فرآیندی اهمیت بیشتری دارد: کدی که روی جدیدترین زنجیره ابزار شما کامپایل می‌شود، هیچ چیز را در مورد کامپایلرهای قدیمی‌تری که کاربران شما واقعاً اجرا می‌کنند ثابت نمی‌کند، و این دسته از رگرسیون‌ها تا زمانی که در برابر هر نسخه پشتیبانی‌شده بیلد نگیرید، نامرئی هستند. اسکریپت‌های انتشار ما اکنون کتابخانه را در تمام ماتریس کامپایلرها کامپایل می‌کنند دقیقاً به این دلیل که بیلد محلی 37.0 نمی‌تواند آسان‌گیری نسخه 13 را شناسایی کند

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

توضیح یک‌جمله‌ای: الحاق یک بایت خام مساوی یا بالاتر از $80 در یک AnsiString با عملگر + می‌تواند به طور خاموش آن بایت را با ? ($3F) در دلفی جایگزین کند، زیرا این عبارت یک مسیر رفت و برگشت ضمنی از AnsiString به UnicodeString و دوباره به AnsiString را از طریق صفحه کد سیستم طی می‌کند. ما این موضوع را از طریق یک تست PDF/A کشف کردیم که نامی حاوی بایت ایزوله $FE را می‌سازد (که هرگز یک بایت شروع معتبر برای UTF-8 نیست) تا تایید کند اعتبارسنج نام‌هایی را که طبق استاندارد ISO 19005-2 بند 6.1.8 دارای UTF-8 معتبر نیستند پرچم‌گذاری می‌نماید

var
  BadName: AnsiString;
begin
  // در دلفی با یک صفحه کد سیستم چندبایتی (در CP936 مشاهده شد)،
  // الحاق از طریق UnicodeString رفت و برگشت می‌کند و بایت $FE که
  // یک دنباله معتبر در CP936 نیست، به عنوان '?' ($3F) بازمی‌گردد
  BadName := '/Bad' + AnsiChar($FE) + 'Name';

  // ایمن: ابتدا با یک متغیر نگهدارنده ASCII بسازید، سپس بایت را در محل اصلاح کنید؛
  // انتساب اندیس‌گذاری شده در یک AnsiString مستقر شده، فرآیند رفت و برگشت را انجام نمی‌دهد
  BadName := '/Bad' + #1 + 'Name';
  BadName[5] := AnsiChar($FE);
end;

در یک سیستم ویندوز چینی که با صفحه کد 936 کار می‌کند، رشته الحاق شده هرگز شامل $FE نبود، بنابراین کتابخانه به درستی هیچ چیزی را گزارش نکرد و تست قرمز شد در حالی که به نظر می‌رسید یک باگ کتابخانه است. اما کتابخانه هرگز اشتباه نمی‌کرد: یک هارس FPC که فایل PDF حاوی بایت $FE واقعی را دریافت می‌کرد، پرچم مورد انتظار را دریافت نمود. خرابی در داخل فایل اجرایی تست دلفی در حین ارزیابی عبارت رشته‌ای رخ داد، زیرا مدل رشته‌ای اولویت‌دار یونیکد در دلفی، عبارت‌های ترکیبی AnsiString را از طریق UnicodeString تبدیل می‌کند، و بایت $FE یک بایت شروع معتبر در CP936 نیست بنابراین تبدیل رفت و برگشت آن را جایگزین می‌کند. درباره این محدودیت واقع‌بین باشیم: در صفحه کد غربی تک‌بایتی مانند CP1252 همان عبارت معمولاً زنده می‌ماند، که دقیقاً به همین دلیل این باگ در اکثر ماشین‌های توسعه پنهان می‌شود و تنها در سیستم‌های آسیای شرقی یا رانرهای CI محلی شده ظاهر می‌گردد. قانونی که ما پذیرفتیم: هرگز وکتورهای تست باینری حاوی بایت‌های مساوی یا بالاتر از $80 را از طریق الحاق AnsiString نسازید؛ یا بایت‌ها را در محل پس از استقرار رشته اصلاح کنید (مانند بالا) یا وکتور را از ابتدا در TBytes از ابتدا بسازید

آنچه یک جریان کاری با دو کامپایلر باید به طور پیش‌فرض بررسی کند

چهار تله، یک الگو: هر کامپایلر درباره زیرمجموعه متفاوتی از باگ‌ها به شما می‌گوید. آنالیز محدوده زمان کامپایل FPC یک اندیس خارج از محدوده را شناسایی کرد که dcc32 ماه‌ها آن را بی‌صدا اجرا می‌کرد، و مدل رشته یونیکد dcc32 وابستگی صفحه کد را آشکار کرد که یک بیلد خالص FPC مبتنی بر بایت هرگز آن را ایجاد نمی‌کرد. نتیجه عملی این است که هیچ‌کدام از خطوط لوله سبز به تنهایی کافی نیستند. کامپایل متقابل فقط یک چک‌باکس قابلیت پورت نیست، بلکه یک تحلیل‌گر استاتیک دوم و یک مدل زمان اجرای دوم است که روی یک سورس اعمال می‌شود، با همان روحیه بررسی‌های مرزی تدافعی در مقاله مقاوم‌سازی حافظه و ABI

قوانین ثابتی که از این حوادث حاصل شدند به اندازه کافی کوتاه هستند که حفظ شوند: رکوردهای نتایج توابع را قبل از خواندن فیلدها به یک متغیر محلی اختصاص دهید. آرایه‌های با محدوده ثابت را با Low() و High() پیمایش کنید، و قبل از اعتماد به هر دموی جدید، حداقل یک بیلد FPC یا با بررسی محدوده فعال اجرا نمایید. فیلدهای آرایه پویا بی‌نام را صراحتاً کست کنید، یا آن‌ها را با انواع نام‌گذاری شده تعریف نمایید، و قبل از انتشار، ماتریس کامل کامپایلرها را بیلد کنید. بایت‌های خام بالا را کاملاً از الحاق AnsiString دور نگه دارید. هیچ‌کدام از این موارد پس از تبدیل شدن به عادت، هزینه قابل اندازه‌گیری ندارند، و هر کدام حالت شکستی را می‌بندند که یک جریان کاری با یک کامپایلر از نظر ساختاری نمی‌تواند ببیند

هر چهار مشکل در حین نگهداری از کامپوننت PDFium پیدا و اصلاح شدند، که همان سورس آبجکت پاسکال را برای دلفی، C++Builder و FPC/Lazarus ارائه می‌دهد و مجموعه‌های سازگاری و رگرسیون خود را روی تک‌تک آن زنجیره ابزارها اجرا می‌کند، بنابراین تله‌های موجود در این مقاله به جای حافظه توسط تست‌ها محافظت می‌شوند