Dev Study
← Rust で小さなブラウザを作る コース

16. 壊れた HTML を直す — 閉じ忘れ、迷子の終了タグ、無い html / head / body

HTML のは壊れた入力で止まりません。HTML Standard は「」と呼ぶ状況のそれぞれについて、エラーを記録したうえで何をするかを定めています。だから <p>one<p>two は 2 つの兄弟の <p> になり、<p>a</b>b</p> の </b> は無視され、<p> だけの文書にも html / head / body ができます。

参照実装の InBody の腕には、その規則の一部が入っています。開始タグが来たとき、それが div / h1 / p / ul などの系(CLOSES_P)で、スタックに <p> が開いていたら、先に <p> を閉じる(pop_until("p"))。これが <p>one<p>two を兄弟にする規則です。終了タグが来たとき、スタックにそのがあれば、そこまで全部下ろす。</div> が来たら中で開いたままの <p> も一緒に閉じます。無ければ無視(parse error)。(br / img / meta など 13 個、HTML Standard 13.1.2)は、置いた直後にスタックから下ろして子を持たせません。<div/> の / は無視され、div は開いたままです(仕様どおり。テスト self_closing_slash_is_ignored_on_non_void_elements)。

この実装が省いた規則もたくさんあります。本物の「has an element in scope」はスタック全体ではなく、table や template の境界までしか見ません。<table> の中に迷い込んだ要素を外に出す foster parenting、<b><i></b></i> のような交差した入れ子を直す 、<li> や <dd> の暗黙の閉じ、などです。どれも「仕様の該当節の規則を InBody の腕に足す」形で、構造は変わりません。

下のコードは構文解析器の step() 全体です。InBody の腕とその下の補助関数(switch / insert_element / flush_text / has_in_scope / pop_until)を追ってください。前のレッスンの実行結果で「<p> の閉じ忘れ」「対応しない終了タグ」「</div> で中の <p> も閉じる」の 3 例が、この腕のどこで処理されたかが対応づけられます。

本物のブラウザでは: 閉じ忘れや入れ違いの回復も、仕様の手順をそのまま持ちます。たとえば <b><p></b> のような書式要素の入れ違いは仕様の「adoption agency algorithm」で直し、 には HTMLTreeBuilder::CallTheAdoptionAgency() があり、「このアルゴリズムは N^2 なので反復回数を制限する」と注釈があります。この回の『p を閉じる要素』の表(CLOSES_P)は、その手順群のうち最初の 1 つです。壊れた HTML に対して全ブラウザが同じ木を作るのは、回復手順まで仕様に書いてあるからです。

開発のどこに活かすか: この「止まらずに直す」規則は、セキュリティの話に直結します。サニタイザが安全だと判断した HTML と、ブラウザが実際に組み立てる木がずれる(parsing differential)と、XSS の抜け道になります。自前で HTML を文字列操作で組み立てたり切り詰めたりしているコードは、この規則を知らずに書かれていることがほとんどです。業務では「HTML は文字列ではなく木として扱う(テンプレートエンジンと API に任せる)」を徹底する根拠になります。

点線の付いた語はタップすると説明が出ます。用語集を開く →

サンプルコード(フレームワーク環境が必要なため表示のみ)

