Articolo tecnico

Deriva lunghezza record BIFF in un writer XLS Delphi

HotXLS 2.376.0 ha corretto una deriva della lunghezza dei record BIFF nel proprio writer XLS classico: l'emitter SXEx per le viste PivotTable dichiarava un body di 24 byte nell'header e poi ne aggiungeva 26. Un reader BIFF si fida della lunghezza dichiarata, quindi i due byte in eccesso desincronizzavano tutto ciò che seguiva e i workbook che abbinavano una PivotTable a un chart sheet perdevano il chart alla riapertura

La parte interessante non è la parola off-by-one. È la distanza tra l'errore e il sintomo. Nulla falliva nel punto del bug. I record pivot venivano serializzati correttamente, il file veniva scritto senza errori, Excel lo apriva e il danno emergeva solo centinaia di byte più avanti, in un substream del tutto non correlato. Questa distanza è tipica di ogni formato binario con lunghezze prefissate, e vale la pena capirla prima di scrivere un altro emitter per uno di questi formati

Perché una sola lunghezza di record errata distrugge un intero worksheet stream?

Un workbook BIFF8 non ha framing oltre alla propria aritmetica. Ogni record è un header di 4 byte formato da record id (2 byte) e body length (2 byte), seguito esattamente da quel numero di byte di payload ([MS-XLS] 2.1.4). Non esistono separatori, magic byte, checksum o punti di risincronizzazione. Il reader arriva al record successivo solo perché il precedente ha detto la verità sulla propria dimensione. La lunghezza dichiarata non è un metadato del record: è il puntatore al successivo. Seguite quindi cosa hanno fatto i due byte in eccesso. Il reader ha consumato l'header SXEx, saltato i 24 byte promessi dall'header e si è fermato due byte prima, su una coppia di zeri rimasta dal body sovradimensionato. Ha letto quegli zeri come record id $0000, poi ha letto il record id del worksheet EOF ($000A) come lunghezza di quel record fantasma e ha saltato diligentemente dieci byte dentro ciò che veniva dopo. Da quel punto ogni header è stato letto a un offset errato. Nel workbook che falliva questo ha prodotto un chart sheet il cui _Chart era nil dopo la riapertura e un debug dump in cui $18AF veniva interpretato come record id. Nessuno dei due valori compare vicino al codice pivot

L'emitter e il writer non si parlano mai

La ragione strutturale per cui la deriva era possibile è che HotXLS costruisce un record BIFF come TXLSBlob i cui header e payload sono due fatti indipendenti. EmitSXEx scrive il record id, poi Blob.AddWord(24) per la lunghezza e quindi aggiunge il body campo per campo. Quel 24 è una costante contata a mano, mai derivata dai byte che seguono né verificata contro di essi. Anche il percorso di scrittura non chiude il divario: AddRec inoltra il blob a TXLSBlobList.Append, che copia nell'output stream verbatim i byte Data.DataLength. DataLength è il conteggio reale dei byte, quindi il writer emette fedelmente 26 byte di body dietro un header che ne dichiara 24. Entrambe le metà fanno esattamente ciò che è stato loro chiesto, e nessuno ha il compito di notare la contraddizione. HotXLS evita già il problema quando riproduce payload conservati: TXLSWorkbook.StoreDConnBlobs calcola la length word dell'header dalla lunghezza reale del body invece di usare un literal, ed è proprio per questo che il replay dei blob non ha mai subito derive

Cosa fissa [MS-XLS] 2.4.282 su SXEx

La specifica è inequivocabile sulla dimensione, ed è ciò che ha reso meccanica la correzione. [MS-XLS] 2.4.282 definisce il body SXEx come un grbit da 4 byte seguito da dieci campi da 2 byte: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle e cchVacateStyle. Quattro più venti fa ventiquattro. Il vecchio emitter scriveva undici word zero dove la specifica ne definisce dieci, e le chiamate AddWord(0) anonime non portavano nomi di campo, quindi contarle a occhio in review era affidabile esattamente quanto sembra. La preallocazione era l'indizio che il layout era stato capito ma il loop no: TXLSBlob.Create(28) richiede esattamente quattro byte di header più un body da 24 byte, ma il blob superava quel suggerimento a ogni chiamata e cresceva in silenzio, perché AdjustBufferSize rialloca quando serve. Un hint di capacità che il codice supera subito merita un secondo sguardo in qualsiasi serializer

