Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .codebuddy/CODEBUDDY.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
## 基本信息

- **模块路径**: `github.com/imroc/req/v3`
- **Go 版本**: 1.24+(CI 测试 1.24.x 和 1.25.x)
- **Go 版本**: 1.25+(CI 测试 1.25.x 和 1.26.x)
- **主分支**: `master`
- **包管理**: Go modules(`go.mod` / `go.sum`)

Expand All @@ -23,6 +23,16 @@
- 固定更新到 Go 标准库 HTTP 最新稳定版 + quic-go 最新稳定版
- 提交信息惯例:`merge upstream net/http: <日期>(<commit短hash>)`、`merge upstream http2: <日期>(<hash>)`、`port quic-go <版本>`

### 同步上游变更的强制流程(quic-go / net/http / http2 通用)

> **禁止只修复编译报错就提交**。编译通过 ≠ 同步完整。必须完整执行以下步骤:

1. **全量 diff**:对比新旧版本所有非测试文件(`diff -rq` 列出差异文件,再逐一 `diff` 非测试文件)
2. **逐文件同步**:每个有差异的非测试文件,都要将上游变更合并到 req 对应的魔改文件,保留 req 定制逻辑(如 dump、middleware、header order 等)
3. **显式记录不适用项**:对于 req 不需要的上游变更(如 server 端逻辑),必须在 PR 描述中列出并说明原因,证明做过全面检查而非遗漏
4. **全量测试**:`go build ./...` + `go test ./...` 全部通过
5. **PR 描述包含同步清单**:列出每个同步的文件和变更点,以及不适用项

## 常用命令

