Műszaki cikk

BIFF rekordhossz elcsúszása Delphi XLS-íróban

A HotXLS 2.376.0 javított egy BIFF rekordhossz-elcsúszást a klasszikus XLS-íróban: a PivotTable-nézetek SXEx emittere 24 bájtos törzset deklarált a fejlécben, majd 26 bájtot fűzött hozzá. A BIFF-reader megbízik a deklarált hosszban, ezért a két többletbájt minden későbbi adatot eltolhatott, és azok a munkafüzetek, amelyek PivotTable-t chart sheettel párosítottak, újranyitáskor elvesztették a chartot

Az érdekes rész nem az egy szónyi off-by-one. Hanem a hiba és a tünet közötti távolság. Semmi nem romlott el a hiba pontján. A pivot rekordok tisztán szerializálódtak, a fájl hiba nélkül kiíródott, az Excel megnyitotta, a kár pedig csak több száz bájttal később, egy teljesen más alstreamben jelent meg. Ez minden hosszprefixes bináris formátum jellegzetessége, és érdemes megérteni, mielőtt újabb emittert írsz egy ilyenhez

Miért semmisít meg egy rossz rekordhossz egy teljes munkalap-streamet?

Egy BIFF8 munkafüzet-streamnek nincs saját számtanán kívüli framingje. Minden rekord egy 4 bájtos fejlécből áll, benne rekordazonosítóval (2 bájt) és törzshosszal (2 bájt), majd pontosan ennyi payload bájttal ([MS-XLS] 2.1.4). Nincs elválasztó, magic byte, checksum vagy újraszinkronizálási pont. A reader csak azért talál rá a következő rekordra, mert az előző igazat mondott a saját méretéről. A deklarált hossz nem a rekord metaadata, hanem pointer a következőre. Kövesd le, mit tett a két többletbájt. A reader elfogyasztotta az SXEx fejlécét, átugrotta a fejléc által ígért 24 bájtot, majd két bájttal túl korán, a túlméretezett törzsből ott maradt két nullára érkezett. Ezt a nullát $0000 rekordazonosítóként olvasta, a következő worksheet EOF rekordazonosítót ($000A) pedig a fantomrekord hosszaként, majd kötelességtudóan tíz bájtot ugrott tovább a következő adatokba. Ettől kezdve minden fejléc rossz offsetről olvasódott. A hibás munkafüzetben ennek az lett az eredménye, hogy a chart sheet _Chart mezője újranyitáskor nil volt, a debug dump pedig a $18AF értéket rekordazonosítóként mutatta. Egyik érték sem a pivot kód közelében keletkezett

Az emitter és az író sosem egyeztet

A sodródás szerkezeti oka, hogy a HotXLS egy BIFF rekordot TXLSBlob-ként épít fel, amelyben a header és a payload két független tény. Az EmitSXEx kiírja a rekordazonosítót, majd Blob.AddWord(24)-et a hosszhoz, ezután mezőről mezőre hozzáfűzi a törzset. Ez a 24 kézzel számolt konstans, amelyet soha nem vezet le és nem hasonlít össze a mögötte álló byte-okkal. Az írási útvonal sem zárja be a rést: az AddRec a blobot a TXLSBlobList.Append-nek adja, amely a Data.DataLength bájtjait változtatás nélkül bemásolja a kimeneti streambe. A DataLength a valódi bájtszám, így az író hűségesen 26 bájtos törzset ír a 24-et állító fejléc mögé. Mindkét fél pontosan azt teszi, amit mondtak neki, az ellentmondást viszont senkinek sem feladata észrevenni. A HotXLS ezt már elkerüli a megőrzött payloadok újrajátszásánál: a TXLSWorkbook.StoreDConnBlobs a header hosszszavát a tényleges törzshosszból számítja, és éppen ezért nem sodródott el soha a blob replay

Mit rögzít az [MS-XLS] 2.4.282 az SXEx-ről?

A specifikáció egyértelműen rögzíti a méretet, ezért volt mechanikus a javítás. Az [MS-XLS] 2.4.282 az SXEx törzsét 4 bájtos grbit-ként, majd tíz 2 bájtos mezőként definiálja: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle és cchVacateStyle. Négy plusz húsz az huszonnégy. A régi emitter tizenegy nullás szót írt, miközben a szabvány tízet definiál, az anonim AddWord(0) hívások pedig nem hordozták a mezőneveket, így review közben szemmel megszámolni pontosan olyan megbízható volt, amilyennek hangzik. Az előallokáció adta a nyomot, hogy az elrendezés érthető volt, a ciklus viszont nem: a TXLSBlob.Create(28) pontosan négy headerbájtot és 24 bájtos törzset kér, a blob mégis minden hívásnál túlnő ezen a jelen, méghozzá csendben, mert az AdjustBufferSize szükség szerint újraallokál. Egy kapacitásjelzés, amelyet a kód azonnal túllép, minden serializerben második pillantást érdemel

function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
  Blob: TXLSBlob;
begin
  Blob := TXLSBlob.Create(28);   // 4 bájtos header + 24 bájtos törzs
  Blob.AddWord($00C6);
  Blob.AddWord(24);
  Blob.AddByte($02);
  Blob.AddByte($00);             // grbit1 = fPrintTitles
  Blob.AddByte($00);
  Blob.AddByte($00);             // grbit2
  // Tíz nullás szó tölti ki a 24 bájtos törzset az [MS-XLS] 2.4.282 szerint
  // A deklarált hossz MUST egyezzen az írt bájtokkal, különben minden
  // ezt követő rekord hibásan parsolódik
  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;

