מאמר טכני

קריאת קבצי OLE2 Compound ב-Delphi בלי COM IStorage

HotXLS Excel Library עבור Delphi ו-C++Builder קורא וכותב את מכולת Compound File Binary שמאחורי כל קובץ .xls ישן ב-Object Pascal טהור. המחלקה TlxCompoundFile מממשת את פריסת [MS-CFB] גרסה 3 ישירות מול TStream — כותרת, DIFAT, שרשראות FAT, MiniFAT ועץ הספרייה — ללא ole32.dll וללא COM IStorage בשום מקום בנתיב

זה נשמע כמו אינסטלציה (plumbing), ובמשך עשרים שנה זו הייתה אינסטלציה ששייכת למישהו אחר. כל בסיס קוד Delphi שנגע בקובץ .xls פנה ל-StgOpenStorage, קיבל בחזרה IStorage, ומשך את זרם ה-Workbook ממנו. שלוש שורות, עבד בסדר, אף אחד לא חשב על זה שוב — עד היום שבו אותו קוד היה צריך לרוץ במקום שבו לא היה Windows

מדוע StgOpenStorage מפסיק לעבוד בשרת?

ה-API של COM structured-storage נכשל בדיוק בצורות הפריסה שבהן קוד Delphi מודרני חי, מסיבות שאין להן שום קשר לפורמט הקובץ. StgOpenStorage היא נקודת כניסה Win32 ב-ole32.dll: היא רוצה נתיב במערכת קבצים, היא רוצה ש-COM יאותחל ב-thread הקורא, והיא רוצה להיות ב-Windows. דרישת הנתיב פוגעת ראשונה, כי נקודת קצה REST שמקבלת חוברת עבודה (workbook) שהועלתה יש לה את הבייטים ב-buffer, לא בדיסק — כך שכותבים את ה-buffer לקובץ זמני, פותחים אותו, קוראים אותו בחזרה, מוחקים אותו, ועכשיו יש לכם מחזור חיים של קובץ זמני שאפשר לטעות בו תחת עומס. ILockBytes הוא פתח המילוט המתועד, אך חיווט מימוש מותאם אישית מעל TMemoryStream הוא יותר COM interop ממה שרוב הצוותים רוצים. דרישת האתחול נושכת שנייה, בדרך כלל ב-thread עובד שירות שאף אחד לא קרא בו ל-CoInitialize, ודרישת הפלטפורמה מסיימת את השיחה ברגע שהיעד הוא Linux תחת FPC, תמונת container, או macOS. HotXLS לכן שומר על נתיב lxOLE הקלאסי הבנוי על StgOpenStorage כברירת מחדל, מכיוון שהוא מוכח-קרב וקוראים קיימים לא צריכים לשנות; TlxCompoundFile הוא החלופה ה-opt-in עבור כל השאר

מה הכותרת ושרשראות ה-FAT באמת אומרות

512 הבייטים הראשונים של קובץ compound עונים על כל שאלה מבנית שצריך לפני קריאת בייט מטען אחד. [MS-CFB] §2.2 קובע את חתימת הכותרת ב-offset 0 כשמונת הבייטים D0 CF 11 E0 A1 B1 1A E1, ו-lxIsCompoundStream בודק בדיוק את זה, משחזר את מיקום ה-stream אחר כך כך שקורא יכול להריח בלי להפריע לכלום. עוד ארבעה שדות קובעים את הגיאומטריה: סדר בייטים (byte order) ב-0x1C חייב להיות 0xFFFE, שמשמש גם כבדיקת חתימה שנייה זולה; sector shift ב-0x1E נותן את גודל הסקטור כ-1 shl SectorShift, כך שגרסה 3 משתמשת ב-shift 9 עבור סקטורים בני 512 בייט וגרסה 4 משתמשת ב-shift 12 עבור 4096; mini sector shift ב-0x20 הוא 6, מה שהופך mini sectors ל-64 בייט; וגבול ה-mini stream ב-0x38 הוא 4096. אריתמטיקת הכתובות שבאה אחר כך היא המקום הנפוץ ביותר לטעות בו. סקטור 0 מתחיל מיד אחרי הכותרת, כך שסקטור N מתחיל ב-byte offset 512 + N * SectorSize — שימו לב ל-512 המילולי, לא SectorSize. בקובץ גרסה 3 השניים זהים והבאג מתחבא לנצח; בקובץ גרסה 4 הוא קורא בשקט את הסקטור הלא נכון, וזו הסיבה ש-HotXLS שומר את זה בפונקציה אחת, SidToOffset

