internal/http3: QPACK encoding and decoding

Basic support for encoding/decoding QPACK headers.

QPACK supports three forms of header compression:
Huffman-encoding of literal strings, a static table of
well-known header values, and a dynamic table of
header values negotiated between encoder and decoder
at runtime.

Right now, we support Huffman compression and the
static table, but not the dynamic table.
This is a supported mode for a QPACK encoder or
decoder, so we can leave dynamic table support
for after the rest of HTTP/3 is working.

For golang/go#70914

Change-Id: Ib694199b99c752a220d43f3a309169b16020b474
Reviewed-on: https://go-review.googlesource.com/c/net/+/642599
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Damien Neil <dneil@google.com>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
diff --git a/internal/http3/http3_test.go b/internal/http3/http3_test.go
index c632c89..281c0cd 100644
--- a/internal/http3/http3_test.go
+++ b/internal/http3/http3_test.go
@@ -7,8 +7,10 @@
 package http3
 
 import (
+	"encoding/hex"
 	"os"
 	"slices"
+	"strings"
 	"testing"
 	"testing/synctest"
 )
@@ -57,3 +59,17 @@
 		f()
 	}
 }
+
+func unhex(s string) []byte {
+	b, err := hex.DecodeString(strings.Map(func(c rune) rune {
+		switch c {
+		case ' ', '\t', '\n':
+			return -1 // ignore
+		}
+		return c
+	}, s))
+	if err != nil {
+		panic(err)
+	}
+	return b
+}
diff --git a/internal/http3/qpack.go b/internal/http3/qpack.go
index 5dcdf6a..66f4e29 100644
--- a/internal/http3/qpack.go
+++ b/internal/http3/qpack.go
@@ -7,11 +7,190 @@
 package http3
 
 import (
+	"errors"
 	"io"
 
 	"golang.org/x/net/http2/hpack"
 )
 
