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