Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 15740] ファイルの概要

このコミットは、Go言語の image/jpeg パッケージにおけるJPEGデコードの堅牢性を向上させるものです。具体的には、JPEGストリーム内に存在する余分な(extraneous)データを libjpeg と同様に無視するように変更し、不正な形式のJPEGファイルでもデコードを可能にすることで、Issue #4705で報告された問題を修正しています。

コミット

image/jpeg: libjpeg と同様に、余分なデータを無視するように変更。

Issue #4705 を修正。

libjpeg は、余分なバイトが多数ある場合は標準エラー出力に警告を出力しますが、ハフマンデコード用の int32 ビットバッファに収まる程度の余分なバイトであれば、警告を出力しない場合があります。Issue #4705 の画像を作成したエンコーダが、厳密には無効な JPEG を生成していることに気づかなかったのは、このためだと推測されます。この問題に添付された画像には、2バイトの余分なデータが含まれています。

例えば、以下のプログラムを libjpeg の djpeg プログラムにパイプすると、N が 20 であっても「18 extraneous bytes」という警告が出力されます。

$ cat main.go
package main

import (
        "bytes"
        "image"
        "image/color"
        "image/jpeg"
        "os"
)

const N = 20

func main() {
        // 1x1 の赤い画像をエンコードする。
        m := image.NewRGBA(image.Rect(0, 0, 1, 1))
        m.Set(0, 0, color.RGBA{255, 0, 0, 255})
        buf := new(bytes.Buffer)
        jpeg.Encode(buf, m, nil)
        b := buf.Bytes()
        // 最後の "\xff\xd9" EOI マーカーを削除する。
        b = b[:len(b)-2]
        // SOS データに N 個のダミー 0x80 バイトを追加する。
        for i := 0; i < N; i++ {
                b = append(b, 0x80)
        }
        // "\xff\xd9" EOI マーカーを元に戻す。
        b = append(b, 0xff, 0xd9)
        os.Stdout.Write(b)
}
$ go run main.go | djpeg /dev/stdin > /tmp/foo.pnm
Corrupt JPEG data: 18 extraneous bytes before marker 0xd9

結果として得られる /tmp/foo.pnm は、完全に正常な 1x1 の赤い画像です。

R=r
CC=golang-dev
https://golang.org/cl/7750043

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/a3d1c1bdce6101212465a59ef24107402d920dda

元コミット内容

commit a3d1c1bdce6101212465a59ef24107402d920dda
Author: Nigel Tao <nigeltao@golang.org>
Date:   Wed Mar 13 10:44:45 2013 +1100

    image/jpeg: ignore extraneous data, the same as what libjpeg does.
    
    Fixes #4705.
    
    Note that libjpeg will print a warning to stderr if there are many
    extraneous bytes, but can be silent if the extraneous bytes can fit
    into its int32 bit-buffer for Huffman decoding. I'm guessing that
    this is why whatever encoder that produced the image filed for issue
    4705 did not realize that they are, strictly speaking, generating an
    invalid JPEG. That issue's attached image has two extraneous bytes.
    
    For example, piping the program below into libjpeg's djpeg program
    will print an "18 extraneous bytes" warning, even though N == 20.
    
    $ cat main.go
    package main
    
    import (
            "bytes"
            "image"
            "image/color"
            "image/jpeg"
            "os"
    )
    
    const N = 20
    
    func main() {
            // Encode a 1x1 red image.
            m := image.NewRGBA(image.Rect(0, 0, 1, 1))
            m.Set(0, 0, color.RGBA{255, 0, 0, 255})
            buf := new(bytes.Buffer)
            jpeg.Encode(buf, m, nil)
            b := buf.Bytes()
            // Strip the final "\xff\xd9" EOI marker.
            b = b[:len(b)-2]
            // Append N dummy 0x80 bytes to the SOS data.
            for i := 0; i < N; i++ {
                    b = append(b, 0x80)
            }
            // Put back the "\xff\xd9" EOI marker.
            b = append(b, 0xff, 0xd9)
            os.Stdout.Write(b)
    }
    $ go run main.go | djpeg /dev/stdin > /tmp/foo.pnm
    Corrupt JPEG data: 18 extraneous bytes before marker 0xd9
    
    The resultant /tmp/foo.pnm is a perfectly good 1x1 red image.
    
    R=r
    CC=golang-dev
    https://golang.org/cl/7750043