קובץ compound הוא מערכת קבצים FAT בתוך קובץ, כך שקריאתו אומרת מעבר על רשימות מקושרות של מזהי סקטור שבהן FAT[n] מחזיק את המזהה שבא אחרי סקטור n. שלושה sentinels מסיימים או מסמנים שרשרת — ENDOFCHAIN, FATSECT עבור סקטור ששייך ל-FAT עצמו, ו-DIFSECT עבור סקטור DIFAT — ושלושתם נקראים כמספרים שלמים חתומים 32-סיביתיים שליליים, מה שמשאיר את תנאי הלולאה פשוטים. מציאת ה-FAT זקוקה לעקיפות נוספת אחת: ה-DIFAT הוא מערך מזהי הסקטור שאומר איפה סקטורי ה-FAT חיים, ו-109 הרשומות הראשונות שלו יושבות בכותרת ב-offset 0x4C. TlxCompoundFile עובר על 109 אלה, עוצר ברשומה השלילית הראשונה, ומשרשר כל סקטור FAT למערך Integer שטוח אחד. זה 109 סקטורי FAT ב-128 רשומות כל אחד על סקטור בן 512 בייט, אז כ-13,952 סקטורים בני-כתובת, אז בערך 6.8 MiB של מכולה לפני שה-DIFAT חייב להישפך לתוך שרשרת משלו

טבלת ההקצאה השנייה קיימת כי סקטורים בני 512 בייט מבזבזים את רוב המקום שלהם על streams קטנים. כל stream מתחת לגבול 4096 הבייט לא מאוחסן בסקטורים בכלל: הוא חי בתוך ה-mini stream, שהוא בעצמו stream רגיל התלוי מרשומת ספריית השורש, מחולק ל-mini sectors בני 64 בייט ומשורשר דרך MiniFAT מקביל ששורשו ב-offset הכותרת 0x3C. פותחים קובץ .xls אמיתי וזרם ה-Workbook יושב על ה-FAT הרגיל בעוד ה-streams של מידע-הסיכום יושבים למטה במרחב ה-mini-sector, וזו הסיבה שמימוש שמכסה רק את נתיב ה-FAT נראה עובד עד שהוא זקוק ל-metadata של המסמך. הספרייה היא המבנה השלישי וזה שהופך את המכולה לניתנת לניווט: כל רשומה היא בדיוק 128 בייט, ארבע לכל סקטור בן 512 בייט, נושאת שם UTF-16 ב-64 הבייטים הראשונים, אורך הבייט שלו ב-0x40, סוג האובייקט ב-0x42 (1 = storage, 2 = stream, 5 = root), קישורי עץ ב-0x44, 0x48 ו-0x4C, סקטור ההתחלה ב-0x74 וגודל ה-stream בן 32 הסיביות ב-0x78. אורך השם הזה סופר בייטים כולל ה-null הסוגר, כך שספירת התווים היא NameLen div 2 - 1, ולטעות בה באחד היא הדרך שבה נגמרים עם stream בשם Workboo

משיכת זרם Workbook מתוך buffer זיכרון

TlxCompoundFile.OpenStream מסתירה את כל מה שלמעלה מאחורי קריאה אחת שלוקחת שם stream ומחזירה TlxCfbStream המחזיק את הבייטים שהומחשו במלואם. כל הרצף — הרחה, טעינה, חילוץ — רץ מול TBytesStream ושום דבר לא נוגע בדיסק

uses
  Classes, SysUtils, lxCompoundFile;

function ExtractBiffPayload(const Blob: TBytes): TBytes;
var
  Src: TBytesStream;
  Cfb: TlxCompoundFile;
  Wb: TlxCfbStream;
begin
  SetLength(Result, 0);
  Src:= TBytesStream.Create(Blob);
  try
    if not lxIsCompoundStream(Src) then
      Exit;                            // not a CFB container at all
    Cfb:= TlxCompoundFile.Create;
    try
      Cfb.LoadFromStream(Src);         // header, FAT, directory, MiniFAT
      Wb:= Cfb.OpenStream('Workbook'); // BIFF8
      if Wb = nil then
        Wb:= Cfb.OpenStream('Book');   // BIFF5 / BIFF7
      if Wb <> nil then
      try
        Result:= Wb.Data;
      finally
        Wb.Free;
      end;
    finally
      Cfb.Free;
    end;
  finally
    Src.Free;
  end;