// src/html/parser.rs の step()(全モード)と補助関数
fn step(&mut self, token: &HtmlToken) -> bool {
    use self::HtmlToken::*;
    use self::InsertionMode::*;

    match self.mode {
        // 13.2.6.4.1 "initial": 本物は DOCTYPE を見る。この実装は DOCTYPE を字句解析で捨てているので
        // 空白を読み飛ばし、それ以外が来たら before html へ
        Initial => match token {
            Char(c) if c.is_ascii_whitespace() => false,
            _ => {
                self.switch(BeforeHtml, token);
                true
            }
        },

        // 13.2.6.4.2 "before html": <html> があればそれを、無ければ暗黙の <html> を作る
        BeforeHtml => match token {
            Char(c) if c.is_ascii_whitespace() => false,
            StartTag { tag, attributes, .. } if tag == "html" => {
                self.insert_element("html", attributes.clone());
                self.switch(BeforeHead, token);
                false
            }
            EndTag { tag } if !["head", "body", "html", "br"].contains(&tag.as_str()) => false,
            _ => {
                self.insert_element("html", vec![]);
                self.switch(BeforeHead, token);
                true
            }
        },

        // 13.2.6.4.3 "before head"
        BeforeHead => match token {
            Char(c) if c.is_ascii_whitespace() => false,
            StartTag { tag, attributes, .. } if tag == "head" => {
                self.insert_element("head", attributes.clone());
                self.switch(InHead, token);
                false
            }
            EndTag { tag } if !["head", "body", "html", "br"].contains(&tag.as_str()) => false,
            _ => {
                self.insert_element("head", vec![]);
                self.switch(InHead, token);
                true
            }
        },

        // 13.2.6.4.4 "in head": style / script / title は中身を文字として集める。meta / link は子を持たない
        InHead => match token {
            Char(c) if c.is_ascii_whitespace() => {
                self.text_buffer.push(*c);
                false
            }
            StartTag { tag, attributes, .. } if HEAD_TEXT_ELEMENTS.contains(&tag.as_str()) => {
                self.insert_element(tag, attributes.clone());
                self.original_mode = InHead;
                self.switch(Text, token);
                false
            }
            StartTag { tag, attributes, .. } if VOID_ELEMENTS.contains(&tag.as_str()) => {
                self.insert_element(tag, attributes.clone());
                self.open_elements.pop();
                false
            }
            EndTag { tag } if tag == "head" => {
                self.open_elements.pop();
                self.switch(AfterHead, token);
                false
            }
            EndTag { tag } if !["body", "html", "br"].contains(&tag.as_str()) => false,
            _ => {
                // head の中に書けないものが来た = head はここで終わり
                self.open_elements.pop();
                self.switch(AfterHead, token);
                true
            }
        },

        // 13.2.6.4.6 "after head": <body> があればそれを、無ければ暗黙の <body> を作る
        AfterHead => match token {
            Char(c) if c.is_ascii_whitespace() => {
                self.text_buffer.push(*c);
                false
            }
            StartTag { tag, attributes, .. } if tag == "body" => {
                self.insert_element("body", attributes.clone());
                self.switch(InBody, token);
                false
            }
            StartTag { tag, attributes, .. } if HEAD_TEXT_ELEMENTS.contains(&tag.as_str()) => {
                // 本物は head をスタックに戻して "in head" の規則で処理する。ここでは直接 head に入れる
                if let Some(head) = self.find_head() {
                    let el = Node::element(tag, attributes.clone());
                    Node::append_child(&head, el.clone());
                    self.open_elements.push(el);
                    self.original_mode = AfterHead;
                    self.switch(Text, token);
                }
                false
            }
            EndTag { tag } if !["body", "html", "br"].contains(&tag.as_str()) => false,
            _ => {
                self.insert_element("body", vec![]);
                self.switch(InBody, token);
                true
            }
        },

        // 13.2.6.4.7 "in body": 一番長い節。ここでは「要素を入れて積む」「終了タグで積んだ分を下ろす」が芯
        InBody => match token {
            Char(c) => {
                self.text_buffer.push(*c);
                false
            }
            StartTag { tag, attributes, .. } => {
                match tag.as_str() {
                    "html" | "head" | "body" => return false, // 2 回目は無視(parse error)
                    _ => {}
                }
                // <p> が開いたまま <div> や <h1> が来たら、<p> はそこで閉じる(仕様 "close a p element")
                if CLOSES_P.contains(&tag.as_str()) && self.has_in_scope("p") {
                    self.pop_until("p");
                }
                self.insert_element(tag, attributes.clone());
                // void 要素は子を持たないので、置いた直後に下ろす。
                // <div/> のように void 以外に付いた "/"(self_closing)は、仕様では無視される(parse error)。
                // だから <div/>x の x は div の中に入る
                if VOID_ELEMENTS.contains(&tag.as_str()) {
                    self.open_elements.pop();
                }
                false
            }
            EndTag { tag } => {
                match tag.as_str() {
                    "body" => {
                        if self.has_in_scope("body") {
                            self.switch(AfterBody, token);
                        }
                        false
                    }
                    "html" => {
                        if self.has_in_scope("body") {
                            self.switch(AfterBody, token);
                            return true;
                        }
                        false
                    }
                    "br" => {
                        // </br> は <br> として扱う(仕様どおり)
                        self.insert_element("br", vec![]);
                        self.open_elements.pop();
                        false
                    }
                    _ => {
                        if self.has_in_scope(tag) {
                            self.pop_until(tag);
                        }
                        // 対応する開始タグが無い終了タグは無視(parse error)
                        false
                    }
                }
            }
            Eof => false,
        },

        // 13.2.6.4.8 "text": 終了タグが来るまで文字を集める
        Text => match token {
            Char(c) => {
                self.text_buffer.push(*c);
                false
            }
            EndTag { .. } => {
                self.open_elements.pop();
                let back = self.original_mode;
                self.switch(back, token);
                false
            }
            _ => {
                // EOF(閉じ忘れ): 要素を閉じて元のモードで EOF を処理し直す
                self.open_elements.pop();
                let back = self.original_mode;
                self.switch(back, token);
                true
            }
        },

        // 13.2.6.4.19 "after body"
        AfterBody => match token {
            Char(c) if c.is_ascii_whitespace() => {
                self.text_buffer.push(*c);
                false
            }
            EndTag { tag } if tag == "html" => {
                self.switch(AfterAfterBody, token);
                false
            }
            Eof => false,
            _ => {
                // </body> の後に中身が来た(parse error): body に戻って続ける
                self.switch(InBody, token);
                true
            }
        },

        // 13.2.6.4.22 "after after body"
        AfterAfterBody => match token {
            Char(c) if c.is_ascii_whitespace() => {
                self.text_buffer.push(*c);
                false
            }
            Eof => false,
            _ => {
                self.switch(InBody, token);
                true
            }
        },
    }
}

fn switch(&mut self, to: InsertionMode, because_of: &HtmlToken) {
    self.trace
        .push(format!("{:?} → {:?}  ({})", self.mode, to, describe(because_of)));
    self.mode = to;
}

手元で動かす(配布物)

図やコードに出てくる用語(タップで説明)

サンプルコードで身につける(3本) →

学んだ概念を、素朴な例から実務寄りの例まで3つの具体例で確認します。

公式ドキュメントで詳しく ↗