مقاله فنی

خواندن فایل‌های ترکیبی OLE2 در Delphi بدون COM IStorage

مؤلفه HotXLS Excel برای Delphi و C++Builder، ظرف Compound File Binary پشت هر فایل قدیمی .xls را در Object Pascal خالص می‌خواند و می‌نویسد. کلاس TlxCompoundFile چیدمان [MS-CFB] نسخه ۳ را مستقیماً علیه یک TStream پیاده می‌کند — سربرگ، DIFAT، زنجیره‌های FAT، MiniFAT و درخت دایرکتوری — بدون ole32.dll و بدون COM IStorage در هیچ‌جای مسیر

این شبیه لوله‌کشی به‌نظر می‌رسد، و برای بیست سال لوله‌کشی‌ای بود که فرد دیگری مالکش بود. هر پایگاه کد Delphi که به یک فایل .xls دست می‌زد، سراغ StgOpenStorage می‌رفت، یک IStorage پس می‌گرفت، و جریان Workbook را از آن بیرون می‌کشید. سه خط، خوب کار می‌کرد، هیچ‌کس دوباره به آن فکر نمی‌کرد — تا روزی که همان کد باید جایی اجرا می‌شد که ویندوز نبود

چرا StgOpenStorage روی یک سرور کار نمی‌کند؟

API ذخیره‌سازی ساختاریافته COM دقیقاً در همان شکل‌های استقراری که کد Delphi مدرن در آن‌ها زندگی می‌کند شکست می‌خورد، به دلایلی که ربطی به فرمت فایل ندارند. StgOpenStorage یک نقطه ورودی Win32 در ole32.dll است: یک مسیر روی سیستم فایل می‌خواهد، COM مقداردهی‌اولیه‌شده روی رشته فراخوان‌کننده می‌خواهد، و می‌خواهد روی ویندوز باشد. الزام مسیر ابتدا آسیب می‌زند، چون یک نقطه پایانی REST که یک workbook آپلودشده دریافت می‌کند بایت‌ها را در یک بافر دارد، نه روی دیسک — پس شما بافر را به یک فایل موقت می‌نویسید، آن را باز می‌کنید، بازش می‌خوانید، حذفش می‌کنید، و اکنون یک چرخه حیات فایل موقت را دارید که باید زیر بار درست عمل کند. ILockBytes دریچه فرار مستندشده است، اما سیم‌کشی یک پیاده‌سازی سفارشی روی یک TMemoryStream بیش از آن مقدار تعامل COM است که بیشتر تیم‌ها بخواهند. الزام مقداردهی‌اولیه دوم گاز می‌گیرد، معمولاً در یک رشته کارگر سرویس که هیچ‌کس روی آن CoInitialize فراخوانی نکرده، و الزام سکو مکالمه را همان لحظه‌ای که هدف Linux زیر FPC، یک تصویر کانتینر، یا macOS باشد پایان می‌دهد. بنابراین HotXLS مسیر کلاسیک lxOLE ساخته‌شده روی StgOpenStorage را به‌عنوان پیش‌فرض نگه می‌دارد، چون آزموده‌شده-در-میدان است و فراخوان‌کنندگان موجود نباید مجبور به تغییر شوند؛ TlxCompoundFile جایگزین اختیاری برای بقیه است

سربرگ و زنجیره‌های FAT واقعاً چه می‌گویند

