From 89cc00b55d9fa8f76695427ed8a2b6013ce591d4 Mon Sep 17 00:00:00 2001 From: aden Date: Thu, 10 Sep 2026 12:47:50 +0700 Subject: [PATCH 1/5] improve forwarded for --- ip.go | 33 +++++++++++---- ip_performance_test.go | 91 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 ip_performance_test.go diff --git a/ip.go b/ip.go index 848593b87..d84362819 100644 --- a/ip.go +++ b/ip.go @@ -247,12 +247,16 @@ func ExtractIPFromXFFHeader(options ...TrustOption) IPExtractor { if len(xffs) == 0 { return directIP } - ips := append(strings.Split(strings.Join(xffs, ","), ","), directIP) - for i := len(ips) - 1; i >= 0; i-- { - ips[i] = strings.TrimSpace(ips[i]) - ips[i] = strings.TrimPrefix(ips[i], "[") - ips[i] = strings.TrimSuffix(ips[i], "]") - ip := net.ParseIP(ips[i]) + // Inspect the direct peer first, then scan header lines and comma-separated + // entries from right to left without joining or allocating a slice. + headerIndex := len(xffs) - 1 + remaining := xffs[headerIndex] + candidate := directIP + for { + candidate = strings.TrimSpace(candidate) + candidate = strings.TrimPrefix(candidate, "[") + candidate = strings.TrimSuffix(candidate, "]") + ip := net.ParseIP(candidate) if ip == nil { // Unable to parse IP; cannot trust entire records return directIP @@ -260,9 +264,22 @@ func ExtractIPFromXFFHeader(options ...TrustOption) IPExtractor { if !checker.trust(ip) { return ip.String() } + if headerIndex < 0 { + // Preserve the original spelling of the furthest trusted IP. + return strings.TrimSpace(candidate) + } + + comma := strings.LastIndexByte(remaining, ',') + candidate = remaining[comma+1:] + if comma >= 0 { + remaining = remaining[:comma] + } else { + headerIndex-- + if headerIndex >= 0 { + remaining = xffs[headerIndex] + } + } } - // All of the IPs are trusted; return first element because it is furthest from server (best effort strategy). - return strings.TrimSpace(ips[0]) } } diff --git a/ip_performance_test.go b/ip_performance_test.go new file mode 100644 index 000000000..e5d886199 --- /dev/null +++ b/ip_performance_test.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package echo + +import ( + "net" + "net/http" + "strings" + "testing" +) + +// Keep the original implementation as a compatibility oracle for malformed +// headers, multiple header lines, and custom trust configurations. +func referenceExtractXFF(req *http.Request, checker *ipChecker) string { + directIP := extractIP(req) + xffs := req.Header[HeaderXForwardedFor] + if len(xffs) == 0 { + return directIP + } + ips := append(strings.Split(strings.Join(xffs, ","), ","), directIP) + for i := len(ips) - 1; i >= 0; i-- { + ips[i] = strings.TrimSpace(ips[i]) + ips[i] = strings.TrimPrefix(ips[i], "[") + ips[i] = strings.TrimSuffix(ips[i], "]") + ip := net.ParseIP(ips[i]) + if ip == nil { + return directIP + } + if !checker.trust(ip) { + return ip.String() + } + } + return strings.TrimSpace(ips[0]) +} + +func FuzzExtractIPFromXFFHeaderCompatibility(f *testing.F) { + for _, seed := range [][3]string{ + {"10.0.0.1:80", "10.0.0.2", "127.0.0.1"}, + {"10.0.0.1:80", "203.0.113.1, 10.0.0.2", "127.0.0.1"}, + {"[::1]:80", " [fc00:0::1] ", "[::1]"}, + {"10.0.0.1:80", "invalid", "203.0.113.1"}, + {"10.0.0.1:80", "10.0.0.2,", ""}, + {"invalid", "", ""}, + {"203.0.113.1:80", "invalid", ""}, + {"10.0.0.1:80", "[ 10.0.0.2 ]", "10.0.0.3"}, + } { + f.Add(seed[0], seed[1], seed[2]) + } + configurations := [][]TrustOption{ + nil, + {TrustLoopback(false), TrustLinkLocal(false), TrustPrivateNet(false)}, + {TrustIPRange(mustParseCIDR("0.0.0.0/0")), TrustIPRange(mustParseCIDR("::/0"))}, + } + f.Fuzz(func(t *testing.T, remote, first, second string) { + for _, options := range configurations { + extractor := ExtractIPFromXFFHeader(options...) + checker := newIPChecker(options) + for _, headers := range [][]string{nil, {}, {first}, {first, second}} { + req := &http.Request{RemoteAddr: remote, Header: http.Header{HeaderXForwardedFor: headers}} + if got, want := extractor(req), referenceExtractXFF(req, checker); got != want { + t.Fatalf("remote=%q headers=%q: got %q, want %q", remote, headers, got, want) + } + } + } + }) +} + +var benchmarkExtractedIP string + +func BenchmarkExtractIPFromXFFHeader(b *testing.B) { + for _, tc := range []struct { + name, remote string + headers []string + }{ + {"TrustedChain", "10.0.0.1:80", []string{"10.0.0.2, 10.0.0.3", "127.0.0.1"}}, + {"PublicClient", "10.0.0.1:80", []string{"203.0.113.1, 10.0.0.2", "127.0.0.1"}}, + {"UntrustedPeer", "203.0.113.1:80", []string{"10.0.0.2, 10.0.0.3", "127.0.0.1"}}, + {"TrustedIPv6", "[::1]:80", []string{"[fc00::1], [fc00::2]", "[::1]"}}, + } { + b.Run(tc.name, func(b *testing.B) { + req := &http.Request{RemoteAddr: tc.remote, Header: http.Header{HeaderXForwardedFor: tc.headers}} + extractor := ExtractIPFromXFFHeader() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + benchmarkExtractedIP = extractor(req) + } + }) + } +} From 9eaa8781105eeef30cf8d8eb993b2c8233cc1ccc Mon Sep 17 00:00:00 2001 From: aden Date: Fri, 25 Sep 2026 08:26:22 +0700 Subject: [PATCH 2/5] implement object pooling --- context.go | 15 ++--- render_pool.go | 28 ++++++++ render_pool_test.go | 152 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 11 deletions(-) create mode 100644 render_pool.go create mode 100644 render_pool_test.go diff --git a/context.go b/context.go index a8ee4b51d..09857aa56 100644 --- a/context.go +++ b/context.go @@ -487,25 +487,18 @@ func (c *Context) Validate(i any) error { return c.echo.Validator.Validate(i) } -// Render renders a template with data and sends a text/html response with status -// code. Renderer must be registered using `Echo.Renderer`. func (c *Context) Render(code int, name string, data any) (err error) { if c.echo.Renderer == nil { return ErrRendererNotRegistered } - // as Renderer.Render can fail, and in that case we need to delay sending status code to the client until - // (global) error handler decides the correct status code for the error to be sent to the client, so we need to write - // the rendered template to the buffer first. - // - // html.Template.ExecuteTemplate() documentations writes: - // > If an error occurs executing the template or writing its output, - // > execution stops, but partial results may already have been written to - // > the output writer. - buf := new(bytes.Buffer) + buf := renderBufPool.Get().(*bytes.Buffer) + defer releaseRenderBuffer(buf) + if err = c.echo.Renderer.Render(c, buf, name, data); err != nil { return } + return c.HTMLBlob(code, buf.Bytes()) } diff --git a/render_pool.go b/render_pool.go new file mode 100644 index 000000000..4e478183c --- /dev/null +++ b/render_pool.go @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package echo + +import ( + "bytes" + "sync" +) + +const maxPooledRenderBuf = 1 << 16 + +var renderBufPool = sync.Pool{ + New: newRenderBuffer, +} + +func newRenderBuffer() any { + return new(bytes.Buffer) +} + +func releaseRenderBuffer(buf *bytes.Buffer) { + if buf.Cap() > maxPooledRenderBuf { + return + } + + buf.Reset() + renderBufPool.Put(buf) +} diff --git a/render_pool_test.go b/render_pool_test.go new file mode 100644 index 000000000..513e6a833 --- /dev/null +++ b/render_pool_test.go @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package echo + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" +) + +type poolTestRenderer func(*Context, io.Writer, string, any) error + +func (r poolTestRenderer) Render(c *Context, w io.Writer, name string, data any) error { + return r(c, w, name, data) +} + +func TestRenderBufferLifecycle(t *testing.T) { + renderErr := errors.New("render failed") + e := New() + e.Renderer = poolTestRenderer(func(c *Context, w io.Writer, name string, data any) error { + if _, err := io.WriteString(w, data.(string)); err != nil { + return err + } + + switch name { + case "error": + return renderErr + case "panic": + panic(renderErr) + } + + return nil + }) + + for worker := range 8 { + t.Run(string(rune('a'+worker)), func(t *testing.T) { + t.Parallel() + + for _, name := range []string{"ok", "error", "ok", "panic", "ok", "large", "ok"} { + body := "body-" + t.Name() + "-" + name + if name == "large" { + body = strings.Repeat(body, 8192) + } + + rec := httptest.NewRecorder() + rec.Code = 0 + c := e.NewContext(httptest.NewRequest(http.MethodGet, "/", nil), rec) + var err error + var recovered any + func() { + defer func() { recovered = recover() }() + + err = c.Render(http.StatusCreated, name, body) + }() + + switch name { + case "error", "panic": + if name == "error" && !errors.Is(err, renderErr) { + t.Fatalf("got error %v, want %v", err, renderErr) + } + + if name == "panic" && recovered != renderErr { + t.Fatalf("got panic %v, want %v", recovered, renderErr) + } + + if rec.Body.Len() != 0 || rec.Code != 0 { + t.Fatal("failed render committed partial output") + } + default: + if err != nil || recovered != nil { + t.Fatalf("error=%v panic=%v", err, recovered) + } + + if rec.Code != http.StatusCreated || rec.Body.String() != body { + t.Fatal("render returned incorrect status or body") + } + + if got := rec.Header().Get(HeaderContentType); got != MIMETextHTMLCharsetUTF8 { + t.Fatalf("unexpected content type %q", got) + } + } + } + }) + } +} + +func BenchmarkRenderBuffer(b *testing.B) { + for _, size := range []int{128, 4096, 131072} { + body := strings.Repeat("x", size) + b.Run(strconv.Itoa(size), func(b *testing.B) { + e := New() + e.Renderer = poolTestRenderer(func(c *Context, w io.Writer, name string, data any) error { + _, err := io.WriteString(w, body) + return err + }) + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := &nopResponseWriter{} + c := e.NewContext(req, w) + if err := c.Render(http.StatusOK, "", nil); err != nil { + b.Fatal(err) + } + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + c.Reset(req, w) + if err := c.Render(http.StatusOK, "", nil); err != nil { + b.Fatal(err) + } + } + }) + } +} + +type renderFailWriter struct { + nopResponseWriter + err error +} + +func (w *renderFailWriter) Write(p []byte) (int, error) { + return 0, w.err +} + +func TestRenderBufferWriteError(t *testing.T) { + writeErr := errors.New("write failed") + e := New() + e.Renderer = poolTestRenderer(func(c *Context, w io.Writer, name string, data any) error { + _, err := io.WriteString(w, name) + return err + }) + req := httptest.NewRequest(http.MethodGet, "/", nil) + c := e.NewContext(req, &renderFailWriter{err: writeErr}) + if err := c.Render(http.StatusOK, "failed body", nil); !errors.Is(err, writeErr) { + t.Fatalf("got %v, want %v", err, writeErr) + } + + rec := httptest.NewRecorder() + c.Reset(req, rec) + if err := c.Render(http.StatusOK, "next body", nil); err != nil { + t.Fatal(err) + } + + if rec.Body.String() != "next body" { + t.Fatalf("unexpected body %q", rec.Body.String()) + } +} From 8897f09dfd488bc3d6a563124b4b9beebd334c08 Mon Sep 17 00:00:00 2001 From: aden Date: Fri, 25 Sep 2026 09:22:03 +0700 Subject: [PATCH 3/5] change package name --- .github/ISSUE_TEMPLATE.md | 2 +- API_CHANGES_V5.md | 2 +- CLAUDE.md | 2 +- Makefile | 2 +- README.md | 9 ++++----- binder_external_test.go | 2 +- echo.go | 5 ++--- echo_test.go | 2 +- echotest/context.go | 2 +- echotest/context_external_test.go | 4 ++-- echotest/context_test.go | 2 +- echotest/reader_external_test.go | 2 +- go.mod | 2 +- httperror_external_test.go | 2 +- llms-full.txt | 8 ++++---- llms.txt | 8 ++++---- middleware/basic_auth.go | 2 +- middleware/basic_auth_test.go | 2 +- middleware/body_dump.go | 2 +- middleware/body_dump_test.go | 2 +- middleware/body_limit.go | 2 +- middleware/body_limit_test.go | 2 +- middleware/compress.go | 2 +- middleware/compress_test.go | 2 +- middleware/context_timeout.go | 2 +- middleware/context_timeout_test.go | 2 +- middleware/cors.go | 2 +- middleware/cors_test.go | 2 +- middleware/csrf.go | 2 +- middleware/csrf_test.go | 2 +- middleware/decompress.go | 3 +-- middleware/decompress_test.go | 2 +- middleware/extractor.go | 2 +- middleware/extractor_test.go | 2 +- middleware/key_auth.go | 2 +- middleware/key_auth_test.go | 2 +- middleware/method_override.go | 2 +- middleware/method_override_test.go | 2 +- middleware/middleware.go | 2 +- middleware/proxy.go | 2 +- middleware/proxy_test.go | 2 +- middleware/rate_limiter.go | 2 +- middleware/rate_limiter_context_test.go | 2 +- middleware/rate_limiter_test.go | 2 +- middleware/recover.go | 2 +- middleware/recover_test.go | 2 +- middleware/redirect.go | 2 +- middleware/redirect_test.go | 2 +- middleware/request_id.go | 2 +- middleware/request_id_test.go | 2 +- middleware/request_logger.go | 2 +- middleware/request_logger_test.go | 2 +- middleware/rewrite.go | 2 +- middleware/rewrite_test.go | 2 +- middleware/secure.go | 2 +- middleware/secure_test.go | 2 +- middleware/slash.go | 2 +- middleware/slash_test.go | 2 +- middleware/static.go | 2 +- middleware/static_percent_test.go | 2 +- middleware/static_test.go | 2 +- route_test.go | 8 ++++---- 62 files changed, 76 insertions(+), 79 deletions(-) diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 1a76adca7..15742fc0a 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -6,7 +6,7 @@ package main import ( - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "net/http" "net/http/httptest" "testing" diff --git a/API_CHANGES_V5.md b/API_CHANGES_V5.md index 0d21bea95..fd1165473 100644 --- a/API_CHANGES_V5.md +++ b/API_CHANGES_V5.md @@ -535,7 +535,7 @@ Top-level binding functions that work with `*Context`. ### 12. **New echotest Package** ```go -package echotest // import "github.com/labstack/echo/v5/echotest" +package echotest // import "github.com/ad3n/echo/echotest" func LoadBytes(t *testing.T, name string, opts ...loadBytesOpts) []byte func TrimNewlineEnd(bytes []byte) []byte diff --git a/CLAUDE.md b/CLAUDE.md index d4c503fec..86afe4228 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## About This Project -Echo is a high performance, minimalist Go web framework. This is the main repository for Echo v5, which is available as a Go module at `github.com/labstack/echo/v5`. +Echo is a high performance, minimalist Go web framework. This is the main repository for Echo v5, which is available as a Go module at `github.com/ad3n/echo`. ## Development Commands diff --git a/Makefile b/Makefile index bd075bbae..42fa60ffd 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -PKG := "github.com/labstack/echo" +PKG := "github.com/ad3n/echo" PKG_LIST := $(shell go list ${PKG}/...) .DEFAULT_GOAL := check diff --git a/README.md b/README.md index cb5e18698..d1792a170 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [![Latest release](https://img.shields.io/github/v/release/labstack/echo?style=flat-square&label=release&color=00afd1)](https://github.com/labstack/echo/releases) [![Last commit](https://img.shields.io/github/last-commit/labstack/echo/master?style=flat-square)](https://github.com/labstack/echo/commits/master) [![Sourcegraph](https://sourcegraph.com/github.com/labstack/echo/-/badge.svg?style=flat-square)](https://sourcegraph.com/github.com/labstack/echo?badge) -[![GoDoc](https://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)](https://pkg.go.dev/github.com/labstack/echo/v5) +[![GoDoc](https://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)](https://pkg.go.dev/github.com/ad3n/echo) [![Go Report Card](https://goreportcard.com/badge/github.com/labstack/echo?style=flat-square)](https://goreportcard.com/report/github.com/labstack/echo) [![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/labstack/echo/echo.yml?style=flat-square)](https://github.com/labstack/echo/actions) [![Codecov](https://img.shields.io/codecov/c/github/labstack/echo.svg?style=flat-square)](https://codecov.io/gh/labstack/echo) @@ -62,8 +62,7 @@ See [ROADMAP.md](./ROADMAP.md) for where Echo is heading and the version support ### Installation ```sh -// go get github.com/labstack/echo/{version} -go get github.com/labstack/echo/v5 +go get github.com/ad3n/echo ``` Latest version of Echo supports last four Go major [releases](https://go.dev/doc/devel/release) and might work with @@ -75,8 +74,8 @@ older versions. package main import ( - "github.com/labstack/echo/v5" - "github.com/labstack/echo/v5/middleware" + "github.com/ad3n/echo" + "github.com/ad3n/echo/middleware" "log/slog" "net/http" ) diff --git a/binder_external_test.go b/binder_external_test.go index d83c891b3..13dd911ce 100644 --- a/binder_external_test.go +++ b/binder_external_test.go @@ -11,7 +11,7 @@ import ( "net/http" "net/http/httptest" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) func ExampleValueBinder_BindErrors() { diff --git a/echo.go b/echo.go index 1a861001d..28b886f06 100644 --- a/echo.go +++ b/echo.go @@ -12,8 +12,8 @@ Example: "log/slog" "net/http" - "github.com/labstack/echo/v5" - "github.com/labstack/echo/v5/middleware" + "github.com/ad3n/echo" + "github.com/ad3n/echo/middleware" ) // Handler @@ -185,7 +185,6 @@ const ( // RouteAny is a special method type that matches any HTTP method in request. Any has lower // priority that other methods that have been registered with Router to that path. RouteAny = "echo_route_any" - ) // Headers diff --git a/echo_test.go b/echo_test.go index 7d2266c13..4882d6286 100644 --- a/echo_test.go +++ b/echo_test.go @@ -608,7 +608,7 @@ func TestEchoFile(t *testing.T) { givenFile: "./go.mod", whenPath: "/", expectCode: http.StatusOK, - expectStartsWith: "module github.com/labstack/echo/v", + expectStartsWith: "module github.com/ad3n/echo", }, { name: "nok file does not exist", diff --git a/echotest/context.go b/echotest/context.go index ca3bd1056..70cfc5d41 100644 --- a/echotest/context.go +++ b/echotest/context.go @@ -13,7 +13,7 @@ import ( "strings" "testing" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // ContextConfig is configuration for creating echo.Context for testing purposes. diff --git a/echotest/context_external_test.go b/echotest/context_external_test.go index d98257148..c5f23c266 100644 --- a/echotest/context_external_test.go +++ b/echotest/context_external_test.go @@ -4,8 +4,8 @@ import ( "net/http" "testing" - "github.com/labstack/echo/v5" - "github.com/labstack/echo/v5/echotest" + "github.com/ad3n/echo" + "github.com/ad3n/echo/echotest" "github.com/stretchr/testify/assert" ) diff --git a/echotest/context_test.go b/echotest/context_test.go index 66815e4b0..4534d599c 100644 --- a/echotest/context_test.go +++ b/echotest/context_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/echotest/reader_external_test.go b/echotest/reader_external_test.go index 43fd57416..792932da4 100644 --- a/echotest/reader_external_test.go +++ b/echotest/reader_external_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/labstack/echo/v5/echotest" + "github.com/ad3n/echo/echotest" "github.com/stretchr/testify/assert" ) diff --git a/go.mod b/go.mod index fba646364..84da674e9 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/labstack/echo/v5 +module github.com/ad3n/echo go 1.25.0 diff --git a/httperror_external_test.go b/httperror_external_test.go index 91acdca25..8ca417fda 100644 --- a/httperror_external_test.go +++ b/httperror_external_test.go @@ -7,7 +7,7 @@ package echo_test import ( "encoding/json" "fmt" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "net/http" "net/http/httptest" ) diff --git a/llms-full.txt b/llms-full.txt index b6db08055..76d23cc07 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2,7 +2,7 @@ > Echo is a high performance, extensible, minimalist Go web framework built on the standard `net/http` library. Echo adds a fast radix-tree router, request binding with pluggable validator, a deep middleware ecosystem, centralized error handling, and template rendering on top of the standard library. -Import path: `github.com/labstack/echo/v5` +Import path: `github.com/ad3n/echo` Go version: 1.25+ Handler signature: `func(c *echo.Context) error` @@ -343,7 +343,7 @@ echo.NewVirtualHostHandler(vhosts map[string]*Echo) *Echo ## echotest package ```go -import "github.com/labstack/echo/v5/echotest" +import "github.com/ad3n/echo/echotest" echotest.LoadBytes(t *testing.T, name string, opts ...loadBytesOpts) []byte echotest.TrimNewlineEnd(bytes []byte) []byte @@ -366,8 +366,8 @@ import ( "syscall" "time" - "github.com/labstack/echo/v5" - "github.com/labstack/echo/v5/middleware" + "github.com/ad3n/echo" + "github.com/ad3n/echo/middleware" ) func main() { diff --git a/llms.txt b/llms.txt index 7071adcf8..738c484c8 100644 --- a/llms.txt +++ b/llms.txt @@ -3,7 +3,7 @@ > Echo is a high performance, extensible, minimalist Go web framework built on the standard `net/http` library. Echo adds a fast radix-tree router, request binding with pluggable validator, a deep middleware ecosystem, centralized error handling, and template rendering on top of the standard library. Key facts for LLMs: -- Import path is `github.com/labstack/echo/v5` +- Import path is `github.com/ad3n/echo` - Handlers use `*echo.Context` (pointer to struct) - `Context` is a concrete struct, not an interface - Logging uses `log/slog` @@ -13,7 +13,7 @@ Key facts for LLMs: - [Echo v5 API Changes](https://github.com/labstack/echo/blob/master/API_CHANGES_V5.md): Breaking changes reference - [Echo Official Docs](https://echo.labstack.com): Human-readable documentation site -- [pkg.go.dev](https://pkg.go.dev/github.com/labstack/echo/v5): API reference on Go package discovery +- [pkg.go.dev](https://pkg.go.dev/github.com/ad3n/echo): API reference on Go package discovery ## Core Patterns @@ -26,8 +26,8 @@ import ( "log/slog" "net/http" - "github.com/labstack/echo/v5" - "github.com/labstack/echo/v5/middleware" + "github.com/ad3n/echo" + "github.com/ad3n/echo/middleware" ) func hello(c *echo.Context) error { diff --git a/middleware/basic_auth.go b/middleware/basic_auth.go index 8a9500a93..6d7e4ee29 100644 --- a/middleware/basic_auth.go +++ b/middleware/basic_auth.go @@ -11,7 +11,7 @@ import ( "strconv" "strings" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // BasicAuthConfig defines the config for BasicAuthWithConfig middleware. diff --git a/middleware/basic_auth_test.go b/middleware/basic_auth_test.go index 42386354f..f44b622a2 100644 --- a/middleware/basic_auth_test.go +++ b/middleware/basic_auth_test.go @@ -12,7 +12,7 @@ import ( "strings" "testing" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/middleware/body_dump.go b/middleware/body_dump.go index 0443a67ab..858d5706a 100644 --- a/middleware/body_dump.go +++ b/middleware/body_dump.go @@ -12,7 +12,7 @@ import ( "net/http" "sync" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // BodyDumpConfig defines the config for BodyDump middleware. diff --git a/middleware/body_dump_test.go b/middleware/body_dump_test.go index e5f64541a..61a7703a9 100644 --- a/middleware/body_dump_test.go +++ b/middleware/body_dump_test.go @@ -11,7 +11,7 @@ import ( "strings" "testing" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/middleware/body_limit.go b/middleware/body_limit.go index 4f1963e18..d77e48dc3 100644 --- a/middleware/body_limit.go +++ b/middleware/body_limit.go @@ -8,7 +8,7 @@ import ( "net/http" "sync" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // BodyLimitConfig defines the config for BodyLimitWithConfig middleware. diff --git a/middleware/body_limit_test.go b/middleware/body_limit_test.go index 68d904da8..73fba482e 100644 --- a/middleware/body_limit_test.go +++ b/middleware/body_limit_test.go @@ -10,7 +10,7 @@ import ( "net/http/httptest" "testing" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/middleware/compress.go b/middleware/compress.go index 7754d5db8..7f443538b 100644 --- a/middleware/compress.go +++ b/middleware/compress.go @@ -14,7 +14,7 @@ import ( "strings" "sync" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) const ( diff --git a/middleware/compress_test.go b/middleware/compress_test.go index 084ffc9c7..2947fdb7c 100644 --- a/middleware/compress_test.go +++ b/middleware/compress_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/middleware/context_timeout.go b/middleware/context_timeout.go index 68465199a..c3964a02b 100644 --- a/middleware/context_timeout.go +++ b/middleware/context_timeout.go @@ -8,7 +8,7 @@ import ( "errors" "time" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // ContextTimeoutConfig defines the config for ContextTimeout middleware. diff --git a/middleware/context_timeout_test.go b/middleware/context_timeout_test.go index c7ba76beb..56fe2695a 100644 --- a/middleware/context_timeout_test.go +++ b/middleware/context_timeout_test.go @@ -6,7 +6,7 @@ package middleware import ( "context" "errors" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "net/http" "net/http/httptest" "net/url" diff --git a/middleware/cors.go b/middleware/cors.go index b4ddbd7f9..631620db1 100644 --- a/middleware/cors.go +++ b/middleware/cors.go @@ -10,7 +10,7 @@ import ( "strconv" "strings" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // CORSConfig defines the config for CORS middleware. diff --git a/middleware/cors_test.go b/middleware/cors_test.go index cd208fb8d..6bda28be0 100644 --- a/middleware/cors_test.go +++ b/middleware/cors_test.go @@ -11,7 +11,7 @@ import ( "strings" "testing" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/middleware/csrf.go b/middleware/csrf.go index 3999a92c9..440de9586 100644 --- a/middleware/csrf.go +++ b/middleware/csrf.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // CSRFUsingSecFetchSite is a context key for CSRF middleware what is set when the client browser is using Sec-Fetch-Site diff --git a/middleware/csrf_test.go b/middleware/csrf_test.go index 1a6b18eda..8bb4b35d9 100644 --- a/middleware/csrf_test.go +++ b/middleware/csrf_test.go @@ -11,7 +11,7 @@ import ( "strings" "testing" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/middleware/decompress.go b/middleware/decompress.go index 52febc680..08771fe73 100644 --- a/middleware/decompress.go +++ b/middleware/decompress.go @@ -10,7 +10,7 @@ import ( "strings" "sync" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // DecompressConfig defines the config for Decompress middleware. @@ -128,7 +128,6 @@ func (config DecompressConfig) ToMiddleware() (echo.MiddlewareFunc, error) { }, nil } - // isGzipContentEncoding reports whether Content-Encoding is gzip. // Content codings are case-insensitive per RFC 9110 §8.4.1. func isGzipContentEncoding(v string) bool { diff --git a/middleware/decompress_test.go b/middleware/decompress_test.go index fadfa7fac..2b5db393b 100644 --- a/middleware/decompress_test.go +++ b/middleware/decompress_test.go @@ -14,7 +14,7 @@ import ( "sync" "testing" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/middleware/extractor.go b/middleware/extractor.go index f800a49e9..10b095e0f 100644 --- a/middleware/extractor.go +++ b/middleware/extractor.go @@ -8,7 +8,7 @@ import ( "net/textproto" "strings" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) const ( diff --git a/middleware/extractor_test.go b/middleware/extractor_test.go index 04cc7b829..5ecd59029 100644 --- a/middleware/extractor_test.go +++ b/middleware/extractor_test.go @@ -13,7 +13,7 @@ import ( "strings" "testing" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/middleware/key_auth.go b/middleware/key_auth.go index 2dcb98039..f724e0aa4 100644 --- a/middleware/key_auth.go +++ b/middleware/key_auth.go @@ -9,7 +9,7 @@ import ( "fmt" "net/http" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // KeyAuthConfig defines the config for KeyAuth middleware. diff --git a/middleware/key_auth_test.go b/middleware/key_auth_test.go index 49a917ed3..e5ef9356d 100644 --- a/middleware/key_auth_test.go +++ b/middleware/key_auth_test.go @@ -11,7 +11,7 @@ import ( "strings" "testing" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/middleware/method_override.go b/middleware/method_override.go index 17335a902..92c56c1d1 100644 --- a/middleware/method_override.go +++ b/middleware/method_override.go @@ -6,7 +6,7 @@ package middleware import ( "net/http" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // MethodOverrideConfig defines the config for MethodOverride middleware. diff --git a/middleware/method_override_test.go b/middleware/method_override_test.go index 525ad10ba..8921878df 100644 --- a/middleware/method_override_test.go +++ b/middleware/method_override_test.go @@ -9,7 +9,7 @@ import ( "net/http/httptest" "testing" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/middleware/middleware.go b/middleware/middleware.go index 4562d03b5..8b43d5c0c 100644 --- a/middleware/middleware.go +++ b/middleware/middleware.go @@ -9,7 +9,7 @@ import ( "strconv" "strings" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // Skipper defines a function to skip middleware. Returning true skips processing the middleware. diff --git a/middleware/proxy.go b/middleware/proxy.go index 497aefea4..2192f111c 100644 --- a/middleware/proxy.go +++ b/middleware/proxy.go @@ -20,7 +20,7 @@ import ( "sync" "time" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // TODO: Handle TLS proxy diff --git a/middleware/proxy_test.go b/middleware/proxy_test.go index 5053f7945..af9bd3f31 100644 --- a/middleware/proxy_test.go +++ b/middleware/proxy_test.go @@ -20,7 +20,7 @@ import ( "testing" "time" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" "golang.org/x/net/websocket" ) diff --git a/middleware/rate_limiter.go b/middleware/rate_limiter.go index bd70e10a9..9954d5317 100644 --- a/middleware/rate_limiter.go +++ b/middleware/rate_limiter.go @@ -11,7 +11,7 @@ import ( "sync" "time" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "golang.org/x/time/rate" ) diff --git a/middleware/rate_limiter_context_test.go b/middleware/rate_limiter_context_test.go index 629c01e47..ff2aada81 100644 --- a/middleware/rate_limiter_context_test.go +++ b/middleware/rate_limiter_context_test.go @@ -9,7 +9,7 @@ import ( "strconv" "testing" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/middleware/rate_limiter_test.go b/middleware/rate_limiter_test.go index 267e8d08f..7ff1d116d 100644 --- a/middleware/rate_limiter_test.go +++ b/middleware/rate_limiter_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" "golang.org/x/time/rate" ) diff --git a/middleware/recover.go b/middleware/recover.go index 01fde5152..de1cb5a93 100644 --- a/middleware/recover.go +++ b/middleware/recover.go @@ -8,7 +8,7 @@ import ( "net/http" "runtime" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // RecoverConfig defines the config for Recover middleware. diff --git a/middleware/recover_test.go b/middleware/recover_test.go index 719e0cc3d..a3e0baf61 100644 --- a/middleware/recover_test.go +++ b/middleware/recover_test.go @@ -11,7 +11,7 @@ import ( "net/http/httptest" "testing" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/middleware/redirect.go b/middleware/redirect.go index bb7045cfe..b2b799228 100644 --- a/middleware/redirect.go +++ b/middleware/redirect.go @@ -8,7 +8,7 @@ import ( "net/http" "strings" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // RedirectConfig defines the config for Redirect middleware. diff --git a/middleware/redirect_test.go b/middleware/redirect_test.go index a127ca40c..a2afbd00d 100644 --- a/middleware/redirect_test.go +++ b/middleware/redirect_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "testing" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/middleware/request_id.go b/middleware/request_id.go index 9d059bc62..4aa4becfc 100644 --- a/middleware/request_id.go +++ b/middleware/request_id.go @@ -4,7 +4,7 @@ package middleware import ( - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // RequestIDConfig defines the config for RequestID middleware. diff --git a/middleware/request_id_test.go b/middleware/request_id_test.go index 465e6fc42..f154e18e3 100644 --- a/middleware/request_id_test.go +++ b/middleware/request_id_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "testing" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/middleware/request_logger.go b/middleware/request_logger.go index 74d09285b..d643101b4 100644 --- a/middleware/request_logger.go +++ b/middleware/request_logger.go @@ -10,7 +10,7 @@ import ( "net/http" "time" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // Example for `slog` https://pkg.go.dev/log/slog diff --git a/middleware/request_logger_test.go b/middleware/request_logger_test.go index 2232c6f6a..0a48d57a3 100644 --- a/middleware/request_logger_test.go +++ b/middleware/request_logger_test.go @@ -16,7 +16,7 @@ import ( "testing" "time" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/middleware/rewrite.go b/middleware/rewrite.go index 02907ca49..635de5eae 100644 --- a/middleware/rewrite.go +++ b/middleware/rewrite.go @@ -8,7 +8,7 @@ import ( "maps" "regexp" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // RewriteConfig defines the config for Rewrite middleware. diff --git a/middleware/rewrite_test.go b/middleware/rewrite_test.go index adcc8e9f5..5e776c95e 100644 --- a/middleware/rewrite_test.go +++ b/middleware/rewrite_test.go @@ -11,7 +11,7 @@ import ( "regexp" "testing" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/middleware/secure.go b/middleware/secure.go index 022cce4a1..e04abf692 100644 --- a/middleware/secure.go +++ b/middleware/secure.go @@ -6,7 +6,7 @@ package middleware import ( "fmt" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // SecureConfig defines the config for Secure middleware. diff --git a/middleware/secure_test.go b/middleware/secure_test.go index 5ca1308a9..547204fb2 100644 --- a/middleware/secure_test.go +++ b/middleware/secure_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "testing" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/middleware/slash.go b/middleware/slash.go index d00102c5e..c9a06107f 100644 --- a/middleware/slash.go +++ b/middleware/slash.go @@ -8,7 +8,7 @@ import ( "net/http" "strings" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // AddTrailingSlashConfig is the middleware config for adding trailing slash to the request. diff --git a/middleware/slash_test.go b/middleware/slash_test.go index 85f65e7ce..8274b401e 100644 --- a/middleware/slash_test.go +++ b/middleware/slash_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "testing" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/middleware/static.go b/middleware/static.go index 200f96a30..652de4ef7 100644 --- a/middleware/static.go +++ b/middleware/static.go @@ -17,7 +17,7 @@ import ( "strings" "sync" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" ) // StaticConfig defines the config for Static middleware. diff --git a/middleware/static_percent_test.go b/middleware/static_percent_test.go index a65d75c90..4adf0391e 100644 --- a/middleware/static_percent_test.go +++ b/middleware/static_percent_test.go @@ -9,7 +9,7 @@ import ( "testing" "testing/fstest" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/middleware/static_test.go b/middleware/static_test.go index 889169eea..17c882a1e 100644 --- a/middleware/static_test.go +++ b/middleware/static_test.go @@ -11,7 +11,7 @@ import ( "testing" "testing/fstest" - "github.com/labstack/echo/v5" + "github.com/ad3n/echo" "github.com/stretchr/testify/assert" ) diff --git a/route_test.go b/route_test.go index 8e2f170ee..9002cf134 100644 --- a/route_test.go +++ b/route_test.go @@ -39,22 +39,22 @@ func TestHandlerName(t *testing.T) { whenHandlerFunc: func(c *Context) error { return nil }, - expect: "github.com/labstack/echo/v5.TestHandlerName.func2", + expect: "github.com/ad3n/echo.TestHandlerName.func2", }, { name: "ok, func as named package variable", whenHandlerFunc: myNamedHandler, - expect: "github.com/labstack/echo/v5.init.func4", + expect: "github.com/ad3n/echo.init.func4", }, { name: "ok, func as named function variable", whenHandlerFunc: myNameFuncVar, - expect: "github.com/labstack/echo/v5.TestHandlerName.func1", + expect: "github.com/ad3n/echo.TestHandlerName.func1", }, { name: "ok, func as struct method", whenHandlerFunc: tmp.getUsers, - expect: "github.com/labstack/echo/v5.(*NameStruct).getUsers-fm", + expect: "github.com/ad3n/echo.(*NameStruct).getUsers-fm", }, } From fd38fa27368d57760c0ae929ab94855b9dfa4148 Mon Sep 17 00:00:00 2001 From: aden Date: Fri, 25 Sep 2026 09:35:18 +0700 Subject: [PATCH 4/5] change package name --- .github/ISSUE_TEMPLATE.md | 2 +- API_CHANGES_V5.md | 2 +- CLAUDE.md | 2 +- Makefile | 2 +- README.md | 8 ++++---- binder_external_test.go | 2 +- echo.go | 4 ++-- echo_test.go | 2 +- echotest/context.go | 2 +- echotest/context_external_test.go | 4 ++-- echotest/context_test.go | 2 +- echotest/reader_external_test.go | 2 +- go.mod | 2 +- httperror_external_test.go | 2 +- llms-full.txt | 8 ++++---- llms.txt | 8 ++++---- middleware/basic_auth.go | 2 +- middleware/basic_auth_test.go | 2 +- middleware/body_dump.go | 2 +- middleware/body_dump_test.go | 2 +- middleware/body_limit.go | 2 +- middleware/body_limit_test.go | 2 +- middleware/compress.go | 2 +- middleware/compress_test.go | 2 +- middleware/context_timeout.go | 2 +- middleware/context_timeout_test.go | 2 +- middleware/cors.go | 2 +- middleware/cors_test.go | 2 +- middleware/csrf.go | 2 +- middleware/csrf_test.go | 2 +- middleware/decompress.go | 2 +- middleware/decompress_test.go | 2 +- middleware/extractor.go | 2 +- middleware/extractor_test.go | 2 +- middleware/key_auth.go | 2 +- middleware/key_auth_test.go | 2 +- middleware/method_override.go | 2 +- middleware/method_override_test.go | 2 +- middleware/middleware.go | 2 +- middleware/proxy.go | 2 +- middleware/proxy_test.go | 2 +- middleware/rate_limiter.go | 2 +- middleware/rate_limiter_context_test.go | 2 +- middleware/rate_limiter_test.go | 2 +- middleware/recover.go | 2 +- middleware/recover_test.go | 2 +- middleware/redirect.go | 2 +- middleware/redirect_test.go | 2 +- middleware/request_id.go | 2 +- middleware/request_id_test.go | 2 +- middleware/request_logger.go | 2 +- middleware/request_logger_test.go | 2 +- middleware/rewrite.go | 2 +- middleware/rewrite_test.go | 2 +- middleware/secure.go | 2 +- middleware/secure_test.go | 2 +- middleware/slash.go | 2 +- middleware/slash_test.go | 2 +- middleware/static.go | 2 +- middleware/static_percent_test.go | 2 +- middleware/static_test.go | 2 +- route_test.go | 8 ++++---- 62 files changed, 76 insertions(+), 76 deletions(-) diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 15742fc0a..a7e303d88 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -6,7 +6,7 @@ package main import ( - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "net/http" "net/http/httptest" "testing" diff --git a/API_CHANGES_V5.md b/API_CHANGES_V5.md index fd1165473..78875b66b 100644 --- a/API_CHANGES_V5.md +++ b/API_CHANGES_V5.md @@ -535,7 +535,7 @@ Top-level binding functions that work with `*Context`. ### 12. **New echotest Package** ```go -package echotest // import "github.com/ad3n/echo/echotest" +package echotest // import "github.com/ad3n/echo/v5/echotest" func LoadBytes(t *testing.T, name string, opts ...loadBytesOpts) []byte func TrimNewlineEnd(bytes []byte) []byte diff --git a/CLAUDE.md b/CLAUDE.md index 86afe4228..ac1a0307c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## About This Project -Echo is a high performance, minimalist Go web framework. This is the main repository for Echo v5, which is available as a Go module at `github.com/ad3n/echo`. +Echo is a high performance, minimalist Go web framework. This is the main repository for Echo v5, which is available as a Go module at `github.com/ad3n/echo/v5`. ## Development Commands diff --git a/Makefile b/Makefile index 42fa60ffd..1e8d40fa5 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -PKG := "github.com/ad3n/echo" +PKG := "github.com/ad3n/echo/v5" PKG_LIST := $(shell go list ${PKG}/...) .DEFAULT_GOAL := check diff --git a/README.md b/README.md index d1792a170..7398a4eed 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [![Latest release](https://img.shields.io/github/v/release/labstack/echo?style=flat-square&label=release&color=00afd1)](https://github.com/labstack/echo/releases) [![Last commit](https://img.shields.io/github/last-commit/labstack/echo/master?style=flat-square)](https://github.com/labstack/echo/commits/master) [![Sourcegraph](https://sourcegraph.com/github.com/labstack/echo/-/badge.svg?style=flat-square)](https://sourcegraph.com/github.com/labstack/echo?badge) -[![GoDoc](https://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)](https://pkg.go.dev/github.com/ad3n/echo) +[![GoDoc](https://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)](https://pkg.go.dev/github.com/ad3n/echo/v5) [![Go Report Card](https://goreportcard.com/badge/github.com/labstack/echo?style=flat-square)](https://goreportcard.com/report/github.com/labstack/echo) [![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/labstack/echo/echo.yml?style=flat-square)](https://github.com/labstack/echo/actions) [![Codecov](https://img.shields.io/codecov/c/github/labstack/echo.svg?style=flat-square)](https://codecov.io/gh/labstack/echo) @@ -62,7 +62,7 @@ See [ROADMAP.md](./ROADMAP.md) for where Echo is heading and the version support ### Installation ```sh -go get github.com/ad3n/echo +go get github.com/ad3n/echo/v5 ``` Latest version of Echo supports last four Go major [releases](https://go.dev/doc/devel/release) and might work with @@ -74,8 +74,8 @@ older versions. package main import ( - "github.com/ad3n/echo" - "github.com/ad3n/echo/middleware" + "github.com/ad3n/echo/v5" + "github.com/ad3n/echo/v5/middleware" "log/slog" "net/http" ) diff --git a/binder_external_test.go b/binder_external_test.go index 13dd911ce..ab9d246ed 100644 --- a/binder_external_test.go +++ b/binder_external_test.go @@ -11,7 +11,7 @@ import ( "net/http" "net/http/httptest" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) func ExampleValueBinder_BindErrors() { diff --git a/echo.go b/echo.go index 28b886f06..edb6d7718 100644 --- a/echo.go +++ b/echo.go @@ -12,8 +12,8 @@ Example: "log/slog" "net/http" - "github.com/ad3n/echo" - "github.com/ad3n/echo/middleware" + "github.com/ad3n/echo/v5" + "github.com/ad3n/echo/v5/middleware" ) // Handler diff --git a/echo_test.go b/echo_test.go index 4882d6286..e25470661 100644 --- a/echo_test.go +++ b/echo_test.go @@ -608,7 +608,7 @@ func TestEchoFile(t *testing.T) { givenFile: "./go.mod", whenPath: "/", expectCode: http.StatusOK, - expectStartsWith: "module github.com/ad3n/echo", + expectStartsWith: "module github.com/ad3n/echo/v5", }, { name: "nok file does not exist", diff --git a/echotest/context.go b/echotest/context.go index 70cfc5d41..e48e9af45 100644 --- a/echotest/context.go +++ b/echotest/context.go @@ -13,7 +13,7 @@ import ( "strings" "testing" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // ContextConfig is configuration for creating echo.Context for testing purposes. diff --git a/echotest/context_external_test.go b/echotest/context_external_test.go index c5f23c266..6dff30aa9 100644 --- a/echotest/context_external_test.go +++ b/echotest/context_external_test.go @@ -4,8 +4,8 @@ import ( "net/http" "testing" - "github.com/ad3n/echo" - "github.com/ad3n/echo/echotest" + "github.com/ad3n/echo/v5" + "github.com/ad3n/echo/v5/echotest" "github.com/stretchr/testify/assert" ) diff --git a/echotest/context_test.go b/echotest/context_test.go index 4534d599c..8e63f1b94 100644 --- a/echotest/context_test.go +++ b/echotest/context_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/echotest/reader_external_test.go b/echotest/reader_external_test.go index 792932da4..bd9864a77 100644 --- a/echotest/reader_external_test.go +++ b/echotest/reader_external_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/ad3n/echo/echotest" + "github.com/ad3n/echo/v5/echotest" "github.com/stretchr/testify/assert" ) diff --git a/go.mod b/go.mod index 84da674e9..669591669 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/ad3n/echo +module github.com/ad3n/echo/v5 go 1.25.0 diff --git a/httperror_external_test.go b/httperror_external_test.go index 8ca417fda..9727ec51c 100644 --- a/httperror_external_test.go +++ b/httperror_external_test.go @@ -7,7 +7,7 @@ package echo_test import ( "encoding/json" "fmt" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "net/http" "net/http/httptest" ) diff --git a/llms-full.txt b/llms-full.txt index 76d23cc07..5d66c4bb7 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2,7 +2,7 @@ > Echo is a high performance, extensible, minimalist Go web framework built on the standard `net/http` library. Echo adds a fast radix-tree router, request binding with pluggable validator, a deep middleware ecosystem, centralized error handling, and template rendering on top of the standard library. -Import path: `github.com/ad3n/echo` +Import path: `github.com/ad3n/echo/v5` Go version: 1.25+ Handler signature: `func(c *echo.Context) error` @@ -343,7 +343,7 @@ echo.NewVirtualHostHandler(vhosts map[string]*Echo) *Echo ## echotest package ```go -import "github.com/ad3n/echo/echotest" +import "github.com/ad3n/echo/v5/echotest" echotest.LoadBytes(t *testing.T, name string, opts ...loadBytesOpts) []byte echotest.TrimNewlineEnd(bytes []byte) []byte @@ -366,8 +366,8 @@ import ( "syscall" "time" - "github.com/ad3n/echo" - "github.com/ad3n/echo/middleware" + "github.com/ad3n/echo/v5" + "github.com/ad3n/echo/v5/middleware" ) func main() { diff --git a/llms.txt b/llms.txt index 738c484c8..5b597ee34 100644 --- a/llms.txt +++ b/llms.txt @@ -3,7 +3,7 @@ > Echo is a high performance, extensible, minimalist Go web framework built on the standard `net/http` library. Echo adds a fast radix-tree router, request binding with pluggable validator, a deep middleware ecosystem, centralized error handling, and template rendering on top of the standard library. Key facts for LLMs: -- Import path is `github.com/ad3n/echo` +- Import path is `github.com/ad3n/echo/v5` - Handlers use `*echo.Context` (pointer to struct) - `Context` is a concrete struct, not an interface - Logging uses `log/slog` @@ -13,7 +13,7 @@ Key facts for LLMs: - [Echo v5 API Changes](https://github.com/labstack/echo/blob/master/API_CHANGES_V5.md): Breaking changes reference - [Echo Official Docs](https://echo.labstack.com): Human-readable documentation site -- [pkg.go.dev](https://pkg.go.dev/github.com/ad3n/echo): API reference on Go package discovery +- [pkg.go.dev](https://pkg.go.dev/github.com/ad3n/echo/v5): API reference on Go package discovery ## Core Patterns @@ -26,8 +26,8 @@ import ( "log/slog" "net/http" - "github.com/ad3n/echo" - "github.com/ad3n/echo/middleware" + "github.com/ad3n/echo/v5" + "github.com/ad3n/echo/v5/middleware" ) func hello(c *echo.Context) error { diff --git a/middleware/basic_auth.go b/middleware/basic_auth.go index 6d7e4ee29..c8f12de29 100644 --- a/middleware/basic_auth.go +++ b/middleware/basic_auth.go @@ -11,7 +11,7 @@ import ( "strconv" "strings" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // BasicAuthConfig defines the config for BasicAuthWithConfig middleware. diff --git a/middleware/basic_auth_test.go b/middleware/basic_auth_test.go index f44b622a2..bef393177 100644 --- a/middleware/basic_auth_test.go +++ b/middleware/basic_auth_test.go @@ -12,7 +12,7 @@ import ( "strings" "testing" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/middleware/body_dump.go b/middleware/body_dump.go index 858d5706a..298372ed8 100644 --- a/middleware/body_dump.go +++ b/middleware/body_dump.go @@ -12,7 +12,7 @@ import ( "net/http" "sync" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // BodyDumpConfig defines the config for BodyDump middleware. diff --git a/middleware/body_dump_test.go b/middleware/body_dump_test.go index 61a7703a9..2544b15b5 100644 --- a/middleware/body_dump_test.go +++ b/middleware/body_dump_test.go @@ -11,7 +11,7 @@ import ( "strings" "testing" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/middleware/body_limit.go b/middleware/body_limit.go index d77e48dc3..65a5500b4 100644 --- a/middleware/body_limit.go +++ b/middleware/body_limit.go @@ -8,7 +8,7 @@ import ( "net/http" "sync" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // BodyLimitConfig defines the config for BodyLimitWithConfig middleware. diff --git a/middleware/body_limit_test.go b/middleware/body_limit_test.go index 73fba482e..25cea4e25 100644 --- a/middleware/body_limit_test.go +++ b/middleware/body_limit_test.go @@ -10,7 +10,7 @@ import ( "net/http/httptest" "testing" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/middleware/compress.go b/middleware/compress.go index 7f443538b..4c8a09f1b 100644 --- a/middleware/compress.go +++ b/middleware/compress.go @@ -14,7 +14,7 @@ import ( "strings" "sync" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) const ( diff --git a/middleware/compress_test.go b/middleware/compress_test.go index 2947fdb7c..e8e91b671 100644 --- a/middleware/compress_test.go +++ b/middleware/compress_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/middleware/context_timeout.go b/middleware/context_timeout.go index c3964a02b..05dce5ccd 100644 --- a/middleware/context_timeout.go +++ b/middleware/context_timeout.go @@ -8,7 +8,7 @@ import ( "errors" "time" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // ContextTimeoutConfig defines the config for ContextTimeout middleware. diff --git a/middleware/context_timeout_test.go b/middleware/context_timeout_test.go index 56fe2695a..fbb617d31 100644 --- a/middleware/context_timeout_test.go +++ b/middleware/context_timeout_test.go @@ -6,7 +6,7 @@ package middleware import ( "context" "errors" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "net/http" "net/http/httptest" "net/url" diff --git a/middleware/cors.go b/middleware/cors.go index 631620db1..ef4ec0dd5 100644 --- a/middleware/cors.go +++ b/middleware/cors.go @@ -10,7 +10,7 @@ import ( "strconv" "strings" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // CORSConfig defines the config for CORS middleware. diff --git a/middleware/cors_test.go b/middleware/cors_test.go index 6bda28be0..cdfe3943c 100644 --- a/middleware/cors_test.go +++ b/middleware/cors_test.go @@ -11,7 +11,7 @@ import ( "strings" "testing" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/middleware/csrf.go b/middleware/csrf.go index 440de9586..70aefe50d 100644 --- a/middleware/csrf.go +++ b/middleware/csrf.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // CSRFUsingSecFetchSite is a context key for CSRF middleware what is set when the client browser is using Sec-Fetch-Site diff --git a/middleware/csrf_test.go b/middleware/csrf_test.go index 8bb4b35d9..6b425eaf0 100644 --- a/middleware/csrf_test.go +++ b/middleware/csrf_test.go @@ -11,7 +11,7 @@ import ( "strings" "testing" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/middleware/decompress.go b/middleware/decompress.go index 08771fe73..b1db9b68f 100644 --- a/middleware/decompress.go +++ b/middleware/decompress.go @@ -10,7 +10,7 @@ import ( "strings" "sync" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // DecompressConfig defines the config for Decompress middleware. diff --git a/middleware/decompress_test.go b/middleware/decompress_test.go index 2b5db393b..1fa8bf5fe 100644 --- a/middleware/decompress_test.go +++ b/middleware/decompress_test.go @@ -14,7 +14,7 @@ import ( "sync" "testing" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/middleware/extractor.go b/middleware/extractor.go index 10b095e0f..4265b90b3 100644 --- a/middleware/extractor.go +++ b/middleware/extractor.go @@ -8,7 +8,7 @@ import ( "net/textproto" "strings" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) const ( diff --git a/middleware/extractor_test.go b/middleware/extractor_test.go index 5ecd59029..856ff7b9c 100644 --- a/middleware/extractor_test.go +++ b/middleware/extractor_test.go @@ -13,7 +13,7 @@ import ( "strings" "testing" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/middleware/key_auth.go b/middleware/key_auth.go index f724e0aa4..4042c363f 100644 --- a/middleware/key_auth.go +++ b/middleware/key_auth.go @@ -9,7 +9,7 @@ import ( "fmt" "net/http" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // KeyAuthConfig defines the config for KeyAuth middleware. diff --git a/middleware/key_auth_test.go b/middleware/key_auth_test.go index e5ef9356d..bd8bd42d2 100644 --- a/middleware/key_auth_test.go +++ b/middleware/key_auth_test.go @@ -11,7 +11,7 @@ import ( "strings" "testing" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/middleware/method_override.go b/middleware/method_override.go index 92c56c1d1..76399434e 100644 --- a/middleware/method_override.go +++ b/middleware/method_override.go @@ -6,7 +6,7 @@ package middleware import ( "net/http" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // MethodOverrideConfig defines the config for MethodOverride middleware. diff --git a/middleware/method_override_test.go b/middleware/method_override_test.go index 8921878df..4c91d4e6e 100644 --- a/middleware/method_override_test.go +++ b/middleware/method_override_test.go @@ -9,7 +9,7 @@ import ( "net/http/httptest" "testing" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/middleware/middleware.go b/middleware/middleware.go index 8b43d5c0c..c4382189e 100644 --- a/middleware/middleware.go +++ b/middleware/middleware.go @@ -9,7 +9,7 @@ import ( "strconv" "strings" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // Skipper defines a function to skip middleware. Returning true skips processing the middleware. diff --git a/middleware/proxy.go b/middleware/proxy.go index 2192f111c..c7d49d0db 100644 --- a/middleware/proxy.go +++ b/middleware/proxy.go @@ -20,7 +20,7 @@ import ( "sync" "time" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // TODO: Handle TLS proxy diff --git a/middleware/proxy_test.go b/middleware/proxy_test.go index af9bd3f31..bdd38db76 100644 --- a/middleware/proxy_test.go +++ b/middleware/proxy_test.go @@ -20,7 +20,7 @@ import ( "testing" "time" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" "golang.org/x/net/websocket" ) diff --git a/middleware/rate_limiter.go b/middleware/rate_limiter.go index 9954d5317..994c6cf11 100644 --- a/middleware/rate_limiter.go +++ b/middleware/rate_limiter.go @@ -11,7 +11,7 @@ import ( "sync" "time" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "golang.org/x/time/rate" ) diff --git a/middleware/rate_limiter_context_test.go b/middleware/rate_limiter_context_test.go index ff2aada81..5b04b063f 100644 --- a/middleware/rate_limiter_context_test.go +++ b/middleware/rate_limiter_context_test.go @@ -9,7 +9,7 @@ import ( "strconv" "testing" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/middleware/rate_limiter_test.go b/middleware/rate_limiter_test.go index 7ff1d116d..5f1c2d830 100644 --- a/middleware/rate_limiter_test.go +++ b/middleware/rate_limiter_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" "golang.org/x/time/rate" ) diff --git a/middleware/recover.go b/middleware/recover.go index de1cb5a93..3d881bb76 100644 --- a/middleware/recover.go +++ b/middleware/recover.go @@ -8,7 +8,7 @@ import ( "net/http" "runtime" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // RecoverConfig defines the config for Recover middleware. diff --git a/middleware/recover_test.go b/middleware/recover_test.go index a3e0baf61..1808e6207 100644 --- a/middleware/recover_test.go +++ b/middleware/recover_test.go @@ -11,7 +11,7 @@ import ( "net/http/httptest" "testing" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/middleware/redirect.go b/middleware/redirect.go index b2b799228..6f05bd513 100644 --- a/middleware/redirect.go +++ b/middleware/redirect.go @@ -8,7 +8,7 @@ import ( "net/http" "strings" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // RedirectConfig defines the config for Redirect middleware. diff --git a/middleware/redirect_test.go b/middleware/redirect_test.go index a2afbd00d..880bcee62 100644 --- a/middleware/redirect_test.go +++ b/middleware/redirect_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "testing" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/middleware/request_id.go b/middleware/request_id.go index 4aa4becfc..6775feb62 100644 --- a/middleware/request_id.go +++ b/middleware/request_id.go @@ -4,7 +4,7 @@ package middleware import ( - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // RequestIDConfig defines the config for RequestID middleware. diff --git a/middleware/request_id_test.go b/middleware/request_id_test.go index f154e18e3..075872f38 100644 --- a/middleware/request_id_test.go +++ b/middleware/request_id_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "testing" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/middleware/request_logger.go b/middleware/request_logger.go index d643101b4..4e6a596da 100644 --- a/middleware/request_logger.go +++ b/middleware/request_logger.go @@ -10,7 +10,7 @@ import ( "net/http" "time" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // Example for `slog` https://pkg.go.dev/log/slog diff --git a/middleware/request_logger_test.go b/middleware/request_logger_test.go index 0a48d57a3..cc725e9a4 100644 --- a/middleware/request_logger_test.go +++ b/middleware/request_logger_test.go @@ -16,7 +16,7 @@ import ( "testing" "time" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/middleware/rewrite.go b/middleware/rewrite.go index 635de5eae..7774933c2 100644 --- a/middleware/rewrite.go +++ b/middleware/rewrite.go @@ -8,7 +8,7 @@ import ( "maps" "regexp" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // RewriteConfig defines the config for Rewrite middleware. diff --git a/middleware/rewrite_test.go b/middleware/rewrite_test.go index 5e776c95e..73ec6c9db 100644 --- a/middleware/rewrite_test.go +++ b/middleware/rewrite_test.go @@ -11,7 +11,7 @@ import ( "regexp" "testing" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/middleware/secure.go b/middleware/secure.go index e04abf692..d5ebcbd7d 100644 --- a/middleware/secure.go +++ b/middleware/secure.go @@ -6,7 +6,7 @@ package middleware import ( "fmt" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // SecureConfig defines the config for Secure middleware. diff --git a/middleware/secure_test.go b/middleware/secure_test.go index 547204fb2..e3e64598f 100644 --- a/middleware/secure_test.go +++ b/middleware/secure_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "testing" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/middleware/slash.go b/middleware/slash.go index c9a06107f..4787f7007 100644 --- a/middleware/slash.go +++ b/middleware/slash.go @@ -8,7 +8,7 @@ import ( "net/http" "strings" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // AddTrailingSlashConfig is the middleware config for adding trailing slash to the request. diff --git a/middleware/slash_test.go b/middleware/slash_test.go index 8274b401e..80dc5f880 100644 --- a/middleware/slash_test.go +++ b/middleware/slash_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "testing" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/middleware/static.go b/middleware/static.go index 652de4ef7..b3e63e350 100644 --- a/middleware/static.go +++ b/middleware/static.go @@ -17,7 +17,7 @@ import ( "strings" "sync" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" ) // StaticConfig defines the config for Static middleware. diff --git a/middleware/static_percent_test.go b/middleware/static_percent_test.go index 4adf0391e..3bd244e5e 100644 --- a/middleware/static_percent_test.go +++ b/middleware/static_percent_test.go @@ -9,7 +9,7 @@ import ( "testing" "testing/fstest" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/middleware/static_test.go b/middleware/static_test.go index 17c882a1e..f08250223 100644 --- a/middleware/static_test.go +++ b/middleware/static_test.go @@ -11,7 +11,7 @@ import ( "testing" "testing/fstest" - "github.com/ad3n/echo" + "github.com/ad3n/echo/v5" "github.com/stretchr/testify/assert" ) diff --git a/route_test.go b/route_test.go index 9002cf134..b13c909af 100644 --- a/route_test.go +++ b/route_test.go @@ -39,22 +39,22 @@ func TestHandlerName(t *testing.T) { whenHandlerFunc: func(c *Context) error { return nil }, - expect: "github.com/ad3n/echo.TestHandlerName.func2", + expect: "github.com/ad3n/echo/v5.TestHandlerName.func2", }, { name: "ok, func as named package variable", whenHandlerFunc: myNamedHandler, - expect: "github.com/ad3n/echo.init.func4", + expect: "github.com/ad3n/echo/v5.init.func4", }, { name: "ok, func as named function variable", whenHandlerFunc: myNameFuncVar, - expect: "github.com/ad3n/echo.TestHandlerName.func1", + expect: "github.com/ad3n/echo/v5.TestHandlerName.func1", }, { name: "ok, func as struct method", whenHandlerFunc: tmp.getUsers, - expect: "github.com/ad3n/echo.(*NameStruct).getUsers-fm", + expect: "github.com/ad3n/echo/v5.(*NameStruct).getUsers-fm", }, } From c5c04360dc2397c31cca2ddd1aa374deed731911 Mon Sep 17 00:00:00 2001 From: aden Date: Fri, 25 Sep 2026 11:07:18 +0700 Subject: [PATCH 5/5] improve safety --- allocation_ownership_test.go | 157 ++++++++ context.go | 41 +- echo.go | 10 +- middleware/body_dump.go | 90 +++-- middleware/body_dump_test.go | 3 +- middleware/body_limit.go | 29 +- middleware/body_limit_ownership_test.go | 121 ++++++ middleware/body_limit_test.go | 10 +- middleware/buffer_pool.go | 17 + middleware/compress.go | 229 +++++++---- middleware/decompress.go | 155 ++++++-- middleware/pool_fuzz_test.go | 104 +++++ middleware/pool_ownership_test.go | 504 ++++++++++++++++++++++++ middleware/pool_safety_bench_test.go | 110 ++++++ middleware/util.go | 24 +- response.go | 15 +- route.go | 21 +- route_allocation_test.go | 47 +++ router_test.go | 18 +- server_test.go | 115 +++--- 20 files changed, 1543 insertions(+), 277 deletions(-) create mode 100644 allocation_ownership_test.go create mode 100644 middleware/body_limit_ownership_test.go create mode 100644 middleware/buffer_pool.go create mode 100644 middleware/pool_fuzz_test.go create mode 100644 middleware/pool_ownership_test.go create mode 100644 middleware/pool_safety_bench_test.go create mode 100644 route_allocation_test.go diff --git a/allocation_ownership_test.go b/allocation_ownership_test.go new file mode 100644 index 000000000..79c8a4842 --- /dev/null +++ b/allocation_ownership_test.go @@ -0,0 +1,157 @@ +package echo + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestContextReleaseDropsRequestReferences(t *testing.T) { + e := New() + req := httptest.NewRequest(http.MethodGet, "/?tag=one&tag=two", nil) + req.Header.Set("X-Test", "original") + c := e.NewContext(req, httptest.NewRecorder()) + c.Set("payload", req) + c.SetLogger(e.Logger.With("request", req)) + c.SetPathValues(PathValues{{Name: "first", Value: "secret"}, {Name: "second", Value: "tail"}}) + c.SetPathValues(PathValues{{Name: "first", Value: "short"}}) + c.orgResponse.Before(func() { _ = req.URL }) + c.orgResponse.After(func() { _ = req.Header }) + c.dsw.ResponseWriter = c.Response() + c.route = &RouteInfo{Path: "/:first"} + c.handler = func(*Context) error { return nil } + c.path = "/:first" + query := c.QueryParams() + var bound map[string][]string + require.NoError(t, BindQueryParams(c, &bound)) + + e.ReleaseContext(c) + + assert.Nil(t, c.request) + assert.Nil(t, c.query) + assert.Empty(t, c.store) + assert.Nil(t, c.route) + assert.Nil(t, c.handler) + assert.Empty(t, c.path) + assert.Same(t, e.Logger, c.logger) + assert.Nil(t, c.orgResponse.ResponseWriter) + assert.Same(t, c.orgResponse, c.response) + assert.Nil(t, c.dsw.ResponseWriter) + assert.Empty(t, c.PathValues()) + for _, value := range (*c.pathValues)[:cap(*c.pathValues)] { + assert.Equal(t, PathValue{}, value) + } + + for _, hooks := range [][]func(){c.orgResponse.beforeFuncs, c.orgResponse.afterFuncs} { + assert.Empty(t, hooks) + for _, hook := range hooks[:cap(hooks)] { + assert.Nil(t, hook) + } + } + + assert.Equal(t, url.Values{"tag": {"one", "two"}}, query) + assert.Equal(t, []string{"one", "two"}, bound["tag"]) + assert.Equal(t, "original", req.Header.Get("X-Test")) +} + +func TestContextResetDropsOversizedStore(t *testing.T) { + c := New().NewContext(nil, nil) + for i := 0; i <= maxPooledContextStoreEntries; i++ { + c.Set(fmt.Sprint(i), i) + } + + c.Reset(nil, nil) + require.Nil(t, c.store) + c.Set("next", "value") + assert.Equal(t, "value", c.Get("next")) +} + +func TestContextResetWithoutEcho(t *testing.T) { + c := NewContext(nil, nil) + c.Set("previous", "value") + assert.NotPanics(t, func() { c.Reset(nil, nil) }) + assert.Empty(t, c.store) + assert.NotNil(t, c.Logger()) +} + +func TestResponseHooksReuseAndRelease(t *testing.T) { + r := NewResponse(httptest.NewRecorder(), New().Logger) + var before, after int + r.Before(func() { before++ }) + r.After(func() { after++ }) + beforeSlot, afterSlot := &r.beforeFuncs[0], &r.afterFuncs[0] + _, err := r.Write([]byte("one")) + require.NoError(t, err) + r.reset(httptest.NewRecorder()) + require.Nil(t, *beforeSlot) + require.Nil(t, *afterSlot) + r.Before(func() { before += 10 }) + r.After(func() { after += 10 }) + assert.Equal(t, beforeSlot, &r.beforeFuncs[0]) + assert.Equal(t, afterSlot, &r.afterFuncs[0]) + _, err = r.Write([]byte("two")) + require.NoError(t, err) + assert.Equal(t, 11, before) + assert.Equal(t, 11, after) + + for range maxPooledResponseHooks + 1 { + r.Before(func() {}) + r.After(func() {}) + } + + r.reset(nil) + assert.Nil(t, r.beforeFuncs) + assert.Nil(t, r.afterFuncs) +} + +func TestServeHTTPReleasesContextOnEveryExit(t *testing.T) { + for _, outcome := range []string{"success", "error", "panic"} { + t.Run(outcome, func(t *testing.T) { + e := New() + var captured *Context + e.GET("/", func(c *Context) error { + captured = c + c.Set("request", c.Request()) + switch outcome { + case "error": + return ErrBadRequest + case "panic": + panic("test") + } + + return c.NoContent(http.StatusNoContent) + }) + serve := func() { + e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) + } + if outcome == "panic" { + assert.Panics(t, serve) + } + + if outcome != "panic" { + assert.NotPanics(t, serve) + } + + require.NotNil(t, captured) + assert.Nil(t, captured.request) + assert.Empty(t, captured.store) + assert.Nil(t, captured.orgResponse.ResponseWriter) + }) + } +} + +func BenchmarkServeHTTP_ResponseHooks(b *testing.B) { + e := New() + hook := func() {} + e.GET("/", func(c *Context) error { + c.orgResponse.Before(hook) + c.orgResponse.After(hook) + return c.NoContent(http.StatusNoContent) + }) + benchServe(b, e, httptest.NewRequest(http.MethodGet, "/", nil)) +} diff --git a/context.go b/context.go index 09857aa56..1b8588a69 100644 --- a/context.go +++ b/context.go @@ -58,35 +58,27 @@ const ( indexPage = "index.html" ) -// Context represents the context of the current HTTP request. It holds request and -// response objects, path, path parameters, data and registered handler. type Context struct { + response http.ResponseWriter request *http.Request orgResponse *Response - response http.ResponseWriter query url.Values - // formParseMaxMemory is used for http.Request.ParseMultipartForm - formParseMaxMemory int64 - route *RouteInfo pathValues *PathValues - // handler is the route handler resolved during routing. It is invoked by the terminal of the global - // middleware chain (see Echo.buildRouterChains) so that the chain can be compiled once and reused. handler HandlerFunc - // dsw is reused by json() so that each JSON response does not heap-allocate a delayedStatusWriter. - // It lives on the pooled Context; &c.dsw is a stable, allocation-free pointer. Only json() may point - // the response at &c.dsw, and only via the nested-call guard there — aliasing it to itself (wrapping - // &c.dsw around &c.dsw) would make the response writer reference itself. - dsw delayedStatusWriter - store map[string]any echo *Echo logger *slog.Logger path string + + dsw delayedStatusWriter + + formParseMaxMemory int64 + lock sync.RWMutex } @@ -134,27 +126,32 @@ func newContext(r *http.Request, w http.ResponseWriter, e *Echo) *Context { return c } -// Reset resets the context after request completes. It must be called along -// with `Echo#AcquireContext()` and `Echo#ReleaseContext()`. -// See `Echo#ServeHTTP()` func (c *Context) Reset(r *http.Request, w http.ResponseWriter) { c.request = r c.orgResponse.reset(w) c.response = c.orgResponse c.query = nil - // clear (rather than nil) keeps the map allocated on the pooled Context so that requests using Set - // do not allocate a fresh map each time. clear(nil) is a no-op. - clear(c.store) - c.logger = c.echo.Logger + if len(c.store) > maxPooledContextStoreEntries { + c.store = nil + } + clear(c.store) c.route = nil c.handler = nil c.dsw = delayedStatusWriter{} c.path = "" - // NOTE: empty by setting length to 0. PathValues has to have capacity of c.echo.contextPathParamAllocSize at all times + clear((*c.pathValues)[:cap(*c.pathValues)]) *c.pathValues = (*c.pathValues)[:0] + if c.echo == nil { + c.logger = slog.Default() + return + } + + c.logger = c.echo.Logger } +const maxPooledContextStoreEntries = 256 + func (c *Context) writeContentType(value string) { header := c.response.Header() if header.Get(HeaderContentType) == "" { diff --git a/echo.go b/echo.go index edb6d7718..3c701ee6d 100644 --- a/echo.go +++ b/echo.go @@ -792,9 +792,8 @@ func (e *Echo) AcquireContext() *Context { return e.contextPool.Get().(*Context) } -// ReleaseContext returns the `Context` instance back to the pool. -// You must call it after `AcquireContext()`. func (e *Echo) ReleaseContext(c *Context) { + c.Reset(nil, nil) e.contextPool.Put(c) } @@ -806,9 +805,12 @@ func (e *Echo) ServeHTTP(w http.ResponseWriter, r *http.Request) { // serveHTTP implements `http.Handler` interface, which serves HTTP requests. func (e *Echo) serveHTTP(w http.ResponseWriter, r *http.Request) { c := e.contextPool.Get().(*Context) - defer e.contextPool.Put(c) + defer e.ReleaseContext(c) - c.Reset(r, w) + c.request = r + c.orgResponse.ResponseWriter = w + c.orgResponse.Status = http.StatusOK + c.logger = e.Logger // The global (e.chain) and pre-middleware (e.preChain) chains are compiled once in buildRouterChains and // reused here, so no middleware closures are allocated per request. diff --git a/middleware/body_dump.go b/middleware/body_dump.go index 298372ed8..972cc82c0 100644 --- a/middleware/body_dump.go +++ b/middleware/body_dump.go @@ -11,6 +11,7 @@ import ( "net" "net/http" "sync" + "sync/atomic" "github.com/ad3n/echo/v5" ) @@ -69,17 +70,19 @@ func BodyDumpWithConfig(config BodyDumpConfig) echo.MiddlewareFunc { return toMiddlewareOrPanic(config) } -// ToMiddleware converts BodyDumpConfig to middleware or returns an error for invalid configuration func (config BodyDumpConfig) ToMiddleware() (echo.MiddlewareFunc, error) { if config.Handler == nil { return nil, errors.New("echo body-dump middleware requires a handler function") } + if config.Skipper == nil { config.Skipper = DefaultSkipper } + if config.MaxRequestBytes == 0 { config.MaxRequestBytes = 5 * MB } + if config.MaxResponseBytes == 0 { config.MaxResponseBytes = 5 * MB } @@ -91,58 +94,99 @@ func (config BodyDumpConfig) ToMiddleware() (echo.MiddlewareFunc, error) { } reqBuf := bodyDumpBufferPool.Get().(*bytes.Buffer) + defer releaseMiddlewareBuffer(&bodyDumpBufferPool, reqBuf) + reqBuf.Reset() - defer bodyDumpBufferPool.Put(reqBuf) + req := c.Request() + source := req.Body + if source == nil { + source = http.NoBody + } - var bodyReader io.Reader = c.Request().Body + var bodyReader io.Reader = source if config.MaxRequestBytes > 0 { - bodyReader = io.LimitReader(c.Request().Body, config.MaxRequestBytes) - } - _, readErr := io.Copy(reqBuf, bodyReader) - if readErr != nil && readErr != io.EOF { - return readErr + bodyReader = io.LimitReader(source, config.MaxRequestBytes) } - if config.MaxRequestBytes > 0 { - // Drain any remaining body data to prevent connection issues - _, _ = io.Copy(io.Discard, c.Request().Body) - _ = c.Request().Body.Close() + + if _, err := io.Copy(reqBuf, bodyReader); err != nil { + return err } - reqBody := make([]byte, reqBuf.Len()) - copy(reqBody, reqBuf.Bytes()) - c.Request().Body = io.NopCloser(bytes.NewReader(reqBody)) + reqBody := bytes.Clone(reqBuf.Bytes()) + replay := &replayReadCloser{ + prefix: reqBody, + source: source, + } + req.Body = replay - // response part resBuf := bodyDumpBufferPool.Get().(*bytes.Buffer) - resBuf.Reset() - defer bodyDumpBufferPool.Put(resBuf) + defer releaseMiddlewareBuffer(&bodyDumpBufferPool, resBuf) + resBuf.Reset() var respWriter io.Writer - if config.MaxResponseBytes > 0 { + switch { + case config.MaxResponseBytes > 0: respWriter = &limitedWriter{ response: c.Response(), dumpBuf: resBuf, limit: config.MaxResponseBytes, } - } else { + default: respWriter = io.MultiWriter(c.Response(), resBuf) } + writer := &bodyDumpResponseWriter{ Writer: respWriter, ResponseWriter: c.Response(), } c.SetResponse(writer) + defer func() { + writer.Writer = writer.ResponseWriter + if c.Response() == writer { + c.SetResponse(writer.ResponseWriter) + } + }() err := next(c) + if !replay.consumed.Load() { + reqBody = bytes.Clone(reqBody) + } - // Callback - config.Handler(c, reqBody, resBuf.Bytes(), err) - + config.Handler(c, reqBody, bytes.Clone(resBuf.Bytes()), err) return err } }, nil } +type replayReadCloser struct { + source io.ReadCloser + prefix []byte + offset int + consumed atomic.Bool +} + +func (r *replayReadCloser) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + + if r.offset < len(r.prefix) { + n := copy(p, r.prefix[r.offset:]) + r.offset += n + if r.offset == len(r.prefix) { + r.consumed.Store(true) + } + + return n, nil + } + + return r.source.Read(p) +} + +func (r *replayReadCloser) Close() error { + return r.source.Close() +} + func (w *bodyDumpResponseWriter) WriteHeader(code int) { w.ResponseWriter.WriteHeader(code) } diff --git a/middleware/body_dump_test.go b/middleware/body_dump_test.go index 2544b15b5..0c4a0f8af 100644 --- a/middleware/body_dump_test.go +++ b/middleware/body_dump_test.go @@ -280,8 +280,7 @@ func TestBodyDump_RequestExceedsLimit(t *testing.T) { assert.NoError(t, err) assert.Equal(t, int(limit), len(requestBodyDumped), "Dumped request should be truncated to limit") assert.Equal(t, strings.Repeat("A", 1024), requestBodyDumped, "Dumped data should match first N bytes") - // Handler should receive truncated data (what was dumped) - assert.Equal(t, strings.Repeat("A", 1024), rec.Body.String()) + assert.Equal(t, largeData, rec.Body.String()) } func TestBodyDump_RequestAtExactLimit(t *testing.T) { diff --git a/middleware/body_limit.go b/middleware/body_limit.go index 65a5500b4..d284dde74 100644 --- a/middleware/body_limit.go +++ b/middleware/body_limit.go @@ -6,7 +6,6 @@ package middleware import ( "io" "net/http" - "sync" "github.com/ad3n/echo/v5" ) @@ -21,8 +20,8 @@ type BodyLimitConfig struct { } type limitedReader struct { - BodyLimitConfig reader io.ReadCloser + limit int64 read int64 } @@ -43,37 +42,30 @@ func BodyLimitWithConfig(config BodyLimitConfig) echo.MiddlewareFunc { return toMiddlewareOrPanic(config) } -// ToMiddleware converts BodyLimitConfig to middleware or returns an error for invalid configuration func (config BodyLimitConfig) ToMiddleware() (echo.MiddlewareFunc, error) { if config.Skipper == nil { config.Skipper = DefaultSkipper } - pool := sync.Pool{ - New: func() any { - return &limitedReader{BodyLimitConfig: config} - }, - } return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c *echo.Context) error { if config.Skipper(c) { return next(c) } - req := c.Request() - // Based on content length + req := c.Request() if req.ContentLength > config.LimitBytes { return echo.ErrStatusRequestEntityTooLarge } - // Based on content read - r, ok := pool.Get().(*limitedReader) - if !ok { - return echo.NewHTTPError(http.StatusInternalServerError, "invalid pool object") + if req.Body == nil { + req.Body = http.NoBody + } + + req.Body = &limitedReader{ + reader: req.Body, + limit: config.LimitBytes, } - r.Reset(req.Body) - defer pool.Put(r) - req.Body = r return next(c) } @@ -83,9 +75,10 @@ func (config BodyLimitConfig) ToMiddleware() (echo.MiddlewareFunc, error) { func (r *limitedReader) Read(b []byte) (n int, err error) { n, err = r.reader.Read(b) r.read += int64(n) - if r.read > r.LimitBytes { + if r.read > r.limit { return n, echo.ErrStatusRequestEntityTooLarge } + return } diff --git a/middleware/body_limit_ownership_test.go b/middleware/body_limit_ownership_test.go new file mode 100644 index 000000000..33e4511f4 --- /dev/null +++ b/middleware/body_limit_ownership_test.go @@ -0,0 +1,121 @@ +package middleware + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/ad3n/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type trackedLimitBody struct { + *strings.Reader + closed bool +} + +func (b *trackedLimitBody) Close() error { + b.closed = true + return nil +} + +func TestBodyLimitRequestOwnsReader(t *testing.T) { + for _, outcome := range []string{"success", "error", "panic"} { + t.Run(outcome, func(t *testing.T) { + e := echo.New() + h := BodyLimit(16)(func(c *echo.Context) error { + switch c.Request().Header.Get("X-Outcome") { + case "error": + return errors.New("handler error") + case "panic": + panic("handler panic") + } + + return nil + }) + firstBody := &trackedLimitBody{Reader: strings.NewReader("first")} + first := httptest.NewRequest(http.MethodPost, "/", nil) + first.Body = firstBody + first.ContentLength = -1 + first.Header.Set("X-Outcome", outcome) + call := func() { + err := h(e.NewContext(first, httptest.NewRecorder())) + if outcome == "error" { + require.Error(t, err) + return + } + + require.NoError(t, err) + } + if outcome == "panic" { + assert.Panics(t, call) + } + + if outcome != "panic" { + call() + } + + secondBody := &trackedLimitBody{Reader: strings.NewReader("second")} + second := httptest.NewRequest(http.MethodPost, "/", nil) + second.Body = secondBody + second.ContentLength = -1 + require.NoError(t, h(e.NewContext(second, httptest.NewRecorder()))) + assert.NotSame(t, first.Body, second.Body) + data, err := io.ReadAll(first.Body) + require.NoError(t, err) + assert.Equal(t, "first", string(data)) + require.NoError(t, first.Body.Close()) + assert.True(t, firstBody.closed) + assert.False(t, secondBody.closed) + data, err = io.ReadAll(second.Body) + require.NoError(t, err) + assert.Equal(t, "second", string(data)) + }) + } +} + +func TestBodyLimitConcurrentRetainedReaders(t *testing.T) { + e := echo.New() + h := BodyLimit(16)(func(*echo.Context) error { return nil }) + var wg sync.WaitGroup + for range 32 { + wg.Go(func() { + for range 32 { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("payload")) + c := e.NewContext(req, httptest.NewRecorder()) + if err := h(c); err != nil { + t.Error(err) + return + } + + data, err := io.ReadAll(req.Body) + if err != nil || string(data) != "payload" { + t.Errorf("body = %q, error = %v", data, err) + return + } + } + }) + } + + wg.Wait() +} + +func BenchmarkBodyLimitReaderOwnership(b *testing.B) { + e := echo.New() + h := BodyLimit(16)(func(*echo.Context) error { return nil }) + req := httptest.NewRequest(http.MethodPost, "/", nil) + c := e.NewContext(req, httptest.NewRecorder()) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + req.Body = http.NoBody + if err := h(c); err != nil { + b.Fatal(err) + } + } +} diff --git a/middleware/body_limit_test.go b/middleware/body_limit_test.go index 25cea4e25..2d5a2dc47 100644 --- a/middleware/body_limit_test.go +++ b/middleware/body_limit_test.go @@ -96,21 +96,15 @@ func TestBodyLimitAfterDecompressUsesDecodedSize(t *testing.T) { func TestBodyLimitReader(t *testing.T) { hw := []byte("Hello, World!") - config := BodyLimitConfig{ - Skipper: DefaultSkipper, - LimitBytes: 2, - } reader := &limitedReader{ - BodyLimitConfig: config, - reader: io.NopCloser(bytes.NewReader(hw)), + limit: 2, + reader: io.NopCloser(bytes.NewReader(hw)), } - // read all should return ErrStatusRequestEntityTooLarge _, err := io.ReadAll(reader) he := err.(echo.HTTPStatusCoder) assert.Equal(t, http.StatusRequestEntityTooLarge, he.StatusCode()) - // reset reader and read two bytes must succeed bt := make([]byte, 2) reader.Reset(io.NopCloser(bytes.NewReader(hw))) n, err := reader.Read(bt) diff --git a/middleware/buffer_pool.go b/middleware/buffer_pool.go new file mode 100644 index 000000000..717fa3d0f --- /dev/null +++ b/middleware/buffer_pool.go @@ -0,0 +1,17 @@ +package middleware + +import ( + "bytes" + "sync" +) + +const maxPooledMiddlewareBuffer = 64 << 10 + +func releaseMiddlewareBuffer(pool *sync.Pool, buffer *bytes.Buffer) { + if buffer.Cap() > maxPooledMiddlewareBuffer { + return + } + + buffer.Reset() + pool.Put(buffer) +} diff --git a/middleware/compress.go b/middleware/compress.go index 4c8a09f1b..4ccf472e6 100644 --- a/middleware/compress.go +++ b/middleware/compress.go @@ -47,12 +47,13 @@ type GzipConfig struct { type gzipResponseWriter struct { io.Writer http.ResponseWriter + buffer *bytes.Buffer + minLength int + code int wroteHeader bool wroteBody bool - minLength int minLengthExceeded bool - buffer *bytes.Buffer - code int + finalized bool } // Gzip returns a middleware which compresses HTTP response using gzip compression scheme. @@ -65,17 +66,19 @@ func GzipWithConfig(config GzipConfig) echo.MiddlewareFunc { return toMiddlewareOrPanic(config) } -// ToMiddleware converts GzipConfig to middleware or returns an error for invalid configuration func (config GzipConfig) ToMiddleware() (echo.MiddlewareFunc, error) { if config.Skipper == nil { config.Skipper = DefaultSkipper } - if config.Level < -2 || config.Level > 9 { // these are consts: gzip.HuffmanOnly and gzip.BestCompression + + if config.Level < -2 || config.Level > 9 { return nil, errors.New("invalid gzip level") } + if config.Level == 0 { config.Level = -1 } + if config.MinLength < 0 { config.MinLength = 0 } @@ -84,117 +87,173 @@ func (config GzipConfig) ToMiddleware() (echo.MiddlewareFunc, error) { bpool := bufferPool() return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c *echo.Context) error { + return func(c *echo.Context) (err error) { if config.Skipper(c) { return next(c) } - res := c.Response() - res.Header().Add(echo.HeaderVary, echo.HeaderAcceptEncoding) - if strings.Contains(c.Request().Header.Get(echo.HeaderAcceptEncoding), gzipScheme) { - i := pool.Get() - w, ok := i.(*gzip.Writer) - if !ok { - return echo.NewHTTPError(http.StatusInternalServerError, "invalid pool object") - } - rw := res - w.Reset(rw) - buf := bpool.Get().(*bytes.Buffer) + rw := c.Response() + rw.Header().Add(echo.HeaderVary, echo.HeaderAcceptEncoding) + if !strings.Contains(c.Request().Header.Get(echo.HeaderAcceptEncoding), gzipScheme) { + return next(c) + } + + encoder, ok := pool.Get().(*gzipEncoder) + if !ok || encoder == nil { + return echo.NewHTTPError(http.StatusInternalServerError, "invalid pool object") + } + + w := encoder.writer + encoder.output.writer = rw + w.Reset(&encoder.output) + var buf *bytes.Buffer + if config.MinLength > 0 { + buf = bpool.Get().(*bytes.Buffer) buf.Reset() + } - grw := &gzipResponseWriter{ - Writer: w, - ResponseWriter: rw, - minLength: config.MinLength, - buffer: buf, - } - c.SetResponse(grw) + grw := &gzipResponseWriter{ + Writer: w, + ResponseWriter: rw, + minLength: config.MinLength, + buffer: buf, + } + c.SetResponse(grw) + completed := false + defer func() { defer func() { - // There are different reasons for cases when we have not yet written response to the client and now need to do so. - // a) handler response had only response code and no response body (ala 404 or redirects etc). Response code need to be written now. - // b) body is shorter than our minimum length threshold and being buffered currently and needs to be written - if !grw.wroteBody { - if res.Header().Get(echo.HeaderContentEncoding) == gzipScheme { - res.Header().Del(echo.HeaderContentEncoding) + grw.finalized = true + grw.Writer = closedGzipWriter{} + if !grw.minLengthExceeded { + grw.Writer = rw + if c.Response() == grw { + c.SetResponse(rw) } - if grw.wroteHeader { - rw.WriteHeader(grw.code) - } - // We have to reset response to it's pristine state when - // nothing is written to body or error is returned. - // See issue #424, #407. - c.SetResponse(rw) - w.Reset(io.Discard) - } else if !grw.minLengthExceeded { - // Write uncompressed response - c.SetResponse(rw) - if grw.wroteHeader { - grw.ResponseWriter.WriteHeader(grw.code) - } - _, _ = grw.buffer.WriteTo(rw) - w.Reset(io.Discard) } - _ = w.Close() - bpool.Put(buf) - pool.Put(w) + + grw.buffer = nil + encoder.output.writer = io.Discard + w.Header = gzip.Header{} + if buf != nil { + releaseMiddlewareBuffer(&bpool, buf) + } + + pool.Put(encoder) }() - } - return next(c) + + if !completed { + return + } + + if grw.minLengthExceeded { + if closeErr := w.Close(); err == nil { + err = closeErr + } + + return + } + + if !grw.wroteBody && rw.Header().Get(echo.HeaderContentEncoding) == gzipScheme { + rw.Header().Del(echo.HeaderContentEncoding) + } + + if grw.wroteHeader { + rw.WriteHeader(grw.code) + } + + if buf != nil { + if _, writeErr := buf.WriteTo(rw); err == nil { + err = writeErr + } + } + }() + + err = next(c) + completed = true + return err } }, nil } +type closedGzipWriter struct{} + +func (closedGzipWriter) Write([]byte) (int, error) { + return 0, io.ErrClosedPipe +} + func (w *gzipResponseWriter) WriteHeader(code int) { - w.Header().Del(echo.HeaderContentLength) // Issue #444 + if w.finalized { + if !w.minLengthExceeded { + w.ResponseWriter.WriteHeader(code) + } - w.wroteHeader = true + return + } - // Delay writing of the header until we know if we'll actually compress the response + w.Header().Del(echo.HeaderContentLength) + w.wroteHeader = true w.code = code } func (w *gzipResponseWriter) Write(b []byte) (int, error) { + if w.finalized { + return w.Writer.Write(b) + } + if w.Header().Get(echo.HeaderContentType) == "" { w.Header().Set(echo.HeaderContentType, http.DetectContentType(b)) } - w.wroteBody = true - if !w.minLengthExceeded { - n, err := w.buffer.Write(b) + w.wroteBody = true + if w.minLengthExceeded { + return w.Writer.Write(b) + } - if w.buffer.Len() >= w.minLength { - w.minLengthExceeded = true + if w.minLength == 0 { + w.startCompression() + return w.Writer.Write(b) + } - // The minimum length is exceeded, add Content-Encoding header and write the header - w.Header().Set(echo.HeaderContentEncoding, gzipScheme) // Issue #806 - if w.wroteHeader { - w.ResponseWriter.WriteHeader(w.code) - } + if len(b) < w.minLength-w.buffer.Len() { + return w.buffer.Write(b) + } - return w.Writer.Write(w.buffer.Bytes()) + w.startCompression() + if w.buffer.Len() > 0 { + if _, err := w.buffer.WriteTo(w.Writer); err != nil { + return 0, err } - - return n, err } return w.Writer.Write(b) } +func (w *gzipResponseWriter) startCompression() { + w.minLengthExceeded = true + w.Header().Del(echo.HeaderContentLength) + w.Header().Set(echo.HeaderContentEncoding, gzipScheme) + if w.wroteHeader { + w.ResponseWriter.WriteHeader(w.code) + } +} + func (w *gzipResponseWriter) Flush() { + if w.finalized { + _ = http.NewResponseController(w.ResponseWriter).Flush() + return + } + if !w.minLengthExceeded { - // Enforce compression because we will not know how much more data will come - w.minLengthExceeded = true - w.Header().Set(echo.HeaderContentEncoding, gzipScheme) // Issue #806 - if w.wroteHeader { - w.ResponseWriter.WriteHeader(w.code) + w.startCompression() + if w.buffer != nil { + _, _ = w.buffer.WriteTo(w.Writer) } - - _, _ = w.Writer.Write(w.buffer.Bytes()) } if gw, ok := w.Writer.(*gzip.Writer); ok { - gw.Flush() + _ = gw.Flush() } + _ = http.NewResponseController(w.ResponseWriter).Flush() } @@ -216,15 +275,31 @@ func (w *gzipResponseWriter) Push(target string, opts *http.PushOptions) error { func gzipCompressPool(config GzipConfig) sync.Pool { return sync.Pool{ New: func() any { - w, err := gzip.NewWriterLevel(io.Discard, config.Level) + encoder := &gzipEncoder{output: gzipOutput{writer: io.Discard}} + w, err := gzip.NewWriterLevel(&encoder.output, config.Level) if err != nil { return err } - return w + + encoder.writer = w + return encoder }, } } +type gzipEncoder struct { + writer *gzip.Writer + output gzipOutput +} + +type gzipOutput struct { + writer io.Writer +} + +func (w *gzipOutput) Write(p []byte) (int, error) { + return w.writer.Write(p) +} + func bufferPool() sync.Pool { return sync.Pool{ New: func() any { diff --git a/middleware/decompress.go b/middleware/decompress.go index b1db9b68f..63c8ec458 100644 --- a/middleware/decompress.go +++ b/middleware/decompress.go @@ -4,6 +4,7 @@ package middleware import ( + "bufio" "compress/gzip" "io" "net/http" @@ -62,15 +63,15 @@ func DecompressWithConfig(config DecompressConfig) echo.MiddlewareFunc { return toMiddlewareOrPanic(config) } -// ToMiddleware converts DecompressConfig to middleware or returns an error for invalid configuration func (config DecompressConfig) ToMiddleware() (echo.MiddlewareFunc, error) { if config.Skipper == nil { config.Skipper = DefaultSkipper } + if config.GzipDecompressPool == nil { config.GzipDecompressPool = &DefaultGzipDecompressPool{} } - // Apply secure default for decompression limit + if config.MaxDecompressedSize == 0 { config.MaxDecompressedSize = 100 * MB } @@ -83,7 +84,13 @@ func (config DecompressConfig) ToMiddleware() (echo.MiddlewareFunc, error) { return next(c) } - if !isGzipContentEncoding(c.Request().Header.Get(echo.HeaderContentEncoding)) { + req := c.Request() + if req.Body == nil { + req.Body = http.NoBody + return next(c) + } + + if !isGzipContentEncoding(req.Header.Get(echo.HeaderContentEncoding)) { return next(c) } @@ -93,71 +100,145 @@ func (config DecompressConfig) ToMiddleware() (echo.MiddlewareFunc, error) { if err, isErr := i.(error); isErr { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } + return echo.NewHTTPError(http.StatusInternalServerError, "unexpected type from gzip decompression pool") } - defer pool.Put(gr) - - b := c.Request().Body - defer b.Close() - if err := gr.Reset(b); err != nil { - if err == io.EOF { //ignore if body is empty + input := newGzipInput(req.Body) + if err := gr.Reset(input); err != nil { + releaseGzipReader(&pool, gr, input) + if err == io.EOF { return next(c) } + return err } - // only Close gzip reader if it was set to a proper gzip source otherwise it will panic on close. - defer gr.Close() - - // Apply decompression size limit to prevent zip bombs - if config.MaxDecompressedSize > 0 { - c.Request().Body = &limitedGzipReader{ - Reader: gr, - remaining: config.MaxDecompressedSize, - limit: config.MaxDecompressedSize, - } - } else { - // -1 means explicitly unlimited (not recommended) - c.Request().Body = gr + req.Body = &limitedGzipReader{ + reader: gr, + input: input, + source: req.Body, + pool: &pool, + remaining: config.MaxDecompressedSize, + limited: config.MaxDecompressedSize > 0, } - c.Request().ContentLength = -1 + req.ContentLength = -1 return next(c) } }, nil } -// isGzipContentEncoding reports whether Content-Encoding is gzip. -// Content codings are case-insensitive per RFC 9110 §8.4.1. func isGzipContentEncoding(v string) bool { return strings.EqualFold(v, GZIPEncoding) } -// limitedGzipReader wraps a gzip reader with size limiting to prevent zip bombs type limitedGzipReader struct { - *gzip.Reader + reader *gzip.Reader + input *gzipInput + source io.ReadCloser + pool *sync.Pool + terminal error + closeErr error remaining int64 - limit int64 + mu sync.Mutex + closeOnce sync.Once + limited bool } -func (r *limitedGzipReader) Read(p []byte) (n int, err error) { - if r.remaining <= 0 { - // Limit exceeded - return 413 error - return 0, echo.ErrStatusRequestEntityTooLarge +func (r *limitedGzipReader) Read(p []byte) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if len(p) == 0 { + return 0, nil + } + + if r.terminal != nil { + return 0, r.terminal + } + + if r.limited && r.remaining == 0 { + var probe [1]byte + n, err := r.reader.Read(probe[:]) + if n > 0 { + err = echo.ErrStatusRequestEntityTooLarge + } + + if err != nil { + r.finish(err) + } + + return 0, err } - // Limit the read to remaining bytes - if int64(len(p)) > r.remaining { + if r.limited && int64(len(p)) > r.remaining { p = p[:r.remaining] } - n, err = r.Reader.Read(p) - r.remaining -= int64(n) + n, err := r.reader.Read(p) + if r.limited { + r.remaining -= int64(n) + } + + if err != nil { + r.finish(err) + } return n, err } +func (r *limitedGzipReader) finish(err error) { + r.terminal = err + if r.reader == nil { + return + } + + _ = r.reader.Close() + releaseGzipReader(r.pool, r.reader, r.input) + r.reader = nil + r.input = nil + r.pool = nil +} + func (r *limitedGzipReader) Close() error { - return r.Reader.Close() + r.closeOnce.Do(func() { + r.closeErr = r.source.Close() + }) + r.mu.Lock() + defer r.mu.Unlock() + + r.finish(http.ErrBodyReadAfterClose) + return r.closeErr +} + +type gzipEOFReader struct{} + +func (gzipEOFReader) Read([]byte) (int, error) { + return 0, io.EOF +} + +func (gzipEOFReader) ReadByte() (byte, error) { + return 0, io.EOF +} + +type gzipInput struct { + io.Reader + io.ByteReader +} + +func newGzipInput(source io.Reader) *gzipInput { + if reader, ok := source.(io.ByteReader); ok { + return &gzipInput{Reader: source, ByteReader: reader} + } + + buffer := bufio.NewReader(source) + return &gzipInput{Reader: buffer, ByteReader: buffer} +} + +func releaseGzipReader(pool *sync.Pool, reader *gzip.Reader, input *gzipInput) { + input.Reader = gzipEOFReader{} + input.ByteReader = gzipEOFReader{} + _ = reader.Reset(gzipEOFReader{}) + pool.Put(reader) } diff --git a/middleware/pool_fuzz_test.go b/middleware/pool_fuzz_test.go new file mode 100644 index 000000000..4d8188071 --- /dev/null +++ b/middleware/pool_fuzz_test.go @@ -0,0 +1,104 @@ +package middleware + +import ( + "bytes" + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ad3n/echo/v5" +) + +func FuzzDecompressLifecycle(f *testing.F) { + f.Add([]byte{}) + f.Add([]byte("invalid gzip")) + compressed, err := gzipString("seed") + if err != nil { + f.Fatal(err) + } + + f.Add(compressed) + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > 64<<10 { + t.Skip() + } + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(data)) + req.Header.Set(echo.HeaderContentEncoding, GZIPEncoding) + h := DecompressWithConfig(DecompressConfig{MaxDecompressedSize: 1024})(func(c *echo.Context) error { + _, err := io.Copy(io.Discard, c.Request().Body) + return err + }) + _ = h(echo.New().NewContext(req, httptest.NewRecorder())) + _ = req.Body.Close() + _ = req.Body.Close() + }) +} + +func FuzzPoolMiddlewareRoundTrip(f *testing.F) { + f.Add([]byte("seed"), uint8(3), false) + f.Add([]byte{}, uint8(0), true) + f.Fuzz(func(t *testing.T, payload []byte, threshold uint8, reverse bool) { + if len(payload) > 64<<10 { + t.Skip() + } + + var compressed bytes.Buffer + gw := gzip.NewWriter(&compressed) + if _, err := gw.Write(payload); err != nil { + t.Fatal(err) + } + + if err := gw.Close(); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/", &compressed) + req.Header.Set(echo.HeaderContentEncoding, GZIPEncoding) + req.Header.Set(echo.HeaderAcceptEncoding, gzipScheme) + dump := BodyDumpWithConfig(BodyDumpConfig{ + MaxRequestBytes: int64(threshold) + 1, + MaxResponseBytes: int64(threshold) + 1, + Handler: func(*echo.Context, []byte, []byte, error) {}, + }) + h := GzipWithConfig(GzipConfig{MinLength: int(threshold)})(func(c *echo.Context) error { + data, err := io.ReadAll(c.Request().Body) + if err != nil { + return err + } + + return c.Blob(http.StatusOK, echo.MIMEOctetStream, data) + }) + if reverse { + h = dump(Decompress()(h)) + } + + if !reverse { + h = Decompress()(dump(h)) + } + + rec := httptest.NewRecorder() + if err := h(echo.New().NewContext(req, rec)); err != nil { + t.Fatal(err) + } + + _ = req.Body.Close() + var result io.Reader = rec.Body + if rec.Header().Get(echo.HeaderContentEncoding) == gzipScheme { + gr, err := gzip.NewReader(rec.Body) + if err != nil { + t.Fatal(err) + } + defer gr.Close() + + result = gr + } + + decoded, err := io.ReadAll(result) + if err != nil || !bytes.Equal(payload, decoded) { + t.Fatalf("round-trip mismatch: input %d bytes, output %d bytes, error %v", len(payload), len(decoded), err) + } + }) +} diff --git a/middleware/pool_ownership_test.go b/middleware/pool_ownership_test.go new file mode 100644 index 000000000..d4b68d3a5 --- /dev/null +++ b/middleware/pool_ownership_test.go @@ -0,0 +1,504 @@ +package middleware + +import ( + "bytes" + "compress/gzip" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/ad3n/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func compressedOwnershipRequest(t *testing.T, value string) *http.Request { + t.Helper() + compressed, err := gzipString(value) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(compressed)) + req.Header.Set(echo.HeaderContentEncoding, GZIPEncoding) + return req +} + +func TestDecompressBodyOutlivesMiddleware(t *testing.T) { + for _, limit := range []int64{-1, 32} { + for _, outcome := range []string{"success", "error", "panic"} { + t.Run(fmt.Sprintf("%d/%s", limit, outcome), func(t *testing.T) { + e := echo.New() + h := DecompressWithConfig(DecompressConfig{MaxDecompressedSize: limit})(func(*echo.Context) error { + switch outcome { + case "error": + return echo.ErrBadRequest + case "panic": + panic("handler") + } + + return nil + }) + requests := []*http.Request{ + compressedOwnershipRequest(t, "first"), + compressedOwnershipRequest(t, "second"), + } + for _, req := range requests { + call := func() { + err := h(e.NewContext(req, httptest.NewRecorder())) + if outcome == "error" { + require.ErrorIs(t, err, echo.ErrBadRequest) + return + } + + require.NoError(t, err) + } + if outcome == "panic" { + assert.Panics(t, call) + continue + } + + call() + } + + for i, req := range requests { + data, err := io.ReadAll(req.Body) + require.NoError(t, err) + assert.Equal(t, []string{"first", "second"}[i], string(data)) + reader := req.Body.(*limitedGzipReader) + assert.Nil(t, reader.reader) + assert.Nil(t, reader.input) + assert.Nil(t, reader.pool) + _, err = req.Body.Read(make([]byte, 1)) + require.ErrorIs(t, err, io.EOF) + require.NoError(t, req.Body.Close()) + require.NoError(t, req.Body.Close()) + _, err = req.Body.Read(make([]byte, 1)) + assert.ErrorIs(t, err, http.ErrBodyReadAfterClose) + } + }) + } + } +} + +func TestDecompressExactLimitAndTerminalErrors(t *testing.T) { + for _, value := range []string{"", "abcd", "abcde"} { + t.Run(value, func(t *testing.T) { + req := compressedOwnershipRequest(t, value) + h := DecompressWithConfig(DecompressConfig{MaxDecompressedSize: 4})(func(*echo.Context) error { return nil }) + require.NoError(t, h(echo.New().NewContext(req, httptest.NewRecorder()))) + var output bytes.Buffer + one := make([]byte, 1) + for { + n, err := req.Body.Read(one) + output.Write(one[:n]) + if err != nil { + if len(value) > 4 { + assert.ErrorIs(t, err, echo.ErrStatusRequestEntityTooLarge) + break + } + + assert.ErrorIs(t, err, io.EOF) + break + } + } + + assert.Equal(t, value[:min(4, len(value))], output.String()) + require.NoError(t, req.Body.Close()) + }) + } +} + +type blockingGzipSource struct { + started chan struct{} + closed chan struct{} + header *bytes.Reader + startOnce sync.Once + closeOnce sync.Once + closes atomic.Int32 +} + +func (r *blockingGzipSource) Read(p []byte) (int, error) { + if r.header.Len() > 0 { + return r.header.Read(p) + } + + r.startOnce.Do(func() { close(r.started) }) + <-r.closed + return 0, io.ErrClosedPipe +} + +func (r *blockingGzipSource) Close() error { + r.closeOnce.Do(func() { + r.closes.Add(1) + close(r.closed) + }) + return nil +} + +func TestDecompressCloseUnblocksRead(t *testing.T) { + compressed, err := gzipString("payload") + require.NoError(t, err) + source := &blockingGzipSource{ + header: bytes.NewReader(compressed[:10]), + started: make(chan struct{}), + closed: make(chan struct{}), + } + t.Cleanup(func() { _ = source.Close() }) + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Body = source + req.Header.Set(echo.HeaderContentEncoding, GZIPEncoding) + require.NoError(t, Decompress()(func(*echo.Context) error { return nil })(echo.New().NewContext(req, httptest.NewRecorder()))) + readDone := make(chan error, 1) + go func() { + _, err := req.Body.Read(make([]byte, 16)) + readDone <- err + }() + select { + case <-source.started: + case <-time.After(3 * time.Second): + t.Fatal("read did not reach source") + } + + closeDone := make(chan error, 1) + go func() { closeDone <- req.Body.Close() }() + select { + case err := <-closeDone: + require.NoError(t, err) + case <-time.After(3 * time.Second): + t.Fatal("Close deadlocked behind Read") + } + + select { + case err := <-readDone: + require.Error(t, err) + case <-time.After(3 * time.Second): + t.Fatal("Read was not unblocked") + } + + require.NoError(t, req.Body.Close()) + assert.Equal(t, int32(1), source.closes.Load()) +} + +func TestBodyDumpOwnsSnapshotsAndReplaysBody(t *testing.T) { + e := echo.New() + var snapshots [][]byte + var captured http.ResponseWriter + mw := BodyDumpWithConfig(BodyDumpConfig{ + MaxRequestBytes: 3, + MaxResponseBytes: 3, + Handler: func(_ *echo.Context, request, response []byte, _ error) { + snapshots = append(snapshots, request, response) + }, + }) + h := mw(func(c *echo.Context) error { + captured = c.Response() + return c.String(http.StatusOK, "response") + }) + first := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("abcdef")) + firstRecorder := httptest.NewRecorder() + c := e.NewContext(first, firstRecorder) + require.NoError(t, h(c)) + firstWriter := captured + snapshots[0][0] = 'X' + data, err := io.ReadAll(first.Body) + require.NoError(t, err) + assert.Equal(t, "abcdef", string(data)) + require.NoError(t, first.Body.Close()) + _, err = firstWriter.Write([]byte("tail")) + require.NoError(t, err) + assert.Equal(t, "responsetail", firstRecorder.Body.String()) + assert.Equal(t, "res", string(snapshots[1])) + assert.Same(t, c.Response(), firstWriter.(*bodyDumpResponseWriter).ResponseWriter) + + for range 16 { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("zzz")) + require.NoError(t, h(e.NewContext(req, httptest.NewRecorder()))) + require.NoError(t, req.Body.Close()) + } + + assert.Equal(t, "Xbc", string(snapshots[0])) + assert.Equal(t, "res", string(snapshots[1])) +} + +func TestBodyDumpDetachesOnPanic(t *testing.T) { + for _, callbackPanic := range []bool{false, true} { + t.Run(fmt.Sprint(callbackPanic), func(t *testing.T) { + c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/", nil), httptest.NewRecorder()) + original := c.Response() + var captured *bodyDumpResponseWriter + h := BodyDump(func(*echo.Context, []byte, []byte, error) { + panic("callback") + })(func(c *echo.Context) error { + captured = c.Response().(*bodyDumpResponseWriter) + if !callbackPanic { + panic("handler") + } + + return nil + }) + assert.Panics(t, func() { _ = h(c) }) + assert.Same(t, original, c.Response()) + assert.Same(t, original, captured.Writer) + }) + } +} + +func TestGzipDetachesAndCountsOnlyCurrentWrite(t *testing.T) { + e := echo.New() + var saved *gzipResponseWriter + mw := GzipWithConfig(GzipConfig{MinLength: 8}) + h := mw(func(c *echo.Context) error { + saved = c.Response().(*gzipResponseWriter) + for _, chunk := range []string{"first", "second"} { + n, err := c.Response().Write([]byte(chunk)) + require.NoError(t, err) + assert.Equal(t, len(chunk), n) + } + + return nil + }) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(echo.HeaderAcceptEncoding, gzipScheme) + rec := httptest.NewRecorder() + require.NoError(t, h(e.NewContext(req, rec))) + first := saved + assert.Nil(t, first.buffer) + assert.IsType(t, closedGzipWriter{}, first.Writer) + gr, err := gzip.NewReader(bytes.NewReader(rec.Body.Bytes())) + require.NoError(t, err) + decoded, err := io.ReadAll(gr) + require.NoError(t, err) + require.NoError(t, gr.Close()) + assert.Equal(t, "firstsecond", string(decoded)) + + for range 16 { + require.NoError(t, h(e.NewContext(req, httptest.NewRecorder()))) + n, err := first.Write([]byte("late")) + assert.Zero(t, n) + assert.ErrorIs(t, err, io.ErrClosedPipe) + } +} + +func TestGzipEmptyFlushAndLargeWrite(t *testing.T) { + for _, payload := range []string{"", strings.Repeat("x", 1<<20)} { + t.Run(fmt.Sprint(len(payload)), func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(echo.HeaderAcceptEncoding, gzipScheme) + rec := httptest.NewRecorder() + h := Gzip()(func(c *echo.Context) error { + writer := c.Response().(*gzipResponseWriter) + if payload == "" { + writer.Flush() + return nil + } + + n, err := writer.Write([]byte(payload)) + assert.Equal(t, len(payload), n) + assert.Nil(t, writer.buffer) + return err + }) + require.NoError(t, h(echo.New().NewContext(req, rec))) + gr, err := gzip.NewReader(bytes.NewReader(rec.Body.Bytes())) + require.NoError(t, err) + decoded, err := io.ReadAll(gr) + require.NoError(t, err) + require.NoError(t, gr.Close()) + assert.Equal(t, payload, string(decoded)) + }) + } +} + +type failingPoolResponse struct { + header http.Header + err error +} + +func (w *failingPoolResponse) Header() http.Header { return w.header } +func (*failingPoolResponse) WriteHeader(int) {} +func (w *failingPoolResponse) Write([]byte) (int, error) { return 0, w.err } + +func TestGzipFinalizationErrorAndPanicCleanup(t *testing.T) { + for _, minLength := range []int{0, 100} { + t.Run(fmt.Sprint(minLength), func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(echo.HeaderAcceptEncoding, gzipScheme) + failure := errors.New("transport failure") + response := &failingPoolResponse{header: make(http.Header), err: failure} + var saved *gzipResponseWriter + h := GzipWithConfig(GzipConfig{MinLength: minLength})(func(c *echo.Context) error { + saved = c.Response().(*gzipResponseWriter) + _, _ = saved.Write([]byte("test")) + return nil + }) + assert.ErrorIs(t, h(echo.New().NewContext(req, response)), failure) + assert.Nil(t, saved.buffer) + h = GzipWithConfig(GzipConfig{MinLength: minLength})(func(c *echo.Context) error { + saved = c.Response().(*gzipResponseWriter) + panic("handler") + }) + assert.Panics(t, func() { _ = h(echo.New().NewContext(req, httptest.NewRecorder())) }) + assert.Nil(t, saved.buffer) + assert.True(t, saved.finalized) + }) + } +} + +func TestPoolMiddlewareConcurrentRequests(t *testing.T) { + e := echo.New() + dump := BodyDumpWithConfig(BodyDumpConfig{ + MaxRequestBytes: 4, + MaxResponseBytes: 4, + Handler: func(*echo.Context, []byte, []byte, error) {}, + }) + e.Use(Decompress(), dump, Gzip()) + e.POST("/", func(c *echo.Context) error { + payload, err := io.ReadAll(c.Request().Body) + if err != nil { + return err + } + + return c.Blob(http.StatusOK, echo.MIMETextPlain, payload) + }) + var wg sync.WaitGroup + for worker := range 16 { + wg.Go(func() { + for iteration := range 16 { + value := fmt.Sprintf("worker-%d-request-%d", worker, iteration) + req := compressedOwnershipRequest(t, value) + req.Header.Set(echo.HeaderAcceptEncoding, gzipScheme) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("status = %d, body = %q", rec.Code, rec.Body.String()) + return + } + + gr, err := gzip.NewReader(bytes.NewReader(rec.Body.Bytes())) + if err != nil { + t.Error(err) + return + } + + payload, err := io.ReadAll(gr) + _ = gr.Close() + _ = req.Body.Close() + if err != nil || string(payload) != value { + t.Errorf("payload %q, want %q, error %v", payload, value, err) + return + } + } + }) + } + + wg.Wait() +} + +func TestRandomStringZeroLength(t *testing.T) { + assert.Empty(t, randomString(0)) +} + +func TestMiddlewareNilBodies(t *testing.T) { + for _, mw := range []echo.MiddlewareFunc{ + BodyLimit(8), + Decompress(), + BodyDump(func(*echo.Context, []byte, []byte, error) {}), + } { + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Body = nil + req.Header.Set(echo.HeaderContentEncoding, GZIPEncoding) + c := echo.New().NewContext(req, httptest.NewRecorder()) + assert.NotPanics(t, func() { + require.NoError(t, mw(func(c *echo.Context) error { + data, err := io.ReadAll(c.Request().Body) + assert.Empty(t, data) + return err + })(c)) + }) + } +} + +func TestReleaseMiddlewareBufferDiscardsOversized(t *testing.T) { + fresh := new(bytes.Buffer) + pool := &sync.Pool{New: func() any { return fresh }} + oversized := bytes.NewBuffer(make([]byte, maxPooledMiddlewareBuffer+1)) + releaseMiddlewareBuffer(pool, oversized) + assert.Same(t, fresh, pool.Get()) + buffer := bytes.NewBufferString("data") + releaseMiddlewareBuffer(pool, buffer) + assert.Zero(t, buffer.Len()) +} + +type panickingPoolResponse struct { + header http.Header +} + +func (w *panickingPoolResponse) Header() http.Header { return w.header } +func (*panickingPoolResponse) WriteHeader(int) {} +func (*panickingPoolResponse) Write([]byte) (int, error) { panic("transport panic") } + +func TestGzipPreservesTransportPanic(t *testing.T) { + for _, minLength := range []int{0, 100} { + t.Run(fmt.Sprint(minLength), func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(echo.HeaderAcceptEncoding, gzipScheme) + var saved *gzipResponseWriter + h := GzipWithConfig(GzipConfig{MinLength: minLength})(func(c *echo.Context) error { + saved = c.Response().(*gzipResponseWriter) + _, err := saved.Write([]byte("test")) + return err + }) + response := &panickingPoolResponse{header: make(http.Header)} + assert.PanicsWithValue(t, "transport panic", func() { + _ = h(echo.New().NewContext(req, response)) + }) + assert.Nil(t, saved.buffer) + assert.True(t, saved.finalized) + rec := httptest.NewRecorder() + require.NoError(t, h(echo.New().NewContext(req, rec))) + assert.NotEmpty(t, rec.Body.Bytes()) + }) + } +} + +func TestBodyDumpSnapshotDuringBodyRead(t *testing.T) { + payload := strings.Repeat("payload", 2048) + for range 32 { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(payload)) + data := make(chan []byte, 1) + readErrors := make(chan error, 1) + h := BodyDumpWithConfig(BodyDumpConfig{ + MaxRequestBytes: 4096, + MaxResponseBytes: 16, + Handler: func(_ *echo.Context, request, response []byte, _ error) { + for i := range request { + request[i] = 'x' + } + + assert.Empty(t, response) + }, + })(func(c *echo.Context) error { + body := c.Request().Body + go func() { + result, err := io.ReadAll(body) + data <- result + readErrors <- err + }() + return nil + }) + require.NoError(t, h(echo.New().NewContext(req, httptest.NewRecorder()))) + select { + case result := <-data: + assert.Equal(t, payload, string(result)) + case <-time.After(3 * time.Second): + t.Fatal("request replay stalled") + } + + require.NoError(t, <-readErrors) + require.NoError(t, req.Body.Close()) + } +} diff --git a/middleware/pool_safety_bench_test.go b/middleware/pool_safety_bench_test.go new file mode 100644 index 000000000..3c91539aa --- /dev/null +++ b/middleware/pool_safety_bench_test.go @@ -0,0 +1,110 @@ +package middleware + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ad3n/echo/v5" +) + +type poolBenchWriter struct { + header http.Header +} + +func (w *poolBenchWriter) Header() http.Header { return w.header } +func (*poolBenchWriter) WriteHeader(int) {} +func (*poolBenchWriter) Write(b []byte) (int, error) { return len(b), nil } + +type poolBenchBody struct { + *bytes.Reader +} + +func (*poolBenchBody) Close() error { return nil } + +func BenchmarkPoolSafety(b *testing.B) { + for _, size := range []int{1024, 65536} { + name := "gzip_1KiB" + if size == 65536 { + name = "gzip_64KiB" + } + + b.Run(name, func(b *testing.B) { + payload := bytes.Repeat([]byte("data"), size/4) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(echo.HeaderAcceptEncoding, gzipScheme) + writer := &poolBenchWriter{header: make(http.Header)} + c := echo.New().NewContext(req, writer) + h := Gzip()(func(c *echo.Context) error { + _, err := c.Response().Write(payload) + return err + }) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + clear(writer.header) + c.Reset(req, writer) + if err := h(c); err != nil { + b.Fatal(err) + } + } + }) + } + + b.Run("decompress_15KiB", func(b *testing.B) { + compressed, err := gzipString(strings.Repeat("benchmark data ", 1000)) + if err != nil { + b.Fatal(err) + } + + source := &poolBenchBody{Reader: bytes.NewReader(compressed)} + req := httptest.NewRequest(http.MethodPost, "/", source) + req.Header.Set(echo.HeaderContentEncoding, GZIPEncoding) + writer := &poolBenchWriter{header: make(http.Header)} + c := echo.New().NewContext(req, writer) + h := Decompress()(func(c *echo.Context) error { + _, err := io.Copy(io.Discard, c.Request().Body) + return err + }) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + source.Reset(compressed) + req.Body = source + c.Reset(req, writer) + if err := h(c); err != nil { + b.Fatal(err) + } + } + }) + + b.Run("body_dump_1KiB", func(b *testing.B) { + payload := strings.Repeat("data", 256) + input := []byte(payload) + source := &poolBenchBody{Reader: bytes.NewReader(input)} + req := httptest.NewRequest(http.MethodPost, "/", source) + writer := &poolBenchWriter{header: make(http.Header)} + c := echo.New().NewContext(req, writer) + h := BodyDump(func(*echo.Context, []byte, []byte, error) {})(func(c *echo.Context) error { + if _, err := io.Copy(io.Discard, c.Request().Body); err != nil { + return err + } + + return c.String(http.StatusOK, payload) + }) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + clear(writer.header) + source.Reset(input) + req.Body = source + c.Reset(req, writer) + if err := h(c); err != nil { + b.Fatal(err) + } + } + }) +} diff --git a/middleware/util.go b/middleware/util.go index 952d34242..9af0f8096 100644 --- a/middleware/util.go +++ b/middleware/util.go @@ -61,42 +61,42 @@ type randStringScratch struct { var randStringScratchPool = sync.Pool{New: func() any { return new(randStringScratch) }} func randomString(length uint8) string { + if length == 0 { + return "" + } + reader := randomReaderPool.Get().(*bufio.Reader) defer randomReaderPool.Put(reader) + sc := randStringScratchPool.Get().(*randStringScratch) defer randStringScratchPool.Put(sc) n := int(length) if cap(sc.b) < n { sc.b = make([]byte, n) - } else { - sc.b = sc.b[:n] } - rlen := n + n/4 // perf: avoid read from rand.Reader many times + + sc.b = sc.b[:n] + rlen := n + n/4 if cap(sc.r) < rlen { sc.r = make([]byte, rlen) - } else { - sc.r = sc.r[:rlen] } + + sc.r = sc.r[:rlen] b, r := sc.b, sc.r var i uint8 = 0 - // security note: - // we can't just simply do b[i]=randomStringCharset[rb%len(randomStringCharset)], - // len(len(randomStringCharset)) is 52, and rb is [0, 255], 256 = 52 * 4 + 48. - // make the first 48 characters more possibly to be generated then others. - // So we have to skip bytes when rb > randomStringMaxByt - for { _, err := io.ReadFull(reader, r) if err != nil { panic("unexpected error happened when reading from bufio.NewReader(crypto/rand.Reader)") } + for _, rb := range r { if rb > randomStringMaxByte { - // Skip this number to avoid bias. continue } + b[i] = randomStringCharset[rb%randomStringCharsetLen] i++ if i == length { diff --git a/response.go b/response.go index e218ef4d6..2f6948d1a 100644 --- a/response.go +++ b/response.go @@ -108,14 +108,25 @@ func (r *Response) Unwrap() http.ResponseWriter { } func (r *Response) reset(w http.ResponseWriter) { - r.beforeFuncs = nil - r.afterFuncs = nil + r.beforeFuncs = resetResponseHooks(r.beforeFuncs) + r.afterFuncs = resetResponseHooks(r.afterFuncs) r.ResponseWriter = w r.Size = 0 r.Status = http.StatusOK r.Committed = false } +const maxPooledResponseHooks = 64 + +func resetResponseHooks(hooks []func()) []func() { + if cap(hooks) > maxPooledResponseHooks { + return nil + } + + clear(hooks[:cap(hooks)]) + return hooks[:0] +} + // UnwrapResponse unwraps given ResponseWriter to return contexts original Echo Response. rw has to implement // following method `Unwrap() http.ResponseWriter` func UnwrapResponse(rw http.ResponseWriter) (*Response, error) { diff --git a/route.go b/route.go index 2468a8816..fe10d98ef 100644 --- a/route.go +++ b/route.go @@ -4,11 +4,11 @@ package echo import ( - "bytes" "errors" "fmt" "reflect" "runtime" + "strings" ) // Route contains information to adding/registering new route with the router. @@ -78,27 +78,36 @@ func (r RouteInfo) Clone() RouteInfo { } } -// Reverse reverses route to URL string by replacing path parameters with given params values. func (r RouteInfo) Reverse(pathValues ...any) string { - uri := new(bytes.Buffer) + var uri strings.Builder + uri.Grow(len(r.Path)) ln := len(pathValues) n := 0 for i, l := 0, len(r.Path); i < l; i++ { hasBackslash := r.Path[i] == '\\' if hasBackslash && i+1 < l && r.Path[i+1] == ':' { - i++ // backslash before colon escapes that colon. in that case skip backslash + i++ } + if n < ln && (r.Path[i] == anyLabel || (!hasBackslash && r.Path[i] == paramLabel)) { - // in case of `*` wildcard or `:` (unescaped colon) param we replace everything till next slash or end of path for ; i < l && r.Path[i] != '/'; i++ { } - fmt.Fprintf(uri, "%v", pathValues[n]) + + switch value := pathValues[n].(type) { + case string: + uri.WriteString(value) + default: + uri.WriteString(fmt.Sprint(value)) + } + n++ } + if i < l { uri.WriteByte(r.Path[i]) } } + return uri.String() } diff --git a/route_allocation_test.go b/route_allocation_test.go new file mode 100644 index 000000000..82db4852b --- /dev/null +++ b/route_allocation_test.go @@ -0,0 +1,47 @@ +package echo + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +type reverseStringer string + +func (s reverseStringer) String() string { + return "custom-" + string(s) +} + +func TestRouteReverseResultOwnership(t *testing.T) { + r := RouteInfo{Path: "/users/:id/files/*"} + first := r.Reverse("alice", "a.txt") + second := r.Reverse("bob", strings.Repeat("b", 1024)) + assert.Equal(t, "/users/alice/files/a.txt", first) + assert.Equal(t, "/users/bob/files/"+strings.Repeat("b", 1024), second) + + for _, value := range []any{42, nil, reverseStringer("value"), []int{1, 2}} { + assert.Equal(t, "/users/"+fmt.Sprintf("%v", value)+"/files/x", r.Reverse(value, "x")) + } +} + +func BenchmarkRouteReverseAllocation(b *testing.B) { + for _, tc := range []struct { + name string + args []any + }{ + {name: "strings", args: []any{"alice", "a.txt"}}, + {name: "mixed", args: []any{12345, "a.txt"}}, + } { + b.Run(tc.name, func(b *testing.B) { + r := RouteInfo{Path: "/users/:id/files/*"} + b.ReportAllocs() + for range b.N { + if r.Reverse(tc.args...) == "" { + b.Fatal("empty route") + } + } + }) + } +} diff --git a/router_test.go b/router_test.go index 22c1dc759..047f88263 100644 --- a/router_test.go +++ b/router_test.go @@ -2519,29 +2519,29 @@ func TestRouterParam1466(t *testing.T) { func TestPathValuesSizeOverMultipleRequests(t *testing.T) { e := New() - e.GET("/test/:id/:action", handlerFunc) // max params is 2 + e.GET("/test/:id/:action", handlerFunc) req := httptest.NewRequest(http.MethodGet, "/test/1/a", nil) rec := httptest.NewRecorder() c := e.AcquireContext() + defer e.ReleaseContext(c) + c.Reset(req, rec) - assert.Equal(t, 0, len(*c.pathValues)) // fresh context is empty - assert.Equal(t, 2, cap(*c.pathValues)) // is set max path params amount + assert.Equal(t, 0, len(*c.pathValues)) + assert.Equal(t, 2, cap(*c.pathValues)) - // imitate some (pre)middleware changing/replacing pathparams to smaller size c.SetPathValues(PathValues{ {Name: "id", Value: "1"}, }) - assert.Equal(t, 1, len(*c.pathValues)) // as SetPathValues was provided - assert.Equal(t, 2, cap(*c.pathValues)) // SetPathValues did not change that to smaller + assert.Equal(t, 1, len(*c.pathValues)) + assert.Equal(t, 2, cap(*c.pathValues)) handler := e.router.Route(c) - e.ReleaseContext(c) assert.NoError(t, handler(c)) - assert.Equal(t, 2, len(*c.pathValues)) // matched route had 2 path params - assert.Equal(t, 2, cap(*c.pathValues)) // was not changed + assert.Equal(t, 2, len(*c.pathValues)) + assert.Equal(t, 2, cap(*c.pathValues)) assert.Equal(t, "1", c.Param("id")) assert.Equal(t, "a", c.Param("action")) } diff --git a/server_test.go b/server_test.go index 3e717c822..af58365db 100644 --- a/server_test.go +++ b/server_test.go @@ -15,7 +15,6 @@ import ( "net" "net/http" "os" - "runtime" "strings" "testing" "time" @@ -26,8 +25,8 @@ import ( ) func startOnRandomPort(ctx stdContext.Context, e *Echo) (string, error) { - addrChan := make(chan string) - errCh := make(chan error) + addrChan := make(chan string, 1) + errCh := make(chan error, 1) go func() { errCh <- (&StartConfig{ @@ -43,10 +42,9 @@ func startOnRandomPort(ctx stdContext.Context, e *Echo) (string, error) { } func waitForServerStart(addrChan <-chan string, errCh <-chan error) (string, error) { - waitCtx, cancel := stdContext.WithTimeout(stdContext.Background(), 200*time.Millisecond) + waitCtx, cancel := stdContext.WithTimeout(stdContext.Background(), 3*time.Second) defer cancel() - // wait for addr to arrive for { select { case <-waitCtx.Done(): @@ -54,10 +52,10 @@ func waitForServerStart(addrChan <-chan string, errCh <-chan error) (string, err case addr := <-addrChan: return addr, nil case err := <-errCh: - if err == http.ErrServerClosed { // was closed normally before listener callback was called. should not be possible + if err == http.ErrServerClosed { return "", nil } - // failed to start and we did not manage to get even listener part. + return "", err } } @@ -83,14 +81,20 @@ func TestStartConfig_Start(t *testing.T) { return c.String(http.StatusOK, "OK") }) - addrChan := make(chan string) - errCh := make(chan error) + addrChan := make(chan string, 1) + errCh := make(chan error, 1) - ctx, shutdown := stdContext.WithTimeout(stdContext.Background(), 200*time.Millisecond) + var startedServer *http.Server + ctx, shutdown := stdContext.WithCancel(stdContext.Background()) defer shutdown() + go func() { errCh <- (&StartConfig{ Address: ":0", + BeforeServeFunc: func(server *http.Server) error { + startedServer = server + return nil + }, ListenerAddrFunc: func(addr net.Addr) { addrChan <- addr.String() }, @@ -100,33 +104,19 @@ func TestStartConfig_Start(t *testing.T) { addr, err := waitForServerStart(addrChan, errCh) assert.NoError(t, err) - // check if server is actually up code, body, err := doGet(fmt.Sprintf("http://%v/ok", addr)) if err != nil { assert.NoError(t, err) return } + assert.Equal(t, http.StatusOK, code) assert.Equal(t, "OK", body) shutdown() - <-errCh // we will be blocking here until server returns from http.Serve - - // check if server was stopped - code, body, err = doGet(fmt.Sprintf("http://%v/ok", addr)) - assert.Equal(t, 0, code) - assert.Equal(t, "", body) - - if err == nil { - t.Errorf("missing error") - return - } - expectContains := "connect: connection refused" - if runtime.GOOS == "windows" { - expectContains = "No connection could be made" - } - assert.True(t, strings.Contains(err.Error(), expectContains)) + require.NoError(t, <-errCh) + assertServerRejectsNewListener(t, startedServer) } func TestStartConfig_GracefulShutdown(t *testing.T) { @@ -163,8 +153,9 @@ func TestStartConfig_GracefulShutdown(t *testing.T) { return c.String(http.StatusOK, msg) }) - addrChan := make(chan string) - errCh := make(chan error) + var startedServer *http.Server + addrChan := make(chan string, 1) + errCh := make(chan error, 1) ctx, shutdown := stdContext.WithTimeout(stdContext.Background(), 50*time.Millisecond) defer shutdown() @@ -172,7 +163,11 @@ func TestStartConfig_GracefulShutdown(t *testing.T) { shutdownErrChan := make(chan error, 1) go func() { errCh <- (&StartConfig{ - Address: ":0", + Address: ":0", + BeforeServeFunc: func(server *http.Server) error { + startedServer = server + return nil + }, GracefulTimeout: 50 * time.Millisecond, OnShutdownError: func(err error) { shutdownErrChan <- err @@ -199,32 +194,38 @@ func TestStartConfig_GracefulShutdown(t *testing.T) { case shutdownErr = <-shutdownErrChan: default: } - if tc.expectGracefulError != "" { - assert.EqualError(t, shutdownErr, tc.expectGracefulError) - } else { + switch tc.expectGracefulError { + case "": assert.NoError(t, shutdownErr) + default: + assert.EqualError(t, shutdownErr, tc.expectGracefulError) } shutdown() - <-errCh // we will be blocking here until server returns from http.Serve - - // check if server was stopped - code, body, err = doGet(fmt.Sprintf("http://%v/ok", addr)) - assert.Error(t, err) - if err != nil { - expectContains := "connect: connection refused" - if runtime.GOOS == "windows" { - expectContains = "No connection could be made" - } - assert.True(t, strings.Contains(err.Error(), expectContains)) - } - assert.Equal(t, 0, code) - assert.Equal(t, "", body) + require.NoError(t, <-errCh) + assertServerRejectsNewListener(t, startedServer) }) } } +func assertServerRejectsNewListener(t *testing.T, server *http.Server) { + t.Helper() + require.NotNil(t, server) + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + result := make(chan error, 1) + go func() { result <- server.Serve(listener) }() + select { + case err := <-result: + require.ErrorIs(t, err, http.ErrServerClosed) + case <-time.After(3 * time.Second): + t.Fatal("server accepted a new listener after shutdown") + } +} + func TestStartConfig_Start_createListenerError(t *testing.T) { e := New() @@ -280,8 +281,8 @@ func TestStartConfig_StartTLS(t *testing.T) { t.Run(tc.name, func(t *testing.T) { e := New() - addrChan := make(chan string) - errCh := make(chan error) + addrChan := make(chan string, 1) + errCh := make(chan error, 1) ctx, shutdown := stdContext.WithTimeout(stdContext.Background(), 200*time.Millisecond) defer shutdown() @@ -368,8 +369,8 @@ func TestFilepathOrContent(t *testing.T) { t.Run(tc.name, func(t *testing.T) { e := New() - addrChan := make(chan string) - errCh := make(chan error) + addrChan := make(chan string, 1) + errCh := make(chan error, 1) ctx, shutdown := stdContext.WithTimeout(stdContext.Background(), 200*time.Millisecond) defer shutdown() @@ -450,8 +451,8 @@ func TestStartConfig_WithListenerNetwork(t *testing.T) { return c.String(http.StatusOK, "OK") }) - addrChan := make(chan string) - errCh := make(chan error) + addrChan := make(chan string, 1) + errCh := make(chan error, 1) ctx, shutdown := stdContext.WithTimeout(stdContext.Background(), 200*time.Millisecond) defer shutdown() @@ -504,8 +505,8 @@ func TestStartConfig_WithHideBanner(t *testing.T) { return c.String(http.StatusOK, "OK") }) - addrChan := make(chan string) - errCh := make(chan error) + addrChan := make(chan string, 1) + errCh := make(chan error, 1) ctx, shutdown := stdContext.WithTimeout(stdContext.Background(), 200*time.Millisecond) defer shutdown() @@ -565,7 +566,7 @@ func TestStartConfig_WithHidePort(t *testing.T) { return c.String(http.StatusOK, "OK") }) - addrChan := make(chan string) + addrChan := make(chan string, 1) errCh := make(chan error, 1) ctx, shutdown := stdContext.WithTimeout(stdContext.Background(), 200*time.Millisecond) @@ -639,7 +640,7 @@ func TestStartConfig_WithHTTP2WithCustomTlsConfig(t *testing.T) { return c.String(http.StatusOK, "OK") }) - addrChan := make(chan string) + addrChan := make(chan string, 1) errCh := make(chan error, 1) ctx, shutdown := stdContext.WithTimeout(stdContext.Background(), 200*time.Millisecond)