変更の背景

この変更の背景には、Go言語の image/jpeg パッケージが、一部の「厳密には不正な」JPEGファイルをデコードできないという問題がありました。具体的には、JPEGストリームのマーカー間に予期せぬデータ(extraneous data)が存在する場合、Goのデコーダはエラーを返していました。

しかし、広く使われている libjpeg ライブラリは、このような余分なデータが存在しても、それを無視してデコードを続行する「寛容な」挙動を示します。Issue #4705では、このような libjpeg では問題なくデコードできるが、Goの image/jpeg ではエラーとなるJPEGファイルが報告されました。これは、一部のJPEGエンコーダが、厳密な仕様に準拠しない形でファイルを生成している可能性があることを示唆しています。

この挙動の違いは、Goの image/jpeg パッケージの互換性と実用性に影響を与えていました。多くの既存のJPEGファイルが libjpeg の寛容なデコードに依存しているため、Goのデコーダがそれらを拒否すると、ユーザーは不便を感じることになります。このコミットは、libjpeg の挙動に合わせることで、より多くのJPEGファイルをGoで処理できるようにし、実用的な互換性を高めることを目的としています。

コミットメッセージの例では、意図的にSOS (Start of Scan) マーカーとEOI (End of Image) マーカーの間にダミーバイトを挿入したJPEGファイルを作成し、libjpegdjpeg コマンドが警告を出しつつも正常にデコードできることを示しています。この挙動をGoのデコーダでも再現することが、この変更の主要な動機です。

前提知識の解説

JPEGファイルフォーマットの基本

JPEG (Joint Photographic Experts Group) は、主に写真などの連続階調画像を圧縮するための標準的な画像フォーマットです。JPEGファイルは、複数の「セグメント」で構成されており、各セグメントは「マーカー」と呼ばれる2バイトのコードで始まります。マーカーは 0xFF に続く1バイトのコードで表現されます。

主要なマーカーには以下のようなものがあります。

  • SOI (Start of Image): 0xFFD8 - 画像データの開始を示します。
  • APPn (Application-specific): 0xFFE0 から 0xFFEF - アプリケーション固有のデータ(Exif情報など)を含みます。
  • DQT (Define Quantization Table): 0xFFDB - 量子化テーブルを定義します。
  • SOF (Start of Frame): 0xFFC0 から 0xFFCF - フレームの開始、画像サイズ、コンポーネント数などを定義します。
  • DHT (Define Huffman Table): 0xFFC4 - ハフマンテーブルを定義します。
  • SOS (Start of Scan): 0xFFDA - スキャンデータの開始を示します。このセグメントの後に実際の圧縮された画像データが続きます。
  • EOI (End of Image): 0xFFD9 - 画像データの終了を示します。

JPEGデコーダは、これらのマーカーを読み取りながら、ファイルの構造を解析し、圧縮された画像データをデコードしていきます。

ハフマン符号化とビットバッファ

JPEGの画像データは、ハフマン符号化という可変長符号化方式で圧縮されています。デコーダは、ビットストリームからハフマンコードを読み取り、対応するシンボル(DCT係数など)に変換します。この際、デコーダは通常、ビットバッファと呼ばれる内部バッファを使用して、ビット単位でデータを読み込みます。

libjpeg のような実装では、このビットバッファが一定量のデータを先読みすることがあります。もし、SOSデータ(圧縮された画像データ)の終わりに、EOIマーカーの前に予期せぬバイトが存在する場合、ビットバッファがそれらのバイトを読み込んでしまう可能性があります。

エスケープシーケンス 0xFF00

JPEGデータストリーム内で、データバイトが 0xFF となる場合、それはマーカーと誤解される可能性があります。これを避けるため、JPEG仕様では、データバイトが 0xFF の場合は、その後に 0x00 を挿入して 0xFF00 というシーケンスとしてエンコードするルールがあります。デコーダは 0xFF00 を読み取った場合、0xFF データバイトとして解釈し、0x00 は無視します。

libjpeg の挙動

libjpeg は、JPEGデコードのデファクトスタンダードとも言えるライブラリです。その設計思想の一つに「堅牢性」があります。これは、厳密なJPEG仕様に完全に準拠していないファイルであっても、可能な限りデコードを試みるというものです。

