Odborný článok

Čítanie OLE2 Compound Files v Delphi bez COM IStorage

HotXLS Excel Library pre Delphi a C++Builder číta a zapisuje kontajner Compound File Binary za každým legacy súborom .xls v čistom Object Pascale. Trieda TlxCompoundFile implementuje rozloženie [MS-CFB] verzie 3 priamo nad TStream — hlavička, DIFAT, FAT reťaze, MiniFAT a strom adresára — bez ole32.dll a bez COM IStorage kdekoľvek na ceste

To znie ako inštalatérstvo, a dvadsať rokov to bolo inštalatérstvo, ktoré vlastnil niekto iný. Každá Delphi kódová báza, ktorá sa dotkla súboru .xls, siahla po StgOpenStorage, dostala späť IStorage, a vytiahla stream Workbook z neho. Tri riadky, fungovalo dobre, nikto o tom viac nepremýšľal — až do dňa, keď ten istý kód musel bežať niekde, kde nebol Windows

Prečo prestane StgOpenStorage fungovať na serveri?

API COM structured-storage zlyháva presne v tvaroch nasadenia, v akých žije moderný Delphi kód, z dôvodov, ktoré nemajú nič spoločné s formátom súboru. StgOpenStorage je Win32 vstupný bod v ole32.dll: chce cestu na súborovom systéme, chce COM inicializovaný na volajúcom vlákne, a chce byť na Windows. Požiadavka na cestu zabolí prvá, pretože REST endpoint prijímajúci nahraný workbook má bajty v bufferi, nie na disku — takže zapíšete buffer do dočasného súboru, otvoríte ho, prečítate späť, vymažete, a teraz vlastníte lifecycle dočasného súboru, ktorý sa dá pod záťažou pokaziť. ILockBytes je zdokumentovaný únikový východ, ale zapojenie vlastnej implementácie nad TMemoryStream je viac COM interopu, než väčšina tímov chce. Požiadavka na inicializáciu hryzie druhá, zvyčajne v service worker vlákne, na ktorom nikto nezavolal CoInitialize, a požiadavka na platformu ukončí rozhovor v momente, keď je cieľom Linux pod FPC, kontajnerový obraz, alebo macOS. HotXLS preto ponecháva klasickú cestu lxOLE postavenú na StgOpenStorage ako predvolenú, keďže je overená v boji a existujúci volajúci by nemali musieť meniť; TlxCompoundFile je opt-in alternatíva pre všetkých ostatných

Čo hlavička a FAT reťaze skutočne hovoria

Prvých 512 bajtov compound súboru odpovedá na každú štrukturálnu otázku, ktorú potrebujete skôr, než prečítate bajt payloadu. [MS-CFB] §2.2 fixuje signature hlavičky na offsete 0 ako osem bajtov D0 CF 11 E0 A1 B1 1A E1, a lxIsCompoundStream presne toto kontroluje, pričom potom obnoví pozíciu streamu, takže volajúci môže vetriť bez narušenia čohokoľvek. Štyri ďalšie polia rozhodujú o geometrii: byte order na 0x1C musí byť 0xFFFE, čo zdvojnásobuje ako lacná druhá kontrola signature; sector shift na 0x1E dáva veľkosť sektora ako 1 shl SectorShift, takže verzia 3 používa shift 9 pre 512-bajtové sektory a verzia 4 používa shift 12 pre 4096; mini sector shift na 0x20 je 6, čo robí mini sektory 64-bajtové; a cutoff mini streamu na 0x38 je 4096. Adresová aritmetika, ktorá nasleduje, je najbežnejšie miesto na pokazenie. Sektor 0 začína ihneď po hlavičke, takže sektor N začína na bajtovom offsete 512 + N * SectorSize — všimnite si literálne 512, nie SectorSize. Vo verzii 3 súbore sú tie dve identické a chyba sa navždy skryje; vo verzii 4 súbore potichu prečíta nesprávny sektor, čo je dôvod, prečo to HotXLS udržuje v jednej funkcii, SidToOffset

