技術記事

Delphi XLS writerのBIFF record length drift

HotXLS 2.376.0はclassic XLS writerのBIFF record length driftを修正しました。PivotTable view用のSXEx emitterがheaderで24-byte bodyを宣言した後、26 byteを追加していました。BIFF readerは宣言されたlengthを信頼するため、余った2 byteが下流全体をdesynchronizeし、PivotTableとchart sheetを組み合わせたworkbookをreopenするとchartが失われました

興味深いのはoff-by-oneのwordではありません。mistakeからsymptomまでの距離です。bugの地点では何も失敗しませんでした。pivot recordはきれいにserializeされ、fileはerrorなしに書かれ、Excelは開きます。そしてdamageが現れるのは、数百byte下流の、まったく無関係なsubstreamです。この距離はlength-prefixed binary formatすべてに特徴的で、次のemitterを書く前に理解しておく価値があります

record lengthを1つ間違えるとworksheet stream全体が壊れる理由

BIFF8 workbook streamには、自身のarithmetic以外のframingがないからです。すべてのrecordはrecord id(2 byte)とbody length(2 byte)からなる4-byte headerに続き、ちょうどその数のpayload byteを持ちます([MS-XLS] 2.1.4)。separatorもmagic byteもchecksumもresynchronization pointもありません。readerが次のrecordへ到達できるのは、前のrecordが自分のsizeについて真実を伝えるからです。declared lengthはrecordのmetadataではなく、次のrecordへのpointerです。2つの余分なbyteがしたことを追ってみましょう。readerはSXEx headerをconsumeし、headerがpromiseした24 byteをskipし、oversized bodyから残ったzero 2 byteの上に2 byte早く到着しました。そのzeroをrecord id $0000として読み、続くworksheet EOF record id($000A)をphantom recordのlengthとして読み、次に来るものの中へ10 byteをskipしました。そこからすべてのheaderが誤ったoffsetで読まれます。failure workbookでは、reopen後に_Chartがnilのchart sheetと、$18AFがrecord idとして解釈されたdebug dumpを生みました。どちらのvalueもpivot codeの近くには現れません

emitterとwriterがお互いを比較しない理由

driftが可能だった構造上の理由は、HotXLSがBIFF recordをheaderとpayloadという独立した2つの事実を持つTXLSBlobとして組み立てるからです。EmitSXExはrecord idを書き、次にlengthとしてBlob.AddWord(24)を書き、その後bodyをfieldごとに追加します。この24は手で数えたconstantで、後続byteとの照合もチェックもありません。write pathもgapを閉じません。AddRecはblobをTXLSBlobList.Appendへ渡し、Data.DataLength byteをそのままoutput streamへcopyします。DataLengthが実際のbyte countなので、writerはheaderが24と主張するbodyの後ろへ26 byteを忠実に出力します。両側は言われた通りに動き、矛盾に気づく仕事だけが誰にも割り当てられていません。HotXLSはpreserved payloadをreplayするところではすでにこれを避けています。TXLSWorkbook.StoreDConnBlobsはliteralではなく実際のbody lengthからheader length wordを計算するため、blob replayがdriftしたことはありません

[MS-XLS] 2.4.282がSXExについて固定するもの

specはsizeについて曖昧ではなく、そのためfixはmechanicalでした。[MS-XLS] 2.4.282はSXEx bodyを4-byteのgrbitと、2-byte field 10個、csxformatcchErrorStringcchNullStringcchTagcsxselectcrwPageccolPagecchPageFieldStylecchTableStylecchVacateStyleとして定義します。4 plus 20は24です。旧emitterはspecが10個と定義するzero wordを11個書き、anonymousなAddWord(0) callにはfield nameがありませんでした。そのためreview中に目視で数える精度は、聞こえの通りのものでした。preallocationが手掛かりです。TXLSBlob.Create(28)はheader 4 byteとbody 24 byteをちょうど要求しますが、blobは毎回このhintを越えて拡張します。AdjustBufferSizeが必要に応じてreallocateするため、静かにです。capacity hintをcodeがすぐにoverrunするなら、どの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
  // [MS-XLS] 2.4.282に従い、10個のzero wordで24-byte bodyを完成させる
  // declared lengthは書いたbyteと一致しなければならず、そうでなければ
  // このrecord以後のすべての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;

