summaryrefslogtreecommitdiff
path: root/internal/http/compression.go
blob: aba8ef1e67c3004f845dd458d644e18b25ce3b6a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
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
}