+// QPACK (RFC 9204) header compression wire encoding.
+// https://www.rfc-editor.org/rfc/rfc9204.html
+
+// tableType is the static or dynamic table.
+//
+// The T bit in QPACK instructions indicates whether a table index refers to
+// the dynamic (T=0) or static (T=1) table. tableTypeForTBit and tableType.tbit
+// convert a T bit from the wire encoding to/from a tableType.
+type tableType byte
+
+const (
+	dynamicTable = 0x00 // T=0, dynamic table
+	staticTable  = 0xff // T=1, static table
+)
+
+// tableTypeForTbit returns the table type corresponding to a T bit value.
+// The input parameter contains a byte masked to contain only the T bit.
+func tableTypeForTbit(bit byte) tableType {
+	if bit == 0 {
+		return dynamicTable
+	}
+	return staticTable
+}
+
+// tbit produces the T bit corresponding to the table type.
+// The input parameter contains a byte with the T bit set to 1,
+// and the return is either the input or 0 depending on the table type.
+func (t tableType) tbit(bit byte) byte {
+	return bit & byte(t)
+}
+
+// indexType indicates a literal's indexing status.
+//
+// The N bit in QPACK instructions indicates whether a literal is "never-indexed".
+// A never-indexed literal (N=1) must not be encoded as an indexed literal if it
+// forwarded on another connection.
+//
+// (See https://www.rfc-editor.org/rfc/rfc9204.html#section-7.1 for details on the
+// security reasons for never-indexed literals.)
+type indexType byte
+
+const (
+	mayIndex   = 0x00 // N=0, not a never-indexed literal
+	neverIndex = 0xff // N=1, never-indexed literal
+)
+
+// indexTypeForNBit returns the index type corresponding to a N bit value.
+// The input parameter contains a byte masked to contain only the N bit.
+func indexTypeForNBit(bit byte) indexType {
+	if bit == 0 {
+		return mayIndex
+	}
+	return neverIndex
+}
+
+// nbit produces the N bit corresponding to the table type.
+// The input parameter contains a byte with the N bit set to 1,
+// and the return is either the input or 0 depending on the table type.
+func (t indexType) nbit(bit byte) byte {
+	return bit & byte(t)
+}
+
+// Indexed Field Line:
+//
+//       0   1   2   3   4   5   6   7
+//     +---+---+---+---+---+---+---+---+
+//     | 1 | T |      Index (6+)       |
+//     +---+---+-----------------------+
+//
+// https://www.rfc-editor.org/rfc/rfc9204.html#section-4.5.2
+
+func appendIndexedFieldLine(b []byte, ttype tableType, index int) []byte {
+	const tbit = 0b_01000000
+	return appendPrefixedInt(b, 0b_1000_0000|ttype.tbit(tbit), 6, int64(index))
+}
+
+func (st *stream) decodeIndexedFieldLine(b byte) (itype indexType, name, value string, err error) {
+	index, err := st.readPrefixedIntWithByte(b, 6)
+	if err != nil {
+		return 0, "", "", err
+	}
+	const tbit = 0b_0100_0000
+	if tableTypeForTbit(b&tbit) == staticTable {
+		ent, err := staticTableEntry(index)
+		if err != nil {
+			return 0, "", "", err
+		}
+		return mayIndex, ent.name, ent.value, nil
+	} else {
+		return 0, "", "", errors.New("dynamic table is not supported yet")
+	}
+}
+
+// Literal Field Line With Name Reference:
+//
+//      0   1   2   3   4   5   6   7
+//     +---+---+---+---+---+---+---+---+
+//     | 0 | 1 | N | T |Name Index (4+)|
+//     +---+---+---+---+---------------+
+//     | H |     Value Length (7+)     |
+//     +---+---------------------------+
+//     |  Value String (Length bytes)  |
+//     +-------------------------------+
+//
+// https://www.rfc-editor.org/rfc/rfc9204.html#section-4.5.4
+
+func appendLiteralFieldLineWithNameReference(b []byte, ttype tableType, itype indexType, nameIndex int, value string) []byte {
+	const tbit = 0b_0001_0000
+	const nbit = 0b_0010_0000
+	b = appendPrefixedInt(b, 0b_0100_0000|itype.nbit(nbit)|ttype.tbit(tbit), 4, int64(nameIndex))
+	b = appendPrefixedString(b, 0, 7, value)
+	return b
+}
+
+func (st *stream) decodeLiteralFieldLineWithNameReference(b byte) (itype indexType, name, value string, err error) {
+	nameIndex, err := st.readPrefixedIntWithByte(b, 4)
+	if err != nil {
+		return 0, "", "", err
+	}
+
+	const tbit = 0b_0001_0000
+	if tableTypeForTbit(b&tbit) == staticTable {
+		ent, err := staticTableEntry(nameIndex)
+		if err != nil {
+			return 0, "", "", err
+		}
+		name = ent.name
+	} else {
+		return 0, "", "", errors.New("dynamic table is not supported yet")
+	}
+
+	_, value, err = st.readPrefixedString(7)
+	if err != nil {
+		return 0, "", "", err
+	}
+
+	const nbit = 0b_0010_0000
+	itype = indexTypeForNBit(b & nbit)
+
+	return itype, name, value, nil
+}
+
+// Literal Field Line with Literal Name:
+//
+//       0   1   2   3   4   5   6   7
+//     +---+---+---+---+---+---+---+---+
+//     | 0 | 0 | 1 | N | H |NameLen(3+)|
+//     +---+---+---+---+---+-----------+
+//     |  Name String (Length bytes)   |
+//     +---+---------------------------+
+//     | H |     Value Length (7+)     |
+//     +---+---------------------------+
+//     |  Value String (Length bytes)  |
+//     +-------------------------------+
+//
+// https://www.rfc-editor.org/rfc/rfc9204.html#section-4.5.6
+
+func appendLiteralFieldLineWithLiteralName(b []byte, itype indexType, name, value string) []byte {
+	const nbit = 0b_0001_0000
+	b = appendPrefixedString(b, 0b_0010_0000|itype.nbit(nbit), 3, name)
+	b = appendPrefixedString(b, 0, 7, value)
+	return b
+}
+
+func (st *stream) decodeLiteralFieldLineWithLiteralName(b byte) (itype indexType, name, value string, err error) {
+	name, err = st.readPrefixedStringWithByte(b, 3)
+	if err != nil {
+		return 0, "", "", err
+	}
+	_, value, err = st.readPrefixedString(7)
+	if err != nil {
+		return 0, "", "", err
+	}
+	const nbit = 0b_0001_0000
+	itype = indexTypeForNBit(b & nbit)
+	return itype, name, value, nil
+}
+
 // Prefixed-integer encoding from RFC 7541, section 5.1
 //
 // Prefixed integers consist of some number of bits of data,
