Tehnički članak

Čitanje OLE2 kompozitnih datoteka u Delphiju bez COM IStorage

HotXLS Excel Library za Delphi i C++Builder čita i piše kontejner Compound File Binary iza svake naslijeđene .xls datoteke u čistom Object Pascalu. Klasa TlxCompoundFile implementira [MS-CFB] verziju 3 rasporeda izravno na TStream — zaglavlje, DIFAT, FAT lance, MiniFAT i stablo direktorija — bez ole32.dll i bez COM IStorage bilo gdje na putu

To zvuči kao instalacijska cijev, i dvadeset godina je to bila cijev koju je posjedovao netko drugi. Svaka Delphi baza kôda koja je dotaknula .xls datoteku posegnula je za StgOpenStorage, dobila natrag IStorage i izvukla tok Workbook iz njega. Tri retka, radilo je dobro, nitko o tome više nije razmišljao — sve do dana kad je isti kôd morao raditi negdje gdje Windowsa nije bilo

Zašto StgOpenStorage prestaje raditi na poslužitelju?

COM API strukturirane pohrane ne uspijeva upravo u oblicima implementacije u kojima živi moderan Delphi kôd, iz razloga koji nemaju veze s formatom datoteke. StgOpenStorage je Win32 ulazna točka u ole32.dll: želi putanju na datotečnom sustavu, želi da je COM inicijaliziran na dretvi pozivatelja, i želi biti na Windowsu. Zahtjev za putanjom prvi boli, jer REST krajnja točka koja prima učitanu radnu bilježnicu ima bajtove u međuspremniku, ne na disku — pa zapišete međuspremnik u privremenu datoteku, otvorite je, pročitate natrag, obrišete, i sada posjedujete životni ciklus privremene datoteke koji treba pogriješiti pod opterećenjem. ILockBytes je dokumentirani izlaz za nuždu, ali povezivanje prilagođene implementacije preko TMemoryStream više je COM interopa nego što većina timova želi. Zahtjev za inicijalizacijom ujeda drugi, obično u dretvi radnika usluge na kojoj nitko nije pozvao CoInitialize, a zahtjev za platformom završava razgovor u trenutku kad je cilj Linux pod FPC-om, slika kontejnera ili macOS. HotXLS zato zadržava klasični put lxOLE izgrađen na StgOpenStorage kao zadani, jer je provjeren u borbi i postojeći pozivatelji ne bi trebali morati mijenjati kôd; TlxCompoundFile je opcionalna alternativa za sve ostale

Što zaglavlje i FAT lanci zapravo govore

Prvih 512 bajtova kompozitne datoteke odgovaraju na svako strukturno pitanje koje trebate prije čitanja ijednog bajta korisnog sadržaja. [MS-CFB] §2.2 fiksira potpis zaglavlja na pomaku 0 kao osam bajtova D0 CF 11 E0 A1 B1 1A E1, a lxIsCompoundStream provjerava upravo to, vraćajući poziciju toka nakon toga tako da pozivatelj može njušiti bez ometanja ičega. Još četiri polja odlučuju geometriju: redoslijed bajtova na 0x1C mora biti 0xFFFE, što udvostručuje kao jeftina druga provjera potpisa; pomak sektora na 0x1E daje veličinu sektora kao 1 shl SectorShift, pa verzija 3 koristi pomak 9 za sektore od 512 bajtova, a verzija 4 pomak 12 za 4096; pomak mini sektora na 0x20 je 6, čineći mini sektore od 64 bajta; a granica mini toka na 0x38 je 4096. Aritmetika adresa koja slijedi najčešće je mjesto gdje se pogriješi. Sektor 0 počinje odmah nakon zaglavlja, pa sektor N počinje na pomaku bajtova 512 + N * SectorSize — primijetite doslovni 512, ne SectorSize. Na datoteci verzije 3 ta dva su identična i bug se zauvijek skriva; na datoteci verzije 4 tiho čita pogrešan sektor, zato HotXLS ovo drži u jednoj funkciji, SidToOffset

