מאמר טכני

Delphi מול FPC: 4 מלכודות קוד PDF נסתרות בבניות PDFium

אותו קוד מקור של Object Pascal יכול להתנהג באופן שונה תחת דלפי (Delphi) ותחת FPC/Lazarus בארבע דרכים המכשילות שוב ושוב את קוד רכיב PDFium: מנוע FPC משחרר משתני רשומות זמניים המוחזרים מפונקציות לפני שבדיקת חברות של in מסיימת לקרוא אותם, dcc32 מגיע כברירת מחדל עם בדיקת טווחים כבויה כך שאינדקסים מחוץ לגבולות המערך קוראים זבל בשקט, רק Delphi 13 מקבל הקצאה של array of Byte אנונימי ל-TBytes ללא המרה (cast), ושרשור AnsiString בדלפי עלול להרוס בתים בערך $80 ומעלה דרך סבב נסתר של דף קוד (code-page round-trip). כל אחד מאלה מייצר ערכת בדיקות שהיא ירוקה בקומפיילר אחד ואדומה, או גרוע מכך, שגויה בשקט, בקומפיילר השני

אם אתם מגדירים פרויקט דו-קומפיילר בפעם הראשונה, המדריך למציג של Lazarus ו-FPC מכסה את הנתיב המוצלח: חבילות, נתיבי חיפוש והצגת חלון תצוגה על המסך. מאמר זה הוא ההפך ממדריך. הוא רשימת הדברים שנתקלנו בהם לאחר שהנתיב המוצלח עבד, כאשר ה-CI היה ירוק תחת FPC, ירוק תחת דלפי, ואז שינוי שעבר בצד אחד התפוצץ בצד השני. כל מלכודת להלן מגיעה מכשל אמיתי בערכת הבדיקות של PDFiumPas או בדמויים שלה, כאשר ניתוח האירוע ברמת ה-commit מתומצת לשחזור מינימלי, הגורם הבסיסי והתיקון שעבר סטנדרטיזציה

מדוע קבוצה (set) נקראת כריקה תחת FPC אך לא בדלפי?

הגרסה במשפט אחד: FPC עלול לסיים (finalize) את המשתנה הזמני המחזיק בתוצאת הרשומה של פונקציה לפני שביטוי הקורא שדה של תוצאה זו הסתיים, כך ש-X in Func().Issues יכול לבדוק חברות מול קבוצה שכבר שוחררה, בעוד שהביטוי המקביל בדלפי עובד. בדיקות תאימות ה-PDF/E שלנו נתקלו בכך בגרסתן הראשונה. המאמת מחזיר רשומה ששדה ה-Issues שלה הוא קבוצה של דגלי הפרה, וה-assertions שילבו את הקריאה בגוף הקוד

// Unreliable under FPC: the function-result record temporary
// can be released before the 'in' test reads Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);

// Reliable on both compilers: pin the result to a local first
var
  Vr: TPdfEValidationResult;
begin
  Vr := ValidateAnsi(Pdf);
  AssertTrue(pveiLzwUsed in Vr.Issues);
end;

הצורה המשולבת קראה את הקבוצה כריקה תחת FPC, כך שכל assertion שציפה לדגל נכשל, בעוד שהבנייה הציפית בדלפי עברה בהצלחה. גורם השורש הוא הבדל באופו שבו שני הקומפיילרים מנהלים את אורך החיים של משתנים זמניים המוחזרים מפונקציות בתוך ביטויים גדולים יותר: דלפי שומר על המשתנה הזמני חי עד סוף המשפט, בעוד שהשחרור של הרשומה הזמנית ב-FPC עלול להתחרות עם אופרטור החברות של הקבוצה שעדיין קורא אותה. כבר תיעדנו את אותה התנהגות פעם אחת בעבר, בהערה על עזר ה-FlagPresent ביחידת בדיקות ה-PDF/A, ואז הצגנו מחדש את הבאג בכל זאת בעת כתיבת בדיקות חדשות מאפס, מה שמספר לכם עד כמה הצורה השבורה נראית טבעית. התיקון הוא מכני וראוי לאימוץ ככלל גורף: לעולם אל תשרשרו גישה לשדה או בדיקת קבוצה ישירות לקריאת פונקציה המחזירה רשומה; הקצו את התוצאה למשתנה מקומי תחילה, ואז קיראו את השדה. זה עולה שורה אחת ומסיר מחלקה שלמה של חוסר יציבות התלוי בקומפיילר

מדוע דלפי מקבל אינדקס מערך ש-FPC מסרב לקמפל?

