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
119 changes: 119 additions & 0 deletions .codebuddy/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 <previous_tag>..HEAD --oneline --no-merges
```

Review each commit to categorize it. Read full commit messages for context:

```bash
git log <hash> -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 <version> -m "<version> Release"
git push origin <version>
```

### 7. Create the GitHub release

Write the release notes to a temp file, then use `gh release create`:

```bash
gh release create <version> \
--title "<version> Release" \
--notes-file /tmp/<version>-release-notes.md
```

### 8. Verify

```bash
gh release view <version>
```

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 <previous_tag> --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
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,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

Expand All @@ -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
)
22 changes: 12 additions & 10 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
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
Loading