Day 053 — gRPC/Protocol Buffers実装(続き): 未知フィールドの読み飛ばし——I32/I64(固定長)と前方互換を壊す4つのバグ

2026-09-23 🟡 上級者 / Phase 3 デバッグ・コードレビュー gRPC/Protocol Buffers実装

📚 背景知識(読んでから問題へ)

Day 051ではWire Type 0(VARINT)、Day 052ではWire Type 2(LEN)を実装しました。残る実用のワイヤタイプは固定長の2つです。公式ドキュメント「Protocol Buffers - Encoding」の表は次のとおりです(出典: protobuf.dev/programming-guides/encoding、"Message Structure"節の表と"Non-varint Numbers"節。2026-09-23にWebFetchで取得し確認)。

ID名前使う型
0VARINTint32, int64, uint32, uint64, sint32, sint64, bool, enum
1I64fixed64, sfixed64, double
2LENstring, bytes, embedded messages, packed repeated fields
3SGROUPgroup start (deprecated)
4EGROUPgroup end (deprecated)
5I32fixed32, sfixed32, float

I64 は「固定8バイトの塊」、I32 は4バイトで、どちらもリトルエンディアン。double/float は IEEE 754 のビット列をそのまま置きます。

今日の本題は、この表がなぜワイヤ上に載っているのかです。公式ドキュメントは、タグにワイヤタイプを入れる理由を「古いパーサが、理解できない新しいフィールドを読み飛ばせるようにするため」と説明しています。つまりワイヤタイプは「この値は何バイトか」を、スキーマを知らない受信者にも教えるための情報です。

これが gRPC の運用で効いてくるのは、クライアントとサーバーが同時にはデプロイされないからです。サーバーを v2(フィールド追加)に上げた直後、v1 のままのクライアントや、v1 のままのバッチ・ログ収集ジョブが v2 のメッセージを受け取ります。このとき v1 のパーサは「知らないフィールド番号」を正しい長さで読み飛ばし、知っているフィールドだけを正しく取り出す必要があります。ここで長さを1バイトでも間違えると、以降のバイト列はずれた位置から解釈され、エラーにならずに値が消えることがあります。

Go の公式実装 google.golang.org/protobuf/encoding/protowire(v1.36.12 を pkg.go.dev で2026-09-23に確認)は、この処理を ConsumeField / ConsumeFieldValue として提供し、**エラー時は負の長さを返し、ParseError(n) でエラー値に変換する**設計になっています。今日はこの関数が内部でやっていることを、バグのある自作版を直すことで体験します。

📌
今日の位置づけ: ワイヤタイプの3bitは「この値は何バイトか」を、スキーマを知らない受信者に教えるための情報です。今日はそれを間違えたときに何が起きるかを、実測したバイト列で追います。

📝 問題

センサーの読み取り値を送る gRPC サービスがあります。v1 のスキーマと、先にデプロイされた v2 のスキーマは次のとおりです。

// v1(受信側。まだこちらのまま動いているクライアントがある)
message Reading {
  int32  id   = 1;
  string name = 4;
}

// v2(送信側。先にデプロイ済み)
message Reading {
  int32   id          = 1;
  fixed32 flags       = 2;  // 追加
  double  temperature = 3;  // 追加
  string  name        = 4;
  string  note        = 5;  // 追加
}

同僚が書いた v1 用の手書きパーサ parseReading には、未知フィールドの読み飛ばしと入力検査に関するバグが4つあります。エンコード側(encodeVarint・tag・varintField・fixed32Field・fixed64Field・lenField)にバグはありません。

package main

import (
	"encoding/binary"
	"fmt"
	"math"
)

// ---- エンコード側(新しい送信者 v2)。ここにバグは無い ----

func encodeVarint(x uint64) []byte {
	var buf []byte
	for x >= 0x80 {
		buf = append(buf, byte(x&0x7f)|0x80)
		x >>= 7
	}
	return append(buf, byte(x))
}

func tag(field, wireType int) []byte { return encodeVarint(uint64(field)<<3 | uint64(wireType)) }

func varintField(field int, v uint64) []byte { return append(tag(field, 0), encodeVarint(v)...) }

func fixed32Field(field int, v uint32) []byte {
	return binary.LittleEndian.AppendUint32(tag(field, 5), v)
}