۵۱۲ بایت اول یک فایل ترکیبی به هر پرسش ساختاری که پیش از خواندن یک بایت از بار محتوایی نیاز دارید پاسخ می‌دهد. [MS-CFB] §2.2 امضای سربرگ در آفست ۰ را به‌صورت هشت بایت D0 CF 11 E0 A1 B1 1A E1 ثابت می‌کند، و lxIsCompoundStream دقیقاً همین را بررسی می‌کند، و پس از آن موقعیت جریان را بازمی‌گرداند تا یک فراخوان‌کننده بتواند بدون مختل‌کردن چیزی بو بکشد. چهار فیلد دیگر هندسه را تعیین می‌کنند: ترتیب بایت در 0x1C باید 0xFFFE باشد، که به‌عنوان یک بررسی امضای دوم ارزان دوکار می‌کند؛ شیفت سکتور در 0x1E اندازه سکتور را به‌صورت 1 shl SectorShift می‌دهد، پس نسخه ۳ از شیفت ۹ برای سکتورهای ۵۱۲بایتی استفاده می‌کند و نسخه ۴ از شیفت ۱۲ برای ۴۰۹۶؛ شیفت مینی‌سکتور در 0x20 برابر ۶ است، که مینی‌سکتورها را ۶۴بایتی می‌کند؛ و آستانه جریان مینی در 0x38 برابر ۴۰۹۶ است. حساب آدرسی که پس از آن می‌آید رایج‌ترین جای اشتباه‌کردن است. سکتور ۰ بلافاصله پس از سربرگ شروع می‌شود، پس سکتور N در آفست بایتی 512 + N * SectorSize شروع می‌شود — به عدد لفظی ۵۱۲ توجه کنید، نه SectorSize. روی یک فایل نسخه ۳ آن دو یکسان‌اند و باگ برای همیشه پنهان می‌ماند؛ روی یک فایل نسخه ۴ به‌سکوت سکتور اشتباه را می‌خواند، به همین دلیل HotXLS این را در یک تابع نگه می‌دارد، SidToOffset

یک فایل ترکیبی یک سیستم فایل FAT درون یک فایل است، پس خواندنش یعنی پیمایش لیست‌های پیوندی شناسه سکتور که در آن‌ها FAT[n] شناسه پیرو سکتور n را نگه می‌دارد. سه نگهبان یک زنجیره را پایان یا حاشیه‌نویسی می‌کنند — ENDOFCHAIN، FATSECT برای سکتوری متعلق به خود FAT، و DIFSECT برای یک سکتور DIFAT — و هر سه به‌صورت اعداد صحیح ۳۲بیتی علامت‌دار منفی خوانده می‌شوند، که شرط‌های حلقه را ساده نگه می‌دارد. یافتن FAT به یک واسطه دیگر نیاز دارد: DIFAT آرایه شناسه‌های سکتوری است که می‌گوید سکتورهای FAT کجا زندگی می‌کنند، و اولین ۱۰۹ مدخلش در سربرگ در آفست 0x4C می‌نشینند. TlxCompoundFile آن ۱۰۹تا را می‌پیماید، در اولین مدخل منفی متوقف می‌شود، و هر سکتور FAT را در یک آرایه Integer تخت به هم می‌پیوندد. این یعنی ۱۰۹ سکتور FAT با ۱۲۸ مدخل در هرکدام روی یک سکتور ۵۱۲بایتی، پس ۱۳,۹۵۲ سکتور آدرس‌پذیر، پس حدود ۶.۸ مگابایت ظرف پیش از آنکه DIFAT مجبور شود به زنجیره خودش سرریز کند

جدول تخصیص دوم وجود دارد چون سکتورهای ۵۱۲بایتی بیشتر فضایشان را روی جریان‌های کوچک هدر می‌دهند. هر جریان زیر آستانه ۴۰۹۶بایتی اصلاً در سکتورها ذخیره نمی‌شود: درون جریان مینی زندگی می‌کند، خودش یک جریان معمولی که از مدخل دایرکتوری ریشه آویزان است، به مینی‌سکتورهای ۶۴بایتی تقسیم شده و از راه یک MiniFAT موازی که در آفست سربرگ 0x3C ریشه دارد زنجیر شده است. یک .xls واقعی را باز کنید و جریان Workbook روی FAT معمولی می‌نشیند در حالی که جریان‌های اطلاعات خلاصه پایین در فضای مینی‌سکتور می‌نشینند، به همین دلیل یک پیاده‌سازی که فقط مسیر FAT را پوشش می‌دهد به‌نظر درست می‌رسد تا وقتی به فراداده سند نیاز پیدا کند. دایرکتوری سومین ساختار است و آنی که ظرف را قابل‌پیمایش می‌کند: هر مدخل دقیقاً ۱۲۸ بایت است، چهارتا در هر سکتور ۵۱۲بایتی، که یک نام UTF-16 در اولین ۶۴ بایت حمل می‌کند، طول بایتی آن در 0x40، نوع آبجکت در 0x42 (۱ = storage، ۲ = stream، ۵ = root)، پیوندهای درخت در 0x44، 0x48 و 0x4C، سکتور شروع در 0x74 و اندازه ۳۲بیتی جریان در 0x78. آن طول نام بایت‌ها را شامل null پایانی می‌شمارد، پس تعداد کاراکتر برابر NameLen div 2 - 1 است، و یک واحد اشتباه در این‌جا شما را به جریانی به‌نام Workboo می‌رساند