```bash
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
test:
strategy:
matrix:
go: ['1.24.x', '1.25.x']
go: ['1.25.x', '1.26.x']
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
Expand Down
12 changes: 6 additions & 6 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
module github.com/imroc/req/v3

go 1.24.0
go 1.25.0

require (
github.com/andybalholm/brotli v1.2.0
github.com/google/go-querystring v1.1.0
github.com/icholy/digest v1.1.0
github.com/klauspost/compress v1.18.2
github.com/quic-go/qpack v0.6.0
github.com/quic-go/quic-go v0.59.0
github.com/quic-go/quic-go v0.60.0
github.com/refraction-networking/utls v1.8.2
golang.org/x/net v0.48.0
golang.org/x/text v0.32.0
golang.org/x/net v0.55.0
golang.org/x/text v0.37.0
)

require (
github.com/google/go-cmp v0.7.0 // indirect
go.uber.org/mock v0.6.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sys v0.45.0 // indirect
)
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
Expand All @@ -27,12 +29,20 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Expand Down
10 changes: 6 additions & 4 deletions internal/http3/conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,12 @@ func (c *Conn) decodeTrailers(r io.Reader, streamID quic.StreamID, hf *headersFr
}
decodeFn := c.decoder.Decode(b)
var fields []qpack.HeaderField
var headerFields *[]qpack.HeaderField
if c.qlogger != nil {
fields = make([]qpack.HeaderField, 0, 16)
headerFields = &fields
}
trailers, err := parseTrailers(decodeFn, &fields)
trailers, err := parseTrailers(decodeFn, maxHeaderBytes, headerFields)
if err != nil {
maybeQlogInvalidHeadersFrame(c.qlogger, streamID, hf.Length)
return nil, err
Expand Down Expand Up @@ -375,7 +377,7 @@ func (c *Conn) sendDatagram(streamID quic.StreamID, b []byte) error {
data = append(data, b...)
if c.qlogger != nil {
c.qlogger.RecordEvent(qlog.DatagramCreated{
QuaterStreamID: quarterStreamID,
QuarterStreamID: quarterStreamID,
Raw: qlog.RawInfo{
Length: len(data),
PayloadLength: len(b),
Expand All @@ -397,8 +399,8 @@ func (c *Conn) receiveDatagrams() error {
return fmt.Errorf("could not read quarter stream id: %w", err)
}
if c.qlogger != nil {
c.qlogger.RecordEvent(qlog.DatagramParsed{
QuaterStreamID: quarterStreamID,
c.qlogger.RecordEvent(qlog.DatagramParsed{
QuarterStreamID: quarterStreamID,
Raw: qlog.RawInfo{
Length: len(b),
PayloadLength: len(b) - n,
Expand Down
82 changes: 63 additions & 19 deletions internal/http3/headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/textproto"
"slices"
"strconv"
"strings"

Expand Down Expand Up @@ -73,12 +74,8 @@ func parseHeaders(decodeFn qpack.DecodeFunc, isRequest bool, sizeLimit int, head
if sizeLimit < 0 {
return header{}, errHeaderTooLarge
}
// field names need to be lowercase, see section 4.2 of RFC 9114
if strings.ToLower(h.Name) != h.Name {
return header{}, fmt.Errorf("header field is not lower-case: %s", h.Name)
}
if !httpguts.ValidHeaderFieldValue(h.Value) {
return header{}, fmt.Errorf("invalid header field value for %s: %q", h.Name, h.Value)
if err := validateHeaderFieldNameAndValue(h); err != nil {
return header{}, err
}
if h.IsPseudo() {
if readFirstRegularHeader {
Expand All @@ -97,7 +94,7 @@ func parseHeaders(decodeFn qpack.DecodeFunc, isRequest bool, sizeLimit int, head
case ":authority":
isDuplicatePseudoHeader = hdr.Authority != ""
hdr.Authority = h.Value
case ":protocol":
case ":protocol": // RFC 9220
isDuplicatePseudoHeader = hdr.Protocol != ""
hdr.Protocol = h.Value
case ":scheme":
Expand All @@ -120,16 +117,8 @@ func parseHeaders(decodeFn qpack.DecodeFunc, isRequest bool, sizeLimit int, head
return header{}, fmt.Errorf("invalid response pseudo header: %s", h.Name)
}
} else {
if !httpguts.ValidHeaderFieldName(h.Name) {
return header{}, fmt.Errorf("invalid header field name: %q", h.Name)
}
for _, invalidField := range invalidHeaderFields {
if h.Name == invalidField {
return header{}, fmt.Errorf("invalid header field name: %q", h.Name)
}
}
if h.Name == "te" && h.Value != "trailers" {
return header{}, fmt.Errorf("invalid TE header field value: %q", h.Value)
if err := validateRegularHeaderField(h); err != nil {
return header{}, err
}
readFirstRegularHeader = true
switch h.Name {
Expand Down Expand Up @@ -163,7 +152,41 @@ func parseHeaders(decodeFn qpack.DecodeFunc, isRequest bool, sizeLimit int, head
return hdr, nil
}

func parseTrailers(decodeFn qpack.DecodeFunc, headerFields *[]qpack.HeaderField) (http.Header, error) {
func validateHeaderFieldNameAndValue(h qpack.HeaderField) error {
// field names need to be lowercase, see section 4.2 of RFC 9114
if strings.ToLower(h.Name) != h.Name {
return fmt.Errorf("header field is not lower-case: %s", h.Name)
}
if !httpguts.ValidHeaderFieldValue(h.Value) {
return fmt.Errorf("invalid header field value for %s: %q", h.Name, h.Value)
}
return nil
}

func validateRegularHeaderField(h qpack.HeaderField) error {
if !httpguts.ValidHeaderFieldName(h.Name) {
return fmt.Errorf("invalid header field name: %q", h.Name)
}
if slices.Contains(invalidHeaderFields[:], h.Name) {
return fmt.Errorf("invalid header field name: %q", h.Name)
}
if h.Name == "te" && h.Value != "trailers" {
return fmt.Errorf("invalid TE header field value: %q", h.Value)
}
return nil
}

func validateTrailerHeaderField(h qpack.HeaderField) error {
if err := validateRegularHeaderField(h); err != nil {
return err
}
if !httpguts.ValidTrailerHeader(h.Name) {
return fmt.Errorf("invalid trailer field name: %q", h.Name)
}
return nil
}

func parseTrailers(decodeFn qpack.DecodeFunc, sizeLimit int, headerFields *[]qpack.HeaderField) (http.Header, error) {
h := make(http.Header)
for {
hf, err := decodeFn()
Expand All @@ -176,14 +199,35 @@ func parseTrailers(decodeFn qpack.DecodeFunc, headerFields *[]qpack.HeaderField)
if headerFields != nil {
*headerFields = append(*headerFields, hf)
}
// RFC 9114, section 4.2.2:
// The size of a field list is calculated based on the uncompressed size of fields,
// including the length of the name and value in bytes plus an overhead of 32 bytes for each field.
sizeLimit -= len(hf.Name) + len(hf.Value) + 32
if sizeLimit < 0 {
return nil, errHeaderTooLarge
}
if err := validateHeaderFieldNameAndValue(hf); err != nil {
return nil, err
}
if hf.IsPseudo() {
return nil, fmt.Errorf("http3: received pseudo header in trailer: %s", hf.Name)
}
if err := validateTrailerHeaderField(hf); err != nil {
return nil, err
}
h.Add(hf.Name, hf.Value)
}
return h, nil
}

func validExtendedConnectProtocol(protocol string) bool {
// RFC 9220 specifies that the semantics of the :protocol pseudo are the same as defined in RFC 8441.
// RFC 8441, Section 4 specifies that :protocol is a single value from the HTTP Upgrade Token Registry.
// RFC 9110, Section 16.7 specifies that HTTP Upgrade Token Registry uses token grammar.
// Therefore, ValidHeaderFieldName is the right syntax check here, despite the misleading name.
return httpguts.ValidHeaderFieldName(protocol)
}

// updateResponseFromHeaders sets up http.Response as an HTTP/3 response,
// using the decoded qpack header filed.
// It is only called for the HTTP header (and not the HTTP trailer).
Expand Down Expand Up @@ -228,7 +272,7 @@ func extractAnnouncedTrailers(header http.Header) http.Header {

trailers := make(http.Header)
for _, rawVal := range rawTrailers {
for _, val := range strings.Split(rawVal, ",") {
for val := range strings.SplitSeq(rawVal, ",") {
trailers[http.CanonicalHeaderKey(textproto.TrimString(val))] = nil
}
}
Expand Down
3 changes: 3 additions & 0 deletions internal/http3/request_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ func (w *requestWriter) encodeHeaders(req *http.Request, addGzipHeader bool, tra

// http.NewRequest sets this field to HTTP/1.1
isExtendedConnect := isExtendedConnectRequest(req)
if isExtendedConnect && !validExtendedConnectProtocol(req.Proto) {
return nil, fmt.Errorf("invalid request :protocol %q", req.Proto)
}

var path string
if req.Method != http.MethodConnect || isExtendedConnect {
Expand Down
Loading