Teknisk artikel

BIFF-postlängdens drift i en Delphi XLS-skrivare

HotXLS 2.376.0 rättade en drift i BIFF-postlängden i den klassiska XLS-skrivaren: SXEx-emitteraren för PivotTable-vyer deklarerade en kropp på 24 byte i sitt huvud och lade sedan till 26 byte. En BIFF-läsare litar på den deklarerade längden, så de två överskottsbytena desynkroniserade allt nedströms, och arbetsböcker som kombinerade en PivotTable med ett diagramblad förlorade diagrammet när de öppnades igen

Det intressanta är inte ordet off-by-one. Det är avståndet mellan misstaget och symptomet. Ingenting gick fel där buggen fanns. Pivot-posterna serialiserades rent, filen skrevs utan fel, Excel öppnade den och skadan visade sig först hundratals byte längre fram i en helt orelaterad underström. Det avståndet är typiskt för varje binärt format med längdprefix, och det är värt att förstå innan du skriver ännu en emitterare för ett sådant

Varför förstör en felaktig postlängd en hel kalkylbladsström?

En BIFF8-arbetsboksström har ingen annan inramning än sin egen aritmetik. Varje post är ett 4-byteshuvud med post-ID (2 byte) och kroppslängd (2 byte), följt av exakt så många nyttolastbyte ([MS-XLS] 2.1.4). Det finns ingen avskiljare, ingen magisk byte, ingen kontrollsumma och ingen omsynkroniseringspunkt. Läsaren landar på nästa post endast eftersom den föregående posten berättade sanningen om sin egen storlek. Den deklarerade längden är inte metadata om posten; den är pekaren till nästa. Följ därför vad de två överskottsbytena gjorde. Läsaren förbrukade SXEx-huvudet, hoppade över de 24 byte som huvudet lovade och landade två byte för tidigt, på ett par nollor som låg kvar efter den överdimensionerade kroppen. Den läste nollorna som ett post-ID på $0000, läste sedan följande kalkylblads-EOF-posts ID ($000A) som längden på den fantomposten och hoppade plikttroget tio byte in i vad som än kom därefter. Från den punkten lästes varje huvud på fel offset. I den felande arbetsboken gav det ett diagramblad vars _Chart var nil efter återöppning och en felsökningsdump som visade $18AF tolkat som ett post-ID. Ingen av de värdena finns i närheten av pivotkoden

Emitteraren och skrivaren jämför aldrig sina anteckningar

Den strukturella orsaken till att driften var möjlig är att HotXLS bygger en BIFF-post som en TXLSBlob vars huvud och nyttolast är två oberoende fakta. EmitSXEx skriver post-ID:t, sedan Blob.AddWord(24) för längden och lägger därefter till kroppsfälten ett i taget. Värdet 24 är en handräknad konstant, aldrig härledd från eller kontrollerad mot bytena som följer. Skrivvägen stänger inte heller luckan: AddRec vidarebefordrar blobben till TXLSBlobList.Append, som kopierar Data.DataLength byte ordagrant till utdataströmmen. DataLength är det verkliga antalet byte, så skrivaren avger troget 26 byte kropp bakom ett huvud som påstår 24. Båda halvorna gör exakt det de blev tillsagda och motsägelsen mellan dem är ingens ansvar att upptäcka. HotXLS undviker redan detta där det spelar upp bevarade nyttolaster: TXLSWorkbook.StoreDConnBlobs beräknar sitt längdord i huvudet från den faktiska kroppslängden i stället för en literal, vilket är precis varför blobuppspelning aldrig har drabbats av drift

Vad [MS-XLS] 2.4.282 fastslår om SXEx

Specifikationen är entydig om storleken, vilket gjorde fixen mekanisk. [MS-XLS] 2.4.282 definierar SXEx-kroppen som ett 4-byte grbit följt av tio 2-bytefält: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle och cchVacateStyle. Fyra plus tjugo är tjugofyra. Den gamla emitteraren skrev elva nollord där specifikationen definierar tio, och de anonyma AddWord(0)-anropen bar inga fältnamn, så att räkna dem med ögat vid en granskning var ungefär så tillförlitligt som det låter. Förallokeringen var ledtråden till att layouten hade förståtts men loopen inte hade det: TXLSBlob.Create(28) ber om exakt fyra byte huvud plus en kropp på 24 byte, men blobben växte förbi den ledtråden vid varje anrop och växte tyst, eftersom AdjustBufferSize allokerar om vid behov. En kapacitetsledtråd som koden omedelbart överskrider är värd en andra blick i varje serialiserare

function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
  Blob: TXLSBlob;
begin
  Blob := TXLSBlob.Create(28);   // 4-byte huvud + 24-byte kropp
  Blob.AddWord($00C6);
  Blob.AddWord(24);
  Blob.AddByte($02);
  Blob.AddByte($00);             // grbit1 = fPrintTitles
  Blob.AddByte($00);
  Blob.AddByte($00);             // grbit2
  // Tio nollord slutför 24-byte-kroppen enligt [MS-XLS] 2.4.282.
  // Den deklarerade längden MÅSTE stämma med skrivna byte, annars
  // feltolkas varje post efter denna.
  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;