بیرون‌کشیدن یک جریان Workbook از یک بافر حافظه

TlxCompoundFile.OpenStream همه موارد بالا را پشت یک فراخوانی پنهان می‌کند که یک نام جریان می‌گیرد و یک 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 است، پس فراخوان‌کننده مسئولیت جریان منبع را نگه می‌دارد — عمدی است، چون مورد رایج جریانی است که اپلیکیشن از قبل مالکش است. و OpenStream یک TlxCfbStream برمی‌گرداند که مالک کپی خودش از بایت‌هاست، که از راه Data، Size، Read، Seek و CopyTo نمایش داده می‌شود. آن کپی روی یک workbook بزرگ هزینه واقعی است، و این قیمت صادقانه یک طراحی است که در آن آبجکت برگشتی پس از آزادشدن ظرف معتبر می‌ماند. وقتی یک workbook آن‌قدر بزرگ است که یک کپی کامل در حافظه شکل اشتباه است، خواننده مستقیم استریمینگ برای صفحات‌گسترده بیش‌ازحد بزرگ نقطه ورود بهتری است

چرا یک XLSX رمزنگاری‌شده شبیه یک فایل XLS به‌نظر می‌رسد؟

چون در سطح ظرف، همین است — و این پاداش عملی مالکیت آن لایه است. یک .xlsx رمزنگاری‌شده را در یک ویرایشگر هگز باز کنید و هشت بایت اول D0 CF 11 E0 A1 B1 1A E1 است، بایت‌به‌بایت یکسان با یک .xls با تاریخ ۱۹۹۷، چون رمزنگاری [MS-OFFCRYPTO] بسته ZIP را در جا رمزنگاری نمی‌کند: کل بسته را درون یک ظرف CFB به‌عنوان یک جریان به‌نام EncryptedPackage می‌پیچد، در کنار یک جریان EncryptionInfo که الگوریتم را توصیف می‌کند. بنابراین امضا فقط ظرف را شناسایی می‌کند و درباره بار محتوایی چیزی نمی‌گوید. تشخیص یک workbook از نوع 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;

نام‌های دایرکتوری ارزش یک هشدار جداگانه دارند: جریان‌های اطلاعات خلاصه یک کاراکتر کنترلی 0x05 پیشرو در نام‌هایشان حمل می‌کنند، پس یک مقایسه نوشته‌شده علیه یک رشته نمایشی ساده هرگز با آن‌ها مطابقت نمی‌یابد و یک خط لاگ ساده‌لوحانه آن‌ها را به‌صورت آشغال رندر می‌کند. هرچیز پایین‌دست این طبقه‌بندی — استخراج کلید، بررسی تأییدکننده رمز عبور — یک مسئله جداگانه است، که در یادداشت‌های چرا Excel یک workbook رمزنگاری‌شده با حالت رمز اشتباه را رد می‌کند پوشش داده شده. لایه ظرف فقط به شما می‌گوید جلوی کدام در ایستاده‌اید

نوشتن ظرفی که Excel واقعاً باز می‌کند

