sentinel_proxy/static_files/
compression.rs

1//! Content compression for static file serving
2//!
3//! This module provides on-the-fly compression for static files,
4//! supporting both gzip and brotli encoding.
5
6use anyhow::Result;
7use bytes::Bytes;
8use flate2::write::GzEncoder;
9use flate2::Compression;
10use http::{header, Request};
11use std::io::Write;
12
13// ============================================================================
14// Content Encoding
15// ============================================================================
16
17/// Content encoding preference
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum ContentEncoding {
20    Identity,
21    Gzip,
22    Brotli,
23}
24
25impl ContentEncoding {
26    /// Get the HTTP header value for this encoding
27    pub fn as_str(&self) -> &'static str {
28        match self {
29            ContentEncoding::Identity => "identity",
30            ContentEncoding::Gzip => "gzip",
31            ContentEncoding::Brotli => "br",
32        }
33    }
34}
35
36// ============================================================================
37// Compression Functions
38// ============================================================================
39
40/// Check if content type should be compressed
41pub fn should_compress(content_type: &str) -> bool {
42    content_type.starts_with("text/")
43        || content_type.contains("javascript")
44        || content_type.contains("json")
45        || content_type.contains("xml")
46        || content_type.contains("svg")
47        || content_type == "application/wasm"
48}
49
50/// Negotiate content encoding based on Accept-Encoding header
51pub fn negotiate_encoding<B>(req: &Request<B>) -> ContentEncoding {
52    if let Some(accept_encoding) = req.headers().get(header::ACCEPT_ENCODING) {
53        if let Ok(accept_str) = accept_encoding.to_str() {
54            // Check for brotli first (better compression)
55            if accept_str.contains("br") {
56                return ContentEncoding::Brotli;
57            }
58            // Fall back to gzip
59            if accept_str.contains("gzip") {
60                return ContentEncoding::Gzip;
61            }
62        }
63    }
64    ContentEncoding::Identity
65}
66
67/// Compress content using the specified encoding
68pub fn compress_content(content: &Bytes, encoding: ContentEncoding) -> Result<Bytes> {
69    match encoding {
70        ContentEncoding::Gzip => {
71            let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
72            encoder.write_all(content)?;
73            let compressed = encoder.finish()?;
74            Ok(Bytes::from(compressed))
75        }
76        ContentEncoding::Brotli => {
77            let mut compressed = Vec::new();
78            {
79                let mut encoder = brotli::CompressorWriter::new(&mut compressed, 4096, 4, 22);
80                encoder.write_all(content)?;
81            }
82            Ok(Bytes::from(compressed))
83        }
84        ContentEncoding::Identity => Ok(content.clone()),
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn test_should_compress() {
94        assert!(should_compress("text/html"));
95        assert!(should_compress("text/css"));
96        assert!(should_compress("application/javascript"));
97        assert!(should_compress("application/json"));
98        assert!(should_compress("image/svg+xml"));
99        assert!(should_compress("application/wasm"));
100
101        assert!(!should_compress("image/png"));
102        assert!(!should_compress("image/jpeg"));
103        assert!(!should_compress("application/octet-stream"));
104    }
105
106    #[test]
107    fn test_content_encoding_as_str() {
108        assert_eq!(ContentEncoding::Identity.as_str(), "identity");
109        assert_eq!(ContentEncoding::Gzip.as_str(), "gzip");
110        assert_eq!(ContentEncoding::Brotli.as_str(), "br");
111    }
112
113    #[test]
114    fn test_compress_content_gzip() {
115        let content = Bytes::from("Hello, World!");
116        let compressed = compress_content(&content, ContentEncoding::Gzip).unwrap();
117
118        // Compressed content should be different (though might be larger for small inputs)
119        assert!(!compressed.is_empty());
120    }
121
122    #[test]
123    fn test_compress_content_identity() {
124        let content = Bytes::from("Hello, World!");
125        let result = compress_content(&content, ContentEncoding::Identity).unwrap();
126        assert_eq!(result, content);
127    }
128}