Varför överlevde detta en hel PivotTable-testsvit?

För att de befintliga pivottesterna aldrig gjorde en tur och retur genom en fil. De byggde en arbetsbok, hävdade mot minnesmodellen och slutade där, och minnespåståenden kan inte se en längdmismatch som bara finns i den serialiserade byteströmmen. Postuppsättningen som täcks av att skriva BIFF8-PivotTable-poster från Delphi var vältestad enligt den standarden och levererade ändå en emitterare som förstörde strömmen. Felet behövde dessutom en andra funktion för att bli synligt: ett pivoterat kalkylblad följt av nästan ingenting öppnades fortfarande igen, eftersom korruptionen sprang ut över slutet av en underström som ingen inspekterade. Först kombinationen av en PivotTable och ett diagramblad, där diagramblad och ritningar upptar en underström som följer efter kalkylbladet, förvandlade en tyst feljustering till ett synligt saknat objekt

// PivotChartRoundTripThroughLinkRecords, förkortat
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);                       // feltolkningen sker här
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);

Före fixen var Wb.Sheets[3]._Chart nil på den raden, eftersom läsaren hade tappat underströmsgränsen långt innan den nådde diagrammets BOF. Påståendet som till slut fångade ett serialiseringsfel i PivotTable var ett påstående om ett diagram

Så läser du tillbaka en feljusterad BIFF-ström till den första dåliga posten

Gå igenom kedjan av huvuden och skriv ut den, eftersom en desynkroniserad BIFF-ström avslöjar sig strukturellt långt innan datan ser fel ut. Börja vid underströmmens BOF ($0809), läs ID och längd, flytta fram med fyra plus längden och upprepa. Så länge strömmen är justerad landar du på rimliga post-ID:n och kedjan avslutas exakt vid EOF ($000A). När den driver får du ID:n som inte finns, längder som löper förbi bufferten eller en kedja som går rakt förbi platsen där EOF borde ha varit

// Gå igenom en BIFF-postström och stanna vid det första huvud som inte kan vara verkligt
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)^;
    // Ett noll-ID är aldrig en laglig post, och en kropp som går förbi
    // bufferten bevisar att kedjan redan drev någonstans uppströms.
    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 sedan resultatet bakifrån och håll fast vid en regel: den första posten som inte kan parsas är nästan aldrig boven. Den är offret. Boven är posten omedelbart före den, den sista som parsades utan klagomål, eftersom en lögnare om sin egen längd alltid kan parsas utan problem. I detta fall stannade genomgången på en fantompost $0000 och posten före den var SXEx. Jämför den postens deklarerade längd med fältlistan i specifikationen, byte för byte, så summerar aritmetiken antingen rätt eller inte. Om genomgången aldrig ens når en rimlig första post ligger problemet ett lager lägre, i OLE2-sammansättningsfilen som innehåller Workbook-strömmen, och ingen mängd dumpning på postnivå hjälper

En emitterare som inte kan ljuga om sin egen längd

Den hållbara fixen är inte en korrekt konstant, utan att ta bort möjligheten att skriva en felaktig. Reservera längdordet, avge kroppen och lappa sedan huvudet från det byteantal du faktiskt producerade. HotXLS exponerar det som behövs: TXLSBlob.DataLength ger den aktuella offseten och SetWord skriver tillbaka till en redan utskriven position

function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
  Blob.AddWord(RecId);
  Result := Blob.DataLength;   // kom ihåg var längdordet ligger
  Blob.AddWord(0);             // platshållare, lappas av 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;

Var ärlig med var garantin upphör. Ett generellt påstående att utskrivna byte är lika med 2 + 2 + deklarerat gäller bara poster som ryms under BIFF8:s gräns på 8224 nyttolastbyte. För stora kroppar deklarerar legitimt 8224 i huvudet och fortsätter i $003C Continue-poster, vilket är exakt vad HotXLS-skrivarna för pivotcache och anslutningar gör för stora nyttolaster, så invarianten är villkorad: under gränsen måste den utskrivna blobblängden vara lika med den deklarerade längden plus fyra, och över den äger delaren aritmetiken. Koda in den skillnaden i hjälpen i stället för i en kommentar. Samma resonemang överförs till varje tagg-längd-värde-format, inte bara BIFF. En emitterare som deklarerar en storlek innan den vet den har skrivit ett påstående som koden inte kan kontrollera och granskaren inte kan räkna, och det fungerar ända tills en andra funktion landar nedströms från den första

BIFF8-skrivaren, pivotpostemitterarna och diagramunderströmmen som diskuteras här levereras som en del av HotXLS Delphi-kalkylbladskomponenten för Delphi och C++Builder, som läser och skriver XLS, XLSX och ODS utan Excel installerat