Miért élte túl ezt egy teljes PivotTable-tesztkészlet?

Azért, mert a meglévő pivottesztek soha nem round-trippeltek fájlon keresztül. Felépítettek egy munkafüzetet, az in-memory modellen ellenőriztek, és megálltak, az in-memory assertionök pedig nem láthatják azt a hosszeltérést, amely csak a szerializált byte-streamben létezik. A BIFF8 PivotTable-rekordok Delphiből való írásáról szóló rekordsorozat ezen a mércén alaposan tesztelt volt, mégis streamet korrumpáló emitter került ki. A hibának egy második funkcióra is szüksége volt, hogy láthatóvá váljon: egy pivotolt munkalap, amelyet nem követett sok minden, továbbra is újranyílt, mert a korrupció egy olyan alstream végén futott túl, amelyet senki nem vizsgált. Csak a PivotTable és chart sheet együttese, ahol a chart sheetek és rajzok a munkalap után következő alstreamet foglalják el, változtatta a néma elcsúszást láthatóan hiányzó objektummá

// PivotChartRoundTripThroughLinkRecords, tömörítve
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);                       // itt történik a félreparsolás
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);

A javítás előtt a Wb.Sheets[3]._Chart ezen a soron nil volt, mert az olvasó már jóval a chart BOF elérése előtt elvesztette az alstream határát. A pivot-szerializálási hibát végül egy charttal kapcsolatos assertion kapta el

Hogyan olvasd vissza az elcsúszott BIFF-streamet az első rossz rekordig?

Járd végig és írd ki a fejléc-láncot, mert egy deszinkronizált BIFF-stream jóval azelőtt szerkezetileg jelzi a bajt, hogy az adatok hibásnak látszanának. Indulj az alstream BOF-jától ($0809), olvasd az azonosítót és a hosszt, ugorj néggyel plusz a hosszal, majd ismételd. Amíg a stream igazított, hihető rekordazonosítókra érkezel, a lánc pedig pontosan az EOF-nál ($000A) ér véget. Amikor sodródni kezd, nem létező azonosítókat kapsz, a hosszok túlfutnak a pufferen, vagy a lánc egyenesen túlhalad azon a helyen, ahol az EOF-nak lennie kellene

// BIFF-rekordstream bejárása, leállás az első nem valós fejlécre
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)^;
    // A nulla azonosító soha nem szabályos rekord, és egy olyan törzs, amely
    // a pufferen túl fut, bizonyítja, hogy a lánc már korábban elcsúszott
    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;

Ezután visszafelé olvasd a kimenetet, és kapaszkodj egy szabályba: az első rekord, amelyik nem parsolható, szinte soha nem a tettes. Ő az áldozat. A tettes a közvetlenül előtte lévő rekord, az utolsó, amely panasz nélkül parsolódott, mert a saját hosszáról hazudó rekord mindig szépen parsolódik. Itt a bejárás egy fantom $0000 rekordon állt meg, az előtte lévő rekord pedig az SXEx volt. Hasonlítsd össze annak deklarált hosszát a specifikáció mezőlistájával, bájtról bájtra, és a számtan vagy kijön, vagy nem. Ha a menet még az első értelmes rekordig sem jut el, akkor a probléma egy réteggel lejjebb, a Workbook-streamet tartó OLE2 compound file-ban van, és semmilyen rekord-szintű dump nem fog segíteni

Emitter, amely nem tud hazudni a saját hosszáról

A tartós javítás nem a helyes konstans, hanem a hibás konstans kiírásának lehetőségét szünteti meg. Foglald le a hosszmezőt, írd ki a törzset, majd a ténylegesen létrehozott bájtszámból írd vissza a fejlécet. A HotXLS ehhez mindent megad: a TXLSBlob.DataLength az aktuális offsetet adja, a SetWord pedig visszaír egy már kibocsátott pozícióba

function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
  Blob.AddWord(RecId);
  Result := Blob.DataLength;   // megjegyezzük, hol van a hosszmező
  Blob.AddWord(0);             // helyőrző, az EndRecord javítja ki
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;

Arról is őszintén kell beszélni, meddig tart ez a garancia. Az a sablonos assertion, hogy a kibocsátott bájtok egyenlők 2 + 2 + deklarált értékkel, csak azokra a rekordokra áll, amelyek beleférnek a BIFF8 8224 bájtos payloadhatárába. A túlméretes törzsek jogosan 8224-et deklarálnak a headerben, és $003C Continue rekordokban folytatódnak, pontosan úgy, ahogy a HotXLS pivot cache- és connection-írói teszik nagy payloadoknál, ezért az invariáns feltételes: a határ alatt a kibocsátott blob hossza a deklarált hossz plusz négy, felette a splitter birtokolja a számtant. Ezt a különbséget a helperben kódold, ne kommentben. Ugyanez az érvelés minden tag-length-value formátumra átvihető, nem csak a BIFF-re. Egy emitter, amely még azelőtt deklarál méretet, hogy ismerné, mit írt, olyan állítást tesz, amelyet a kód nem tud ellenőrizni, a reviewer pedig nem tud megszámolni, és addig működik, amíg egy második feature nem kerül az első mögé

A BIFF8-író, a pivot-emitterek és az itt tárgyalt chart-alstream a HotXLS Delphi spreadsheet component része Delphi és C++Builder számára, XLS, XLSX és ODS olvasásával és írásával telepített Excel nélkül