Műszaki cikk

OLE2 Compound File-ok olvasása Delphiben COM IStorage nélkül

A HotXLS Excel Library Delphihez és C++Builderhez tiszta Object Pascalban olvassa és írja a Compound File Binary konténert, amely minden örökölt .xls fájl mögött áll. A TlxCompoundFile osztály közvetlenül valósítja meg az [MS-CFB] v3 elrendezést egy TStream ellen — fejléc, DIFAT, FAT láncok, MiniFAT és a könyvtárfa — ole32.dll és COM IStorage nélkül bárhol az útvonalon

Ez vezetéknek hangzik, és húsz évig valaki más tulajdonában lévő vezeték is volt. Minden Delphi kódbázis, amely érintett egy .xls fájlt, a StgOpenStorage-hoz nyúlt, kapott vissza egy IStorage-t, és kihúzta belőle a Workbook streamet. Három sor, jól működött, senki nem gondolt rá többet — egészen addig a napig, amíg ugyanennek a kódnak futnia kellett valahol, ahol nem volt Windows

Miért hagy abba a StgOpenStorage a működést egy szerveren?

A COM strukturáltan-tárolt API pontosan azokban a telepítési formákban bukik el, amelyekben a modern Delphi kód él, olyan okokból, amelyeknek semmi közük a fájlformátumhoz. A StgOpenStorage egy Win32 belépési pont az ole32.dll-ben: útvonalat akar egy fájlrendszeren, COM-ot akar inicializálva a hívó szálon, és Windowson akar futni. Az útvonal-követelmény fáj elsőnek, mert egy feltöltött munkafüzetet fogadó REST végpontnak a bájtok egy pufferben vannak, nem a lemezen — így kiírod a puffert egy ideiglenes fájlba, megnyitod, visszaolvasod, törlöd, és most már egy ideiglenes-fájl életciklust birtokolsz, amit terhelés alatt elronthatsz. Az ILockBytes a dokumentált kiskapu, de egy egyedi implementáció bedrótozása egy TMemoryStream fölé több COM interopot jelent, mint amennyit a legtöbb csapat akar. Az inicializálási követelmény másodikként harap, általában egy szolgáltatás worker-szálon, amin senki nem hívott CoInitialize-t, és a platform-követelmény véget vet a beszélgetésnek abban a pillanatban, hogy a cél Linux FPC alatt, egy konténerkép, vagy macOS. A HotXLS ezért megtartja a klasszikus lxOLE útvonalat a StgOpenStorage-ra épülve alapértelmezettként, mivel az harcban edzett, és a meglévő hívóknak nem kellene változniuk; a TlxCompoundFile az opt-in alternatíva mindenki más számára

Mit mond el valójában a fejléc és a FAT láncok

Egy compound file első 512 bájtja megválaszol minden strukturális kérdést, amire szükséged van, mielőtt egyetlen payload-bájtot is olvasnál. Az [MS-CFB] §2.2 a fejléc-aláírást a 0-s eltolásnál a nyolc D0 CF 11 E0 A1 B1 1A E1 bájtként rögzíti, és az lxIsCompoundStream pontosan ezt ellenőrzi, ezután visszaállítva a stream pozícióját, hogy a hívó szagolhasson anélkül, hogy bármit megzavarna. Négy további mező dönti el a geometriát: a bájtsorrend a 0x1C-nél 0xFFFE kell legyen, ami egyben egy olcsó második aláírás-ellenőrzésként is szolgál; a szektor-eltolás a 0x1E-nél megadja a szektorméretet 1 shl SectorShift-ként, így a v3 az eltolást 9-re állítja 512-bájtos szektorokhoz, a v4 pedig 12-re a 4096-hoz; a mini szektor-eltolás a 0x20-nál 6, ami 64-bájtos mini szektorokat ad; és a mini stream levágási határ a 0x38-nál 4096. Az ezt követő cím-aritmetika a leggyakoribb hely, ahol elrontják. A 0-s szektor közvetlenül a fejléc után kezdődik, így az N. szektor a 512 + N * SectorSize bájteltolásnál kezdődik — figyeld meg a szó szerinti 512-t, nem a SectorSize-ot. Egy v3-as fájlon a kettő azonos, és a hiba örökre elrejtőzik; egy v4-es fájlon csendben rossz szektort olvas, ez az oka annak, hogy a HotXLS ezt egyetlen függvényben tartja, a SidToOffset-ben