@@ -135,7 +314,9 @@
 	return string(data), nil
 }
 
-// appendPrefixedString appends an RFC 7541 string to st.
+// appendPrefixedString appends an RFC 7541 string to st,
+// applying Huffman encoding and setting the H bit (indicating Huffman encoding)
+// when appropriate.
 //
 // The firstByte parameter includes the non-integer bits of the first byte.
 // The other bits must be zero.
diff --git a/internal/http3/qpack_decode.go b/internal/http3/qpack_decode.go
new file mode 100644
index 0000000..018867a
--- /dev/null
+++ b/internal/http3/qpack_decode.go
@@ -0,0 +1,83 @@
+// Copyright 2025 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build go1.24
+
+package http3
+
+import (
+	"errors"
+	"math/bits"
+)
+
+type qpackDecoder struct {
+	// The decoder has no state for now,
+	// but that'll change once we add dynamic table support.
+	//
+	// TODO: dynamic table support.
+}
+
+func (qd *qpackDecoder) decode(st *stream, f func(itype indexType, name, value string) error) error {
+	// Encoded Field Section prefix.
+
+	// We set SETTINGS_QPACK_MAX_TABLE_CAPACITY to 0,
+	// so the Required Insert Count must be 0.
+	_, requiredInsertCount, err := st.readPrefixedInt(8)
+	if err != nil {
+		return err
+	}
+	if requiredInsertCount != 0 {
+		return errQPACKDecompressionFailed
+	}
+
+	// Delta Base. We don't use the dynamic table yet, so this may be ignored.
+	_, _, err = st.readPrefixedInt(7)
+	if err != nil {
+		return err
+	}
+
+	sawNonPseudo := false
+	for st.lim > 0 {
+		firstByte, err := st.ReadByte()
+		if err != nil {
+			return err
+		}
+		var name, value string
+		var itype indexType
+		switch bits.LeadingZeros8(firstByte) {
+		case 0:
+			// Indexed Field Line
+			itype, name, value, err = st.decodeIndexedFieldLine(firstByte)
+		case 1:
+			// Literal Field Line With Name Reference
+			itype, name, value, err = st.decodeLiteralFieldLineWithNameReference(firstByte)
+		case 2:
+			// Literal Field Line with Literal Name
+			itype, name, value, err = st.decodeLiteralFieldLineWithLiteralName(firstByte)
+		case 3:
+			// Indexed Field Line With Post-Base Index
+			err = errors.New("dynamic table is not supported yet")
+		case 4:
+			// Indexed Field Line With Post-Base Name Reference
+			err = errors.New("dynamic table is not supported yet")
+		}
+		if err != nil {
+			return err
+		}
+		if len(name) == 0 {
+			return errH3MessageError
+		}
+		if name[0] == ':' {
+			if sawNonPseudo {
+				return errH3MessageError
+			}
+		} else {
+			sawNonPseudo = true
+		}
+		if err := f(itype, name, value); err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/internal/http3/qpack_decode_test.go b/internal/http3/qpack_decode_test.go
new file mode 100644
index 0000000..1b779aa
--- /dev/null
+++ b/internal/http3/qpack_decode_test.go
@@ -0,0 +1,196 @@
+// Copyright 2025 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build go1.24
+
+package http3
+
+import (
+	"reflect"
+	"strings"
+	"testing"
+)
+
+func TestQPACKDecode(t *testing.T) {
+	type header struct {
+		itype       indexType
+		name, value string
+	}
+	// Many test cases here taken from Google QUICHE,
+	// quiche/quic/core/qpack/qpack_encoder_test.cc.
+	for _, test := range []struct {
+		name string
+		enc  []byte
+		want []header
+	}{{
+		name: "empty",
+		enc:  unhex("0000"),
+		want: []header{},
+	}, {
+		name: "literal entry empty value",
+		enc:  unhex("000023666f6f00"),
+		want: []header{
+			{mayIndex, "foo", ""},
+		},
+	}, {
+		name: "simple literal entry",
+		enc:  unhex("000023666f6f03626172"),
+		want: []header{
+			{mayIndex, "foo", "bar"},
+		},
+	}, {
+		name: "multiple literal entries",
+		enc: unhex("0000" + // prefix
+			// foo: bar
+			"23666f6f03626172" +
+			// 7 octet long header name, the smallest number
+			// that does not fit on a 3-bit prefix.
+			"2700666f6f62616172" +
+			// 127 octet long header value, the smallest number
+			// that does not fit on a 7-bit prefix.
+			"7f00616161616161616161616161616161616161616161616161616161616161616161" +
+			"6161616161616161616161616161616161616161616161616161616161616161616161" +
+			"6161616161616161616161616161616161616161616161616161616161616161616161" +
+			"616161616161616161616161616161616161616161616161",
+		),
+		want: []header{
+			{mayIndex, "foo", "bar"},
+			{mayIndex, "foobaar", strings.Repeat("a", 127)},
+		},
+	}, {
+		name: "line feed in value",
+		enc:  unhex("000023666f6f0462610a72"),
+		want: []header{
+			{mayIndex, "foo", "ba\nr"},
+		},
+	}, {
+		name: "huffman simple",
+		enc:  unhex("00002f0125a849e95ba97d7f8925a849e95bb8e8b4bf"),
+		want: []header{
+			{mayIndex, "custom-key", "custom-value"},
+		},
+	}, {
+		name: "alternating huffman nonhuffman",
+		enc: unhex("0000" + // Prefix.
+			"2f0125a849e95ba97d7f" + // Huffman-encoded name.
+			"8925a849e95bb8e8b4bf" + // Huffman-encoded value.
+			"2703637573746f6d2d6b6579" + // Non-Huffman encoded name.
+			"0c637573746f6d2d76616c7565" + // Non-Huffman encoded value.
+			"2f0125a849e95ba97d7f" + // Huffman-encoded name.
+			"0c637573746f6d2d76616c7565" + // Non-Huffman encoded value.
+			"2703637573746f6d2d6b6579" + // Non-Huffman encoded name.
+			"8925a849e95bb8e8b4bf", // Huffman-encoded value.
+		),
+		want: []header{
+			{mayIndex, "custom-key", "custom-value"},
+			{mayIndex, "custom-key", "custom-value"},
+			{mayIndex, "custom-key", "custom-value"},
+			{mayIndex, "custom-key", "custom-value"},
+		},
+	}, {
+		name: "static table",
+		enc:  unhex("0000d1d45f00055452414345dfcc5f108621e9aec2a11f5c8294e75f1000"),
+		want: []header{
+			{mayIndex, ":method", "GET"},
+			{mayIndex, ":method", "POST"},
+			{mayIndex, ":method", "TRACE"},
+			{mayIndex, "accept-encoding", "gzip, deflate, br"},
+			{mayIndex, "location", ""},
+			{mayIndex, "accept-encoding", "compress"},
+			{mayIndex, "location", "foo"},
+			{mayIndex, "accept-encoding", ""},
+		},
+	}} {
+		runSynctestSubtest(t, test.name, func(t testing.TB) {
+			st1, st2 := newStreamPair(t)
+			st1.Write(test.enc)
+			st1.Flush()
+
+			st2.lim = int64(len(test.enc))
+
+			var dec qpackDecoder
+			got := []header{}
+			err := dec.decode(st2, func(itype indexType, name, value string) error {
+				got = append(got, header{itype, name, value})
+				return nil
+			})
+			if err != nil {
+				t.Fatalf("decode: %v", err)
+			}
+			if !reflect.DeepEqual(got, test.want) {
+				t.Errorf("encoded: %x", test.enc)
+				t.Errorf("got headers:")
+				for _, h := range got {
+					t.Errorf("  %v: %q", h.name, h.value)
+				}
+				t.Errorf("want headers:")
+				for _, h := range test.want {
+					t.Errorf("  %v: %q", h.name, h.value)
+				}
+			}
+		})
+	}
+}
+
+func TestQPACKDecodeErrors(t *testing.T) {
+	// Many test cases here taken from Google QUICHE,
+	// quiche/quic/core/qpack/qpack_encoder_test.cc.
+	for _, test := range []struct {
+		name string
+		enc  []byte
+	}{{
+		name: "literal entry empty name",
+		enc:  unhex("00002003666f6f"),
+	}, {
+		name: "literal entry empty name and value",
+		enc:  unhex("00002000"),
+	}, {
+		name: "name length too large for varint",
+		enc:  unhex("000027ffffffffffffffffffff"),
+	}, {
+		name: "string literal too long",
+		enc:  unhex("000027ffff7f"),
+	}, {
+		name: "value length too large for varint",
+		enc:  unhex("000023666f6f7fffffffffffffffffffff"),
+	}, {
+		name: "value length too long",
+		enc:  unhex("000023666f6f7fffff7f"),
+	}, {
+		name: "incomplete header block",
+		enc:  unhex("00002366"),
+	}, {
+		name: "huffman name does not have eos prefix",
+		enc:  unhex("00002f0125a849e95ba97d7e8925a849e95bb8e8b4bf"),
+	}, {
+		name: "huffman value does not have eos prefix",
+		enc:  unhex("00002f0125a849e95ba97d7f8925a849e95bb8e8b4be"),
+	}, {
+		name: "huffman name eos prefix too long",
+		enc:  unhex("00002f0225a849e95ba97d7fff8925a849e95bb8e8b4bf"),
+	}, {
+		name: "huffman value eos prefix too long",
+		enc:  unhex("00002f0125a849e95ba97d7f8a25a849e95bb8e8b4bfff"),
+	}, {
+		name: "too high static table index",
+		enc:  unhex("0000ff23ff24"),
+	}} {
+		runSynctestSubtest(t, test.name, func(t testing.TB) {
+			st1, st2 := newStreamPair(t)
+			st1.Write(test.enc)
+			st1.Flush()
+
+			st2.lim = int64(len(test.enc))
+
+			var dec qpackDecoder
+			err := dec.decode(st2, func(itype indexType, name, value string) error {
+				return nil
+			})
+			if err == nil {
+				t.Errorf("encoded: %x", test.enc)
+				t.Fatalf("decode succeeded; want error")
+			}
+		})
+	}
+}
diff --git a/internal/http3/qpack_encode.go b/internal/http3/qpack_encode.go
new file mode 100644
index 0000000..0f35e0c
--- /dev/null
+++ b/internal/http3/qpack_encode.go
@@ -0,0 +1,47 @@
+// Copyright 2025 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build go1.24
+
+package http3
+
+type qpackEncoder struct {
+	// The encoder has no state for now,
+	// but that'll change once we add dynamic table support.
+	//
+	// TODO: dynamic table support.
+}
+
+func (qe *qpackEncoder) init() {
+	staticTableOnce.Do(initStaticTableMaps)
+}
+
+// encode encodes a list of headers into a QPACK encoded field section.
+//
+// The headers func must produce the same headers on repeated calls,
+// although the order may vary.
+func (qe *qpackEncoder) encode(headers func(func(itype indexType, name, value string))) []byte {
+	// Encoded Field Section prefix.
+	//
+	// We don't yet use the dynamic table, so both values here are zero.
+	var b []byte
+	b = appendPrefixedInt(b, 0, 8, 0) // Required Insert Count
+	b = appendPrefixedInt(b, 0, 7, 0) // Delta Base
+
+	headers(func(itype indexType, name, value string) {
+		if itype == mayIndex {
+			if i, ok := staticTableByNameValue[tableEntry{name, value}]; ok {
+				b = appendIndexedFieldLine(b, staticTable, i)
+				return
+			}
+		}
+		if i, ok := staticTableByName[name]; ok {
+			b = appendLiteralFieldLineWithNameReference(b, staticTable, itype, i, value)
+		} else {
+			b = appendLiteralFieldLineWithLiteralName(b, itype, name, value)
+		}
+	})
+
+	return b
+}
diff --git a/internal/http3/qpack_encode_test.go b/internal/http3/qpack_encode_test.go
new file mode 100644
index 0000000..f426d77
--- /dev/null
+++ b/internal/http3/qpack_encode_test.go
@@ -0,0 +1,126 @@
+// Copyright 2025 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build go1.24
+
+package http3
+
+import (
+	"bytes"
+	"strings"
+	"testing"
+)
+
+func TestQPACKEncode(t *testing.T) {
+	type header struct {
+		itype       indexType
+		name, value string
+	}
+	// Many test cases here taken from Google QUICHE,
+	// quiche/quic/core/qpack/qpack_encoder_test.cc.
+	for _, test := range []struct {
+		name    string
+		headers []header
+		want    []byte
+	}{{
+		name:    "empty",
+		headers: []header{},
+		want:    unhex("0000"),
+	}, {
+		name: "empty name",
+		headers: []header{
+			{mayIndex, "", "foo"},
+		},
+		want: unhex("0000208294e7"),
+	}, {
+		name: "empty value",
+		headers: []header{
+			{mayIndex, "foo", ""},
+		},
+		want: unhex("00002a94e700"),
+	}, {
+		name: "empty name and value",
+		headers: []header{
+			{mayIndex, "", ""},
+		},
+		want: unhex("00002000"),
+	}, {
+		name: "simple",
+		headers: []header{
+			{mayIndex, "foo", "bar"},
+		},
+		want: unhex("00002a94e703626172"),
+	}, {
+		name: "multiple",
+		headers: []header{
+			{mayIndex, "foo", "bar"},
+			{mayIndex, "ZZZZZZZ", strings.Repeat("Z", 127)},
+		},
+		want: unhex("0000" + // prefix
+			// foo: bar
+			"2a94e703626172" +
+			// 7 octet long header name, the smallest number
+			// that does not fit on a 3-bit prefix.
+			"27005a5a5a5a5a5a5a" +
+			// 127 octet long header value, the smallest
+			// number that does not fit on a 7-bit prefix.
+			"7f005a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a" +
+			"5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a" +
+			"5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a" +
+			"5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a"),
+	}, {
+		name: "static table 1",
+		headers: []header{
+			{mayIndex, ":method", "GET"},
+			{mayIndex, "accept-encoding", "gzip, deflate, br"},
+			{mayIndex, "location", ""},
+		},
+		want: unhex("0000d1dfcc"),
+	}, {
+		name: "static table 2",
+		headers: []header{
+			{mayIndex, ":method", "POST"},
+			{mayIndex, "accept-encoding", "compress"},
+			{mayIndex, "location", "foo"},
+		},
+		want: unhex("0000d45f108621e9aec2a11f5c8294e7"),
+	}, {
+		name: "static table 3",
+		headers: []header{
+			{mayIndex, ":method", "TRACE"},
+			{mayIndex, "accept-encoding", ""},
+		},
+		want: unhex("00005f000554524143455f1000"),
+	}, {
+		name: "never indexed literal field line with name reference",
+		headers: []header{
+			{neverIndex, ":method", ""},
+		},
+		want: unhex("00007f0000"),
+	}, {
+		name: "never indexed literal field line with literal name",
+		headers: []header{
+			{neverIndex, "a", "b"},
+		},
+		want: unhex("000031610162"),
+	}} {
+		t.Run(test.name, func(t *testing.T) {
+			var enc qpackEncoder
+			enc.init()
+
+			got := enc.encode(func(f func(itype indexType, name, value string)) {
+				for _, h := range test.headers {
+					f(h.itype, h.name, h.value)
+				}
+			})
+			if !bytes.Equal(got, test.want) {
+				for _, h := range test.headers {
+					t.Logf("header %v: %q", h.name, h.value)
+				}
+				t.Errorf("got:  %x", got)
+				t.Errorf("want: %x", test.want)
+			}
+		})
+	}
+}
diff --git a/internal/http3/qpack_static.go b/internal/http3/qpack_static.go
new file mode 100644
index 0000000..cb0884e
--- /dev/null
+++ b/internal/http3/qpack_static.go
@@ -0,0 +1,144 @@
+// Copyright 2025 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build go1.24
+
+package http3
+
+import "sync"
+
+type tableEntry struct {
+	name  string
+	value string
+}
+
+// staticTableEntry returns the static table entry with the given index.
+func staticTableEntry(index int64) (tableEntry, error) {
+	if index >= int64(len(staticTableEntries)) {
+		return tableEntry{}, errQPACKDecompressionFailed
+	}
+	return staticTableEntries[index], nil
+}
+
+func initStaticTableMaps() {
+	staticTableByName = make(map[string]int)
+	staticTableByNameValue = make(map[tableEntry]int)
+	for i, ent := range staticTableEntries {
+		if _, ok := staticTableByName[ent.name]; !ok {
+			staticTableByName[ent.name] = i
+		}
+		staticTableByNameValue[ent] = i
+	}
+}
+
+var (
+	staticTableOnce        sync.Once
+	staticTableByName      map[string]int
+	staticTableByNameValue map[tableEntry]int
+)
+
+// https://www.rfc-editor.org/rfc/rfc9204.html#appendix-A
+//
+// Note that this is different from the HTTP/2 static table.
+var staticTableEntries = [...]tableEntry{
+	0:  {":authority", ""},
+	1:  {":path", "/"},
+	2:  {"age", "0"},
+	3:  {"content-disposition", ""},
+	4:  {"content-length", "0"},
+	5:  {"cookie", ""},
+	6:  {"date", ""},
+	7:  {"etag", ""},
+	8:  {"if-modified-since", ""},
+	9:  {"if-none-match", ""},
+	10: {"last-modified", ""},
+	11: {"link", ""},
+	12: {"location", ""},
+	13: {"referer", ""},
+	14: {"set-cookie", ""},
+	15: {":method", "CONNECT"},
+	16: {":method", "DELETE"},
+	17: {":method", "GET"},
+	18: {":method", "HEAD"},
+	19: {":method", "OPTIONS"},
+	20: {":method", "POST"},
+	21: {":method", "PUT"},
+	22: {":scheme", "http"},
+	23: {":scheme", "https"},
+	24: {":status", "103"},
+	25: {":status", "200"},
+	26: {":status", "304"},
+	27: {":status", "404"},
+	28: {":status", "503"},
+	29: {"accept", "*/*"},
+	30: {"accept", "application/dns-message"},
+	31: {"accept-encoding", "gzip, deflate, br"},
+	32: {"accept-ranges", "bytes"},
+	33: {"access-control-allow-headers", "cache-control"},
+	34: {"access-control-allow-headers", "content-type"},
+	35: {"access-control-allow-origin", "*"},
+	36: {"cache-control", "max-age=0"},
+	37: {"cache-control", "max-age=2592000"},
+	38: {"cache-control", "max-age=604800"},
+	39: {"cache-control", "no-cache"},
+	40: {"cache-control", "no-store"},
+	41: {"cache-control", "public, max-age=31536000"},
+	42: {"content-encoding", "br"},
+	43: {"content-encoding", "gzip"},
+	44: {"content-type", "application/dns-message"},
+	45: {"content-type", "application/javascript"},
+	46: {"content-type", "application/json"},
+	47: {"content-type", "application/x-www-form-urlencoded"},
+	48: {"content-type", "image/gif"},
+	49: {"content-type", "image/jpeg"},
+	50: {"content-type", "image/png"},
+	51: {"content-type", "text/css"},
+	52: {"content-type", "text/html; charset=utf-8"},
+	53: {"content-type", "text/plain"},
+	54: {"content-type", "text/plain;charset=utf-8"},
+	55: {"range", "bytes=0-"},
+	56: {"strict-transport-security", "max-age=31536000"},
+	57: {"strict-transport-security", "max-age=31536000; includesubdomains"},
+	58: {"strict-transport-security", "max-age=31536000; includesubdomains; preload"},
+	59: {"vary", "accept-encoding"},
+	60: {"vary", "origin"},
+	61: {"x-content-type-options", "nosniff"},
+	62: {"x-xss-protection", "1; mode=block"},
+	63: {":status", "100"},
+	64: {":status", "204"},
+	65: {":status", "206"},
+	66: {":status", "302"},
+	67: {":status", "400"},
+	68: {":status", "403"},
+	69: {":status", "421"},
+	70: {":status", "425"},
+	71: {":status", "500"},
+	72: {"accept-language", ""},
+	73: {"access-control-allow-credentials", "FALSE"},
+	74: {"access-control-allow-credentials", "TRUE"},
+	75: {"access-control-allow-headers", "*"},
+	76: {"access-control-allow-methods", "get"},
+	77: {"access-control-allow-methods", "get, post, options"},
+	78: {"access-control-allow-methods", "options"},
+	79: {"access-control-expose-headers", "content-length"},
+	80: {"access-control-request-headers", "content-type"},
+	81: {"access-control-request-method", "get"},
+	82: {"access-control-request-method", "post"},
+	83: {"alt-svc", "clear"},
+	84: {"authorization", ""},
+	85: {"content-security-policy", "script-src 'none'; object-src 'none'; base-uri 'none'"},
+	86: {"early-data", "1"},
+	87: {"expect-ct", ""},
+	88: {"forwarded", ""},
+	89: {"if-range", ""},
+	90: {"origin", ""},
+	91: {"purpose", "prefetch"},
+	92: {"server", ""},
+	93: {"timing-allow-origin", "*"},
+	94: {"upgrade-insecure-requests", "1"},
+	95: {"user-agent", ""},
+	96: {"x-forwarded-for", ""},
+	97: {"x-frame-options", "deny"},
+	98: {"x-frame-options", "sameorigin"},
+}
diff --git a/internal/http3/stream_test.go b/internal/http3/stream_test.go
index d76b890..62140d0 100644
--- a/internal/http3/stream_test.go
+++ b/internal/http3/stream_test.go
@@ -263,7 +263,7 @@
 	}
 }
 
-func newStreamPair(t *testing.T) (s1, s2 *stream) {
+func newStreamPair(t testing.TB) (s1, s2 *stream) {
 	t.Helper()
 	q1, q2 := newQUICStreamPair(t)
 	return newStream(q1), newStream(q2)