func fixed64Field(field int, v uint64) []byte {
	return binary.LittleEndian.AppendUint64(tag(field, 1), v)
}

func lenField(field int, data []byte) []byte {
	b := append(tag(field, 2), encodeVarint(uint64(len(data)))...)
	return append(b, data...)
}

// ---- デコード側(古い受信者 v1)。v1 は id(1) と name(4) しか知らない ----

type Reading struct {
	ID   int32
	Name string
}

func decodeVarint(buf []byte) (uint64, int) {
	var x uint64
	var shift uint
	for i, b := range buf {
		x |= uint64(b&0x7f) << shift
		if b&0x80 == 0 {
			return x, i + 1
		}
		shift += 7
	}
	return x, len(buf)
}

func parseReading(buf []byte) (Reading, error) {
	var r Reading
	for i := 0; i < len(buf); {
		t, n := decodeVarint(buf[i:])
		i += n
		field, wt := int(t>>3), int(t&7)
		switch {
		case field == 1 && wt == 0:
			v, n := decodeVarint(buf[i:])
			r.ID = int32(v)
			i += n
		case field == 4 && wt == 2:
			l, n := decodeVarint(buf[i:])
			i += n
			r.Name = string(buf[i : i+int(l)])
			i += int(l)
		default:
			// 知らないフィールドは読み飛ばす(前方互換)
			switch wt {
			case 0:
				_, n := decodeVarint(buf[i:])
				i += n
			case 1, 5:
				i += 8
			case 2:
				l, _ := decodeVarint(buf[i:])
				i += int(l)
			}
		}
	}
	return r, nil
}

func run(name string, buf []byte) {
	defer func() {
		if p := recover(); p != nil {
			fmt.Printf("%-22s PANIC: %v\n", name, p)
		}
	}()
	r, err := parseReading(buf)
	fmt.Printf("%-22s got=%+v err=%v\n", name, r, err)
}

func main() {
	var v2 []byte
	v2 = append(v2, varintField(1, 7)...)
	v2 = append(v2, fixed64Field(3, math.Float64bits(21.5))...) // v2で追加: double temperature = 3
	v2 = append(v2, fixed32Field(2, 0x0000_0003)...)           // v2で追加: fixed32 flags = 2
	v2 = append(v2, lenField(4, []byte("sensor-a"))...)
	fmt.Printf("v2 bytes (%d): % x\n", len(v2), v2)

	run("case1 v2->v1", v2)

	var withNote []byte
	withNote = append(withNote, lenField(5, []byte("calibrated-v2"))...) // v2で追加: string note = 5
	withNote = append(withNote, varintField(1, 7)...)
	withNote = append(withNote, lenField(4, []byte("sensor-a"))...)
	run("case2 unknown LEN", withNote)

	truncated := append(tag(4, 2), 100, 's', 'e', 'n') // 長さ100と言いつつ3バイトしかない
	run("case3 truncated", truncated)

	group := append(varintField(1, 7), tag(6, 3)...) // 廃止済み SGROUP
	group = append(group, lenField(4, []byte("x"))...)
	run("case4 SGROUP", group)
}

このコードの実行結果(実測。go1.27.1 darwin/arm64):

v2 bytes (26): 08 07 19 00 00 00 00 00 80 35 40 15 03 00 00 00 22 08 73 65 6e 73 6f 72 2d 61
case1 v2->v1           got={ID:7 Name:} err=<nil>
case2 unknown LEN      got={ID:0 Name:} err=<nil>
case3 truncated        PANIC: runtime error: slice bounds out of range [:102] with capacity 8
case4 SGROUP           got={ID:7 Name:x} err=<nil>

期待する結果は、case1 と case2 が {ID:7 Name:sensor-a}、case3 と case4 が **panic せずに error を返す**ことです。

  1. バグを4つ特定する: parseReading(とそれが呼ぶ decodeVarint)のバグを4つ挙げ、それぞれ「どの case で、なぜその出力になるか」をバイト列(v2 bytes の16進)を使って説明してください。
  2. 修正する: 4つを直し、上の4ケースが期待どおりになるようにしてください。関数の戻り値の形 (Reading, error) は変えないこと。
  3. fuzz で確かめる: どんな入力でも panic しないことを go test -fuzz で確かめるテスト FuzzParseReading を書き、バグ版と修正版でそれぞれ10秒回した結果を比べてください。
  4. 説明する(言葉で): case1 と case2 は panic も error も出さずに Name が空になりました。「panic するバグ」と「黙って値が消えるバグ」はどちらが本番で危険か、gRPC のローリングデプロイの状況を例に説明してください。

