Tehnički članak

Drift dužine BIFF zapisa u Delphi XLS writer-u

HotXLS 2.376.0 popravio je drift dužine BIFF zapisa u svom klasičnom XLS writer-u: SXEx emitter za PivotTable prikaze deklarisao je telo od 24 bajta u zaglavlju, a zatim dodao 26 bajtova. BIFF reader veruje deklarisanoj dužini, pa su dva suvišna bajta desinhronizovala sve nizvodno, a radne sveske koje uparuju PivotTable sa chart sheet-om gubile su grafikon pri ponovnom otvaranju

Zanimljiv deo nije reč off-by-one. To je razdaljina između greške i simptoma. Ništa nije otkazalo na mestu baga. Pivot zapisi su se uredno serijalizovali, fajl se upisao bez greške, Excel ga je otvorio, a šteta se pojavila tek stotinama bajtova nizvodno u potpuno nepovezanom podstream-u. Ta udaljenost je karakteristična za svaki binarni format sa dužinom ispred sadržaja i vredi je razumeti pre nego što napišete još jedan emitter za takav format

Zašto jedna pogrešna dužina zapisa uništi ceo stream radnog lista?

BIFF8 stream radne sveske nema framing osim sopstvene aritmetike. Svaki zapis jeste zaglavlje od 4 bajta, sa ID-jem zapisa od 2 bajta i dužinom tela od 2 bajta, za kojim sledi tačno toliko bajtova payload-a ([MS-XLS] 2.1.4). Nema separatora, magic bajta, checksum-a ni tačke za resynchronization. Reader stiže do sledećeg zapisa samo zato što mu prethodni govori istinu o sopstvenoj veličini. Deklarisana dužina nije metadata o zapisu; ona je pokazivač na sledeći zapis. Zato pratite šta su dva suvišna bajta uradila. Reader je pročitao SXEx zaglavlje, preskočio 24 bajta koje je zaglavlje obećalo i stigao dva bajta prerano, na par nula koje su ostale od prevelikog tela. Te nule je pročitao kao ID zapisa $0000, zatim je ID sledećeg worksheet EOF zapisa ($000A) pročitao kao dužinu tog fantomskog zapisa i poslušno preskočio deset bajtova u ono što je sledilo. Od tog mesta svako zaglavlje čitalo se na pogrešnom offsetu. U radnoj svesci sa greškom to je proizvelo chart sheet čiji je _Chart posle ponovnog otvaranja bio nil, kao i debug dump u kojem je $18AF tumačen kao ID zapisa. Nijedna od tih vrednosti ne pojavljuje se blizu Pivot koda

Emitter i writer nikada ne upoređuju beleške

Strukturni razlog zbog kojeg je drift bio moguć jeste to što HotXLS gradi BIFF zapis kao TXLSBlob, čije su zaglavlje i payload dve nezavisne činjenice. EmitSXEx upisuje ID zapisa, zatim Blob.AddWord(24) za dužinu, pa dodaje polja tela jedno po jedno. Tih 24 je ručno prebrojana konstanta, nikada izvedena iz bajtova koji slede niti proverena prema njima. Ni putanja upisa ne zatvara jaz: AddRec prosleđuje blob u TXLSBlobList.Append, koji verbatim kopira Data.DataLength bajtova u izlazni stream. DataLength je stvarni broj bajtova, pa writer verno emituje 26 bajtova tela iza zaglavlja koje tvrdi da ih ima 24. Obe polovine rade tačno ono što im je rečeno, a protivrečnost između njih nije ničiji posao da je primeti. HotXLS to već izbegava tamo gde ponavlja sačuvane payload-e: TXLSWorkbook.StoreDConnBlobs računa reč dužine zaglavlja iz stvarne dužine tela, a upravo zato replay blob-ova nikada nije driftovao

Šta [MS-XLS] 2.4.282 precizira za SXEx

Specifikacija je nedvosmislena u pogledu veličine, zbog čega je popravka bila mehanička. [MS-XLS] 2.4.282 definiše SXEx telo kao 4-bajtni grbit za kojim sledi deset polja od po 2 bajta: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle i cchVacateStyle. Četiri plus dvadeset jeste dvadeset četiri. Stari emitter upisao je jedanaest nultih reči tamo gde specifikacija definiše deset, a anonimni pozivi AddWord(0) nisu nosili imena polja, pa je njihovo brojanje okom tokom review-a bilo pouzdano tačno koliko zvuči. Prealokacija je bila nagoveštaj da je layout shvaćen, ali da petlja nije: TXLSBlob.Create(28) traži tačno četiri bajta zaglavlja i telo od 24 bajta, a blob je na svakom pozivu prerastao tu sugestiju i to nečujno, jer AdjustBufferSize po potrebi realocira. Sugestiju kapaciteta koju kod odmah prekorači vredi ponovo pogledati u svakom serializer-u

function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
  Blob: TXLSBlob;
begin
  Blob := TXLSBlob.Create(28);   // zaglavlje od 4 bajta + telo od 24 bajta
  Blob.AddWord($00C6);
  Blob.AddWord(24);
  Blob.AddByte($02);
  Blob.AddByte($00);             // grbit1 = fPrintTitles
  Blob.AddByte($00);
  Blob.AddByte($00);             // grbit2
  // Deset nultih reči kompletira telo od 24 bajta prema [MS-XLS] 2.4.282
  // Deklarisana dužina MORA da se podudara sa upisanim bajtovima, inače
  // svaki zapis posle ovog biva pogrešno parsiran
  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;

