مقاله فنی

لغزش طول BIFF record در XLS writer در Delphi

HotXLS 2.376.0 یک لغزش طول BIFF record را در XLS writer کلاسیک خود اصلاح کرد: emitter مربوط به SXEx برای viewهای PivotTable در header، body بیست‌وچهاربایتی اعلام می‌کرد و سپس 26 byte append می‌کرد. BIFF reader به length اعلام‌شده اعتماد می‌کند، بنابراین دو byte اضافی همه چیز را در ادامه از sync خارج می‌کرد و workbookهایی که PivotTable را با chart sheet جفت می‌کردند، هنگام reopen نمودار را از دست می‌دادند

بخش جالب ماجرا خود off-by-one نیست، بلکه فاصله میان اشتباه و symptom است. در نقطه bug هیچ چیز fail نشد. pivot recordها تمیز serialize شدند، file بدون error نوشته شد، Excel آن را باز کرد و damage فقط صدها byte بعد، در substreamی کاملاً نامرتبط ظاهر شد. این فاصله ویژگی همه formatهای binary با length prefix است و بهتر است پیش از نوشتن emitter دیگری برای چنین formatی آن را درک کنید

چرا یک طول record اشتباه کل worksheet stream را خراب می‌کند؟

BIFF8 workbook stream به‌جز arithmetic خودش framing دیگری ندارد. هر record یک header چهار بایتی از record id دو بایتی و body length دو بایتی دارد و بعد دقیقاً همان تعداد payload byte می‌آید ([MS-XLS] 2.1.4). separator، magic byte، checksum یا نقطه resynchronization وجود ندارد. reader فقط به این دلیل به record بعدی می‌رسد که record قبلی حقیقت اندازه خودش را گفته است. length اعلام‌شده metadata مربوط به record نیست؛ pointer مربوط به record بعدی است. پس ببینیم آن دو byte اضافی چه کردند. reader header مربوط به SXEx را مصرف کرد، 24 byte وعده‌داده‌شده در header را skip کرد و دو byte زودتر، روی دو zero باقی‌مانده از body بزرگ‌شده، فرود آمد. آن zeroها را به‌عنوان record id برابر $0000 خواند، سپس record id مربوط به worksheet EOF یعنی $000A را به‌عنوان length همان record خیالی خواند و با وظیفه‌شناسی ده byte داخل آنچه بعد می‌آمد skip کرد. از آنجا هر header در offset اشتباه خوانده شد. در workbook معیوب، نتیجه chart sheetی بود که پس از reopen، _Chart آن nil بود و debug dump نشان می‌داد $18AF به‌عنوان record id تفسیر شده است. هیچ‌کدام از این valueها نزدیک code مربوط به pivot نیستند

emitter و writer هیچ‌وقت با هم مقایسه نمی‌کنند

دلیل ساختاری امکان‌پذیر شدن این drift آن است که HotXLS یک BIFF record را به‌صورت TXLSBlob می‌سازد و header و payload دو fact مستقل هستند. EmitSXEx record id را می‌نویسد، بعد Blob.AddWord(24) را برای length می‌نویسد و سپس body را field به field append می‌کند. این 24 یک constant با شمارش دستی است و هرگز از byteهای بعدی مشتق یا با آن‌ها check نمی‌شود. write path نیز gap را نمی‌بندد: AddRec blob را به TXLSBlobList.Append forward می‌کند و آن متد Data.DataLength byte را عیناً در output stream copy می‌کند. DataLength شمارش واقعی byteهاست، بنابراین writer با وفاداری 26 byte body را پشت headerای می‌نویسد که 24 اعلام کرده است. هر دو نیمه دقیقاً همان چیزی را انجام می‌دهند که به آن‌ها گفته شده و هیچ‌کس مسئول دیدن contradiction میان آن دو نیست. HotXLS از قبل در جایی که payloadهای حفظ‌شده را replay می‌کند این مشکل را ندارد: TXLSWorkbook.StoreDConnBlobs length word header را از length واقعی body حساب می‌کند نه از literal و دقیقاً به همین دلیل replay کردن blob هرگز drift نکرده است