function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
  Blob: TXLSBlob;
begin
  Blob := TXLSBlob.Create(28);   // header da 4 byte + body da 24 byte
  Blob.AddWord($00C6);
  Blob.AddWord(24);
  Blob.AddByte($02);
  Blob.AddByte($00);             // grbit1 = fPrintTitles
  Blob.AddByte($00);
  Blob.AddByte($00);             // grbit2
  // Dieci word zero completano il body da 24 byte secondo [MS-XLS] 2.4.282
  // La lunghezza dichiarata DEVE corrispondere ai byte scritti, altrimenti ogni record
  // successivo viene interpretato male
  Blob.AddWord(0);               // csxformat
  Blob.AddWord(0);               // cchErrorString
  Blob.AddWord(0);               // cchNullString
  Blob.AddWord(0);               // cchTag
  Blob.AddWord(0);               // csxselect
  Blob.AddWord(0);               // crwPage
  Blob.AddWord(0);               // ccolPage
  Blob.AddWord(0);               // cchPageFieldStyle
  Blob.AddWord(0);               // cchTableStyle
  Blob.AddWord(0);               // cchVacateStyle
  AddRec(DataList, Blob);
  Result := 1;
end;

Perché questo è sopravvissuto a un'intera suite PivotTable?

Perché i test pivot esistenti non facevano mai round-trip attraverso un file. Costruivano un workbook, verificavano il modello in memoria e si fermavano, mentre le asserzioni in memoria non possono vedere una discrepanza di lunghezza che esiste solo nel byte stream serializzato. L'insieme di record trattato da scrivere record PivotTable BIFF8 da Delphi era ben testato secondo quello standard e tuttavia distribuiva un emitter che corrompeva lo stream. Inoltre il difetto doveva combinarsi con una seconda funzionalità per diventare visibile: un worksheet pivotato seguito da poco altro continuava a riaprire, perché la corruzione usciva dalla fine di un substream che nessuno controllava. Solo la combinazione di una PivotTable e di un chart sheet, dove chart sheet e drawing occupano un substream successivo a quello del worksheet, trasformava un disallineamento silenzioso in un oggetto visibilmente mancante

// PivotChartRoundTripThroughLinkRecords, condensato
Wb.Sheets.Add.Name := 'Report';
Wb.Sheets[2].AddPivotTable('Data!A1:B3', 2, 2, 'SalesPivot');
Wb.Sheets.AddChartSheet('PivotView', TXLSChartType(2), '', '', '',
  Series, No3D, PivotInfo);
Assert.AreEqual(1, Wb.SaveAs(TempPath));
Wb.Free;
Wb := TXLSWorkbook.Create;
Wb.Open(TempPath);                       // il misparse avviene qui
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);

Prima della correzione, Wb.Sheets[3]._Chart era nil su quella riga, perché il reader aveva perso il confine del substream molto prima di raggiungere il chart BOF. L'asserzione che ha finalmente intercettato un bug di serializzazione pivot riguardava un chart

Come rileggere uno stream BIFF disallineato fino al primo record errato

Percorrete la catena degli header e stampatela, perché uno stream BIFF desincronizzato si annuncia strutturalmente molto prima che i dati appaiano errati. Partite dal substream BOF ($0809), leggete id e length, avanzate di quattro più la lunghezza e ripetete. Finché lo stream è allineato atterrate su record id plausibili e la catena termina esattamente su EOF ($000A). Quando deriva, ottenete id inesistenti, lunghezze che superano il buffer o una catena che passa dritta oltre il punto in cui avrebbe dovuto esserci EOF

