Odborný článok

Odchýlka dĺžky BIFF záznamu v Delphi XLS writeri

HotXLS 2.376.0 opravil odchýlku dĺžky BIFF záznamu vo svojom klasickom XLS writeri: emitter SXEx pre pohľady PivotTable deklaroval v hlavičke telo s veľkosťou 24 bajtov a potom pripojil 26 bajtov. BIFF reader dôveruje deklarovanej dĺžke, takže dva prebytočné bajty desynchronizovali všetko downstream a workbooky spájajúce PivotTable s chart sheet po znovuotvorení stratili graf

Zaujímavá časť nie je off-by-one slovo. Je ňou vzdialenosť medzi chybou a príznakom. V mieste chyby nič nezlyhalo. Pivot records sa serializovali čisto, súbor sa zapísal bez chyby, Excel ho otvoril a poškodenie sa prejavilo až o stovky bajtov ďalej v úplne nesúvisiacom substreame. Táto vzdialenosť je typická pre každý binárny formát s dĺžkou v prefixe a oplatí sa jej rozumieť skôr, než pre jeden taký formát napíšete ďalší emitter

Prečo jedna nesprávna dĺžka záznamu zničí celý worksheet stream?

BIFF8 workbook stream nemá iné framing než vlastnú aritmetiku. Každý record je 4-bajtová hlavička s record id (2 bajty) a dĺžkou tela (2 bajty), po ktorej nasleduje presne toľko payload bajtov ([MS-XLS] 2.1.4). Nie je tu oddeľovač, magic byte, checksum ani resynchronizačný bod. Reader pristane na ďalšom recorde iba preto, že predchádzajúci mu povedal pravdu o vlastnej veľkosti. Deklarovaná dĺžka nie je metadata recordu, ale pointer na ďalší. Sledujte teda, čo urobili dva prebytočné bajty. Reader spotreboval hlavičku SXEx, preskočil 24 bajtov, ktoré hlavička sľúbila, a pristál o dva bajty príliš skoro na dvojici núl, ktoré zostali z predimenzovaného tela. Tieto nuly prečítal ako record id $0000, potom id worksheet EOF recordu ($000A) prečítal ako dĺžku fantómového recordu a poslušne preskočil desať bajtov do toho, čo nasledovalo. Od tohto bodu sa každá hlavička čítala na nesprávnom offsete. Vo workbooke, ktorý zlyhal, to vytvorilo chart sheet, ktorého _Chart bol po znovuotvorení nil, a debug dump ukázal interpretáciu $18AF ako record id. Ani jedna z týchto hodnôt sa nenachádza v blízkosti pivot kódu

Emitter a writer spolu nikdy neporovnávajú poznámky

Štrukturálny dôvod, prečo bola odchýlka možná, je, že HotXLS zostavuje BIFF record ako TXLSBlob, ktorého hlavička a payload sú dve nezávislé skutočnosti. EmitSXEx zapíše record id, potom Blob.AddWord(24) pre dĺžku a potom pridá telo po jednotlivých poliach. Hodnota 24 je ručne spočítaná konštanta, nikdy odvodená z bajtov, ktoré po nej nasledujú, ani s nimi porovnaná. Write path medzeru tiež nezatvorí: AddRec odovzdá blob do TXLSBlobList.Append, ktorý skopíruje Data.DataLength bajtov bez zmeny do výstupného streamu. DataLength je skutočný počet bajtov, takže writer poctivo emituje 26 bajtov tela za hlavičkou tvrdiacou 24. Obe polovice robia presne to, čo im bolo povedané, a rozpor medzi nimi nemá kto zaregistrovať. HotXLS sa tomu už vyhýba pri replayi zachovaných payloadov: TXLSWorkbook.StoreDConnBlobs vypočíta length word hlavičky zo skutočnej dĺžky tela namiesto literálu, presne preto blob replay nikdy nedriftoval

Čo presne stanovuje [MS-XLS] 2.4.282 o SXEx

Špecifikácia je v otázke veľkosti jednoznačná, čo urobilo opravu mechanickou. [MS-XLS] 2.4.282 definuje telo SXEx ako 4-bajtový grbit nasledovaný desiatimi 2-bajtovými poľami: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle a cchVacateStyle. Štyri plus dvadsať je dvadsaťštyri. Starý emitter zapísal jedenásť nulových wordov tam, kde špecifikácia definuje desať, a anonymné volania AddWord(0) neniesli názvy polí, takže počítať ich pri review od oka bolo presne také spoľahlivé, ako to znie. Preallocácia bola nápovedou, že layout bol pochopený, ale slučka nie: TXLSBlob.Create(28) žiada presne štyri bajty hlavičky plus 24-bajtové telo, no blob túto nápovedu pri každom volaní prerastie a rastie potichu, pretože AdjustBufferSize realokuje podľa potreby. Na hint kapacity, ktorý kód okamžite prekročí, sa oplatí pozrieť druhýkrát v každom serializéri

function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
  Blob: TXLSBlob;
begin
  Blob := TXLSBlob.Create(28);   // 4-bajtová hlavička + 24-bajtové telo
  Blob.AddWord($00C6);
  Blob.AddWord(24);
  Blob.AddByte($02);
  Blob.AddByte($00);             // grbit1 = fPrintTitles
  Blob.AddByte($00);
  Blob.AddByte($00);             // grbit2
  // Desať nulových wordov dokončí 24-bajtové telo podľa [MS-XLS] 2.4.282
  // Deklarovaná dĺžka MUSÍ súhlasiť so zapísanými bajtmi, inak sa každý
  // ďalší record parsuje nesprávne
  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;

