技術記事

COM IStorageなしでDelphiからOLE2複合ファイルを読む

DelphiとC++Builder向けのHotXLS Excel Libraryは、あらゆるレガシー.xlsファイルの背後にあるCompound File Binaryコンテナを純粋なObject Pascalで読み書きする。TlxCompoundFileクラスは[MS-CFB]バージョン3のレイアウト——ヘッダー、DIFAT、FATチェーン、MiniFAT、ディレクトリツリー——をTStreamに対して直接実装しており、経路のどこにもole32.dllもCOMのIStorageも存在しない

これは配管作業のように聞こえるし、20年間それは実際に他の誰かが所有していた配管だった。.xlsファイルに触れるあらゆるDelphiコードベースはStgOpenStorageに手を伸ばし、IStorageを受け取り、そこからWorkbookストリームを取り出していた。3行で済み、問題なく動き、誰もそれについて二度と考えなかった——同じコードをWindowsではないどこかで動かさなければならない日が来るまでは

なぜStgOpenStorageはサーバー上で動かなくなるのか

COMの構造化ストレージAPIは、ファイル形式とは何の関係もない理由から、現代のDelphiコードが暮らしているまさにそのデプロイ形態の中で失敗する。StgOpenStorageole32.dll内のWin32エントリポイントである:ファイルシステム上のパスを求め、呼び出し元スレッドでCOMが初期化されていることを求め、Windows上であることを求める。パスの要求が最初に効いてくる。アップロードされたワークブックを受け取るRESTエンドポイントはバイト列をバッファの中に持っているのであってディスク上にではないため、バッファを一時ファイルに書き、それを開き、読み戻し、削除する——そして負荷の下で間違えかねない一時ファイルのライフサイクルを抱え込むことになる。ILockBytesは文書化された抜け道だが、TMemoryStreamの上にカスタム実装を組むのは、たいていのチームが望むよりも多くのCOM相互運用を伴う。初期化の要求は2番目に噛みついてくる。たいていは誰もCoInitializeを呼んでいないサービスのワーカースレッドの中でだ。そしてプラットフォームの要求は、対象がFPC上のLinuxやコンテナイメージ、macOSになった瞬間に会話を終わらせる。そのためHotXLSはStgOpenStorageの上に構築された古典的なlxOLE経路をデフォルトのまま保持している。実戦で鍛えられており、既存の呼び出し元がわざわざ変更する必要はないからだ。TlxCompoundFileはそれ以外の全員のためのオプトインの代替手段である

ヘッダーとFATチェーンが実際に語ること

複合ファイルの最初の512バイトは、ペイロードを1バイトも読む前に必要な構造上の問いのすべてに答えてくれる。[MS-CFB] §2.2はオフセット0のヘッダー署名を8バイトのD0 CF 11 E0 A1 B1 1A E1に固定しており、lxIsCompoundStreamはまさにそれをチェックし、その後ストリーム位置を復元するため、呼び出し側は何も乱すことなく調べられる。さらに4つのフィールドがジオメトリを決める:0x1Cのバイトオーダーは0xFFFEでなければならず、これは安価な第二の署名チェックも兼ねる。0x1Eのセクタシフトはセクタサイズを1 shl SectorShiftとして与え、バージョン3はシフト9で512バイトセクタを、バージョン4はシフト12で4096バイトセクタを使う。0x20のミニセクタシフトは6で、ミニセクタは64バイトになる。そして0x38のミニストリームのカットオフは4096である。その後に続くアドレス演算は、最も間違いやすい場所だ。セクタ0はヘッダーの直後から始まるため、セクタNはバイトオフセット512 + N * SectorSizeから始まる——ここでの512は文字どおりの512であってSectorSizeではないことに注意してほしい。バージョン3のファイルではこの2つは同一であり、バグは永遠に隠れたままになる。バージョン4のファイルではこれが静かに間違ったセクタを読んでしまうため、HotXLSはこれをSidToOffsetという1つの関数の中に閉じ込めている

