Technischer Artikel

BIFF-Längenfehler in einem Delphi-XLS-Writer

HotXLS 2.376.0 behob einen BIFF-Fehler bei der Record-Länge im klassischen XLS-Writer: Der SXEx-Emitter für PivotTable-Ansichten deklarierte im Header einen Body von 24 Bytes und hängte anschließend 26 Bytes an. Ein BIFF-Reader vertraut auf die deklarierte Länge, daher brachten die beiden überschüssigen Bytes alles Folgende aus dem Takt, und Arbeitsmappen mit einer PivotTable und einem Chart-Sheet verloren beim erneuten Öffnen das Chart

Der interessante Teil ist nicht das Off-by-one-Wort, sondern der Abstand zwischen Fehler und Symptom. Am Fehlerort ging nichts schief. Die Pivot-Records wurden sauber serialisiert, die Datei wurde ohne Fehler geschrieben, Excel öffnete sie, und der Schaden wurde erst Hunderte Bytes später in einem völlig unabhängigen Substream sichtbar. Diese Distanz ist typisch für jedes längenpräfixierte Binärformat, und man sollte sie verstehen, bevor man einen weiteren Emitter dafür schreibt

Warum zerstört eine falsche Record-Länge einen ganzen Worksheet-Stream?

Ein BIFF8-Workbook-Stream besitzt außer seiner eigenen Arithmetik keine Framing-Information. Jeder Record besteht aus einem 4-Byte-Header mit Record-ID (2 Bytes) und Body-Länge (2 Bytes), gefolgt von genau dieser Anzahl Payload-Bytes ([MS-XLS] 2.1.4). Es gibt keinen Separator, kein Magic Byte, keine Prüfsumme und keinen Resynchronisationspunkt. Der Reader landet nur deshalb am nächsten Record, weil der vorige ihm die Wahrheit über seine eigene Größe gesagt hat. Die deklarierte Länge ist keine Metadaten zum Record, sondern der Zeiger auf den nächsten. Verfolgen Sie, was die beiden überschüssigen Bytes bewirken: Der Reader konsumierte den SXEx-Header, übersprang die vom Header versprochenen 24 Bytes und landete zwei Bytes zu früh auf einem Paar Nullen, das aus dem übergroßen Body übrig war. Er las diese Nullen als Record-ID $0000, danach die ID des folgenden Worksheet-EOF-Records ($000A) als Länge jenes Phantom-Records und übersprang pflichtbewusst zehn Bytes in das, was danach kam. Von dort wurde jeder Header am falschen Offset gelesen. In der fehlerhaften Arbeitsmappe entstand dadurch ein Chart-Sheet, dessen _Chart nach dem Öffnen nil war, sowie ein Debug-Dump, in dem $18AF als Record-ID interpretiert wurde. Keiner dieser Werte liegt in der Nähe des Pivot-Codes

Emitter und Writer vergleichen ihre Notizen nie

Der strukturelle Grund, warum der Drift möglich war, ist, dass HotXLS einen BIFF-Record als TXLSBlob aufbaut, dessen Header und Payload zwei unabhängige Tatsachen sind. EmitSXEx schreibt die Record-ID, dann mit Blob.AddWord(24) die Länge und hängt anschließend die Body-Felder einzeln an. Die 24 ist eine von Hand gezählte Konstante, die weder aus den folgenden Bytes abgeleitet noch gegen sie geprüft wird. Auch der Schreibpfad schließt die Lücke nicht: AddRec reicht den Blob an TXLSBlobList.Append weiter, das Data.DataLength Bytes unverändert in den Ausgabestream kopiert. DataLength ist die echte Bytezahl, daher gibt der Writer pflichtgemäß 26 Body-Bytes hinter einem Header aus, der 24 behauptet. Beide Hälften tun genau, was man ihnen gesagt hat, und niemand hat den Auftrag, den Widerspruch zu bemerken. HotXLS vermeidet das beim Replay erhaltener Payloads bereits: TXLSWorkbook.StoreDConnBlobs berechnet das Längenwort im Header aus der tatsächlichen Body-Länge statt aus einem Literal. Genau deshalb ist der Blob-Replay noch nie gedriftet

