Teknisk artikel

Læs OLE2 Compound Files i Delphi uden COM IStorage

HotXLS Excel Library til Delphi og C++Builder læser og skriver Compound File Binary-containeren bag hver legacy-.xls-fil i ren Object Pascal. TlxCompoundFile-klassen implementerer [MS-CFB] version 3-layoutet direkte mod en TStream — header, DIFAT, FAT-kæder, MiniFAT og directory-træet — uden ole32.dll og uden COM IStorage noget sted i stien

Det lyder som VVS, og i tyve år var det VVS, nogen andre ejede. Hver Delphi-kodebase, der rørte en .xls-fil, rakte ud efter StgOpenStorage, fik en IStorage tilbage, og trak Workbook-strømmen ud af den. Tre linjer, virkede fint, ingen tænkte over det igen — indtil den dag, den samme kode skulle køre et sted, Windows ikke var

Hvorfor stopper StgOpenStorage med at virke på en server?

COM structured-storage-API'en fejler netop i de udrulningsformer, moderne Delphi-kode lever i, af grunde der intet har med filformatet at gøre. StgOpenStorage er et Win32-indgangspunkt i ole32.dll: den vil have en sti på et filsystem, den vil have COM initialiseret på det kaldende tråd, og den vil være på Windows. Sti-kravet gør ondt først, fordi et REST-endpoint, der modtager en uploadet arbejdsbog, har bytesne i en buffer, ikke på disk — så man skriver bufferen til en temp-fil, åbner den, læser den tilbage, sletter den, og ejer nu en temp-fil-livscyklus, man kan få galt under belastning. ILockBytes er den dokumenterede undvigelsesluge, men at koble en brugerdefineret implementering over en TMemoryStream er mere COM-interop, end de fleste teams vil have. Initialiseringskravet bider for det andet, som regel i en service-worker-tråd, ingen kaldte CoInitialize på, og platformskravet ender samtalen i det øjeblik målet er Linux under FPC, et container-image, eller macOS. HotXLS beholder derfor den klassiske lxOLE-sti bygget på StgOpenStorage som standard, siden den er kamptestet, og eksisterende kaldere ikke bør skulle ændre sig; TlxCompoundFile er opt-in-alternativet for alle andre

Hvad header og FAT-kæderne faktisk siger

De første 512 bytes af en compound file besvarer hvert strukturelt spørgsmål, man har brug for, før man læser en byte payload. [MS-CFB] §2.2 fastsætter header-signaturen ved offset 0 som de otte bytes D0 CF 11 E0 A1 B1 1A E1, og lxIsCompoundStream tjekker netop det, og gendanner strøm-positionen bagefter, så en kalder kan snuse uden at forstyrre noget. Fire felter mere afgør geometrien: byte-rækkefølgen ved 0x1C skal være 0xFFFE, hvilket fordobler som et billigt andet signaturtjek; sector shift ved 0x1E giver sektorstørrelsen som 1 shl SectorShift, så version 3 bruger shift 9 for 512-byte-sektorer og version 4 bruger shift 12 for 4096; mini sector shift ved 0x20 er 6, hvilket gør mini-sektorer 64 bytes; og mini-stream-cutoffet ved 0x38 er 4096. Adresse-aritmetikken der følger er det mest almindelige sted at fejle. Sektor 0 begynder umiddelbart efter headeren, så sektor N starter ved byteoffset 512 + N * SectorSize — bemærk den bogstavelige 512, ikke SectorSize. På en version 3-fil er de to identiske, og fejlen gemmer sig for evigt; på en version 4-fil læser den stille den forkerte sektor, hvilket er hvorfor HotXLS holder dette i én funktion, SidToOffset

