HTMLマジックバイト検出を「末尾チェック」で強化し、コメント先行問題を解消

rails/marcel

HTMLファイルの先頭にコメントが置かれると application/xml と誤判定される問題を、正規表現と負のオフセットによる末尾検索の組み合わせで解決しました。

背景

marcelのHTMLマジックバイト検出は、ファイル先頭の固定オフセットと固定文字列の組み合わせで実装されており、先頭付近に <html><!DOCTYPE html> が現れることを前提としていました。しかしIssue #102 が報告したように、先頭に長いコメントが置かれると <html> タグが検索範囲外に押し出され、代わりに <!-- で始まるXMLのマジック定義が先にマッチしてしまいます。

問題の核心は、HTMLマジック定義が lib/marcel/tables.rb 内のXMLマジック定義よりも後に評価されることです。先頭128バイト以内に <html> が収まらない場合、XML判定が優先されてしまいます。先頭オフセットをいくらか広げるワークアラウンドは一時的な回避策に過ぎず、コメントは任意の長さを取り得るため根本的な解決にはなりません。

技術的な変更

マジック定義の抜本的な書き換え により、先頭の固定文字列マッチングから正規表現ベースの柔軟な検出へ移行しました。

変更前:

Marcel::MimeType.extend "text/html",
  extensions: %w( html htm ),
  magic: [
    [0, "<!DOCTYPE html"],
    [0, "<!DOCTYPE HTML"],
    [0, "<!doctype html"],
    [0, "<!doctype HTML"],
    [0, "<html"],
    [0, "<HTML"],
    [0, " <!DOCTYPE html"],
    [0, "\n<!DOCTYPE html"],
    [0, "\r<!DOCTYPE html"],
    [0, "\r\n<!DOCTYPE html"],
    [0, "\t<!DOCTYPE html"],
    [0, " <html"],
    [0, "\n<html"],
    [0, "\r<html"],
    [0, "\r\n<html"],
    [0, "\t<html"]
  ]

変更後:

Marcel::MimeType.extend "text/html",
  extensions: %w( html htm ),
  magic: [
    [64, %r{\A\s*<(!DOCTYPE html|html)}mi],
    [-64, %r{</html>\s*\z}mi],
  ]

変更後の定義は2つのルールで構成されます。1つ目はオフセット 64 から \A\s*<(!DOCTYPE html|html) にマッチさせる正規表現で、/mi フラグにより大文字小文字と改行を問いません。2つ目はオフセット -64(末尾から64バイト)から </html>\s*\z にマッチさせる正規表現で、ファイル末尾に閉じタグが存在することをもって確認を取ります。

負のオフセットを扱うために lib/marcel/magic.rbio_seek メソッドも拡張されました。

def self.io_seek(io, offset, buffer)
  return if offset == 0
  offset = io.size + offset if offset < 0
  return if offset < 0

  if io.respond_to?(:seek)
    io.seek(offset, IO::SEEK_CUR)

offset < 0 の場合に io.size + offset で末尾からの絶対位置へ変換し、それでも負になる(ファイルサイズ自体が小さすぎる)場合はシークをスキップします。追加は2行のみで、既存のシーク処理へのオーバーヘッドはありません。

設計判断

「先頭でなく末尾を見る」という発想の転換 が本PRの設計上の要点です。

HTMLコメントが先頭に来る場合、<html> の位置は任意になるため先頭チェックだけでは原理的に網羅できません。一方で、整形式のHTMLドキュメントなら </html> は末尾付近に現れるという不変の構造的特性があります。末尾64バイトを見るルールはこの特性を利用しており、コメントの長さに依存しません。

16個の固定文字列パターンを正規表現2つに集約したことも重要な判断です。大文字小文字の組み合わせや前置ホワイトスペースの種類ごとに列挙する必要がなくなり、定義が宣言的になりました。また、先頭ルールにオフセット 64 を指定することで、短いBOMやメタ情報が先行する一般的なケースにも余裕を持って対応しています。

まとめ

16パターンの固定文字列から正規表現2つへの置き換えは、単なるリファクタリングを超えた設計の改善です。先頭と末尾の両端からHTMLの構造を確認するアプローチにより、コメント長に依存しない堅牢な検出が実現され、同様の誤判定が根本から排除されています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
6ba042fe

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

「総論→各論→結論」の構成が明確です。リード文で要旨を述べ、背景、技術詳細、設計判断、まとめと、論理的な流れで記事が構成されています。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト、PR番号・Issue番号のリンク記法共に正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

「マジックバイト」「オフセット」「正規表現」などの専門用語が適切に使用されており、専門知識を持つエンジニアという対象読者に適合しています。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論→各論の構造を持ち、各段落がトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が守られており、非常に読みやすいです。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内で引用されているコードは、提供されたDiffの内容(変更前、変更後、コンテキスト)と完全に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「マジックバイト検出」「負のオフセット」「シーク」などの技術用語が、文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

正規表現の動作や負のオフセットを扱うための`io_seek`メソッドの変更に関する説明は、コードの変更内容と一致しており、技術的に正確です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内の主張はすべて、PRが修正対象とするIssue #102の内容やDiff内のコード変更によって裏付けられており、ハルシネーションは見られません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#146)、Issue番号(#102)、コード内のオフセット値(64, -64)、パターンの数(16)など、すべての数値・固有名詞が正確です。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトルは、PRの主題である「末尾チェックによるHTML検出の改善」を的確に反映しており、内容と一致しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

バージョンサポート情報やリリース予定など、PR情報に基づかない外部知識の記述はなく、提供された情報源に忠実です。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

時間表現に不自然な点や、PR情報との齟齬は見られません。