// Percorre uno stream di record BIFF e si ferma al primo header impossibile
procedure ScanRecords(Buf: PByte; Size: LongWord);
var
  Pos: LongWord;
  Id, Len: Word;
begin
  Pos := 0;
  while Pos + 4 <= Size do
  begin
    Id  := PWord(Buf + Pos)^;
    Len := PWord(Buf + Pos + 2)^;
    // Un id zero non è mai un record legale, e un body oltre il
    // buffer dimostra che la catena è già derivata a monte
    if (Id = 0) or (Pos + 4 + LongWord(Len) > Size) then
    begin
      WriteLn(Format('desync at %d: id=$%.4x len=%d', [Pos, Id, Len]));
      Break;
    end;
    WriteLn(Format('%6d  id=$%.4x  len=%d', [Pos, Id, Len]));
    if Id = $000A then
      WriteLn('-- EOF, substream ends cleanly --');
    Inc(Pos, 4 + LongWord(Len));
  end;
end;

Poi leggete l'output a ritroso e tenete a mente una regola: il primo record che non riesce a essere analizzato è quasi mai il colpevole. È la vittima. Il colpevole è il record immediatamente precedente, l'ultimo analizzato senza protestare, perché chi mente sulla propria lunghezza viene analizzato correttamente. In questo caso la scansione si è fermata su un record fantasma $0000 e il record precedente era SXEx. Confrontate la lunghezza dichiarata di quel record con l'elenco dei campi nella specifica, byte per byte, e l'aritmetica torna oppure no. Se la scansione non raggiunge neppure un primo record sensato, il problema è a un livello inferiore, nel compound file OLE2 che contiene lo stream Workbook, e nessuna quantità di dump a livello record sarà d'aiuto

Un emitter che non può mentire sulla propria lunghezza

La correzione duratura non è una costante corretta, ma eliminare l'opportunità di scriverne una errata. Riservate la word della lunghezza, emettete il body e poi correggete l'header dal conteggio dei byte realmente prodotti. HotXLS espone ciò che serve: TXLSBlob.DataLength restituisce l'offset corrente e SetWord riscrive in una posizione già emessa

function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
  Blob.AddWord(RecId);
  Result := Blob.DataLength;   // ricordare dove si trova la word length
  Blob.AddWord(0);             // placeholder, corretto da EndRecord
end;

procedure EndRecord(Blob: TXLSBlob; LenPos: LongWord);
var
  Body: LongWord;
begin
  Body := Blob.DataLength - LenPos - SizeOf(Word);
  if Body > 8224 then
    raise Exception.Create('BIFF body exceeds 8224 bytes, split with Continue');
  Blob.SetWord(Word(Body), LenPos);
end;

È importante essere onesti su dove finisce questa garanzia. Un'asserzione generale secondo cui i byte emessi equivalgono a 2 + 2 + declared vale solo per i record che rientrano nel limite BIFF8 di 8224 byte di payload. I body più grandi dichiarano legittimamente 8224 nell'header e continuano nei record $003C Continue, esattamente come fanno i writer HotXLS di pivot cache e connection per i payload grandi, quindi l'invariante è condizionale: sotto il limite la lunghezza del blob emesso deve essere uguale alla lunghezza dichiarata più quattro, sopra il limite è lo splitter a possedere l'aritmetica. Codificate questa distinzione nell'helper invece che in un commento. Lo stesso ragionamento vale per ogni formato tag-length-value, non solo per BIFF. Un emitter che dichiara una dimensione prima di conoscerla ha scritto una pretesa che il codice non può controllare e il reviewer non può contare, e funziona finché una seconda funzionalità non arriva downstream della prima

Il writer BIFF8, gli emitter dei record pivot e il chart substream discusso qui sono inclusi nel componente spreadsheet Delphi HotXLS per Delphi e C++Builder, che legge e scrive XLS, XLSX e ODS senza Excel installato