En compound file er et FAT-filsystem inde i en fil, så at læse den betyder at gennemgå linkede lister af sektor-id'er, hvor FAT[n] holder id'et der følger sektor n. Tre sentineller afslutter eller annoterer en kæde — ENDOFCHAIN, FATSECT for en sektor der hører til FAT'en selv, og DIFSECT for en DIFAT-sektor — og alle tre læses som negative signerede 32-bit heltal, hvilket holder loop-betingelserne simple. At finde FAT'en kræver én indirektion mere: DIFAT'en er arrayet af sektor-id'er, der siger hvor FAT-sektorerne bor, og dens første 109 poster sidder i headeren ved offset 0x4C. TlxCompoundFile gennemgår de 109, stopper ved den første negative post, og sammenkæder hver FAT-sektor til ét fladt Integer-array. Det er 109 FAT-sektorer med 128 poster hver på en 512-byte-sektor, altså 13.952 adresserbare sektorer, altså omkring 6,8 MiB container, før DIFAT'en må spille over i sin egen kæde

Den anden allokeringstabel findes, fordi 512-byte-sektorer spilder det meste af deres plads på små streams. Enhver stream under 4096-byte-cutoffet gemmes slet ikke i sektorer: den bor inde i mini-streamen, selv en ordinær stream hængende af root-directory-posten, underopdelt i 64-byte mini-sektorer og kædet gennem en parallel MiniFAT rodfæstet ved header-offset 0x3C. Åbn en rigtig .xls, og Workbook-strømmen sidder på den normale FAT, mens summary-information-strømmene sidder nede i mini-sektor-rummet, hvilket er hvorfor en implementering, der kun dækker FAT-stien, ser ud til at virke helt indtil den skal bruge dokumentmetadata. Directory'et er den tredje struktur og den, der gør containeren navigerbar: hver post er præcis 128 bytes, fire per 512-byte-sektor, bærende et UTF-16-navn i de første 64 bytes, dens bytelængde ved 0x40, objekttypen ved 0x42 (1 = storage, 2 = stream, 5 = root), træ-links ved 0x44, 0x48 og 0x4C, start-sektoren ved 0x74 og den 32-bit stream-størrelse ved 0x78. Den navnelængde tæller bytes inklusive den afsluttende null, så tegnantallet er NameLen div 2 - 1, og at få den galt med én er, hvordan man ender med en stream ved navn Workboo

At trække en Workbook-stream ud af en hukommelsesbuffer

TlxCompoundFile.OpenStream skjuler alt det ovenstående bag ét kald, der tager et stream-navn og returnerer en TlxCfbStream, der holder de fuldt materialiserede bytes. Hele sekvensen — snus, indlæs, udtræk — kører mod en TBytesStream, uden at noget nogensinde rører 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;

To detaljer her er værd at fremhæve. LoadFromStream tager et AOwnsStream-flag, der som standard er False, så kalderen beholder ansvaret for kildestrømmen — bevidst, fordi det almindelige tilfælde er en strøm, applikationen allerede ejer. Og OpenStream returnerer en TlxCfbStream, der ejer sin egen kopi af bytesne, eksponeret gennem Data, Size, Read, Seek og CopyTo. Den kopi er en reel omkostning på en stor arbejdsbog, og det er den ærlige pris for et design, hvor det returnerede objekt forbliver gyldigt, efter containeren er frigjort. Når en arbejdsbog er stor nok til at en fuld kopi i hukommelsen er den helt forkerte form, er den strømmende direkte læser til oversize regneark det bedre indgangspunkt

Hvorfor ligner en krypteret XLSX en XLS-fil?

Fordi den er én, på containerniveau — og det er den praktiske gevinst ved at eje det lag. Åbn en krypteret .xlsx i en hex-editor, og de første otte bytes er D0 CF 11 E0 A1 B1 1A E1, byte for byte identisk med en 1997-årgang-.xls, fordi [MS-OFFCRYPTO]-kryptering ikke krypterer ZIP-pakken på plads: den pakker hele pakken ind i en CFB-container som en stream ved navn EncryptedPackage, ved siden af en EncryptionInfo-stream, der beskriver algoritmen. Signaturen identificerer derfor containeren og siger intet om payloaden. At skelne en BIFF-arbejdsbog fra en krypteret OOXML-pakke betyder at læse directory'et, hvilket efter LoadFromStream er et scan over EntryCount og Entries, eller et par HasStream-prober

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;

