บทความเทคนิค

อ่านไฟล์ OLE2 Compound ใน Delphi โดยไม่ใช้ COM IStorage

HotXLS Excel Library สำหรับ Delphi และ C++Builder อ่านและเขียน container Compound File Binary ที่อยู่เบื้องหลังไฟล์ .xls รุ่นเก่าทุกไฟล์ด้วย Object Pascal ล้วน ๆ คลาส TlxCompoundFile implement layout ของ [MS-CFB] เวอร์ชัน 3 โดยตรงกับ TStream ครอบคลุม header, DIFAT, FAT chain, MiniFAT และ directory tree โดยไม่มี ole32.dll และไม่มี COM IStorage อยู่ใน path เลยแม้แต่น้อย

ฟังดูเหมือนเป็นแค่ระบบท่อประปาพื้นฐาน และมันก็เป็นแบบนั้นมายี่สิบปีในฐานะระบบท่อประปาที่คนอื่นเป็นเจ้าของ ทุก codebase ของ Delphi ที่แตะไฟล์ .xls จะเรียกใช้ StgOpenStorage ได้ IStorage กลับมา แล้วดึง stream Workbook ออกมาจากมัน สามบรรทัด ใช้งานได้ดี ไม่มีใครคิดถึงมันอีกเลย จนกระทั่งวันที่โค้ดเดียวกันนั้นต้องรันในที่ที่ไม่มี Windows

ทำไม StgOpenStorage ถึงหยุดทำงานบนเซิร์ฟเวอร์

COM structured-storage API ล้มเหลวพอดีในรูปแบบ deployment ที่โค้ด Delphi สมัยใหม่อาศัยอยู่ ด้วยเหตุผลที่ไม่เกี่ยวข้องกับรูปแบบไฟล์เลย StgOpenStorage เป็นจุดเข้า Win32 ใน ole32.dll มันต้องการ path บน filesystem ต้องการให้ COM ถูก initialize บน thread ที่เรียก และต้องการให้อยู่บน Windows ข้อกำหนดเรื่อง path เจ็บก่อนใคร เพราะ REST endpoint ที่รับ workbook ที่อัปโหลดมามี byte อยู่ใน buffer ไม่ได้อยู่บน disk ดังนั้นคุณต้องเขียน buffer ลงไฟล์ชั่วคราว เปิดมัน อ่านกลับ ลบทิ้ง แล้วตอนนี้คุณก็เป็นเจ้าของ lifecycle ของไฟล์ชั่วคราวที่จะพลาดได้ง่ายภายใต้ load หนัก ILockBytes เป็นทางออกที่มีเอกสารรองรับ แต่การเชื่อมต่อ implementation ที่กำหนดเองเข้ากับ TMemoryStream ก็เป็น COM interop มากกว่าที่ทีมส่วนใหญ่ต้องการ ข้อกำหนดเรื่อง initialization กัดเป็นอันดับสอง มักเกิดใน service worker thread ที่ไม่มีใครเรียก CoInitialize ไว้ และข้อกำหนดเรื่องแพลตฟอร์มก็จบบทสนทนาทันทีที่เป้าหมายคือ Linux ภายใต้ FPC, container image หรือ macOS HotXLS จึงคง path lxOLE แบบคลาสสิกที่สร้างบน StgOpenStorage ไว้เป็นค่าเริ่มต้น เพราะมันผ่านการทดสอบในสนามจริงมาแล้วและผู้เรียกเดิมไม่ควรต้องเปลี่ยนแปลงอะไร TlxCompoundFile คือทางเลือกแบบ opt-in สำหรับทุกคนที่เหลือ

Header กับ FAT chain บอกอะไรจริง ๆ

