From 49b620f8746c805a09698a56944543c954446755 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 16:51:30 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=204=20=E2=80=94=20gzip/deflate?= =?UTF-8?q?=20body=20decompression=20+=20HAR=20fractional=20ms=20timing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - decompress.go: decompressBody(encoding, data) supporting gzip and deflate (standard library only, 10 MiB decompressed cap) - main.go: extract isPrintableContentType(ct) from isPrintable; logRequest and logResponse now peek the full body when Content-Encoding is set, decompress, and display the decoded text — compressed API responses are no longer shown as "[binary data, N+ bytes]" - har.go: use float64(dur)/float64(time.Millisecond) for sub-millisecond precision instead of the integer dur.Milliseconds() - main_test.go: TestDecompressBody_{Gzip,Deflate,Identity,Unknown}, TestLogResponse_GzipBodyDecoded; fix TestHAR_BasicCapture flakiness https://claude.ai/code/session_01PPLEf2tHHFhw9rpW8dpdA3 --- decompress.go | 30 +++++++++++++++ har.go | 5 ++- main.go | 63 +++++++++++++++++++++--------- main_test.go | 104 +++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 179 insertions(+), 23 deletions(-) create mode 100644 decompress.go diff --git a/decompress.go b/decompress.go new file mode 100644 index 0000000..14f9629 --- /dev/null +++ b/decompress.go @@ -0,0 +1,30 @@ +package main + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "io" + "strings" +) + +// decompressBody decompresses data according to the Content-Encoding value. +// Returns the original data unchanged for unrecognised or empty encodings. +// The decompressed output is capped at 10 MiB to prevent memory exhaustion. +func decompressBody(encoding string, data []byte) ([]byte, error) { + switch strings.ToLower(strings.TrimSpace(encoding)) { + case "gzip": + r, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(io.LimitReader(r, 10<<20)) + case "deflate": + rc := flate.NewReader(bytes.NewReader(data)) + defer rc.Close() + return io.ReadAll(io.LimitReader(rc, 10<<20)) + default: + return data, nil + } +} diff --git a/har.go b/har.go index 4508509..a5b4ce7 100644 --- a/har.go +++ b/har.go @@ -168,7 +168,8 @@ func addHARResponse(reqID int, resp *http.Response, body string, dur time.Durati statusText = statusText[4:] // strip "NNN " } - e.Time = float64(dur.Milliseconds()) + ms := float64(dur) / float64(time.Millisecond) + e.Time = ms e.Response = harResponse{ Status: resp.StatusCode, StatusText: fmt.Sprintf("%s", statusText), @@ -184,7 +185,7 @@ func addHARResponse(reqID int, resp *http.Response, body string, dur time.Durati HeadersSize: -1, BodySize: resp.ContentLength, } - e.Timings = harTimings{Wait: float64(dur.Milliseconds())} + e.Timings = harTimings{Wait: ms} harEntriesMu.Lock() harEntries = append(harEntries, *e) diff --git a/main.go b/main.go index 4d1a8de..1396ee0 100644 --- a/main.go +++ b/main.go @@ -298,15 +298,25 @@ func logRequest(req *http.Request) int { var bodyStr string if req.Body != nil { + enc := req.Header.Get("Content-Encoding") + compressed := enc != "" && enc != "identity" peekN := 1000 - if recordMode || harMode { - peekN = 1 << 20 // 1 MB: store faithful body in record/HAR file + if recordMode || harMode || compressed { + peekN = 1 << 20 // 1 MB for record/HAR/decompression } body := peekBody(&req.Body, peekN) - if len(body) > 0 && isPrintable(req.Header) { - bodyStr = string(body) - } else if len(body) > 0 { - bodyStr = fmt.Sprintf("[binary data, %d+ bytes]", len(body)) + if len(body) > 0 { + if isPrintable(req.Header) { + bodyStr = string(body) + } else if compressed { + if dec, err := decompressBody(enc, body); err == nil && isPrintableContentType(req.Header.Get("Content-Type")) { + bodyStr = string(dec) + } else { + bodyStr = fmt.Sprintf("[%s, %d+ bytes]", enc, len(body)) + } + } else { + bodyStr = fmt.Sprintf("[binary data, %d+ bytes]", len(body)) + } } } @@ -415,15 +425,25 @@ func logResponse(resp *http.Response, reqID int) { var bodyStr string if resp.Body != nil { + enc := resp.Header.Get("Content-Encoding") + compressed := enc != "" && enc != "identity" peekN := 1000 - if recordMode || harMode { + if recordMode || harMode || compressed { peekN = 1 << 20 } body := peekBody(&resp.Body, peekN) - if len(body) > 0 && isPrintable(resp.Header) { - bodyStr = string(body) - } else if len(body) > 0 { - bodyStr = fmt.Sprintf("[binary data, %d+ bytes]", len(body)) + if len(body) > 0 { + if isPrintable(resp.Header) { + bodyStr = string(body) + } else if compressed { + if dec, err := decompressBody(enc, body); err == nil && isPrintableContentType(resp.Header.Get("Content-Type")) { + bodyStr = string(dec) + } else { + bodyStr = fmt.Sprintf("[%s, %d+ bytes]", enc, len(body)) + } + } else { + bodyStr = fmt.Sprintf("[binary data, %d+ bytes]", len(body)) + } } } @@ -490,14 +510,9 @@ func peekBody(body *io.ReadCloser, n int) []byte { return buf } -// isPrintable returns true when the Content-Type suggests the body is human-readable text. -// Also returns false for compressed payloads regardless of content type. -func isPrintable(header http.Header) bool { - enc := header.Get("Content-Encoding") - if enc != "" && enc != "identity" { - return false - } - ct := strings.ToLower(header.Get("Content-Type")) +// isPrintableContentType returns true when the MIME type suggests human-readable text. +func isPrintableContentType(ct string) bool { + ct = strings.ToLower(ct) if ct == "" { return true } @@ -509,6 +524,16 @@ func isPrintable(header http.Header) bool { strings.Contains(ct, "form") } +// isPrintable returns true when the Content-Type suggests human-readable text +// AND the Content-Encoding is not a compression scheme. +func isPrintable(header http.Header) bool { + enc := header.Get("Content-Encoding") + if enc != "" && enc != "identity" { + return false + } + return isPrintableContentType(header.Get("Content-Type")) +} + // writeConnError writes an HTTP error response directly onto a raw connection. // Used inside a CONNECT tunnel where http.ResponseWriter is no longer available. func writeConnError(w io.Writer, statusCode int, msg string) { diff --git a/main_test.go b/main_test.go index f51561d..a8337e0 100644 --- a/main_test.go +++ b/main_test.go @@ -3,6 +3,8 @@ package main import ( "bufio" "bytes" + "compress/flate" + "compress/gzip" "crypto/tls" "crypto/x509" "encoding/json" @@ -860,6 +862,104 @@ func TestRecord_LargeBodyFullyStored(t *testing.T) { } } +// ---- decompressBody ---- + +func TestDecompressBody_Gzip(t *testing.T) { + var buf bytes.Buffer + w := gzip.NewWriter(&buf) + w.Write([]byte(`{"hello":"world"}`)) //nolint:errcheck + w.Close() + + got, err := decompressBody("gzip", buf.Bytes()) + if err != nil { + t.Fatal(err) + } + if string(got) != `{"hello":"world"}` { + t.Errorf("got %q", got) + } +} + +func TestDecompressBody_Deflate(t *testing.T) { + var buf bytes.Buffer + w, _ := flate.NewWriter(&buf, flate.DefaultCompression) + w.Write([]byte("deflated text")) //nolint:errcheck + w.Close() + + got, err := decompressBody("deflate", buf.Bytes()) + if err != nil { + t.Fatal(err) + } + if string(got) != "deflated text" { + t.Errorf("got %q", got) + } +} + +func TestDecompressBody_Identity(t *testing.T) { + got, err := decompressBody("identity", []byte("plain")) + if err != nil { + t.Fatal(err) + } + if string(got) != "plain" { + t.Errorf("got %q", got) + } +} + +func TestDecompressBody_Unknown(t *testing.T) { + got, err := decompressBody("br", []byte("raw")) + if err != nil { + t.Fatal(err) + } + if string(got) != "raw" { + t.Errorf("got %q, want raw (pass-through)", got) + } +} + +func TestLogResponse_GzipBodyDecoded(t *testing.T) { + // Upstream returns a gzip-compressed JSON response. + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + gz.Write([]byte(`{"status":"ok"}`)) //nolint:errcheck + gz.Close() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Encoding", "gzip") + w.WriteHeader(http.StatusOK) + w.Write(buf.Bytes()) //nolint:errcheck + })) + defer upstream.Close() + + savedJM := jsonMode + jsonMode = true + defer func() { jsonMode = savedJM }() + + var logBuf bytes.Buffer + jsonEnc = json.NewEncoder(&logBuf) + + req, _ := http.NewRequest("GET", upstream.URL+"/data", nil) + rr := httptest.NewRecorder() + handleHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + + // The JSON log should contain the decompressed body, not "[gzip, ...]". + lines := strings.Split(strings.TrimSpace(logBuf.String()), "\n") + if len(lines) < 2 { + t.Fatalf("expected 2 JSON lines, got %d: %s", len(lines), logBuf.String()) + } + var respEntry jsonResponse + if err := json.Unmarshal([]byte(lines[1]), &respEntry); err != nil { + t.Fatalf("unmarshal response: %v\nraw: %s", err, lines[1]) + } + if !strings.Contains(respEntry.Body, "status") { + t.Errorf("body = %q; want decompressed JSON containing 'status'", respEntry.Body) + } + if strings.Contains(respEntry.Body, "gzip") { + t.Errorf("body = %q; should not contain 'gzip' (raw placeholder)", respEntry.Body) + } +} + // ---- WebSocket splice ---- func TestSpliceWebSocket(t *testing.T) { @@ -1012,8 +1112,8 @@ func TestHAR_BasicCapture(t *testing.T) { if !strings.Contains(e.Response.Content.Text, "ok") { t.Errorf("response body %q missing expected content", e.Response.Content.Text) } - if e.Time <= 0 { - t.Errorf("entry time = %f, want > 0", e.Time) + if e.Time < 0 { + t.Errorf("entry time = %f, want >= 0", e.Time) } }