複合ファイルはファイルの中のFATファイルシステムであり、それを読むということは、FAT[n]がセクタnに続くIDを保持するセクタIDの連結リストを辿ることを意味する。3つの番兵がチェーンを終端または注釈する——ENDOFCHAIN、FAT自体に属するセクタ用のFATSECT、DIFATセクタ用のDIFSECTである——そしてこの3つはすべて負の符号付き32ビット整数として読まれ、これがループ条件を単純に保っている。FATを見つけるにはもう一段の間接参照が必要だ:DIFATはFATセクタがどこにあるかを示すセクタID配列であり、最初の109個のエントリはヘッダーのオフセット0x4Cに収まっている。TlxCompoundFileはこの109個を走査し、最初の負のエントリで止まり、各FATセクタを1つのフラットなInteger配列へと連結する。512バイトセクタでは1セクタあたり128エントリのFATセクタが109個あるので、13,952個のアドレス可能なセクタとなり、コンテナが自分自身のチェーンへとDIFATを溢れさせなければならなくなるまでにおよそ6.8MiBある

2つ目の割り当てテーブルが存在するのは、512バイトのセクタが小さなストリームに対してはその大半のスペースを無駄にしてしまうからだ。4096バイトのカットオフを下回るどんなストリームも、そもそもセクタには保存されない:それはミニストリームの内側に住んでおり、ミニストリーム自体はルートディレクトリエントリからぶら下がる普通のストリームで、64バイトのミニセクタへと細分され、ヘッダーオフセット0x3Cを根とする並行するMiniFATを通じて連結される。実際の.xlsを開けばWorkbookストリームは通常のFATの上にあり、要約情報のストリームはミニセクタ空間の下のほうにある。FATの経路しかカバーしない実装が、文書のメタデータを必要とするまさにその瞬間まで正しく動いているように見える理由がこれである。ディレクトリは3番目の構造であり、コンテナを走査可能にするものだ:各エントリはちょうど128バイトで、512バイトセクタあたり4つ、最初の64バイトにUTF-16の名前を運び、0x40にそのバイト長、0x42にオブジェクトタイプ(1 = ストレージ、2 = ストリーム、5 = ルート)、0x440x480x4Cにツリーリンク、0x74に開始セクタ、0x78に32ビットのストリームサイズを持つ。この名前の長さは終端のnullを含んだバイト数を数えているため、文字数はNameLen div 2 - 1であり、これを1つ間違えるとWorkbooという名前のストリームができあがることになる

メモリバッファからWorkbookストリームを取り出す

TlxCompoundFile.OpenStreamは、ストリーム名を受け取り完全に実体化されたバイト列を保持するTlxCfbStreamを返す1回の呼び出しの背後に、上記のすべてを隠している。この一連の流れ——嗅ぎ分け、読み込み、抽出——は、ディスクに何も触れることなくTBytesStreamに対して実行される

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;

ここで2つの詳細を指摘する価値がある。LoadFromStreamはデフォルトでFalseAOwnsStreamフラグを取るため、呼び出し元がソースストリームの責任を持ち続ける——これは意図的なもので、よくあるケースはアプリケーションがすでに所有しているストリームだからだ。そしてOpenStreamは自分自身のバイト列のコピーを所有するTlxCfbStreamを返し、DataSizeReadSeekCopyToを通じてそれを公開する。そのコピーは大きなワークブックに対しては本物のコストであり、コンテナが解放された後も返されたオブジェクトが有効であり続けるという設計に対する正直な代償である。ワークブックが大きすぎてメモリ上への完全なコピーがそもそも間違った形になる場合には、巨大なスプレッドシート向けのストリーミング直接リーダーのほうが優れた入口である

なぜ暗号化されたXLSXはXLSファイルのように見えるのか

コンテナのレベルではそれが実際にそうだからだ——そしてこれこそがその層を所有することの実務上の見返りである。暗号化された.xlsxをhexエディタで開くと、最初の8バイトはD0 CF 11 E0 A1 B1 1A E1であり、これは1997年代の.xlsとバイト単位でまったく同一である。なぜなら[MS-OFFCRYPTO]の暗号化はZIPパッケージをそのまま暗号化するのではなく、パッケージ全体をEncryptedPackageという名前のストリームとしてCFBコンテナの中に包み、その隣にアルゴリズムを記述するEncryptionInfoストリームを置くからだ。したがって署名はコンテナを識別するだけであり、ペイロードについては何も語らない。BIFFワークブックを暗号化されたOOXMLパッケージと区別するにはディレクトリを読む必要があり、LoadFromStreamの後ではそれはEntryCountEntriesを走査すること、あるいは2回の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;

