Files
caddy/modules/caddyhttp/encode/encode_test.go
T
Kévin DunglasandKağan Küçük 857d5731f3 caddyhttp: optimise header access and fix encoding negotiation (#8025)
* perf: bypass header key normalization for canonical literals

http.Header's Get, Set, Add and Del run textproto.CanonicalMIMEHeaderKey
on every call. When the key is a canonical literal the result is the key
itself, so the scan is pure overhead. Access the map directly instead.

Only hot, allocation-free paths are converted, and only where the key is
a canonical literal. Call sites taking a key from config, a template or
the request itself still go through the normalizing methods, since they
need it for correctness.

allHeaderValues and lastHeaderValue dropped their http.CanonicalHeaderKey
call for the same reason: all callers pass canonical literals.

benchstat, Apple M5 Pro, go1.27.1:

    PrepareRequest-15      659.4n ± 2%  ->  457.7n ± 2%  -30.58% (p=0.000 n=12)
    AcceptedEncodings-15   330.8n ± 5%  ->  309.4n ± 7%   -6.44% (p=0.019 n=10)

    ResponseWriterWriteHeader/event-stream-15    1007.5n ± 3%  ->  732.5n ± 1%  -27.30%
    ResponseWriterWriteHeader/informational-15     60.0n ± 3%  ->   48.0n ± 2%  -20.01%
    ResponseWriterWriteHeader/not-modified-15     214.3n ± 6%  ->  175.3n ± 4%  -18.20%
    ResponseWriterWriteHeader/connect-15           60.2n ± 3%  ->   51.9n ± 4%  -13.85%
    ResponseWriterWriteHeader/html-15             206.5n ± 4%  ->  197.9n ± 2%   -4.19%
    geomean                                                                    -14.38%

Allocations are unchanged.

* tests: verify http.Header map keys are canonical

Indexing http.Header directly skips the canonicalization Get/Set/Add/Del
perform, which also puts the key outside what CI checks. canonicalheader
only inspects method calls. staticcheck's SA1008 covers reads, but stops
descending as soon as an assignment writes to a header, so it misses the
write, the delete builtin and the read-modify-write append. A key that is
not canonical in those positions is a silent lookup miss, not a compile
error.

Walk the loaded packages and verify every string constant used as a key in
an http.Header map operation matches http.CanonicalHeaderKey. A helper that
takes the key from its caller would hide the literal again, so a parameter
named with a canonical prefix opts its arguments into the same check.

The WebSocket headers Caddy deliberately writes with RFC 6455 casing are
allowed explicitly; they are only ever written out, never looked up against
a parsed header map.

Covers 64 operations, runs in under a second, and promotes golang.org/x/tools
from an indirect to a direct dependency at the version already in the module
graph.

* headercheck: resolve aliases of http.Header

* headercheck: check http.Header composite literal keys

* headercheck: note overlap with canonicalheader

* headercheck: drop parameter-name check

Helpers now take the indexed values, so every key sits in an index expression the analyzer and the linter already check.

* headercheck: scope RFC 6455 exceptions to their call sites

A key-based allow-list also accepted reads such as h["Sec-WebSocket-Key"], which miss because parsed headers are canonical. Honor //nolint:canonicalheader on the line instead, which the linter will use too.

* encode: read every Accept-Encoding field line

AcceptedEncodings read only the first field line, so a client that split
the list across several lines had the rest ignored. RFC 9110 section 5.3
treats repeated list-field lines as one comma-joined list. Iterate over
the items of every line instead of joining them, and skip empty items,
which used to yield an empty encoding. file_server's precompressed
selection uses the same function and is fixed with it.

Fixes https://github.com/caddyserver/caddy/issues/8272

Co-authored-by: Kağan Küçük <kaankck@gmail.com>

* encode: read every Cache-Control and Content-Encoding field line

Only the first field line was read, but RFC 9110 section 5.3 makes
multiple lines of a list-based field equivalent to one comma-joined
value:

- Cache-Control: no-transform on a later line was ignored, so the
  request or response was compressed anyway.
- Content-Encoding: an empty first line followed by a real coding let
  the handler encode the body a second time.

* encode: parse Cache-Control no-transform per RFC 9111

The substring match missed No-Transform, though RFC 9111 section 5.2 compares directive names case-insensitively, and fired on text that is not the directive: longer tokens such as x-no-transform-ext, quoted arguments such as community="no-transform", and semicolon-separated values, which are not valid Cache-Control syntax. Split the field on commas outside quoted strings and match only a well-formed no-transform element.

* encode: keep accepted encoding preferences on the stack

AcceptedEncodings counted commas to size the preference slice, and the variable capacity put it on the heap. A constant capacity covers typical Accept-Encoding headers and lets the compiler keep the slice on the stack; append still moves it to the heap for longer ones. BenchmarkAcceptedEncodings: 151.8ns -> 120.5ns (-21%), 192 -> 64 B/op, 2 -> 1 allocs/op (n=8).

* encode: build the encoded ETag without fmt

fmt.Sprintf boxed both arguments and parsed the format on every encoded response with a strong ETag. Concatenation: 47ns -> 16.5ns, 56 -> 24 B, 3 -> 1 allocs.

* reverseproxy: access response Server and Via headers directly

finalizeResponse runs on every proxied response; skip the key normalization as prepareRequest already does on the request side.

* encode: access headers directly in Write, WriteHeader and Close

These run once per response; convert the remaining literal-key Get, Set and Add calls like the rest of the handler.

* encode: match Cache-Control no-transform with httpguts

httpguts.HeaderValuesContainsToken already compares list elements case-insensitively across field lines, so the hand-written list parser is unnecessary. A no-transform inside a comma-separated quoted argument now also disables encoding, which only errs toward not transforming.

* encode: inline the no-transform check

isEncodeAllowed only wrapped one httpguts call. Its unit test now drives the response directive through ServeHTTP instead.

---------

Co-authored-by: Kağan Küçük <kaankck@gmail.com>
2026-10-29 23:19:50 +10:00

513 lines
15 KiB
Go

package encode
import (
"context"
"io"
"net/http"
"net/http/httptest"
"slices"
"sync"
"testing"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
)
func BenchmarkOpenResponseWriter(b *testing.B) {
enc := new(Encode)
for b.Loop() {
enc.openResponseWriter("test", nil, false)
}
}
// discardResponseWriter is a minimal http.ResponseWriter used to isolate
// WriteHeader's own cost from a real transport.
type discardResponseWriter struct {
header http.Header
}
func (w *discardResponseWriter) Header() http.Header { return w.header }
func (w *discardResponseWriter) Write(p []byte) (int, error) { return len(p), nil }
func (w *discardResponseWriter) WriteHeader(int) {}
// BenchmarkResponseWriterWriteHeader covers the branches inside WriteHeader:
// the plain/common case, the SSE Content-Type check (both when it doesn't
// match and when it does and rw.init() runs), CONNECT 2xx, informational
// (1xx), and 304 Not Modified (Vary bookkeeping).
func BenchmarkResponseWriterWriteHeader(b *testing.B) {
benchCases := []struct {
name string
encoding string
isConnect bool
status int
contentType string
}{
{name: "plain", encoding: "test", status: http.StatusOK},
{name: "html", encoding: "test", status: http.StatusOK, contentType: "text/html; charset=utf-8"},
{name: "event-stream", encoding: "gzip", status: http.StatusOK, contentType: "text/event-stream"},
{name: "connect", encoding: "test", isConnect: true, status: http.StatusOK},
{name: "informational", encoding: "test", status: http.StatusEarlyHints},
{name: "not-modified", encoding: "test", status: http.StatusNotModified},
}
for _, bc := range benchCases {
b.Run(bc.name, func(b *testing.B) {
enc := new(Encode)
if bc.name == "event-stream" {
enc.writerPools = map[string]*sync.Pool{
"gzip": {New: func() any { return mockEncoder{} }},
}
ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
defer cancel()
if err := enc.Provision(ctx); err != nil {
b.Fatalf("Provision() error = %v", err)
}
}
w := &discardResponseWriter{header: make(http.Header)}
rw := enc.openResponseWriter(bc.encoding, w, bc.isConnect)
for b.Loop() {
for k := range rw.Header() {
delete(rw.Header(), k)
}
if bc.contentType != "" {
rw.Header().Set("Content-Type", bc.contentType)
}
rw.wroteHeader = false
rw.statusCode = 0
rw.disabled = false
rw.w = nil
rw.WriteHeader(bc.status)
}
})
}
}
func TestIsSSE(t *testing.T) {
for _, tc := range []struct {
contentType string
want bool
}{
{"", false},
{"text/plain", false},
{"text/event-stream", true},
{"Text/Event-Stream", true},
{"text/event-stream; charset=utf-8", true},
{"text/event-stream ; charset=utf-8", true},
{"text/event-stream ", true},
{"text/event-streamfoo", false},
{"text/event-stream nonsense", false},
{"text/event-stream x; charset=utf-8", false},
} {
if got := isSSE(tc.contentType); got != tc.want {
t.Errorf("isSSE(%q) = %v, want %v", tc.contentType, got, tc.want)
}
}
}
func TestPreferOrder(t *testing.T) {
testCases := []struct {
name string
accept string
prefer []string
expected []string
}{
{
name: "PreferOrder(): 4 accept, 3 prefer",
accept: "deflate, gzip, br, zstd",
prefer: []string{"zstd", "br", "gzip"},
expected: []string{"zstd", "br", "gzip", "deflate"},
},
{
name: "PreferOrder(): 2 accept, 3 prefer",
accept: "deflate, zstd",
prefer: []string{"zstd", "br", "gzip"},
expected: []string{"zstd", "deflate"},
},
{
name: "PreferOrder(): 2 accept (1 empty), 3 prefer",
accept: "gzip,,zstd",
prefer: []string{"zstd", "br", "gzip"},
expected: []string{"zstd", "gzip"},
},
{
name: "PreferOrder(): 1 accept, 2 prefer",
accept: "gzip",
prefer: []string{"zstd", "gzip"},
expected: []string{"gzip"},
},
{
name: "PreferOrder(): 4 accept (1 duplicate), 1 prefer",
accept: "deflate, gzip, br, br",
prefer: []string{"br"},
expected: []string{"br", "br", "deflate", "gzip"},
},
{
name: "PreferOrder(): empty accept, 0 prefer",
accept: "",
prefer: []string{},
expected: []string{},
},
{
name: "PreferOrder(): empty accept, 1 prefer",
accept: "",
prefer: []string{"gzip"},
expected: []string{},
},
{
name: "PreferOrder(): with q-factor",
accept: "deflate;q=0.8, gzip;q=0.4, br;q=0.2, zstd",
prefer: []string{"gzip"},
expected: []string{"zstd", "deflate", "gzip", "br"},
},
{
name: "PreferOrder(): with q-factor, no prefer",
accept: "deflate;q=0.8, gzip;q=0.4, br;q=0.2, zstd",
prefer: []string{},
expected: []string{"zstd", "deflate", "gzip", "br"},
},
{
name: "PreferOrder(): q-factor=0 filtered out",
accept: "deflate;q=0.1, gzip;q=0.4, br;q=0.5, zstd;q=0",
prefer: []string{"gzip"},
expected: []string{"br", "gzip", "deflate"},
},
{
name: "PreferOrder(): q-factor=0 filtered out, no prefer",
accept: "deflate;q=0.1, gzip;q=0.4, br;q=0.5, zstd;q=0",
prefer: []string{},
expected: []string{"br", "gzip", "deflate"},
},
{
name: "PreferOrder(): with invalid q-factor",
accept: "br, deflate, gzip;q=2, zstd;q=0.1",
prefer: []string{"zstd", "gzip"},
expected: []string{"gzip", "br", "deflate", "zstd"},
},
{
name: "PreferOrder(): with invalid q-factor, no prefer",
accept: "br, deflate, gzip;q=2, zstd;q=0.1",
prefer: []string{},
expected: []string{"br", "deflate", "gzip", "zstd"},
},
}
enc := new(Encode)
r, _ := http.NewRequest("", "", nil)
for _, test := range testCases {
t.Run(test.name, func(t *testing.T) {
if test.accept == "" {
r.Header.Del("Accept-Encoding")
} else {
r.Header.Set("Accept-Encoding", test.accept)
}
enc.Prefer = test.prefer
result := AcceptedEncodings(r, enc.Prefer)
if !slices.Equal(result, test.expected) {
t.Errorf("AcceptedEncodings() actual: %s expected: %s",
result,
test.expected)
}
})
}
}
func TestValidate(t *testing.T) {
type testCase struct {
name string
prefer []string
wantErr bool
}
var err error
var testCases []testCase
enc := new(Encode)
enc.writerPools = map[string]*sync.Pool{
"zstd": nil,
"gzip": nil,
"br": nil,
}
testCases = []testCase{
{
name: "ValidatePrefer (zstd, gzip & br enabled): valid order with all encoder",
prefer: []string{"zstd", "br", "gzip"},
wantErr: false,
},
{
name: "ValidatePrefer (zstd, gzip & br enabled): valid order with 2 out of 3 encoders",
prefer: []string{"br", "gzip"},
wantErr: false,
},
{
name: "ValidatePrefer (zstd, gzip & br enabled): valid order with 1 out of 3 encoders",
prefer: []string{"gzip"},
wantErr: false,
},
{
name: "ValidatePrefer (zstd, gzip & br enabled): 1 duplicated (once) encoder",
prefer: []string{"gzip", "zstd", "gzip"},
wantErr: true,
},
{
name: "ValidatePrefer (zstd, gzip & br enabled): 1 not enabled encoder in prefer list",
prefer: []string{"br", "zstd", "gzip", "deflate"},
wantErr: true,
},
{
name: "ValidatePrefer (zstd, gzip & br enabled): no prefer list",
prefer: []string{},
wantErr: false,
},
}
for _, test := range testCases {
t.Run(test.name, func(t *testing.T) {
enc.Prefer = test.prefer
err = enc.Validate()
if (err != nil) != test.wantErr {
t.Errorf("Validate() error = %v, wantErr = %v", err, test.wantErr)
}
})
}
enc.writerPools = map[string]*sync.Pool{
"zstd": nil,
"gzip": nil,
}
testCases = []testCase{
{
name: "ValidatePrefer (zstd & gzip enabled): 1 not enabled encoder in prefer list",
prefer: []string{"zstd", "br", "gzip"},
wantErr: true,
},
{
name: "ValidatePrefer (zstd & gzip enabled): 2 not enabled encoder in prefer list",
prefer: []string{"br", "zstd", "gzip", "deflate"},
wantErr: true,
},
{
name: "ValidatePrefer (zstd & gzip enabled): only not enabled encoder in prefer list",
prefer: []string{"deflate", "br", "gzip"},
wantErr: true,
},
{
name: "ValidatePrefer (zstd & gzip enabled): 1 duplicated (once) encoder in prefer list",
prefer: []string{"gzip", "zstd", "gzip"},
wantErr: true,
},
{
name: "ValidatePrefer (zstd & gzip enabled): 1 duplicated (twice) encoder in prefer list",
prefer: []string{"gzip", "zstd", "gzip", "gzip"},
wantErr: true,
},
{
name: "ValidatePrefer (zstd & gzip enabled): 1 duplicated encoder in prefer list",
prefer: []string{"zstd", "zstd", "gzip", "gzip"},
wantErr: true,
},
{
name: "ValidatePrefer (zstd & gzip enabled): 1 duplicated not enabled encoder in prefer list",
prefer: []string{"br", "br", "gzip"},
wantErr: true,
},
{
name: "ValidatePrefer (zstd & gzip enabled): 2 duplicated not enabled encoder in prefer list",
prefer: []string{"br", "deflate", "br", "deflate"},
wantErr: true,
},
{
name: "ValidatePrefer (zstd & gzip enabled): valid order zstd first",
prefer: []string{"zstd", "gzip"},
wantErr: false,
},
{
name: "ValidatePrefer (zstd & gzip enabled): valid order gzip first",
prefer: []string{"gzip", "zstd"},
wantErr: false,
},
}
for _, test := range testCases {
t.Run(test.name, func(t *testing.T) {
enc.Prefer = test.prefer
err = enc.Validate()
if (err != nil) != test.wantErr {
t.Errorf("Validate() error = %v, wantErr = %v", err, test.wantErr)
}
})
}
}
func TestServeHTTPResponseNoTransform(t *testing.T) {
enc := &Encode{
MinLength: 1,
writerPools: map[string]*sync.Pool{
"gzip": {New: func() any { return mockEncoder{} }},
},
}
ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
defer cancel()
if err := enc.Provision(ctx); err != nil {
t.Fatalf("Provision failed: %v", err)
}
for _, tc := range []struct {
cacheControl []string
encoded bool
}{
{cacheControl: nil, encoded: true},
{cacheControl: []string{"no-cache, no-transform"}, encoded: false},
{cacheControl: []string{"max-age=0, No-Transform"}, encoded: false},
{cacheControl: []string{"no-cache", "no-transform"}, encoded: false},
{cacheControl: []string{`private="Set-Cookie, Vary", no-transform`}, encoded: false},
// extension values and longer tokens are not the directive
{cacheControl: []string{`community="no-transform"`}, encoded: true},
{cacheControl: []string{"community=no-transform"}, encoded: true},
{cacheControl: []string{"x-no-transform-ext, no-transformation"}, encoded: true},
// semicolons are not Cache-Control list separators
{cacheControl: []string{"no-cache; no-transform"}, encoded: true},
} {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Accept-Encoding", "gzip")
w := httptest.NewRecorder()
err := enc.ServeHTTP(w, r, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.Header()["Content-Type"] = []string{"text/plain"}
w.Header()["Cache-Control"] = tc.cacheControl
_, err := w.Write([]byte("hello"))
return err
}))
if err != nil {
t.Fatalf("Cache-Control %q: ServeHTTP: %v", tc.cacheControl, err)
}
if encoded := w.Header().Get("Content-Encoding") == "gzip"; encoded != tc.encoded {
t.Errorf("Cache-Control %q: encoded = %t, want %t", tc.cacheControl, encoded, tc.encoded)
}
}
}
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 TestServeHTTPDefaultEncodingPreference(t *testing.T) {
enc := new(Encode)
enc.MinLength = 1 // compress everything
enc.writerPools = map[string]*sync.Pool{
"gzip": {
New: func() any { return mockEncoder{} },
},
"zstd": {
New: func() any { return mockEncoder{} },
},
}
// Call Provision() with a valid caddy.Context to exercise the real path
ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
defer cancel()
if err := enc.Provision(ctx); err != nil {
t.Fatalf("Provision failed: %v", err)
}
// Test default preference: zstd preferred over gzip
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatalf("error creating request: %v", err)
}
r.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
w := httptest.NewRecorder()
w.Header().Set("Content-Type", "text/plain")
next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte("Hello, world! This is a long enough string to satisfy min length if it wasn't 1."))
return err
})
err = enc.ServeHTTP(w, r, next)
if err != nil {
t.Fatalf("ServeHTTP returned error: %v", err)
}
// ETag suffix or Content-Encoding header should reflect zstd
contentEncoding := w.Header().Get("Content-Encoding")
if contentEncoding != "zstd" {
t.Errorf("Expected Content-Encoding to be 'zstd' by default, got '%s'", contentEncoding)
}
// Test explicit user preference: gzip over zstd
enc.Prefer = []string{"gzip", "zstd"}
w2 := httptest.NewRecorder()
w2.Header().Set("Content-Type", "text/plain")
err = enc.ServeHTTP(w2, r, next)
if err != nil {
t.Fatalf("ServeHTTP returned error: %v", err)
}
contentEncoding2 := w2.Header().Get("Content-Encoding")
if contentEncoding2 != "gzip" {
t.Errorf("Expected Content-Encoding to be 'gzip' when explicitly preferred, got '%s'", contentEncoding2)
}
}
// RFC 9110 section 5.3: a recipient MAY combine multiple field lines with the
// same name by joining their values with commas; the semantics of a list-based
// field like Accept-Encoding do not change when it is split across lines.
func TestAcceptedEncodingsMultipleFieldLines(t *testing.T) {
for _, tc := range []struct {
lines []string
expected []string
}{
{[]string{"gzip", "br"}, []string{"br", "gzip"}},
{[]string{"identity", "br"}, []string{"br", "identity"}},
{[]string{"gzip;q=0", "br"}, []string{"br"}},
{[]string{"", "gzip"}, []string{"gzip"}},
{[]string{"gzip;q=0.5", "zstd;q=0.8, br;q=0"}, []string{"zstd", "gzip"}},
} {
r, _ := http.NewRequest(http.MethodGet, "/", nil)
for _, l := range tc.lines {
r.Header.Add("Accept-Encoding", l)
}
got := AcceptedEncodings(r, []string{"zstd", "br", "gzip"})
if !slices.Equal(got, tc.expected) {
t.Errorf("Accept-Encoding lines %q: got %v, expected %v", tc.lines, got, tc.expected)
}
}
}
func TestServeHTTPMultipleAcceptEncodingLines(t *testing.T) {
enc := new(Encode)
enc.MinLength = 1
enc.writerPools = map[string]*sync.Pool{
"br": {New: func() any { return mockEncoder{} }},
}
ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
defer cancel()
if err := enc.Provision(ctx); err != nil {
t.Fatal(err)
}
r, _ := http.NewRequest(http.MethodGet, "/", nil)
r.Header.Add("Accept-Encoding", "identity")
r.Header.Add("Accept-Encoding", "br")
w := httptest.NewRecorder()
w.Header().Set("Content-Type", "text/plain")
next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte("hello world"))
return err
})
if err := enc.ServeHTTP(w, r, next); err != nil {
t.Fatal(err)
}
if ce := w.Header().Get("Content-Encoding"); ce != "br" {
t.Errorf("expected Content-Encoding br, got %q", ce)
}
}