Was [MS-XLS] 2.4.282 über SXEx festlegt

Die Spezifikation ist bei der Größe eindeutig, wodurch die Korrektur mechanisch wurde. [MS-XLS] 2.4.282 definiert den SXEx-Body als 4 Byte grbit, gefolgt von zehn 2-Byte-Feldern: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle und cchVacateStyle. Vier plus zwanzig ergibt 24. Der alte Emitter schrieb elf Nullwörter, obwohl die Spezifikation zehn definiert, und die anonymen AddWord(0)-Aufrufe trugen keine Feldnamen. Sie beim Review mit bloßem Auge zu zählen, war daher genau so zuverlässig, wie es klingt. Die Vorallokation war der Hinweis, dass das Layout verstanden, die Schleife aber nicht angepasst worden war: TXLSBlob.Create(28) verlangt exakt vier Headerbytes plus einen 24-Byte-Body, doch der Blob wuchs bei jedem Aufruf über diesen Hinweis hinaus – still, weil AdjustBufferSize nach Bedarf neu allokiert. Einen Kapazitätshinweis, den der Code sofort überschreitet, sollte man bei jedem Serializer genauer ansehen

function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
  Blob: TXLSBlob;
begin
  Blob := TXLSBlob.Create(28);   // 4-Byte-Header + 24-Byte-Body
  Blob.AddWord($00C6);
  Blob.AddWord(24);
  Blob.AddByte($02);
  Blob.AddByte($00);             // grbit1 = fPrintTitles
  Blob.AddByte($00);
  Blob.AddByte($00);             // grbit2
  // Zehn Nullwörter vervollständigen den 24-Byte-Body nach [MS-XLS] 2.4.282
  // Die deklarierte Länge MUSS den geschriebenen Bytes entsprechen, sonst
  // wird jeder folgende Record falsch geparst
  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;

Warum überstand das eine ganze PivotTable-Testsuite?

Weil die vorhandenen Pivot-Tests nie einen Roundtrip über eine Datei machten. Sie bauten eine Arbeitsmappe, prüften das In-Memory-Modell und hörten dort auf; In-Memory-Assertions können eine Längendifferenz nicht sehen, die nur im serialisierten Byte-Stream existiert. Der von den BIFF8-PivotTable-Records aus Delphi abgedeckte Record-Satz war nach diesem Maßstab gut getestet und lieferte trotzdem einen Stream-zersetzenden Emitter. Der Fehler benötigte außerdem ein zweites Feature, um sichtbar zu werden: Eine Pivot-Arbeitsmappe, auf die nicht viel folgte, ließ sich weiterhin öffnen, weil die Beschädigung am Ende eines Substreams auslief, den niemand prüfte. Erst die Kombination aus PivotTable und Chart-Sheet, bei der Chart-Sheets und Zeichnungen in einem nach dem Worksheet folgenden Substream liegen, verwandelte eine stille Fehlanpassung in ein sichtbar fehlendes Objekt

// PivotChartRoundTripThroughLinkRecords, gekürzt
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);                       // hier passiert das Fehlparsing
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);

Vor der Korrektur war Wb.Sheets[3]._Chart an dieser Stelle nil, weil der Reader die Substream-Grenze lange vor dem Chart-BOF verloren hatte. Die Assertion, die endlich einen Pivot-Serialisierungsfehler fand, war eine Assertion über ein Chart

Wie man einen fehljustierten BIFF-Stream bis zum ersten falschen Record zurückliest