Kompozitna datoteka je FAT datotečni sustav unutar datoteke, pa njezino čitanje znači prolazak povezanih popisa ID-ova sektora, gdje FAT[n] nosi ID koji slijedi sektor n. Tri stražara terminiraju ili anotiraju lanac — ENDOFCHAIN, FATSECT za sektor koji pripada samom FAT-u, i DIFSECT za DIFAT sektor — a sva tri se čitaju kao negativni predznačeni 32-bitni cijeli brojevi, što drži uvjete petlje jednostavnima. Pronalaženje FAT-a treba još jednu indirekciju: DIFAT je polje ID-ova sektora koje govori gdje žive FAT sektori, a njegovih prvih 109 unosa sjedi u zaglavlju na pomaku 0x4C. TlxCompoundFile prolazi kroz tih 109, staje na prvi negativan unos, i spaja svaki FAT sektor u jedno ravno polje Integer. To je 109 FAT sektora po 128 unosa na sektoru od 512 bajtova, dakle otprilike 13 952 adresibilna sektora, dakle otprilike 6,8 MiB kontejnera prije nego DIFAT mora preliti u vlastiti lanac

Druga tablica alokacije postoji jer sektori od 512 bajtova troše većinu svog prostora na male tokove. Svaki tok ispod granice od 4096 bajtova uopće se ne pohranjuje u sektorima: živi unutar mini toka, samog običnog toka koji visi na unosu direktorija korijena, podijeljen u mini sektore od 64 bajta i lančano povezan preko paralelnog MiniFAT-a ukorijenjenog na pomaku zaglavlja 0x3C. Otvorite pravu .xls datoteku i tok Workbook sjedi na normalnom FAT-u dok tokovi sažetih informacija sjede dolje u mini-sektorskom prostoru, zato implementacija koja pokriva samo FAT put izgleda da radi sve dok joj ne zatrebaju metapodaci dokumenta. Direktorij je treća struktura i ona koja čini kontejner navigabilnim: svaki unos je točno 128 bajtova, četiri po sektoru od 512 bajtova, nosi UTF-16 ime u prvih 64 bajta, njegovu duljinu u bajtovima na 0x40, tip objekta na 0x42 (1 = pohrana, 2 = tok, 5 = korijen), poveznice stabla na 0x44, 0x48 i 0x4C, početni sektor na 0x74 i 32-bitnu veličinu toka na 0x78. Ta duljina imena broji bajtove uključujući terminirajući nul-znak, pa je broj znakova NameLen div 2 - 1, a promašiti to za jedan znači završiti s tokom nazvanim Workboo

Izvlačenje toka Workbook iz memorijskog međuspremnika

TlxCompoundFile.OpenStream skriva sve gore navedeno iza jednog poziva koji uzima ime toka i vraća TlxCfbStream koji drži potpuno materijalizirane bajtove. Cijeli slijed — njušenje, učitavanje, izdvajanje — pokreće se na TBytesStream, a ništa nikad ne dotakne disk

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;

Dvije pojedinosti ondje vrijedi istaknuti. LoadFromStream uzima zastavicu AOwnsStream koja prema zadanom iznosi False, pa pozivatelj zadržava odgovornost za izvorni tok — namjerno, jer je uobičajen slučaj tok koji aplikacija već posjeduje. A OpenStream vraća TlxCfbStream koji posjeduje vlastitu kopiju bajtova, izloženu kroz Data, Size, Read, Seek i CopyTo. Ta kopija je stvarna cijena na velikoj radnoj bilježnici, i to je poštena cijena dizajna gdje vraćeni objekt ostaje valjan nakon što se kontejner oslobodi. Kad je radna bilježnica dovoljno velika da je puna kopija u memoriji potpuno pogrešan oblik, strujni izravni čitač za predimenzionirane proračunske tablice je bolja ulazna točka

Zašto šifrirani XLSX izgleda kao XLS datoteka?

Zato što to jest, na razini kontejnera — i ovo je praktična isplativost posjedovanja tog sloja. Otvorite šifriran .xlsx u hex uređivaču i prvih osam bajtova su D0 CF 11 E0 A1 B1 1A E1, bajt po bajt identično .xls datoteci iz 1997., jer [MS-OFFCRYPTO] enkripcija ne šifrira ZIP paket na mjestu: omata cijeli paket unutar CFB kontejnera kao tok nazvan EncryptedPackage, pored toka EncryptionInfo koji opisuje algoritam. Potpis stoga identificira kontejner, a ne govori ništa o korisnom sadržaju. Razlikovanje BIFF radne bilježnice od šifriranog OOXML paketa znači čitanje direktorija, što je nakon LoadFromStream sken preko EntryCount i Entries, ili par provjera 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;