コミットメッセージにもあるように、libjpeg は以下のような挙動を示します。

  1. マーカー間の余分なデータ: マーカー(特にSOSとEOIの間)に予期せぬデータが存在する場合、libjpeg はそれを「extraneous bytes」(余分なバイト)として扱い、警告(JWRN_EXTRANEOUS_DATA)を出力しつつも、デコードを続行します。
  2. ハフマンデコード中の先読み: jdhuff.cjpeg_fill_bit_buffer のような関数は、ハフマンデコードのために可能な限り多くのバイトを読み込みます。これにより、スキャンデータの終わりを超えて、場合によってはEOIマーカーまで読み込んでしまうことがあります。この際、読み込んだデータの中にマーカーが含まれていれば、それを「戻す」処理が行われますが、非マーカーデータは戻されず、結果として少量の余分な非マーカーバイトが「黙って」無視されることがあります。
  3. 0xFF フィルバイト: JPEG仕様 (B.1.1.2) では、任意のマーカーの前に任意の数の 0xFF フィルバイトを置くことができるとされています。libjpeg はこれらのフィルバイトも適切に処理します。

このコミットは、Goの image/jpeg パッケージが、これらの libjpeg の寛容な挙動を模倣するように変更することで、より多くの「実世界の」JPEGファイルを扱えるようにすることを目指しています。

技術的詳細

Goの image/jpeg パッケージのデコーダは、JPEGストリームを読み進める際に、常に 0xFF バイトがマーカーの開始を示すことを期待していました。しかし、実際のJPEGファイルには、この 0xFF バイトの前に、あるいはマーカー間に、予期せぬデータ(extraneous data)が含まれることがあります。従来のGoのデコーダは、このような状況に遭遇すると FormatError("missing 0xff marker start") を返してデコードを中断していました。

このコミットの主要な変更点は、reader.go 内のマーカー読み込みロジックを修正し、libjpeg と同様に、0xFF バイトが見つかるまで余分なデータを読み飛ばすようにしたことです。

具体的には、d.tmp[0]0xFF でない場合にエラーを返すのではなく、d.tmp[0]0xFF になるまでバイトを読み進めるループが導入されました。このループ内で読み飛ばされるバイトは、厳密にはフォーマットエラーですが、libjpeg が警告を出しつつもデコードを続行するのと同様に、Goのデコーダもこれを許容します。

さらに、0xFF00 というシーケンスがデータストリーム中に現れた場合、これは 0xFF データバイトのエスケープシーケンスであり、0x00 は無視されるべきです。従来のデコーダは 0xFF00 をマーカーとして誤解釈する可能性がありましたが、この変更により、0xFF の後に 0x00 が続く場合は、それを余分なデータとして扱い、読み飛ばすように修正されました。これにより、エスケープされた 0xFF データバイトが正しく処理されるようになります。

この変更は、reader_test.goTestExtraneousData という新しいテストケースを追加することで検証されています。このテストは、意図的にSOSマーカーとEOIマーカーの間にランダムな余分なデータを挿入したJPEGストリームを作成し、それがGoのデコーダによってエラーなくデコードされ、元の画像とほぼ同じ結果が得られることを確認します。これにより、デコーダが余分なデータを適切に無視できるようになったことが保証されます。

また、writer_test.go では、averageDelta 関数が独立したヘルパー関数として抽出され、コードの重複が解消されています。これは直接的な機能変更ではありませんが、テストコードの品質向上に貢献しています。

コアとなるコードの変更箇所

変更の中心は src/pkg/image/jpeg/reader.godecode メソッド内のマーカー読み込み部分です。

