HotXLS 2.376.0 a reparat o derivă a lungimii unui BIFF record în writer-ul XLS clasic: emitter-ul SXEx pentru view-uri PivotTable declara în header un body de 24 de bytes, apoi adăuga 26. Un BIFF reader are încredere în lungimea declarată, așa că cei doi bytes în plus desincronizau tot ce urma, iar workbook-urile care combinau un PivotTable cu un chart sheet pierdeau chart-ul la reopen
Partea interesantă nu este cuvântul off-by-one. Este distanța dintre greșeală și simptom. Nimic nu eșua la locul bug-ului. Pivot records se serializau curat, fișierul se scria fără eroare, Excel îl deschidea, iar damage-ul ieșea la suprafață abia la sute de bytes mai jos, într-un substream complet fără legătură. Această distanță este caracteristică oricărui binary format cu lungime prefixată și merită înțeleasă înainte să mai scrii un emitter pentru unul
De ce distruge o singură lungime greșită întregul worksheet stream?
Un workbook stream BIFF8 nu are framing în afară de propria aritmetică. Fiecare record este un header de 4 bytes format din record id (2 bytes) plus body length (2 bytes), urmat de exact numărul respectiv de payload bytes ([MS-XLS] 2.1.4). Nu există separator, magic byte, checksum sau punct de resynchronization. Reader-ul ajunge la record-ul următor doar pentru că record-ul anterior i-a spus adevărul despre propria dimensiune. Lungimea declarată nu este metadata despre record, ci pointerul către următorul. Urmărește ce au făcut cei doi bytes suplimentari. Reader-ul a consumat header-ul SXEx, a sărit peste cei 24 de bytes promiși de header și a ajuns cu doi bytes prea devreme, pe o pereche de zero-uri rămase din body-ul supradimensionat. A citit acele zero-uri ca record id $0000, apoi a citit record id-ul worksheet EOF ($000A) următor ca lungimea record-ului phantom și a sărit disciplinat zece bytes în ce urma. De acolo fiecare header a fost citit de la un offset greșit. În workbook-ul care eșua, asta a produs un chart sheet al cărui _Chart era nil după reopen și un debug dump în care $18AF era interpretat ca record id. Niciuna dintre aceste valori nu apare undeva în apropierea codului de pivot
Emitter-ul și writer-ul nu își compară niciodată notițele
Motivul structural pentru care deriva a fost posibilă este că HotXLS construiește un BIFF record ca TXLSBlob, al cărui header și payload sunt două fapte independente. EmitSXEx scrie record id-ul, apoi Blob.AddWord(24) pentru length și adaugă body-ul field cu field. Acel 24 este o constantă numărată manual, niciodată derivată sau verificată față de bytes-ii care urmează. Nici write path-ul nu închide breșa: AddRec transmite blob-ul la TXLSBlobList.Append, care copiază în output stream exact Data.DataLength bytes. DataLength este numărul real de bytes, așa că writer-ul emite fidel 26 de bytes de body în spatele unui header care declară 24. Ambele jumătăți fac exact ce li s-a spus, iar contradicția dintre ele nu este responsabilitatea nimănui. HotXLS evită deja asta când replay-uiește payload-uri păstrate: TXLSWorkbook.StoreDConnBlobs calculează word-ul de lungime din header după dimensiunea reală a body-ului, nu după un literal, tocmai de aceea blob replay nu a derivat niciodată
Ce fixează [MS-XLS] 2.4.282 despre SXEx
Spec-ul este neechivoc în privința dimensiunii, ceea ce a făcut fix-ul mecanic. [MS-XLS] 2.4.282 definește body-ul SXEx ca un grbit de 4 bytes urmat de zece field-uri de 2 bytes: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle și cchVacateStyle. Patru plus douăzeci înseamnă douăzeci și patru. Emitter-ul vechi scria unsprezece zero words acolo unde spec-ul definește zece, iar apelurile anonime AddWord(0) nu purtau nume de field-uri, așa că numărarea lor cu ochiul în review era exact atât de sigură pe cât sună. Prealocarea era indiciul că layout-ul fusese înțeles, dar loop-ul nu: TXLSBlob.Create(28) cere exact patru bytes de header plus un body de 24, însă blob-ul trecea de acest hint la fiecare apel și creștea în tăcere, deoarece AdjustBufferSize realocă la nevoie. O capacitate peste care codul trece imediat merită o a doua privire în orice serializer
function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
Blob: TXLSBlob;
begin
Blob := TXLSBlob.Create(28); // header de 4 bytes + body de 24 bytes
Blob.AddWord($00C6);
Blob.AddWord(24);
Blob.AddByte($02);
Blob.AddByte($00); // grbit1 = fPrintTitles
Blob.AddByte($00);
Blob.AddByte($00); // grbit2
// Zece zero words completează body-ul de 24 bytes conform [MS-XLS] 2.4.282
// Lungimea declarată TREBUIE să corespundă bytes-ilor scriși, altfel fiecare record
// de după acesta este parsat greșit
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;
De ce a trecut printr-o întreagă suită de teste PivotTable?
Deoarece testele de pivot existente nu făceau niciodată round-trip printr-un fișier. Construiau un workbook, verificau modelul in-memory și se opreau acolo, iar aserțiunile in-memory nu pot vedea o lungime greșită care există doar în byte stream-ul serializat. Setul de records acoperit de scrierea BIFF8 PivotTable records din Delphi era bine testat după acel standard și totuși livra un emitter care corupea stream-ul. Defectul avea nevoie și de o a doua funcționalitate ca să devină vizibil: un worksheet cu pivot urmat de aproape nimic se redeschidea în continuare, deoarece corupția ieșea din capătul unui substream pe care nimeni nu îl inspecta. Doar combinația dintre un PivotTable și un chart sheet, unde chart sheets și drawings ocupă un substream care urmează worksheet-ul, transforma o desincronizare tăcută într-un obiect vizibil lipsă
// PivotChartRoundTripThroughLinkRecords, condensat
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); // misparse-ul se întâmplă aici
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);
Înainte de fix, Wb.Sheets[3]._Chart era nil la acea linie, deoarece reader-ul pierduse limita substream-ului cu mult înainte să ajungă la chart BOF. Aserțiunea care a prins în sfârșit un bug de serializare pivot era o aserțiune despre un chart
Cum citești un BIFF stream desaliniat înapoi până la primul record greșit
Parcurge lanțul de headers și tipărește-l, deoarece un BIFF stream desincronizat se anunță structural cu mult înainte ca datele să arate greșit. Începe la substream BOF ($0809), citește id și length, avansează cu patru plus length și repetă. Cât timp stream-ul este aliniat, ajungi pe record ids plauzibile, iar lanțul se termină exact la EOF ($000A). Când se deplasează, primești ids care nu există, lengths care depășesc buffer-ul sau un lanț care trece direct de locul unde ar fi trebuit să fie EOF
// Parcurge un BIFF record stream și oprește la primul header imposibil
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)^;
// Un id zero nu este niciodată un record legal, iar un body care trece de
// buffer dovedește că lanțul a început deja să derive undeva mai sus
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;
Apoi citește output-ul invers și ține minte o regulă: primul record care nu poate fi parsat este aproape niciodată vinovatul. Este victima. Vinovatul este record-ul imediat anterior, ultimul care a fost parsat fără plângere, deoarece un record care minte despre propria lungime se parsează perfect. În cazul acesta, parcurgerea s-a oprit pe un record phantom $0000, iar record-ul dinaintea lui era SXEx. Compară lungimea declarată de acel record cu lista de field-uri din spec, byte cu byte, iar aritmetica fie se adună, fie nu. Dacă parcurgerea nu ajunge nici măcar la un prim record sănătos, problema este la un layer mai jos, în compound file-ul OLE2 care ține Workbook stream-ul, iar niciun volum de dump-uri la nivel de record nu va ajuta
Un emitter care nu poate minți despre propria lungime
Fix-ul durabil nu este o constantă corectă, ci eliminarea oportunității de a scrie una incorectă. Rezervă length word, emite body-ul și apoi actualizează header-ul din numărul de bytes produs efectiv. HotXLS expune exact ce trebuie: TXLSBlob.DataLength dă offset-ul curent, iar SetWord scrie înapoi într-o poziție deja emisă
function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
Blob.AddWord(RecId);
Result := Blob.DataLength; // reține unde stă length word
Blob.AddWord(0); // placeholder, actualizat de 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;
Fii onest cu privire la locul în care se oprește garanția. Aserțiunea generală că bytes-ii emiși sunt egali cu 2 + 2 + declared este valabilă doar pentru records care încap sub limita BIFF8 de 8224 payload bytes. Body-urile supradimensionate declară legitim 8224 în header și continuă în records $003C Continue, exact ce fac writer-ele HotXLS pentru pivot cache și connections cu payload-uri mari, așa că invariant-ul este condiționat: sub limită, lungimea blob-ului emis trebuie să fie egală cu lungimea declarată plus patru, iar peste limită splitter-ul deține aritmetica. Codifică diferența în helper, nu într-un comment. Același raționament se transferă la orice tag-length-value format, nu doar BIFF. Un emitter care declară o dimensiune înainte să știe cât a scris face o afirmație pe care codul nu o poate verifica și reviewer-ul nu o poate număra, iar aceasta funcționează exact până când o a doua funcționalitate apare downstream de prima
Writer-ul BIFF8, emitter-ele de pivot records și chart substream-ul discutat aici sunt livrate în componenta spreadsheet HotXLS pentru Delphi pentru Delphi și C++Builder, care citește și scrie XLS, XLSX și ODS fără Excel instalat