הגרסה במשפט אחד: dcc32 מקמפל אינדקס מחוץ לטווח לתוך מערך בעל גבולות קבועים ובעזרת בדיקת טווחים כבויה כברירת מחדל, קורא או כותב זיכרון סמוך בזמן ריצה ללא כל שגיאה, בעוד ש-FPC פוסל את אותו אינדקס בזמן קומפילציה. רכיב PDFium מצהיר על נקודות מרובע כמערך מבוסס 1 (1-based), כלומר TQuadrilateralPoint = array [1..4] of TPdfPoint, התואם לאופן שבו ערכי QuadPoints של PDF ממוספרים בדרך כלל. דמו שמילא אותו בלולאה מבוססת 0 עבד במשך חודשים תחת דלפי

var
  I: Integer;
begin
  for I := 0 to 3 do                       // wrong: the array is [1..4]
    Data.AttachmentPoints[I] := Corner[I]; // dcc32 default: compiles, index 0
                                           // silently touches adjacent memory
                                           // FPC: compile-time range check error
  for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
    Data.AttachmentPoints[I] := Corner[I - 1];  // correct on both compilers
end;

הבנייה בדלפי הייתה false positive: כאשר בדיקת הטווחים כבויה, שזהו מחדל המערכת ב-dcc32, אינדקס 0 נחת על שדה כלשהו הקודם למערך ברשומה, והדמו נראה כרץ. העברת אותו דמו ל-Lazarus ייצרה מיד שגיאת בדיקת טווח בזמן קומפילציה מצד FPC, ותיקון האינדקס חשף אז באג שני ועמוק יותר בנתיב ההערות של הספרייה שקריאות הזבל מסכו, זה המנותח במאמר על הערות QuadPoints. שני לקחים עלו מהאירוע. הראשון, העדיפו את Low() ו-High() על פני גבולות מילוליים בכל פעם שסוג המערך אינו מבוסס 0 מעצם בנייתו. השני, התייחסו לקומפילציית FPC, או לפחות לבניית דלפי אחת עם {$R+} פעיל, כאל שער חובה להרצה ראשונה של כל דמו או בדיקה חדשים: ברירות המחדל של dcc32 לא יספרו לכם על מחלקת באגים זו, ותוכנית שרצה אינה הוכחה לכך שהיא נכונה

הקצאת TBytes שרק Delphi 13 מקבל

הגרסה במשפט אחד: הקצאת שדה המוצהר כ-array of Byte אנונימי למשתנה TBytes מתקמפלת ב-Delphi 13 (גרסת קומפיילר 37.0) אך נכשלת ב-Delphi 12 Athens ובכל גרסה מוקדמת יותר עם E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. מקרה זה אינו פיצול Delphi מול FPC אלא פיצול של Delphi מול עברה שלה, אך הוא פוגע באותו קוד מרובה קומפיילרים באותו אופן: הקומפיילר החדש ביותר מקבל בשקט מבנה שכל השאר פוסלים

type
  TValidator = class
  private
    FBuffer: array of Byte;   // anonymous dynamic array type
  end;

var
  OrigBytes: TBytes;
begin
  OrigBytes := FBuffer;          // Delphi 13 only; E2010 on Delphi 12
                                 // Athens and earlier
  OrigBytes := TBytes(FBuffer);  // compiles everywhere; same byte layout,
                                 // safe hard cast
end;

שלחנו בדיוק את זה בשגרת אימות, שפותחה ונבדקה מקומית ב-Delphi 13, שבה ההמרה המשתמעת התקבלה בשקט. מתקין הקוד המלא משרת משתמשים ב-Delphi 12 ומטה במספרים גדולים, ועבורם היחידה פשוט לא התקמפלה. התיקון המבני הוא המרה קשיחה (hard cast) כפי שמוצג לעיל, שהיא בטוחה מכיוון ש-array of Byte אנונימי ו-TBytes חולקים מבנה מערך דינמי זהה, או עדיף מכך, הצהרה על השדה כסוג בעל שם כגון TBytes מלכתחילה כך ששום המרה לא תידרש. תיקון התהליך חשוב יותר: מבנה המתקמפל בשרשרת הכלים החדשה ביותר שלכם אינו מוכיח דבר על קומפיילרים ישנים יותר שהמשתמשים שלכם מריצים בפועל, ועבורם התיקון המצוין נדרש

בית ה-AnsiString שנעלם במכונת Windows סינית

הגרסה במשפט אחד: שרשור בית גולמי בערך $80 ומעלה לתוך AnsiString באמצעות + עלול להחליף בשקט בית זה ב-? ($3F) תחת דלפי, מכיוון שהביטוי לוקח סבב משתמע של AnsiString ל-UnicodeString ל-AnsiString דרך דף הקוד של המערכת. מצאנו זאת דרך בדיקת PDF/A הבונה שם המכיל בית $FE מבודד, שלעולם אינו בית מוביל תקין של UTF-8, כדי לאמת שהמאמת מסמן שמות שאינם UTF-8 תקינים לפי תקן ISO 19005-2 סעיף 6.1.8

