mirror of
https://github.com/caddyserver/caddy.git
synced 2026-11-01 07:51:04 -05:00
* 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>
513 lines
15 KiB
Go
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)
|
|
}
|
|
}
|