Imena direktorija zaslužuju vlastito upozorenje: tokovi sažetih informacija nose vodeći kontrolni znak 0x05 u svojim imenima, pa se usporedba napisana prema običnom prikaznom nizu nikad neće podudariti s njima, a naivni redak zapisnika ih iscrtava kao smeće. Sve nizvodno od ove klasifikacije — izvođenje ključa, provjera verifikatora zaporke — zaseban je problem, pokriven u bilješkama o zašto Excel odbija radnu bilježnicu šifriranu pogrešnim načinom rada šifre. Sloj kontejnera vam govori samo pred kojim vratima stojite

Pisanje kontejnera koji će Excel stvarno otvoriti

Strana pisanja u TlxCompoundFile namjerno je uža od strane čitanja, i razumijevanje zašto štedi svađu sa specifikacijom. [MS-CFB] dopušta ogroman prostor valjanih kontejnera: pohrane s više razina, ispravno uravnotežena crveno-crna stabla direktorija, mini tokove, DIFAT lance. Excel emitira mali kutak tog prostora, a čita nešto veći. HotXLS piše kutak još manji — minimum koji Excel dokazivo učitava. Svaki tok ide na normalni FAT bez puta mini-toka, što košta prostora na disku, a kupuje ispravnost: sažetak toka od 300 bajtova koji bi Excel upakirao u pet mini sektora od 64 bajta umjesto toga zauzima pun sektor od 512 bajtova, a za radnu bilježnicu to je šum uz održavanje druge tablice alokacije, drugog prolaska lanca i toka korijenskog unosa koji ga podupire na putu pisanja. Unosi direktorija tvore ravan lanac sestrinstva pod korijenom sa svakim čvorom obojanim crno, a redoslijed emitiranja je fiksan: rezervirano mjesto zaglavlja, sektori podataka toka, sektori direktorija, FAT sektori, zatim skok natrag za ponovno pisanje zaglavlja s ID-ovima sektora koji su poznati tek na kraju. FAT sam sebi određuje veličinu kroz kratku petlju s fiksnom točkom, jer dodavanje FAT sektora može gurnuti broj sektora dovoljno visoko da zatreba još jedan 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;

Gdje implementacija staje

Tri granice vrijedi jasno navesti, jer je čitač kontejnera koji tiho pogrešno obrađuje rubni slučaj gori od onog koji baci iznimku. TlxCompoundFile čita 109 DIFAT unosa smještenih u zaglavlju i ne prati DIFAT lanac na 0x44 izvan njih, ograničavajući čitljiv kontejner na otprilike 6,8 MiB na sektorima od 512 bajtova — udobno iznad stvarnih .xls datoteka koje HotXLS susreće na terenu, ali svejedno tvrda granica, a pisac provodi isto ograničenje eksplicitno umjesto da emitira kontejner koji ne može opisati. Drugo, kontejneri verzije 4 sa sektorima od 4096 bajtova podržani su aritmetikom veličine sektora, ali nisu ono za što je kôd podešen, a 64-bitna veličina toka se ne konzultira: HotXLS čita donjih 32 bita na pomaku 0x78 i ostavlja gornju polovicu netaknutom, što je ispravno za verziju 3, i samo za verziju 3. Treće, pretraga unosa je ravan sken po imenu preko popisa direktorija, a ne prolazak crveno-crnim stablom od roditeljske pohrane, pa se ugniježđene pohrane razrješavaju sudarom imena, a ne putanjom — svaki tok koji .xls datoteka treba sjedi na najvišoj razini, što opravdava jednostavniji dizajn, ali kôd koji očekuje adresirati SomeStorage/SomeStream to neće pronaći

Ništa od toga ne mijenja svrhu jedinice. Posjedovanje sloja kontejnera pretvara rukovanje .xls datotekama u obični Object Pascal: raščlanjivo iz polja bajtova, testibilno bez datotečnog sustava, prenosivo na bilo koju platformu koju kompajler cilja, i bez COM apartmana. Također povlači prečice njušenja, jer identificiranje radne bilježnice sada znači čitanje njezina direktorija, a ne prvih osam bajtova — ista disciplina iza popisivanja imena listova bez otvaranja cijele radne bilježnice

TlxCompoundFile isporučuje se kao dio HotXLS Excel Component za Delphi i C++Builder, uz slojeve BIFF i OOXML koji na njoj počivaju; produktna stranica sadrži potpunu referencu jedinice i podržanu matricu kompajlera