encode: add standard benchmark and conformance harness (#7804)
Some checks are pending
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Waiting to run
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Waiting to run
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Waiting to run
Tests / test (s390x on IBM Z) (push) Waiting to run
Tests / goreleaser-check (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, aix) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, linux) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, windows) (push) Waiting to run
Lint / lint (push) Waiting to run
Lint / lint-1 (push) Waiting to run
Lint / lint-2 (push) Waiting to run
Lint / govulncheck (push) Waiting to run
Lint / dependency-review (push) Waiting to run
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Waiting to run

This is a shared encode_test harness with HTML/JSON/JS/CSS payloads taken from caddyserver.com

Benchmarks:
- BenchmarkStandardEncodingPayloads: raw encoder NewEncoder/Write/Close path
- BenchmarkEncodeHandlerCorpus: full Encode.ServeHTTP middleware path
- Grid: 4 payloads × gzip levels 1/5/9 × zstd fastest/default/best
- Each subtest runs with 4 parallel workers; compare runs on MB/s and allocs/op

Conformance tests:
- Encoder contract: Reset, Flush, Close, and pool-style Reset-after-Close reuse
- Corpus HTTP encoding: Content-Encoding, Vary, ETag suffix, header stripping
- Response semantics: minimum_length, 304, HEAD, range, WebSocket bypass,
  If-None-Match rewrite, Cache-Control no-transform, content-type matcher rejection
This commit is contained in:
Sam Ottenhoff 2026-06-11 19:55:18 -04:00 committed by GitHub
parent 0f7f8e9cf6
commit 997d3f6b0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 5128 additions and 6 deletions

View file

