Articolo tecnico

Leggere File Compound OLE2 in Delphi Senza COM IStorage

HotXLS Excel Library per Delphi e C++Builder legge e scrive il contenitore Compound File Binary dietro ogni file .xls legacy in puro Object Pascal. La classe TlxCompoundFile implementa direttamente il layout [MS-CFB] versione 3 contro un TStream — header, DIFAT, catene FAT, MiniFAT e albero delle directory — senza alcun ole32.dll e senza COM IStorage in nessun punto del percorso

Suona come idraulica, e per vent'anni è stata idraulica di proprietà di qualcun altro. Ogni codebase Delphi che toccava un file .xls allungava la mano verso StgOpenStorage, otteneva indietro un IStorage, ed estraeva lo stream Workbook da esso. Tre righe, funzionava bene, nessuno ci pensava più — finché il giorno in cui lo stesso codice ha dovuto girare in un posto dove non c'era Windows

Perché StgOpenStorage smette di funzionare su un server?

L'API COM di structured storage fallisce esattamente nelle forme di deployment in cui vive il codice Delphi moderno, per motivi che non hanno nulla a che fare con il formato del file. StgOpenStorage è un punto di ingresso Win32 in ole32.dll: vuole un percorso su un filesystem, vuole COM inizializzato sul thread chiamante, e vuole trovarsi su Windows. Il requisito del percorso fa male per primo, perché un endpoint REST che riceve un workbook caricato ha i byte in un buffer, non su disco — quindi scrivi il buffer in un file temporaneo, lo apri, lo rileggi, lo cancelli, e ora possiedi un ciclo di vita di file temporaneo da sbagliare sotto carico. ILockBytes è la via di fuga documentata, ma cablare un'implementazione personalizzata sopra un TMemoryStream è più interop COM di quanto la maggior parte dei team voglia. Il requisito di inizializzazione morde per secondo, di solito in un thread worker di servizio su cui nessuno ha chiamato CoInitialize, e il requisito di piattaforma chiude la conversazione nel momento in cui il target è Linux sotto FPC, un'immagine container, o macOS. HotXLS quindi mantiene il classico percorso lxOLE costruito su StgOpenStorage come default, poiché è collaudato in battaglia e i chiamanti esistenti non dovrebbero dover cambiare; TlxCompoundFile è l'alternativa opt-in per tutti gli altri

Cosa dicono realmente l'header e le catene FAT

I primi 512 byte di un compound file rispondono a ogni domanda strutturale necessaria prima di leggere un solo byte di payload. [MS-CFB] §2.2 fissa la firma dell'header all'offset 0 come gli otto byte D0 CF 11 E0 A1 B1 1A E1, e lxIsCompoundStream verifica esattamente quello, ripristinando la posizione dello stream dopo così un chiamante può ispezionare senza disturbare nulla. Altri quattro campi decidono la geometria: l'ordine dei byte a 0x1C deve essere 0xFFFE, che funge anche da secondo controllo firma economico; lo shift di settore a 0x1E dà la dimensione del settore come 1 shl SectorShift, così la versione 3 usa shift 9 per settori da 512 byte e la versione 4 usa shift 12 per 4096; lo shift dei mini settori a 0x20 è 6, rendendo i mini settori di 64 byte; e il cutoff del mini stream a 0x38 è 4096. L'aritmetica degli indirizzi che segue è il punto più comune dove sbagliare. Il settore 0 inizia subito dopo l'header, quindi il settore N inizia all'offset in byte 512 + N * SectorSize — nota il 512 letterale, non SectorSize. Su un file versione 3 i due sono identici e il bug si nasconde per sempre; su un file versione 4 legge silenziosamente il settore sbagliato, motivo per cui HotXLS mantiene questo in un'unica funzione, SidToOffset