512 byte แรกของ compound file ตอบคำถามเชิงโครงสร้างทุกข้อที่คุณต้องรู้ก่อนอ่าน payload สักหนึ่ง byte [MS-CFB] §2.2 กำหนด header signature ไว้ที่ offset 0 เป็นแปด byte D0 CF 11 E0 A1 B1 1A E1 และ lxIsCompoundStream ก็ตรวจสอบตรงนั้นเป๊ะ ๆ พร้อมคืนตำแหน่ง stream กลับหลังจากนั้น เพื่อให้ผู้เรียกดมกลิ่นได้โดยไม่รบกวนอะไรเลย มีอีกสี่ฟิลด์ที่ตัดสินเรขาคณิต: byte order ที่ 0x1C ต้องเป็น 0xFFFE ซึ่งทำหน้าที่เป็นการตรวจสอบ signature ที่สองแบบประหยัด sector shift ที่ 0x1E ให้ขนาด sector เป็น 1 shl SectorShift ดังนั้นเวอร์ชัน 3 ใช้ shift 9 สำหรับ sector ขนาด 512 byte และเวอร์ชัน 4 ใช้ shift 12 สำหรับ 4096 byte mini sector shift ที่ 0x20 คือ 6 ทำให้ mini sector มีขนาด 64 byte และจุดตัดของ mini stream ที่ 0x38 คือ 4096 เลขคณิตของ address ที่ตามมาเป็นจุดที่พลาดกันบ่อยที่สุด sector 0 เริ่มต้นทันทีหลัง header ดังนั้น sector N จึงเริ่มที่ byte offset 512 + N * SectorSize สังเกตว่าเป็นตัวเลข 512 ตายตัว ไม่ใช่ SectorSize บนไฟล์เวอร์ชัน 3 ทั้งสองค่าเท่ากันพอดีและบั๊กก็ซ่อนตัวไปตลอดกาล บนไฟล์เวอร์ชัน 4 มันจะอ่าน sector ผิดอย่างเงียบ ๆ นี่คือเหตุผลที่ HotXLS เก็บสิ่งนี้ไว้ในฟังก์ชันเดียวคือ SidToOffset

Compound file คือระบบไฟล์ FAT ที่อยู่ภายในไฟล์หนึ่ง ดังนั้นการอ่านมันหมายถึงการไล่ linked list ของ sector ID ที่ FAT[n] เก็บ ID ที่ตามหลัง sector n มี sentinel สามตัวที่ปิดหรือกำกับ chain คือ ENDOFCHAIN, FATSECT สำหรับ sector ที่เป็นของ FAT เอง และ DIFSECT สำหรับ sector ของ DIFAT และทั้งสามอ่านเป็น signed 32-bit integer ที่เป็นลบ ซึ่งทำให้เงื่อนไขของลูปเรียบง่าย การหา FAT ต้องมี indirection อีกชั้นหนึ่ง คือ DIFAT เป็น array ของ sector ID ที่บอกว่า sector ของ FAT อยู่ที่ไหน และ 109 entry แรกของมันอยู่ใน header ที่ offset 0x4C TlxCompoundFile ไล่ผ่าน 109 entry นั้น หยุดที่ entry แรกที่เป็นลบ แล้วต่อแต่ละ sector ของ FAT เข้าเป็น array ของ Integer แบบแบนอันเดียว นั่นคือ 109 sector ของ FAT ที่ 128 entry ต่อ sector ของ 512 byte ดังนั้นจึงมี 13,952 sector ที่ระบุตำแหน่งได้ หรือประมาณ 6.8 MiB ของ container ก่อนที่ DIFAT จะต้องล้นไปเป็น chain ของตัวเอง

ตารางจัดสรรที่สองมีอยู่เพราะ sector ขนาด 512 byte เปลืองพื้นที่ส่วนใหญ่ไปกับ stream ขนาดเล็ก stream ใดก็ตามที่ต่ำกว่าจุดตัด 4096 byte จะไม่ถูกเก็บใน sector เลย มันอยู่ภายใน mini stream ซึ่งตัวมันเองก็เป็น stream ธรรมดาที่ห้อยอยู่กับ root directory entry แบ่งย่อยเป็น mini sector ขนาด 64 byte และเชื่อมโยงผ่าน MiniFAT คู่ขนานที่มีรากอยู่ที่ header offset 0x3C เปิดไฟล์ .xls จริง ๆ แล้ว stream Workbook จะอยู่บน FAT ปกติ ในขณะที่ stream summary-information อยู่ลึกลงไปในพื้นที่ mini-sector ซึ่งนี่คือเหตุผลที่ implementation ที่ครอบคลุมแค่ path ของ FAT ดูเหมือนจะทำงานได้ดีจนกว่ามันจะต้องการ metadata ของเอกสาร directory คือโครงสร้างที่สามและเป็นตัวที่ทำให้ container นำทางได้ แต่ละ entry มีขนาดพอดี 128 byte สี่ entry ต่อ sector ขนาด 512 byte บรรทุกชื่อแบบ UTF-16 ใน 64 byte แรก ความยาวเป็น byte ของมันที่ 0x40 ชนิดอ็อบเจกต์ที่ 0x42 (1 = storage, 2 = stream, 5 = root) ลิงก์ tree ที่ 0x44, 0x48 และ 0x4C sector เริ่มต้นที่ 0x74 และขนาด stream แบบ 32 บิตที่ 0x78 ความยาวชื่อนั้นนับ byte รวมถึง null ที่จบด้วย ดังนั้นจำนวนตัวอักษรคือ NameLen div 2 - 1 และถ้าคลาดเคลื่อนไปหนึ่งคุณก็จะได้ stream ที่ชื่อ Workboo

