featherbit/plugins/native/
gzip.rs1use async_trait::async_trait;
20use std::collections::HashMap;
21
22use crate::context::Context;
23use crate::plugins::util::content_codec::{self, ContentEncoding};
24use crate::plugins::{Plugin, PluginOutput, PluginResult};
25
26pub struct GzipPlugin {
34 types: ContentTypes,
35 min_length: usize,
36 comp_level: u32,
37 vary: bool,
38}
39
40pub(crate) enum ContentTypes {
43 Any,
44 List(Vec<String>),
45}
46
47impl ContentTypes {
48 pub(crate) fn parse(raw: Option<&serde_json::Value>, default: &[&str]) -> Result<Self, String> {
51 match raw {
52 None => Ok(Self::List(default.iter().map(|s| s.to_string()).collect())),
53 Some(serde_json::Value::String(s)) if s == "*" => Ok(Self::Any),
54 Some(serde_json::Value::Array(items)) => {
55 if items.is_empty() {
56 return Err("types must contain at least one content type".to_string());
57 }
58 let list = items
59 .iter()
60 .map(|v| {
61 v.as_str()
62 .filter(|s| !s.is_empty())
63 .map(String::from)
64 .ok_or("types entries must be non-empty strings".to_string())
65 })
66 .collect::<Result<Vec<_>, _>>()?;
67 Ok(Self::List(list))
68 }
69 Some(_) => Err("types must be \"*\" or an array of content types".to_string()),
70 }
71 }
72
73 pub(crate) fn matches(&self, ctx: &Context) -> bool {
77 let Some(content_type) = ctx
78 .response
79 .headers
80 .get("content-type")
81 .and_then(|v| v.first())
82 else {
83 return false;
84 };
85 match self {
86 Self::Any => true,
87 Self::List(list) => {
88 let base = content_type.split(';').next().unwrap_or("").trim();
89 list.iter().any(|t| t == base)
90 }
91 }
92 }
93}
94
95pub(crate) fn accepts_encoding(ctx: &Context, token: &str) -> bool {
99 let Some(values) = ctx.request.headers.get("accept-encoding") else {
100 return false;
101 };
102 for value in values {
103 for part in value.split(',') {
104 let mut pieces = part.trim().splitn(2, ';');
105 let name = pieces.next().unwrap_or("").trim().to_ascii_lowercase();
106 if name != token && name != "*" {
107 continue;
108 }
109 let q_is_zero = pieces
110 .next()
111 .and_then(|params| params.trim().strip_prefix("q="))
112 .map(|q| q.trim().parse::<f32>() == Ok(0.0))
113 .unwrap_or(false);
114 if !q_is_zero {
115 return true;
116 }
117 }
118 }
119 false
120}
121
122pub(crate) fn response_already_encoded(ctx: &Context) -> bool {
127 match ctx
128 .response
129 .headers
130 .get("content-encoding")
131 .and_then(|v| v.first())
132 {
133 None => false,
134 Some(value) => !matches!(ContentEncoding::parse(value), Ok(None)),
135 }
136}
137
138impl GzipPlugin {
139 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
161 let types = ContentTypes::parse(config.get("types"), &["text/html"])?;
162
163 let min_length = match config.get("min_length") {
164 None => 20,
165 Some(v) => {
166 let n = v
167 .as_u64()
168 .filter(|n| *n >= 1)
169 .ok_or("min_length must be an integer >= 1".to_string())?;
170 n as usize
171 }
172 };
173
174 let comp_level = match config.get("comp_level") {
175 None => 1,
176 Some(v) => {
177 let n = v
178 .as_u64()
179 .ok_or("comp_level must be an integer".to_string())?;
180 if !(1..=9).contains(&n) {
181 return Err(format!("comp_level must be within 1-9, got {}", n));
182 }
183 n as u32
184 }
185 };
186
187 let vary = config
188 .get("vary")
189 .and_then(|v| v.as_bool())
190 .unwrap_or(false);
191
192 Ok(Self {
193 types,
194 min_length,
195 comp_level,
196 vary,
197 })
198 }
199}
200
201#[async_trait]
202impl Plugin for GzipPlugin {
203 fn plugin_type(&self) -> &str {
204 "gzip"
205 }
206
207 async fn execute(&self, mut ctx: Context) -> PluginResult {
208 let skip = !accepts_encoding(&ctx, "gzip")
209 || response_already_encoded(&ctx)
210 || !self.types.matches(&ctx)
211 || ctx.response.body.len() < self.min_length;
212
213 if !skip {
214 match content_codec::encode(&ContentEncoding::Gzip, &ctx.response.body, self.comp_level)
215 {
216 Ok(compressed) => {
217 ctx.response.body = compressed;
218 ctx.response.headers.remove("content-length");
221 ctx.response.headers.insert(
222 "content-encoding".to_string(),
223 vec![ContentEncoding::Gzip.header_value().to_string()],
224 );
225 if self.vary {
226 ctx.response
227 .headers
228 .entry("vary".to_string())
229 .or_default()
230 .push("Accept-Encoding".to_string());
231 }
232 }
233 Err(e) => {
234 tracing::warn!("gzip: compression skipped: {}", e);
235 }
236 }
237 }
238
239 Ok(PluginOutput::success(ctx))
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
247 use bytes::Bytes;
248
249 pub(crate) fn compressible_context(body: &[u8], content_type: &str) -> Context {
250 let mut request_headers = HashMap::new();
251 request_headers.insert(
252 "accept-encoding".to_string(),
253 vec!["gzip, deflate, br".to_string()],
254 );
255 let mut response_headers = HashMap::new();
256 response_headers.insert("content-type".to_string(), vec![content_type.to_string()]);
257 response_headers.insert("content-length".to_string(), vec![body.len().to_string()]);
258 Context {
259 request: GatewayRequest {
260 method: "GET".to_string(),
261 path: "/test".to_string(),
262 host: "localhost".to_string(),
263 scheme: "http".to_string(),
264 headers: request_headers,
265 query_params: HashMap::new(),
266 body: Bytes::new(),
267 remote_addr: "127.0.0.1:12345".to_string(),
268 protocol: Protocol::Http1,
269 },
270 response: GatewayResponse {
271 status_code: 200,
272 headers: response_headers,
273 body: Bytes::copy_from_slice(body),
274 stream: None,
275 },
276 message: HashMap::new(),
277 errors: Vec::new(),
278 }
279 }
280
281 const BODY: &[u8] = b"<html>a body long enough to clear the default min_length</html>";
282
283 fn plugin(config: serde_json::Value) -> GzipPlugin {
284 let map: HashMap<String, serde_json::Value> =
285 serde_json::from_value(config).expect("test config must be an object");
286 GzipPlugin::from_config(&map).expect("config should be valid")
287 }
288
289 #[tokio::test]
290 async fn test_gzip_compresses_matching_response() {
291 let p = plugin(serde_json::json!({ "vary": true }));
292 let ctx = compressible_context(BODY, "text/html; charset=utf-8");
293 let out = p.execute(ctx).await.unwrap();
294
295 let headers = &out.context.response.headers;
296 assert_eq!(
297 headers.get("content-encoding"),
298 Some(&vec!["gzip".to_string()])
299 );
300 assert!(!headers.contains_key("content-length"));
301 assert_eq!(
302 headers.get("vary"),
303 Some(&vec!["Accept-Encoding".to_string()])
304 );
305
306 let decoded =
307 content_codec::decode(&ContentEncoding::Gzip, &out.context.response.body).unwrap();
308 assert_eq!(decoded.as_ref(), BODY);
309 }
310
311 #[tokio::test]
312 async fn test_gzip_skips_when_client_does_not_accept() {
313 let p = plugin(serde_json::json!({}));
314 let mut ctx = compressible_context(BODY, "text/html");
315 ctx.request.headers.remove("accept-encoding");
316 let out = p.execute(ctx).await.unwrap();
317 assert_eq!(out.context.response.body.as_ref(), BODY);
318 assert!(!out
319 .context
320 .response
321 .headers
322 .contains_key("content-encoding"));
323
324 let p = plugin(serde_json::json!({}));
326 let mut ctx = compressible_context(BODY, "text/html");
327 ctx.request.headers.insert(
328 "accept-encoding".to_string(),
329 vec!["gzip;q=0, br".to_string()],
330 );
331 let out = p.execute(ctx).await.unwrap();
332 assert!(!out
333 .context
334 .response
335 .headers
336 .contains_key("content-encoding"));
337 }
338
339 #[tokio::test]
340 async fn test_gzip_skips_already_encoded_response() {
341 let plain = Bytes::copy_from_slice(BODY);
342 let pre_encoded = content_codec::encode(&ContentEncoding::Brotli, &plain, 6).unwrap();
343
344 let p = plugin(serde_json::json!({}));
345 let mut ctx = compressible_context(&pre_encoded, "text/html");
346 ctx.response
347 .headers
348 .insert("content-encoding".to_string(), vec!["br".to_string()]);
349 let out = p.execute(ctx).await.unwrap();
350
351 assert_eq!(
353 out.context.response.headers.get("content-encoding"),
354 Some(&vec!["br".to_string()])
355 );
356 assert_eq!(out.context.response.body, pre_encoded);
357 assert!(out.context.response.headers.contains_key("content-length"));
358 }
359
360 #[tokio::test]
361 async fn test_gzip_type_and_length_gates() {
362 let p = plugin(serde_json::json!({}));
364 let ctx = compressible_context(BODY, "application/json");
365 let out = p.execute(ctx).await.unwrap();
366 assert!(!out
367 .context
368 .response
369 .headers
370 .contains_key("content-encoding"));
371
372 let p = plugin(serde_json::json!({ "types": "*" }));
374 let mut ctx = compressible_context(BODY, "text/html");
375 ctx.response.headers.remove("content-type");
376 let out = p.execute(ctx).await.unwrap();
377 assert!(!out
378 .context
379 .response
380 .headers
381 .contains_key("content-encoding"));
382
383 let p = plugin(serde_json::json!({ "types": "*" }));
385 let ctx = compressible_context(BODY, "application/octet-stream");
386 let out = p.execute(ctx).await.unwrap();
387 assert!(out
388 .context
389 .response
390 .headers
391 .contains_key("content-encoding"));
392
393 let p = plugin(serde_json::json!({ "min_length": 1000 }));
395 let ctx = compressible_context(BODY, "text/html");
396 let out = p.execute(ctx).await.unwrap();
397 assert!(!out
398 .context
399 .response
400 .headers
401 .contains_key("content-encoding"));
402 }
403
404 #[test]
405 fn test_gzip_config_validation() {
406 for bad in [
407 serde_json::json!({ "comp_level": 0 }),
408 serde_json::json!({ "comp_level": 10 }),
409 serde_json::json!({ "min_length": 0 }),
410 serde_json::json!({ "types": [] }),
411 serde_json::json!({ "types": [""] }),
412 serde_json::json!({ "types": "text/html" }),
413 ] {
414 let map: HashMap<String, serde_json::Value> = serde_json::from_value(bad).unwrap();
415 assert!(GzipPlugin::from_config(&map).is_err());
416 }
417 }
418
419 #[test]
420 fn test_accepts_encoding_parsing() {
421 let mut ctx = compressible_context(BODY, "text/html");
422 let cases = [
423 ("gzip", true),
424 ("*", true),
425 ("br;q=0.8, gzip;q=0.5", true),
426 ("GZIP", true),
427 ("gzip;q=0", false),
428 ("br", false),
429 ("", false),
430 ("*;q=0", false),
431 ];
432 for (header, expected) in cases {
433 ctx.request
434 .headers
435 .insert("accept-encoding".to_string(), vec![header.to_string()]);
436 assert_eq!(
437 accepts_encoding(&ctx, "gzip"),
438 expected,
439 "header {header:?}"
440 );
441 }
442 }
443}