HotXLS 2.376.0 แก้ BIFF record length drift ใน classic XLS writer ของมัน SXEx emitter สำหรับ PivotTable view ประกาศ body ยาว 24 byte ใน header แล้ว append 26 byte BIFF reader เชื่อค่าความยาวที่ประกาศไว้ ดังนั้น byte ส่วนเกินสองตัวจึงทำให้ทุกอย่างหลังจากนั้น desynchronize และ workbook ที่จับคู่ PivotTable กับ chart sheet จะทำ chart หายเมื่อเปิดใหม่
ส่วนที่น่าสนใจไม่ใช่คำว่า off-by-one แต่คือระยะห่างระหว่างความผิดพลาดกับอาการ ไม่มีอะไร fail ตรงจุดที่ bug อยู่ pivot record serialize ได้สะอาด ไฟล์เขียนได้โดยไม่มี error และ Excel เปิดได้ ความเสียหายเพิ่งโผล่หลายร้อย byte ถัดไปใน substream ที่ไม่เกี่ยวกันโดยตรง ระยะห่างแบบนี้เป็นลักษณะของ binary format ที่มี length prefix ทุกชนิด และควรเข้าใจก่อนเขียน emitter ให้ format ใดอีก
ทำไม record length ที่ผิดเพียงตัวเดียวจึงทำลาย worksheet stream ทั้งหมด
BIFF8 workbook stream ไม่มี framing นอกจาก arithmetic ของตัวเอง record ทุกตัวมี 4-byte header ประกอบด้วย record id 2 byte กับ body length 2 byte แล้วตามด้วย payload byte ตามจำนวนที่ระบุพอดี ([MS-XLS] 2.1.4) ไม่มี separator ไม่มี magic byte ไม่มี checksum และไม่มี resynchronization point reader จะไปถึง record ถัดไปได้ก็ต่อเมื่อ record ก่อนหน้าบอกขนาดของตัวเองตามจริง declared length จึงไม่ใช่ metadata เกี่ยวกับ record แต่มันคือ pointer ไปยัง record ถัดไป ลองไล่ดูว่า byte เกินสองตัวทำอะไร reader อ่าน SXEx header ข้าม 24 byte ตามที่ header สัญญา แล้วไปหยุดก่อนจุดจริงสอง byte บนคู่ zero ที่เหลือจาก body ซึ่งใหญ่เกินไป มันอ่าน zero เป็น record id $0000 จากนั้นอ่าน worksheet EOF record id ($000A) เป็น length ของ phantom record แล้วข้ามไปข้างหน้า 10 byte ในสิ่งที่ตามมา จากนั้นทุก header จะถูกอ่านจาก offset ที่ผิด ใน workbook ที่พังจึงได้ chart sheet ที่ _Chart เป็น nil หลังเปิดใหม่ และ debug dump ที่แสดงว่า $18AF ถูกตีความเป็น record id ค่าเหล่านี้ไม่ได้อยู่ใกล้ pivot code เลย
emitter กับ writer ไม่เคยตรวจข้อมูลของกันและกัน
สาเหตุเชิงโครงสร้างที่ทำให้ drift เกิดขึ้นได้คือ HotXLS สร้าง BIFF record เป็น TXLSBlob ที่ header กับ payload เป็นข้อเท็จจริงคนละชุด EmitSXEx เขียน record id แล้วเขียน Blob.AddWord(24) เป็น length จากนั้น append body ทีละ field ค่า 24 เป็น constant ที่นับด้วยมือ ไม่เคย derive หรือ check จาก byte ที่ตามมา write path ก็ไม่ได้ปิดช่องว่าง AddRec ส่ง blob ต่อให้ TXLSBlobList.Append ซึ่ง copy Data.DataLength byte แบบตรง ๆ ลง output stream DataLength คือจำนวน byte จริง ดังนั้น writer จึง emit body 26 byte หลัง header ที่อ้างว่า 24 byte ทั้งสองฝั่งทำตามที่ถูกสั่งทุกอย่าง และไม่มีใครมีหน้าที่สังเกต contradiction ระหว่างกัน HotXLS หลีกเลี่ยงเรื่องนี้อยู่แล้วในจุดที่ replay payload ที่ preserve ไว้ TXLSWorkbook.StoreDConnBlobs คำนวณ header length word จาก body length จริงแทน literal และนี่คือเหตุผลที่ blob replay ไม่เคย drift
[MS-XLS] 2.4.282 กำหนดอะไรเกี่ยวกับ SXEx
spec ระบุขนาดไว้อย่างชัดเจน ทำให้การแก้เป็นงานเชิงกล [MS-XLS] 2.4.282 กำหนด SXEx body เป็น grbit ขนาด 4 byte ตามด้วย field ขนาด 2 byte สิบตัว ได้แก่ csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle และ cchVacateStyle สี่บวกยี่สิบเท่ากับยี่สิบสี่ emitter เดิมเขียน zero word สิบเอ็ดตัวทั้งที่ spec กำหนดสิบตัว และการเรียก AddWord(0) แบบไร้ชื่อ field ทำให้การนับด้วยตาระหว่าง review เชื่อถือได้พอ ๆ กับที่เห็น โครงสร้าง preallocation เป็น clue ว่า layout ถูกเข้าใจแต่ loop ผิด TXLSBlob.Create(28) ขอพื้นที่พอดีกับ header 4 byte บวก body 24 byte แต่ blob โตเกินคำใบ้นี้ทุก call และโตอย่างเงียบ ๆ เพราะ AdjustBufferSize reallocates ตามต้องการ capacity hint ที่ code เขียนเกินทันทีควรได้รับการตรวจซ้ำใน serializer ทุกตัว
function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
Blob: TXLSBlob;
begin
Blob := TXLSBlob.Create(28); // header 4 byte + body 24 byte
Blob.AddWord($00C6);
Blob.AddWord(24);
Blob.AddByte($02);
Blob.AddByte($00); // grbit1 = fPrintTitles
Blob.AddByte($00);
Blob.AddByte($00); // grbit2
// zero word สิบตัวทำให้ body ครบ 24 byte ตาม [MS-XLS] 2.4.282
// declared 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;
ทำไมเรื่องนี้รอดจาก PivotTable test suite ทั้งชุด
เพราะ pivot test เดิมไม่เคย round-trip ผ่านไฟล์ มันสร้าง workbook แล้ว assert กับ in-memory model จากนั้นหยุด และ in-memory assertion มองไม่เห็น length mismatch ที่มีอยู่เฉพาะใน serialized byte stream record set ที่ เขียน BIFF8 PivotTable record จาก Delphi รองรับถูก test ตามมาตรฐานนั้นอย่างดี แต่ยังปล่อย emitter ที่ทำให้ stream เสียได้ออกไป bug ยังต้องการ feature ที่สองจึงจะมองเห็น pivoted worksheet ที่ตามด้วยสิ่งอื่นไม่มากยังเปิดใหม่ได้ เพราะ corruption วิ่งเลยท้าย substream ที่ไม่มีใครตรวจ มีเพียงการจับคู่ PivotTable กับ chart sheet ซึ่ง chart sheet และ drawing อยู่ใน substream ถัดจาก worksheet ที่เปลี่ยน silent 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);
ก่อนแก้ Wb.Sheets[3]._Chart เป็น nil ที่บรรทัดนั้น เพราะ reader เสียขอบเขต substream ไปนานก่อนจะถึง chart BOF assertion ที่จับ serialization bug ของ pivot ได้ในที่สุดจึงเป็น assertion เรื่อง chart
อ่าน BIFF stream ที่ misalign ย้อนกลับไปหา record แรกที่ผิด
ให้เดินตาม header chain แล้ว print มันออกมา เพราะ BIFF stream ที่ desynchronize จะประกาศตัวเองในเชิงโครงสร้างนานก่อนข้อมูลจะดูผิด เริ่มที่ substream BOF ($0809) อ่าน id กับ length ขยับไปสี่บวก length แล้วทำซ้ำ ระหว่างที่ stream ยัง align คุณจะไปเจอ record id ที่สมเหตุสมผลและ chain จะจบตรง EOF ($000A) พอดี เมื่อ drift แล้วจะได้ id ที่ไม่มีอยู่จริง length ที่เลย 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)^;
// id เป็นศูนย์ไม่ใช่ 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 ย้อนกลับและจำกฎข้อหนึ่งไว้ record แรกที่ parse fail แทบไม่เคยเป็น culprit มันคือ victim culprit คือ record ก่อนหน้าทันที ซึ่งเป็นตัวสุดท้ายที่ parse ได้โดยไม่ร้องเรียน เพราะ record ที่โกหกความยาวของตัวเองย่อม parse ได้ปกติ ในกรณีนี้ walk หยุดที่ phantom record $0000 และ record ก่อนหน้านั้นคือ SXEx ให้เทียบ declared length ของ record นั้นกับ field list ใน spec ทีละ byte แล้ว arithmetic จะบอกเองว่าครบหรือไม่ หาก walk ไปไม่ถึง record แรกที่สมเหตุสมผล ปัญหาอยู่ชั้นล่างกว่าใน OLE2 compound file ที่เก็บ Workbook stream และการ dump ระดับ record จะช่วยอะไรไม่ได้
emitter ที่โกหกความยาวของตัวเองไม่ได้
การแก้ที่ทนทานไม่ใช่การใส่ constant ให้ถูก แต่คือการลบโอกาสเขียนค่าที่ผิดออกไป reserve length word ไว้ก่อน emit body แล้ว patch header จากจำนวน byte ที่สร้างจริง HotXLS เปิด API ที่ต้องใช้ไว้ TXLSBlob.DataLength ให้ current offset และ SetWord เขียนกลับไปยังตำแหน่งที่ 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 แบบเหมารวมว่า emitted byte เท่ากับ 2 + 2 + declared ใช้ได้เฉพาะ record ที่อยู่ใต้ BIFF8 limit ซึ่ง payload ยาว 8224 byte record ที่ใหญ่กว่านั้นประกาศ 8224 ใน header ได้อย่างถูกต้องแล้วต่อใน $003C Continue record ซึ่งเป็นสิ่งที่ pivot cache และ connection writer ของ HotXLS ทำกับ payload ใหญ่ ดังนั้น invariant มีเงื่อนไข: ต่ำกว่า limit emitted blob length ต้องเท่ากับ declared length บวกสี่ แต่เมื่อเกิน limit splitter จะเป็นเจ้าของ arithmetic เอง ให้ encode ความแตกต่างนี้ไว้ใน helper แทนการเขียนไว้ใน comment เหตุผลแบบเดียวกันถ่ายโอนไปยัง tag-length-value format ทุกชนิด ไม่ใช่แค่ BIFF emitter ที่ประกาศ size ก่อนรู้ค่าจริงกำลังสร้าง claim ที่ code ตรวจไม่ได้และ reviewer นับไม่ได้ และมันจะทำงานได้จนกว่า feature ที่สองจะมาต่อท้าย feature แรก
BIFF8 writer, pivot record emitter และ chart substream ที่กล่าวถึงอยู่ใน HotXLS Delphi spreadsheet component สำหรับ Delphi และ C++Builder ซึ่งอ่านและเขียน XLS, XLSX และ ODS ได้โดยไม่ต้องติดตั้ง Excel