[MS-XLS] 2.4.282 درباره SXEx چه چیزی را قطعی می‌کند؟

spec درباره اندازه صریح است و همین fix را مکانیکی کرد. [MS-XLS] 2.4.282، body مربوط به SXEx را یک grbit چهار بایتی به‌اضافه ده field دو بایتی تعریف می‌کند: csxformat، cchErrorString، cchNullString، cchTag، csxselect، crwPage، ccolPage، cchPageFieldStyle، cchTableStyle و cchVacateStyle. چهار به‌اضافه بیست می‌شود بیست‌وچهار. emitter قدیمی به‌جای ده word صفر، یازده word می‌نوشت و callهای بی‌نام AddWord(0) هیچ نام fieldی نداشتند؛ بنابراین شمردن آن‌ها با چشم در review دقیقاً به همان اندازه قابل اعتماد بود که انتظار دارید. سرنخ، preallocation بود که نشان می‌داد layout فهمیده شده اما loop نه: TXLSBlob.Create(28) دقیقاً چهار byte header به‌اضافه body بیست‌وچهاربایتی را درخواست می‌کند، اما blob در هر call از این hint عبور می‌کرد و چون AdjustBufferSize در صورت نیاز reallocates می‌کند، این رشد بی‌سروصدا بود. در هر serializer، capacity hintی که code بلافاصله از آن overruns می‌کند ارزش نگاه دوم دارد

function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
  Blob: TXLSBlob;
begin
  Blob := TXLSBlob.Create(28);   // header چهار بایتی + body بیست‌وچهاربایتی
  Blob.AddWord($00C6);
  Blob.AddWord(24);
  Blob.AddByte($02);
  Blob.AddByte($00);             // grbit1 = fPrintTitles
  Blob.AddByte($00);
  Blob.AddByte($00);             // grbit2
  // ده word صفر مطابق [MS-XLS] 2.4.282، body بیست‌وچهاربایتی را کامل می‌کنند
  // length اعلام‌شده باید با byteهای نوشته‌شده برابر باشد وگرنه هر record
  // بعد از این یکی اشتباه parse می‌شود
  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;

چرا این bug از یک test suite کامل PivotTable جان سالم به در برد؟

چون testهای موجود pivot هرگز round-trip از مسیر file را انجام نمی‌دادند. workbook را می‌ساختند، model داخل memory را assert می‌کردند و همان‌جا تمام می‌شدند؛ assertionهای in-memory نمی‌توانند length mismatchی را ببینند که فقط در serialized byte stream وجود دارد. مجموعه recordهایی که در نوشتن recordهای BIFF8 PivotTable از Delphi پوشش داده شده بود، با آن استاندارد خوب test شده بود اما همچنان emitterی تحویل می‌داد که stream را corrupt می‌کرد. defect برای visible شدن به feature دوم هم نیاز داشت: pivoted worksheet که بعد از آن چیز مهمی نیاید، هنوز reopen می‌شد، چون corruption از انتهای substreamی عبور می‌کرد که کسی inspect نمی‌کرد. فقط ترکیب PivotTable و chart sheet، جایی که chart sheetها و drawingها در substreamی بعد از worksheet قرار دارند، misalignment خاموش را به objectی آشکاراً گمشده تبدیل کرد

// PivotChartRoundTripThroughLinkRecords، نسخه خلاصه‌شده
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);

پیش از fix، Wb.Sheets[3]._Chart در همان line nil بود، چون reader مدت‌ها پیش از رسیدن به chart BOF، مرز substream را از دست داده بود. assertionی که سرانجام bug مربوط به serialization pivot را گرفت، درباره chart بود

چطور یک BIFF stream misaligned را تا نخستین record خراب عقب بخوانیم؟

