HotXLS 2.376.0 תיקנה סטייה באורך רשומת BIFF בכותב ה-XLS הקלאסי שלה: מחולל SXEx לתצוגות PivotTable הצהיר בכותרת על גוף בן 24 בתים ואז הוסיף 26 בתים. קורא BIFF סומך על האורך המוצהר, ולכן שני הבתים העודפים הוציאו מסנכרון את כל מה שלאחר מכן, וחוברות עבודה ששילבו PivotTable עם גיליון תרשים איבדו את התרשים בפתיחה מחדש
החלק המעניין אינו מילת ה-off-by-one. זהו המרחק בין הטעות לתסמין. שום דבר לא נכשל בנקודת הבאג. רשומות ה-pivot עברו serialization באופן נקי, הקובץ נכתב ללא שגיאה, Excel פתח אותו והנזק הופיע רק מאות בתים בהמשך ב-substream לא קשור. המרחק הזה אופייני לכל פורמט בינארי עם אורך-prefix, וכדאי להבין אותו לפני שכותבים עבורו emitter נוסף
מדוע אורך רשומה שגוי אחד הורס זרם worksheet שלם
לזרם חוברת עבודה של BIFF8 אין framing מעבר לחשבון שלו עצמו. כל רשומה היא header בן 4 בתים של מזהה רשומה (2 בתים) ואורך גוף (2 בתים), ואחריו בדיוק מספר זה של בתי payload ([MS-XLS] 2.1.4). אין separator, אין magic byte, אין checksum ואין נקודת resynchronization. הקורא מגיע לרשומה הבאה רק מפני שהרשומה הקודמת אמרה את האמת על הגודל שלה. האורך המוצהר אינו metadata על הרשומה; הוא המצביע לרשומה הבאה. לכן עקבו אחרי מה שעשו שני הבתים העודפים. הקורא צרך את ה-header של SXEx, דילג על 24 הבתים שה-header הבטיח ונחת שני בתים מוקדם מדי, על זוג אפסים שנשארו מהגוף הגדול. הוא קרא את האפסים האלה כמזהה רשומה $0000, ואז קרא את מזהה רשומת ה-EOF של ה-worksheet ($000A) כאורך של הרשומה הדמיונית הזו ודילג בצייתנות עשרה בתים לתוך מה שבא אחר כך. מכאן כל header נקרא ב-offset שגוי. בחוברת העבודה שנכשלה הדבר יצר גיליון תרשים שבו _Chart היה nil לאחר הפתיחה מחדש, ו-dump של debug שהראה את $18AF מפורש כמזהה רשומה. אף אחד מהערכים האלה אינו מופיע ליד קוד ה-pivot
ה-emitter וה-writer לעולם אינם משווים רשימות
הסיבה המבנית לכך שהסטייה הייתה אפשרית היא ש-HotXLS בונה רשומת BIFF כ-TXLSBlob שה-header וה-payload שלה הם שתי עובדות בלתי תלויות. EmitSXEx כותבת את מזהה הרשומה, אחר כך Blob.AddWord(24) לאורך, ואז מוסיפה את שדות הגוף בזה אחר זה. ה-24 הוא קבוע שנספר ביד, לעולם אינו נגזר מהבתים שאחריו ולעולם אינו נבדק מולם. גם נתיב הכתיבה אינו סוגר את הפער: AddRec מעבירה את ה-blob אל TXLSBlobList.Append, שמעתיקה לזרם הפלט את Data.DataLength בתים ללא שינוי. DataLength הוא מספר הבתים האמיתי, ולכן ה-writer פולט בנאמנות 26 בתים של גוף מאחורי header שטוען ל-24. שני החצאים עושים בדיוק מה שנאמר להם, והסתירה ביניהם אינה באחריותו של אף אחד לשים לב אליה. HotXLS כבר נמנעת מזה במקום שבו היא משחזרת payloads שנשמרו: TXLSWorkbook.StoreDConnBlobs מחשבת את מילת האורך ב-header מתוך אורך הגוף בפועל ולא מתוך literal, וזו בדיוק הסיבה ששחזור blob מעולם לא סטה
מה [MS-XLS] 2.4.282 מקבע לגבי SXEx
המפרט חד-משמעי לגבי הגודל, ולכן התיקון היה מכני. [MS-XLS] 2.4.282 מגדיר את גוף SXEx כ-grbit בן 4 בתים ואחריו עשרה שדות בני 2 בתים: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle ו-cchVacateStyle. ארבעה ועוד עשרים הם 24. ה-emitter הישן כתב אחד-עשר words אפסיים במקום עשרה, וקריאות AddWord(0) האנונימיות לא נשאו שמות שדות, לכן ספירה בעין בזמן review הייתה אמינה בדיוק כפי שנדמה. ה-preallocation היה הרמז שה-layout הובן אך הלולאה לא: TXLSBlob.Create(28) מבקשת בדיוק ארבעה בתים של header ועוד גוף של 24 בתים, אולם ה-blob גדל מעבר לרמז הזה בכל קריאה, וגדל בשקט מפני ש-AdjustBufferSize מקצה מחדש לפי הצורך. hint של capacity שהקוד חורג ממנו מיד ראוי לבדיקה שנייה בכל serializer
function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
Blob: TXLSBlob;
begin
Blob := TXLSBlob.Create(28); // header של 4 בתים + גוף של 24 בתים
Blob.AddWord($00C6);
Blob.AddWord(24);
Blob.AddByte($02);
Blob.AddByte($00); // grbit1 = fPrintTitles
Blob.AddByte($00);
Blob.AddByte($00); // grbit2
// עשרה zero words משלימים את גוף 24 הבתים לפי [MS-XLS] 2.4.282
// האורך המוצהר חייב להתאים לבתים שנכתבו, אחרת כל רשומה
// שאחרי זו מפוענחת באופן שגוי
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;
מדוע זה שרד חבילת בדיקות שלמה ל-PivotTable
מפני שבדיקות ה-pivot הקיימות מעולם לא ביצעו round-trip דרך קובץ. הן בנו חוברת עבודה, בדקו מול המודל בזיכרון ועצרו, ובדיקות בזיכרון אינן יכולות לראות אי-התאמת אורך שקיימת רק בזרם הבתים המסוריאל. קבוצת הרשומות שמכוסה בכתיבת רשומות PivotTable של BIFF8 מ-Delphi נבדקה היטב לפי הסטנדרט הזה ועדיין שלחה emitter שמשחית את הזרם. גם הפגם נזקק לתכונה שנייה כדי להיות נראה: worksheet עם pivot ואחריו לא הרבה עדיין נפתח מחדש, מפני שהשחיתות ברחה מקצה substream שאף אחד לא בדק. רק השילוב של PivotTable וגיליון תרשים, שבו גיליונות תרשים וציורים תופסים substream שמגיע אחרי ה-worksheet, הפך אי-סנכרון שקט לאובייקט שחסר באופן גלוי
// PivotChartRoundTripThroughLinkRecords, condensed
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
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);
לפני התיקון, Wb.Sheets[3]._Chart היה nil בשורה הזו, מפני שהקורא איבד את גבול ה-substream זמן רב לפני שהגיע ל-chart BOF. ה-assertion שסוף סוף תפס באג serialization של pivot היה assertion על תרשים
איך קוראים זרם BIFF שיצא מיישור חזרה לרשומה הראשונה הרעה
עברו על שרשרת ה-header והדפיסו אותה, מפני שזרם BIFF שיצא מסנכרון מודיע על עצמו מבנית הרבה לפני שהנתונים נראים שגויים. התחילו ב-substream BOF ($0809), קראו id ו-length, התקדמו בארבעה בתים ועוד האורך וחזרו על כך. כל עוד הזרם מיושר אתם נוחתים על מזהי רשומות סבירים והשרשרת מסתיימת בדיוק ב-EOF ($000A). ברגע שהיא סוטה, מקבלים מזהים שאינם קיימים, אורכים שחורגים מה-buffer או שרשרת שהולכת ישר מעבר למקום שבו EOF היה אמור להיות
// עבור על זרם רשומות BIFF ועצור ב-header הראשון שלא יכול להיות אמיתי
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)^;
// id אפס לעולם אינו רשומה חוקית, וגוף שחורג מה-buffer
// מוכיח שהשרשרת כבר סטתה אי-שם לפני כן
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;
לאחר מכן קראו את הפלט לאחור והחזיקו בכלל אחד: הרשומה הראשונה שנכשלת בפענוח היא כמעט אף פעם לא האשמה. היא הקורבן. האשמה היא הרשומה שמיד לפניה, האחרונה שעברה parsing בלי תלונה, מפני שמי שמשקר לגבי האורך שלו תמיד עובר parsing בהצלחה. במקרה הזה המעבר נעצר ברשומת $0000 דמיונית, והרשומה לפניה הייתה SXEx. השוו את האורך המוצהר של הרשומה לרשימת השדות במפרט, בייט אחר בייט, והחשבון או מסתדר או לא. אם המעבר אינו מגיע אפילו לרשומה ראשונה שפויה, הבעיה נמצאת שכבה נמוכה יותר, בתוך קובץ ה-OLE2 המורכב שמחזיק את זרם Workbook, ושום כמות של dump ברמת הרשומות לא תעזור
Emitter שאינו יכול לשקר לגבי האורך שלו
התיקון העמיד אינו קבוע נכון, אלא הסרת ההזדמנות לכתוב קבוע שגוי. שמרו את מילת האורך, פלטו את הגוף ואז עדכנו את ה-header ממספר הבתים שבאמת הפקתם. HotXLS חושפת את מה שצריך לכך: TXLSBlob.DataLength נותנת את ה-offset הנוכחי ו-SetWord כותבת בחזרה למיקום שכבר נפלט
function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
Blob.AddWord(RecId);
Result := Blob.DataLength; // זכור היכן יושבת מילת האורך
Blob.AddWord(0); // placeholder שמתוקן על ידי 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;
היו כנים לגבי המקום שבו ההבטחה נעצרת. assertion גורף של emitted bytes שווים ל-2 + 2 + declared נכון רק לרשומות שנכנסות מתחת למגבלת BIFF8 של 8224 בתי payload. גופים גדולים מדי מצהירים באופן חוקי על 8224 ב-header וממשיכים ברשומות $003C Continue, וזה בדיוק מה שכותבי pivot cache ו-connection של HotXLS עושים עבור payloads גדולים, לכן ה-invariant מותנה: מתחת למגבלה אורך ה-blob שנפלט חייב להיות שווה לאורך המוצהר ועוד ארבעה, ומעליה ה-splitter אחראי לחשבון. קודדו את ההבחנה הזו ב-helper ולא בהערה. אותו היגיון עובר לכל פורמט tag-length-value, לא רק BIFF. emitter שמצהיר על גודל לפני שהוא יודע אותו כותב טענה שהקוד אינו יכול לבדוק וה-reviewer אינו יכול לספור, והוא עובד בדיוק עד שתכונה שנייה נוחתת downstream של הראשונה
כותב BIFF8, מחוללי רשומות ה-pivot וה-substream של התרשימים שמתואר כאן נשלחים כחלק מרכיב ה-spreadsheet של HotXLS ל-Delphi עבור Delphi ו-C++Builder, שקורא וכותב XLS, XLSX ו-ODS ללא התקנת Excel