기술 문서

Delphi XLS Writer의 BIFF 레코드 길이 불일치

HotXLS 2.376.0은 classic XLS writer의 BIFF record length drift를 수정했습니다. PivotTable view용 SXEx emitter가 header에 24-byte body라고 선언한 뒤 26 byte를 붙이고 있었습니다. BIFF reader는 선언된 length를 신뢰하므로 남은 두 byte가 이후의 모든 것을 desynchronize했고 PivotTable과 chart sheet를 함께 가진 workbook은 다시 열 때 chart를 잃었습니다

흥미로운 점은 off-by-one word가 아닙니다. 실수와 증상 사이의 거리입니다. bug가 난 순간에는 아무것도 실패하지 않았습니다. pivot record는 깨끗하게 serialize되고 file도 error 없이 써졌으며 Excel도 열렸지만 손상은 수백 byte 뒤의 전혀 관계없는 substream에서야 드러났습니다. 이 거리는 length-prefixed binary format에서 흔히 나타나며 그런 emitter를 또 작성하기 전에 이해해 둘 가치가 있습니다

잘못된 record length 하나가 worksheet stream 전체를 파괴하는 이유

BIFF8 workbook stream에는 자체 산술 외에 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가 자신의 크기를 정확히 말했기 때문뿐입니다. declared length는 record에 대한 metadata가 아니라 다음 record를 가리키는 pointer입니다. 두 surplus byte가 만든 일을 따라가 보세요. reader는 SXEx header를 소비하고 header가 약속한 24 byte를 건너뛴 뒤 oversized body의 남은 zero 두 byte 위에 두 byte 일찍 도착합니다. 그 zero를 record id $0000으로 읽고 다음 worksheet EOF record id ($000A)를 phantom record의 length로 읽은 다음, 성실하게 다음 위치로 10 byte를 건너뜁니다. 그 뒤 모든 header가 잘못된 offset에서 읽힙니다. 실패한 workbook에서는 reopen 후 _Chart가 nil인 chart sheet와 $18AF가 record id로 해석되었다는 debug dump가 만들어졌습니다. 그 두 value는 pivot code 근처 어디에도 없습니다

emitter와 writer가 서로 확인하지 않는 것

drift가 가능했던 구조적 이유는 HotXLS가 BIFF record를 header와 payload라는 독립적인 두 사실을 가진 TXLSBlob으로 만들기 때문입니다. EmitSXEx는 record id를 쓴 다음 length에 Blob.AddWord(24)를 쓰고 body를 field별로 append합니다. 이 24는 손으로 센 constant이며 뒤따르는 byte에서 유도되거나 검증되지 않습니다. write path도 gap을 닫지 않습니다. AddRec가 blob을 TXLSBlobList.Append로 넘기면 Data.DataLength byte를 그대로 output stream에 복사합니다. 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은 크기에 대해 모호하지 않으므로 수정은 기계적이었습니다. [MS-XLS] 2.4.282는 SXEx body를 4-byte grbit와 2-byte field 열 개로 정의합니다. csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle, cchVacateStyle입니다. 4 더하기 20은 24입니다. 예전 emitter는 spec이 10개로 정의한 zero word를 11개 썼고 anonymous AddWord(0) call에는 field name이 없어서 review 중 눈으로 세는 일은 들리는 만큼 신뢰할 수 없었습니다. preallocation이 단서였습니다. TXLSBlob.Create(28)은 정확히 4-byte header와 24-byte body를 요청하지만 매 call마다 이 hint를 넘어서 blob이 자랐고 AdjustBufferSize가 필요하면 재할당했으므로 조용히 진행됐습니다. code가 즉시 초과하는 capacity hint는 어떤 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에 따른 24-byte body를 완성하는 zero word 열 개
  // 선언된 length는 write한 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;

전체 PivotTable test suite를 통과한 이유

