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