Technical Article

הקשחת קישור PDFium VCL: תאימות ABI ובטיחות זיכרון

קישור (binding) של Pascal מעל ספריית C נקרא כמו Pascal רגיל. אתה קורא למתודה, מקבל רשומה (record) בחזרה, ומשחרר את מה שהקצית. הבעיה היא ש-PDFium היא ספריית C ו-C++ עם מוסכמות קריאה משלה, רוחבי שלמים משלה וחוקים משלה לגבי מי הבעלים של הזיכרון ומי משחרר אותו. שום דבר מזה אינו חוצה את גבול השפה מעצמו. כל אחד מהחוזים הללו חייב להיות מוגדר מחדש ידנית בהצהרות ה-Pascal, ומילה אחת שגויה הופכת קריאה שנראית נקייה להשחתת מחסנית, היסט קטוע (truncated offset) או שחרור כפול (double free). ביקורת בגרסה v1.61.0 של קישור PDFium VCL חשפה פגם אחד מכל סוג. כדאי לעבור עליהם מכיוון שהם אינם ספציפיים לקישור זה. הם הסכנות הקבועות בעטיפת כל ממשק C API ב-Delphi או ב-Lazarus.

cdecl הוא חלק מטיפוס הפונקציה, לא קישוט

PDFium הוא קוד C מקומפל. ב-Win32 הייצואים שלו, ויותר חשוב, ה-callbacks שהוא מפעיל משתמשים במוסכמת הקריאה cdecl. תחת cdecl הקורא מנקה את המחסנית לאחר שהקריאה חוזרת. ברירת המחדל הטבעית של Delphi היא register, ותקן ה-C של Win32 עבור callbacks הוא stdcall בחלק מהספריות, שבהן הנקרא מנקה במקום זאת. כאשר מבנה מוסר ל-PDFium פוינטר לפונקציה ואתה שוכח את ה-cdecl בטיפוס של אותו פוינטר, שני הצדדים אינם מסכימים לגבי מי מתאים את פוינטר המחסנית. שניהם מתקנים אותו, או שאף אחד מהם לא עושה זאת, ופוינטר המחסנית נודד בגודל הארגומנטים בכל קריאה.

הסיבה שקשה למצוא את הפגם הזה היא שהנזק אינו מקומי. הקריאה המושחתת חוזרת ונראית בסדר. חוסר היישור מופיע מאוחר יותר, בפונקציה לא קשורה כלשהי שהמסגרת שלה יושבת כעת על פוינטר מחסנית שהוא כמה בתים מחוץ למקום, וזה מתבטא כקריאה שגויה, כתובת חזרה רעה או קריסה עם backtrace שמצביע רחוק מאוד מה-callback שבו באמת טעית. מילוי טפסים (Form-fill) הוא המקום הקלאסי שבו זה פוגע, מכיוון שממשק מילוי הטפסים הוא רשומה מלאה ב-callbacks ש-PDFium מפעיל בחזרה. אחד מהם, FFI_OpenFile, מוסר ל-PDFium פונקציה שהוא יקרא לה כדי לפתוח קובץ חיצוני, המוצהרת כ-function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. ה-cdecl שבסוף הוא החלק שחשוב להעתיק. השמט אותו והקוד עדיין יתקמפל, עדיין יתקשר (link), ועדיין ירוץ ממש עד ש-PDFium יקרא לפונקציה. המוסכמה שייכת לטיפוס הפונקציה עצמו. זה לא סוכר אופציונלי, והקומפיילר לא יזהיר אותך כאשר הוא חסר מכיוון שטיפוס פונקציה פשוט הוא טיפוס Pascal חוקי לחלוטין. ההגנה היחידה היא להתייחס למוסכמת הקריאה כשדה חובה של כל חתימה מיובאת וכל callback שאתה מעביר החוצה.

size_t הוא ברוחב פוינטר, וב-FPC Win64 זה אומר 64 סיביות