🔍 ヒント(段階的開示)

ヒント1 — 方向性

case1 のバイト列を手で追ってください。19 はタグ(フィールド3・ワイヤタイプ1)、15 はタグ(フィールド2・ワイヤタイプ5)です。それぞれの後ろに何バイトの値があるべきか、背景知識の表と照らし合わせます。case2 は 2a 0d から始まります。0d の1バイトは誰が進めていますか。

ヒント2 — アプローチ

「値が何バイトか」を返す関数 skipValue(wt int, buf []byte) (int, error) を独立させると、4つのバグを1か所で直せます。

  • I32 と I64 は別の長さ
  • LEN は「長さ varint のバイト数 + 長さの値」
  • 長さの値は送信者が書いた数字にすぎない。残りのバイト数と比べてから使う
  • decodeVarint は入力が途中で尽きたときに len(buf) を返して「読めた」ことにしている
  • ワイヤタイプ 3/4(廃止済み group)と 6/7(未定義)を switch が何もせず通り抜けている
ヒント3 — コード骨格
func decodeVarint(buf []byte) (uint64, int, error) // 途中で尽きたら errTruncated、10バイト超は errOverflow

func skipValue(wt int, buf []byte) (int, error) {
	switch wt {
	case 0: // VARINT
	case 1: // I64: 残り8バイト以上あるか確認して 8
	case 5: // I32: 残り4バイト以上あるか確認して 4
	case 2: // LEN: l, n := decodeVarint; l > len(buf)-n ならエラー; n + l
	default: // 3,4,6,7 はエラー
	}
}

✅ 模範解答

main.go(go run . で実行):

package main

import (
	"encoding/binary"
	"errors"
	"fmt"
	"math"
)

// ---- エンコード側(新しい送信者 v2)。問題文と同じ ----

func encodeVarint(x uint64) []byte {
	var buf []byte
	for x >= 0x80 {
		buf = append(buf, byte(x&0x7f)|0x80)
		x >>= 7
	}
	return append(buf, byte(x))
}

func tag(field, wireType int) []byte { return encodeVarint(uint64(field)<<3 | uint64(wireType)) }

func varintField(field int, v uint64) []byte { return append(tag(field, 0), encodeVarint(v)...) }

func fixed32Field(field int, v uint32) []byte {
	return binary.LittleEndian.AppendUint32(tag(field, 5), v)
}

func fixed64Field(field int, v uint64) []byte {
	return binary.LittleEndian.AppendUint64(tag(field, 1), v)
}

func lenField(field int, data []byte) []byte {
	b := append(tag(field, 2), encodeVarint(uint64(len(data)))...)
	return append(b, data...)
}

// ---- デコード側(古い受信者 v1): 修正版 ----

type Reading struct {
	ID   int32
	Name string
}

var (
	errTruncated = errors.New("protowire: unexpected end of input")
	errOverflow  = errors.New("protowire: varint overflows 64 bits")
	errWireType  = errors.New("protowire: unsupported wire type")
	errFieldNum  = errors.New("protowire: invalid field number")
)

// 修正4a: 途中で入力が尽きたら「読めた」ことにしない。10バイトを超える varint も拒否する。
func decodeVarint(buf []byte) (uint64, int, error) {
	var x uint64
	var shift uint
	for i, b := range buf {
		if i == 10 {
			return 0, 0, errOverflow
		}
		x |= uint64(b&0x7f) << shift
		if b&0x80 == 0 {
			return x, i + 1, nil
		}
		shift += 7
	}
	return 0, 0, errTruncated
}

