Tekninen artikkeli

OLE2-yhdistetiedostojen luku Delphissä ilman COM IStoragea

HotXLS Excel Library Delphille ja C++Builderille lukee ja kirjoittaa Compound File Binary -säiliötä, joka on jokaisen vanhan .xls-tiedoston takana, puhtaassa Object Pascalissa. TlxCompoundFile-luokka toteuttaa [MS-CFB]-version 3 rakenteen suoraan TStream:ia vasten — otsikko, DIFAT, FAT-ketjut, MiniFAT ja hakemistopuu — ilman ole32.dll:ää ja ilman COM IStorage:a missään polulla

Se kuulostaa putkistolta, ja kaksikymmentä vuotta se olikin putkisto, jonka joku muu omisti. Jokainen Delphi-koodikanta, joka kosketti .xls-tiedostoa, tavoitti StgOpenStorage-funktion, sai takaisin IStorage:n, ja veti Workbook-virran siitä ulos. Kolme riviä, toimi hyvin, kukaan ei ajatellut sitä enää — kunnes tuli päivä, jolloin sama koodi joutui ajamaan jossain, missä Windowsia ei ollut

Miksi StgOpenStorage lakkaa toimimasta palvelimella?

COM-strukturoidun tallennuksen API epäonnistuu täsmälleen niissä käyttöönottomuodoissa, joissa moderni Delphi-koodi elää, syistä, joilla ei ole mitään tekemistä tiedostomuodon kanssa. StgOpenStorage on Win32-liittymäpiste ole32.dll:ssä: se haluaa polun tiedostojärjestelmässä, se haluaa COM:in alustetuksi kutsuvassa säikeessä, ja se haluaa olla Windowsissa. Polkuvaatimus sattuu ensin, koska REST-päätepiste, joka vastaanottaa ladatun työkirjan, pitää tavut puskurissa, ei levyllä — joten kirjoitat puskurin väliaikaistiedostoon, avaat sen, luet sen takaisin, poistat sen, ja omistat nyt väliaikaistiedoston elinkaaren, jonka voi mennä väärin kuormituksen alla. ILockBytes on dokumentoitu pakotie, mutta mukautetun toteutuksen kytkeminen TMemoryStream:in päälle on enemmän COM-interopia kuin useimmat tiimit haluavat. Alustusvaatimus purree toiseksi, yleensä palvelutyösäikeessä, jossa kukaan ei kutsunut CoInitialize:a, ja alustavaatimus lopettaa keskustelun heti, kun kohde on Linux FPC:n alla, konttikuva tai macOS. HotXLS pitää siis klassisen lxOLE-polun, joka on rakennettu StgOpenStorage:n varaan, oletuksena, koska se on taistelussa testattu, eikä olemassa olevien kutsujien pitäisi joutua muuttamaan mitään; TlxCompoundFile on opt-in-vaihtoehto kaikille muille

Mitä otsikko ja FAT-ketjut oikeasti kertovat

Yhdistetiedoston ensimmäiset 512 tavua vastaavat jokaiseen rakenteelliseen kysymykseen, jonka tarvitset ennen kuin luet yhtään hyötykuorman tavua. [MS-CFB] §2.2 kiinnittää otsikon allekirjoituksen siirtymään 0 kahdeksaksi tavuksi D0 CF 11 E0 A1 B1 1A E1, ja lxIsCompoundStream tarkistaa täsmälleen sen, palauttaen virran sijainnin jälkikäteen, jotta kutsuja voi haistella häiritsemättä mitään. Neljä muuta kenttää päättävät geometrian: tavujärjestyksen kohdassa 0x1C täytyy olla 0xFFFE, mikä toimii myös halpana toisena allekirjoitustarkistuksena; sektorisiirto kohdassa 0x1E antaa sektorikoon muodossa 1 shl SectorShift, joten versio 3 käyttää siirtoa 9 512-tavuisille sektoreille ja versio 4 käyttää siirtoa 12 4096-tavuisille; minisektorisiirto kohdassa 0x20 on 6, mikä tekee minisektoreista 64-tavuisia; ja minivirran raja-arvo kohdassa 0x38 on 4096. Sitä seuraava osoitearitmetiikka on yleisin paikka mennä väärin. Sektori 0 alkaa heti otsikon jälkeen, joten sektori N alkaa tavusiirtymässä 512 + N * SectorSize — huomaa kirjaimellinen 512, ei SectorSize. Versio 3 -tiedostossa nämä kaksi ovat identtisiä ja bugi piiloutuu ikuisesti; versio 4 -tiedostossa se lukee hiljaa väärän sektorin, minkä vuoksi HotXLS pitää tämän yhdessä funktiossa, SidToOffset

