Tehnični članak

Branje sestavljenih datotek OLE2 v Delphiju brez COM IStorage

HotXLS Excel Library za Delphi in C++Builder bere in piše vsebnik Compound File Binary za vsako podedovano datoteko .xls v čistem Object Pascalu. Razred TlxCompoundFile implementira postavitev [MS-CFB] različice 3 neposredno proti TStream — glava, DIFAT, verige FAT, MiniFAT in drevo imenika — brez ole32.dll in brez COM IStorage kjerkoli na poti

To je videti kot vodovodni napeljavi, dvajset let pa je bilo vodovodno delo, ki ga je lastnil nekdo drug. Vsaka baza kode Delphi, ki se je dotaknila datoteke .xls, je segla po StgOpenStorage, dobila nazaj IStorage in izvlekla tok Workbook iz njega. Tri vrstice, delovalo je v redu, nihče o tem ni več razmišljal — dokler ni prišel dan, ko je morala ista koda teči nekje, kjer ni bilo Windows

Zakaj StgOpenStorage neha delovati na strežniku?

API strukturiranega shranjevanja COM odpove natanko v oblikah uvajanja, v katerih danes živi koda Delphi, iz razlogov, ki nimajo nič opraviti s formatom datoteke. StgOpenStorage je vhodna točka Win32 v ole32.dll: hoče pot na datotečnem sistemu, hoče inicializiran COM na klicalni niti in hoče biti na Windows. Zahteva po poti boli najprej, ker ima končna točka REST, ki prejme naložen delovni zvezek, bajte v medpomnilniku, ne pa na disku — zato zapišete medpomnilnik v začasno datoteko, jo odprete, preberete nazaj, izbrišete, zdaj pa lastnite življenjski cikel začasne datoteke, ki ga je treba pod obremenitvijo narediti napačno. ILockBytes je dokumentirana izhodna loputa, vendar je ožičenje implementacije po meri prek TMemoryStream več interoperabilnosti COM, kot si večina ekip želi. Zahteva po inicializaciji ugrizne drugič, ponavadi v delovni niti storitve, na kateri nihče ni poklical CoInitialize, zahteva po platformi pa konča pogovor v trenutku, ko je cilj Linux pod FPC, slika vsebnika ali macOS. HotXLS zato ohranja klasično pot lxOLE, zgrajeno na StgOpenStorage, kot privzeto, ker je preverjena v boju in obstoječim klicateljem ni treba ničesar spreminjati; TlxCompoundFile je alternativa po izbiri za vse druge

Kaj glava in verige FAT dejansko povesta

Prvih 512 bajtov sestavljene datoteke odgovori na vsako strukturno vprašanje, ki ga potrebujete, preden preberete en sam bajt vsebine. [MS-CFB] §2.2 določi podpis glave pri odmiku 0 kot osem bajtov D0 CF 11 E0 A1 B1 1A E1, lxIsCompoundStream pa preveri natanko to, nato pa obnovi položaj toka, tako da klicatelj lahko povoha, ne da bi kar koli motil. Še štiri polja odločajo o geometriji: vrstni red bajtov pri 0x1C mora biti 0xFFFE, kar dvojno služi kot poceni drugo preverjanje podpisa; premik sektorja pri 0x1E poda velikost sektorja kot 1 shl SectorShift, tako da različica 3 uporablja premik 9 za 512-bajtne sektorje, različica 4 pa premik 12 za 4096; premik mini sektorja pri 0x20 je 6, kar naredi mini sektorje 64-bajtne; presek mini toka pri 0x38 pa je 4096. Aritmetika naslavljanja, ki sledi, je najpogostejše mesto za napako. Sektor 0 se začne takoj za glavo, tako da se sektor N začne pri bajtnem odmiku 512 + N * SectorSize — opazite dobesedno 512, ne pa SectorSize. Pri datoteki različice 3 sta oba enaka in hrošč se skrije za vedno; pri datoteki različice 4 tiho prebere napačen sektor, zato ga HotXLS drži v eni funkciji, SidToOffset

