Technický článek

Čtení souborů OLE2 Compound File v Delphi bez COM IStorage

HotXLS Excel Library pro Delphi a C++Builder čte a zapisuje kontejner Compound File Binary stojící za každým starším souborem .xls, a to v čistém Object Pascalu. Třída TlxCompoundFile implementuje rozvržení [MS-CFB] verze 3 přímo nad TStream – hlavičku, DIFAT, řetězy FAT, MiniFAT a strom adresáře – bez ole32.dll a bez COM IStorage kdekoli na cestě

To zní jako instalatérská práce a dvacet let to byla instalatérská práce, kterou vlastnil někdo jiný. Každá delphská codebase, která se dotkla souboru .xls, sáhla po StgOpenStorage, dostala zpět IStorage a z něj vytáhla stream Workbook. Tři řádky, fungovalo to dobře, nikdo o tom už nikdy nepřemýšlel – až do dne, kdy stejný kód musel běžet někde, kde Windows nebyl

Proč StgOpenStorage přestane fungovat na serveru?

API strukturovaného úložiště COM selhává přesně v těch nasazovacích tvarech, ve kterých moderní kód v Delphi žije, a to z důvodů, které nemají nic společného se samotným formátem souboru. StgOpenStorage je vstupní bod Win32 v ole32.dll: chce cestu na souborovém systému, chce COM inicializované na volajícím vlákně a chce být na Windows. Požadavek na cestu bolí nejdřív, protože REST endpoint přijímající nahraný sešit má bajty v bufferu, nikoli na disku – takže zapíšete buffer do dočasného souboru, otevřete jej, přečtete zpět, smažete, a teď vlastníte životní cyklus dočasného souboru, který se pod zátěží dá snadno pokazit. ILockBytes je dokumentovaný únikový poklop, ale zapojit vlastní implementaci nad TMemoryStream je víc COM interopu, než si většina týmů přeje. Požadavek na inicializaci kousne podruhé, obvykle ve vlákně servisního workeru, na kterém nikdo nezavolal CoInitialize, a požadavek na platformu ukončí diskuzi v okamžiku, kdy je cílem Linux pod FPC, obraz kontejneru nebo macOS. HotXLS proto ponechává klasickou cestu lxOLE postavenou na StgOpenStorage jako výchozí, protože je prověřená v praxi a stávající volající by nemuseli měnit kód; TlxCompoundFile je volitelná alternativa pro všechny ostatní

Co skutečně říká hlavička a řetězy FAT

Prvních 512 bajtů souboru compound file zodpoví každou strukturální otázku, kterou potřebujete, ještě než přečtete jediný bajt obsahu. [MS-CFB] §2.2 fixuje signaturu hlavičky na offsetu 0 jako osm bajtů D0 CF 11 E0 A1 B1 1A E1, a lxIsCompoundStream přesně to kontroluje, načež obnoví pozici streamu, aby volající mohl čichnout k obsahu, aniž by cokoli narušil. Čtyři další pole rozhodují o geometrii: pořadí bajtů na 0x1C musí být 0xFFFE, což zároveň slouží jako levná druhá kontrola signatury; posun sektoru na 0x1E udává velikost sektoru jako 1 shl SectorShift, takže verze 3 používá posun 9 pro 512bajtové sektory a verze 4 posun 12 pro 4096; posun mini sektoru na 0x20 je 6, což dělá mini sektory 64bajtové; a hranice mini streamu na 0x38 je 4096. Aritmetika adres, která následuje, je nejčastějším místem, kde se dá udělat chyba. Sektor 0 začíná hned za hlavičkou, takže sektor N začíná na bajtovém offsetu 512 + N * SectorSize – všimněte si doslovného 512, nikoli SectorSize. Na souboru verze 3 jsou tato dvě čísla identická a chyba se schová navždy; na souboru verze 4 potichu čte špatný sektor, a proto to HotXLS drží v jediné funkci, SidToOffset

