Технічна стаття

Читання OLE2 Compound Files у 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 будь-де на цьому шляху

Звучить як сантехніка, і двадцять років це справді була сантехніка, якою володів хтось інший. Кожна кодова база Delphi, що торкалася файлу .xls, тягнулася до StgOpenStorage, отримувала назад IStorage і витягала з нього потік Workbook. Три рядки, працювало добре, ніхто про це більше не думав — аж до дня, коли той самий код мусив запускатися там, де Windows не було

Чому StgOpenStorage перестає працювати на сервері?

API структурованого сховища COM провалюється рівно в тих формах розгортання, у яких живе сучасний код Delphi, з причин, що не мають нічого спільного з форматом файлу. StgOpenStorage — точка входу Win32 у ole32.dll: вона хоче шлях у файловій системі, хоче ініціалізований COM на потоці виклику й хоче бути на Windows. Вимога шляху болить першою, бо REST-ендпоінт, що отримує завантажену книгу, має байти в буфері, а не на диску — тож ти записуєш буфер у тимчасовий файл, відкриваєш його, читаєш назад, видаляєш, і тепер володієш життєвим циклом тимчасового файлу, який треба зробити правильно під навантаженням. ILockBytes — задокументований запасний вихід, але монтування власної реалізації над TMemoryStream — більше COM-взаємодії, ніж хочуть більшість команд. Вимога ініціалізації кусає другою, зазвичай у робочому потоці сервісу, де ніхто не викликав CoInitialize, а вимога платформи закінчує розмову в мить, коли ціль — Linux під FPC, образ контейнера чи macOS. Тому HotXLS зберігає класичний шлях lxOLE, побудований на StgOpenStorage, за замовчуванням, оскільки він перевірений у бою і наявні викликачі не повинні змінюватися; TlxCompoundFile — опційна альтернатива для всіх інших

Що насправді кажуть заголовок і ланцюжки FAT

Перші 512 байтів compound-файлу відповідають на кожне структурне питання, потрібне до того, як прочитаєш хоч байт корисного навантаження. [MS-CFB] §2.2 фіксує сигнатуру заголовка на зсуві 0 як вісім байтів D0 CF 11 E0 A1 B1 1A E1, і lxIsCompoundStream перевіряє точно це, відновлюючи позицію потоку потім, тож викликач може принюхатися, не турбуючи нічого. Ще чотири поля вирішують геометрію: порядок байтів на 0x1C має бути 0xFFFE, що подвоюється як дешева друга перевірка сигнатури; зсув сектора на 0x1E дає розмір сектора як 1 shl SectorShift, тож версія 3 використовує зсув 9 для 512-байтних секторів, а версія 4 — зсув 12 для 4096; зсув міні-сектора на 0x20 дорівнює 6, роблячи міні-сектори 64-байтними; а межа міні-потоку на 0x38 — 4096. Арифметика адрес, що слідує, — найпоширеніше місце, щоб помилитися. Сектор 0 починається одразу після заголовка, тож сектор N починається на байтовому зсуві 512 + N * SectorSize — зверни увагу на буквальні 512, а не SectorSize. У файлі версії 3 ці два значення ідентичні, і баг ховається назавжди; у файлі версії 4 він тихо читає неправильний сектор, тому HotXLS тримає це в одній функції, SidToOffset

Compound-файл — це файлова система FAT усередині файлу, тож читати його означає обходити зв'язані списки ідентифікаторів секторів, де FAT[n] тримає ідентифікатор, що йде за сектором n. Три вартові завершують чи анотують ланцюжок — ENDOFCHAIN, FATSECT для сектора, що належить самому FAT, та DIFSECT для сектора DIFAT — і всі три читаються як від'ємні 32-бітні цілі числа зі знаком, що спрощує умови циклу. Знаходження FAT потребує ще однієї непрямості: DIFAT — це масив ідентифікаторів секторів, що каже, де живуть сектори FAT, і його перші 109 записів сидять у заголовку на зсуві 0x4C. TlxCompoundFile обходить ці 109, зупиняється на першому від'ємному записі й конкатенує кожен сектор FAT в один плаский масив Integer. Це 109 секторів FAT по 128 записів кожен на 512-байтному секторі, тобто приблизно 13 952 адресовних секторів, тобто приблизно 6,8 МіБ контейнера, перш ніж DIFAT мусить перелитися у власний ланцюжок