var
  BadName: AnsiString;
begin
  // On Delphi with a multi-byte system code page (observed on CP936),
  // the concatenation round-trips through UnicodeString and $FE, which
  // is not a valid CP936 sequence, comes back as '?' ($3F)
  BadName := '/Bad' + AnsiChar($FE) + 'Name';

  // Safe: build with an ASCII placeholder, then patch the byte in place;
  // indexed assignment into a settled AnsiString does not round-trip
  BadName := '/Bad' + #1 + 'Name';
  BadName[5] := AnsiChar($FE);
end;

במערכת Windows סינית המריצה את דף הקוד 936, המחרוזת המשורשרת לא הכילה $FE כלל, ולכן הספרייה דיווחה בצדק על שום דבר והבדיקה הפכה לאדומה תוך שהיא נראית כמו באג של הספרייה. הספרייה מעולם לא טעתה: הרצת FPC שהזינה קובץ PDF המכיל באמת את הבית $FE קיבלה את הדגל הצפוי. ההשחתה התרחשה בתוך קובץ הבדיקה של דלפי בזמן שביטוי המחרוזת הוערך, מכיוון שמודל מחרוזות ה-Unicode תחילה של דלפי ממיר ביטויי AnsiString מעורבים דרך UnicodeString, ו-$FE אינו בית מוביל תקין ב-CP936 ולכן הסבב מחליף אותו. היו כנים לגבי הגבול כאן: בדפי קוד מערביים של בית בודד כגון CP1252 אותו ביטוי בדרך כלל שורד, וזו בדיוק הסיבה שהבאג הזה מתחבא ברוב מכונות הפיתוח ומופיע רק במערכות מזרח-אסיאתיות או בשרתי CI מקומיים. הכלל שאימצנו: לעולם אל תבנו וקטורי בדיקה בינאריים המכילים בתים בערך $80 ומעלה באמצעות שרשור AnsiString; או שתתקנו את הבתים במקום לאחר שהמחרוזת התייצבה, כפי שמוצג לעיל, או שתבנו את הווקטור ב-TBytes מלכתחילה

מה שזרימת עבודה של דו-קומפיילר צריכה לבדוק כברירת מחדל

ארבע מלכודות, דפוס אחד: כל קומפיילר מספר לכם על תת-קבוצה שונה של הבאגים שלכם. ניתוח הטווחים בזמן קומפילציה של FPC תפס אינדקס מחוץ לטווח ש-dcc32 הריץ בשקט במשך חודשים, ומדל מחרוזות ה-Unicode של dcc32 חשף תלות בדפי קוד שבניית FPC טהורה וממוקדת בתים אינה מפעילה לעולם. התוצאה המעשית היא שאף אחד מצינורות ה-CI הירוקים אינו מספיק לבדו. קומפילציה צולבת (Cross-compiling) אינה רק תיבת סימון של ניידות, היא מנתח סטטי שני ומותג זמן ריצה שני המוחלים על אותו קוד מקור, באותה רוח של בדיקות הגבול המגננתיות במאמר על הקשחת אבטחת זיכרון ו-ABI

הכללים הקבועים שנבעו מאירועים אלה קצרים מספיק לשינון. הצמידו רשומות המוחזרות מפונקציות למשתנה מקומי לפני קריאת שדות. בצעו איטרציה של מערכים בעלי גבולות קבועים באמצעות Low() ו-High(), והריצו לפחות בנייה אחת עם בדיקת טווחים או בניית FPC לפני שתסמכו על דמו חדש. בצעו המרה מפורשת (cast) של שדות מערכים דינמיים אנונימיים, או הצהירו עליהם כסוגים בעלי שם, ובנו את מטריצת הקומפיילרים המלאה לפני השחרור. שימרו על בתים גבוהים גולמיים מחוץ לשרשור AnsiString לחלוטין. אף אחד מאלה אינו עולה מאמץ מדיד ברגע שהם הופכים להרגלים, וכל אחד מהם סוגר מצב כשל שזרימת עבודה של קומפיילר יחיד מבחינה מבנית אינה יכולה לראות

כל ארבע הבעיות נמצאו ותוקנו במהלך התחזוקה של רכיב PDFium, המספק את אותו קוד מקור של Object Pascal עבור Delphi, C++Builder ו-FPC/Lazarus ומריץ את ערכות התאימות והנסיגה שלו בכל אחת משרשראות הכלים הללו, כך שהמלכודות במאמר זה מוגנות על ידי בדיקות ולא על ידי זיכרון