summaryrefslogtreecommitdiff
path: root/internal/http/compression.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/http/compression.go')
-rw-r--r--internal/http/compression.go142
1 files changed, 142 insertions, 0 deletions
diff --git a/internal/http/compression.go b/internal/http/compression.go
new file mode 100644
index 0000000..aba8ef1
--- /dev/null
+++ b/internal/http/compression.go
@@ -0,0 +1,142 @@
+package http
+
+import (
+ "bytes"
+ "compress/flate"
+ "compress/gzip"
+ "compress/zlib"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/andybalholm/brotli"
+ "github.com/klauspost/compress/zstd"
+)
+
+const MaxDisplayBytes = 64 * 1024 // 64 KB
+
+// Decompress decompresses body according to the Content-Encoding header.
+// Returns the original body unchanged if encoding is unknown or decompression fails.
+//
+// Deflate note: servers claiming "deflate" Content-Encoding almost universally
+// send zlib-wrapped data (RFC 1950), not raw DEFLATE (RFC 1951). We try zlib
+// first and fall back to raw deflate so both are handled correctly.
+func Decompress(header http.Header, body []byte) []byte {
+ encoding := strings.ToLower(strings.TrimSpace(header.Get("Content-Encoding")))
+
+ switch encoding {
+ case "gzip":
+ gr, err := gzip.NewReader(bytes.NewReader(body))
+ if err != nil {
+ return body
+ }
+ defer gr.Close()
+ out, err := io.ReadAll(gr)
+ if err != nil {
+ return body
+ }
+ return out
+
+ case "deflate":
+ // Try zlib (RFC 1950) first — the de-facto standard despite the name.
+ if out, err := decompressZlib(body); err == nil {
+ return out
+ }
+ // Fall back to raw DEFLATE (RFC 1951).
+ out, err := io.ReadAll(flate.NewReader(bytes.NewReader(body)))
+ if err != nil {
+ return body
+ }
+ return out
+
+ case "br":
+ out, err := io.ReadAll(brotli.NewReader(bytes.NewReader(body)))
+ if err != nil {
+ return body
+ }
+ return out
+
+ case "zstd":
+ dec, err := zstd.NewReader(bytes.NewReader(body))
+ if err != nil {
+ return body
+ }
+ defer dec.Close()
+ out, err := io.ReadAll(dec)
+ if err != nil {
+ return body
+ }
+ return out
+
+ case "", "identity":
+ // No encoding or explicit identity — return as-is.
+ return body
+ }
+
+ // Unknown encoding — return original bytes unchanged.
+ return body
+}
+
+// decompressZlib attempts to decompress body as zlib (RFC 1950).
+func decompressZlib(body []byte) ([]byte, error) {
+ r, err := zlib.NewReader(bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ defer r.Close()
+ return io.ReadAll(r)
+}
+
+// IsBinary returns true if the response body should not be stored or displayed as text.
+//
+// If Content-Encoding is a known compression we support, the body will be
+// decompressed to text before this is called, so those are not binary.
+// Unknown or future Content-Encoding values are treated as binary since we
+// cannot safely decode them.
+func IsBinary(header http.Header) bool {
+ encoding := strings.ToLower(strings.TrimSpace(header.Get("Content-Encoding")))
+ switch encoding {
+ case "", "identity", "gzip", "deflate", "br", "zstd":
+ // Known encodings we can decompress — fall through to Content-Type check.
+ default:
+ // Unknown encoding — we can't decode it, treat as binary.
+ return true
+ }
+
+ ct := strings.ToLower(header.Get("Content-Type"))
+ if ct == "" {
+ // No Content-Type — assume text so we don't silently discard bodies.
+ return false
+ }
+
+ textPrefixes := []string{
+ "text/",
+ "application/json",
+ "application/xml",
+ "application/javascript",
+ "application/x-www-form-urlencoded",
+ "application/xhtml",
+ "application/ld+json",
+ "application/graphql",
+ "application/x-yaml",
+ "application/yaml",
+ }
+ for _, prefix := range textPrefixes {
+ if strings.Contains(ct, prefix) {
+ return false
+ }
+ }
+ return true
+}
+
+// TruncateBody returns body truncated to MaxDisplayBytes with a notice appended.
+func TruncateBody(body []byte) []byte {
+ if len(body) <= MaxDisplayBytes {
+ return body
+ }
+ notice := []byte("\n... truncated")
+ out := make([]byte, MaxDisplayBytes+len(notice))
+ copy(out, body[:MaxDisplayBytes])
+ copy(out[MaxDisplayBytes:], notice)
+ return out
+}