@ -251,13 +251,16 @@ type responseWriter struct {
statusCode int
wroteHeader bool
isConnect bool
disabled bool // disable encoding (for error responses)
disabled bool // disable encoding for this response
}
// WriteHeader stores the status to write when the time comes
// to actually write the header.
func (rw *responseWriter) WriteHeader(status int) {
rw.statusCode = status
if status == http.StatusPartialContent {
rw.disabled = true // partial representations must not be dynamically re-encoded
}
// See #5849 and RFC 9110 section 15.4.5 (https://www.rfc-editor.org/rfc/rfc9110.html#section-15.4.5) - 304
// Not Modified must have certain headers set as if it was a 200 response, and according to the issue
@ -444,8 +447,7 @@ func (rw *responseWriter) Unwrap() http.ResponseWriter {
// init should be called before we write a response, if rw.buf has contents.
func (rw *responseWriter) init() {
// Don't initialize encoder for error responses
// This prevents response corruption when handle_errors is used
// Don't initialize encoder for responses that must not be encoded.
if rw.disabled {
return
}

View file

@ -0,0 +1,169 @@
package encode_test
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
)
const (
benchmarkParallelism = 4
handlerBenchWarmupIterations = 5
)
// BenchmarkStandardEncodingPayloads measures raw encoder throughput (NewEncoder → Write → Close)
// across the standard HTML/JSON/JS/CSS payloads and gzip/zstd compression levels.
// Each subtest runs with 4 parallel workers (SetParallelism).
func BenchmarkStandardEncodingPayloads(b *testing.B) {
forEachBenchmarkCase(b, func(b *testing.B, corpus benchmarkCorpus, encCase encoderCase) {
benchmarkEncode(b, corpus.data, encCase.encoding)
})
}
// BenchmarkEncodeHandlerCorpus measures the full encode middleware path (ServeHTTP,
// responseWriter, writer pools) using the same payload and level grid.
func BenchmarkEncodeHandlerCorpus(b *testing.B) {
forEachBenchmarkCase(b, func(b *testing.B, corpus benchmarkCorpus, encCase encoderCase) {
enc := newEncodeHandler(b, encCase, 1)
benchmarkEncodeHandler(b, enc, encCase, corpus)
})
}
func forEachBenchmarkCase(b *testing.B, fn func(b *testing.B, corpus benchmarkCorpus, encCase encoderCase)) {
for _, corpus := range benchmarkCorpora(b) {
for _, encCase := range benchmarkEncoderCases(b) {
b.Run(benchmarkSubtestName(corpus.name, encCase), func(b *testing.B) {
fn(b, corpus, encCase)
})
}
}
}
func benchmarkSubtestName(corpus string, encCase encoderCase) string {
return fmt.Sprintf("payload-%s/encoder-%s/compress-level-%s",
corpus, encCase.encoder, encCase.level)
}
func benchmarkEncode(b *testing.B, payload []byte, encoding encode.Encoding) {
b.Helper()
b.ReportAllocs()
b.SetBytes(int64(len(payload)))
b.SetParallelism(benchmarkParallelism)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
encoder := encoding.NewEncoder()
var dst bytes.Buffer
for pb.Next() {
dst.Reset()
encoder.Reset(&dst)
if _, err := encoder.Write(payload); err != nil {
b.Fatalf("Write() error = %v", err)
}
if err := encoder.Close(); err != nil {
b.Fatalf("Close() error = %v", err)
}
}
})
}
func benchmarkEncodeHandler(b *testing.B, enc *encode.Encode, encCase encoderCase, corpus benchmarkCorpus) {
b.Helper()
b.ReportAllocs()
b.SetBytes(int64(len(corpus.data)))
b.SetParallelism(benchmarkParallelism)
next := corpusHandler(corpus)
w := newBenchmarkResponseWriter()
r := newHandlerBenchRequest(encCase)
warmupEncodeHandler(enc, w, r, next)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
w := newBenchmarkResponseWriter()
r := newHandlerBenchRequest(encCase)
for pb.Next() {
w.reset()
if err := enc.ServeHTTP(w, r, next); err != nil {
b.Fatalf("ServeHTTP() error = %v", err)
}
}
})
}
func newHandlerBenchRequest(encCase encoderCase) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Accept-Encoding", encCase.encoding.AcceptEncoding())
return r
}
func warmupEncodeHandler(enc *encode.Encode, w *benchmarkResponseWriter, r *http.Request, next caddyhttp.Handler) {
for range handlerBenchWarmupIterations {
w.reset()
if err := enc.ServeHTTP(w, r, next); err != nil {
panic("warmup ServeHTTP: " + err.Error())
}
}
}
// benchmarkResponseWriter is a resettable http.ResponseWriter for handler benchmarks.
// httptest.ResponseRecorder cannot be safely reused because it keeps unexported state.
type benchmarkResponseWriter struct {
header http.Header
code int
body bytes.Buffer
wroteHeader bool
}
func newBenchmarkResponseWriter() *benchmarkResponseWriter {
return &benchmarkResponseWriter{
header: make(http.Header),
}
}
func (w *benchmarkResponseWriter) reset() {
w.code = 0
w.wroteHeader = false
w.body.Reset()
for k := range w.header {
delete(w.header, k)
}
}
func (w *benchmarkResponseWriter) Header() http.Header {
return w.header
}
func (w *benchmarkResponseWriter) Write(p []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.body.Write(p)
}
func (w *benchmarkResponseWriter) WriteHeader(statusCode int) {
if w.wroteHeader {
return
}
w.code = statusCode
w.wroteHeader = true
}
func (w *benchmarkResponseWriter) Flush() {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
}
func corpusHandler(corpus benchmarkCorpus) caddyhttp.Handler {
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", corpus.contentType)
_, err := w.Write(corpus.data)
return err
})
}

View file

