Technisch artikel

BIFF-recordlengtedrift in een Delphi XLS-writer

HotXLS 2.376.0 repareerde een drift in de BIFF-recordlengte van zijn klassieke XLS-writer: de SXEx-emitter voor PivotTable-views declareerde in de header een body van 24 bytes en voegde daarna 26 bytes toe. Een BIFF-reader vertrouwt op de gedeclareerde lengte, dus de twee overtollige bytes desynchroniseerden alles wat volgde, en workbooks die een PivotTable met een chartsheet combineerden verloren de chart bij het opnieuw openen

Het interessante deel is niet het off-by-one-woord. Het is de afstand tussen fout en symptoom. Op het punt van de bug faalde niets. De pivotrecords werden netjes geserialiseerd, het bestand werd zonder fout geschreven, Excel opende het en de schade kwam pas honderden bytes verderop aan het licht in een volledig ongerelateerde substream. Die afstand is kenmerkend voor elk binary format met lengteprefixed data, en het is de moeite waard dit te begrijpen voordat je er nog een emitter voor schrijft

Waarom vernietigt één verkeerde recordlengte een volledige worksheetstream?

Een BIFF8-workbookstream heeft buiten zijn eigen rekenwerk geen framing. Elk record bestaat uit een 4-byte header met een record-ID van 2 bytes en een bodylengte van 2 bytes, gevolgd door precies zoveel payloadbytes ([MS-XLS] 2.1.4). Er is geen separator, geen magic byte, geen checksum en geen resynchronisatiepunt. De reader komt alleen op het volgende record uit omdat het vorige record de waarheid over zijn eigen grootte vertelde. De gedeclareerde lengte is geen metadata over het record; ze is de pointer naar het volgende. Kijk dus wat de twee overtollige bytes deden. De reader consumeerde de SXEx-header, sloeg de 24 bytes over die de header beloofde en landde twee bytes te vroeg, op twee nullen die van de te grote body waren overgebleven. Die nullen las hij als record-ID $0000, daarna las hij het volgende worksheet-EOF-record-ID ($000A) als de lengte van dat fantoomrecord en sloeg plichtsgetrouw tien bytes over in wat daarna kwam. Vanaf daar werd elke header op een verkeerde offset gelezen. In de falende workbook leverde dat een chartsheet op waarvan _Chart na het opnieuw openen nil was, plus een debugdump waarin $18AF als record-ID werd geïnterpreteerd. Geen van die waarden staat ergens in de buurt van de pivotcode

De emitter en de writer overleggen nooit

De structurele reden dat de drift mogelijk was, is dat HotXLS een BIFF-record opbouwt als een TXLSBlob waarvan header en payload twee onafhankelijke feiten zijn. EmitSXEx schrijft het record-ID, daarna Blob.AddWord(24) voor de lengte en voegt vervolgens de body veld voor veld toe. Die 24 is een met de hand getelde constante, nooit afgeleid van of gecontroleerd tegen de bytes die erop volgen. Ook het writepad dicht de kloof niet: AddRec geeft de blob door aan TXLSBlobList.Append, die Data.DataLength bytes ongewijzigd naar de outputstream kopieert. DataLength is de echte bytecount, dus de writer schrijft trouw 26 bodybytes achter een header die 24 beweert. Beide helften doen precies wat hun is opgedragen en niemand heeft de taak om de tegenspraak te zien. HotXLS vermijdt dit al wanneer het bewaarde payloads afspeelt: TXLSWorkbook.StoreDConnBlobs berekent zijn header-lengtewoord uit de echte bodylengte in plaats van uit een literal, precies waarom blob replay nooit is gaan driften

Wat legt [MS-XLS] 2.4.282 over SXEx vast?

De specificatie is ondubbelzinnig over de grootte, waardoor de fix mechanisch was. [MS-XLS] 2.4.282 definieert de SXEx-body als een 4-byte grbit, gevolgd door tien velden van 2 bytes: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle en cchVacateStyle. Vier plus twintig is vierentwintig. De oude emitter schreef elf nulwoorden waar de specificatie er tien definieert, en de anonieme AddWord(0)-calls droegen geen veldnamen. Met het oog tellen tijdens review was dus precies zo betrouwbaar als het klinkt. De preallocatie was de aanwijzing dat de layout was begrepen maar de loop niet: TXLSBlob.Create(28) vraagt exact vier headerbytes plus een body van 24 bytes, maar de blob groeide bij elke call over die hint heen en deed dat stilletjes, omdat AdjustBufferSize op aanvraag reallocatet. Een capacity hint waar de code onmiddellijk overheen groeit verdient in elke serializer een tweede blik

function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
  Blob: TXLSBlob;
begin
  Blob := TXLSBlob.Create(28);   // 4-byte header + body van 24 bytes
  Blob.AddWord($00C6);
  Blob.AddWord(24);
  Blob.AddByte($02);
  Blob.AddByte($00);             // grbit1 = fPrintTitles
  Blob.AddByte($00);
  Blob.AddByte($00);             // grbit2
  // Tien nulwoorden maken de body van 24 bytes compleet volgens [MS-XLS] 2.4.282
  // De gedeclareerde lengte MOET overeenkomen met de geschreven bytes, anders
  // wordt elk record na dit record verkeerd geparsed
  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;

