HotXLS 2.376.0 korjasi BIFF-tietueen pituuspoikkeaman klassisessa XLS-kirjoittajassa: PivotTable-näkymien SXEx-emitteri ilmoitti otsakkeessa rungon pituudeksi 24 tavua mutta liitti perään 26 tavua. BIFF-lukija luottaa ilmoitettuun pituuteen, joten kaksi ylimääräistä tavua synkronoi kaiken myöhemmän väärin ja PivotTablen chart sheet -parit menettivät chartin uudelleenavauksessa
Kiinnostava osa ei ole yhden sanan off-by-one. Se on etäisyys virheen ja oireen välillä. Mikään ei epäonnistunut bugin kohdalla. Pivot-tietueet serialisoituivat siististi, tiedosto kirjoittui ilman virhettä, Excel avasi sen ja vahinko tuli näkyviin vasta satoja tavuja myöhemmin täysin asiaan liittymättömässä alistreamissa. Tämä etäisyys on jokaiselle pituus-etuliitteiselle binääriformaatille tyypillinen ja se kannattaa ymmärtää ennen kuin kirjoitat sille uuden emitterin
Miksi yksi väärä tietuepituus tuhoaa kokonaisen worksheet-streamin?
BIFF8-työkirjavirran kehystys perustuu vain sen omaan aritmetiikkaan. Jokainen tietue on 4 tavun otsake, jossa on tietueen id (2 tavua) ja rungon pituus (2 tavua), jota seuraa täsmälleen niin monta payload-tavua ([MS-XLS] 2.1.4). Erotinta, magic-tavua, tarkistussummaa tai uudelleensynkronointipistettä ei ole. Lukija päätyy seuraavan tietueen alkuun vain siksi että edellinen tietue kertoi totuuden omasta koostaan. Ilmoitettu pituus ei ole tietueen metatieto vaan osoitin seuraavaan tietueeseen. Seuraa siis mitä kaksi ylimääräistä tavua tekivät. Lukija kulutti SXEx-otsakkeen, ohitti otsakkeen lupaamat 24 tavua ja päätyi kaksi tavua liian aikaisin ylimittaisen rungon jäljelle jääneisiin kahteen nollaan. Se luki nollat tietue-id:ksi $0000, luki seuraavan worksheet EOF -tietueen id:n ($000A) tuon haamurekisterin pituudeksi ja ohitti tottelevaisesti kymmenen tavua seuraavan datan sisään. Tästä eteenpäin jokainen otsake luettiin väärästä offsetista. Viallisessa työkirjassa tämä tuotti chart sheetin jonka _Chart oli uudelleenavaamisen jälkeen nil ja debug-dumpin jossa $18AF tulkittiin tietue-id:ksi. Kumpaakaan arvoa ei esiinny missään pivot-koodin lähellä
Emitteri ja kirjoittaja eivät koskaan vertaa tuloksiaan
Rakenteellinen syy poikkeaman mahdollisuuteen on että HotXLS rakentaa BIFF-tietueen TXLSBlob-oliona jonka otsake ja payload ovat kaksi riippumatonta faktaa. EmitSXEx kirjoittaa tietue-id:n, sitten pituudeksi Blob.AddWord(24) ja liittää rungon kenttä kerrallaan. Tuo 24 on käsin laskettu vakio, jota ei johdeta seuraavista tavuista eikä verrata niihin. Kirjoituspolku ei sulje aukkoa: AddRec välittää blobin TXLSBlobList.Append-metodille, joka kopioi Data.DataLength tavua sellaisenaan output-streamiin. DataLength on todellinen tavumäärä, joten kirjoittaja emittoi uskollisesti 26 tavua runkoa otsakkeen väitteellä 24. Molemmat puoliskot tekevät täsmälleen sen mitä käskettiin, eikä ristiriidan huomaaminen kuulu kenellekään. HotXLS välttää tämän jo kohdissa joissa se toistaa säilytettyjä payloadeja: TXLSWorkbook.StoreDConnBlobs laskee otsakkeen pituussanan todellisen rungon pituudesta literaalin sijaan, ja juuri siksi blobien toisto ei ole koskaan ajautunut
Mitä [MS-XLS] 2.4.282 määrittelee SXExistä
Spesifikaatio on koosta yksiselitteinen, mikä teki korjauksesta mekaanisen. [MS-XLS] 2.4.282 määrittelee SXEx-rungon 4-tavuiseksi grbit-kentäksi jota seuraa kymmenen 2-tavuista kenttää: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle ja cchVacateStyle. Neljä plus kaksikymmentä on kaksikymmentäneljä. Vanha emitteri kirjoitti yksitoista nollasanaa vaikka spesifikaatio määrittelee kymmenen, eikä nimettömillä AddWord(0)-kutsuilla ollut kenttien nimiä, joten niiden laskeminen silmällä katselmoinnin aikana oli juuri niin luotettavaa kuin kuulostaa. Ennakkovaraus oli vihje siitä että rakenne oli ymmärretty mutta silmukka ei: TXLSBlob.Create(28) pyytää täsmälleen neljän otsaketavun ja 24-tavuisen rungon verran, mutta blob kasvoi vihjeen yli jokaisella kutsulla ja kasvoi hiljaisesti, koska AdjustBufferSize allokoi lisää tarpeen mukaan. Kapasiteettivihje jonka koodi ylittää heti on missä tahansa serialisoijassa toisen katsomisen arvoinen
function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
Blob: TXLSBlob;
begin
Blob := TXLSBlob.Create(28); // 4 tavun otsake + 24 tavun runko
Blob.AddWord($00C6);
Blob.AddWord(24);
Blob.AddByte($02);
Blob.AddByte($00); // grbit1 = fPrintTitles
Blob.AddByte($00);
Blob.AddByte($00); // grbit2
// Kymmenen nollasanaa täydentää 24-tavuisen rungon [MS-XLS] 2.4.282:n mukaan
// Ilmoitetun pituuden TÄYTYY vastata kirjoitettuja tavuja tai jokainen
// tämän jälkeinen tietue jäsentyy väärin
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;
Miksi tämä selvisi kokonaisesta PivotTable-testipaketista?
Koska nykyiset pivot-testit eivät koskaan tehneet tiedostokierrosta. Ne rakensivat työkirjan, vertasivat muistissa olevaan malliin ja lopettivat, eivätkä muistissa tehdyt assertionit voi nähdä serialisoidussa tavuvirrassa olevaa pituuseroa. Artikkelissa BIFF8 PivotTable -tietueiden kirjoittaminen Delphillä käsitelty tietuejoukko oli tällä mittarilla hyvin testattu ja silti toimitettiin streamia korruptoiva emitteri. Vika tarvitsi myös toisen ominaisuuden tullakseen näkyviin: pivotilla varustettu worksheet ilman paljon sen jälkeen tulevaa avautui edelleen, koska korruptio juoksi ohi alistreamin jota kukaan ei tarkastanut. Vasta PivotTablen ja chart sheetin yhdistelmä, jossa chart sheetit ja piirrokset ovat worksheetin jälkeen seuraavassa alistreamissa, muutti hiljaisen kohdistusvirheen näkyvästi puuttuvaksi objektiksi
// PivotChartRoundTripThroughLinkRecords, tiivistettynä
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); // väärä jäsennys tapahtuu tässä
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);
Ennen korjausta Wb.Sheets[3]._Chart oli tuossa kohdassa nil, koska lukija oli menettänyt alistreamin rajan jo kauan ennen chartin BOFia. Lopulta pivot-serialisointibugin havainnut assertion koski chartia
BIFF-virran palauttaminen ensimmäiseen väärään tietueeseen
Käy otsakeketju läpi ja tulosta se, sillä synkronoinnista irronnut BIFF-stream ilmoittaa itsestään rakenteellisesti kauan ennen kuin data näyttää väärältä. Aloita alistreamin BOF:sta ($0809), lue id ja pituus, siirry neljän tavun ja pituuden verran eteenpäin ja toista. Kun stream on kohdistettu, päädyt uskottaviin tietue-id:hin ja ketju päättyy täsmälleen EOF:iin ($000A). Kun se ajautuu, saat olemattomia id:itä, puskurin yli meneviä pituuksia tai ketjun joka kulkee suoraan siitä kohdasta ohi jossa EOF:n pitäisi olla
// Käy BIFF-tietuevirta läpi ja pysähdy ensimmäiseen mahdottomaan otsakkeeseen
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)^;
// Nolla-id ei ole koskaan laillinen tietue, ja puskurin yli ulottuva
// runko todistaa että ketju ajautui jo jossakin ylempänä
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;
Lue tuloste sitten takaperin ja pidä kiinni yhdestä säännöstä: ensimmäinen jäsentämisessä epäonnistuva tietue on lähes koskaan syyllinen. Se on uhri. Syyllinen on sitä välittömästi edeltävä tietue, viimeinen joka jäsentyi ilman huomautusta, koska oman pituutensa valehteleva tietue jäsentyy aina siististi. Tässä tapauksessa kävely pysähtyi haamuun $0000 ja sitä edeltävä tietue oli SXEx. Vertaa kyseisen tietueen ilmoitettua pituutta spesifikaation kenttälistaan tavu tavulta, ja aritmetiikka joko täsmää tai ei. Jos kävely ei koskaan pääse edes järkevään ensimmäiseen tietueeseen, ongelma on kerrosta alempana OLE2 compound file -tiedostossa joka sisältää Workbook-streamin, eikä mikään tietuetason dumpaus auta
Emitteri joka ei voi valehdella omasta pituudestaan
Kestävä korjaus ei ole oikea vakio vaan väärän vakion kirjoittamisen mahdollisuuden poistaminen. Varaa pituussana, emittoi runko ja paikkaa otsake tuottamasi todellisen tavumäärän perusteella. HotXLS tarjoaa tähän tarvittavat osat: TXLSBlob.DataLength antaa nykyisen offsetin ja SetWord kirjoittaa takaisin jo emittoituun kohtaan
function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
Blob.AddWord(RecId);
Result := Blob.DataLength; // muista missä pituussana sijaitsee
Blob.AddWord(0); // paikanvaraaja, EndRecord paikkaa sen
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;
Ole rehellinen siitä mihin takuu päättyy. Yleinen assertion jonka mukaan emittoidut tavut ovat 2 + 2 + ilmoitettu pätee vain tietueisiin jotka mahtuvat BIFF8:n 8224 payload-tavun rajan alle. Ylikokoiset rungot ilmoittavat otsakkeessa oikeutetusti 8224 tavua ja jatkavat $003C Continue -tietueissa, kuten HotXLS:n pivot cache- ja connection-kirjoittajat tekevät suurilla payloadeilla, joten invariantti on ehdollinen: rajan alla emittoidun blobin pituuden täytyy olla ilmoitettu pituus plus neljä, sen yllä splitter omistaa aritmetiikan. Koodaa tämä ero helperiin eikä kommenttiin. Sama järkeily siirtyy jokaiseen tag-length-value-formaattiin, ei vain BIFFiin. Emitter joka ilmoittaa koon ennen kuin tietää sen kirjoittaa väitteen jota koodi ei voi tarkistaa eikä tarkastaja laskea, ja se toimii täsmälleen siihen asti kun ensimmäisen ominaisuuden jälkeen tulee toinen
Tässä käsitelty BIFF8-kirjoittaja, pivot-tietueiden emitterit ja chart-alistream toimitetaan HotXLS Delphi spreadsheet component -komponentin osana Delphille ja C++Builderille, ja se lukee ja kirjoittaa XLS-, XLSX- ja ODS-tiedostoja ilman asennettua Exceliä