@ -0,0 +1,400 @@
package encode_test
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
)
const conformanceContentType = "text/plain"
// TestStandardEncoderContract verifies Reset, Flush, Close, and Reset-after-Close
// reuse for each encoder using the same HTML/JSON/JS/CSS payloads as the benchmark suite.
func TestStandardEncoderContract(t *testing.T) {
for _, encCase := range standardEncoderCases(t) {
t.Run(encCase.name, func(t *testing.T) {
for _, corpus := range benchmarkCorpora(t) {
t.Run(corpus.name, func(t *testing.T) {
encoder := encCase.encoding.NewEncoder()
original := corpus.data
encodeAndVerifyRoundTrip(t, encCase, encoder, original)
// Simulate writer-pool reuse: Close → Reset(nil) → Reset(writer).
encoder.Reset(nil)
encodeAndVerifyRoundTrip(t, encCase, encoder, original)
})
}
})
}
}
// TestEncodeCorpusResponse verifies encoded-response semantics (Content-Encoding, Vary,
// ETag suffix, header stripping) for each benchmark corpus and encoder.
func TestEncodeCorpusResponse(t *testing.T) {
for _, encCase := range standardEncoderCases(t) {
t.Run(encCase.name, func(t *testing.T) {
for _, corpus := range benchmarkCorpora(t) {
t.Run(corpus.name, func(t *testing.T) {
enc := newEncodeHandler(t, encCase, 1)
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Accept-Encoding", encCase.encoding.AcceptEncoding())
w := httptest.NewRecorder()
next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", corpus.contentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(corpus.data)))
w.Header().Set("Accept-Ranges", "bytes")
w.Header().Set("Etag", `"response"`)
_, err := w.Write(corpus.data)
return err
})
if err := enc.ServeHTTP(w, r, next); err != nil {
t.Fatalf("ServeHTTP() error = %v", err)
}
checkEncodedCorpusResponse(t, w, encCase, corpus)
})
}
})
}
}
type encodeScenario struct {
name string
method string
minLength int
reqHeaders func(encoderCase) http.Header
checkRequest func(*testing.T, *http.Request)
next func(encoderCase) caddyhttp.Handler
checkResponse func(*testing.T, *httptest.ResponseRecorder, encoderCase)
}
var encodeScenarios = []encodeScenario{
{
name: "minimum length prevents encoding",
method: http.MethodGet,
minLength: 1024,
next: func(encoderCase) caddyhttp.Handler {
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", conformanceContentType)
_, err := w.Write([]byte("short"))
return err
})
},
checkResponse: checkMinLengthPreventsEncoding,
},
{
name: "not modified adds vary without encoding",
method: http.MethodGet,
minLength: 1,
next: func(encoderCase) caddyhttp.Handler {
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(http.StatusNotModified)
return nil
})
},
checkResponse: checkNotModifiedVary,
},
{
name: "head response headers can be encoded without body",
method: http.MethodHead,
minLength: 1,
next: func(encoderCase) caddyhttp.Handler {
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", conformanceContentType)
w.Header().Set("Content-Length", "128")
return nil
})
},
checkResponse: checkHeadEncodedHeaders,
},
{
name: "range response bypasses encoding",
method: http.MethodGet,
minLength: 1,
reqHeaders: func(encoderCase) http.Header {
return http.Header{"Range": {"bytes=0-15"}}
},
next: func(encoderCase) caddyhttp.Handler {
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", conformanceContentType)
w.Header().Set("Content-Range", "bytes 0-15/128")
w.Header().Set("Accept-Ranges", "bytes")
w.WriteHeader(http.StatusPartialContent)
_, err := w.Write([]byte("0123456789abcdef"))
return err
})
},
checkResponse: checkRangeResponseBypassesEncoding,
},
{
name: "websocket handshake bypasses encoding",
method: http.MethodGet,
minLength: 1,
reqHeaders: func(encoderCase) http.Header {
return http.Header{
"Connection": {"Upgrade"},
"Sec-WebSocket-Key": {"dGhlIHNhbXBsZSBub25jZQ=="},
"Upgrade": {"websocket"},
}
},
next: func(encoderCase) caddyhttp.Handler {
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(http.StatusSwitchingProtocols)
return nil
})
},
checkResponse: checkWebSocketBypass,
},
{
name: "strips encoded etag suffix before next handler",
method: http.MethodGet,
minLength: 1,
reqHeaders: func(encCase encoderCase) http.Header {
return http.Header{
"If-None-Match": {fmt.Sprintf(`"response-%s"`, encCase.encoding.AcceptEncoding())},
}
},
checkRequest: func(t *testing.T, r *http.Request) {
if got := r.Header.Get("If-None-Match"); got != `"response"` {
t.Fatalf("If-None-Match = %q, want %q", got, `"response"`)
}
},
next: func(encoderCase) caddyhttp.Handler {
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(http.StatusNotModified)
return nil
})
},
checkResponse: checkStripsEncodedETagSuffix,
},
{
name: "request cache-control no-transform prevents encoding",
method: http.MethodGet,
minLength: 1,
reqHeaders: func(encoderCase) http.Header {
return http.Header{"Cache-Control": {"no-cache, no-transform"}}
},
next: func(encoderCase) caddyhttp.Handler {
return conformanceLargeBodyHandler(conformanceContentType)
},
checkResponse: checkBypassesEncoding,
},
{
name: "response cache-control no-transform prevents encoding",
method: http.MethodGet,
minLength: 1,
next: func(encoderCase) caddyhttp.Handler {
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", conformanceContentType)
w.Header().Set("Cache-Control", "no-cache, no-transform")
_, err := w.Write(conformanceLargeBody())
return err
})
},
checkResponse: checkBypassesEncoding,
},
{
name: "content type matcher rejection prevents encoding",
method: http.MethodGet,
minLength: 1,
next: func(encoderCase) caddyhttp.Handler {
return conformanceLargeBodyHandler("image/png")
},
checkResponse: checkBypassesEncoding,
},
}
// TestEncodeResponseSemantics verifies HTTP edge cases (304, HEAD, range, WebSocket,
// minimum_length, ETag request rewriting, no-transform, matcher rejection) independent
// of the benchmark corpora.
func TestEncodeResponseSemantics(t *testing.T) {
for _, encCase := range standardEncoderCases(t) {
t.Run(encCase.name, func(t *testing.T) {
for _, sc := range encodeScenarios {
t.Run(sc.name, func(t *testing.T) {
runEncodeScenario(t, encCase, sc)
})
}
})
}
}
func runEncodeScenario(t *testing.T, encCase encoderCase, sc encodeScenario) {
t.Helper()
enc := newEncodeHandler(t, encCase, sc.minLength)
r := httptest.NewRequest(sc.method, "/", nil)
r.Header.Set("Accept-Encoding", encCase.encoding.AcceptEncoding())
if sc.reqHeaders != nil {
for name, values := range sc.reqHeaders(encCase) {
r.Header.Del(name)
for _, value := range values {
r.Header.Add(name, value)
}
}
}
w := httptest.NewRecorder()
var rw http.ResponseWriter = w
if sc.method == http.MethodHead {
// httptest.ResponseRecorder still stores body writes on HEAD; discard them
// so Close() path matches real clients that must not receive a body.
rw = noBodyResponseWriter{ResponseRecorder: w}
}
next := sc.next(encCase)
if sc.checkRequest != nil {
inner := next
next = caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
sc.checkRequest(t, r)
return inner.ServeHTTP(w, r)
})
}
if err := enc.ServeHTTP(rw, r, next); err != nil {
t.Fatalf("%s: ServeHTTP() error = %v", sc.name, err)
}
sc.checkResponse(t, w, encCase)
}
// noBodyResponseWriter discards Write data while still allowing the encode
// middleware to observe writes for Content-Length / min-length decisions.
type noBodyResponseWriter struct {
*httptest.ResponseRecorder
}
func (w noBodyResponseWriter) Write(p []byte) (int, error) {
return len(p), nil
}
func checkEncodedCorpusResponse(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase, corpus benchmarkCorpus) {
t.Helper()
encName := encCase.encoding.AcceptEncoding()
if got := w.Header().Get("Content-Encoding"); got != encName {
t.Fatalf("Content-Encoding = %q, want %q", got, encName)
}
if !encode.HasVaryValue(w.Header(), "Accept-Encoding") {
t.Fatalf("Vary = %q, want Accept-Encoding", w.Header().Values("Vary"))
}
if got := w.Header().Get("Content-Length"); got != "" {
t.Fatalf("Content-Length = %q, want empty", got)
}
if got := w.Header().Get("Accept-Ranges"); got != "" {
t.Fatalf("Accept-Ranges = %q, want empty", got)
}
wantETag := fmt.Sprintf(`"response-%s"`, encName)
if got := w.Header().Get("Etag"); got != wantETag {
t.Fatalf("Etag = %q, want %q", got, wantETag)
}
assertDecompresses(t, encCase, w.Body.Bytes(), corpus.data)
}
func checkMinLengthPreventsEncoding(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
t.Helper()
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty", got)
}
if got := w.Body.String(); got != "short" {
t.Fatalf("body = %q, want short", got)
}
}
func checkNotModifiedVary(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
t.Helper()
if got := w.Code; got != http.StatusNotModified {
t.Fatalf("status = %d, want %d", got, http.StatusNotModified)
}
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty", got)
}
if !encode.HasVaryValue(w.Header(), "Accept-Encoding") {
t.Fatalf("Vary = %q, want Accept-Encoding", w.Header().Values("Vary"))
}
if got := w.Body.Len(); got != 0 {
t.Fatalf("body length = %d, want 0", got)
}
}
func checkHeadEncodedHeaders(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
t.Helper()
if got := w.Header().Get("Content-Encoding"); got != encCase.encoding.AcceptEncoding() {
t.Fatalf("Content-Encoding = %q, want %q", got, encCase.encoding.AcceptEncoding())
}
if got := w.Body.Len(); got != 0 {
t.Fatalf("body length = %d, want 0", got)
}
}
func checkRangeResponseBypassesEncoding(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
t.Helper()
if got := w.Code; got != http.StatusPartialContent {
t.Fatalf("status = %d, want %d", got, http.StatusPartialContent)
}
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty", got)
}
if got := w.Header().Get("Content-Range"); got != "bytes 0-15/128" {
t.Fatalf("Content-Range = %q, want %q", got, "bytes 0-15/128")
}
if got := w.Header().Get("Accept-Ranges"); got != "bytes" {
t.Fatalf("Accept-Ranges = %q, want bytes", got)
}
if got := w.Body.String(); got != "0123456789abcdef" {
t.Fatalf("body = %q, want %q", got, "0123456789abcdef")
}
}
func checkWebSocketBypass(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
t.Helper()
if got := w.Code; got != http.StatusSwitchingProtocols {
t.Fatalf("status = %d, want %d", got, http.StatusSwitchingProtocols)
}
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty", got)
}
}
func checkStripsEncodedETagSuffix(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
t.Helper()
if got := w.Code; got != http.StatusNotModified {
t.Fatalf("status = %d, want %d", got, http.StatusNotModified)
}
if !encode.HasVaryValue(w.Header(), "Accept-Encoding") {
t.Fatalf("Vary = %q, want Accept-Encoding", w.Header().Values("Vary"))
}
}
func conformanceLargeBodyHandler(contentType string) caddyhttp.Handler {
body := conformanceLargeBody()
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", contentType)
_, err := w.Write(body)
return err
})
}
func checkBypassesEncoding(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
t.Helper()
want := conformanceLargeBody()
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty", got)
}
if !bytes.Equal(w.Body.Bytes(), want) {
t.Fatalf("body len = %d, want len = %d", w.Body.Len(), len(want))
}
}

