Tehnički članak

Pomak duljine BIFF zapisa u Delphi XLS pisaču

HotXLS 2.376.0 popravio je pomak duljine BIFF zapisa u svojem klasičnom XLS pisaču: SXEx emiter za prikaze PivotTablea u zaglavlju je deklarirao tijelo od 24 bajta, a zatim dodao 26 bajtova. BIFF čitač vjeruje deklariranoj duljini, pa su dva suvišna bajta desinkronizirala sve nizvodno, a radne knjige koje uparuju PivotTable s listom grafikona izgubile su grafikon pri ponovnom otvaranju

Zanimljiv dio nije riječ off-by-one. To je udaljenost između pogreške i simptoma. Ništa nije zakazalo na mjestu kvara. Pivot zapisi serijalizirali su se čisto, datoteka se zapisala bez pogreške, Excel ju je otvorio, a šteta se pojavila tek stotinama bajtova nizvodno u posve nepovezanom podtoku. Ta je udaljenost svojstvena svakom binarnom formatu s prefiksom duljine i vrijedi je razumjeti prije nego što za neki od njih napišete još jedan emiter

Zašto jedna pogrešna duljina zapisa uništi cijeli tok radnog lista?

BIFF8 tok radne knjige nema uokvirivanje osim vlastite aritmetike. Svaki zapis je zaglavlje od 4 bajta, s ID-jem zapisa od 2 bajta i duljinom tijela od 2 bajta, nakon kojeg slijedi točno toliko bajtova korisnog tereta ([MS-XLS] 2.1.4). Nema razdjelnika, magičnog bajta, kontrolne sume ni točke za ponovnu sinkronizaciju. Čitač stiže na sljedeći zapis samo zato što mu prethodni govori istinu o vlastitoj veličini. Deklarirana duljina nije metapodatak o zapisu; ona je pokazivač na sljedeći. Zato pratite što su napravila dva suvišna bajta. Čitač je potrošio SXEx zaglavlje, preskočio 24 bajta koja je zaglavlje obećalo i sletio dva bajta prerano, na par nula preostalih iz prevelikog tijela. Te je nule pročitao kao ID zapisa $0000, zatim je ID sljedećeg EOF zapisa radnog lista ($000A) pročitao kao duljinu tog fantomskog zapisa i poslušno preskočio deset bajtova u ono što je slijedilo. Od tog trenutka svako je zaglavlje čitao na pogrešnom pomaku. U radnoj knjizi koja je padala to je proizvelo list grafikona čiji je _Chart nakon ponovnog otvaranja bio nil i ispis otklanjanja pogrešaka koji je $18AF tumačio kao ID zapisa. Nijedna se od tih vrijednosti ne pojavljuje blizu koda pivota

Emiter i pisač nikad ne uspoređuju bilješke

Strukturni razlog zbog kojeg je pomak bio moguć jest da HotXLS BIFF zapis gradi kao TXLSBlob čije su zaglavlje i korisni teret dvije neovisne činjenice. EmitSXEx zapisuje ID zapisa, zatim Blob.AddWord(24) za duljinu i naposljetku dodaje tijelo polje po polje. Ta je 24 ručno prebrojena konstanta, nikad izvedena iz bajtova koji slijede niti provjerena prema njima. Put za pisanje također ne zatvara prazninu: AddRec prosljeđuje blob u TXLSBlobList.Append, koji bajtove Data.DataLength doslovno kopira u izlazni tok. DataLength je stvarni broj bajtova, pa pisač vjerno emitira 26 bajtova tijela iza zaglavlja koje tvrdi da ih ima 24. Obje polovice rade točno ono što im je rečeno, a proturječje između njih nije ničiji posao primijetiti. HotXLS to već izbjegava ondje gdje ponavlja sačuvane korisne terete: TXLSWorkbook.StoreDConnBlobs računa riječ duljine zaglavlja iz stvarne duljine tijela, a upravo zato se pri ponavljanju blobova nikad nije dogodio pomak

Što [MS-XLS] 2.4.282 utvrđuje za SXEx

Specifikacija je nedvosmislena o veličini, zbog čega je popravak bio mehanički. [MS-XLS] 2.4.282 definira SXEx tijelo kao četverobajtni grbit iza kojeg slijedi deset polja od 2 bajta: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle i cchVacateStyle. Četiri plus dvadeset jest dvadeset i četiri. Stari emiter zapisivao je jedanaest nultih riječi ondje gdje specifikacija definira deset, a anonimni pozivi AddWord(0) nisu nosili imena polja, pa je njihovo brojanje okom tijekom pregleda bilo pouzdano upravo koliko i zvuči. Prealokacija je bila trag da je raspored shvaćen, ali petlja nije: TXLSBlob.Create(28) traži točno četiri bajta zaglavlja i tijelo od 24 bajta, a blob je ipak pri svakom pozivu narastao iznad te naznake i to tiho jer AdjustBufferSize po potrebi ponovno alocira. Na naznaku kapaciteta koju kod odmah prekoračuje vrijedi drugi put pogledati u svakom serijalizatoru

function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
  Blob: TXLSBlob;
