Files
tailscale/tsweb/compserve/compress_test.go
James Tucker 6608b9a387 tsweb/compserve, client/web: add zstd, remove brotli for precompressed assets
Adds tsweb/compserve: content negotiation for precompressed static
variants, with a transcode-to-identity fallback for clients that do not
accept an encoding (including when the raw file is absent), and
CompressWriter, which live-compresses dynamic responses with zstd in its
fastest mode, streamed incrementally with no buffering. Negotiation is
q-value and wildcard aware (gzip;q=0 previously matched gzip).

client/web serves its prebuilt embedded assets through compserve,
replacing brotli with zstd; the embedded FS is wrapped in
tsweb/vcstime for conditional-request mod times. tsweb/compserve/gzip.go
keeps transitional serving of gzip variants from pre-zstd file systems
(such as the currently published web-client-prebuilt module):
passthrough to gzip-accepting clients, transcoded to identity otherwise;
it becomes inert once a zstd-only module is published.

util/zstdframe gains pooled GetDecoder and GetStreamingEncoder
(concurrency=1). util/precompress is now a build-time tool, generating
zstd variants only. cmd/tsconnect and cmd/build-webclient consume the
new precompress/compserve split. tsweb.AcceptsEncoding and
tsweb/tswebutil are removed; negotiation lives in compserve and the
deprecated shim had no callers. go.mod bumps web-client-prebuilt.

Also fixes a transcoding bug where http.ServeContent's size probe via
the promoted zstd.Decoder.WriteTo could report a zero length, serving
empty bodies.

Updates tailscale/corp#20099

Signed-off-by: James Tucker <james@tailscale.com>
2026-09-17 15:23:49 -07:00