Compound file je souborový systém FAT uvnitř souboru, takže jeho čtení znamená procházet spojové seznamy ID sektorů, kde FAT[n] drží ID následující po sektoru n. Tři strážní hodnoty ukončují nebo anotují řetěz – ENDOFCHAIN, FATSECT pro sektor patřící samotné FAT a DIFSECT pro sektor DIFAT – a všechny tři se čtou jako záporná 32bitová celá čísla se znaménkem, což drží podmínky cyklu jednoduché. Nalezení FAT vyžaduje ještě jednu úroveň nepřímosti: DIFAT je pole ID sektorů říkající, kde žijí sektory FAT, a jeho prvních 109 položek sedí v hlavičce na offsetu 0x4C. TlxCompoundFile prochází těchto 109 položek, zastaví se na první záporné a zřetězí každý sektor FAT do jednoho plochého pole Integer. To je 109 sektorů FAT po 128 položkách na 512bajtový sektor, tedy 13 952 adresovatelných sektorů, tedy zhruba 6,8 MiB kontejneru, než se DIFAT musí přelít do vlastního řetězu

Druhá alokační tabulka existuje proto, že 512bajtové sektory promrhají většinu svého místa na malé streamy. Jakýkoli stream pod hranicí 4096 bajtů se v sektorech vůbec neukládá: žije uvnitř mini streamu, což je sám o sobě obyčejný stream visící na kořenové položce adresáře, rozdělený na 64bajtové mini sektory a zřetězený přes paralelní MiniFAT zakotvenou na offsetu hlavičky 0x3C. Otevřete reálný .xls a stream Workbook sedí na běžné FAT, zatímco streamy souhrnných informací sedí dole v prostoru mini sektorů, a proto se implementace pokrývající jen cestu FAT jeví jako funkční až do okamžiku, kdy potřebuje metadata dokumentu. Adresář je třetí strukturou a tou, která dělá kontejner navigovatelným: každá položka má přesně 128 bajtů, čtyři na 512bajtový sektor, nese jméno v UTF-16 v prvních 64 bajtech, svou bajtovou délku na 0x40, typ objektu na 0x42 (1 = storage, 2 = stream, 5 = root), stromové odkazy na 0x44, 0x48 a 0x4C, počáteční sektor na 0x74 a 32bitovou velikost streamu na 0x78. Tato délka jména počítá bajty včetně ukončující nuly, takže počet znaků je NameLen div 2 - 1, a spletete-li se o jedna, skončíte se streamem pojmenovaným Workboo

Vytažení streamu Workbook z paměťového bufferu

TlxCompoundFile.OpenStream skrývá vše výše uvedené za jediné volání, které přebírá jméno streamu a vrací TlxCfbStream držící plně materializované bajty. Celá sekvence – čichnutí, načtení, extrakce – běží nad TBytesStream, aniž by se cokoli dotklo disku

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;

Stojí za zmínku dva detaily. LoadFromStream přebírá příznak AOwnsStream s výchozí hodnotou False, takže odpovědnost za zdrojový stream zůstává na volajícím – záměrně, protože běžným případem je stream, který aplikace už vlastní. A OpenStream vrací TlxCfbStream vlastnící svou vlastní kopii bajtů, vystavenou přes Data, Size, Read, Seek a CopyTo. Tato kopie je u velkého sešitu reálná cena a je to poctivá daň za návrh, kde vrácený objekt zůstává platný i po uvolnění kontejneru. Když je sešit natolik velký, že úplná kopie v paměti je úplně špatný přístup, lepším vstupním bodem je streamovací přímý čtenář pro nadměrně velké sešity

Proč vypadá šifrovaný XLSX jako soubor XLS?

Protože na úrovni kontejneru jím skutečně je – a to je praktický přínos toho, vlastnit tuto vrstvu. Otevřete šifrovaný .xlsx v hex editoru a prvních osm bajtů je D0 CF 11 E0 A1 B1 1A E1, bajt po bajtu identických s .xls z roku 1997, protože šifrování [MS-OFFCRYPTO] nešifruje ZIP balíček na místě: zabalí celý balíček dovnitř kontejneru CFB jako stream pojmenovaný EncryptedPackage, vedle streamu EncryptionInfo popisujícího algoritmus. Signatura tedy identifikuje kontejner a nic neříká o obsahu. Odlišit sešit BIFF od šifrovaného balíčku OOXML znamená přečíst adresář, což je po LoadFromStream sken přes EntryCount a Entries, nebo dvojice sond 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;

