Teknisk artikel

BIFF-recordlængde-driftsfejl i en Delphi XLS-writer

HotXLS 2.376.0 rettede en BIFF-recordlængdedrift i sin klassiske XLS-writer: SXEx-emitteren til PivotTable-visninger deklarerede en body på 24 bytes i sin header og tilføjede derefter 26 bytes. En BIFF-reader stoler på den deklarerede længde, så de to overskydende bytes desynkroniserede alt downstream, og workbooks, der parrede en PivotTable med et chartsheet, mistede chartet ved genåbning

Det interessante er ikke off-by-one-ordet. Det er afstanden mellem fejlen og symptomet. Intet fejlede på det sted, hvor fejlen lå. Pivot-records blev serialiseret rent, filen blev skrevet uden fejl, Excel åbnede den, og skaden viste sig først hundredvis af bytes længere nede i en helt uvedkommende substream. Den afstand er karakteristisk for alle længdeprefixede binærformater, og den er værd at forstå, før du skriver endnu en emitter til et af dem

Hvorfor ødelægger én forkert recordlængde en hel worksheet-stream?

En BIFF8-workbook-stream har ingen framing ud over sin egen aritmetik. Hver record er en 4-byte-header med record-id (2 bytes) plus bodylængde (2 bytes) efterfulgt af præcis så mange payload-bytes ([MS-XLS] 2.1.4). Der er ingen separator, ingen magic byte, ingen checksum, intet resynchroniseringspunkt. Readeren lander på den næste record, kun fordi den forrige record fortalte sandheden om sin egen størrelse. Den deklarerede længde er ikke metadata om recorden; den er pointeren til den næste. Følg derfor, hvad de to overskydende bytes gjorde. Readeren konsumerede SXEx-headeren, sprang de 24 bytes over, som headeren lovede, og landede to bytes for tidligt på et par nuller, der var tilbage fra den for store body. Den læste nullerne som et record-id på $0000, læste derefter den følgende worksheet EOF-records id ($000A) som længden på den fantom-record og sprang pligtskyldigt ti bytes ind i det, der kom bagefter. Fra det øjeblik blev hver header læst ved en forkert offset. I den fejlende workbook gav det et chartsheet, hvis _Chart var nil efter genåbning, og et debug-dump, der viste $18AF fortolket som et record-id. Ingen af disse værdier findes i nærheden af pivotkoden

Emitteren og writeren sammenholder aldrig deres noter

Den strukturelle årsag til, at driften var mulig, er, at HotXLS bygger en BIFF-record som en TXLSBlob, hvis header og payload er to uafhængige fakta. EmitSXEx skriver record-idet, derefter Blob.AddWord(24) for længden og tilføjer så bodystoffet felt for felt. Tallet 24 er en manuelt optalt konstant, der aldrig udledes af eller kontrolleres mod de bytes, der følger efter den. Skrivevejen lukker heller ikke hullet: AddRec videresender blobben til TXLSBlobList.Append, som kopierer Data.DataLength bytes ordret ind i outputstreamen. DataLength er det reelle antal bytes, så writeren udsender trofast 26 bodybytes bag en header, der påstår 24. Begge halvdele gør præcis, hvad de fik besked på, og modsigelsen mellem dem er ingens opgave at opdage. HotXLS undgår allerede dette, når den genafspiller bevarede payloads: TXLSWorkbook.StoreDConnBlobs beregner sin header-længdeword ud fra den faktiske bodylængde i stedet for en literal, hvilket netop er grunden til, at blob-replay aldrig har drevet

Hvad fastlåser [MS-XLS] 2.4.282 om SXEx?

Specifikationen er entydig om størrelsen, hvilket gjorde rettelsen mekanisk. [MS-XLS] 2.4.282 definerer SXEx-bodyen som en 4-byte grbit efterfulgt af ti 2-byte-felter: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle og cchVacateStyle. Fire plus tyve er 24. Den gamle emitter skrev elleve nul-words, hvor specifikationen definerer ti, og de anonyme AddWord(0)-kald bar ingen feltnavne, så at tælle dem med øjnene under en review var lige så pålideligt, som det lyder. Forallokeringen var ledetråden til, at layoutet var forstået, men løkken ikke: TXLSBlob.Create(28) beder om præcis fire headerbytes plus en body på 24 bytes, men blobben voksede ud over hintet ved hvert eneste kald og voksede lydløst, fordi AdjustBufferSize reallokerer efter behov. Et kapacitetshint, som koden straks overskrider, fortjener et ekstra blik i enhver serializer

function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
  Blob: TXLSBlob;
begin
  Blob := TXLSBlob.Create(28);   // 4-byte-header + body på 24 bytes
  Blob.AddWord($00C6);
  Blob.AddWord(24);
  Blob.AddByte($02);
  Blob.AddByte($00);             // grbit1 = fPrintTitles
  Blob.AddByte($00);
  Blob.AddByte($00);             // grbit2
  // Ti nul-words fuldender 24-byte-bodyen ifølge [MS-XLS] 2.4.282
  // Den deklarerede længde SKAL matche de skrevne bytes, ellers parses
  // alle records efter denne forkert
  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;