การดึง stream ของ Workbook ออกจาก memory buffer

TlxCompoundFile.OpenStream ซ่อนทุกอย่างข้างต้นไว้หลังการเรียกเดียวที่รับชื่อ stream และคืนค่า TlxCfbStream ที่บรรจุ byte ทั้งหมดที่สร้างขึ้นสมบูรณ์แล้ว ลำดับทั้งหมด คือ ดม โหลด แตกออกมา ทำงานกับ TBytesStream โดยไม่มีอะไรแตะ disk เลย

uses
  Classes, SysUtils, lxCompoundFile;

function ExtractBiffPayload(const Blob: TBytes): TBytes;
var
  Src: TBytesStream;
  Cfb: TlxCompoundFile;
  Wb: TlxCfbStream;
begin
  SetLength(Result, 0);
  Src:= TBytesStream.Create(Blob);
  try
    if not lxIsCompoundStream(Src) then
      Exit;                            // not a CFB container at all
    Cfb:= TlxCompoundFile.Create;
    try
      Cfb.LoadFromStream(Src);         // header, FAT, directory, MiniFAT
      Wb:= Cfb.OpenStream('Workbook'); // BIFF8
      if Wb = nil then
        Wb:= Cfb.OpenStream('Book');   // BIFF5 / BIFF7
      if Wb <> nil then
      try
        Result:= Wb.Data;
      finally
        Wb.Free;
      end;
    finally
      Cfb.Free;
    end;
  finally
    Src.Free;
  end;
end;

มีสองรายละเอียดที่ควรพูดถึง LoadFromStream รับ flag AOwnsStream ที่ค่าเริ่มต้นเป็น False ดังนั้นผู้เรียกยังคงรับผิดชอบ source stream ต่อไป ซึ่งจงใจให้เป็นแบบนั้น เพราะกรณีทั่วไปคือ stream ที่แอปพลิเคชันเป็นเจ้าของอยู่แล้ว และ OpenStream คืนค่า TlxCfbStream ที่เป็นเจ้าของสำเนา byte ของตัวเอง เปิดเผยผ่าน Data, Size, Read, Seek และ CopyTo การคัดลอกนั้นมีต้นทุนจริงบน workbook ขนาดใหญ่ และมันคือราคาที่ซื่อตรงของการออกแบบที่ทำให้อ็อบเจกต์ที่คืนมายังใช้งานได้หลังจาก container ถูกปล่อยทิ้งไปแล้ว เมื่อ workbook ใหญ่พอที่การคัดลอกในหน่วยความจำทั้งหมดจะเป็นรูปแบบที่ผิดไปเลย ตัวอ่านแบบ streaming โดยตรงสำหรับสเปรดชีตขนาดใหญ่เกิน คือจุดเริ่มต้นที่ดีกว่า

ทำไมไฟล์ XLSX ที่เข้ารหัสถึงดูเหมือนไฟล์ XLS

เพราะมันเป็นไฟล์ XLS จริง ๆ ในระดับ container และนี่คือผลตอบแทนในทางปฏิบัติของการเป็นเจ้าของ layer นั้น เปิดไฟล์ .xlsx ที่เข้ารหัสด้วย hex editor แล้วแปด byte แรกจะเป็น D0 CF 11 E0 A1 B1 1A E1 เหมือนกันทุก byte กับไฟล์ .xls รุ่นปี 1997 เพราะการเข้ารหัสตาม [MS-OFFCRYPTO] ไม่ได้เข้ารหัส package ZIP ตรง ๆ ในที่เดิม มันห่อ package ทั้งหมดไว้ภายใน CFB container เป็น stream ที่ชื่อ EncryptedPackage ข้าง ๆ stream EncryptionInfo ที่อธิบายอัลกอริทึม signature จึงระบุแค่ container และไม่บอกอะไรเลยเกี่ยวกับ payload การแยก BIFF workbook ออกจาก OOXML package ที่เข้ารหัสหมายถึงการอ่าน directory ซึ่งหลังจาก LoadFromStream ก็คือการสแกนผ่าน EntryCount กับ Entries หรือการตรวจสอบด้วย HasStream สองครั้ง

