Технічна стаття

Дрейф довжини BIFF record у Delphi XLS writer

HotXLS 2.376.0 виправив drift довжини BIFF record у своєму classic XLS writer: SXEx emitter для PivotTable views оголошував у header body розміром 24 bytes, а потім додавав 26 bytes. BIFF reader довіряє declared length, тому два зайві bytes розсинхронізували все downstream, і workbooks, де PivotTable поєднувалася з chart sheet, втрачали chart після reopen

Цікава частина — не off-by-one word. Цікава відстань між помилкою та симптомом. У точці bug нічого не провалилося. Pivot records серіалізувалися чисто, file записувався без error, Excel відкривав його, а damage проявлявся лише за сотні bytes downstream у цілком unrelated substream. Така відстань характерна для кожного length-prefixed binary format, і її варто зрозуміти до того, як ви напишете для нього ще один emitter

Чому одна неправильна record length руйнує цілий worksheet stream?

BIFF8 workbook stream не має framing, крім власної арифметики. Кожен record — це 4-byte header із record id (2 bytes) і body length (2 bytes), за яким іде рівно стільки payload bytes ([MS-XLS] 2.1.4). Немає separator, magic byte, checksum чи resynchronization point. Reader потрапляє на наступний record лише тому, що попередній правдиво повідомив власний size. Declared length — не metadata про record, а pointer до наступного. Тому простежте, що зробили два зайві bytes. Reader спожив SXEx header, пропустив 24 bytes, обіцяні header, і став на два bytes раніше — на пару zeros, що залишилася від oversized body. Він прочитав ці zeros як record id $0000, потім прочитав id worksheet EOF ($000A) як length phantom record і сумлінно пропустив десять bytes у все, що йшло далі. Відтоді кожен header читався з неправильного offset. У failing workbook це створило chart sheet, у якого після reopen _Chart був nil, і debug dump, де $18AF інтерпретувався як record id. Жодне з цих values не лежить поблизу pivot code

Emitter і writer ніколи не звіряють свої записи

Structural reason, через яку drift став можливим, у тому, що HotXLS будує BIFF record як TXLSBlob, header і payload якого є двома незалежними facts. EmitSXEx записує record id, потім Blob.AddWord(24) для length, а потім додає body field by field. Це 24 — hand-counted constant, який ніколи не виводиться з bytes, що йдуть далі, і не перевіряється проти них. Write path також не закриває gap: AddRec передає blob у TXLSBlobList.Append, який verbatim копіює Data.DataLength bytes у output stream. DataLength — справжня byte count, тому writer сумлінно виводить 26 body bytes за header, який заявляє 24. Обидві половини роблять саме те, що їм сказали, а суперечність між ними не входить ні до чиєї роботи. HotXLS уже уникає цього під час replay preserved payloads: TXLSWorkbook.StoreDConnBlobs обчислює header length word з actual body length, а саме тому blob replay ніколи не drift-ив

Що [MS-XLS] 2.4.282 фіксує про SXEx

Spec однозначно задає size, тому fix вийшов механічним. [MS-XLS] 2.4.282 визначає SXEx body як 4-byte grbit, за яким ідуть десять 2-byte fields: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle і cchVacateStyle. Four plus twenty — twenty-four. Старий emitter записував одинадцять zero words, хоча spec визначає десять, а anonymous AddWord(0) calls не мали field names, тому рахувати їх на око під час review було саме так надійно, як це звучить. Clue дала preallocation: TXLSBlob.Create(28) запитує рівно чотири header bytes плюс 24-byte body, але blob на кожному call виростав за цю hint, і виростав silently, бо AdjustBufferSize reallocates on demand. Capacity hint, який code одразу переростає, варта другого погляду в будь-якому serializer

function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
  Blob: TXLSBlob;
begin
  Blob := TXLSBlob.Create(28);   // 4-byte header + 24-byte body
  Blob.AddWord($00C6);
  Blob.AddWord(24);
  Blob.AddByte($02);
  Blob.AddByte($00);             // grbit1 = fPrintTitles
  Blob.AddByte($00);
  Blob.AddByte($00);             // grbit2
  // Десять zero words завершують 24-byte body за [MS-XLS] 2.4.282.
  // Declared length MUST match записаним bytes, інакше кожен наступний
  // record буде parsed неправильно.
  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 test suite?