--- a/src/pkg/image/jpeg/reader.go
+++ b/src/pkg/image/jpeg/reader.go
@@ -245,10 +245,38 @@ func (d *decoder) decode(r io.Reader, configOnly bool) (image.Image, error) {
 		if err != nil {
 			return nil, err
 		}
-		if d.tmp[0] != 0xff {
-			return nil, FormatError("missing 0xff marker start")
+		for d.tmp[0] != 0xff {
+			// Strictly speaking, this is a format error. However, libjpeg is
+			// liberal in what it accepts. As of version 9, next_marker in
+			// jdmarker.c treats this as a warning (JWRN_EXTRANEOUS_DATA) and
+			// continues to decode the stream. Even before next_marker sees
+			// extraneous data, jpeg_fill_bit_buffer in jdhuff.c reads as many
+			// bytes as it can, possibly past the end of a scan's data. It
+			// effectively puts back any markers that it overscanned (e.g. an
+			// "\xff\xd9" EOI marker), but it does not put back non-marker data,
+			// and thus it can silently ignore a small number of extraneous
+			// non-marker bytes before next_marker has a chance to see them (and
+			// print a warning).
+			//
+			// We are therefore also liberal in what we accept. Extraneous data
+			// is silently ignored.
+			//
+			// This is similar to, but not exactly the same as, the restart
+			// mechanism within a scan (the RST[0-7] markers).
+			//
+			// Note that extraneous 0xff bytes in e.g. SOS data are escaped as
+			// "\xff\x00", and so are detected a little further down below.
+			d.tmp[0] = d.tmp[1]
+			d.tmp[1], err = d.r.ReadByte()
+			if err != nil {
+				return nil, err
+			}
 		}
 		marker := d.tmp[1]
+		if marker == 0 {
+			// Treat "\xff\x00" as extraneous data.
+			continue
+		}
 		for marker == 0xff {
 			// Section B.1.1.2 says, "Any marker may optionally be preceded by any
 			// number of fill bytes, which are bytes assigned code X'FF'".

コアとなるコードの解説

変更された reader.go のコードは、JPEGデコードの堅牢性を高めるための重要なロジックを含んでいます。

  1. for d.tmp[0] != 0xff ループ:

    • 以前は d.tmp[0]0xff でない場合に即座に FormatError を返していましたが、このループが追加されたことで、0xff バイトが見つかるまでストリームを読み進めるようになりました。
    • d.tmp はデコーダの内部バッファで、d.tmp[0]d.tmp[1] にそれぞれ現在のバイトと次のバイトが格納されます。
    • ループ内では、d.tmp[0]d.tmp[1] の値をコピーし、d.tmp[1] にはストリームから新しいバイトを読み込みます。これにより、1バイトずつストリームを「スキップ」していくことになります。
    • このスキップ処理は、libjpeg が余分なデータを警告しつつも無視する挙動を模倣しています。コメントにもあるように、これは厳密にはフォーマットエラーですが、実用的な互換性のために許容されます。
    • このループは、マーカーの開始を示す 0xff バイトが来るまで、非 0xff バイトを読み飛ばし続けます。
  2. if marker == 0 チェック:

    • for ループを抜けた後、d.tmp[0]0xff になっています。marker 変数には d.tmp[1] の値、つまり 0xff の次のバイトが格納されます。
    • if marker == 0 の条件は、0xff00 というバイトシーケンスを検出するためのものです。
    • JPEGの仕様では、データストリーム中に 0xff というバイト値が現れる場合、それがマーカーと誤解されないように、その後に 0x00 を挿入して 0xff00 というエスケープシーケンスとしてエンコードされます。デコーダは 0xff00 を読み取った場合、0xff をデータバイトとして解釈し、0x00 は無視する必要があります。
    • この if marker == 0 のチェックとそれに続く continue は、0xff00 シーケンスを「余分なデータ」として扱い、現在のマーカー処理をスキップして次のバイトの読み込みに進むことを意味します。これにより、エスケープされた 0xff データバイトが正しく処理され、デコードが続行されます。

これらの変更により、Goの image/jpeg デコーダは、JPEGストリーム内の予期せぬ非マーカーバイトや 0xff00 エスケープシーケンスをより堅牢に処理できるようになり、libjpeg と同等の寛容性を持つようになりました。

関連リンク

参考にした情報源リンク

  • JPEG File Interchange Format (JFIF) Specification: https://www.w3.org/Graphics/JPEG/jfif3.pdf
  • ITU-T T.81 (JPEG) Information technology – Digital compression and coding of continuous-tone still images – Requirements and guidelines: https://www.itu.int/rec/T-REC-T.81-199210-I/en
  • libjpeg source code (jdmarker.c, jdhuff.c): libjpeg の具体的な挙動を理解するために、そのソースコード(特にマーカー処理とハフマンデコード関連のファイル)を参照しました。これは特定のURLではなく、libjpeg の配布に含まれるファイルです。
  • Go image/jpeg package documentation: https://pkg.go.dev/image/jpeg
  • Stack Overflow and other technical forums: JPEGフォーマットの「extraneous data」や 0xFF00 エスケープシーケンスに関する一般的な議論や説明を参考にしました。