Egy compound file egy FAT fájlrendszer egy fájlon belül, így az olvasása szektorazonosítók láncolt listáinak bejárását jelenti, ahol a FAT[n] tartja az n szektort követő azonosítót. Három őrszem zárja le vagy jegyzi meg a láncot — az ENDOFCHAIN, a FATSECT a FAT-hoz magához tartozó szektorhoz, és a DIFSECT egy DIFAT szektorhoz — és mindhárom negatív előjeles 32-bites egészként olvasódik, ami egyszerűvé teszi a ciklusfeltételeket. A FAT megtalálásához még egy indirekcióra van szükség: a DIFAT azoknak a szektorazonosítóknak a tömbje, amelyek megmondják, hol élnek a FAT szektorok, és az első 109 bejegyzése a fejlécben ül a 0x4C eltolásnál. A TlxCompoundFile bejárja ezt a 109-et, megáll az első negatív bejegyzésnél, és összefűzi minden FAT szektort egyetlen lapos Integer tömbbe. Ez 109 FAT szektor, szektoronként 128 bejegyzéssel egy 512-bájtos szektoron, tehát nagyjából 13 952 megcímezhető szektor, tehát nagyjából 6,8 MiB konténer, mielőtt a DIFAT-nak saját láncba kellene ömlenie

A második allokációs tábla azért létezik, mert az 512-bájtos szektorok a helyük nagy részét kis streamekre pazarolják. Bármely, a 4096-bájtos levágási határ alatti stream egyáltalán nem szektorokban tárolódik: a mini streamben él, ami maga is egy hétköznapi stream, ami a root könyvtárbejegyzésről lóg le, 64-bájtos mini szektorokra felosztva, és egy párhuzamos MiniFAT-on keresztül láncolva, amely a fejléc 0x3C eltolásánál gyökerezik. Nyiss meg egy valódi .xls-t, és a Workbook stream a normál FAT-on ül, míg az összegzés-információ streamek lent ülnek a mini-szektor térben, ami azért van, mert egy implementáció, amely csak a FAT útvonalat fedi le, egészen addig működni tűnik, amíg dokumentum-metaadatra nincs szüksége. A könyvtár a harmadik struktúra, és az, amelyik navigálhatóvá teszi a konténert: minden bejegyzés pontosan 128 bájt, négy egy 512-bájtos szektorban, egy UTF-16 nevet hordozva az első 64 bájtban, a bájthosszát a 0x40-nál, az objektumtípust a 0x42-nél (1 = tároló, 2 = stream, 5 = root), fa-linkeket a 0x44, 0x48 és 0x4C-nél, a kezdő szektort a 0x74-nél és a 32-bites stream-méretet a 0x78-nál. Az a névhossz bájtokat számol, beleértve a záró null-t is, így a karakterszám NameLen div 2 - 1, és ezen egy híján elszámolni az az út, ahogy egy Workboo nevű stream-mel végzed

Egy Workbook stream kihúzása egy memóriapufferből

A TlxCompoundFile.OpenStream mindezt egyetlen hívás mögé rejti, amely egy stream-nevet vesz át, és visszaad egy TlxCfbStream-et, amely a teljesen anyagosított bájtokat tartja. A teljes szekvencia — szagolás, betöltés, kinyerés — egy TBytesStream ellen fut, anélkül hogy bármi hozzáérne a lemezhez

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;

Két részlet érdemes kiemelni itt. A LoadFromStream egy AOwnsStream jelzőt vesz át, amely alapértelmezetten False, így a hívó marad felelős a forrás streamért — szándékosan, mert a gyakori eset egy olyan stream, amelyet az alkalmazás már birtokol. És az OpenStream egy TlxCfbStream-et ad vissza, amely a saját másolatát birtokolja a bájtoknak, kitéve a Data, Size, Read, Seek és CopyTo-n keresztül. Ez a másolat valódi költség egy nagy munkafüzetnél, és az őszinte ára egy olyan tervezésnek, ahol a visszaadott objektum érvényes marad, miután a konténert felszabadították. Amikor egy munkafüzet elég nagy ahhoz, hogy egy teljes memóriabeli másolat teljesen rossz forma legyen, a streamelő közvetlen olvasó túlméretezett táblázatokhoz a jobb belépési pont

Miért néz ki egy titkosított XLSX úgy, mint egy XLS fájl?

Mert az, a konténer szintjén — és ez ennek a rétegnek a birtoklásának gyakorlati haszna. Nyiss meg egy titkosított .xlsx-et egy hexa-szerkesztőben, és az első nyolc bájt D0 CF 11 E0 A1 B1 1A E1, bájtra pontosan azonos egy 1997-es vintage .xls-szel, mert az [MS-OFFCRYPTO] titkosítás nem a ZIP csomagot titkosítja a helyén: az egész csomagot egy CFB konténerbe csomagolja, mint egy EncryptedPackage nevű streamet, egy EncryptionInfo stream mellett, amely leírja az algoritmust. Az aláírás ezért a konténert azonosítja, és semmit nem mond a payloadról. Egy BIFF munkafüzet megkülönböztetése egy titkosított OOXML csomagtól a könyvtár olvasását jelenti, ami a LoadFromStream után egy pásztázás az EntryCount és Entries fölött, vagy egy pár HasStream próba

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;