begin
  Blob := TXLSBlob.Create(28);   // četverobajtno zaglavlje + tijelo 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 riječi dovršava tijelo od 24 bajta prema [MS-XLS] 2.4.282
  // Deklarirana duljina MORA odgovarati zapisanim bajtovima ili se svaki zapis
  // nakon ovoga pogrešno raščlanjuje
  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živjelo cijeli testni paket PivotTablea?

Jer postojeći testovi pivota nikad nisu prošli kroz povratni ciklus datoteke. Izgradili su radnu knjigu, provjerili model u memoriji i ondje stali, a provjere u memoriji ne mogu vidjeti neslaganje duljine koje postoji samo u serijaliziranom toku bajtova. Skup zapisa koji pokriva zapisivanje BIFF8 PivotTable zapisa iz Delphija bio je dobro testiran prema tom standardu, a ipak je isporučio emiter koji kvari tok. Kvaru je također trebala druga značajka da bi postao vidljiv: pivotirani radni list iza kojeg ne slijedi mnogo toga i dalje se ponovno otvarao jer je korupcija pobjegla s kraja podtoka koji nitko nije pregledavao. Tek je kombinacija PivotTablea i lista grafikona, gdje listovi grafikona i crteži zauzimaju podtok koji slijedi nakon radnog lista, pretvorila tihu neusklađenost u vidljivo nedostajući objekt

// PivotChartRoundTripThroughLinkRecords, sažeto
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 događa se ovdje
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);

Prije popravka Wb.Sheets[3]._Chart bio je nil u tom retku jer je čitač davno prije izgubio granicu podtoka, prije nego što je došao do BOF-a grafikona. Provjera koja je konačno uhvatila pogrešku u serijalizaciji pivota bila je provjera grafikona

Kako se neporavnati BIFF tok čita natrag do prvog lošeg zapisa

Prođite lancem zaglavlja i ispišite ga jer se desinkronizirani BIFF tok strukturno najavi mnogo prije nego što podaci izgledaju pogrešno. Počnite na BOF-u podtoka ($0809), pročitajte ID i duljinu, pomaknite se za četiri plus duljinu i ponovite. Dok je tok poravnat, dolazite na uvjerljive ID-jeve zapisa i lanac završava točno na EOF-u ($000A). Kada se pomakne, dobivate ID-jeve koji ne postoje, duljine koje prelaze međuspremnik ili lanac koji ide ravno pokraj mjesta na kojem je EOF trebao biti

// Prođi BIFF tokom 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 nikad nije valjan zapis, a tijelo koje prelazi
    // međuspremnik dokaz je da je lanac već negdje uzvodno skrenuo
    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 izlaz čitajte unatrag i držite se jednog pravila: prvi zapis koji se ne uspije raščlaniti gotovo nikad nije krivac. On je žrtva. Krivac je zapis neposredno prije njega, posljednji koji se raščlanio bez prigovora, jer zapis koji laže o vlastitoj duljini uvijek se raščlani bez problema. Ovdje se prolaz zaustavio na fantomskom zapisu $0000, a zapis prije njega bio je SXEx. Usporedite deklariranu duljinu tog zapisa s popisom polja u specifikaciji, bajt po bajt, i aritmetika će se ili zbrojiti ili neće. Ako prolaz uopće ne stiže do razumnog prvog zapisa, problem je sloj niže, u složenoj datoteci OLE2 koja drži tok Workbooka, i nikakvo ispisivanje na razini zapisa neće pomoći

Emiter koji ne može lagati o vlastitoj duljini

Trajni popravak nije točna konstanta, nego uklanjanje mogućnosti da se zapiše pogrešna. Rezervirajte riječ duljine, emitirajte tijelo i zatim zakrpajte zaglavlje prema broju bajtova koji ste stvarno proizveli. HotXLS izlaže ono što za to treba: TXLSBlob.DataLength daje trenutačni pomak, a SetWord zapisuje natrag na već emitirano mjesto

function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
  Blob.AddWord(RecId);
  Result := Blob.DataLength;   // zapamti gdje se nalazi riječ duljine
  Blob.AddWord(0);             // rezervirano mjesto, zakrpa ga 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;

Budite pošteni o tome gdje to jamstvo prestaje. Opća tvrdnja da emitirani bajtovi jednako 2 + 2 + deklarirano vrijedi samo za zapise koji stanu ispod BIFF8 ograničenja od 8224 bajta korisnog tereta. Prevelika tijela legitimno u zaglavlju deklariraju 8224 i nastavljaju se u zapisima $003C Continue, upravo kako HotXLS rade za predmemoriju pivota i pisače veza s velikim teretima, pa je invarijanta uvjetna: ispod granice duljina emitiranog bloba mora biti jednaka deklariranoj duljini plus četiri, a iznad nje aritmetiku preuzima razdjelnik. Tu razliku kodirajte u pomoćniku, a ne u komentaru. Isto razmišljanje prenosi se na svaki format oznaka-duljina-vrijednost, ne samo na BIFF. Emiter koji deklarira veličinu prije nego što je zna zapisao je tvrdnju koju kod ne može provjeriti, a recenzent ne može prebrojati, i radi upravo dok druga značajka ne sleti nizvodno od prve

BIFF8 pisač, emiteri pivot zapisa i podtok grafikona opisani ovdje isporučuju se kao dio HotXLS Delphi spreadsheet component za Delphi i C++Builder, koja čita i zapisuje XLS, XLSX i ODS bez instaliranog Excela