// skipValue は wire type ごとに「値が何バイトか」だけを返す。中身は解釈しない。
func skipValue(wt int, buf []byte) (int, error) {
	switch wt {
	case 0: // VARINT
		_, n, err := decodeVarint(buf)
		return n, err
	case 1: // I64: fixed64, sfixed64, double
		if len(buf) < 8 {
			return 0, errTruncated
		}
		return 8, nil
	case 5: // 修正1: I32 (fixed32, sfixed32, float) は 4 バイト
		if len(buf) < 4 {
			return 0, errTruncated
		}
		return 4, nil
	case 2: // 修正2: 長さ varint 自体のバイト数 n も進める
		l, n, err := decodeVarint(buf)
		if err != nil {
			return 0, err
		}
		if l > uint64(len(buf)-n) { // 修正4b: 長さを信用する前に残りバイト数と比べる
			return 0, errTruncated
		}
		return n + int(l), nil
	default: // 修正3: 3/4 (廃止済み group) と 6/7 (未定義) は黙って続けない
		return 0, fmt.Errorf("%w %d", errWireType, wt)
	}
}

func parseReading(buf []byte) (Reading, error) {
	var r Reading
	for i := 0; i < len(buf); {
		t, n, err := decodeVarint(buf[i:])
		if err != nil {
			return Reading{}, fmt.Errorf("tag at offset %d: %w", i, err)
		}
		i += n
		field, wt := t>>3, int(t&7)
		if field == 0 {
			return Reading{}, fmt.Errorf("offset %d: %w 0", i-n, errFieldNum)
		}
		switch {
		case field == 1 && wt == 0:
			v, n, err := decodeVarint(buf[i:])
			if err != nil {
				return Reading{}, fmt.Errorf("field 1 at offset %d: %w", i, err)
			}
			r.ID = int32(v)
			i += n
		case field == 4 && wt == 2:
			m, err := skipValue(2, buf[i:]) // 長さ検査は skipValue に一本化
			if err != nil {
				return Reading{}, fmt.Errorf("field 4 at offset %d: %w", i, err)
			}
			_, ln, _ := decodeVarint(buf[i:]) // skipValue が検査済みなので失敗しない
			r.Name = string(buf[i+ln : i+m])
			i += m
		default:
			m, err := skipValue(wt, buf[i:])
			if err != nil {
				return Reading{}, fmt.Errorf("unknown field %d at offset %d: %w", field, i, err)
			}
			i += m
		}
	}
	return r, nil
}

func run(name string, buf []byte) {
	defer func() {
		if p := recover(); p != nil {
			fmt.Printf("%-22s PANIC: %v\n", name, p)
		}
	}()
	r, err := parseReading(buf)
	fmt.Printf("%-22s got=%+v err=%v\n", name, r, err)
}

func main() {
	var v2 []byte
	v2 = append(v2, varintField(1, 7)...)
	v2 = append(v2, fixed64Field(3, math.Float64bits(21.5))...) // v2で追加: double temperature = 3
	v2 = append(v2, fixed32Field(2, 0x0000_0003)...)           // v2で追加: fixed32 flags = 2
	v2 = append(v2, lenField(4, []byte("sensor-a"))...)
	fmt.Printf("v2 bytes (%d): % x\n", len(v2), v2)

	run("case1 v2->v1", v2)

	var withNote []byte
	withNote = append(withNote, lenField(5, []byte("calibrated-v2"))...) // v2で追加: string note = 5
	withNote = append(withNote, varintField(1, 7)...)
	withNote = append(withNote, lenField(4, []byte("sensor-a"))...)
	run("case2 unknown LEN", withNote)

	truncated := append(tag(4, 2), 100, 's', 'e', 'n') // 長さ100と言いつつ3バイトしかない
	run("case3 truncated", truncated)

	group := append(varintField(1, 7), tag(6, 3)...) // 廃止済み SGROUP
	group = append(group, lenField(4, []byte("x"))...)
	run("case4 SGROUP", group)

	// double は IEEE 754 のビット列をリトルエンディアンで 8 バイト置いたもの
	le := binary.LittleEndian.Uint64(v2[3:11])
	be := binary.BigEndian.Uint64(v2[3:11])
	fmt.Printf("temperature LE=%v  BE(誤読)=%v\n", math.Float64frombits(le), math.Float64frombits(be))
}

main_test.go(go test -run='^$' -fuzz=FuzzParseReading -fuzztime=10s で実行):

package main

import (
	"math"
	"testing"
)