Prečo to prežilo celú test suite PivotTable?

Pretože existujúce pivot testy nikdy nevykonali round-trip cez súbor. Vytvorili workbook, overili in-memory model a tam skončili, pričom in-memory assertions nevidia nesúlad dĺžok, ktorý existuje iba v serializovanom byte streame. Množina recordov pokrytá článkom o zápise BIFF8 PivotTable recordov z Delphi bola podľa tohto štandardu dobre otestovaná a aj tak dodala emitter poškodzujúci stream. Defekt navyše potreboval druhú funkciu, aby sa stal viditeľným: pivot worksheet, po ktorom toho veľa nenasledovalo, sa stále znovu otvoril, pretože korupcia utiekla za koniec substreamu, ktorý nikto nekontroloval. Až kombinácia PivotTable a chart sheet, kde chart sheets a drawings zaberajú substream nasledujúci za worksheetom, premenila tiché zlé zarovnanie na viditeľne chýbajúci objekt

// PivotChartRoundTripThroughLinkRecords, skrátene
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);                       // nesprávne parsovanie nastane tu
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);

Pred opravou bol Wb.Sheets[3]._Chart na tomto riadku nil, pretože reader stratil hranicu substreamu dávno predtým, než došiel k chart BOF. Assertion, ktorá napokon zachytila chybu v pivot serializácii, bola assertion o grafe

Ako prečítať zle zarovnaný BIFF stream späť k prvému chybnému záznamu

Prejdite reťaz hlavičiek a vypíšte ju, pretože desynchronizovaný BIFF stream sa štrukturálne ohlási dávno predtým, než dáta začnú vyzerať nesprávne. Začnite na substream BOF ($0809), prečítajte id a length, posuňte sa o štyri plus length a opakujte. Kým je stream zarovnaný, pristávate na vierohodných record ids a reťaz sa presne skončí na EOF ($000A). Keď driftne, dostanete id, ktoré neexistujú, length, ktoré pretečú buffer, alebo reťaz, ktorá kráča rovno za miesto, kde mal byť EOF

// Prejdi BIFF record stream a zastav na prvej hlavičke, ktorá nemôže byť skutočná
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)^;
    // Nulové id nikdy nie je legálny record a telo, ktoré ide za buffer,
    // dokazuje, že reťaz už niekde vyššie driftla
    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;

Potom čítajte výstup odzadu a držte sa jedného pravidla: prvý record, ktorý sa nedá parsovať, je takmer nikdy vinník. Je obeť. Vinníkom je record bezprostredne pred ním, posledný, ktorý sa parsoval bez sťažnosti, pretože klamár o vlastnej dĺžke sa vždy parsuje dobre. V tomto prípade sa prechod zastavil na fantómovom recorde $0000 a record pred ním bol SXEx. Porovnajte deklarovanú dĺžku recordu so zoznamom polí v špecifikácii bajt po bajte a aritmetika buď vyjde, alebo nie. Ak prechod nedôjde ani k rozumnej prvej record, problém je o vrstvu nižšie, v zloženom súbore OLE2, ktorý drží Workbook stream, a žiadne množstvo dumpovania na úrovni recordov nepomôže

Emitter, ktorý nemôže klamať o vlastnej dĺžke

Trvalá oprava nie je správna konštanta, ale odstránenie príležitosti zapísať nesprávnu. Rezervujte length word, emitujte telo a potom opravte hlavičku podľa počtu bajtov, ktoré ste skutočne vyprodukovali. HotXLS poskytuje presne to, čo treba: TXLSBlob.DataLength dá aktuálny offset a SetWord zapíše späť do už emitovanej pozície

function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
  Blob.AddWord(RecId);
  Result := Blob.DataLength;   // zapamätaj si, kde sedí length word
  Blob.AddWord(0);             // placeholder, ktorý opraví 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;

Buďte poctiví v tom, kde sa táto garancia končí. Všeobecná assertion, že emitované bajty sa rovnajú 2 + 2 + declared, platí iba pre recordy, ktoré sa zmestia pod BIFF8 limit 8224 payload bajtov. Predimenzované telá legitímne deklarujú v hlavičke 8224 a pokračujú v recordoch $003C Continue, čo je presne to, čo writeri HotXLS pre pivot cache a connections robia pri veľkých payloadoch, takže invariant je podmienený: pod limitom sa dĺžka emitovaného blobu musí rovnať deklarovanej dĺžke plus štyri, nad limitom aritmetiku vlastní splitter. Zakódujte toto rozlíšenie do helpera, nie do komentára. Rovnaké uvažovanie sa prenáša na každý tag-length-value formát, nielen BIFF. Emitter, ktorý deklaruje veľkosť skôr, než ju pozná, vyslovil tvrdenie, ktoré kód nevie skontrolovať a reviewer spočítať, a funguje presne dovtedy, kým downstream od prvého nepribudne druhá funkcia

BIFF8 writer, emitery pivot recordov a chart substream opísané tu sa dodávajú ako súčasť HotXLS Delphi spreadsheet component pre Delphi a C++Builder, ktorý číta a zapisuje XLS, XLSX a ODS bez nainštalovaného Excelu