زنجیره header را walk کنید و چاپش کنید، چون BIFF stream از نظر ساختاری مدت‌ها پیش از آنکه data غلط به نظر برسد، misaligned شدن خود را اعلام می‌کند. از substream BOF یعنی $0809 شروع کنید، id و length را بخوانید، به اندازه چهار به‌اضافه length جلو بروید و تکرار کنید. تا وقتی stream aligned است، روی record idهای plausible فرود می‌آیید و زنجیره دقیقاً روی EOF یعنی $000A تمام می‌شود. پس از drift، idهایی می‌گیرید که وجود ندارند، lengthهایی که از buffer عبور می‌کنند یا زنجیره‌ای که مستقیم از جایی که EOF باید باشد رد می‌شود

// یک BIFF record stream را walk کن و روی نخستین 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 صفر هرگز record قانونی نیست و bodyای که از buffer بیرون برود،
    // ثابت می‌کند زنجیره از جایی در upstream از قبل drift کرده است
    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;

سپس output را از آخر به عقب بخوانید و این rule را نگه دارید: نخستین recordی که parse نمی‌شود تقریباً هرگز culprit نیست؛ victim است. culprit record بلافاصله پیش از آن است، آخرین recordی که بدون شکایت parse شده، چون recordی که درباره length خودش دروغ می‌گوید همیشه خودش خوب parse می‌شود. در این case walk روی record خیالی $0000 متوقف شد و record پیش از آن SXEx بود. length اعلام‌شده آن record را byte به byte با field list داخل spec مقایسه کنید؛ arithmetic یا جمع می‌شود یا نمی‌شود. اگر walk اصلاً به نخستین record سالم نمی‌رسد، مشکل یک لایه پایین‌تر و در فایل compound از نوع OLE2 که Workbook stream را نگه می‌دارد است و هیچ مقدار dump در سطح record کمک نمی‌کند

emitterای که نمی‌تواند درباره length خودش دروغ بگوید

fix پایدار، یک constant درست نیست؛ حذف فرصت نوشتن constant نادرست است. جای length word را رزرو کنید، body را emit کنید و سپس header را از byte count واقعی patch کنید. HotXLS چیزی را که لازم دارید expose می‌کند: TXLSBlob.DataLength offset فعلی را می‌دهد و SetWord در positionای که از قبل emit شده می‌نویسد

function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
  Blob.AddWord(RecId);
  Result := Blob.DataLength;   // به یاد بسپار length word کجا قرار می‌گیرد
  Blob.AddWord(0);             // placeholder که EndRecord آن را patch می‌کند
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;

باید صادقانه گفت این guarantee کجا متوقف می‌شود. assertion کلی که byteهای emit‌شده برابر 2 + 2 + declared باشند، فقط برای recordهایی برقرار است که زیر limit مربوط به BIFF8 یعنی 8224 payload byte جا می‌شوند. bodyهای بزرگ‌تر به‌طور قانونی 8224 را در header اعلام می‌کنند و در recordهای $003C از نوع Continue ادامه پیدا می‌کنند؛ دقیقاً همان کاری که pivot cache و connection writerهای HotXLS برای payloadهای بزرگ انجام می‌دهند. پس invariant شرطی است: پایین‌تر از limit، length blob emit‌شده باید برابر length اعلام‌شده به‌اضافه چهار باشد و بالاتر از آن، splitter صاحب arithmetic است. این تفاوت را در helper encode کنید، نه در comment. همین reasoning به هر format از نوع tag-length-value، نه فقط BIFF، منتقل می‌شود. emitterی که قبل از دانستن اندازه، آن را اعلام می‌کند ادعایی می‌نویسد که code نمی‌تواند check کند و reviewer نمی‌تواند بشمارد و تا وقتی feature دومی پایین‌دست feature اول نیاید، درست به نظر می‌رسد

BIFF8 writer، emitterهای pivot record و chart substreamی که اینجا درباره آن صحبت شد، بخشی از HotXLS Delphi spreadsheet component برای Delphi و C++Builder هستند که XLS، XLSX و ODS را بدون نصب Excel می‌خوانند و می‌نویسند