// FuzzParseReading: どんなバイト列でも panic せず、成功したときは name が入力の範囲内であることだけを確かめる。
func FuzzParseReading(f *testing.F) {
	var v2 []byte
	v2 = append(v2, varintField(1, 7)...)
	v2 = append(v2, fixed64Field(3, math.Float64bits(21.5))...)
	v2 = append(v2, fixed32Field(2, 3)...)
	v2 = append(v2, lenField(4, []byte("sensor-a"))...)
	f.Add(v2)
	f.Fuzz(func(t *testing.T, b []byte) {
		r, err := parseReading(b)
		if err == nil && len(r.Name) > len(b) {
			t.Fatalf("name longer than input: %d > %d", len(r.Name), len(b))
		}
	})
}

go.mod:

module fixed

go 1.27

実行結果(実測。go1.27.1 darwin/arm64):

v2 bytes (26): 08 07 19 00 00 00 00 00 80 35 40 15 03 00 00 00 22 08 73 65 6e 73 6f 72 2d 61
case1 v2->v1           got={ID:7 Name:sensor-a} err=<nil>
case2 unknown LEN      got={ID:7 Name:sensor-a} err=<nil>
case3 truncated        got={ID:0 Name:} err=field 4 at offset 1: protowire: unexpected end of input
case4 SGROUP           got={ID:0 Name:} err=unknown field 6 at offset 3: protowire: unsupported wire type 3
temperature LE=21.5  BE(誤読)=4.151258e-317

fuzz の結果(実測。同環境、8ワーカー、各10秒):

版結果実行回数
バグ版0.02秒・120回目で FAIL。panic: runtime error: slice bounds out of range [:50] with capacity 32(main.go:71 の buf[i : i+int(l)])120
修正版PASS(panic なし)1,055,634

バグ版で失敗した入力は testdata/fuzz/FuzzParseReading/ に保存され、以後の go test で毎回再実行される回帰テストになります。

🪜 Step-by-Step 解説

1
バグ1 — I32 を8バイト読み飛ばしている(case1)

case 1, 5: i += 8 は、I64(8バイト)と I32(4バイト)を同じ長さとして扱っています。case1 のバイト列を追うと次のようになります。

08 07                    field1 varint = 7        → ID=7(正しい)
19 [00 00 00 00 00 80 35 40]  field3 I64 → 8バイト飛ばす(正しい)
15 [03 00 00 00] 22 08 73 65  field2 I32 → 8バイト飛ばす(4バイト余計)
                  ^^^^^^^^^^^ name のタグ 22 と長さ 08 と "se" を食べてしまう
6e 73 6f 72 2d 61        ここから "nsor-a" をタグとして解釈し始める

6e はフィールド13・ワイヤタイプ6(未定義)、73 はフィールド14・ワイヤタイプ3(SGROUP)…と解釈され、バグ3(知らないワイヤタイプを黙って通過)と組み合わさって、ループは最後まで進んでしまいます。結果は **Name が空で err=nil**。

補足: 問題文の送信順を flags → temperature にすると、8バイト飛ばした先がたまたま double の途中の 00 や 80 35 になり、00(フィールド0)や 80 35(2バイトのタグ)として読み進んだあと偶然整列が戻って、case1 が正しい結果を返してしまいました(作問時に実測)。フィールドの順序次第でバグが隠れることがある、というのがこの種のバグの厄介さです。

2
バグ2 — LEN の長さ varint 自体を進めていない(case2)
2a 0d 63 61 6c 69 62 72 61 74 65 64 2d 76 32   field5 LEN(13) "calibrated-v2"

l, _ := decodeVarint(buf[i:]); i += int(l) は長さ 0d=13 だけ進め、長さを表す 0d の1バイトぶんを進めていません。着地点は末尾の 32('2')です。0x32 はフィールド6・ワイヤタイプ2(LEN)と読めてしまい、次の 08(本来は id のタグ)を「長さ8」として読みます。ここでも同じバグで長さバイトを進めないので、08 07 22 08 73 65 6e 73 の8バイト(id と name の先頭)がまとめて飛ばされます。**ID も Name も消えて err=nil**。修正は n + int(l) を返すことです。

3
バグ3 — 知らないワイヤタイプを黙って通過している(case4)