Compound súbor je FAT súborový systém vnútri súboru, takže jeho čítanie znamená prechádzanie spojených zoznamov ID sektorov, kde FAT[n] drží ID nasledujúce po sektore n. Tri sentinely ukončujú alebo anotujú reťaz — ENDOFCHAIN, FATSECT pre sektor patriaci samotnému FAT, a DIFSECT pre sektor DIFAT — a všetky tri sa čítajú ako záporné signed 32-bitové celé čísla, čo udržuje podmienky slučky jednoduché. Nájdenie FAT potrebuje ešte jednu indirekciu: DIFAT je pole ID sektorov hovoriace, kde žijú sektory FAT, a jeho prvých 109 záznamov sedí v hlavičke na offsete 0x4C. TlxCompoundFile prejde týchto 109, zastaví na prvom zápornom zázname, a zreťazí každý FAT sektor do jedného plochého poľa Integer. To je 109 FAT sektorov po 128 záznamov na 512-bajtovom sektore, teda zhruba 13 952 adresovateľných sektorov, teda zhruba 6,8 MiB kontajnera skôr, než sa DIFAT musí rozliať do vlastnej reťaze

Druhá alokačná tabuľka existuje, pretože 512-bajtové sektory plytvajú väčšinou svojho miesta na malé streamy. Akýkoľvek stream pod hranicou 4096 bajtov nie je vôbec uložený v sektoroch: žije vnútri mini streamu, samotného obyčajného streamu visiaceho na root adresárovej položke, rozdeleného na 64-bajtové mini sektory a reťazeného cez paralelný MiniFAT zakorenený na offsete hlavičky 0x3C. Otvorte skutočný .xls a stream Workbook sedí na normálnom FAT, kým streamy summary-information sedia dole v priestore mini sektorov, čo je dôvod, prečo implementácia pokrývajúca len cestu FAT vyzerá, že funguje, až kým nepotrebuje metadáta dokumentu. Adresár je treťou štruktúrou a tou, ktorá robí kontajner navigovateľným: každý záznam má presne 128 bajtov, štyri na 512-bajtový sektor, nesúci meno v UTF-16 v prvých 64 bajtoch, jeho bajtovú dĺžku na 0x40, typ objektu na 0x42 (1 = storage, 2 = stream, 5 = root), stromové odkazy na 0x44, 0x48 a 0x4C, počiatočný sektor na 0x74 a 32-bitovú veľkosť streamu na 0x78. Táto dĺžka mena počíta bajty vrátane ukončujúcej nuly, takže počet znakov je NameLen div 2 - 1, a pomýliť sa v tom o jeden je spôsob, ako skončíte so streamom pomenovaným Workboo

Vytiahnutie streamu Workbook z pamäťového bufferu

TlxCompoundFile.OpenStream skrýva všetko vyššie za jedno volanie, ktoré berie meno streamu a vráti TlxCfbStream držiaci plne materializované bajty. Celá sekvencia — vetriť, načítať, extrahovať — beží proti TBytesStream, pričom sa nič nikdy nedotkne 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;

Dva detaily tu stoja za zmienku. LoadFromStream berie príznak AOwnsStream s predvolenou hodnotou False, takže volajúci si ponechá zodpovednosť za zdrojový stream — zámerne, pretože bežný prípad je stream, ktorý aplikácia už vlastní. A OpenStream vráti TlxCfbStream vlastniaci svoju vlastnú kópiu bajtov, vystavenú cez Data, Size, Read, Seek a CopyTo. Táto kópia je reálna cena na veľkom workbooku, a je to poctivá cena za dizajn, kde vrátený objekt ostáva platný po tom, čo je kontajner uvoľnený. Keď je workbook dostatočne veľký na to, aby plná kópia v pamäti bola nesprávnym tvarom úplne, streamovací priamy reader pre nadmerne veľké spreadsheety je lepším vstupným bodom

Prečo šifrovaný XLSX vyzerá ako súbor XLS?

Pretože na úrovni kontajnera ním je — a toto je praktický prínos vlastníctva tejto vrstvy. Otvorte šifrovaný .xlsx v hex editore a prvých osem bajtov je D0 CF 11 E0 A1 B1 1A E1, bajt za bajtom identické s .xls z roku 1997, pretože šifrovanie [MS-OFFCRYPTO] nešifruje ZIP balík na mieste: zabalí celý balík vnútri CFB kontajnera ako stream pomenovaný EncryptedPackage, popri streame EncryptionInfo popisujúcom algoritmus. Signature teda identifikuje kontajner a nehovorí nič o payloade. Rozlíšenie BIFF workbooku od šifrovaného OOXML balíka znamená prečítanie adresára, čo je po LoadFromStream skenovanie cez EntryCount a Entries, alebo pár 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;