Друга таблиця виділення існує, бо 512-байтні сектори марнують більшість свого простору на дрібних потоках. Будь-який потік нижче межі 4096 байтів взагалі не зберігається в секторах: він живе всередині міні-потоку, самого по собі звичайного потоку, що звисає з кореневого запису каталогу, поділеного на 64-байтні міні-сектори й зчепленого через паралельний MiniFAT, вкорінений у зсуві заголовка 0x3C. Відкрий реальний .xls, і потік Workbook сидить на звичайному FAT, тоді як потоки зведеної інформації сидять унизу в просторі міні-секторів, тому реалізація, що охоплює лише шлях FAT, здається, що працює рівно доти, доки не знадобляться метадані документа. Каталог — третя структура, і саме та, що робить контейнер навігованим: кожен запис — точно 128 байтів, чотири на 512-байтний сектор, несучи UTF-16 ім'я в перших 64 байтах, довжину в байтах на 0x40, тип об'єкта на 0x42 (1 = сховище, 2 = потік, 5 = корінь), посилання дерева на 0x44, 0x48 та 0x4C, початковий сектор на 0x74 та 32-бітний розмір потоку на 0x78. Ця довжина імені рахує байти, включно з термінуючим нулем, тож кількість символів — це 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. Ця копія — реальна ціна на великій книзі, і це чесна ціна дизайну, де повернутий об'єкт лишається валідним після звільнення контейнера. Коли книга достатньо велика, що повна копія в пам'яті — взагалі неправильна форма, кращою точкою входу є потоковий прямий читач для завеликих таблиць

Чому зашифрований XLSX виглядає як файл XLS?

Тому що на рівні контейнера він і є таким — і це практичний виграш від володіння цим шаром. Відкрий зашифрований .xlsx у hex-редакторі, і перші вісім байтів — D0 CF 11 E0 A1 B1 1A E1, побайтово ідентичні до .xls зразка 1997 року, бо шифрування [MS-OFFCRYPTO] не шифрує ZIP-пакет на місці: воно загортає весь пакет усередину контейнера CFB як потік з ім'ям EncryptedPackage, поруч з потоком 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;

Імена каталогу заслуговують на власне попередження: потоки зведеної інформації несуть провідний керуючий символ 0x05 у своїх іменах, тож порівняння, написане проти звичайного рядка відображення, ніколи їх не зіставить, а наївний рядок логу відрендерить їх як сміття. Усе, що йде далі за цю класифікацію — виведення ключа, перевірка верифікатора пароля — окрема проблема, розглянута в нотатках про чому Excel відхиляє книгу, зашифровану неправильним режимом шифру. Шар контейнера лише повідомляє, перед якими дверима ти стоїш

Запис контейнера, що Excel справді відкриє

Бік запису TlxCompoundFile навмисно вужчий за бік читання, і розуміння чому рятує від суперечки зі специфікацією. [MS-CFB] дозволяє величезний простір валідних контейнерів: багаторівневі сховища, правильно збалансовані червоно-чорні дерева каталогів, міні-потоки, ланцюжки DIFAT. Excel видає невеликий куток цього простору й читає дещо більший. HotXLS пише куток ще менший — мінімум, що Excel демонстративно завантажує. Кожен потік йде на звичайний FAT без шляху міні-потоку, що коштує дискового простору й купує коректність: 300-байтний потік зведення, що Excel запакував би в п'ять 64-байтних міні-секторів, натомість займає повний 512-байтний сектор, і для книги це шум порівняно з підтримкою другої таблиці виділення, другого проходу ланцюжка та потоку кореневого запису, що його підтримує на шляху запису. Записи каталогу утворюють плаский ланцюжок сиблінгів під коренем з кожним вузлом, зафарбованим у чорний, і порядок видачі фіксований: заповнювач заголовка, сектори даних потоку, сектори каталогу, сектори 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 читає 109 записів DIFAT, резидентних у заголовку, і не йде за ланцюжком DIFAT на 0x44 поза ними, обмежуючи читабельний контейнер приблизно 6,8 МіБ на 512-байтних секторах — комфортно вище за реальні файли .xls, з якими HotXLS зустрічається в полі, але жорсткою стелею попри це, і писач примусово забезпечує ту саму межу явно, замість видавати контейнер, який не може описати. По-друге, контейнери версії 4 з 4096-байтними секторами враховуються арифметикою розміру сектора, але це не те, під що налаштований код, а 64-бітний розмір потоку не консультується: HotXLS читає нижні 32 біти на зсуві 0x78 і лишає верхню половину недоторканою, що коректно для версії 3 і лише для версії 3. По-третє, пошук запису — це плаский скан за іменем по списку каталогу, а не обхід вниз по червоно-чорному дереву від батьківського сховища, тож вкладені сховища розв'язуються за колізією імен, а не за шляхом — кожен потік, що потрібен файлу .xls, сидить на верхньому рівні, що і робить простіший дизайн виправданим, але код, що очікує адресувати SomeStorage/SomeStream, його не знайде

Ніщо з цього не змінює призначення модуля. Володіння шаром контейнера перетворює обробку .xls на звичайний Object Pascal: розпарсовуваний з масиву байтів, тестовний без файлової системи, портовний на будь-яку платформу, яку націлює компілятор, і вільний від апартаменту COM. Це також виводить з ужитку короткі шляхи принюхування, бо ідентифікація книги тепер означає читання її каталогу, а не перших восьми байтів — та сама дисципліна, що стоїть за переліком назв аркушів без відкриття всієї книги

TlxCompoundFile постачається як частина HotXLS Excel Component для Delphi та C++Builder, поряд з шарами BIFF та OOXML, що сидять поверх нього; сторінка продукту містить повний довідник модуля та матрицю підтримуваних компіляторів