内側の switch wt に default がないため、ワイヤタイプ 3/4/6/7 のタグを読んでも i はタグの分しか進まず、次のバイトをタグとして読み続けます。case4 は 33(フィールド6・SGROUP)の後ろがたまたま整列していたので Name:x になりましたが、これは偶然です。6/7 は仕様上存在しないのでエラーにします。3/4 の扱いは「設計思想」で述べるトレードオフがあります。

4
バグ4 — 送信者が書いた長さを信用している(case3)

buf[i : i+int(l)] は、長さ 100 をそのまま信じてスライスし、slice bounds out of range で panic します。gRPC サーバーでハンドラ内の panic を recover していなければ、1つの壊れたリクエストでプロセス全体が落ちます。修正は2つです。

  • decodeVarint が途中で入力が尽きたときに len(buf) を返して成功扱いしていたのを、エラーを返すように変える(10バイトを超える varint も拒否する。64bit は 7bit×10 で収まるため)
  • 長さ l を使う前に l > uint64(len(buf)-n) を確認する。uint64 のまま比べるのは、巨大な l を int に変換すると負数になり、比較をすり抜けるからです
5
fuzz で「どんな入力でも panic しない」を確かめる

FuzzParseReading はシード入力(正しい v2 のバイト列)から変異させた入力を大量に試します。確かめている性質は「panic しない」と「成功したなら Name は入力より長くない」の2つだけです。この程度の性質でも、バグ版は 120回目の入力で panic を見つけました。パーサのように外部入力を受ける関数では、例を手で並べるテストよりも、性質を書いて fuzz に入力を探させる方が穴を見つけやすい、という実例です。

💡 設計思想・なぜこう書くのか

「値の長さ」をスキーマではなくワイヤに載せたことが、Protocol Buffers の前方互換を成り立たせています。JSON はキー名で値を探せるので未知のキーは無視すれば済みますが、PB は数字のフィールド番号と3bitのワイヤタイプしか持たないので、その3bitで長さを確定できないと先に進めません。Day 052 で見た「LEN は長さ + 生バイト列」という設計も、同じ目的のための仕組みです。

Go 側の書き方については、次の3点を意識して修正しています。

  1. 外部入力の長さは信用しない。長さ接頭辞は「送信者の自己申告」です。io.LimitReader や http.MaxBytesReader と同じく、Go の標準ライブラリは「入力が主張するサイズ」を上限で抑える設計を繰り返しています
  2. **失敗は error で返し、panic を recover に頼らない**。protowire も panic ではなく「負の長さ + ParseError」でエラーを返します。recover で包む案もありますが、壊れた入力は想定内の事象なので、呼び出し側が if err != nil で処理できる形にします
  3. **skipValue に長さの判断を一本化する**。既知のフィールド(field 4)の読み取りも同じ関数で長さ検査しているので、検査の抜けが1か所に集まります
⚖️
迷った点と採らなかった案
論点採った案採らなかった案と理由前提が変わったら
ワイヤタイプ 3/4(group)エラーにするgroup を読み飛ばす(SGROUP から対応する EGROUP までを再帰的にスキップ)。protowire.ConsumeFieldValue はこちらを採っており正しいが、入れ子の深さ上限も必要になり、今日の論点(長さの取り違え)から外れるため省いた相手が proto2 の group や、Editions の message_encoding = DELIMITED(group と同じワイヤ形式)を使うなら、エラーにすると正しいメッセージを拒否してしまう。その場合は読み飛ばしを実装するか、protowire を使う
panic の扱い入力検査で panic 自体を起こさないdefer recover() でパーサ全体を包む。短く書けるが、どこで壊れたか(offset)が失われ、ほかの本物のバグも握りつぶすハンドラの外側(gRPC の interceptor)での recover は別の目的(最後の防波堤)として併用してよい
自作するか学習のため自作本番では protowire か生成コード(proto.Unmarshal)を使う。今日の4つのバグは、公式実装がすでに潰しているものばかり生成コードを使えない環境(.proto がない他社のバイナリを調べる、パケットキャプチャを解析する等)では、今日の skipValue がそのまま役に立つ

🌐 他言語との比較

