[インデックス 13547] ファイルの概要
このコミットは、Go言語の実験的なHTMLパーサーライブラリ exp/html
におけるトークナイザーのバグ修正と改善に関するものです。具体的には、終了タグ(end tag)内に属性が存在する場合のトークン化処理が修正され、特に引用符で囲まれた属性値の中に>
文字が含まれる場合に発生していた早期終了の問題が解決されました。
コミット
commit 9f3b00579eca946337d486776797b78aaf3bc55b
Author: Andrew Balholm <andybalholm@gmail.com>
Date: Wed Aug 1 09:35:02 2012 +1000
exp/html: tokenize attributes of end tags
If an end tag has an attribute that is a quoted string containing '>',
the tokenizer would end the tag prematurely. Now it reads the attributes
on end tags just as it does on start tags, but the high-level interface
still doesn't return them, because their presence is a parse error.
Pass 1 additional test.
R=nigeltao
CC=golang-dev
https://golang.org/cl/6457060
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/9f3b00579eca946337d486776797b78aaf3bc55b
元コミット内容
このコミットは、Go言語の exp/html
パッケージ(HTMLパーサーの実験的な実装)において、HTMLのトークナイザーが終了タグ(例: </script>
) 内に記述された属性を正しく処理できない問題を修正します。
元の問題は、終了タグに属性(例: </script foo="bar">
)が存在し、その属性値が引用符で囲まれており、かつその引用符内に>
文字が含まれる場合(例: </script foo=">" dd>
)に発生していました。この状況下で、トークナイザーは引用符内の>
をタグの終了と誤認識し、タグの解析を途中で終了させてしまうというバグがありました。
このコミットでは、開始タグ(start tag)の属性を読み込むのと同じ方法で、終了タグの属性も読み込むようにトークナイザーのロジックが変更されました。これにより、引用符内の>
文字が正しくエスケープされ、タグが途中で終了する問題が解消されます。
ただし、HTMLの仕様上、終了タグに属性が存在することは構文エラー(parse error)です。そのため、トークナイザーはこれらの属性を内部的に正しく解析するものの、高レベルのインターフェース(Token()
メソッドなど)からはこれらの属性を返さないように設計されています。これは、低レベルのトークン化と高レベルのHTML仕様準拠の分離を意味します。
この変更を検証するために、1つの追加テストケースがパスするようになりました。
変更の背景
HTMLの解析は、WebブラウザやWebスクレイピングツール、HTMLテンプレートエンジンなど、多くのアプリケーションで不可欠な機能です。HTML文書を正確に処理するためには、まず文書を構成する個々の要素(タグ、属性、テキスト、コメントなど)に分解する「トークン化(Tokenization)」のプロセスが非常に重要になります。
このコミットが行われた当時、Go言語の exp/html
パッケージはまだ実験段階であり、HTML5の仕様に準拠した堅牢なパーサーを目指していました。HTML5の仕様は、不正なHTML(malformed HTML)に対しても、どのように処理すべきかという詳細なルールを定めています。
問題の背景には、トークナイザーがHTMLの終了タグの属性を処理する際のロジックの不備がありました。HTMLの仕様では終了タグに属性を持たせることは許可されていませんが、現実のWebページには不正なHTMLが多数存在します。堅牢なパーサーは、このような不正な入力に対してもクラッシュすることなく、仕様に沿ったエラー処理を行いながら、可能な限り解析を続行する必要があります。
特に、引用符で囲まれた属性値の中にタグの終了を示す>
文字が含まれるケースは、トークナイザーが最も注意を払うべきエッジケースの一つです。このバグは、このようなエッジケースにおいて、トークナイザーが誤った状態遷移を起こし、本来のタグの範囲を誤って解釈してしまうことに起因していました。この修正は、より堅牢で仕様に準拠したHTMLパーサーを構築するための重要なステップでした。
前提知識の解説
HTMLの基本構造とタグの種類
- 開始タグ (Start Tag):
<div>
,<p>
,<a href="...">
のように、要素の開始を示すタグ。属性を持つことができます。 - 終了タグ (End Tag):
</div>
,</p>
のように、要素の終了を示すタグ。HTMLの仕様上、終了タグは属性を持つべきではありません。 - 自己終了タグ (Self-Closing Tag):
<img src="...">
,<br/>
のように、開始タグと終了タグが一体となったタグ。HTML5では<img src="">
のように末尾のスラッシュは必須ではありませんが、XML/XHTML互換のために使用されることがあります。
HTMLトークン化 (HTML Tokenization)
HTMLトークン化は、HTML文書を解析する最初の段階です。入力されたバイトストリームを読み込み、意味のある単位(トークン)に分割します。例えば、<p class="intro">Hello</p>
というHTML断片は、以下のようなトークンに分解されます。
StartTagToken
(タグ名:p
, 属性:class="intro"
)TextToken
(テキスト:Hello
)EndTagToken
(タグ名:p
)
トークナイザーは、HTMLの仕様に定義された複雑な状態機械(state machine)に従って動作します。各文字を読み込むたびに、現在の状態と読み込んだ文字に基づいて次の状態に遷移し、適切なトークンを生成します。
HTML5パーシングアルゴリズム
HTML5の仕様は、WebブラウザがHTML文書をどのように解析すべきかについて、非常に詳細なアルゴリズムを定義しています。これには、不正なHTMLに対するエラー処理のルールも含まれます。重要な点として、HTML5のパーシングアルゴリズムは、たとえ構文エラーがあっても、可能な限り文書の解析を続行し、DOMツリーを構築しようとします。
このコミットで扱われた「終了タグの属性」は、HTML5の仕様では「パースエラー」と見なされます。しかし、パースエラーであっても、トークナイザーはそれらの属性を正しく「消費」し、その後の文書の解析に影響を与えないようにする必要があります。高レベルのパーサーは、これらの不正な属性を無視してDOMツリーを構築します。
Go言語の exp/html
パッケージ
exp/html
は、Go言語でHTML5の仕様に準拠したパーサーを実装するための実験的なパッケージでした。このパッケージは、HTML文書をトークンストリームに変換する Tokenizer
と、そのトークンストリームからDOMツリーを構築する Parser
の2つの主要なコンポーネントで構成されています。このコミットは、そのうちの Tokenizer
コンポーネントの改善に焦点を当てています。
技術的詳細
このコミットの核心は、HTMLトークナイザーの内部ロジック、特にタグの属性を解析する部分の改善にあります。
既存の問題点とトークナイザーの挙動
元の Tokenizer
は、開始タグと終了タグの属性解析ロジックが異なっていました。
readStartTag
は属性を読み込むループを持っていました。readEndTag
は属性を読み込むロジックを持っておらず、タグ名が読み込まれた後、すぐに>
文字を探してタグの終了を判断していました。
この違いが、問題の根本原因でした。例えば、</script foo=">" dd>
のような不正なHTMLが入力された場合、readEndTag
は foo="
の後の >
をタグの終了と誤解し、dd>
の部分をタグ外のテキストとして扱ってしまう可能性がありました。これは、引用符で囲まれた文字列内の>
を正しくエスケープ解除できない、またはそのコンテキストを認識できないことに起因します。
修正アプローチ:ロジックの共通化と堅牢化
このコミットでは、以下の主要な変更が行われました。
-
readTag
関数の導入:readStartTag
とreadEndTag
の両方から呼び出される新しいプライベート関数readTag
が導入されました。readTag
関数は、タグ名とそれに続く属性キー/値ペアを読み込む共通のロジックを含んでいます。これにより、開始タグと終了タグの属性解析ロジックが統一され、重複が排除されました。- この関数は、属性値が引用符で囲まれている場合でも、引用符内の
>
文字を正しく処理し、属性の終了を正確に判断できるようになりました。ループ内でz.readTagAttrKey()
とz.readTagAttrVal()
を呼び出し、属性のキーと値を抽出します。 - 属性の読み込みは、
>
文字に遭遇するか、エラーが発生するまで続行されます。
-
高レベルインターフェースの挙動維持:
Tokenizer.Token()
メソッドは、トークナイザーが生成した低レベルのトークンを、より高レベルのhtml.Token
構造体に変換して返します。- このメソッドは、
EndTagToken
の場合には、たとえreadTag
が属性を解析していても、t.Attr
スライスに属性を追加しないように明示的に条件 (z.tt != EndTagToken
) が追加されました。 - これは、HTMLの仕様に準拠するためです。仕様では終了タグに属性は許可されておらず、その存在はパースエラーであるため、高レベルのインターフェースからはこれらの不正な属性を公開しないという設計判断です。これにより、低レベルの堅牢なトークン化と、高レベルの仕様準拠の分離が実現されています。
テストケースの追加
src/pkg/exp/html/testlogs/scriptdata01.dat.log
ファイルの変更は、この修正が正しく機能することを示す回帰テストです。
FAIL "FOO<script></script foo=\\\">\\\" dd>BAR"
がPASS "FOO<script></script foo=\\\">\\\" dd>BAR"
に変更されました。- これは、
</script>
終了タグにfoo=">"
という属性が含まれるケースで、以前はトークナイザーが>
をタグの終了と誤解して失敗していたものが、修正によって正しく解析されるようになったことを示しています。
この修正により、exp/html
パッケージは、より多くの不正なHTML入力に対しても堅牢に動作し、HTML5のパーシングアルゴリズムにさらに近づきました。
コアとなるコードの変更箇所
変更は主に src/pkg/exp/html/token.go
と src/pkg/exp/html/testlogs/scriptdata01.dat.log
の2ファイルです。
src/pkg/exp/html/token.go
-
readStartTag
関数の変更:- 以前は
readStartTag
内に直接記述されていた属性読み込みのループが削除され、新しく導入されたz.readTag()
の呼び出しに置き換えられました。 - これにより、開始タグの属性解析も
readTag
に委譲されるようになりました。
--- a/src/pkg/exp/html/token.go +++ b/src/pkg/exp/html/token.go @@ -468,29 +468,10 @@ loop: // readStartTag reads the next start tag token. The opening "<a>" has already // been consumed, where 'a' means anything in [A-Za-z]. func (z *Tokenizer) readStartTag() TokenType { - z.attr = z.attr[:0] - z.nAttrReturned = 0 - // Read the tag name and attribute key/value pairs. - z.readTagName() - if z.skipWhiteSpace(); z.err != nil { + z.readTag() + if z.err != nil && len(z.attr) == 0 { return ErrorToken } - for { - c := z.readByte() - if z.err != nil || c == '>' { - break - } - z.raw.end-- - z.readTagAttrKey() - z.readTagAttrVal() - // Save pendingAttr if it has a non-empty key. - if z.pendingAttr[0].start != z.pendingAttr[0].end { - z.attr = append(z.attr, z.pendingAttr) - } - if z.skipWhiteSpace(); z.err != nil { - break - } - } // Several tags flag the tokenizer's next token as raw. c, raw := z.buf[z.data.start], false if 'A' <= c && c <= 'Z' {
- 以前は
-
readEndTag
関数の削除とreadTag
関数の導入:- 既存の
readEndTag
関数が削除され、そのロジックが新しいreadTag
関数に統合されました。 readTag
関数は、タグ名と属性を読み込む共通のロジックを提供します。
--- a/src/pkg/exp/html/token.go +++ b/src/pkg/exp/html/token.go @@ -520,16 +501,30 @@ func (z *Tokenizer) readStartTag() TokenType { return StartTagToken } -// readEndTag reads the next end tag token. The opening "</a" has already -// been consumed, where 'a' means anything in [A-Za-z]. -func (z *Tokenizer) readEndTag() { +// readTag reads the next tag token. The opening "<a" or "</a" has already been +// consumed, where 'a' means anything in [A-Za-z]. +func (z *Tokenizer) readTag() { z.attr = z.attr[:0] z.nAttrReturned = 0 + // Read the tag name and attribute key/value pairs. z.readTagName() + if z.skipWhiteSpace(); z.err != nil { + return + } for { c := z.readByte() if z.err != nil || c == '>' { - return + break + } + z.raw.end-- + z.readTagAttrKey() + z.readTagAttrVal() + // Save pendingAttr if it has a non-empty key. + if z.pendingAttr[0].start != z.pendingAttr[0].end { + z.attr = append(z.attr, z.pendingAttr) + } + if z.skipWhiteSpace(); z.err != nil { + break } } }
- 既存の
-
Tokenizer.Token()
メソッドの変更:EndTagToken
の場合でも、TagName()
を呼び出してタグ名を読み込むようになりました。- 属性の追加ロジックが
z.tt != EndTagToken
の条件で囲まれ、終了タグの属性は高レベルインターフェースからは返されないように明示されました。
--- a/src/pkg/exp/html/token.go +++ b/src/pkg/exp/html/token.go @@ -858,22 +853,18 @@ func (z *Tokenizer) Token() Token { switch z.tt { case TextToken, CommentToken, DoctypeToken: t.Data = string(z.Text()) - case StartTagToken, SelfClosingTagToken: - var attr []Attribute + case StartTagToken, SelfClosingTagToken, EndTagToken: name, moreAttr := z.TagName() - for moreAttr { - var key, val []byte - key, val, moreAttr = z.TagAttr() - attr = append(attr, Attribute{"", atom.String(key), string(val)}) - } - if a := atom.Lookup(name); a != 0 { - t.DataAtom, t.Data = a, a.String() - } else { - t.DataAtom, t.Data = 0, string(name) + // Since end tags should not have attributes, the high-level tokenizer + // interface will not return attributes for an end tag token even if + // it looks like </br foo="bar">. + if z.tt != EndTagToken { + for moreAttr { + var key, val []byte + key, val, moreAttr = z.TagAttr() + t.Attr = append(t.Attr, Attribute{"", atom.String(key), string(val)}) + } } - t.Attr = attr - case EndTagToken: - name, _ := z.TagName() if a := atom.Lookup(name); a != 0 { t.DataAtom, t.Data = a, a.String() } else {
src/pkg/exp/html/testlogs/scriptdata01.dat.log
-
特定のテストケースの結果が
FAIL
からPASS
に変更されました。--- a/src/pkg/exp/html/testlogs/scriptdata01.dat.log +++ b/src/pkg/exp/html/testlogs/scriptdata01.dat.log @@ -4,7 +4,7 @@ PASS "FOO<script></script >BAR" PASS "FOO<script></script/>BAR" PASS "FOO<script></script/ >BAR" PASS "FOO<script type=\"text/plain\"></scriptx>BAR" -FAIL "FOO<script></script foo=\">\" dd>BAR" +PASS "FOO<script></script foo=\">\" dd>BAR" PASS "FOO<script>'<'</script>BAR" PASS "FOO<script>'<!'</script>BAR" PASS "FOO<script>'<!-'</script>BAR"
コアとなるコードの解説
このコミットの最も重要な変更は、readTag
という新しいヘルパー関数を導入し、タグの属性を解析するロジックを共通化した点です。
func (z *Tokenizer) readTag()
この関数は、開始タグ (<tag>
) と終了タグ (</tag>
) の両方で、タグ名に続く属性を読み込む責任を負います。
z.attr = z.attr[:0]
とz.nAttrReturned = 0
: 既存の属性スライスをクリアし、属性が返されていない状態にリセットします。これは、新しいタグの解析を開始する前に、以前のタグの情報を確実に消去するために重要です。z.readTagName()
: まずタグ名を読み込みます。これは、<div>
のdiv
や</p>
のp
の部分です。if z.skipWhiteSpace(); z.err != nil { return }
: タグ名の後に続く空白文字をスキップします。エラーが発生した場合は処理を中断します。- 属性読み込みループ:
for { c := z.readByte() if z.err != nil || c == '>' { break } z.raw.end-- z.readTagAttrKey() z.readTagAttrVal() // Save pendingAttr if it has a non-empty key. if z.pendingAttr[0].start != z.pendingAttr[0].end { z.attr = append(z.attr, z.pendingAttr) } if z.skipWhiteSpace(); z.err != nil { break } }
- このループは、
>
文字(タグの終了を示す)に遭遇するか、読み込みエラーが発生するまで繰り返されます。 c := z.readByte()
: 次の文字を読み込みます。if z.err != nil || c == '>' { break }
: エラーが発生したか、>
文字に遭遇したらループを終了します。これが、引用符内の>
を誤ってタグの終了と判断していたバグの修正箇所です。readTagAttrVal
が引用符内の>
を正しく処理するため、この>
チェックは属性値の外部でのみ有効になります。z.raw.end--
: これは、現在の文字(c
)が属性の一部ではないことを示すために、内部的な生データ範囲を調整している可能性があります。z.readTagAttrKey()
: 属性のキー(例:href
)を読み込みます。z.readTagAttrVal()
: 属性の値(例:"https://example.com"
)を読み込みます。この関数が、引用符で囲まれた文字列内の>
を正しく処理するロジックを含んでいます。if z.pendingAttr[0].start != z.pendingAttr[0].end { z.attr = append(z.attr, z.pendingAttr) }
: 読み込んだ属性が空でない場合、それを内部の属性リストz.attr
に追加します。if z.skipWhiteSpace(); z.err != nil { break }
: 属性の後に続く空白文字をスキップし、次の属性の読み込みに備えます。
- このループは、
func (z *Tokenizer) Token() Token
の変更
この関数は、トークナイザーの外部インターフェースであり、解析されたトークンを html.Token
構造体として返します。
case StartTagToken, SelfClosingTagToken, EndTagToken:
: 開始タグ、自己終了タグ、終了タグのいずれの場合も、共通してname, moreAttr := z.TagName()
を呼び出してタグ名を取得するようになりました。- 終了タグの属性の扱い:
このコードブロックが重要です。if z.tt != EndTagToken { for moreAttr { var key, val []byte key, val, moreAttr = z.TagAttr() t.Attr = append(t.Attr, Attribute{"", atom.String(key), string(val)}) } }
z.tt != EndTagToken
という条件により、現在のトークンが終了タグでない場合にのみ、解析された属性 (z.TagAttr()
) を高レベルのhtml.Token
のt.Attr
フィールドに追加します。 これにより、低レベルのトークナイザーは終了タグの属性も正しく解析して消費しますが、高レベルのインターフェースはHTML仕様に準拠し、終了タグの属性を返さないという「分離」が実現されています。これは、不正なHTMLに対する堅牢性と、仕様準拠のバランスを取るための設計です。
これらの変更により、トークナイザーはより堅牢になり、不正なHTML入力に対しても正しく動作するようになりました。
関連リンク
- Go言語の
html
パッケージ (現在の安定版): https://pkg.go.dev/golang.org/x/net/htmlexp/html
は後にgolang.org/x/net/html
に移動し、Goの標準ライブラリの一部となりました。
- HTML5仕様 (W3C): https://www.w3.org/TR/html5/
- 特に「Tokenization」セクションは、HTMLパーサーの動作を理解する上で重要です。
参考にした情報源リンク
- HTML5 Parsing Algorithm: https://html.spec.whatwg.org/multipage/parsing.html#tokenization
- Go言語の
x/net/html
パッケージのドキュメント: https://pkg.go.dev/golang.org/x/net/html - Go言語のコードレビューシステム (Gerrit): https://golang.org/cl/6457060 (コミットメッセージに記載されている変更リストのURL)
- HTMLの終了タグに属性を記述することの是非に関する議論 (Stack Overflowなど):
- Go言語の
exp
パッケージの歴史と目的に関する情報 (Goの公式ブログやメーリングリストなど)- https://go.dev/blog/go-modules-and-beyond (Goモジュールに関する記事ですが、
x
リポジトリの役割について触れられています) - https://groups.google.com/g/golang-dev (golang-devメーリングリストのアーカイブ)
- https://go.dev/blog/go-modules-and-beyond (Goモジュールに関する記事ですが、