end;

שני פרטים שם שווים ציון. LoadFromStream לוקחת דגל AOwnsStream שברירת המחדל שלו False, כך שהקורא שומר על האחריות לגבי ה-stream המקור — מכוון, כי המקרה הנפוץ הוא stream שהאפליקציה כבר מחזיקה בו. ו-OpenStream מחזירה TlxCfbStream שמחזיק עותק משלו של הבייטים, חשוף דרך Data, Size, Read, Seek ו-CopyTo. העותק הזה הוא עלות אמיתית על חוברת עבודה גדולה, וזה המחיר הכן של עיצוב שבו האובייקט המוחזר נשאר תקף אחרי שהמכולה משוחררת. כשחוברת עבודה גדולה מספיק כך שעותק מלא בזיכרון הוא הצורה הלא נכונה לגמרי, הקורא הישיר בזרימה עבור גיליונות אלקטרוניים בגודל חריג היא נקודת הכניסה הטובה יותר

מדוע XLSX מוצפן נראה כמו קובץ XLS?

כי הוא כזה, ברמת המכולה — וזה התגמול המעשי של החזקת השכבה הזו. פותחים .xlsx מוצפן בעורך הקסדצימלי ושמונת הבייטים הראשונים הם D0 CF 11 E0 A1 B1 1A E1, זהים בייט-בבייט ל-.xls מוצא-1997, כי הצפנת [MS-OFFCRYPTO] לא מצפינה את חבילת ה-ZIP במקום: היא עוטפת את כל החבילה בתוך מכולת CFB כ-stream בשם EncryptedPackage, לצד stream של EncryptionInfo שמתאר את האלגוריתם. החתימה לכן מזהה את המכולה ולא אומרת דבר על המטען. להבחין בין חוברת עבודה BIFF לחבילת OOXML מוצפנת פירושו לקרוא את הספרייה, שאחרי LoadFromStream היא סריקה על פני EntryCount ו-Entries, או זוג בדיקות HasStream

type
  TCfbPayload = (cpUnknown, cpBiffWorkbook, cpEncryptedOoxml);

function ClassifyContainer(AStream: TStream): TCfbPayload;
var
  Cfb: TlxCompoundFile;
  E: TlxCfbEntry;
  I: Integer;
begin
  Result:= cpUnknown;
  Cfb:= TlxCompoundFile.Create;
  try
    Cfb.LoadFromStream(AStream);
    for I:= 0 to Cfb.EntryCount - 1 do
    begin
      E:= Cfb.Entries(I);
      if E.EntryType <> cfbStream then
        Continue;
      if E.Name = 'EncryptedPackage' then
        Result:= cpEncryptedOoxml
      else if (E.Name = 'Workbook') or (E.Name = 'Book') then
        Result:= cpBiffWorkbook;
    end;
  finally
    Cfb.Free;
  end;
end;

שמות ספרייה ראויים לאזהרה משלהם: ה-streams של מידע-הסיכום נושאים תו בקרה 0x05 מוביל בשמותיהם, כך שהשוואה שנכתבה מול מחרוזת תצוגה רגילה לעולם לא תתאים להם ושורת יומן נאיבית מרנדרת אותם כזבל. כל מה שבמורד הזרם מהסיווג הזה — גזירת המפתח, בדיקת מאמת הסיסמה — היא בעיה נפרדת, מכוסה בהערות על מדוע Excel דוחה חוברת עבודה שהוצפנה עם מצב צופן שגוי. שכבת המכולה רק אומרת לכם מול איזו דלת אתם עומדים

כתיבת מכולה ש-Excel באמת יפתח

