mirror of
https://github.com/caddyserver/caddy.git
synced 2026-11-01 00:41:31 -04: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>
593 lines
20 KiB
Go
593 lines
20 KiB
Go
package encode_test
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
|
|
)
|
|
|
|
const conformanceContentType = "text/plain"
|
|
|
|
func TestEncodeIfNoneMatch(t *testing.T) {
|
|
for _, encCase := range standardEncoderCases(t) {
|
|
t.Run(encCase.name, func(t *testing.T) {
|
|
enc := newEncodeHandler(t, encCase, 1)
|
|
for _, tc := range []struct {
|
|
name string
|
|
values []string
|
|
want []string
|
|
etag string
|
|
status int
|
|
}{
|
|
{"single", []string{`"response-%s"`}, []string{`"response"`}, `"response"`, 304},
|
|
{"matching first", []string{`"response-%s", "stale-%s"`}, []string{`"response", "stale"`}, `"response"`, 304},
|
|
{"matching last", []string{`"stale-%s", "response-%s"`}, []string{`"stale", "response"`}, `"response"`, 304},
|
|
{"weak", []string{`W/"response-%s"`}, []string{`W/"response"`}, `"response"`, 304},
|
|
{"mixed", []string{`W/"stale-%s", "response-%s"`}, []string{`W/"stale", "response"`}, `"response"`, 304},
|
|
{"quoted comma", []string{`"response,part-%s", "stale-%s"`}, []string{`"response,part", "stale"`}, `"response,part"`, 304},
|
|
{"multiple lines", []string{`"stale-%s"`, `W/"response-%s"`}, []string{`"stale", W/"response"`}, `"response"`, 304},
|
|
{"wildcard", []string{`*`}, []string{`*`}, `"response"`, 304},
|
|
{"unencoded", []string{`"response"`}, []string{`"response"`}, `"response"`, 304},
|
|
{"other suffix", []string{`"response-other"`}, []string{`"response-other"`}, `"response"`, 200},
|
|
{"suffix inside tag", []string{`"response-%s-extra"`}, []string{`"response-%s-extra"`}, `"response"`, 200},
|
|
{"unterminated", []string{`"response-%s`}, []string{`"response-%s`}, `"response"`, 200},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set("Accept-Encoding", encCase.encoding.AcceptEncoding())
|
|
values, want := slices.Clone(tc.values), slices.Clone(tc.want)
|
|
for _, list := range [][]string{values, want} {
|
|
for i := range list {
|
|
list[i] = strings.ReplaceAll(list[i], "%s", encCase.encoding.AcceptEncoding())
|
|
}
|
|
}
|
|
r.Header["If-None-Match"] = values
|
|
w := httptest.NewRecorder()
|
|
next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
if got := r.Header.Values("If-None-Match"); !slices.Equal(got, want) {
|
|
t.Errorf("If-None-Match = %q, want %q", got, want)
|
|
}
|
|
w.Header().Set("Etag", tc.etag)
|
|
http.ServeContent(w, r, "asset.txt", time.Time{}, strings.NewReader("response body"))
|
|
return nil
|
|
})
|
|
if err := enc.ServeHTTP(w, r, next); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if w.Code != tc.status {
|
|
t.Errorf("status = %d, want %d", w.Code, tc.status)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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: "request cache-control no-transform on a later line 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 on a later line 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()["Cache-Control"] = []string{"no-cache", "no-transform"}
|
|
_, err := w.Write(conformanceLargeBody())
|
|
return err
|
|
})
|
|
},
|
|
checkResponse: checkBypassesEncoding,
|
|
},
|
|
{
|
|
name: "content-encoding after an empty line 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()["Content-Encoding"] = []string{"", "gzip"}
|
|
_, err := w.Write(conformanceLargeBody())
|
|
return err
|
|
})
|
|
},
|
|
checkResponse: checkKeepsUpstreamContentEncoding,
|
|
},
|
|
{
|
|
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))
|
|
}
|
|
}
|
|
|
|
func checkKeepsUpstreamContentEncoding(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
|
|
t.Helper()
|
|
|
|
if got, want := w.Header().Values("Content-Encoding"), []string{"", "gzip"}; !slices.Equal(got, want) {
|
|
t.Fatalf("Content-Encoding = %q, want %q", got, want)
|
|
}
|
|
if want := conformanceLargeBody(); !bytes.Equal(w.Body.Bytes(), want) {
|
|
t.Fatalf("body len = %d, want len = %d", w.Body.Len(), len(want))
|
|
}
|
|
}
|
|
|
|
// TestEncodeHeadResponseContentLength verifies that a HEAD response does not
|
|
// advertise the length of an empty encoded stream.
|
|
func TestEncodeHeadResponseContentLength(t *testing.T) {
|
|
for _, encCase := range standardEncoderCases(t) {
|
|
t.Run(encCase.name, func(t *testing.T) {
|
|
enc := newEncodeHandler(t, encCase, 1)
|
|
next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
w.Header().Set("Content-Type", conformanceContentType)
|
|
w.Header().Set("Content-Length", "128")
|
|
return nil
|
|
})
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if err := enc.ServeHTTP(w, r, next); err != nil {
|
|
t.Errorf("ServeHTTP() error = %v", err)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
req, err := http.NewRequest(http.MethodHead, srv.URL, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set("Accept-Encoding", encCase.encoding.AcceptEncoding())
|
|
resp, err := srv.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
if got := resp.Header.Get("Content-Encoding"); got != encCase.encoding.AcceptEncoding() {
|
|
t.Fatalf("Content-Encoding = %q, want %q", got, encCase.encoding.AcceptEncoding())
|
|
}
|
|
if resp.ContentLength != -1 {
|
|
t.Fatalf("ContentLength = %d, want -1", resp.ContentLength)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEncodeHeaderOnlyResponseHasNoBody verifies that a response whose header
|
|
// is written without any body does not get an empty encoded stream as its body.
|
|
func TestEncodeHeaderOnlyResponseHasNoBody(t *testing.T) {
|
|
for _, encCase := range standardEncoderCases(t) {
|
|
t.Run(encCase.name, func(t *testing.T) {
|
|
enc := newEncodeHandler(t, encCase, 1)
|
|
next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.WriteHeader(http.StatusOK)
|
|
return nil
|
|
})
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if err := enc.ServeHTTP(w, r, next); err != nil {
|
|
t.Errorf("ServeHTTP() error = %v", err)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set("Accept-Encoding", encCase.encoding.AcceptEncoding())
|
|
resp, err := srv.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if got := resp.Header.Get("Content-Encoding"); got != encCase.encoding.AcceptEncoding() {
|
|
t.Fatalf("Content-Encoding = %q, want %q", got, encCase.encoding.AcceptEncoding())
|
|
}
|
|
if len(body) != 0 {
|
|
t.Fatalf("body len = %d, want 0", len(body))
|
|
}
|
|
})
|
|
}
|
|
}
|