type
  TCfbPayload = (cpUnknown, cpBiffWorkbook, cpEncryptedOoxml);

function ClassifyContainer(AStream: TStream): TCfbPayload;
var
  Cfb: TlxCompoundFile;
  E: TlxCfbEntry;
  I: Integer;
begin
  Result:= cpUnknown;
  Cfb:= TlxCompoundFile.Create;
  try
    Cfb.LoadFromStream(AStream);
    for I:= 0 to Cfb.EntryCount - 1 do
    begin
      E:= Cfb.Entries(I);
      if E.EntryType <> cfbStream then
        Continue;
      if E.Name = 'EncryptedPackage' then
        Result:= cpEncryptedOoxml
      else if (E.Name = 'Workbook') or (E.Name = 'Book') then
        Result:= cpBiffWorkbook;
    end;
  finally
    Cfb.Free;
  end;
end;

ชื่อใน directory ควรค่าแก่คำเตือนของตัวเอง stream summary-information บรรทุกอักขระควบคุม 0x05 นำหน้าไว้ในชื่อของมัน ดังนั้นการเปรียบเทียบที่เขียนกับ display string ธรรมดาจะไม่มีวันจับคู่กับมันได้ และ log line ที่ไร้เดียงสาก็จะแสดงมันออกมาเป็นขยะ ทุกอย่างที่อยู่ปลายทางของการจัดหมวดหมู่นี้ คือการหากุญแจ การตรวจสอบตัวยืนยันรหัสผ่าน เป็นปัญหาแยกต่างหาก ครอบคลุมไว้ในบันทึกเรื่อง ทำไม Excel ถึงปฏิเสธ workbook ที่เข้ารหัสด้วย cipher mode ผิด layer ของ container บอกคุณได้แค่ว่าคุณกำลังยืนอยู่หน้าประตูบานไหน

การเขียน container ที่ Excel จะเปิดได้จริง

ฝั่งการเขียนของ TlxCompoundFile แคบกว่าฝั่งการอ่านโดยตั้งใจ และการเข้าใจว่าทำไมช่วยประหยัดการโต้เถียงกับสเปกได้ [MS-CFB] อนุญาตพื้นที่มหาศาลของ container ที่ถูกต้อง: storage หลายระดับ red-black directory tree ที่สมดุลอย่างถูกต้อง mini stream, DIFAT chain Excel ปล่อยแค่มุมเล็ก ๆ ของพื้นที่นั้นออกมาและอ่านพื้นที่ที่ใหญ่กว่าเล็กน้อย HotXLS เขียนมุมที่เล็กกว่านั้นอีก คือขั้นต่ำสุดที่ Excel พิสูจน์แล้วว่าโหลดได้ ทุก stream อยู่บน FAT ปกติโดยไม่มี path ของ mini-stream เลย ซึ่งเสียพื้นที่ disk แต่แลกมาด้วยความถูกต้อง stream summary ขนาด 300 byte ที่ Excel จะบรรจุลงใน mini sector ขนาด 64 byte ห้าตัว กลับใช้พื้นที่ sector เต็ม 512 byte แทน และสำหรับ workbook นั่นเป็นแค่ noise เทียบกับการดูแลตารางจัดสรรที่สอง การไล่ chain ที่สอง และ root-entry stream ที่รองรับมันบน path การเขียน directory entry สร้างเป็น sibling chain แบบแบนใต้ root โดยทุก node มีสีดำ และลำดับการปล่อยออกมาก็ตายตัว: header ตัวยึดที่ก่อน stream data sector, directory sector, FAT sector แล้วจึง seek กลับไปเขียน header ใหม่ด้วย sector ID ที่รู้ได้ก็ต่อเมื่อจบแล้วเท่านั้น FAT กำหนดขนาดตัวเองผ่านลูป fixed-point สั้น ๆ เพราะการเพิ่ม sector ของ FAT อาจดันจำนวน sector สูงขึ้นจนต้องการ sector ของ FAT อีกตัวหนึ่ง

