Skip to main content

featherbit/plugins/util/
content_codec.rs

1//! Response body encode/decode helpers shared by body-mutating plugins
2//! (response-rewrite, gzip/brotli compression, loggers that include response
3//! bodies).
4//!
5//! Mirrors APISIX's `content-decode.lua` semantics: upstream bodies are
6//! decoded before text filters run, then optionally re-encoded on the way
7//! out. HTTP `deflate` is the zlib-wrapped format (RFC 1950), not raw
8//! DEFLATE — this module follows that convention.
9//!
10//! # Body-mutation convention for plugin authors
11//!
12//! After replacing a response body you MUST:
13//! 1. remove the `content-length` header — the server layer recomputes it
14//!    from the final body; and
15//! 2. remove `content-encoding` (if you left the body decoded) or set it to
16//!    [`ContentEncoding::header_value`] of the codec you re-encoded with.
17//!
18//! Getting either wrong yields truncated or garbled responses at the client:
19//! a stale `content-length` truncates or over-reads the stream, and a stale
20//! `content-encoding` makes the client "decompress" plain bytes.
21
22use std::io::{Read, Write};
23
24use bytes::Bytes;
25use flate2::read::{GzDecoder, ZlibDecoder};
26use flate2::write::{GzEncoder, ZlibEncoder};
27use flate2::Compression;
28
29/// Buffer size used for brotli's streaming reader/writer, matching the 4 KiB
30/// chunks flate2 uses internally.
31const BROTLI_BUFFER_SIZE: usize = 4096;
32
33/// Brotli window size (log2). 22 is the codec's common default and what
34/// nginx's brotli module ships with.
35const BROTLI_LGWIN: u32 = 22;
36
37/// Content-Encoding values the gateway can decode/encode.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ContentEncoding {
40    Gzip,
41    /// HTTP "deflate": zlib-wrapped DEFLATE (RFC 1950).
42    Deflate,
43    Brotli,
44}
45
46impl ContentEncoding {
47    /// Parses a `Content-Encoding` header value ("gzip", "deflate", "br"),
48    /// case-insensitively and ignoring surrounding whitespace.
49    ///
50    /// `Ok(None)` means "identity" or an empty value — the body is not
51    /// encoded and there is nothing to do. `Err` carries the unsupported
52    /// encoding name; the caller decides whether to skip the body untouched
53    /// or fail the request.
54    pub fn parse(value: &str) -> Result<Option<Self>, String> {
55        let normalized = value.trim().to_ascii_lowercase();
56        match normalized.as_str() {
57            "" | "identity" => Ok(None),
58            "gzip" => Ok(Some(Self::Gzip)),
59            "deflate" => Ok(Some(Self::Deflate)),
60            "br" => Ok(Some(Self::Brotli)),
61            other => Err(format!("unsupported content-encoding: {}", other)),
62        }
63    }
64
65    /// The canonical header value ("gzip", "deflate", "br") — what plugins
66    /// should write into `content-encoding` after re-encoding a body.
67    pub fn header_value(&self) -> &'static str {
68        match self {
69            Self::Gzip => "gzip",
70            Self::Deflate => "deflate",
71            Self::Brotli => "br",
72        }
73    }
74}
75
76/// Decodes a body compressed with `encoding`. Errors on corrupt or
77/// truncated data, with a message naming the codec.
78pub fn decode(encoding: &ContentEncoding, body: &Bytes) -> Result<Bytes, String> {
79    let mut out = Vec::new();
80    match encoding {
81        ContentEncoding::Gzip => {
82            GzDecoder::new(body.as_ref())
83                .read_to_end(&mut out)
84                .map_err(|e| format!("gzip decode error: {}", e))?;
85        }
86        ContentEncoding::Deflate => {
87            ZlibDecoder::new(body.as_ref())
88                .read_to_end(&mut out)
89                .map_err(|e| format!("deflate decode error: {}", e))?;
90        }
91        ContentEncoding::Brotli => {
92            brotli::Decompressor::new(body.as_ref(), BROTLI_BUFFER_SIZE)
93                .read_to_end(&mut out)
94                .map_err(|e| format!("brotli decode error: {}", e))?;
95        }
96    }
97    Ok(Bytes::from(out))
98}
99
100/// Encodes a body with `encoding`. `level` is clamped to the codec's valid
101/// range (gzip/deflate 0-9, brotli 0-11), so out-of-range config values
102/// degrade to maximum compression instead of failing.
103pub fn encode(encoding: &ContentEncoding, body: &Bytes, level: u32) -> Result<Bytes, String> {
104    match encoding {
105        ContentEncoding::Gzip => {
106            let mut encoder = GzEncoder::new(Vec::new(), Compression::new(level.min(9)));
107            encoder
108                .write_all(body)
109                .and_then(|_| encoder.finish())
110                .map(Bytes::from)
111                .map_err(|e| format!("gzip encode error: {}", e))
112        }
113        ContentEncoding::Deflate => {
114            let mut encoder = ZlibEncoder::new(Vec::new(), Compression::new(level.min(9)));
115            encoder
116                .write_all(body)
117                .and_then(|_| encoder.finish())
118                .map(Bytes::from)
119                .map_err(|e| format!("deflate encode error: {}", e))
120        }
121        ContentEncoding::Brotli => {
122            let mut out = Vec::new();
123            {
124                let mut writer = brotli::CompressorWriter::new(
125                    &mut out,
126                    BROTLI_BUFFER_SIZE,
127                    level.min(11),
128                    BROTLI_LGWIN,
129                );
130                writer
131                    .write_all(body)
132                    .and_then(|_| writer.flush())
133                    .map_err(|e| format!("brotli encode error: {}", e))?;
134                // Dropping the writer finalizes the brotli stream into `out`.
135            }
136            Ok(Bytes::from(out))
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    const SAMPLE: &[u8] = b"the quick brown fox jumps over the lazy dog, \
146                            repeated so compression has something to chew on: \
147                            the quick brown fox jumps over the lazy dog";
148
149    #[test]
150    fn test_content_codec_round_trip_gzip() {
151        let body = Bytes::from_static(SAMPLE);
152        let encoded = encode(&ContentEncoding::Gzip, &body, 6).unwrap();
153        assert_ne!(encoded, body);
154        let decoded = decode(&ContentEncoding::Gzip, &encoded).unwrap();
155        assert_eq!(decoded, body);
156    }
157
158    #[test]
159    fn test_content_codec_round_trip_deflate() {
160        let body = Bytes::from_static(SAMPLE);
161        let encoded = encode(&ContentEncoding::Deflate, &body, 6).unwrap();
162        assert_ne!(encoded, body);
163        let decoded = decode(&ContentEncoding::Deflate, &encoded).unwrap();
164        assert_eq!(decoded, body);
165    }
166
167    #[test]
168    fn test_content_codec_round_trip_brotli() {
169        let body = Bytes::from_static(SAMPLE);
170        let encoded = encode(&ContentEncoding::Brotli, &body, 6).unwrap();
171        assert_ne!(encoded, body);
172        let decoded = decode(&ContentEncoding::Brotli, &encoded).unwrap();
173        assert_eq!(decoded, body);
174    }
175
176    #[test]
177    fn test_content_codec_round_trip_empty_body() {
178        for encoding in [
179            ContentEncoding::Gzip,
180            ContentEncoding::Deflate,
181            ContentEncoding::Brotli,
182        ] {
183            let encoded = encode(&encoding, &Bytes::new(), 6).unwrap();
184            let decoded = decode(&encoding, &encoded).unwrap();
185            assert!(decoded.is_empty(), "{:?} empty round trip", encoding);
186        }
187    }
188
189    #[test]
190    fn test_content_codec_decode_corrupt_errors() {
191        let garbage = Bytes::from_static(b"\x00\x01definitely not a compressed stream\xff\xfe");
192        for encoding in [
193            ContentEncoding::Gzip,
194            ContentEncoding::Deflate,
195            ContentEncoding::Brotli,
196        ] {
197            let result = decode(&encoding, &garbage);
198            assert!(result.is_err(), "{:?} should reject corrupt data", encoding);
199        }
200    }
201
202    #[test]
203    fn test_content_codec_decode_truncated_gzip_errors() {
204        let body = Bytes::from_static(SAMPLE);
205        let encoded = encode(&ContentEncoding::Gzip, &body, 6).unwrap();
206        let truncated = encoded.slice(..encoded.len() / 2);
207        assert!(decode(&ContentEncoding::Gzip, &truncated).is_err());
208    }
209
210    #[test]
211    fn test_content_codec_parse() {
212        assert_eq!(
213            ContentEncoding::parse("gzip"),
214            Ok(Some(ContentEncoding::Gzip))
215        );
216        assert_eq!(
217            ContentEncoding::parse("GZIP"),
218            Ok(Some(ContentEncoding::Gzip))
219        );
220        assert_eq!(
221            ContentEncoding::parse(" gzip "),
222            Ok(Some(ContentEncoding::Gzip))
223        );
224        assert_eq!(
225            ContentEncoding::parse("br"),
226            Ok(Some(ContentEncoding::Brotli))
227        );
228        assert_eq!(
229            ContentEncoding::parse("deflate"),
230            Ok(Some(ContentEncoding::Deflate))
231        );
232        assert_eq!(ContentEncoding::parse("identity"), Ok(None));
233        assert_eq!(ContentEncoding::parse("Identity"), Ok(None));
234        assert_eq!(ContentEncoding::parse(""), Ok(None));
235        assert_eq!(ContentEncoding::parse("   "), Ok(None));
236        assert!(ContentEncoding::parse("x-unknown").is_err());
237        // Multi-codec values are unsupported, not silently misparsed.
238        assert!(ContentEncoding::parse("gzip, br").is_err());
239    }
240
241    #[test]
242    fn test_content_codec_header_value() {
243        assert_eq!(ContentEncoding::Gzip.header_value(), "gzip");
244        assert_eq!(ContentEncoding::Deflate.header_value(), "deflate");
245        assert_eq!(ContentEncoding::Brotli.header_value(), "br");
246    }
247
248    #[test]
249    fn test_content_codec_level_clamped() {
250        let body = Bytes::from_static(SAMPLE);
251        for encoding in [
252            ContentEncoding::Gzip,
253            ContentEncoding::Deflate,
254            ContentEncoding::Brotli,
255        ] {
256            // Out-of-range level must clamp, not panic or error.
257            let encoded = encode(&encoding, &body, 99).unwrap();
258            let decoded = decode(&encoding, &encoded).unwrap();
259            assert_eq!(decoded, body, "{:?} with clamped level", encoding);
260        }
261    }
262}