Yhdistetiedosto on FAT-tiedostojärjestelmä tiedoston sisällä, joten sen lukeminen tarkoittaa linkitettyjen sektoritunnusten listojen läpikäymistä, joissa FAT[n] pitää sektoria n seuraavan tunnuksen. Kolme merkkiä päättävät tai merkitsevät ketjun — ENDOFCHAIN, FATSECT itse FAT:iin kuuluvalle sektorille, ja DIFSECT DIFAT-sektorille — ja kaikki kolme luetaan negatiivisina etumerkillisinä 32-bittisinä kokonaislukuina, mikä pitää silmukkaehdot yksinkertaisina. FAT:n löytäminen tarvitsee vielä yhden epäsuoruuden: DIFAT on sektoritunnusten taulukko, joka kertoo missä FAT-sektorit sijaitsevat, ja sen ensimmäiset 109 merkintää istuvat otsikossa siirtymässä 0x4C. TlxCompoundFile kulkee läpi nuo 109, pysähtyy ensimmäiseen negatiiviseen merkintään, ja ketjuttaa jokaisen FAT-sektorin yhdeksi litteäksi Integer-taulukoksi. Se on 109 FAT-sektoria, kukin 128 merkintää 512-tavuisella sektorilla, joten 13 952 osoitettavaa sektoria, joten suunnilleen 6,8 MiB säiliötä ennen kuin DIFAT:n täytyy vuotaa omaan ketjuunsa

Toinen varaustaulukko on olemassa, koska 512-tavuiset sektorit tuhlaavat suurimman osan tilastaan pieniin virtoihin. Mikä tahansa virta alle 4096-tavuisen raja-arvon ei tallennu sektoreihin lainkaan: se elää minivirran sisällä, joka on itsessään tavallinen virta, joka riippuu juurihakemistomerkinnästä, jaettuna 64-tavuisiin minisektoreihin ja ketjutettuna rinnakkaisen MiniFAT:in kautta, joka juurtuu otsikon siirtymään 0x3C. Avaa oikea .xls, ja Workbook-virta istuu tavallisella FAT:illa, kun taas yhteenveto-informaatiovirrat istuvat alhaalla minisektoritilassa, minkä vuoksi toteutus, joka kattaa vain FAT-polun, näyttää toimivan oikein aina siihen asti, kunnes se tarvitsee dokumentin metatietoja. Hakemisto on kolmas rakenne ja se, joka tekee säiliöstä navigoitavan: jokainen merkintä on täsmälleen 128 tavua, neljä per 512-tavuinen sektori, kantaen UTF-16-nimeä ensimmäisissä 64 tavussa, sen tavupituutta kohdassa 0x40, objektityyppiä kohdassa 0x42 (1 = tallennus, 2 = virta, 5 = juuri), puulinkkejä kohdissa 0x44, 0x48 ja 0x4C, alkusektoria kohdassa 0x74 ja 32-bittistä virran kokoa kohdassa 0x78. Tuo nimen pituus laskee tavuja mukaan lukien päättävä null, joten merkkimäärä on NameLen div 2 - 1, ja sen yhden verran väärin saaminen on tapa, jolla päädyt virtaan nimeltä Workboo