הפגם השני הוא חוסר התאמה ברוחב השלמים שמופיע רק ביעד אחד. ה-size_t של C מוגדר להיות רחב מספיק כדי להחזיק כל גודל אובייקט, שבפלטפורמת 64 סיביות פירושו שלם בלתי חתום של 64 סיביות. ממשקי הטעינה הפרוגרסיבית של PDFium מדברים בהיסטי בתים של size_t. רשומת ה-FX_FILEAVAIL של ספק הזמינות נושאת callback בשם IsDataAvail callback ש-PDFium מפעיל עם היסט וגודל, וה-callback של 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 וב-Delphi Win64, ואז יישבר בחשאי ב-FPC וב-Lazarus Win64. הגורם לכך הוא עדין. ב-FPC Win64, המשתנה NativeUInt הוא טיפוס 64 סיביות אמיתי ברוחב פוינטר, ו-size_t הוא כינוי (alias) שלו. לקישור יש הערה במקטע הטיפוסים המזהירה בדיוק מפני דריסת NativeUInt ב-FPC, מכיוון שהגדרתו מחדש ככינוי של 32 סיביות שם תאלץ את size_t ל-32 סיביות ותשחית כל פרמטר size_t המועבר לספרייה או נכתב על ידה. היסט של 64 סיביות המגיע לפרמטר של 32 סיביות מאבד את החצי העליון שלו. עבור קובץ קטן כל היסט מתאים ל-32 סיביות ושום דבר אינו לא תקין. עבור קובץ גדול, ברגע שהיסט חוצה את קו הארבעה גיגה-בייט הערך הקטוע מצביע למקום אחר לחלוטין, PDFium שואל אם טווח הבתים הלא נכון זמין, והטעינה הפרוגרסיבית נתקעת או קוראת זבל. הפגם בלתי נראה עד שהקובץ גדול מספיק והיעד הוא זה שבו size_t באמת התרחב.

חריגת Pascal אסור לעולם שתתפתח (unwind) דרך מסגרת C

הסוג השלישי עוסק במודל החריגות, שאין ל-C. כאשר PDFium מפעיל את אחד מה-callbacks שלך, קוד ה-Pascal שלך רץ בתוך מחסנית של מסגרות C ו-C++ שאינן יודעות דבר על מנגנון החריגות של Delphi. אם ה-callback שלך מעורר חריגה ומאפשר לה להתפשט, היא מתפתחת (unwinds) דרך מסגרות שמעולם לא נבנו להתפתח כזו. הניקוי של PDFium עצמו לא ירוץ, המשתנים הפנימיים הקבועים שלו יישארו מעודכנים למחצה, והתהליך נמצא כעת במצב שהספרייה מעולם לא צפתה. החוזה עבור ה-callbacks הללו הוא קוד חזרה, לא חריגה.

שני callbacks הופכים זאת למוחשי. FPDF_FILEWRITE הוא המקלט (sink) ש-PDFium כותב לתוכו מסמך שמור, ו-FPDF_FILEACCESS הוא המקור שממנו הוא קורא מסמך קלט. שניהם ממומשים כאן מעל TStream של Delphi, ושניהם יכולים להיכשל כפי שכל זרם נכשל: הדיסק מתמלא, הזרם נסגר מתחתיך, או שקריאה חורגת מהקצה. ה-callback של הכתיבה עוטף את כתיבת הזרם שלו והופך כל כשל לקוד הכשל של 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 נראה שגוי למתכנת Pascal שהוכשר לעולם לא לבלוע חריגות, וב-Pascal רגיל זה אכן שגוי. בגבול ABI זו הצורה הנכונה, מכיוון שהערך הבטוח היחיד שיש להחזיר לקורא ה-C הוא קוד סטטוס שהוא יודע לפרש. הכשל עדיין מתפשט, פשוט דרך ערך החזרה, והקוד הקורא שמעל הספרייה מציף אותו כ-EPdfError ברגע שהשליטה חוזרת לצד ה-Pascal של הגדר.

שחרור כפול מסתתר בנתיב השגיאה

הפגם הרביעי הוא בעלות (ownership). מזהה (handle) מסמך של PDFium נפתח על ידי הספרייה וחייב להיסגר בדיוק פעם אחת, על ידי FPDF_CloseDocument. הסכנה היא נתיב שגיאה המשחרר מזהה שניקוי שני גם הוא בעליו. דמיין שגרה היוצרת אובייקט עטיפה, מייחסת לו מזהה מסמך שנפתח זה עתה, ולאחר מכן מבצעת הגדרות נוספות שעלולות להיכשל. אם ההגדרה זורקת חריגה, מטפל החזרה המוקדמת שקורא ל-FPDF_CloseDocument על המזהה הגולמי יסגור אותו, ואז הדסטרקטור של אובייקט העטיפה עצמו יסגור אותו שוב כאשר האובייקט ישוחרר. המזהה משוחרר פעמיים, מה שמהווה התנהגות לא מוגדרת (undefined behavior) וסבירות גבוהה לקריסה.

