edge: Accept-Encoding gzip negotiation
q-valued token list, not a substring search: gzip;q=0 is a client refusing gzip, and a specific token outranks the wildcard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NoqzkUVahW5ykubsBQitxc
2 files changed,
+136
−0
internal/edge/compress.go+63 −0internal/edge/compress_test.go+73 −0
diff --git a/internal/edge/compress.go b/internal/edge/compress.go| new file mode 100644 |
| index 000000000..78e2ca286 |
| --- /dev/null |
| +++ b/internal/edge/compress.go |
| @@ -0,0 +1,63 @@ |
| +// Edge response compression: one gzip ResponseWriter wrapper applied at the |
| +// client-write boundary in Edge.ServeHTTP, so every serving class — cache |
| +// hits, proxied misses, static trees, the status host — compresses through a |
| +// single site. Apps ship identity bytes; transport is the platform's problem. |
| +// |
| +// The wrapper sits BELOW recordingResponseWriter and ABOVE countingWriter: |
| +// the cache keeps storing identity bodies (one variant, compressed after |
| +// retrieval on every delivery) and the usage meter counts what actually |
| +// crossed the wire. |
| +package edge |
| + |
| +import ( |
| + "net/http" |
| + "strconv" |
| + "strings" |
| +) |
| + |
| +// acceptsGzip reports whether r's Accept-Encoding admits gzip. Parsed as the |
| +// q-valued token list RFC 9110 §12.5.3 defines rather than a substring search: |
| +// "gzip;q=0" is a client REFUSING gzip, and answering it with a compressed |
| +// body is a broken response, not a missed optimization. |
| +// |
| +// A specific token outranks the wildcard, so the first gzip token decides — |
| +// "gzip;q=0, *" is a refusal. The wildcard only speaks when gzip is unnamed. |
| +// |
| +// Read through joinHeader, not Header.Get: a field may legally arrive on |
| +// several lines, and Get returns only the first (the same class of bug |
| +// joinHeader's doc comment describes for Vary and Cache-Control). |
| +func acceptsGzip(h http.Header) bool { |
| + var wildcard, wildcardOK bool |
| + for _, part := range strings.Split(joinHeader(h, "Accept-Encoding"), ",") { |
| + token, params, _ := strings.Cut(strings.TrimSpace(part), ";") |
| + switch strings.ToLower(strings.TrimSpace(token)) { |
| + case "gzip", "x-gzip": |
| + return qValue(params) > 0 |
| + case "*": |
| + wildcard, wildcardOK = true, qValue(params) > 0 |
| + } |
| + } |
| + if wildcard { |
| + return wildcardOK |
| + } |
| + return false |
| +} |
| + |
| +// qValue reads the q parameter out of one Accept-Encoding element's parameter |
| +// string ("q=0.5", "q=0"). Absent or malformed means q=1: a client whose |
| +// header we cannot fully parse is treated as accepting, which is the same |
| +// default the stdlib's own negotiation takes. |
| +func qValue(params string) float64 { |
| + for _, p := range strings.Split(params, ";") { |
| + name, val, ok := strings.Cut(strings.TrimSpace(p), "=") |
| + if !ok || !strings.EqualFold(strings.TrimSpace(name), "q") { |
| + continue |
| + } |
| + f, err := strconv.ParseFloat(strings.TrimSpace(val), 64) |
| + if err != nil { |
| + return 1 |
| + } |
| + return f |
| + } |
| + return 1 |
| +} |
diff --git a/internal/edge/compress_test.go b/internal/edge/compress_test.go| new file mode 100644 |
| index 000000000..6ddda82e6 |
| --- /dev/null |
| +++ b/internal/edge/compress_test.go |
| @@ -0,0 +1,73 @@ |
| +package edge |
| + |
| +import ( |
| + "net/http" |
| + "testing" |
| +) |
| + |
| +// TestAcceptsGzip pins the q-value rules. The two that matter in production |
| +// are `gzip;q=0` (a client explicitly refusing gzip — some corporate proxies |
| +// and old curl invocations send it) and a bare `*`, which admits gzip only |
| +// when its own q is non-zero. A specific token always outranks the wildcard, |
| +// so `gzip;q=0, *` is a refusal however generous the wildcard is. |
| +func TestAcceptsGzip(t *testing.T) { |
| + for _, tc := range []struct { |
| + ae string |
| + want bool |
| + }{ |
| + {"", false}, |
| + {"gzip", true}, |
| + {"GZIP", true}, |
| + {"x-gzip", true}, |
| + {"gzip, deflate, br", true}, |
| + {" gzip ; q=0.5 ", true}, |
| + {"gzip;q=0", false}, |
| + {"gzip;q=0.0", false}, |
| + {"gzip;q=0, *", false}, |
| + {"identity", false}, |
| + {"deflate, br", false}, |
| + {"*", true}, |
| + {"*;q=0", false}, |
| + {"deflate, *;q=0.1", true}, |
| + } { |
| + h := http.Header{} |
| + if tc.ae != "" { |
| + h.Set("Accept-Encoding", tc.ae) |
| + } |
| + if got := acceptsGzip(h); got != tc.want { |
| + t.Errorf("acceptsGzip(%q) = %v, want %v", tc.ae, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// TestAcceptsGzipReadsEveryLine: Accept-Encoding may arrive on several lines |
| +// and that is identical in meaning to one comma-joined line (RFC 9110 §5.3). |
| +// Reading it with Header.Get would see only the first, which is the exact bug |
| +// joinHeader exists to prevent for Vary and Cache-Control. |
| +func TestAcceptsGzipReadsEveryLine(t *testing.T) { |
| + h := http.Header{} |
| + h.Add("Accept-Encoding", "deflate") |
| + h.Add("Accept-Encoding", "gzip") |
| + if !acceptsGzip(h) { |
| + t.Fatal("gzip on a second Accept-Encoding line was invisible — read every line via joinHeader, not Get") |
| + } |
| +} |
| + |
| +func TestQValue(t *testing.T) { |
| + for _, tc := range []struct { |
| + params string |
| + want float64 |
| + }{ |
| + {"", 1}, |
| + {"q=1", 1}, |
| + {"q=0", 0}, |
| + {"q=0.5", 0.5}, |
| + {" Q = 0.25 ", 0.25}, |
| + {"q=bogus", 1}, // malformed q is ignored, not treated as a refusal |
| + {"charset=utf-8", 1}, |
| + } { |
| + if got := qValue(tc.params); got != tc.want { |
| + t.Errorf("qValue(%q) = %v, want %v", tc.params, got, tc.want) |
| + } |
| + } |
| +} |