Directory-navne fortjener deres egen advarsel: summary-information-strømmene bærer et indledende 0x05-kontroltegn i deres navne, så en sammenligning skrevet mod en almindelig visningsstreng vil aldrig matche dem, og en naiv logline rendrer dem som skrammel. Alt nedstrøms for denne klassifikation — at udlede nøglen, tjekke password-verifieren — er et separat problem, dækket i notaterne om hvorfor Excel afviser en arbejdsbog krypteret med den forkerte cipher-tilstand. Container-laget fortæller kun, hvilken dør man står foran

At skrive en container, Excel faktisk vil åbne

Skrivesiden af TlxCompoundFile er bevidst smallere end læsesiden, og at forstå hvorfor sparer en diskussion med specifikationen. [MS-CFB] tillader et enormt rum af gyldige containere: flerniveau-storages, korrekt balancerede red-black directory-træer, mini-streams, DIFAT-kæder. Excel udsender et lille hjørne af det rum og læser et noget større et. HotXLS skriver et hjørne endnu mindre — det minimum, Excel demonstrerbart indlæser. Hver stream går på den normale FAT uden nogen mini-stream-sti, hvilket koster diskplads og køber korrekthed: en 300-byte summary-stream, som Excel ville have pakket ind i fem 64-byte mini-sektorer, optager i stedet en hel 512-byte-sektor, og for en arbejdsbog er det støj ved siden af at vedligeholde en anden allokeringstabel, en anden kædegennemgang og den root-entry-stream, der bakker den op på skrivesiden. Directory-poster danner en flad søskendekæde under roden med hver node farvet sort, og udsendelsesrækkefølgen er fast: header-pladsholder, stream-datasektorer, directory-sektorer, FAT-sektorer, derefter en søgning tilbage for at genskrive headeren med de sektor-id'er, der først kendes til sidst. FAT'en dimensionerer sig selv gennem en kort fastpunkts-løkke, fordi at tilføje FAT-sektorer kan skubbe sektorantallet højt nok til at kræve endnu en 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;

Hvor implementeringen stopper

Tre grænser er værd at nævne ligeud, fordi en container-læser, der stille mishåndterer et kanttilfælde, er værre end en, der rejser en fejl. TlxCompoundFile læser de 109 DIFAT-poster, der bor i headeren, og følger ikke DIFAT-kæden ved 0x44 ud over dem, hvilket sætter et loft for en læsbar container på omkring 6,8 MiB på 512-byte-sektorer — komfortabelt over de rigtige .xls-filer, HotXLS møder i marken, men et hårdt loft ikke desto mindre, og skriveren håndhæver den samme grænse eksplicit frem for at udsende en container, den ikke kan beskrive. For det andet imødekommes version 4-containere med 4096-byte-sektorer af sektorstørrelses-aritmetikken, men de er ikke hvad koden er tunet til, og den 64-bit stream-størrelse konsulteres ikke: HotXLS læser de lave 32 bit ved offset 0x78 og lader den høje halvdel være i fred, hvilket er korrekt for version 3 og kun for version 3. For det tredje er post-opslag et fladt scan efter navn på tværs af directory-listen frem for en gennemgang ned i red-black-træet fra en forældre-storage, så indlejrede storages opløses ved navnekollision frem for ved sti — hver stream, en .xls-fil har brug for, sidder på topniveauet, hvilket er hvad der gør det simplere design forsvarligt, men kode der forventer at adressere SomeStorage/SomeStream vil ikke finde den

Intet af det ændrer, hvad enheden er til. At eje container-laget gør .xls-håndtering til almindelig Object Pascal: parsbar fra et bytearray, testbar uden et filsystem, portabel til hvilken platform compileren end sigter mod, og fri for et COM-apartment. Det pensionerer også snuse-genvejene, fordi at identificere en arbejdsbog nu betyder at læse dens directory frem for dens første otte bytes — den samme disciplin bag at liste arknavne uden at åbne hele arbejdsbogen

TlxCompoundFile leveres som del af HotXLS Excel Component til Delphi og C++Builder, sammen med BIFF- og OOXML-lagene der sidder ovenpå den; produktsiden bærer den fulde enhedsreference og den understøttede compiler-matrix