A könyvtárneveket külön figyelmeztetés illeti: az összegzés-információ streamek egy vezető 0x05 vezérlőkaraktert hordoznak a nevükben, így egy sima megjelenítési sztring ellen írt összehasonlítás sosem fog egyezni velük, és egy naiv naplósor szemétként jeleníti meg őket. Minden, ami downstream van ettől az osztályozástól — a kulcs levezetése, a jelszó-ellenőrző ellenőrzése — külön probléma, amelyet a az Excel miért utasít el egy rossz rejtjel-móddal titkosított munkafüzetet jegyzetek tárgyalnak. A konténer réteg csak azt mondja meg, melyik ajtó előtt állsz

Olyan konténer írása, amelyet az Excel tényleg megnyit

A TlxCompoundFile írási oldala szándékosan szűkebb, mint az olvasási oldal, és annak megértése, hogy miért, megspórol egy vitát a specifikációval. Az [MS-CFB] hatalmas teret enged az érvényes konténereknek: többszintű tárolók, megfelelően kiegyensúlyozott vörös-fekete könyvtárfák, mini streamek, DIFAT láncok. Az Excel ennek a térnek egy kis sarkát bocsátja ki, és egy valamivel nagyobbat olvas. A HotXLS még ennél is kisebb sarkot ír — azt a minimumot, amelyet az Excel demonstrálhatóan betölt. Minden stream a normál FAT-on megy, mini-stream útvonal nélkül, ami lemezterületbe kerül, és korrektséget vásárol: egy 300-bájtos összegzés-stream, amelyet az Excel öt 64-bájtos mini szektorba csomagolt volna, ehelyett egy teljes 512-bájtos szektort foglal el, és egy munkafüzet esetén ez zaj egy második allokációs tábla, egy második láncbejárás és az azt hordozó root-bejegyzés stream fenntartása mellett az írási útvonalon. A könyvtárbejegyzések lapos testvérláncot alkotnak a root alatt minden csomóponttal feketére színezve, és a kibocsátási sorrend rögzített: fejléc-helykitöltő, stream-adat szektorok, könyvtár szektorok, FAT szektorok, majd egy visszakeresés a fejléc újraírásához azokkal a szektorazonosítókkal, amelyek csak a végén ismertek. A FAT önmagát méretezi egy rövid fixpontos ciklussal, mert FAT szektorok hozzáadása annyira megnövelheti a szektorszámot, hogy egy újabb FAT szektor szükséges

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;

Ahol a megvalósítás megáll

Három határt érdemes tisztán kimondani, mert egy konténer-olvasó, amely csendben rosszul kezel egy szélsőséges esetet, rosszabb, mint egy, amely kivételt dob. A TlxCompoundFile a fejlécben rezidens 109 DIFAT-bejegyzést olvassa, és nem követi a DIFAT láncot a 0x44-nél túl rajtuk, egy olvasható konténert nagyjából 6,8 MiB-ra korlátozva 512-bájtos szektorokon — kényelmesen a valódi .xls fájlok fölött, amelyekkel a HotXLS a terepen találkozik, de mégis kemény plafon, és az író kifejezetten kikényszeríti ugyanezt a korlátot ahelyett, hogy olyan konténert bocsátana ki, amelyet nem tud leírni. Másodszor, a 4096-bájtos szektorú v4-es konténerek elférnek a szektorméret-aritmetikában, de nem erre van hangolva a kód, és a 64-bites stream-méret nincs figyelembe véve: a HotXLS az alsó 32 bitet olvassa a 0x78 eltolásnál, és a felső felét érintetlenül hagyja, ami helyes v3-hoz, és csak v3-hoz. Harmadszor, a bejegyzéskeresés lapos pásztázás név szerint a könyvtárlistán, nem pedig egy bejárás a vörös-fekete fán lefelé egy szülő-tárolóból, így a beágyazott tárolók névütközés alapján oldódnak fel, nem útvonal alapján — minden stream, amire egy .xls fájlnak szüksége van, a felső szinten ül, ami az egyszerűbb tervezést védhetővé teszi, de a SomeStorage/SomeStream-et megcímezni akaró kód nem fogja megtalálni

Ez semmit nem változtat azon, mire való az egység. A konténer réteg birtoklása hétköznapi Object Pascallá teszi a .xls kezelését: elemezhető egy bájttömbből, tesztelhető fájlrendszer nélkül, hordozható bármely platformra, amit a fordító céloz, és mentes egy COM apartmenttől. Nyugdíjazza a szagló-rövidítéseket is, mert egy munkafüzet azonosítása most a könyvtárának olvasását jelenti, nem az első nyolc bájtját — ugyanaz a fegyelem, amely a munkalapnevek listázása a teljes munkafüzet megnyitása nélkül mögött áll

A TlxCompoundFile a HotXLS Excel Component részeként érkezik Delphihez és C++Builderhez, a BIFF és OOXML rétegek mellett, amelyek rá épülnek; a termékoldal tartalmazza a teljes egységreferenciát és a támogatott fordítómátrixot