Gehen Sie die Headerkette durch und geben Sie sie aus, denn ein desynchronisierter BIFF-Stream kündigt sich strukturell an, lange bevor die Daten falsch aussehen. Beginnen Sie beim Substream-BOF ($0809), lesen Sie ID und Länge, rücken Sie um vier plus Länge vor und wiederholen Sie das. Solange der Stream ausgerichtet ist, landen Sie auf plausiblen Record-IDs, und die Kette endet exakt bei EOF ($000A). Sobald sie driftet, erscheinen unbekannte IDs, Längen laufen über den Puffer oder die Kette läuft geradewegs über die erwartete EOF-Position hinaus

// Einen BIFF-Record-Stream durchlaufen und beim ersten unmöglichen Header stoppen
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)^;
    // Eine Null-ID ist nie ein zulässiger Record, und ein Body hinter dem
    // Puffer beweist, dass die Kette schon weiter oben gedriftet ist
    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;

Lesen Sie die Ausgabe anschließend rückwärts und halten Sie sich an eine Regel: Der erste Record, der nicht geparst werden kann, ist fast nie der Schuldige. Er ist das Opfer. Der Schuldige ist der unmittelbar davorliegende Record, der letzte, der ohne Beschwerde geparst wurde, denn ein Record, der über seine eigene Länge lügt, wird problemlos geparst. In diesem Fall stoppte der Durchlauf bei einem Phantom-$0000-Record, davor lag SXEx. Vergleichen Sie dessen deklarierte Länge Byte für Byte mit der Feldliste der Spezifikation; die Arithmetik geht entweder auf oder nicht. Erreicht der Durchlauf nicht einmal den ersten plausiblen Record, liegt das Problem eine Schicht tiefer, in der OLE2-Compound-Datei, die den Workbook-Stream enthält, und kein Record-Dump hilft dann

Ein Emitter, der über seine eigene Länge nicht lügen kann

Die dauerhafte Lösung ist nicht eine korrekte Konstante, sondern das Entfernen der Gelegenheit, eine falsche zu schreiben. Reservieren Sie das Längenwort, geben Sie den Body aus und patchen Sie dann den Header aus der tatsächlich erzeugten Bytezahl. HotXLS stellt dafür die nötigen Mittel bereit: TXLSBlob.DataLength liefert den aktuellen Offset, und SetWord schreibt an eine bereits ausgegebene Position zurück

function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
  Blob.AddWord(RecId);
  Result := Blob.DataLength;   // merken, wo das Längenwort liegt
  Blob.AddWord(0);             // Platzhalter, von EndRecord gepatcht
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;

Man sollte klar sagen, wo diese Garantie endet. Eine pauschale Assertion, dass ausgegebene Bytes gleich 2 + 2 + deklarierter Länge sind, gilt nur für Records unter dem BIFF8-Limit von 8224 Payload-Bytes. Überlange Bodies deklarieren im Header berechtigt 8224 und werden in $003C Continue-Records fortgesetzt. Genau das tun die HotXLS-Writer für Pivot-Cache und Verbindungen bei großen Payloads. Die Invariante ist daher bedingt: Unterhalb des Limits muss die Länge des ausgegebenen Blobs der deklarierten Länge plus vier entsprechen; oberhalb gehört die Arithmetik dem Splitter. Kodieren Sie diese Unterscheidung im Helper und nicht in einem Kommentar. Dieselbe Überlegung gilt für jedes Tag-Length-Value-Format, nicht nur für BIFF. Ein Emitter, der eine Größe deklariert, bevor er sie kennt, stellt eine Behauptung auf, die der Code nicht prüfen und der Reviewer nicht zählen kann. Das funktioniert genau so lange, bis hinter dem ersten Feature ein zweites landet

Der BIFF8-Writer, die Pivot-Record-Emitter und der hier besprochene Chart-Substream gehören zur HotXLS Delphi spreadsheet component für Delphi und C++Builder, die XLS, XLSX und ODS ohne installierte Excel-Anwendung liest und schreibt