HotXLS 2.376.0 opravil drift délky BIFF záznamu ve svém writeru klasického XLS: emitter SXEx pro pohledy PivotTable v hlavičce deklaroval tělo o 24 bajtech a potom připojil 26 bajtů. BIFF reader důvěřuje deklarované délce, takže dva přebytečné bajty desynchronizovaly vše downstream a workbooky kombinující PivotTable s chart sheetem po znovuotevření ztratily graf
Zajímavá není samotná off-by-one hodnota. Zajímavá je vzdálenost mezi chybou a příznakem. V místě chyby nic neselhalo. Pivot rekordy se serializovaly čistě, soubor se zapsal bez chyby, Excel ho otevřel a poškození se objevilo až o stovky bajtů dál v úplně nesouvisejícím substreamu. Tato vzdálenost je typická pro každý binární formát s délkou uvozenou prefixem a je dobré jí rozumět, než pro některý z nich napíšete další emitter
Proč jedna špatná délka záznamu zničí celý worksheet stream
BIFF8 workbook stream nemá žádné framing kromě vlastní aritmetiky. Každý záznam tvoří čtyřbajtová hlavička s ID záznamu (2 bajty) a délkou těla (2 bajty), po níž následuje přesně tolik payloadových bajtů ([MS-XLS] 2.1.4). Neexistuje oddělovač, magic byte, checksum ani resynchronizační bod. Reader se dostane na další záznam jen proto, že předchozí mu pravdivě řekl svou velikost. Deklarovaná délka není metadata o záznamu, ale pointer na další záznam. Sledujte tedy, co způsobily dva přebytečné bajty. Reader načetl hlavičku SXEx, přeskočil 24 bajtů slíbených hlavičkou a skončil o dva bajty příliš brzy na dvojici nul, která zůstala z příliš velkého těla. Tyto nuly přečetl jako ID záznamu $0000, potom ID worksheet EOF ($000A) jako délku tohoto přízračného záznamu a poslušně přeskočil deset bajtů do toho, co následovalo. Od té chvíle se každá hlavička četla z nesprávného offsetu. V rozbitém workbooku to vytvořilo chart sheet, jehož _Chart byl po znovuotevření nil, a debug dump ukázal $18AF interpretované jako ID záznamu. Ani jedna z těchto hodnot nebyla nikde poblíž pivotového kódu
Emitter a writer si navzájem nekontrolují práci
Strukturální důvod, proč byl drift možný, spočívá v tom, že HotXLS sestaví BIFF záznam jako TXLSBlob, jehož hlavička a payload jsou dvě nezávislé skutečnosti. EmitSXEx zapíše ID záznamu, potom Blob.AddWord(24) pro délku a následně přidává pole těla jedno po druhém. Hodnota 24 je ručně spočítaná konstanta, která se nikdy neodvodí z následujících bajtů ani se s nimi neporovná. Write path mezeru také nezavírá: AddRec předá blob do TXLSBlobList.Append, který verbatim zkopíruje Data.DataLength bajtů do výstupního streamu. DataLength je skutečný počet bajtů, takže writer věrně vypíše 26 bajtů těla za hlavičkou, která tvrdí 24. Obě poloviny dělají přesně to, co jim bylo řečeno, a rozpor mezi nimi není ničí úkol zaznamenat. HotXLS už tomu předchází tam, kde přehrává zachované payloady: TXLSWorkbook.StoreDConnBlobs spočítá délkové slovo hlavičky ze skutečné délky těla místo literálu, a právě proto se replay blobů nikdy nerozjel
Co o SXEx pevně stanoví [MS-XLS] 2.4.282
Specifikace je o velikosti jednoznačná, což učinilo opravu mechanickou. [MS-XLS] 2.4.282 definuje tělo SXEx jako čtyřbajtový grbit následovaný deseti dvoubajtovými poli: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle a cchVacateStyle. Čtyři plus dvacet je dvacet čtyři. Starý emitter zapsal jedenáct nulových slov místo deseti, které specifikace definuje, a anonymní volání AddWord(0) nenesla jména polí, takže jejich počítání očima při review bylo přesně tak spolehlivé, jak zní. Vodítkem byla předalokace: TXLSBlob.Create(28) žádá přesně čtyři hlavičkové bajty plus 24 bajtů těla, přesto blob při každém volání přerostl tento hint a potichu, protože AdjustBufferSize podle potřeby realokuje. Na kapacitní hint, který kód okamžitě překročí, je dobré se u každého serializeru podívat podruhé
function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
Blob: TXLSBlob;
begin
Blob := TXLSBlob.Create(28); // 4 bajty hlavičky + 24 bajtů těla
Blob.AddWord($00C6);
Blob.AddWord(24);
Blob.AddByte($02);
Blob.AddByte($00); // grbit1 = fPrintTitles
Blob.AddByte($00);
Blob.AddByte($00); // grbit2
// Deset nulových slov dokončí tělo o 24 bajtech podle [MS-XLS] 2.4.282
// Deklarovaná délka MUSÍ odpovídat zapsaným bajtům, jinak se
// každý další záznam parsuje chybně
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;
Proč to přežilo celou testovací sadu PivotTable
Protože existující pivot testy nikdy neprovedly round-trip přes soubor. Sestavily workbook, ověřily in-memory model a skončily, přičemž in-memory assertion nemůže vidět nesoulad délky, který existuje jen v serializovaném byte streamu. Sada rekordů popsaná v zápisu rekordů BIFF8 PivotTable z Delphi byla podle tohoto standardu dobře testovaná, a přesto vypustila emitter poškozující stream. Defekt potřeboval ke zviditelnění také druhou funkci: pivotovaná worksheet, po níž už nic podstatného nenásledovalo, se stále znovu otevřela, protože korupce utekla za konec substreamu, který nikdo nekontroloval. Teprve kombinace PivotTable a chart sheetu, kde chart sheety a drawingy zabírají substream následující po worksheet, proměnila tiché špatné zarovnání ve viditelně chybějící objekt
// PivotChartRoundTripThroughLinkRecords, zkráceno
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); // k chybnému parsování dojde zde
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);
Před opravou byl Wb.Sheets[3]._Chart na tomto řádku nil, protože reader ztratil hranici substreamu dlouho předtím, než se dostal k BOF grafu. Assertion, která nakonec zachytila chybu v serializaci pivotu, byla assertion o grafu
Jak přečíst špatně zarovnaný BIFF stream zpět k prvnímu chybnému záznamu
Projít řetězec hlaviček a vypsat ho je správná cesta, protože desynchronizovaný BIFF stream se strukturálně prozradí dlouho předtím, než data vypadají špatně. Začněte u substream BOF ($0809), načtěte ID a délku, posuňte se o čtyři plus délku a opakujte. Dokud je stream zarovnaný, dopadáte na plausibilní ID záznamů a řetězec skončí přesně na EOF ($000A). Po driftu dostanete neexistující ID, délky překračující buffer nebo řetězec, který projde rovnou za místo, kde měl být EOF
// Projdi BIFF record stream a zastav se u první hlavičky, která nemůže být skuteč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 není nikdy legální záznam a tělo, které přesáhne
// buffer, dokazuje, že řetězec už někde předtím ujel
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 výstup čtěte pozpátku a držte se jednoho pravidla: první záznam, který se neparsuje, je téměř nikdy viníkem. Je obětí. Viníkem je bezprostředně předchozí záznam, poslední, který se bez stížnosti naparsoval, protože lhář o vlastní délce se naparsuje dobře. V tomto případě se průchod zastavil na přízračném záznamu $0000 a záznamem před ním byl SXEx. Porovnejte deklarovanou délku tohoto záznamu se seznamem polí ve specifikaci bajt po bajtu a aritmetika buď vyjde, nebo ne. Pokud se průchod nikdy nedostane ani k rozumnému prvnímu záznamu, je problém o vrstvu níže, v OLE2 compound file držícím Workbook stream, a žádné množství dumpů na úrovni rekordů nepomůže
Emitter, který nemůže lhát o vlastní délce
Trvalou opravou není správná konstanta, ale odstranění možnosti zapsat nesprávnou. Rezervujte délkové slovo, vyemitujte tělo a potom hlavičku opravte podle počtu bajtů, které jste skutečně vytvořili. HotXLS nabízí vše potřebné: TXLSBlob.DataLength vrací aktuální offset a SetWord zapisuje zpět do již vyemitované pozice
function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
Blob.AddWord(RecId);
Result := Blob.DataLength; // zapamatuj si pozici délkového slova
Blob.AddWord(0); // placeholder, který 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;
Poctivě si řekněme, kde tato záruka končí. Obecná assertion, že emitované bajty se rovnají 2 + 2 + deklarované, platí pouze pro rekordy, které se vejdou pod limit BIFF8 8224 payloadových bajtů. Příliš velká těla legitimně deklarují v hlavičce 8224 a pokračují v rekordech $003C Continue, přesně jak to dělají writer pivot cache a connections v HotXLS, takže invariant je podmíněný: pod limitem musí délka emitovaného blobu odpovídat deklarované délce plus čtyři, nad ním přebírá aritmetiku splitter. Tento rozdíl zakódujte do helperu, nikoli do komentáře. Stejné uvažování se přenáší na každý tag-length-value formát, nejen na BIFF. Emitter, který deklaruje velikost dřív, než ji zná, vytvořil tvrzení, jež kód nedokáže zkontrolovat a reviewer spočítat, a funguje přesně do chvíle, kdy downstream prvního přibude druhá funkce
Writer BIFF8, emitery pivotových rekordů a chart substream popsaný zde jsou součástí HotXLS Delphi spreadsheet component pro Delphi a C++Builder, který čte a zapisuje XLS, XLSX a ODS bez instalovaného Excelu