PivotTable test suite全体をすり抜けた理由

既存のpivot testがfileをround-tripしていなかったからです。workbookをbuildし、in-memory modelに対してassertし、そこで止めていました。in-memory assertionではserialized byte streamにだけ存在するlength mismatchを見られません。DelphiからBIFF8 PivotTable recordを書き出す処理がカバーするrecord setは、その基準では十分にtestされていました。それでもstream-corrupting emitterを出荷しました。defectが見えるには2つ目のfeatureも必要でした。pivoted worksheetの後に大したものがなければreopenできます。corruptionが誰もinspectしないsubstreamの末尾からはみ出すためです。PivotTableとchart sheetの組み合わせだけが、chart sheetとdrawingがworksheetの後ろにsubstreamを占めるため、silent misalignmentをvisible missing 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がnilでした。readerはchart BOFへ到達するはるか前にsubstream boundaryを失っていたためです。最終的にpivot serialization bugを捕捉したassertionは、chartについてのassertionでした

misaligned BIFF streamを最初のbad recordまで読み戻す方法

header chainをwalkしてprintしてください。dataが間違って見えるよりずっと前に、desynchronized BIFF streamはstructureとして自身を知らせます。substream BOF($0809)から始め、idとlengthを読み、4 plus lengthだけadvanceして繰り返します。streamがalignedな間は、もっともらしいrecord idへ着地し、chainはEOF($000A)できれいに終了します。driftすると、存在しないid、bufferをoverrunするlength、またはEOFのあるべき場所をまっすぐ通り過ぎるchainが現れます

// BIFF record streamをwalkし、realでない最初の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ではなく、bufferを越えるbodyはchainが
    // 上流のどこかですでに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を逆向きに読み、1つのruleを覚えておいてください。最初にparseに失敗するrecordは、ほとんどの場合culpritではありません。victimです。culpritはその直前、文句なしに最後にparseできたrecordです。自分のlengthについて嘘をつくrecordはparse自体はきれいに通るからです。このcaseではwalkがphantom $0000 recordで止まり、その前がSXExでした。そのrecordのdeclared lengthをspecのfield listとbyte単位で比較すれば、arithmeticが合っているかどうかが分かります。まともなfirst recordにさえ到達しないなら、問題はWorkbook streamを格納するOLE2 compound fileという1つ下のlayerにあり、record-level dumpを増やしても助けになりません

自分のlengthについて嘘をつけないemitter

durable fixは正しいconstantではなく、間違ったconstantを書く機会をなくすことです。length wordの場所をreserveし、bodyをemitし、実際に生成したbyte countからheaderをpatchします。HotXLSには必要なものがあります。TXLSBlob.DataLengthが現在のoffsetを返し、SetWordがすでにemitしたpositionへ書き戻します

function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
  Blob.AddWord(RecId);
  Result := Blob.DataLength;   // length wordの位置を覚える
  Blob.AddWord(0);             // EndRecordがpatchするplaceholder
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;

この保証がどこで止まるかは正直に扱う必要があります。emitted byteが2 + 2 + declaredに等しいというblanket assertionは、BIFF8 limitの8224 payload byte以下に収まるrecordにだけ当てはまります。oversize bodyはheaderで8224を正当に宣言し、$003C Continue recordで続けます。大きなpayloadのためにHotXLSのpivot cacheとconnection writerが行っているのはまさにこれです。したがってinvariantはconditionalです。limit未満ならemitted blob lengthはdeclared length plus fourに一致し、超えるならsplitterがarithmeticを所有します。この違いをcommentではなくhelperにencodeしてください。同じreasoningはBIFFに限らず、すべてのtag-length-value formatへ移せます。知る前にsizeを宣言するemitterは、codeがcheckできずreviewerが数えられないclaimを書き、後段に2つ目のfeatureが到着するまで動き続けます

ここで扱ったBIFF8 writer、pivot record emitter、chart substreamは、DelphiとC++Builder向けHotXLS Delphi spreadsheet componentの一部です。ExcelをinstallせずにXLS、XLSX、ODSをreadとwriteできます