Workbook-virran vetäminen ulos muistipuskurista

TlxCompoundFile.OpenStream piilottaa kaiken edellä mainitun yhden kutsun taakse, joka ottaa virran nimen ja palauttaa TlxCfbStream:n, joka pitää täysin materialisoituneet tavut. Koko sekvenssi — haistelu, lataus, poiminta — ajaa TBytesStream:ia vasten, ja mikään ei koskaan kosketa levyä

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;

Kaksi yksityiskohtaa siellä ansaitsevat maininnan. LoadFromStream ottaa AOwnsStream-lipun, joka oletusarvoisesti on False, joten kutsuja pitää vastuun lähdevirrasta — tarkoituksellista, koska tavallinen tapaus on virta, jonka sovellus jo omistaa. Ja OpenStream palauttaa TlxCfbStream:n, joka omistaa oman kopionsa tavuista, paljastettuna Data-, Size-, Read-, Seek- ja CopyTo-kautta. Tuo kopio on todellinen kustannus suurella työkirjalla, ja se on rehellinen hinta suunnittelusta, jossa palautettu objekti pysyy pätevänä sen jälkeen, kun säiliö vapautetaan. Kun työkirja on tarpeeksi suuri, että täysi muistinsisäinen kopio on väärä muoto kokonaan, suoratoistava suoralukija ylikokoisille laskentataulukoille on parempi lähtöpiste

Miksi salattu XLSX näyttää XLS-tiedostolta?

Koska se on sitä, säiliötasolla — ja tämä on tuon kerroksen omistamisen käytännön hyöty. Avaa salattu .xlsx heksaeditorissa, ja ensimmäiset kahdeksan tavua ovat D0 CF 11 E0 A1 B1 1A E1, tavu tavulta identtiset vuoden 1997 .xls:n kanssa, koska [MS-OFFCRYPTO]-salaus ei salaa ZIP-pakettia paikallaan: se kääri koko paketin CFB-säiliön sisään virtana nimeltä EncryptedPackage, vierellä EncryptionInfo-virta, joka kuvaa algoritmin. Allekirjoitus siis tunnistaa säiliön eikä kerro mitään hyötykuormasta. BIFF-työkirjan erottaminen salatusta OOXML-paketista tarkoittaa hakemiston lukemista, mikä LoadFromStream:n jälkeen on skannaus EntryCount:in ja Entries:in yli, tai pari HasStream-koetta

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;

Hakemistonimet ansaitsevat omanlaisensa varoituksen: yhteenveto-informaatiovirrat kantavat johtavaa 0x05-ohjausmerkkiä nimissään, joten vertailu, joka on kirjoitettu tavallista näyttömerkkijonoa vasten, ei koskaan täsmää niihin, ja naiivi lokirivi renderöi ne roskaksi. Kaikki tämän luokittelun alavirrassa — avaimen johtaminen, salasanavarmentimen tarkistaminen — on erillinen ongelma, käsitelty muistiossa miksi Excel hylkää väärällä salausmoodilla salatun työkirjan. Säiliökerros kertoo vain, minkä oven edessä seisot

Säiliön kirjoittaminen, jonka Excel oikeasti avaa