View file

@ -0,0 +1,224 @@
// Package encode_test provides the standard encode benchmark and conformance suite
// for Caddy's gzip and zstd encoder modules.
//
// Run encoder-level benchmarks (direct NewEncoder calls):
//
// go test -bench=BenchmarkStandardEncodingPayloads -benchmem ./modules/caddyhttp/encode/
//
// Run middleware-level benchmarks (Encode.ServeHTTP, writer pools, responseWriter):
//
// go test -bench=BenchmarkEncodeHandlerCorpus -benchmem ./modules/caddyhttp/encode/
//
// Benchmark subtest names:
//
// payload-{html|json|js|css}/encoder-{gzip|zstd}/compress-level-{N|fastest|...}
//
// Each subtest uses 4 parallel workers (benchmarkParallelism in encode_bench_test.go).
// Go may append -{GOMAXPROCS} to the printed benchmark name; ignore it when comparing runs.
//
// Grid: 4 payloads × 6 compress levels (gzip 1/5/9, zstd fastest/default/best) = 24 subtests
// per benchmark function (48 total with encoder + handler).
//
// Run conformance tests (Reset/Flush/Close, Vary, ETag, 304/HEAD/range/WebSocket, minimum_length):
//
// go test -run='TestStandardEncoderContract|TestEncodeCorpusResponse|TestEncodeResponseSemantics' ./modules/caddyhttp/encode/
//
// Conformance also covers Cache-Control no-transform, content-type matcher rejection,
// and encoder Reset-after-Close reuse (pool pattern).
package encode_test
import (
"bytes"
stdgzip "compress/gzip"
"context"
"fmt"
"io"
"os"
"testing"
"github.com/klauspost/compress/zstd"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig"
"github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
caddygzip "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode/gzip"
caddyzstd "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode/zstd"
)
// benchmarkCorpus is a fixed payload used by both benchmarks and conformance tests.
type benchmarkCorpus struct {
name string
data []byte
contentType string
}
var (
benchmarkGzipLevels = []int{1, 5, 9}
benchmarkZstdLevels = []string{"fastest", "default", "best"}
)
type encoderCase struct {
name string // conformance subtest label, e.g. gzip-level-5
encoder string // gzip or zstd
level string // gzip numeric level or zstd level name
encoding encode.Encoding
decompress func([]byte) ([]byte, error)
contentType string
}
func benchmarkCorpora(tb testing.TB) []benchmarkCorpus {
tb.Helper()
return []benchmarkCorpus{
{name: "html", data: readBenchmarkPayload(tb, "testdata/caddy_home.html"), contentType: "text/html; charset=utf-8"},
{name: "json", data: readBenchmarkPayload(tb, "testdata/caddy_config_http_servers.json"), contentType: "application/json"},
{name: "js", data: readBenchmarkPayload(tb, "testdata/caddy_asciinema_player.js"), contentType: "application/javascript"},
{name: "css", data: readBenchmarkPayload(tb, "testdata/caddy_asciinema_player.css"), contentType: "text/css"},
}
}
func readBenchmarkPayload(tb testing.TB, filename string) []byte {
tb.Helper()
data, err := os.ReadFile(filename)
if err != nil {
tb.Fatalf("reading benchmark payload %s: %v", filename, err)
}
return data
}
// conformanceLargeBody returns a payload large enough to exceed default minimum_length.
func conformanceLargeBody() []byte {
data, err := os.ReadFile("testdata/caddy_home.html")
if err != nil {
panic("conformanceLargeBody: " + err.Error())
}
return data
}
func standardEncoderCases(t testing.TB) []encoderCase {
t.Helper()
return provisionEncoderCases(t, []int{5}, []string{"default"})
}
func benchmarkEncoderCases(t testing.TB) []encoderCase {
t.Helper()
return provisionEncoderCases(t, benchmarkGzipLevels, benchmarkZstdLevels)
}
func provisionEncoderCases(t testing.TB, gzipLevels []int, zstdLevels []string) []encoderCase {
t.Helper()
ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
t.Cleanup(cancel)
var cases []encoderCase
for _, level := range gzipLevels {
gzipEncoding := &caddygzip.Gzip{Level: level}
if err := gzipEncoding.Provision(ctx); err != nil {
t.Fatalf("gzip level %d Provision() error = %v", level, err)
}
cases = append(cases, encoderCase{
name: fmt.Sprintf("gzip-level-%d", level),
encoder: "gzip",
level: fmt.Sprintf("%d", level),
encoding: gzipEncoding,
decompress: decompressGzip,
contentType: "text/plain",
})
}
for _, level := range zstdLevels {
zstdEncoding := &caddyzstd.Zstd{Level: level}
if err := zstdEncoding.Provision(ctx); err != nil {
t.Fatalf("zstd level %q Provision() error = %v", level, err)
}
cases = append(cases, encoderCase{
name: "zstd-level-" + level,
encoder: "zstd",
level: level,
encoding: zstdEncoding,
decompress: decompressZstd,
contentType: "text/plain",
})
}
return cases
}
func newEncodeHandler(tb testing.TB, encCase encoderCase, minLength int) *encode.Encode {
tb.Helper()
encodingName := encCase.encoding.AcceptEncoding()
enc := &encode.Encode{
EncodingsRaw: caddy.ModuleMap{
encodingName: caddyconfig.JSON(encCase.encoding, nil),
},
Prefer: []string{encodingName},
MinLength: minLength,
}
ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
tb.Cleanup(cancel)
if err := enc.Provision(ctx); err != nil {
tb.Fatalf("Provision() error = %v", err)
}
if err := enc.Validate(); err != nil {
tb.Fatalf("Validate() error = %v", err)
}
return enc
}
func assertDecompresses(t *testing.T, encCase encoderCase, compressed, original []byte) {
t.Helper()
decompressed, err := encCase.decompress(compressed)
if err != nil {
t.Fatalf("decompress %s: %v", encCase.name, err)
}
if !bytes.Equal(decompressed, original) {
t.Fatalf("decompressed len = %d, want len = %d", len(decompressed), len(original))
}
}
// encodeAndVerifyRoundTrip exercises Write → Flush → Write → Close and verifies
// the compressed stream round-trips to original.
func encodeAndVerifyRoundTrip(t *testing.T, encCase encoderCase, encoder encode.Encoder, original []byte) {
t.Helper()
var compressed bytes.Buffer
encoder.Reset(&compressed)
if _, err := encoder.Write(original[:len(original)/2]); err != nil {
t.Fatalf("Write() error = %v", err)
}
if err := encoder.Flush(); err != nil {
t.Fatalf("Flush() error = %v", err)
}
if compressed.Len() == 0 {
t.Fatal("Flush() wrote no compressed bytes")
}
if _, err := encoder.Write(original[len(original)/2:]); err != nil {
t.Fatalf("Write() error = %v", err)
}
if err := encoder.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
assertDecompresses(t, encCase, compressed.Bytes(), original)
}
func decompressGzip(compressed []byte) ([]byte, error) {
reader, err := stdgzip.NewReader(bytes.NewReader(compressed))
if err != nil {
return nil, err
}
defer reader.Close()
return io.ReadAll(reader)
}
func decompressZstd(compressed []byte) ([]byte, error) {
decoder, err := zstd.NewReader(nil)
if err != nil {
return nil, err
}
defer decoder.Close()
return decoder.DecodeAll(compressed, nil)
}