procedure SaveAsCompoundFile(const Dest: string; const BiffBytes: TBytes);
var
  FS: TFileStream;
  Cfb: TlxCompoundFile;
begin
  FS:= TFileStream.Create(Dest, fmCreate);
  try
    Cfb:= TlxCompoundFile.Create;
    try
      Cfb.CreateNew(FS);                  // v3 header, 512-byte sectors
      Cfb.AddStream('Workbook', BiffBytes);
      Cfb.Save;                           // data -> dir -> FAT -> header
    finally
      Cfb.Free;
    end;
  finally
    FS.Free;
  end;
end;

ขอบเขตที่ implementation หยุดอยู่

มีสามขอบเขตที่ควรระบุไว้อย่างตรงไปตรงมา เพราะตัวอ่าน container ที่จัดการ edge case ผิดอย่างเงียบ ๆ นั้นแย่กว่าตัวที่ raise ออกมา TlxCompoundFile อ่าน 109 entry ของ DIFAT ที่อยู่ใน header และไม่ไล่ตาม DIFAT chain ที่ 0x44 เกินกว่านั้น ทำให้ container ที่อ่านได้ถูกจำกัดไว้ที่ประมาณ 6.8 MiB บน sector ขนาด 512 byte ซึ่งมากกว่าไฟล์ .xls จริง ๆ ที่ HotXLS เจอในภาคสนามอย่างสบาย แต่ก็ยังเป็นเพดานที่แข็งอยู่ดี และตัวเขียนก็บังคับใช้ขีดจำกัดเดียวกันนี้อย่างชัดเจนแทนที่จะปล่อย container ที่มันอธิบายไม่ได้ออกมา ประการที่สอง container เวอร์ชัน 4 ที่มี sector ขนาด 4096 byte ถูกรองรับด้วยเลขคณิตขนาด sector แต่ไม่ใช่สิ่งที่โค้ดถูกปรับแต่งไว้ และขนาด stream แบบ 64 บิตไม่ถูกใช้เลย HotXLS อ่าน 32 บิตล่างที่ offset 0x78 และปล่อยครึ่งบนไว้เฉย ๆ ซึ่งถูกต้องสำหรับเวอร์ชัน 3 เท่านั้น ประการที่สาม การค้นหา entry เป็นการสแกนแบบแบนตามชื่อผ่านรายการ directory แทนที่จะไล่ลงไปใน red-black tree จาก storage ต้นทาง ดังนั้น storage ที่ซ้อนกันจะ resolve ด้วยการชนกันของชื่อแทนที่จะเป็นการไล่ตาม path ทุก stream ที่ไฟล์ .xls ต้องการอยู่ที่ระดับบนสุดทั้งหมด ซึ่งเป็นสิ่งที่ทำให้การออกแบบที่ง่ายกว่านี้อธิบายได้ แต่โค้ดที่คาดหวังจะระบุที่อยู่ SomeStorage/SomeStream จะไม่พบมัน

ไม่มีข้อไหนเปลี่ยนแปลงจุดประสงค์ของ unit นี้เลย การเป็นเจ้าของ layer ของ container เปลี่ยนการจัดการ .xls ให้กลายเป็น Object Pascal ธรรมดา คือ parse ได้จาก byte array ทดสอบได้โดยไม่ต้องใช้ filesystem พกพาได้ไปยังแพลตฟอร์มใดก็ตามที่ compiler กำหนดเป้าหมายไว้ และปราศจาก COM apartment มันยังเลิกใช้ทางลัดการดมกลิ่นด้วย เพราะการระบุ workbook ตอนนี้หมายถึงการอ่าน directory ของมัน ไม่ใช่แปด byte แรกของมัน ซึ่งเป็นวินัยเดียวกับที่อยู่เบื้องหลัง การแสดงรายชื่อ sheet โดยไม่ต้องเปิด workbook ทั้งเล่ม

TlxCompoundFile มาพร้อมกับ HotXLS Excel Component สำหรับ Delphi และ C++Builder ควบคู่ไปกับ layer ของ BIFF และ OOXML ที่วางอยู่ด้านบนมัน หน้าผลิตภัณฑ์มีเอกสารอ้างอิง unit แบบเต็มและตาราง compiler ที่รองรับ