言語・実装未知フィールドの扱い壊れた入力の扱い
Go(protowire)ConsumeField が長さを返し、呼び出し側が読み飛ばす。生成コードの proto.Unmarshal は未知フィールドを捨てずに保持する負の長さを返し、ParseError(n) で error に変換する(pkg.go.dev の doc comment で確認)
Java(protobuf-java)生成コードが UnknownFieldSet に保持するInvalidProtocolBufferException(checked exception)を投げる
Python(protobuf)生成クラスが未知フィールドを保持するDecodeError 例外を投げる
JSON(Go の encoding/json)構造体にないキーは無視する(DisallowUnknownFields で拒否に切り替え可能)。キー名があるので長さの問題は起きない*json.SyntaxError などを error で返す

Java・Python の行は、各言語の公式 protobuf ライブラリの一般的な振る舞いとして記載しています。今日は実行して確かめていません(Go のみ実測)。

🏆 実務での使いどころ

  • ローリングデプロイ中の互換性: サーバーを先に v2 にしたとき、v1 クライアントが壊れないのは、生成コードが今日の skipValue と同じ処理をしているからです。逆に言うと、フィールドのワイヤタイプを変える変更(例: int32 → fixed32)は、古い受信者から見ると同じ番号で別の長さの値が来るため互換性を壊します。公式の方針が「フィールド番号の再利用や型の変更を避ける」なのはこのためです
  • gRPC サーバーの入力防御: 手書きパーサ、独自のバイナリプロトコル、ファイル形式のヘッダ解析など、「長さ接頭辞」を読むコードはすべて case3 と同じ穴を持ち得ます。長さを使う前に残りバイト数と比べる、という1行が抜けないように、今日の FuzzParseReading のような fuzz テストを CI に置きます
  • 未知フィールドの保持: 生成コードの proto.Unmarshal は、読み飛ばした未知フィールドを捨てずに保持し、再シリアライズ時にそのまま書き戻します。v1 のプロキシが v2 のメッセージを中継しても note が消えないのはこのためです。今日の実装は読み飛ばすだけで保持はしていません

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
I32 と I64 は「固定長」でまとめて扱える「fixed」という名前で同じグループに見えるI32 は4バイト、I64 は8バイト。ワイヤタイプ番号も 5 と 1 で離れている
LEN の読み飛ばしは「長さの値だけ進める」長さそのものが varint で1〜10バイトあることを忘れる進める量は「長さ varint のバイト数 + 長さの値」
テストが通ったので読み飛ばしは正しいフィールドの順序によっては、ずれた位置から読み進めて偶然整列が戻ることがある(Step 1 の補足で実測)送信順を変えたケースや fuzz で確かめる。手で並べた例だけでは足りない
長さを int(l) に変換してから比べれば安全uint64 の巨大な値を int に変換すると負数になり、int(l) > len(buf) をすり抜けるuint64 のまま l > uint64(len(buf)-n) と比べる
recover で包めば壊れた入力にも耐えられるpanic が止まれば十分だと考えるどこで壊れたかが分からず、ほかの本物のバグも隠す。入力検査で error を返すのが Go の書き方

🚧 扱わなかった範囲

  • group の読み飛ばし: 上の表のとおり、今日はエラーにしました。実装するなら入れ子の深さ上限も必要です
  • フィールド番号の上限: 仕様上の最大は 2^29−1 ですが、今日は 0 だけを拒否しています
  • **string の UTF-8 検査**: proto3 の string は正しい UTF-8 であることが求められ、Go の生成コードは不正な UTF-8 をエラーにします(今日は未検証)。今日の実装は検査していません
  • **zigzag(sint32)・packed repeated・未知フィールドの保持**

🚀 次のステップ

  • 発展: skipValue に group の読み飛ばし(SGROUP から、同じフィールド番号の EGROUP までを再帰的に進める)を加え、入れ子の深さ上限を設けたうえで、protowire.ConsumeFieldValue と同じ入力で結果が一致するかを fuzz で比べてみましょう
  • 次回予告: gRPC/Protocol Buffers は今日で6日目です。自己評価が「完全理解/おおむね理解」で3回そろえばテーマ15「ミドルウェア設計パターン」(http.Handler のチェーン設計。今日触れた gRPC の interceptor と同じ考え方です)へ進みます

🎯 自己評価

自分の回答

気づき・メモ