Тому що наявні pivot tests ніколи не робили round-trip через file. Вони будували workbook, перевіряли in-memory model і зупинялися, а in-memory assertions не бачать length mismatch, який існує лише в serialized byte stream. Набір records, описаний у записі BIFF8 PivotTable records із Delphi, за цим стандартом був добре протестований і все одно випустив emitter, що corruption-ив stream. Defect також потребував другої feature, щоб стати видимим: pivoted worksheet, після якого майже нічого немає, усе ще reopen-ився, бо corruption ішов за кінцем substream, який ніхто не inspect-ив. Лише поєднання PivotTable та chart sheet, де chart sheets і drawings займають substream після worksheet, перетворило silent misalignment на видимо відсутній object

// 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);

До fix Wb.Sheets[3]._Chart у цьому рядку був nil, бо reader втратив substream boundary задовго до того, як дійшов до chart BOF. Assertion, яка зрештою зловила pivot serialization bug, була assertion про chart

Як прочитати misaligned BIFF stream назад до першого bad record

Пройдіть header chain і виведіть її, бо desynchronized BIFF stream задовго до data wrong повідомляє про себе структурно. Почніть із substream BOF ($0809), прочитайте id і length, перемістіться на four плюс length і повторюйте. Поки stream aligned, ви потрапляєте на plausible record ids, а chain завершується рівно на EOF ($000A). Після drift отримуєте ids, яких не існує, lengths, що виходять за buffer, або chain, яка проходить просто повз місце, де мав бути EOF

// Пройти BIFF record stream і зупинитися на першому нереальному 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)^;
    // Zero id ніколи не є legal record, а body, що виходить за buffer,
    // доводить, що chain уже drift-нула десь upstream
    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 назад і тримайтеся одного правила: перший record, який не parse-иться, майже ніколи не є culprit. Він victim. Culprit — безпосередньо попередній record, останній, який parsed-ився без complaint, бо той, хто бреше про власну length, сам parse-иться чудово. У цьому випадку walk зупинився на phantom $0000 record, а перед ним був SXEx. Порівняйте declared length цього record із field list у spec, byte by byte, і арифметика або зійдеться, або ні. Якщо walk взагалі не доходить до sane first record, проблема рівнем нижче — у OLE2 compound file, який містить Workbook stream, — і жоден record-level dump не допоможе

Emitter, який не може збрехати про власну length

Durable fix — не правильна constant, а усунення можливості записати неправильну. Зарезервуйте length word, emit-ьте body, а потім patch-те header за реально створеним byte count. HotXLS дає все потрібне: TXLSBlob.DataLength повертає current offset, а SetWord записує назад у вже emitted position

function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
  Blob.AddWord(RecId);
  Result := Blob.DataLength;   // запам’ятати, де лежить length word
  Blob.AddWord(0);             // placeholder, який patch-ить 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;

Чесно назвіть межу цієї guarantee. Загальна assertion, що emitted bytes дорівнюють 2 + 2 + declared, справедлива лише для records, які вкладаються в BIFF8 limit 8224 payload bytes. Oversize bodies легітимно оголошують 8224 у header і продовжуються в $003C Continue records, саме так HotXLS pivot cache та connection writers працюють із великими payloads, тому invariant умовний: нижче limit emitted blob length має дорівнювати declared length плюс four, а вище splitter володіє арифметикою. Закодуйте цю відмінність у helper, а не в comment. Та саме reasoning переноситься на кожен tag-length-value format, не лише BIFF. Emitter, який оголошує size до того, як його знає, записує claim, який code не може перевірити, а reviewer — порахувати, і працює рівно доти, доки downstream першої feature не з’явиться друга

BIFF8 writer, pivot record emitters і chart substream, обговорений тут, постачаються як частина HotXLS Delphi spreadsheet component для Delphi та C++Builder, який читає й записує XLS, XLSX та ODS без установленого Excel