Un compound file è un filesystem FAT dentro un file, quindi leggerlo significa percorrere liste concatenate di ID di settore dove FAT[n] contiene l'ID che segue il settore n. Tre sentinelle terminano o annotano una catena — ENDOFCHAIN, FATSECT per un settore appartenente alla FAT stessa, e DIFSECT per un settore DIFAT — e tutte e tre si leggono come interi con segno a 32 bit negativi, il che mantiene semplici le condizioni di ciclo. Trovare la FAT richiede un'indirezione in più: la DIFAT è l'array di ID di settore che dice dove risiedono i settori FAT, e le sue prime 109 voci si trovano nell'header all'offset 0x4C. TlxCompoundFile percorre quelle 109, si ferma alla prima voce negativa, e concatena ogni settore FAT in un unico array Integer piatto. Sono 109 settori FAT con 128 voci ciascuno su un settore da 512 byte, quindi 13.952 settori indirizzabili, quindi circa 6,8 MiB di contenitore prima che la DIFAT debba traboccare in una propria catena

La seconda tabella di allocazione esiste perché i settori da 512 byte sprecano la maggior parte del proprio spazio sui piccoli stream. Ogni stream sotto il cutoff di 4096 byte non viene memorizzato affatto in settori: vive dentro il mini stream, esso stesso uno stream ordinario appeso alla voce di directory radice, suddiviso in mini settori da 64 byte e concatenato tramite una MiniFAT parallela radicata all'offset dell'header 0x3C. Apri un vero .xls e lo stream Workbook si trova sulla FAT normale mentre gli stream summary-information si trovano giù nello spazio dei mini settori, motivo per cui un'implementazione che copre solo il percorso FAT sembra funzionare fino al momento in cui serve il metadata del documento. La directory è la terza struttura ed è quella che rende navigabile il contenitore: ogni voce è esattamente 128 byte, quattro per settore da 512 byte, e porta un nome UTF-16 nei primi 64 byte, la sua lunghezza in byte a 0x40, il tipo di oggetto a 0x42 (1 = storage, 2 = stream, 5 = root), collegamenti dell'albero a 0x44, 0x48 e 0x4C, il settore iniziale a 0x74 e la dimensione a 32 bit dello stream a 0x78. Quella lunghezza di nome conta i byte incluso il null terminatore, quindi il conteggio dei caratteri è NameLen div 2 - 1, e sbagliarlo di uno è come finisci con uno stream chiamato Workboo

Estrarre uno stream Workbook da un buffer di memoria

TlxCompoundFile.OpenStream nasconde tutto quanto sopra dietro un'unica chiamata che prende un nome di stream e restituisce un TlxCfbStream che contiene i byte completamente materializzati. L'intera sequenza — ispezione, caricamento, estrazione — viene eseguita contro un TBytesStream senza che nulla tocchi mai il disco

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;

Due dettagli lì valgono la pena di essere segnalati. LoadFromStream prende un flag AOwnsStream il cui default è False, così il chiamante mantiene la responsabilità dello stream sorgente — deliberato, perché il caso comune è uno stream che l'applicazione già possiede. E OpenStream restituisce un TlxCfbStream che possiede una propria copia dei byte, esposta tramite Data, Size, Read, Seek e CopyTo. Quella copia è un costo reale su un workbook grande, ed è il prezzo onesto di un design in cui l'oggetto restituito resta valido dopo che il contenitore viene liberato. Quando un workbook è abbastanza grande che una copia completa in memoria è del tutto la forma sbagliata, il lettore diretto in streaming per fogli di calcolo di grandi dimensioni è il punto di ingresso migliore

Perché un XLSX crittografato sembra un file XLS?

Perché lo è, a livello di contenitore — ed è questo il ritorno pratico di possedere quello strato. Apri un .xlsx crittografato in un editor esadecimale e i primi otto byte sono D0 CF 11 E0 A1 B1 1A E1, identici byte per byte a un .xls d'annata 1997, perché la crittografia [MS-OFFCRYPTO] non crittografa il pacchetto ZIP sul posto: avvolge l'intero pacchetto dentro un contenitore CFB come stream chiamato EncryptedPackage, accanto a uno stream EncryptionInfo che descrive l'algoritmo. La firma quindi identifica il contenitore e non dice nulla sul payload. Distinguere un workbook BIFF da un pacchetto OOXML crittografato significa leggere la directory, che dopo LoadFromStream è una scansione su EntryCount ed Entries, oppure una coppia di sonde 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;

