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)