ディレクトリの名前にはそれ自体の注意点がある:要約情報ストリームはその名前の先頭に0x05という制御文字を持っているため、普通の表示用文字列と比較するように書かれた比較は決してマッチせず、素朴なログ行はそれをガベージとして表示してしまう。この分類より下流のすべて——鍵の導出、パスワード検証子のチェック——は別の問題であり、Excelが間違った暗号モードで暗号化されたワークブックを拒否する理由に関するノートで扱っている。コンテナ層はあなたがどのドアの前に立っているかだけを教えてくれる

Excelが実際に開いてくれるコンテナを書く

TlxCompoundFileの書き込み側は読み取り側よりも意図的に狭くしてあり、その理由を理解しておけば仕様との議論を避けられる。[MS-CFB]は膨大な空間の有効なコンテナを許容している:多階層のストレージ、正しくバランスされた赤黒ディレクトリツリー、ミニストリーム、DIFATチェーンなどだ。Excelはその空間のうちの小さな一角を出力し、それより幾分広い一角を読む。HotXLSはさらに小さな一角——Excelが確実に読み込むことが実証されている最小限——を書く。すべてのストリームはミニストリーム経路なしで通常のFAT上に置かれ、これはディスク容量を犠牲にして正しさを買う:Excelなら5つの64バイトミニセクタに詰め込んだであろう300バイトの要約ストリームは、代わりに512バイトのセクタを丸ごと1つ占有する。ワークブックにとってそれは、書き込み経路で2つ目の割り当てテーブルと2つ目のチェーン走査、それを裏付けるルートエントリストリームを維持することに比べればノイズにすぎない。ディレクトリエントリはルートの下でフラットな兄弟チェーンを形成し、すべてのノードが黒に着色され、出力順序は固定である:ヘッダーのプレースホルダー、ストリームデータのセクタ、ディレクトリのセクタ、FATのセクタ、そして最後にしかわからないセクタIDでヘッダーを書き直すためのシークバックである。FATはFATセクタを追加することでセクタ数がもう1つ別の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;

この実装が止まる場所

はっきり述べておく価値のある境界が3つある。エッジケースを静かに誤処理するコンテナリーダーは、例外を発生させるリーダーよりも悪いからだ。TlxCompoundFileはヘッダーに収まっている109個のDIFATエントリを読み、それを超えて0x44のDIFATチェーンを辿ることはない。これは512バイトセクタで読み取れるコンテナをおよそ6.8MiBに制限する——HotXLSが実際の現場で出会う本物の.xlsファイルよりは十分に大きいが、それでも固い天井であり、ライターも自分が記述できないコンテナを出力するのではなく、同じ制限を明示的に強制する。第二に、4096バイトセクタを持つバージョン4のコンテナはセクタサイズの演算によって収容はされるが、コードが調整されているのはそこではなく、64ビットのストリームサイズも参照されない:HotXLSはオフセット0x78の下位32ビットを読み、上位半分には手を触れない。これはバージョン3にとって正しく、バージョン3に対してのみ正しい。第三に、エントリのルックアップは親ストレージから赤黒木を辿るのではなく、ディレクトリリスト全体を名前でフラットに走査するものであり、そのためネストしたストレージはパスによってではなく名前の衝突によって解決される——.xlsファイルが必要とするすべてのストリームはトップレベルにあり、それがこのより単純な設計を正当化しているのだが、SomeStorage/SomeStreamをアドレスしようとするコードはそれを見つけられないだろう

それでもこのユニットの目的は変わらない。コンテナ層を所有することは、.xlsの扱いを普通のObject Pascalへと変える:バイト配列からパース可能で、ファイルシステムなしでテスト可能で、コンパイラが対象とするどんなプラットフォームにも移植可能で、COMアパートメントとは無縁になる。それはまた嗅ぎ分けの近道も葬り去る。ワークブックを識別するということは、今や最初の8バイトではなくそのディレクトリを読むことを意味するからだ。これはワークブック全体を開かずにシート名を列挙するの背後にあるのと同じ規律である

TlxCompoundFileは、その上に乗るBIFF層とOOXML層とともに、DelphiとC++Builder向けのHotXLS Excel Componentの一部として提供される。製品ページには完全なユニットリファレンスとサポート対象コンパイラの一覧が掲載されている