기존 pivot test가 file을 round-trip하지 않았기 때문입니다. workbook을 만들고 in-memory model을 assert한 뒤 끝냈으므로 in-memory assertion은 serialized byte stream에만 존재하는 length mismatch를 볼 수 없습니다. Delphi에서 BIFF8 PivotTable record를 쓰는 과정이 다루는 record set은 그 기준으로는 충분히 테스트되었지만 stream을 망가뜨리는 emitter를 여전히 배포했습니다. defect가 보이려면 두 번째 feature도 필요했습니다. pivot된 worksheet 뒤에 별일이 없으면 corruption이 아무도 검사하지 않는 substream 끝으로 빠져나가므로 reopen도 여전히 됩니다. chart sheet와 PivotTable을 조합하고 chart sheet와 drawing이 worksheet 뒤의 substream을 차지해야만 조용한 misalignment가 눈에 보이는 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);

수정 전에는 reader가 chart BOF에 도달하기 훨씬 전 substream boundary를 잃었으므로 그 줄에서 Wb.Sheets[3]._Chart가 nil이었습니다. pivot serialization bug를 마침내 잡은 assertion은 chart에 대한 assertion이었습니다

어긋난 BIFF stream을 첫 번째 잘못된 record까지 거슬러 읽는 방법

header chain을 순회하고 출력하세요. data가 이상해 보이기 훨씬 전에 desynchronized BIFF stream은 구조적으로 스스로를 알립니다. substream BOF ($0809)에서 시작해 id와 length를 읽고 4에 length를 더해 이동한 뒤 반복합니다. stream이 정렬된 동안에는 그럴듯한 record id에 도착하고 chain이 정확히 EOF ($000A)에서 끝납니다. drift가 시작되면 존재하지 않는 id, buffer를 넘어서는 length 또는 EOF가 있어야 할 곳을 곧장 지나가는 chain을 얻습니다

// BIFF record stream을 순회하고 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가 아니며 body가 buffer를 넘으면
    // 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을 거꾸로 읽고 한 규칙을 기억하세요. parse에 처음 실패한 record는 거의 culprit가 아닙니다. victim입니다. culprit는 불평 없이 parse된 마지막 record, 즉 그 직전 record입니다. 자신의 length에 거짓말하는 record는 항상 정상적으로 parse되기 때문입니다. 이 경우 walk는 phantom $0000 record에서 멈췄고 그 앞 record는 SXEx였습니다. 그 record의 declared length를 spec의 field list와 byte 단위로 비교하면 산술이 맞는지 아닌지가 드러납니다. sane한 첫 record에도 도달하지 못한다면 문제는 Workbook stream을 담은 OLE2 compound file인 더 낮은 layer에 있으므로 record-level dump를 아무리 해도 도움이 되지 않습니다

자기 length에 거짓말할 수 없는 emitter

지속 가능한 수정은 올바른 constant를 넣는 것이 아니라 틀린 값을 쓸 기회를 없애는 것입니다. length word를 예약하고 body를 내보낸 다음 실제로 만든 byte count에서 header를 patch합니다. HotXLS는 이를 위해 필요한 것을 노출합니다. TXLSBlob.DataLength는 current 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에만 성립합니다. oversized body는 header에서 정당하게 8224를 선언하고 $003C Continue record로 이어집니다. 이것이 HotXLS pivot cache와 connection writer가 큰 payload에 대해 수행하는 방식이므로 invariant는 conditional입니다. limit 아래에서는 emitted blob length가 declared length 더하기 4와 같아야 하고 limit 위에서는 splitter가 산술을 소유합니다. 이 구분을 comment가 아니라 helper에 encode하세요. 같은 reasoning은 BIFF뿐 아니라 모든 tag-length-value format에 적용됩니다. 크기를 알기 전에 선언하는 emitter는 code가 확인할 수 없고 reviewer가 셀 수 없는 claim을 쓰며, 첫 feature 뒤에 두 번째 feature가 들어올 때까지는 작동합니다

BIFF8 writer, pivot record emitter와 여기서 다룬 chart substream은 Delphi와 C++Builder용 HotXLS Delphi spreadsheet component에 포함되어 있으며 Excel을 설치하지 않고도 XLS, XLSX와 ODS를 읽고 씁니다