diff --git a/.codebuddy/CODEBUDDY.md b/.codebuddy/CODEBUDDY.md index ffadd2dd..25208f5a 100644 --- a/.codebuddy/CODEBUDDY.md +++ b/.codebuddy/CODEBUDDY.md @@ -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`) @@ -23,6 +23,16 @@ - 固定更新到 Go 标准库 HTTP 最新稳定版 + quic-go 最新稳定版 - 提交信息惯例:`merge upstream net/http: <日期>()`、`merge upstream http2: <日期>()`、`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 diff --git a/.codebuddy/skills/release/SKILL.md b/.codebuddy/skills/release/SKILL.md new file mode 100644 index 00000000..4dff6c7a --- /dev/null +++ b/.codebuddy/skills/release/SKILL.md @@ -0,0 +1,119 @@ +--- +name: release +description: Creates a new GitHub release for req. Handles tag creation, release notes generation from commit history, and gh release create. Use whenever the user asks to publish a release, cut a version, or create a tag/release for req. +allowed-tools: Read, Bash, Grep +--- + +# Release Manager + +Creates a new GitHub release for the req project. This involves creating a git tag, generating release notes from commits since the last release, and publishing via `gh`. + +## Prerequisites + +- The `gh-cli` skill must be loaded for GitHub release commands. If not already loaded, load it first. +- Working tree should be clean (no uncommitted changes to tracked files). +- Current branch should be `master`. + +## Workflow + +### 1. Determine the version + +The user specifies the version (e.g. `v3.58.0`). Confirm it follows the existing `vMAJOR.MINOR.PATCH` convention. + +### 2. Find the previous release + +```bash +git tag --sort=-v:refname | head -5 +``` + +The most recent tag is the baseline for generating release notes. For example, if releasing `v3.58.0`, the previous tag is `v3.57.0`. + +### 3. Collect commits since the previous tag + +```bash +git log ..HEAD --oneline --no-merges +``` + +Review each commit to categorize it. Read full commit messages for context: + +```bash +git log -1 --format='%B' +``` + +### 4. Categorize and filter commits + +Group commits into these categories: + +- **New Features** — `feat:` commits that add user-facing functionality +- **Bug Fixes** — `fix:` commits that resolve issues +- **Dependencies** — `chore: upgrade` or dependency-related commits +- **Tests** — `test:` commits (only mention if significant) +- **Internal** — `ci:`, `docs:`, loop engineering infrastructure, STATE.md updates + +Filter out internal-only commits that don't affect library users: +- `.codebuddy/` infrastructure (loop engineering, cron scripts) +- STATE.md updates +- Documentation about internal tooling + +When a commit references an issue or PR (e.g. `Closes #123`, `Closes PR #456 by @user`), include the reference in the release notes. Credit external contributors. + +### 5. Write release notes + +Use this template (in English): + +```markdown +## New Features + +- **Brief description** — details. Closes #123. + +## Bug Fixes + +- **Brief description** — details. Fixes #456. + +## Dependencies + +- **Brief description** — details. Addresses #789. + +## Tests + +- Brief description of significant test additions. +``` + +Omit empty sections. Keep descriptions concise but informative — users read these to decide whether to upgrade. + +### 6. Create the tag + +```bash +git tag -a -m " Release" +git push origin +``` + +### 7. Create the GitHub release + +Write the release notes to a temp file, then use `gh release create`: + +```bash +gh release create \ + --title " Release" \ + --notes-file /tmp/-release-notes.md +``` + +### 8. Verify + +```bash +gh release view +``` + +Confirm the release URL is returned and the notes render correctly. + +## Rules + +- Release notes must be in English +- Always credit external contributors (e.g. "Closes PR #123 by @username") +- Filter out internal infrastructure commits that don't affect library users +- The previous release format can be checked with: + ```bash + gh release view --json body,name,tagName + ``` +- Never create a release without the user explicitly confirming the version number +- If the working tree has uncommitted changes, warn the user before proceeding diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f909beb..41b7986a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/go.mod b/go.mod index 82fe5140..e0f04545 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/imroc/req/v3 -go 1.24.0 +go 1.25.0 replace github.com/refraction-networking/utls v1.8.2 => github.com/banyansecurity/utls v0.0.0-20260120191037-c47b0c63efd5 @@ -10,15 +10,15 @@ require ( 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 ) diff --git a/go.sum b/go.sum index 48307785..3878d449 100644 --- a/go.sum +++ b/go.sum @@ -15,24 +15,26 @@ github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uq github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= 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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 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/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -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/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +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.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= diff --git a/internal/http3/conn.go b/internal/http3/conn.go index b1f09c23..5deae955 100644 --- a/internal/http3/conn.go +++ b/internal/http3/conn.go @@ -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 @@ -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), @@ -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, diff --git a/internal/http3/headers.go b/internal/http3/headers.go index 572ca74c..02152742 100644 --- a/internal/http3/headers.go +++ b/internal/http3/headers.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/textproto" + "slices" "strconv" "strings" @@ -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 { @@ -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": @@ -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 { @@ -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() @@ -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). @@ -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 } } diff --git a/internal/http3/request_writer.go b/internal/http3/request_writer.go index fbf0c5e1..8e879ab8 100644 --- a/internal/http3/request_writer.go +++ b/internal/http3/request_writer.go @@ -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 {