צד הכתיבה של TlxCompoundFile צר יותר בכוונה מצד הקריאה, והבנת הסיבה חוסכת ויכוח עם המפרט. [MS-CFB] מתיר מרחב עצום של מכולות תקינות: storages רב-רמתיים, עצי ספרייה red-black מאוזנים כראוי, mini streams, שרשראות DIFAT. Excel פולט פינה קטנה של המרחב הזה וקורא אחת קצת יותר גדולה. HotXLS כותב פינה עוד יותר קטנה — המינימום ש-Excel מוכיח שהוא טוען. כל stream הולך על ה-FAT הרגיל ללא נתיב mini-stream, מה שעולה שטח דיסק וקונה נכונות: stream סיכום בן 300 בייט ש-Excel היה אורז לחמישה mini sectors בני 64 בייט תופס במקום זאת סקטור מלא בן 512 בייט, ועבור חוברת עבודה זה רעש לעומת תחזוקת טבלת הקצאה שנייה, מעבר שרשרת שני ו-stream רשומת השורש שתומך בו בנתיב הכתיבה. רשומות ספרייה יוצרות שרשרת אחים שטוחה תחת השורש עם כל צומת צבוע שחור, וסדר הפליטה קבוע: placeholder כותרת, סקטורי נתוני stream, סקטורי ספרייה, סקטורי FAT, ואז seek בחזרה לכתוב מחדש את הכותרת עם מזהי הסקטור שידועים רק בסוף. ה-FAT קובע את גודלו דרך לולאת נקודה-קבועה קצרה, כי הוספת סקטורי FAT יכולה לדחוף את ספירת הסקטורים גבוה מספיק כדי לדרוש סקטור FAT נוסף

procedure SaveAsCompoundFile(const Dest: string; const BiffBytes: TBytes);
var
  FS: TFileStream;
  Cfb: TlxCompoundFile;
begin
  FS:= TFileStream.Create(Dest, fmCreate);
  try
    Cfb:= TlxCompoundFile.Create;
    try
      Cfb.CreateNew(FS);                  // v3 header, 512-byte sectors
      Cfb.AddStream('Workbook', BiffBytes);
      Cfb.Save;                           // data -> dir -> FAT -> header
    finally
      Cfb.Free;
    end;
  finally
    FS.Free;
  end;
end;

איפה המימוש נעצר

שלושה גבולות שווים ציון ברור, כי קורא מכולה שמטפל בשקט לא נכון במקרה קצה גרוע יותר מאחד שמעלה חריגה. TlxCompoundFile קורא את 109 רשומות ה-DIFAT השוכנות בכותרת ולא עוקב אחרי שרשרת ה-DIFAT ב-0x44 מעבר אליהן, מה שמכסה מכולה קריאה בערך ב-6.8 MiB על סקטורים בני 512 בייט — הרבה מעל קבצי ה-.xls האמיתיים ש-HotXLS פוגש בשטח, אך תקרה קשיחה בכל זאת, והכותב אוכף את אותה מגבלה במפורש במקום לפלוט מכולה שהוא לא יכול לתאר. שנית, מכולות גרסה 4 עם סקטורים בני 4096 בייט מתאפשרות על ידי אריתמטיקת גודל הסקטור אך לא זה מה שהקוד מכוון אליו, וגודל ה-stream בן 64 הסיביות לא נבדק: HotXLS קורא את 32 הביטים הנמוכים ב-offset 0x78 ומשאיר את המחצית הגבוהה בלי לגעת, מה שנכון עבור גרסה 3 ורק עבור גרסה 3. שלישית, חיפוש רשומה הוא סריקה שטוחה לפי שם על פני רשימת הספרייה במקום מעבר במורד עץ ה-red-black מ-storage הורה, כך שstorages מקוננים נפתרים לפי התנגשות שם ולא לפי נתיב — כל stream שקובץ .xls זקוק לו יושב ברמה העליונה, וזה מה שהופך את העיצוב הפשוט יותר להיות ניתן להגנה, אך קוד שמצפה להפנות ל-SomeStorage/SomeStream לא ימצא אותו

שום דבר מזה לא משנה את מטרת היחידה. החזקת שכבת המכולה הופכת טיפול ב-.xls ל-Object Pascal רגיל: ניתן לניתוח ממערך בייטים, ניתן לבדיקה בלי מערכת קבצים, נייד לכל פלטפורמה שהמהדר מכוון אליה, וחופשי מ-COM apartment. זה גם מבטל את קיצורי ההרחה, כי זיהוי חוברת עבודה עכשיו אומר קריאת הספרייה שלה במקום שמונת הבייטים הראשונים שלה — אותה משמעת מאחורי רשימת שמות גיליונות בלי לפתוח את כל חוברת העבודה

TlxCompoundFile מגיע כחלק מ-HotXLS Excel Component עבור Delphi ו-C++Builder, לצד שכבות ה-BIFF וה-OOXML שיושבות מעליו; דף המוצר נושא את הפניית היחידה המלאה ומטריצת המהדרים הנתמכת