View file

@ -305,9 +305,9 @@ func TestIsEncodeAllowed(t *testing.T) {
type mockEncoder struct{}
func (mockEncoder) Write(p []byte) (n int, err error) { return len(p), nil }
func (mockEncoder) Close() error { return nil }
func (mockEncoder) Reset(w io.Writer) {}
func (mockEncoder) Flush() error { return nil }
func (mockEncoder) Close() error { return nil }
func (mockEncoder) Reset(w io.Writer) {}
func (mockEncoder) Flush() error { return nil }
func TestServeHTTPDefaultEncodingPreference(t *testing.T) {
enc := new(Encode)

View file

@ -0,0 +1,8 @@
package encode
import "net/http"
// HasVaryValue exposes hasVaryValue for external tests in encode_test.
func HasVaryValue(hdr http.Header, target string) bool {
return hasVaryValue(hdr, target)
}

View file

@ -0,0 +1,7 @@
These benchmark payloads are snapshots from caddyserver.com, fetched on
2026-06-06:
- `caddy_home.html`: https://caddyserver.com/
- `caddy_config_http_servers.json`: https://caddyserver.com/api/docs/config/apps/http/servers
- `caddy_asciinema_player.css`: https://caddyserver.com/resources/css/vendor/asciinema-player-3.6.1.css?v=378d6d0
- `caddy_asciinema_player.js`: https://caddyserver.com/resources/js/vendor/asciinema-player-3.6.1.min.js?v=378d6d0

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -15,7 +15,10 @@
package fileserver
import (
"bytes"
"compress/gzip"
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
@ -26,6 +29,7 @@ import (
"time"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
)
func TestFileHidden(t *testing.T) {
@ -184,3 +188,122 @@ func check_validator_headers(modTime time.Time, expect_headers bool, t *testing.
}
}
}
func TestPrecompressedRangeResponse(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "range.txt"), []byte("original response body"), 0o600); err != nil {
t.Fatal(err)
}
sidecar := gzipBytes(t, []byte("original response body"))
if err := os.WriteFile(filepath.Join(root, "range.txt.gz"), sidecar, 0o600); err != nil {
t.Fatal(err)
}
fsrv := FileServer{
Root: root,
CanonicalURIs: new(bool),
PrecompressedOrder: []string{"gzip"},
}
ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
if err := fsrv.Provision(ctx); err != nil {
t.Fatal(err)
}
fsrv.precompressors = map[string]encode.Precompressed{
"gzip": testPrecompressed{encoding: "gzip", suffix: ".gz"},
}
t.Run("full response", func(t *testing.T) {
w := httptest.NewRecorder()
r := newPrecompressedRequest(t, "/range.txt")
r.Header.Set("Accept-Encoding", "gzip")
if err := fsrv.ServeHTTP(w, r, nil); err != nil {
t.Fatal(err)
}
if got := w.Code; got != http.StatusOK {
t.Fatalf("status = %d, want %d", got, http.StatusOK)
}
if got := w.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
if got := w.Header().Get("Content-Length"); got != fmt.Sprintf("%d", len(sidecar)) {
t.Fatalf("Content-Length = %q, want %d", got, len(sidecar))
}
if got := w.Header().Get("Vary"); got != "Accept-Encoding" {
t.Fatalf("Vary = %q, want Accept-Encoding", got)
}
if got := w.Body.Bytes(); !bytes.Equal(got, sidecar) {
t.Fatalf("body len = %d, want len = %d", len(got), len(sidecar))
}
})
t.Run("range response", func(t *testing.T) {
w := httptest.NewRecorder()
r := newPrecompressedRequest(t, "/range.txt")
r.Header.Set("Accept-Encoding", "gzip")
r.Header.Set("Range", "bytes=2-5")
if err := fsrv.ServeHTTP(w, r, nil); err != nil {
t.Fatal(err)
}
if got := w.Code; got != http.StatusPartialContent {
t.Fatalf("status = %d, want %d", got, http.StatusPartialContent)
}
if got := w.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
wantContentRange := fmt.Sprintf("bytes 2-5/%d", len(sidecar))
if got := w.Header().Get("Content-Range"); got != wantContentRange {
t.Fatalf("Content-Range = %q, want %q", got, wantContentRange)
}
if got := w.Header().Get("Content-Length"); got != "4" {
t.Fatalf("Content-Length = %q, want 4", got)
}
if got := w.Header().Get("Vary"); got != "Accept-Encoding" {
t.Fatalf("Vary = %q, want Accept-Encoding", got)
}
if got, want := w.Body.Bytes(), sidecar[2:6]; !bytes.Equal(got, want) {
t.Fatalf("body = %x, want %x", got, want)
}
})
}
func gzipBytes(t *testing.T, data []byte) []byte {
t.Helper()
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
if _, err := zw.Write(data); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func newPrecompressedRequest(t *testing.T, target string) *http.Request {
t.Helper()
r := httptest.NewRequest(http.MethodGet, target, nil)
repl := caddy.NewReplacer()
ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, repl)
return r.WithContext(ctx)
}
type testPrecompressed struct {
encoding string
suffix string
}
func (p testPrecompressed) AcceptEncoding() string {
return p.encoding
}
func (p testPrecompressed) Suffix() string {
return p.suffix
}