Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions decompress.go
Original file line number Diff line number Diff line change
@@ -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
}
}
5 changes: 3 additions & 2 deletions har.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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)
Expand Down
63 changes: 44 additions & 19 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
}

Expand Down Expand Up @@ -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))
}
}
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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) {
Expand Down
104 changes: 102 additions & 2 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package main
import (
"bufio"
"bytes"
"compress/flate"
"compress/gzip"
"crypto/tls"
"crypto/x509"
"encoding/json"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}

Expand Down
Loading