Technický článek

Drift délky BIFF záznamu ve writeru XLS pro Delphi

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