I nomi delle directory meritano un avviso a parte: gli stream summary-information portano un carattere di controllo 0x05 iniziale nei loro nomi, quindi un confronto scritto contro una semplice stringa di visualizzazione non li farà mai corrispondere e una riga di log ingenua li renderizza come spazzatura. Tutto ciò che sta a valle di questa classificazione — derivare la chiave, verificare il verificatore della password — è un problema separato, trattato nelle note su perché Excel rifiuta un workbook crittografato con la modalità di cifratura sbagliata. Lo strato del contenitore ti dice solo davanti a quale porta ti trovi

Scrivere un contenitore che Excel aprirà davvero

Il lato scrittura di TlxCompoundFile è deliberatamente più stretto del lato lettura, e capire perché evita una discussione con la specifica. [MS-CFB] permette uno spazio enorme di contenitori validi: storage multi-livello, alberi di directory red-black correttamente bilanciati, mini stream, catene DIFAT. Excel emette un piccolo angolo di quello spazio e ne legge uno un po' più ampio. HotXLS scrive un angolo ancora più piccolo — il minimo che Excel dimostrabilmente carica. Ogni stream va sulla FAT normale senza alcun percorso mini-stream, il che costa spazio su disco e compra correttezza: uno stream summary di 300 byte che Excel avrebbe impacchettato in cinque mini settori da 64 byte occupa invece un intero settore da 512 byte, e per un workbook quello è rumore rispetto a mantenere una seconda tabella di allocazione, un secondo percorso di catena e lo stream della voce radice che lo sostiene sul percorso di scrittura. Le voci di directory formano una catena piatta di fratelli sotto la radice con ogni nodo colorato nero, e l'ordine di emissione è fisso: segnaposto dell'header, settori dati dello stream, settori di directory, settori FAT, poi un seek all'indietro per riscrivere l'header con gli ID di settore che sono noti solo alla fine. La FAT si dimensiona da sola tramite un breve ciclo a punto fisso, perché aggiungere settori FAT può spingere il conteggio dei settori abbastanza in alto da richiedere un altro settore 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;

Dove si ferma l'implementazione

Tre confini vale la pena dichiarare apertamente, perché un lettore di contenitori che gestisce silenziosamente male un caso limite è peggio di uno che solleva un'eccezione. TlxCompoundFile legge le 109 voci DIFAT residenti nell'header e non segue la catena DIFAT a 0x44 oltre esse, limitando un contenitore leggibile a circa 6,8 MiB su settori da 512 byte — comodamente sopra i veri file .xls che HotXLS incontra sul campo, ma un tetto rigido comunque, e lo scrittore applica esplicitamente lo stesso limite anziché emettere un contenitore che non può descrivere. Secondo, i contenitori versione 4 con settori da 4096 byte sono gestiti dall'aritmetica della dimensione settore ma non sono ciò per cui il codice è ottimizzato, e la dimensione stream a 64 bit non viene consultata: HotXLS legge i 32 bit bassi all'offset 0x78 e lascia stare la metà alta, il che è corretto per la versione 3 e solo per la versione 3. Terzo, la ricerca delle voci è una scansione piatta per nome attraverso l'elenco di directory anziché un percorso lungo l'albero red-black da uno storage genitore, quindi gli storage annidati si risolvono per collisione di nome anziché per percorso — ogni stream di cui un file .xls ha bisogno si trova al livello superiore, il che rende difendibile il design più semplice, ma codice che si aspetta di indirizzare SomeStorage/SomeStream non lo troverà

Nulla di tutto ciò cambia a cosa serve l'unit. Possedere lo strato del contenitore trasforma la gestione di .xls in ordinario Object Pascal: analizzabile da un array di byte, testabile senza un filesystem, portabile su qualunque piattaforma il compilatore prenda di mira, e libero da un apartment COM. Ritira anche le scorciatoie di ispezione, perché identificare un workbook ora significa leggere la sua directory anziché i suoi primi otto byte — la stessa disciplina dietro l'elenco dei nomi dei fogli senza aprire l'intero workbook

TlxCompoundFile viene fornito come parte del HotXLS Excel Component per Delphi e C++Builder, insieme agli strati BIFF e OOXML che poggiano su di esso; la pagina prodotto riporta il riferimento completo dell'unit e la matrice dei compilatori supportati