Jména v adresáři si zaslouží vlastní varování: streamy souhrnných informací nesou ve svých jménech vedoucí řídicí znak 0x05, takže porovnání napsané proti obyčejnému zobrazovanému řetězci je nikdy nenajde a naivní řádek logu je vykreslí jako nesmysl. Vše, co následuje po této klasifikaci – odvození klíče, kontrola ověřovače hesla – je samostatný problém, popsaný v poznámkách o tom, proč Excel odmítne sešit zašifrovaný špatným režimem šifry. Vrstva kontejneru vám jen řekne, před kterými dveřmi stojíte

Zápis kontejneru, který Excel skutečně otevře

Zapisovací strana TlxCompoundFile je záměrně užší než čtecí strana, a pochopení proč ušetří hádku se specifikací. [MS-CFB] povoluje obrovský prostor platných kontejnerů: víceúrovňová úložiště, řádně vyvážené červeno-černé stromy adresáře, mini streamy, řetězy DIFAT. Excel vydává jen malý koutek tohoto prostoru a čte poněkud větší. HotXLS píše koutek ještě menší – minimum, které Excel prokazatelně načte. Každý stream jde na normální FAT bez cesty přes mini stream, což stojí místo na disku a kupuje správnost: 300bajtový souhrnný stream, který by Excel zabalil do pěti 64bajtových mini sektorů, místo toho zabírá celý 512bajtový sektor, a pro sešit je to šum vedle udržování druhé alokační tabulky, druhého procházení řetězu a streamu kořenové položky, který jej na zapisovací cestě podepírá. Položky adresáře tvoří plochý sourozenecký řetěz pod kořenem, přičemž každý uzel je obarven černě, a pořadí emitování je pevné: zástupný symbol hlavičky, sektory dat streamu, sektory adresáře, sektory FAT, pak seek zpět a přepsání hlavičky ID sektorů, která jsou známa až na konci. FAT si určuje svou velikost přes krátký cyklus s pevným bodem, protože přidání sektorů FAT může posunout počet sektorů dost vysoko na to, aby si vyžádal další sektor 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;

Kde implementace končí

Tři hranice stojí za jasné vyslovení, protože čtenář kontejneru, který potichu špatně obslouží hraniční případ, je horší než ten, který vyhodí výjimku. TlxCompoundFile čte 109 položek DIFAT přítomných v hlavičce a nenásleduje řetěz DIFAT na 0x44 za ně, čímž omezuje čitelný kontejner na zhruba 6,8 MiB při 512bajtových sektorech – pohodlně nad reálnými soubory .xls, se kterými se HotXLS v praxi setkává, ale přesto je to tvrdý strop, a writer stejný limit vynucuje explicitně, místo aby vydal kontejner, který nedokáže popsat. Za druhé, kontejnery verze 4 se 4096bajtovými sektory jsou pokryté aritmetikou velikosti sektoru, ale nejsou tím, na co je kód laděný, a 64bitová velikost streamu se nekonzultuje: HotXLS čte spodních 32 bitů na offsetu 0x78 a horní polovinu ponechává stranou, což je správně pro verzi 3, a jen pro verzi 3. Za třetí, vyhledání položky je plochý sken podle jména napříč seznamem adresáře, nikoli průchod červeno-černým stromem od rodičovského úložiště, takže vnořená úložiště se rozřeší kolizí jmen, nikoli cestou – každý stream, který soubor .xls potřebuje, sedí na nejvyšší úrovni, což dělá jednodušší návrh obhajitelným, ale kód očekávající adresování SomeStorage/SomeStream jej nenajde

Nic z toho nemění, k čemu ten unit slouží. Vlastnictví vrstvy kontejneru mění zacházení se .xls na obyčejný Object Pascal: parsovatelné z pole bajtů, testovatelné bez souborového systému, přenositelné na jakoukoli platformu, kterou kompilátor cílí, a bez COM apartmentu. Také to odchází do důchodu zkratky založené na čichání k souboru, protože identifikace sešitu teď znamená přečíst jeho adresář, nikoli prvních osm bajtů – stejná disciplína, jaká stojí za výpisem názvů listů bez otevření celého sešitu

TlxCompoundFile je součástí HotXLS Excel Component pro Delphi a C++Builder, spolu s vrstvami BIFF a OOXML, které na něm stojí; produktová stránka obsahuje kompletní referenci unitu a matici podporovaných kompilátorů