TlxCompoundFile:n kirjoituspuoli on tarkoituksella kapeampi kuin lukupuoli, ja syyn ymmärtäminen säästää väittelyn spesifikaation kanssa. [MS-CFB] sallii valtavan tilan päteviä säiliöitä: monitasoisia tallennuksia, kunnolla tasapainotettuja punamusta-hakemistopuita, minivirtoja, DIFAT-ketjuja. Excel tuottaa pienen kulman tuosta tilasta ja lukee jonkin verran suuremman. HotXLS kirjoittaa vieläkin pienemmän kulman — minimin, jonka Excel todistetusti lataa. Jokainen virta menee tavalliselle FAT:ille ilman minivirtapolkua, mikä maksaa levytilaa ja ostaa oikeellisuutta: 300-tavuinen yhteenvetovirta, jonka Excel olisi pakannut viiteen 64-tavuiseen minisektoriin, vie sen sijaan täyden 512-tavuisen sektorin, ja työkirjalle se on kohinaa verrattuna toisen varaustaulukon, toisen ketjun läpikäynnin ja sitä tukevan juurimerkintävirran ylläpitämiseen kirjoituspolulla. Hakemistomerkinnät muodostavat litteän sisarusketjun juuren alla jokaisella solmulla värjättynä mustaksi, ja tuotantojärjestys on kiinteä: otsikon paikkamerkki, virran datasektorit, hakemistosektorit, FAT-sektorit, sitten haku takaisin otsikon uudelleenkirjoittamiseksi sektoritunnuksilla, jotka tiedetään vasta lopussa. FAT mitoittaa itsensä lyhyen kiinteän pisteen silmukan kautta, koska FAT-sektorien lisääminen voi työntää sektorimäärän tarpeeksi korkeaksi vaatiakseen toisen FAT-sektorin

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;

Mihin toteutus pysähtyy

Kolme rajaa kannattaa todeta suoraan, koska säiliölukija, joka hiljaa käsittelee reunatapauksen väärin, on pahempi kuin sellainen, joka nostaa virheen. TlxCompoundFile lukee 109 otsikossa asuvaa DIFAT-merkintää eikä seuraa DIFAT-ketjua kohdassa 0x44 niiden yli, mikä rajoittaa luettavan säiliön suunnilleen 6,8 MiB:iin 512-tavuisilla sektoreilla — mukavasti yli niiden todellisten .xls-tiedostojen, joita HotXLS kohtaa kentällä, mutta silti kova katto, ja kirjoittaja pakottaa saman rajan eksplisiittisesti sen sijaan, että se tuottaisi säiliön, jota se ei pysty kuvaamaan. Toiseksi, versio 4 -säiliöt 4096-tavuisilla sektoreilla mahdollistuvat sektorikoon aritmetiikan kautta, mutta ne eivät ole se, mihin koodi on viritetty, eikä 64-bittistä virran kokoa konsultoida: HotXLS lukee alemmat 32 bittiä siirtymässä 0x78 ja jättää ylemmän puoliskon koskemattomaksi, mikä on oikein versiolle 3 ja vain versiolle 3. Kolmanneksi, merkinnän haku on litteä skannaus nimen mukaan hakemistolistan yli sen sijaan, että se kulkisi punamusta-puuta alaspäin vanhemmasta tallennuksesta, joten sisäkkäiset tallennukset ratkeavat nimikollision mukaan eikä polun mukaan — jokainen virta, jota .xls-tiedosto tarvitsee, istuu ylimmällä tasolla, mikä tekee yksinkertaisemmasta suunnittelusta puolustettavan, mutta koodi, joka odottaa osoittavansa SomeStorage/SomeStream, ei löydä sitä

Mikään tuosta ei muuta sitä, mihin yksikkö on tarkoitettu. Säiliökerroksen omistaminen muuttaa .xls-käsittelyn tavalliseksi Object Pascaliksi: jäsennettävissä tavutaulukosta, testattavissa ilman tiedostojärjestelmää, siirrettävissä mihin tahansa alustaan, jota kääntäjä kohdentaa, ja vapaa COM-osastosta. Se myös eläkkeelle siirtää haisteluoikoties, koska työkirjan tunnistaminen tarkoittaa nyt sen hakemiston lukemista sen ensimmäisten kahdeksan tavun sijaan — sama kuri, joka on artikkelin taulukkonimien listaamisesta avaamatta koko työkirjaa takana

TlxCompoundFile toimitetaan osana HotXLS Excel -komponenttia Delphille ja C++Builderille, yhdessä BIFF- ja OOXML-kerrosten kanssa, jotka istuvat sen päällä; tuotesivu sisältää täydellisen yksikköviitteen ja tuetun kääntäjämatriisin