Kako je ovo preživelo ceo PivotTable test suite?

Zato što postojeći pivot testovi nikada nisu radili round-trip kroz fajl. Izgradili bi radnu svesku, proverili model u memoriji i tu stali, a asercije u memoriji ne mogu da vide neslaganje dužine koje postoji samo u serijalizovanom byte stream-u. Skup zapisa koji pokriva tekst o upisu BIFF8 PivotTable zapisa iz Delphi-ja bio je dobro testiran po tom kriterijumu, a ipak je isporučio emitter koji korumpira stream. Defektu je bila potrebna i druga funkcija da bi postao vidljiv: pivotovan worksheet iza kojeg nije sledilo mnogo toga i dalje se ponovo otvarao, jer je korupcija izlazila na kraj podstream-a koji niko nije pregledao. Tek kombinacija PivotTable-a i chart sheet-a, gde chart sheet-ovi i crteži zauzimaju podstream koji sledi za worksheet-om, pretvorila je tihu neusklađenost u vidljiv objekat koji nedostaje

// PivotChartRoundTripThroughLinkRecords, skraćeno
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);                       // pogrešno parsiranje dešava se ovde
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);

Pre popravke, Wb.Sheets[3]._Chart bio je nil u tom redu, jer je reader izgubio granicu podstream-a mnogo pre nego što je stigao do chart BOF-a. Asercija koja je konačno uhvatila grešku u Pivot serijalizaciji bila je asercija o grafikonu

Kako pročitati pogrešno poravnat BIFF stream nazad do prvog lošeg zapisa

Prođite lanac zaglavlja i ispišite ga, jer se desinhronizovani BIFF stream strukturalno najavi mnogo pre nego što podaci izgledaju pogrešno. Počnite od podstream BOF-a ($0809), pročitajte ID i dužinu, pomerite se za četiri plus dužinu i ponavljajte. Dok je stream poravnat, stižete do verovatnih ID-jeva zapisa i lanac se tačno završava na EOF-u ($000A). Kada drift počne, dobijate ID-jeve koji ne postoje, dužine koje izlaze iz bafera ili lanac koji pravo prolazi pored mesta na kojem je EOF trebalo da bude

// Prođi kroz BIFF stream zapisa i stani na prvom zaglavlju koje ne može biti stvarno
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)^;
    // Nulti ID nikada nije legalan zapis, a telo koje prelazi
    // bafer dokazuje da je lanac već negde ranije driftovao
    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;

Zatim čitajte izlaz unazad i držite se jednog pravila: prvi zapis koji ne uspe da se parsira gotovo nikada nije krivac. On je žrtva. Krivac je zapis neposredno pre njega, poslednji koji je prošao bez prigovora, jer lažov o sopstvenoj dužini uvek uspešno prođe. U ovom slučaju prolaz se zaustavio na fantomskom zapisu $0000, a zapis pre njega bio je SXEx. Uporedite deklarisanu dužinu tog zapisa sa spiskom polja u specifikaciji, bajt po bajt, i aritmetika će ili da se složi ili neće. Ako prolaz uopšte ne stigne do razumnog prvog zapisa, problem je sloj niže, u OLE2 compound fajlu koji drži Workbook stream, i nikakvo dump-ovanje na nivou zapisa neće pomoći

Emitter koji ne može da slaže o sopstvenoj dužini

Trajna popravka nije ispravna konstanta, već uklanjanje mogućnosti da se upiše pogrešna. Rezervišite reč dužine, emitujte telo, pa zaglavlje zakrpite iz broja bajtova koji ste zaista proizveli. HotXLS izlaže ono što je potrebno: TXLSBlob.DataLength daje trenutni offset, a SetWord upisuje nazad na već emitovanu poziciju

function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
  Blob.AddWord(RecId);
  Result := Blob.DataLength;   // zapamti gde stoji reč dužine
  Blob.AddWord(0);             // placeholder, EndRecord ga zakrpi
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;

Budite iskreni gde ta garancija prestaje. Opšta asercija da emitovani bajtovi jednaki 2 + 2 + deklarisana dužina važi samo za zapise koji staju ispod BIFF8 limita od 8224 payload bajta. Telo veće od toga legitimno deklariše 8224 u zaglavlju i nastavlja u $003C Continue zapisima, što upravo rade HotXLS pivot cache i connection writer-i za velike payload-e, pa je invarijanta uslovna: ispod limita dužina emitovanog bloba mora da bude deklarisana dužina plus četiri, a iznad njega splitter preuzima aritmetiku. Tu razliku kodirajte u helper-u, a ne u komentaru. Isto razmišljanje prenosi se na svaki tag-length-value format, ne samo na BIFF. Emitter koji objavi veličinu pre nego što je zna napisao je tvrdnju koju kod ne može da proveri, a reviewer ne može da prebroji, i radi tačno do trenutka kada druga funkcija legne nizvodno od prve

BIFF8 writer, pivot emitter-i i chart podstream opisan ovde isporučuju se kao deo HotXLS Delphi spreadsheet komponente za Delphi i C++Builder, koja čita i upisuje XLS, XLSX i ODS bez instaliranog Excel-a