Sestavljena datoteka je datotečni sistem FAT znotraj datoteke, zato branje pomeni prehajanje povezanih seznamov identifikatorjev sektorjev, kjer FAT[n] drži identifikator, ki sledi sektorju n. Trije stražarji zaključijo ali označijo verigo — ENDOFCHAIN, FATSECT za sektor, ki pripada sami FAT, in DIFSECT za sektor DIFAT — vsi trije pa se berejo kot negativna podpisana 32-bitna cela števila, kar pogoje zanke drži preproste. Iskanje FAT potrebuje še eno posrednost: DIFAT je polje identifikatorjev sektorjev, ki pove, kje živijo sektorji FAT, njegovih prvih 109 vnosov pa sedi v glavi pri odmiku 0x4C. TlxCompoundFile prehodi teh 109, ustavi se pri prvem negativnem vnosu in vsak sektor FAT stakne v eno ravno polje Integer. To je 109 sektorjev FAT po 128 vnosov na 512-bajtnem sektorju, torej približno 13.952 naslovljivih sektorjev, torej približno 6,8 MiB vsebnika, preden mora DIFAT preliti v svojo lastno verigo

Druga tabela dodelitve obstaja, ker 512-bajtni sektorji zapravijo večino svojega prostora za majhne tokove. Vsak tok pod presekom 4096 bajtov sploh ni shranjen v sektorjih: živi znotraj mini toka, ki je sam navaden tok, ki visi na vnosu korenskega imenika, razdeljen na 64-bajtne mini sektorje in verižen prek vzporedne MiniFAT, ukoreninjene pri odmiku glave 0x3C. Odprite pravo datoteko .xls in tok Workbook sedi na normalni FAT, medtem ko tokovi povzetka informacij sedijo spodaj v prostoru mini sektorjev, zato se implementacija, ki pokriva le pot FAT, zdi, da deluje pravilno, vse dokler ne potrebuje metapodatkov dokumenta. Imenik je tretja struktura in tista, ki vsebnik naredi navigabilnega: vsak vnos je natanko 128 bajtov, štirje na 512-bajtnem sektorju, nosi ime UTF-16 v prvih 64 bajtih, svojo bajtno dolžino pri 0x40, tip objekta pri 0x42 (1 = shramba, 2 = tok, 5 = koren), povezave drevesa pri 0x44, 0x48 in 0x4C, začetni sektor pri 0x74 in 32-bitno velikost toka pri 0x78. Ta dolžina imena šteje bajte, vključno z zaključno ničlo, tako da je število znakov NameLen div 2 - 1, zamik za ena pa vas pripelje do toka, poimenovanega Workboo

Izvlečenje toka Workbook iz medpomnilnika v pomnilniku

TlxCompoundFile.OpenStream vse zgoraj skrije za enim klicem, ki sprejme ime toka in vrne TlxCfbStream, ki drži popolnoma materializirane bajte. Celotno zaporedje — povohaj, naloži, izvleci — teče proti TBytesStream, ne da bi se karkoli kdaj dotaknilo diska

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;

Vredno je omeniti dve podrobnosti. LoadFromStream sprejme zastavico AOwnsStream, ki privzeto je False, tako da klicatelj obdrži odgovornost za izvorni tok — namerno, ker je pogost primer tok, ki ga aplikacija že lasti. In OpenStream vrne TlxCfbStream, ki lasti svojo lastno kopijo bajtov, izpostavljeno prek Data, Size, Read, Seek in CopyTo. Ta kopija je resnična cena na velikem delovnem zvezku, poštena cena zasnove, kjer vrnjeni objekt ostane veljaven po tem, ko je vsebnik sproščen. Kadar je delovni zvezek dovolj velik, da je popolna kopija v pomnilniku napačna oblika v celoti, je pretočni neposredni bralnik za prevelike preglednice boljša vstopna točka

Zakaj je šifriran XLSX videti kot datoteka XLS?

Ker to je, na ravni vsebnika — in to je praktični izplačilo lastništva tega sloja. Odprite šifriran .xlsx v heksadecimalnem urejevalniku in prvih osem bajtov je D0 CF 11 E0 A1 B1 1A E1, bajt za bajtom enako kot .xls iz leta 1997, ker šifriranje [MS-OFFCRYPTO] ne šifrira paketa ZIP na mestu: cel paket ovije znotraj vsebnika CFB kot tok, imenovan EncryptedPackage, zraven toka EncryptionInfo, ki opisuje algoritem. Podpis torej identificira vsebnik in ne pove ničesar o vsebini. Razlikovanje delovnega zvezka BIFF od šifriranega paketa OOXML pomeni branje imenika, kar je po LoadFromStream pregled čez EntryCount in Entries, ali par 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;