سمت نوشتن TlxCompoundFile عمداً از سمت خواندن باریک‌تر است، و فهمیدن اینکه چرا در برابر مشخصات یک بحث را نجات می‌دهد. [MS-CFB] فضایی عظیم از ظرف‌های معتبر را اجازه می‌دهد: storage های چندسطحی، درخت‌های دایرکتوری قرمز-سیاه به‌درستی متوازن، جریان‌های مینی، زنجیره‌های DIFAT. Excel یک گوشه کوچک از آن فضا را منتشر می‌کند و یک فضای کمی بزرگ‌تر را می‌خواند. HotXLS گوشه‌ای حتی کوچک‌تر می‌نویسد — حداقلی که Excel اثبات‌شده بارگذاری می‌کند. هر جریان روی FAT معمولی می‌رود بدون مسیر مینی‌جریان، که هزینه فضای دیسک را می‌پردازد و درستی می‌خرد: یک جریان خلاصه ۳۰۰بایتی که Excel آن را در پنج مینی‌سکتور ۶۴بایتی بسته‌بندی می‌کرد، به‌جایش یک سکتور کامل ۵۱۲بایتی اشغال می‌کند، و برای یک workbook این نویز در کنار نگه‌داشتن یک جدول تخصیص دوم، یک پیمایش زنجیره دوم و جریان مدخل ریشه پشتیبانش در مسیر نوشتن است. مدخل‌های دایرکتوری زیر ریشه یک زنجیره خواهر تخت تشکیل می‌دهند با هر گره رنگ‌شده سیاه، و ترتیب انتشار ثابت است: نگهدارنده سربرگ، سکتورهای داده جریان، سکتورهای دایرکتوری، سکتورهای FAT، سپس یک بازگشت برای بازنویسی سربرگ با شناسه‌های سکتوری که فقط در پایان معلوم می‌شوند. 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 ۱۰۹ مدخل DIFAT ساکن در سربرگ را می‌خواند و زنجیره DIFAT در 0x44 را فراتر از آن‌ها دنبال نمی‌کند، که یک ظرف قابل‌خواندن را روی حدود ۶.۸ مگابایت روی سکتورهای ۵۱۲بایتی سقف می‌زند — به‌راحتی بالاتر از فایل‌های .xls واقعی که HotXLS در میدان با آن‌ها روبه‌رو می‌شود، اما بااین‌حال یک سقف سخت، و نویسنده همان محدودیت را صریحاً اعمال می‌کند به‌جای انتشار ظرفی که نمی‌تواند توصیفش کند. دوم، ظرف‌های نسخه ۴ با سکتورهای ۴۰۹۶بایتی توسط حساب اندازه سکتور جا داده می‌شوند اما چیزی نیستند که کد برایش تنظیم شده، و اندازه جریان ۶۴بیتی مشورت نمی‌شود: HotXLS ۳۲ بیت پایینی در آفست 0x78 را می‌خواند و نیمه بالایی را رها می‌کند، که برای نسخه ۳ درست است و فقط برای نسخه ۳. سوم، جست‌وجوی مدخل یک اسکن تخت با نام در سراسر فهرست دایرکتوری است نه یک پیمایش پایین درخت قرمز-سیاه از یک storage والد، پس storage های تودرتو با برخورد نامی حل می‌شوند نه با مسیر — هر جریانی که یک فایل .xls نیاز دارد در سطح بالا می‌نشیند، که چیزی است که طراحی ساده‌تر را قابل‌دفاع می‌کند، اما کدی که انتظار آدرس‌دهی SomeStorage/SomeStream را دارد آن را نخواهد یافت

هیچ‌کدام از این‌ها آنچه واحد برایش وجود دارد را تغییر نمی‌دهد. مالکیت لایه ظرف، مدیریت .xls را به Object Pascal معمولی تبدیل می‌کند: قابل‌تجزیه از یک آرایه بایتی، قابل‌آزمون بدون سیستم فایل، قابل‌حمل به هر سکویی که کامپایلر هدف می‌گیرد، و آزاد از یک اپارتمان COM. همچنین میان‌برهای بوکشیدن را بازنشسته می‌کند، چون شناسایی یک workbook اکنون یعنی خواندن دایرکتوریش نه هشت بایت اولش — همان نظمی که پشت فهرست‌کردن نام برگه‌ها بدون بازکردن کل workbook است

TlxCompoundFile به‌عنوان بخشی از مؤلفه HotXLS Excel برای Delphi و C++Builder ارائه می‌شود، در کنار لایه‌های BIFF و OOXML که رویش می‌نشینند؛ صفحه محصول مرجع کامل واحد و ماتریس کامپایلرهای پشتیبانی‌شده را در بر دارد