הביקורת מצאה זאת בנתיב ייבוא בסגנון imposition הבונה 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 ישחיתו בשקט. רבות מפונקציות העזר של קישור זה מחזירות רשומה (record) המכילה WideString או מערך דינמי. אלו שדות בעלי ספירת הפניות (reference-counted), והקומפיילר פולט רישום נסתר כדי לשמור על ספירתם. האינסטינקט שמגיע מ-C הוא לנקות רשומה חדשה עם FillChar(Result, SizeOf(Result), 0). פעולה זו חותמת אפסים על ההפניה המנוהלת בתוך הרשומה מבלי להפחית את ספירתה קודם לכן. הקומפיילר עושה שימוש חוזר במשתנה זמני נסתר אחד עבור תוצאת הפונקציה לאורך איטרציות של לולאה, כך שבאיטרציה השנייה FillChar דורס פוינטר מחרוזת חי שמעולם לא שוחרר, והמחרוזת שאליה הוא הצביע מודלפת. קרא לפונקציה בלולאה על פני אלף הערות ותדליף אלף מחרוזות.

התיקון הוא לתת לשפה לנקות את הרשומה בדרך שהיא מכירה, עם 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);

בעיית בעלות קשורה חיה בגבול טעינת הספרייה. קישור זה פותר כמה מאות פוינטרים לפונקציות מתוך ה-DLL של PDFium באמצעות GetProcAddress לאחר LoadLibrary. אם ייצוא נדרש אחד חסר, המצב המקושר חלקית הוא מסוכן: עשרות פוינטרים תקפים, השאר הם nil או מיושנים, וכל קריאה מאוחרת יותר דרך אחד מהם קופצת לתוך מודול שייתכן שכבר נפרק. הקישור מטפל בכך על ידי פריקת הספרייה והרצת ClearAllBindings מלא המאפס כל פוינטר מיובא בחזרה ל-nil בכל פעם שייצוא נדרש נכשל בפתרון. לאחר מכן, שום פוינטר לפונקציה אינו תלוי לתוך מודול שנפרק, וקריאה מאוחרת יותר נכשלת בצורה נקייה עם בדיקת פוינטר-nil במקום להסתעף לקוד ששוחרר.

העטיפה היא המקום שבו ארבעה חוזים מוגדרים מחדש ידנית

אף אחד מחמשת הפגמים הללו אינו אקזוטי. הם מצבי הכשל הצפויים של שכבת Pascal דקה מעל C API, והם מתקבצים יחד מכיוון ששכבה זו היא בדיוק המקום שבו ארבעה חוזים נפרדים חייבים להיות מוצהרים מחדש. מוסכמת הקריאה חייבת להיכתב כ-cdecl בכל callback. רוחב השלם חייב להתאים ל-size_t ביעד היחיד שבו הוא באמת מתרחב. מודל החריגות חייב להמיר לקודי חזרה בכל callback שחוצה אל מחוץ ל-Pascal. הבעלות על כל מזהה (handle) וכל שדה מנוהל חייבת להיות מוצהרת פעם אחת ומכובדת בכל נתיב, כולל נתיבי השגיאה שאף אחד אינו מריץ עד לייצור. פספס כל אחד מהם ותקבל פגם שהסימפטום שלו מופיע רחוק מהגורם לו, וזה מה שהופך את הקטגוריה הזו ליקרה. הערך של הביקורת היה פחות בכל תיקון יחיד ויותר בהתייחסות לכל אחד מאלה כמשמעת משלו שיש לבדוק לאורך כל הקישור.

אם ברצונך לראות את הקישור מבצע עבודה אמיתית במקום להגן על קצוותיו, טכניקות ה-render-cache והזום ב-הערה שלנו על ביצועי render-cache וזום מציגות את נתיב הרינדור, והמדריך לקומפילציה צולבת ב-בניית מציג Lazarus ו-FPC הוא המקום שבו התנהגות ה-size_t של Win64 המתוארת כאן באמת משנה. שניהם בונים על אותה עבודת בטיחות זיכרון ו-ABI שנשלחת בתוך PDFium Component עבור Delphi, Lazarus ו-C++Builder, לצד ממשקי ה-API לרינדור, חילוץ טקסט וטפסים המכוסים במקומות אחרים בבלוג זה.