Waarom overleefde dit een volledige PivotTable-testsuite?

Omdat de bestaande pivottests nooit een round-trip door een bestand maakten. Ze bouwden een workbook, asserteten tegen het in-memorymodel en stopten daar, en in-memory assertions kunnen geen lengtemismatch zien die alleen in de geserialiseerde bytestream bestaat. De recordset die wordt behandeld bij BIFF8-PivotTable-records schrijven vanuit Delphi was volgens die standaard goed getest en leverde toch een emitter af die de stream corrumpeerde. Het defect had ook een tweede feature nodig om zichtbaar te worden: een gepivot werkblad dat door weinig meer werd gevolgd opende nog steeds, omdat de corruptie voorbij het einde van een substream liep waar niemand naar keek. Alleen de combinatie van een PivotTable en een chartsheet, waarbij chartsheets en drawings een substream bezetten die na het worksheet komt, veranderde een stille misalignment in een zichtbaar ontbrekend object

// PivotChartRoundTripThroughLinkRecords, ingekort
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);                       // de misparse gebeurt hier
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);

Voor de fix was Wb.Sheets[3]._Chart op die regel nil, omdat de reader de substreamgrens al lang kwijt was voordat hij de chart-BOF bereikte. De assertion die uiteindelijk een pivotserialisatiebug ving, was een assertion over een chart

Hoe lees je een verkeerd uitgelijnde BIFF-stream terug naar het eerste slechte record?

Loop de headerchain door en print hem, want een gedesynchroniseerde BIFF-stream kondigt zich structureel aan lang voordat de data er verkeerd uitziet. Begin bij de substream-BOF ($0809), lees ID en lengte, ga vier plus de lengte verder en herhaal. Zolang de stream uitgelijnd is, land je op plausibele record-ID's en eindigt de chain precies op EOF ($000A). Zodra de drift optreedt, krijg je ID's die niet bestaan, lengtes die buiten de buffer lopen of een chain die recht voorbij de plaats loopt waar EOF had moeten staan

// Loop door een BIFF-recordstream en stop bij de eerste header die niet echt kan zijn
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)^;
    // Een nul-ID is nooit een geldig record en een body die voorbij de
    // buffer loopt bewijst dat de chain eerder al ergens is gaan driften
    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;

Lees de output daarna achteruit en houd één regel vast: het eerste record dat niet parseert is bijna nooit de schuldige. Het is het slachtoffer. De schuldige is het record er direct voor, het laatste dat zonder klacht parseerde, want een record dat over zijn eigen lengte liegt, parseert altijd probleemloos. In dit geval stopte de walk op een fantoomrecord $0000 en was het record ervoor SXEx. Vergelijk de gedeclareerde lengte van dat record met de veldlijst in de specificatie, byte voor byte, en de rekenkunde klopt of klopt niet. Als de walk zelfs het eerste verstandige record niet bereikt, zit het probleem een laag lager, in het OLE2-compoundbestand met de Workbook-stream, en helpt geen enkele dump op recordniveau

Een emitter die niet over zijn eigen lengte kan liegen

De duurzame fix is geen correcte constante maar het wegnemen van de mogelijkheid om een verkeerde te schrijven. Reserveer het lengtewoord, emit de body en patch daarna de header met de bytecount die je werkelijk hebt geproduceerd. HotXLS stelt daarvoor beschikbaar wat nodig is: TXLSBlob.DataLength geeft de huidige offset en SetWord schrijft terug naar een positie die al is uitgegeven

function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
  Blob.AddWord(RecId);
  Result := Blob.DataLength;   // onthoud waar het lengtewoord staat
  Blob.AddWord(0);             // placeholder, gepatcht door 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;

Wees eerlijk over waar die garantie ophoudt. Een algemene assertion dat geschreven bytes gelijk zijn aan 2 + 2 + declared geldt alleen voor records die onder de BIFF8-limiet van 8224 payloadbytes blijven. Bodies groter dan dat mogen in de header legitiem 8224 declareren en doorgaan in $003C Continue-records, precies wat de HotXLS-pivotcache- en connection-writers doen voor grote payloads. De invariant is dus conditioneel: onder de limiet moet de lengte van de uitgegeven blob gelijk zijn aan de gedeclareerde lengte plus vier, erboven bezit de splitter de rekenkunde. Encodeer dat onderscheid in de helper in plaats van in een comment. Dezelfde redenering gaat op voor elk tag-length-value-format en niet alleen voor BIFF. Een emitter die een grootte declareert voordat hij die kent, schrijft een claim die de code niet kan controleren en de reviewer niet kan tellen, en hij werkt precies tot er een tweede feature downstream van de eerste bijkomt

De BIFF8-writer, de pivotrecordemitters en de chartsheet-substream die hier wordt besproken worden geleverd als onderdeel van de HotXLS Delphi spreadsheet component voor Delphi en C++Builder, die XLS, XLSX en ODS leest en schrijft zonder dat Excel geïnstalleerd is