Mená adresára si zaslúžia vlastné varovanie: streamy summary-information nesú vedúci riadiaci znak 0x05 vo svojich menách, takže porovnanie napísané voči obyčajnému zobrazovaciemu reťazcu ich nikdy nezhodné, a naivný log riadok ich vykreslí ako odpad. Všetko downstream od tejto klasifikácie — odvodenie kľúča, kontrola overovača hesla — je samostatný problém, pokrytý v poznámkach o tom, prečo Excel odmieta workbook šifrovaný nesprávnym cipher módom. Vrstva kontajnera vám povie len to, pred akými dverami stojíte

Zápis kontajnera, ktorý Excel skutočne otvorí

Zapisovacia strana TlxCompoundFile je zámerne užšia než čítacia strana, a pochopiť prečo ušetrí hádku so špecifikáciou. [MS-CFB] povoľuje obrovský priestor platných kontajnerov: viacúrovňové storage, správne vyvážené red-black stromy adresára, mini streamy, DIFAT reťaze. Excel vydáva malý kút tohto priestoru a číta o niečo väčší. HotXLS zapisuje kút ešte menší — minimum, ktoré Excel preukázateľne načíta. Každý stream ide na normálny FAT bez cesty mini streamu, čo stojí miesto na disku a kupuje správnosť: 300-bajtový summary stream, ktorý by Excel zbalil do piatich 64-bajtových mini sektorov, namiesto toho zaberie celý 512-bajtový sektor, a pre workbook je to šum vedľa udržiavania druhej alokačnej tabuľky, druhého prechodu reťaze a stream root položky, ktorá ju podporuje na zapisovacej ceste. Adresárové položky tvoria plochú súrodeneckú reťaz pod root, s každým uzlom sfarbeným čierno, a poradie vydávania je pevné: zástupný symbol hlavičky, dátové sektory streamu, adresárové sektory, FAT sektory, potom seek späť na prepísanie hlavičky s ID sektorov, ktoré sú známe len na konci. FAT sa dimenzuje cez krátku fixed-point slučku, pretože pridávanie FAT sektorov môže vytlačiť počet sektorov dostatočne vysoko, aby vyžadoval ďalší FAT sektor

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 sa implementácia zastavuje

Tri hranice stoja za jasné vyslovenie, pretože reader kontajnera, ktorý potichu zle spracuje hraničný prípad, je horší než ten, ktorý vyvolá výnimku. TlxCompoundFile číta 109 rezidentných DIFAT záznamov v hlavičke a nesleduje reťaz DIFAT na 0x44 za nimi, čím ohraničí čitateľný kontajner na zhruba 6,8 MiB na 512-bajtových sektoroch — pohodlne nad reálnymi súbormi .xls, s ktorými sa HotXLS v teréne stretáva, no napriek tomu tvrdý strop, a writer vynucuje rovnaký limit explicitne namiesto vydania kontajnera, ktorý nedokáže popísať. Po druhé, kontajnery verzie 4 so 4096-bajtovými sektormi sú akomodované aritmetikou veľkosti sektora, ale nie sú tým, na čo je kód ladený, a 64-bitová veľkosť streamu sa nekonzultuje: HotXLS číta spodných 32 bitov na offsete 0x78 a hornú polovicu necháva bokom, čo je správne pre verziu 3 a len pre verziu 3. Po tretie, vyhľadávanie záznamov je plochý scan podľa mena cez zoznam adresára namiesto prechodu nadol po red-black strome od rodičovského storage, takže vnorené storage sa vyriešia podľa kolízie mena, nie podľa cesty — každý stream, ktorý súbor .xls potrebuje, sedí na najvyššej úrovni, čo je to, čo robí jednoduchší dizajn obhájiteľným, ale kód, ktorý očakáva adresovanie SomeStorage/SomeStream, ho nenájde

Nič z toho nemení, na čo je unit určený. Vlastníctvo vrstvy kontajnera premení spracovanie .xls na obyčajný Object Pascal: parsovateľný z poľa bajtov, testovateľný bez súborového systému, prenosný na akúkoľvek platformu, na ktorú kompilátor cieli, a bez COM apartmentu. Tiež to odchádza do dôchodku skratky vetrenia, pretože identifikácia workbooku teraz znamená čítanie jeho adresára namiesto jeho prvých ôsmich bajtov — rovnaká disciplína ako za výpisom mien hárkov bez otvorenia celého workbooku

TlxCompoundFile sa dodáva ako súčasť HotXLS Excel Component pre Delphi a C++Builder, popri vrstvách BIFF a OOXML, ktoré na nej sedia; produktová stránka nesie kompletnú referenciu unitu a podporovanú maticu kompilátorov