Imena imenika si zaslužijo lastno opozorilo: tokovi povzetka informacij nosijo vodilni kontrolni znak 0x05 v svojih imenih, tako da se primerjava, napisana proti navadnemu prikaznemu nizu, nikoli ne ujame z njimi, naivna vrstica dnevnika pa jih izriše kot smeti. Vse naprej po verigi od te razvrstitve — izpeljava ključa, preverjanje potrjevalca gesla — je ločena težava, obravnavana v opombah o tem, zakaj Excel zavrne delovni zvezek, šifriran z napačnim načinom šifre. Sloj vsebnika vam pove le, pred katerimi vrati stojite

Pisanje vsebnika, ki ga Excel dejansko odpre

Pisalna stran TlxCompoundFile je namerno ožja od bralne strani, razumevanje zakaj pa prihrani prepir s specifikacijo. [MS-CFB] dovoli ogromen prostor veljavnih vsebnikov: večnivojske shrambe, pravilno uravnotežena rdeče-črna drevesa imenikov, mini tokove, verige DIFAT. Excel izda majhen kotiček tega prostora in bere nekoliko večjega. HotXLS piše še manjši kotiček — minimum, ki ga Excel dokazljivo naloži. Vsak tok gre na normalno FAT brez poti mini toka, kar stane prostor na disku in kupi pravilnost: 300-bajtni tok povzetka, ki bi ga Excel zapakiral v pet 64-bajtnih mini sektorjev, namesto tega zaseda cel 512-bajtni sektor, za delovni zvezek pa je to šum ob vzdrževanju druge tabele dodelitve, drugega prehoda verige in toka korenskega vnosa, ki ga podpira na pisalni poti. Vnosi imenika tvorijo raven sosedski verigo pod korenom z vsakim vozliščem, obarvanim črno, vrstni red izdaje pa je fiksen: mesto za glavo, sektorji podatkov toka, sektorji imenika, sektorji FAT, nato skok nazaj za prepis glave z identifikatorji sektorjev, ki so znani šele na koncu. FAT se sama dimenzionira skozi kratko zanko s fiksno točko, ker lahko dodajanje sektorjev FAT potisne število sektorjev dovolj visoko, da zahteva še en 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;

Kje se implementacija ustavi

Vredno je jasno navesti tri meje, ker je bralnik vsebnikov, ki tiho napačno obravnava robni primer, slabši od takega, ki javi napako. TlxCompoundFile bere 109 vnosov DIFAT, ki so prisotni v glavi, in ne sledi verigi DIFAT pri 0x44 onkraj njih, kar berljiv vsebnik omeji na približno 6,8 MiB pri 512-bajtnih sektorjih — udobno nad resničnimi datotekami .xls, na katere HotXLS naleti na terenu, vendar kljub temu trd strop, pisec pa isto mejo izrecno uveljavlja namesto da bi izdal vsebnik, ki ga ne more opisati. Drugič, vsebniki različice 4 s 4096-bajtnimi sektorji so sprejeti z aritmetiko velikosti sektorja, vendar niso tisto, za kar je koda uglašena, 64-bitna velikost toka pa se ne posvetuje: HotXLS bere spodnjih 32 bitov pri odmiku 0x78 in pusti zgornjo polovico pri miru, kar je pravilno za različico 3 in le zanjo. Tretjič, iskanje vnosa je ravno preiskovanje po imenu čez seznam imenika namesto prehoda navzdol po rdeče-črnem drevesu iz nadrejene shrambe, tako da se gnezdene shrambe razrešijo po trčenju imen namesto po poti — vsak tok, ki ga datoteka .xls potrebuje, sedi na vrhnji ravni, kar zasnovo, enostavnejšo, naredi zagovorljivo, koda, ki pričakuje nasloviti SomeStorage/SomeStream, pa je ne bo našla

Nič od tega ne spremeni, čemu enota služi. Lastništvo sloja vsebnika ravnanje z .xls spremeni v navaden Object Pascal: razčlenljivo iz bajtnega polja, testljivo brez datotečnega sistema, prenosljivo na katerokoli platformo, ki jo cilja prevajalnik, in prosto COM apartmaja. Prav tako upokoji bližnjice povohanja, ker identifikacija delovnega zvezka zdaj pomeni branje njegovega imenika namesto njegovih prvih osmih bajtov — ista disciplina, ki stoji za naštevanjem imen listov brez odpiranja celega delovnega zvezka

TlxCompoundFile je izdan kot del HotXLS Excel Component za Delphi in C++Builder, skupaj s slojema BIFF in OOXML, ki sedita na njem; stran izdelka nosi celoten referenčni opis enote in podprto matriko prevajalnikov