Teknisk artikkel

BIFF-recordlengde i drift i en Delphi XLS-skriver

HotXLS 2.376.0 rettet en drift i BIFF-recordlengden i den klassiske XLS-skriveren: SXEx-emitteren for PivotTable-visninger deklarerte en kropp på 24 byte i headeren og la deretter til 26 byte. En BIFF-leser stoler på den deklarerte lengden, så de to overskytende byte-ene desynkroniserte alt nedstrøms, og arbeidsbøker som koblet en PivotTable med et chart sheet, mistet chartet ved ny åpning

Det interessante er ikke ordet off-by-one. Det er avstanden mellom feilen og symptomet. Ingenting feilet der feilen lå. Pivot-recordene ble serialisert rent, filen ble skrevet uten feil, Excel åpnet den, og skaden viste seg først hundrevis av byte senere i en fullstendig urelatert understrøm. Den avstanden er typisk for alle binærformater med lengdeprefiks, og den er verdt å forstå før du skriver en ny emitter for et slikt format

Hvorfor ødelegger én feil record-lengde en hel worksheet-stream?

En BIFF8-arbeidsbokstrøm har ingen framing utover sin egen aritmetikk. Hver record er en 4-byte-header med record-id (2 byte) pluss kroppslengde (2 byte), fulgt av nøyaktig så mange payload-byte ([MS-XLS] 2.1.4). Det finnes ingen separator, magic byte, checksum eller resynkroniseringspunkt. Leseren lander på neste record bare fordi den forrige record-en fortalte sannheten om sin egen størrelse. Den deklarerte lengden er ikke metadata om record-en; den er pekeren til den neste. Følg derfor hva de to ekstra byte-ene gjorde. Leseren konsumerte SXEx-headeren, hoppet over de 24 byte headeren lovet og landet to byte for tidlig, på et par nuller som var igjen fra den for store kroppen. Den leste nullene som en record-id på $0000, leste deretter den følgende worksheet EOF-record-id-en ($000A) som lengden til den fantom-record-en og hoppet pliktoppfyllende ti byte inn i det som kom etterpå. Derfra ble hver header lest på feil offset. I arbeidsboken som feilet, ga dette et chart sheet der _Chart var nil etter ny åpning, og en debug-dump som viste $18AF tolket som record-id. Ingen av disse verdiene finnes i nærheten av pivot-koden

Emitteren og writer-en sammenligner aldri notater

Den strukturelle grunnen til at driften var mulig, er at HotXLS bygger en BIFF-record som en TXLSBlob der header og payload er to uavhengige fakta. EmitSXEx skriver record-id-en, deretter Blob.AddWord(24) for lengden, og legger så til kroppsfeltene ett for ett. Tallet 24 er en opptalt konstant, aldri avledet fra eller kontrollert mot byte-ene som følger. Skrivebanen lukker heller ikke gapet: AddRec sender blob-en videre til TXLSBlobList.Append, som kopierer Data.DataLength byte ordrett inn i output-strømmen. DataLength er det reelle antallet byte, så writer-en skriver trofast 26 byte kropp bak en header som hevder 24. Begge halvdelene gjør nøyaktig det de ble bedt om, og motsigelsen mellom dem er ingens jobb å oppdage. HotXLS unngår allerede dette når den spiller av bevarte payload-er: TXLSWorkbook.StoreDConnBlobs beregner headerens lengdeord fra den faktiske kroppslengden i stedet for en literal, og det er nettopp derfor blob-avspilling aldri har drevet

Hva fastsetter [MS-XLS] 2.4.282 om SXEx?

Spesifikasjonen er entydig om størrelsen, noe som gjorde rettingen mekanisk. [MS-XLS] 2.4.282 definerer SXEx-kroppen som en 4-byte grbit fulgt av ti 2-byte-felt: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle og cchVacateStyle. Fire pluss tjue er tjuefire. Den gamle emitteren skrev elleve nullord der spesifikasjonen definerer ti, og de anonyme AddWord(0)-kallene hadde ingen feltnavn, så det å telle dem med øyet under en review var akkurat så pålitelig som det høres ut. Forallokeringen var ledetråden til at layouten var forstått, men løkken ikke var det: TXLSBlob.Create(28) ber om nøyaktig fire header-byte pluss en kropp på 24 byte, men blob-en vokste forbi dette hintet på hvert eneste kall, og vokste stille fordi AdjustBufferSize allokerer på nytt ved behov. En kapasitetsindikasjon som koden umiddelbart overskrider, fortjener et ekstra blikk i enhver serializer

function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
  Blob: TXLSBlob;
begin
  Blob := TXLSBlob.Create(28);   // 4-byte-header + 24-byte-kropp
  Blob.AddWord($00C6);
  Blob.AddWord(24);
  Blob.AddByte($02);
  Blob.AddByte($00);             // grbit1 = fPrintTitles
  Blob.AddByte($00);
  Blob.AddByte($00);             // grbit2
  // Ti nullord fullfører 24-byte-kroppen i henhold til [MS-XLS] 2.4.282.
  // Den deklarerte lengden MÅ stemme med byte-ene som skrives, ellers
  // parses hver record etter denne feil.
  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 overlevde dette et helt PivotTable-testsett?