Hvorfor overlevede det en hel PivotTable-testpakke?

Fordi de eksisterende pivot-tests aldrig rundrejste gennem en fil. De byggede en workbook, hævdede noget om in-memory-modellen og stoppede dér, og in-memory-assertions kan ikke se en længdefejl, der kun findes i den serialiserede byte-stream. Record-sættet, der dækkes af skrivning af BIFF8 PivotTable-records fra Delphi, var veltestet efter den standard og leverede stadig en stream-korrumperende emitter. Defekten krævede også en anden feature for at blive synlig: En pivoteret worksheet efterfulgt af næsten ingenting kunne stadig genåbnes, fordi korruptionen løb ud over enden af en substream, ingen inspicerede. Kun kombinationen af en PivotTable og et chartsheet, hvor chartsheets og drawings fylder en substream, der følger efter worksheeten, forvandlede en lydløs fejljustering til et synligt manglende objekt

// PivotChartRoundTripThroughLinkRecords, forkortet
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);                       // fejlparsningen sker her
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);

Før rettelsen var Wb.Sheets[3]._Chart nil på den linje, fordi readeren havde mistet substream-grænsen længe før den nåede chartets BOF. Den assertion, der endelig fangede en pivot-serialiseringsfejl, var en assertion om et chart

Sådan læser du en fejljusteret BIFF-stream tilbage til den første dårlige record

Gå headerkæden igennem og skriv den ud, fordi en desynkroniseret BIFF-stream afslører sig strukturelt længe før dataene ser forkerte ud. Start ved substream-BOF ($0809), læs id og længde, gå fire plus længden frem, og gentag. Så længe streamen er justeret, lander du på plausible record-id'er, og kæden afsluttes præcis ved EOF ($000A). Når den driver, får du id'er, der ikke findes, længder, som overskrider bufferen, eller en kæde, der går direkte forbi det sted, hvor EOF burde have været

// Gå gennem en BIFF-record-stream og stop ved den første header, der ikke kan være ægte
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)^;
    // Et nul-id er aldrig en lovlig record, og en body, der løber forbi
    // bufferen, beviser, at kæden allerede drev et sted upstream
    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;

Læs derefter outputtet baglæns, og hold fast i én regel: Den første record, der ikke kan parses, er næsten aldrig synderen. Den er offeret. Synderen er recorden lige før den, den sidste der blev parsed uden klage, fordi en løgner om sin egen længde altid parses fint. I dette tilfælde stoppede gennemgangen på en fantom-record $0000, og recorden før den var SXEx. Sammenlign den records deklarerede længde med feltlisten i specifikationen byte for byte, og aritmetikken går enten op eller også gør den ikke. Hvis gennemgangen ikke engang når frem til en fornuftig første record, ligger problemet et lag længere nede i OLE2-compound-filen, der indeholder Workbook-streamen, og ingen mængde record-level-dump hjælper

En emitter, der ikke kan lyve om sin egen længde

Den holdbare rettelse er ikke en korrekt konstant, men at fjerne muligheden for at skrive en forkert. Reserver længdeordet, udsend bodyen, og patch derefter headeren ud fra det antal bytes, du faktisk producerede. HotXLS eksponerer det, der er nødvendigt: TXLSBlob.DataLength giver den aktuelle offset, og SetWord skriver tilbage på en position, der allerede er udsendt

function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
  Blob.AddWord(RecId);
  Result := Blob.DataLength;   // husk, hvor længdeordet ligger
  Blob.AddWord(0);             // placeholder, patches af 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;

Vær ærlig om, hvor garantien stopper. En blanket-assertion om, at udsendte bytes er lig med 2 + 2 + deklareret, holder kun for records, der passer under BIFF8-grænsen på 8224 payloadbytes. Bodies, der er for store, deklarerer legitimt 8224 i headeren og fortsætter i $003C Continue-records, hvilket er præcis det, HotXLS pivot-cache- og connection-writere gør ved store payloads, så invarianten er betinget: Under grænsen skal den udsendte bloblængde være lig med den deklarerede længde plus fire, over den ejer splitteren aritmetikken. Kod den forskel ind i hjælperen i stedet for i en kommentar. Den samme tankegang overføres til alle tag-length-value-formater og ikke kun BIFF. En emitter, der deklarerer en størrelse, før den kender den, har skrevet en påstand, som koden ikke kan kontrollere, og revieweren ikke kan tælle, og den virker, lige indtil en anden feature lander downstream for den første

BIFF8-writeren, pivot-record-emitterne og chart-substreamen, der beskrives her, leveres som en del af HotXLS Delphi-regnearkskomponenten til Delphi og C++Builder, som læser og skriver XLS, XLSX og ODS uden Excel installeret