548 lines
18 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package compserve
import (
"bufio"
"bytes"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"time"
"tailscale.com/util/zstdframe"
)
var timeZero time.Time
// serveCompressed runs inner through CompressWriter with the given
// Accept-Encoding header and returns the recorder.
func serveCompressed(t *testing.T, ae string, inner http.HandlerFunc) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest("GET", "/", nil)
if ae != "" {
r.Header.Set("Accept-Encoding", ae)
}
w := httptest.NewRecorder()
cw := CompressWriter(w, r)
defer cw.Close()
inner.ServeHTTP(cw, r)
return w
}
const testBody = `{"hello": "world, this is a moderately sized json response body intended to exceed the minimum compression size threshold"}`
func TestCompressWriter_Compresses(t *testing.T) {
w := serveCompressed(t, "zstd", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(strings.Repeat(testBody, 20)))
})
if got := w.Header().Get("Content-Encoding"); got != "zstd" {
t.Errorf("Content-Encoding = %q, want %q", got, "zstd")
}
if got := w.Code; got != 200 {
t.Errorf("code = %d, want 200", got)
}
decoded, err := zstdframe.AppendDecode(nil, w.Body.Bytes())
if err != nil {
t.Fatalf("decoding response: %v", err)
}
if want := strings.Repeat(testBody, 20); string(decoded) != want {
t.Errorf("decoded body = %q, want %q", decoded, want)
}
// The compressed size is not known up front, so no Content-Length is
// set; the response is chunked.
if got := w.Header().Get("Content-Length"); got != "" {
t.Errorf("Content-Length = %q, want empty", got)
}
}
func TestCompressWriter_VaryAndWeakETag(t *testing.T) {
w := serveCompressed(t, "zstd", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("ETag", `"strong-etag"`)
w.Header().Set("Vary", "Origin")
w.Write([]byte(strings.Repeat(testBody, 20)))
})
if got := w.Header().Get("ETag"); got != `W/"strong-etag"` {
t.Errorf("ETag = %q, want weakened", got)
}
if vary := strings.Join(w.Header().Values("Vary"), ", "); vary != "Origin, Accept-Encoding" {
t.Errorf("Vary = %q, want %q", vary, "Origin, Accept-Encoding")
}
}
func TestCompressWriter_PreservesWeakETag(t *testing.T) {
w := serveCompressed(t, "zstd", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("ETag", `W/"already-weak"`)
w.Write([]byte(strings.Repeat(testBody, 20)))
})
if got := w.Header().Get("ETag"); got != `W/"already-weak"` {
t.Errorf("ETag = %q, want unchanged", got)
}
if got := w.Header().Get("Content-Encoding"); got != "zstd" {
t.Errorf("Content-Encoding = %q, want zstd", got)
}
}
func TestCompressWriter_NotAccepted(t *testing.T) {
want := strings.Repeat(testBody, 20)
w := serveCompressed(t, "br", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(want))
})
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Errorf("Content-Encoding = %q, want empty", got)
}
if w.Body.String() != want {
t.Error("body was modified")
}
}
func TestCompressWriter_SkipsWhenAlreadyEncoded(t *testing.T) {
want := strings.Repeat(testBody, 20)
w := serveCompressed(t, "zstd", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Encoding", "zstd") // e.g. precompressed passthrough
w.Write([]byte(want))
})
if w.Body.String() != want {
t.Error("body was modified")
}
if n := len(w.Header().Values("Content-Encoding")); n != 1 {
t.Errorf("Content-Encoding set %d times, want 1", n)
}
}
func TestCompressWriter_GzipNotLiveCompressed(t *testing.T) {
// Gzip has no Compress hook: live compression is zstd-only, so a
// gzip-accepting client gets identity rather than gzip.
want := strings.Repeat(testBody, 20)
w := serveCompressed(t, "gzip", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(want))
})
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Errorf("Content-Encoding = %q, want empty", got)
}
if w.Body.String() != want {
t.Error("body was modified")
}
}
func TestCompressWriter_SkipsRange(t *testing.T) {
want := strings.Repeat(testBody, 20)
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "zstd")
r.Header.Set("Range", "bytes=0-99")
w := httptest.NewRecorder()
cw := CompressWriter(w, r)
defer cw.Close()
cw.WriteHeader(http.StatusPartialContent)
cw.Write([]byte(want))
if w.Body.String() != want {
t.Error("body was modified")
}
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Errorf("Content-Encoding = %q, want empty", got)
}
}
func TestCompressWriter_SkipsIncompressibleType(t *testing.T) {
// Media codec content types are excluded by category, regardless of
// what the bytes look like.
want := strings.Repeat(testBody, 20)
w := serveCompressed(t, "zstd", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "video/mp4")
w.Write([]byte(want))
})
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Errorf("Content-Encoding = %q, want empty", got)
}
if w.Body.String() != want {
t.Error("body was modified")
}
// Raw audio is compressed despite the audio/ exclusion.
w = serveCompressed(t, "zstd", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "audio/wav")
w.Write([]byte(want))
})
if got := w.Header().Get("Content-Encoding"); got != "zstd" {
t.Errorf("Content-Encoding = %q, want zstd", got)
}
}
func TestCompressWriter_SniffsUnsetContentType(t *testing.T) {
// No Content-Type: http.DetectContentType sniffs text/plain, which is
// compressible. The sniffed type must be persisted on the response:
// net/http would otherwise sniff the compressed bytes and label the
// response application/octet-stream.
w := serveCompressed(t, "zstd", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(strings.Repeat(testBody, 20)))
})
if got := w.Header().Get("Content-Encoding"); got != "zstd" {
t.Errorf("Content-Encoding = %q, want zstd", got)
}
if got := w.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Errorf("Content-Type = %q, want text/plain; charset=utf-8", got)
}
}
func TestCompressWriter_SkipsNon200(t *testing.T) {
want := strings.Repeat(testBody, 20)
w := serveCompressed(t, "zstd", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(want))
})
if w.Code != http.StatusNotFound {
t.Errorf("code = %d, want 404", w.Code)
}
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Errorf("Content-Encoding = %q, want empty", got)
}
if w.Body.String() != want {
t.Error("body was modified")
}
}
func TestCompressWriter_FlushStreamsCompressed(t *testing.T) {
// Streaming handlers that flush mid-body remain compressed; each flush
// emits the pending compressed bytes to the client.
want := strings.Repeat(testBody, 20)
w := serveCompressed(t, "zstd", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(want[:100]))
w.(http.Flusher).Flush()
w.Write([]byte(want[100:]))
})
if got := w.Header().Get("Content-Encoding"); got != "zstd" {
t.Errorf("Content-Encoding = %q, want zstd", got)
}
decoded, err := zstdframe.AppendDecode(nil, w.Body.Bytes())
if err != nil {
t.Fatalf("decoding response: %v", err)
}
if string(decoded) != want {
t.Error("decoded body mismatch")
}
}
func TestCompressWriter_NoBody(t *testing.T) {
// A handler that only sets headers (e.g. a redirect) still produces a
// well-formed response.
w := serveCompressed(t, "zstd", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Location", "/elsewhere")
w.WriteHeader(http.StatusFound)
})
if w.Code != http.StatusFound {
t.Errorf("code = %d, want 302", w.Code)
}
if got := w.Header().Get("Location"); got != "/elsewhere" {
t.Errorf("Location = %q", got)
}
}
func TestCompressContentTypes(t *testing.T) {
tests := map[string]bool{
// Default: compressible.
"text/html; charset=utf-8": true,
"application/json": true,
"application/json; charset=utf-8": true,
"application/javascript": true,
"application/manifest+json": true,
"application/vnd.api+json": true,
"application/problem+json": true,
"application/xhtml+xml": true,
"image/svg+xml": true,
"image/svg+xml; charset=utf-8": true,
"application/rss+xml": true,
"application/wasm": true,
"TEXT/PLAIN": true,
"application/octet-stream": true, // no-gain guard bails if needed
"image/png": true, // no-gain guard bails if needed
"image/jpeg; charset=binary": true, // no-gain guard bails if needed
"font/woff2": true, // no-gain guard bails if needed
"": true,
// Media codecs are already compressed; excluded by category...
"audio/mpeg": false,
"audio/ogg": false,
"audio/flac": false,
"video/mp4": false,
"video/webm": false,
"video/H264": false,
// ...unless raw, uncompressed media.
"audio/wav": true,
"audio/x-wav": true,
"audio/vnd.wave": true,
"audio/pcm": true,
"audio/L16": true,
"audio/l24": true,
"video/x-y4m": true,
"video/x-raw": true,
"video/raw": true,
// Known compression formats are skipped.
"application/zip": false,
"application/gzip": false,
"application/x-gzip": false,
"application/x-bzip2": false,
"application/x-xz": false,
"application/zstd": false,
"application/x-7z-compressed": false,
"application/x-rar-compressed": false,
"application/vnd.rar": false,
"application/x-compress": false,
"application/x-snappy-framed": false,
"application/x-lz4": false,
"application/java-archive": false, // jar: zip
"application/vnd.android.package-archive": false, // apk: zip
"application/x-apple-diskimage": false, // dmg
}
for ct, want := range tests {
if got := compressibleContentType(ct); got != want {
t.Errorf("compressibleContentType(%q) = %v, want %v", ct, got, want)
}
}
}
func TestCompressWriter_ServeContentIntegration(t *testing.T) {
// An inner http.ServeContent handler (raw, non-precompressed file)
// should be compressed, with its Content-Length replaced and ETag
// weakened.
original := []byte(strings.Repeat(testBody, 20))
tfs := fstest.MapFS{"data.json": &fstest.MapFile{Data: original}}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f, err := tfs.Open("data.json")
if err != nil {
t.Fatal(err)
}
defer f.Close()
// fs.File does not claim to implement Seeker, but MapFS files do.
http.ServeContent(w, r, "data.json", timeZero, f.(io.ReadSeeker))
})
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "zstd")
w := httptest.NewRecorder()
cw := CompressWriter(w, r)
handler.ServeHTTP(cw, r)
// Close before asserting: it is what emits the buffered response.
cw.Close()
if got := w.Header().Get("Content-Encoding"); got != "zstd" {
t.Errorf("Content-Encoding = %q, want zstd", got)
}
// http.ServeContent sets Content-Length for the raw file; streaming
// compression must have removed it rather than lie about the size.
if got := w.Header().Get("Content-Length"); got != "" {
t.Errorf("Content-Length = %q, want empty", got)
}
decoded, err := zstdframe.AppendDecode(nil, w.Body.Bytes())
if err != nil {
t.Fatalf("decoding: %v", err)
}
if !bytes.Equal(decoded, original) {
t.Error("decoded body mismatch")
}
}
func TestCompressWriter_DoubleClose(t *testing.T) {
// A second Close must not return the pooled encoder to the pool twice;
// two concurrent requests would then share one encoder.
body := strings.Repeat(testBody, 20)
w := serveCompressed(t, "zstd", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(body))
})
// serveCompressed already Closed once; a second Close is a no-op.
decoded, err := zstdframe.AppendDecode(nil, w.Body.Bytes())
if err != nil {
t.Fatalf("decoding response: %v", err)
}
if string(decoded) != body {
t.Error("decoded body mismatch")
}
// The pool is not poisoned: the next request compresses correctly.
w2 := serveCompressed(t, "zstd", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(body))
})
decoded2, err := zstdframe.AppendDecode(nil, w2.Body.Bytes())
if err != nil {
t.Fatalf("decoding second response: %v", err)
}
if string(decoded2) != body {
t.Error("second decoded body mismatch")
}
}
// hijackableRecorder wraps a ResponseRecorder, recording whether the
// handler used the raw connection.
type hijackableRecorder struct {
*httptest.ResponseRecorder
hijacked bool
}
func (h *hijackableRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
h.hijacked = true
return nil, nil, nil
}
func TestCompressWriter_HijackBeforeWrite(t *testing.T) {
// A handler that hijacks before producing a response gets the raw
// connection: no compression is attached, no headers are sent, and the
// wrapper adds no io overhead on the hijacked path.
hr := &hijackableRecorder{ResponseRecorder: httptest.NewRecorder()}
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "zstd")
cw := CompressWriter(hr, r)
conn, rw, err := cw.Hijack()
if err != nil || conn != nil || rw != nil {
t.Fatalf("Hijack() = %v, %v, %v", conn, rw, err)
}
// The wrapper's passthrough path must not forward a header-only
// response for a hijacked request.
if hr.Code != 200 {
t.Errorf("WriteHeader forwarded to underlying writer (code %d)", hr.Code)
}
if got := hr.Header().Get("Content-Encoding"); got != "" {
t.Errorf("Content-Encoding = %q, want empty", got)
}
if !hr.hijacked {
t.Error("underlying writer was not hijacked")
}
// Post-hijack writes are rejected, not forwarded.
if n, err := cw.Write([]byte("x")); err == nil || n != 0 {
t.Errorf("post-hijack Write = (%d, %v), want (0, error)", n, err)
}
cw.Close()
}
func TestCompressWriter_HijackAfterCompression(t *testing.T) {
// Hijacking mid-compressed-stream finishes the frame (pooled encoder
// returned) and hands over the connection.
original := strings.Repeat(testBody, 20)
hr := &hijackableRecorder{ResponseRecorder: httptest.NewRecorder()}
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "zstd")
cw := CompressWriter(hr, r)
if _, err := cw.Write([]byte(original)); err != nil {
t.Fatal(err)
}
if got := hr.Header().Get("Content-Encoding"); got != "zstd" {
t.Fatalf("Content-Encoding = %q, want zstd", got)
}
if _, _, err := cw.Hijack(); err != nil {
t.Fatal(err)
}
if got := wBody(hr); !bytes.Equal(got, nil) && len(got) == 0 {
t.Error("compressed body was dropped")
}
decoded, err := zstdframe.AppendDecode(nil, wBody(hr))
if err != nil {
t.Fatalf("decoding streamed body (frame not finished?): %v", err)
}
if string(decoded) != original {
t.Error("decoded body mismatch")
}
// Close after hijack must not double-close the pooled encoder.
cw.Close()
}
// unwrapOnlyWriter is middleware supporting neither Hijack nor Flush,
// only Unwrap, like other middleware that may sit below compressWriter.
type unwrapOnlyWriter struct {
http.ResponseWriter
}
func (u unwrapOnlyWriter) Unwrap() http.ResponseWriter { return u.ResponseWriter }
func TestCompressWriter_HijackThroughUnwrap(t *testing.T) {
// compressWriter -> unwrapOnlyWriter -> hijackableRecorder: the
// hijacker is two layers down. Hijack must walk the Unwrap chain
// (via http.ResponseController) and reach it.
hr := &hijackableRecorder{ResponseRecorder: httptest.NewRecorder()}
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "zstd")
cw := CompressWriter(unwrapOnlyWriter{hr}, r)
if _, _, err := cw.Hijack(); err != nil {
t.Fatalf("Hijack through middleware: %v", err)
}
if !hr.hijacked {
t.Error("deepest writer was not hijacked")
}
cw.Close()
}
func TestCompressWriter_HijackNotSupported(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
cw := CompressWriter(w, r)
if _, _, err := cw.Hijack(); !errors.Is(err, http.ErrNotSupported) {
t.Errorf("Hijack on non-hijackable writer = %v, want http.ErrNotSupported", err)
}
cw.Close()
}
func wBody(h *hijackableRecorder) []byte {
return h.Body.Bytes()
}
func TestCompressWriter_SkipsUpgradesAndUnusualMethods(t *testing.T) {
// Protocol upgrades and non-standard methods get identity responses:
// their bodies are not normal response payloads (tunnels, WebDAV
// extensions, etc.).
want := strings.Repeat(testBody, 20)
for _, tt := range []struct {
name string
method string
upgrade bool
}{
{name: "upgrade", method: "GET", upgrade: true},
{name: "propfind", method: "PROPFIND"},
} {
r := httptest.NewRequest(tt.method, "/", nil)
r.Header.Set("Accept-Encoding", "zstd")
if tt.upgrade {
r.Header.Set("Upgrade", "websocket")
}
w := httptest.NewRecorder()
cw := CompressWriter(w, r)
cw.WriteHeader(200)
cw.Write([]byte(want))
cw.Close()
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Errorf("%s: Content-Encoding = %q, want empty", tt.name, got)
}
if w.Body.String() != want {
t.Errorf("%s: body was modified", tt.name)
}
}
}
func TestCompressWriter_HeadRequestPassthrough(t *testing.T) {
// HEAD responses have no body; they pass through untouched.
r := httptest.NewRequest("HEAD", "/", nil)
r.Header.Set("Accept-Encoding", "zstd")
w := httptest.NewRecorder()
cw := CompressWriter(w, r)
defer cw.Close()
cw.WriteHeader(200)
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Errorf("Content-Encoding = %q, want empty", got)
}
}