Fordi de eksisterende pivottestene aldri gikk tur-retur gjennom en fil. De bygget en arbeidsbok, hevdet mot modellen i minnet og stoppet der, og minneassertions kan ikke se en lengdemismatch som bare finnes i den serialiserte byte-strømmen. Record-settet som dekkes av skriving av BIFF8 PivotTable-recorder fra Delphi, var godt testet etter den standarden og leverte likevel en emitter som korrumperte strømmen. Feilen trengte også en sekundær funksjon for å bli synlig: En pivotert worksheet etterfulgt av lite annet åpnet fortsatt, fordi korrupsjonen løp ut av slutten på en understrøm ingen inspiserte. Bare kombinasjonen av en PivotTable og et chart sheet, der chart sheets og tegninger opptar en understrøm som følger worksheet-en, gjorde en stille feiljustering til et synlig 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);                       // feilparseringen skjer her
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);

Før rettingen var Wb.Sheets[3]._Chart nil på den linjen, fordi leseren hadde mistet understrømgrensen lenge før den nådde chartets BOF. Assertet som endelig fanget en pivots serialiseringsfeil, var et assert om et chart

Hvordan leser du en feiljustert BIFF-strøm tilbake til den første feilaktige record-en?

Gå gjennom headerkjeden og skriv den ut, fordi en desynkronisert BIFF-strøm avslører seg strukturelt lenge før dataene ser feil ut. Start ved understrømmens BOF ($0809), les id og lengde, gå frem med fire pluss lengden og gjenta. Så lenge strømmen er justert, lander du på plausible record-id-er, og kjeden avsluttes nøyaktig på EOF ($000A). Når den driver, får du id-er som ikke finnes, lengder som går forbi bufferet eller en kjede som går rett forbi stedet EOF skulle ha vært

// Gå gjennom en BIFF-record-strøm og stopp ved den første headeren som ikke kan være ekte
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)^;
    // En null-id er aldri en lovlig record, og en kropp som går forbi
    // bufferet, beviser at kjeden allerede drev et sted oppstrø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;

Les deretter output-en baklengs, og hold fast på én regel: Den første record-en som ikke parses, er nesten aldri den skyldige. Den er offeret. Den skyldige er record-en rett før den, den siste som ble parslet uten klage, fordi en løgner om sin egen lengde alltid parses fint. Her stoppet gjennomgangen på en fantom-record $0000, og record-en før den var SXEx. Sammenlign den record-ens deklarerte lengde med feltlisten i spesifikasjonen, byte for byte, så summerer aritmetikken seg eller så gjør den ikke det. Hvis gjennomgangen ikke når en fornuftig første record i det hele tatt, ligger problemet ett lag lavere, i OLE2 compound-filen som holder Workbook-strømmen, og ingen mengde dumping på record-nivå vil hjelpe

En emitter som ikke kan lyve om sin egen lengde

Den varige løsningen er ikke en korrekt konstant, men å fjerne muligheten til å skrive en feilaktig en. Reserver lengdeordet, emitter kroppen og oppdater deretter headeren fra byte-antallet du faktisk produserte. HotXLS eksponerer det som trengs: TXLSBlob.DataLength gir gjeldende offset, og SetWord skriver tilbake på en posisjon som allerede er emittert

function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
  Blob.AddWord(RecId);
  Result := Blob.DataLength;   // husk hvor lengdeordet ligger
  Blob.AddWord(0);             // plassholder, oppdateres 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;

Vær ærlig om hvor garantien stopper. En generell assertion om at emitterte byte er lik 2 + 2 + deklarert, gjelder bare record-er som passer under BIFF8-grensen på 8224 payload-byte. For store kropper deklarerer legitimt 8224 i headeren og fortsetter i $003C Continue-recorder, som er nøyaktig det HotXLS-skriverne for pivot-cache og forbindelser gjør for store payload-er, så invarianten er betinget: Under grensen må lengden på den emitterte blob-en være lik den deklarerte lengden pluss fire, over den eier splitteren aritmetikken. Kod denne forskjellen inn i hjelpefunksjonen i stedet for i en kommentar. Den samme tankegangen overføres til alle tag-length-value-formater, ikke bare BIFF. En emitter som deklarerer en størrelse før den kjenner den, har skrevet et krav koden ikke kan kontrollere og revieweren ikke kan telle, og det fungerer helt til en sekundær funksjon lander nedstrøms for den første

BIFF8-skriveren, pivot-record-emitterne og chart-understrømmen som omtales her, leveres som del av HotXLS Delphi spreadsheet-komponenten for Delphi og C++Builder, som leser og skriver XLS, XLSX og ODS uten Excel installert